From 3e7371908fc6f5275ea47c997aa9feb7ba9f0074 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 6 Feb 2026 16:10:32 +0000 Subject: [PATCH 01/10] feat(install): prefer ~/.local/bin and record installation method Install script changes: - Prefer ~/.local/bin or ~/bin when available and in $PATH - Fall back to ~/.sentry/bin only when needed (adds to PATH) - Support SENTRY_INSTALL_DIR env var for custom locations - Call 'sentry cli record-install' after installation New 'sentry cli record-install' command: - Records method, path, version in SQLite metadata table - Called automatically by install script - Enables future upgrades to use correct method/path Detection improvements: - Check stored install info first (fast path) - Legacy detection for existing installs (auto-saves for future) - Expanded known curl paths: ~/.local/bin, ~/bin, ~/.sentry/bin Upgrade command: - Persists --method flag for future upgrades - Uses stored path for curl upgrades instead of hardcoded path --- docs/public/install | 190 +---------------------------- install | 147 +++++++++++++++++++++- src/commands/cli/index.ts | 2 + src/commands/cli/record-install.ts | 64 ++++++++++ src/commands/cli/upgrade.ts | 27 +++- src/lib/db/install-info.ts | 96 +++++++++++++++ src/lib/upgrade.ts | 116 +++++++++++++++--- test/commands/cli.test.ts | 85 +++++++++++++ test/lib/db/install-info.test.ts | 162 ++++++++++++++++++++++++ 9 files changed, 678 insertions(+), 211 deletions(-) mode change 100755 => 120000 docs/public/install create mode 100644 src/commands/cli/record-install.ts create mode 100644 src/lib/db/install-info.ts create mode 100644 test/lib/db/install-info.test.ts diff --git a/docs/public/install b/docs/public/install deleted file mode 100755 index 0e86a932..00000000 --- a/docs/public/install +++ /dev/null @@ -1,189 +0,0 @@ -#!/bin/bash -set -euo pipefail - -RED='\033[0;31m' -MUTED='\033[0;2m' -NC='\033[0m' - -usage() { - cat < Install a specific version (e.g., 0.2.0) - --no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.) - -Examples: - curl -fsSL https://cli.sentry.dev/install | bash - curl -fsSL https://cli.sentry.dev/install | bash -s -- --version 0.2.0 -EOF -} - -requested_version="" -no_modify_path=false -while [[ $# -gt 0 ]]; do - case "$1" in - -h|--help) usage; exit 0 ;; - -v|--version) - if [[ -n "${2:-}" ]]; then - requested_version="$2" - shift 2 - else - echo -e "${RED}Error: --version requires a version argument${NC}" - exit 1 - fi - ;; - --no-modify-path) - no_modify_path=true - shift - ;; - *) shift ;; - esac -done - -# Detect OS -case "$(uname -s)" in - Darwin*) os="darwin" ;; - Linux*) os="linux" ;; - MINGW*|MSYS*|CYGWIN*) os="windows" ;; - *) echo -e "${RED}Unsupported OS: $(uname -s)${NC}"; exit 1 ;; -esac - -# Detect architecture -arch=$(uname -m) -case "$arch" in - x86_64) arch="x64" ;; - aarch64|arm64) arch="arm64" ;; - *) echo -e "${RED}Unsupported architecture: $arch${NC}"; exit 1 ;; -esac - -# Validate supported combinations -suffix="" -if [[ "$os" == "windows" ]]; then - suffix=".exe" - if [[ "$arch" != "x64" ]]; then - echo -e "${RED}Unsupported: windows-$arch (only windows-x64 is supported)${NC}" - exit 1 - fi -fi - -# Resolve version -if [[ -z "$requested_version" ]]; then - version=$(curl -fsSL https://api.github.com/repos/getsentry/cli/releases/latest | sed -n 's/.*"tag_name": *"\([^"]*\)".*/\1/p') - if [[ -z "$version" ]]; then - echo -e "${RED}Failed to fetch latest version${NC}" - exit 1 - fi -else - version="$requested_version" -fi - -# Strip leading 'v' if present (releases use version without 'v' prefix) -version="${version#v}" -filename="sentry-${os}-${arch}${suffix}" -url="https://github.com/getsentry/cli/releases/download/${version}/${filename}" - -# Install -install_dir="$HOME/.sentry/bin" -install_path="${install_dir}/sentry${suffix}" - -mkdir -p "$install_dir" -echo -e "${MUTED}Downloading sentry v${version}...${NC}" -curl -fsSL --progress-bar "$url" -o "$install_path" -chmod +x "$install_path" - -# Add to PATH -add_to_path() { - local config_file=$1 - local command=$2 - - if grep -Fxq "$command" "$config_file"; then - echo -e "${MUTED}PATH already configured in ${NC}$config_file" - elif [[ -w $config_file ]]; then - echo -e "\n# sentry" >> "$config_file" - echo "$command" >> "$config_file" - echo -e "${MUTED}Added ${NC}sentry ${MUTED}to PATH in ${NC}$config_file" - else - echo -e "${MUTED}Manually add the directory to $config_file (or similar):${NC}" - echo " $command" - fi -} - -XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config} - -current_shell=$(basename "${SHELL:-sh}") -case $current_shell in - fish) - config_files=("$XDG_CONFIG_HOME/fish/config.fish") - ;; - zsh) - config_files=("$HOME/.zshrc" "$HOME/.zshenv" "$XDG_CONFIG_HOME/zsh/.zshrc" "$XDG_CONFIG_HOME/zsh/.zshenv") - ;; - bash) - config_files=("$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.profile" "$XDG_CONFIG_HOME/bash/.bash_profile" "$XDG_CONFIG_HOME/bash/.bashrc") - ;; - ash|sh) - config_files=("$HOME/.profile" "/etc/profile") - ;; - *) - config_files=("$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.profile") - ;; -esac - -if [[ "$no_modify_path" != "true" ]]; then - config_file="" - for file in "${config_files[@]}"; do - if [[ -f $file ]]; then - config_file=$file - break - fi - done - - if [[ -z $config_file ]]; then - echo -e "${MUTED}No config file found. Manually add to PATH:${NC}" - case $current_shell in - fish) - echo " fish_add_path \"$install_dir\"" - ;; - *) - echo " export PATH=\"$install_dir\":\$PATH" - ;; - esac - else - case $current_shell in - fish) - add_to_path "$config_file" "fish_add_path \"$install_dir\"" - ;; - *) - add_to_path "$config_file" "export PATH=\"$install_dir\":\$PATH" - ;; - esac - fi -else - echo -e "${MUTED}Skipping PATH modification. Manually add to your shell config:${NC}" - case $current_shell in - fish) - echo " fish_add_path \"$install_dir\"" - ;; - *) - echo " export PATH=\"$install_dir\":\$PATH" - ;; - esac -fi - -if [[ -n "${GITHUB_ACTIONS:-}" ]] && [[ "${GITHUB_ACTIONS}" == "true" ]] && [[ -n "${GITHUB_PATH:-}" ]]; then - echo "$install_dir" >> "$GITHUB_PATH" - echo -e "${MUTED}Added to \$GITHUB_PATH${NC}" -fi - -# Success message -echo "" -echo -e "Installed ${NC}sentry v${version}${MUTED} to ${NC}${install_path}" -echo "" -echo -e "${MUTED}Get started (restart your shell or open a new terminal):${NC}" -echo " sentry --help" -echo "" -echo -e "${MUTED}https://cli.sentry.dev${NC}" diff --git a/docs/public/install b/docs/public/install new file mode 120000 index 00000000..29fc0702 --- /dev/null +++ b/docs/public/install @@ -0,0 +1 @@ +../../install \ No newline at end of file diff --git a/install b/install index 115f75a0..b2ebb7ca 100755 --- a/install +++ b/install @@ -14,14 +14,20 @@ Usage: install [options] Options: -h, --help Display this help message -v, --version Install a specific version (e.g., 0.2.0) + --no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.) + +Environment Variables: + SENTRY_INSTALL_DIR Override the installation directory Examples: curl -fsSL https://cli.sentry.dev/install | bash curl -fsSL https://cli.sentry.dev/install | bash -s -- --version 0.2.0 + SENTRY_INSTALL_DIR=~/.local/bin curl -fsSL https://cli.sentry.dev/install | bash EOF } requested_version="" +no_modify_path=false while [[ $# -gt 0 ]]; do case "$1" in -h|--help) usage; exit 0 ;; @@ -34,6 +40,10 @@ while [[ $# -gt 0 ]]; do exit 1 fi ;; + --no-modify-path) + no_modify_path=true + shift + ;; *) shift ;; esac done @@ -80,23 +90,148 @@ version="${version#v}" filename="sentry-${os}-${arch}${suffix}" url="https://github.com/getsentry/cli/releases/download/${version}/${filename}" -# Install -install_dir="$HOME/.sentry/bin" +# Determine install directory +# Priority: +# 1. SENTRY_INSTALL_DIR environment variable (if set and writable) +# 2. ~/.local/bin (if exists AND in $PATH) +# 3. ~/bin (if exists AND in $PATH) +# 4. ~/.sentry/bin (fallback, will modify PATH) +needs_path_modification=false +install_dir="" + +if [[ -n "${SENTRY_INSTALL_DIR:-}" ]]; then + # User explicitly specified install directory + install_dir="$SENTRY_INSTALL_DIR" + if [[ ! -d "$install_dir" ]]; then + mkdir -p "$install_dir" 2>/dev/null || true + fi + if [[ ! -w "$install_dir" ]]; then + echo -e "${RED}Error: Cannot write to $install_dir${NC}" + echo -e "${MUTED}Try running with sudo or choose a different directory.${NC}" + exit 1 + fi + # Check if it's in PATH + if ! echo "$PATH" | tr ':' '\n' | grep -Fxq "$install_dir"; then + needs_path_modification=true + fi +elif [[ -d "$HOME/.local/bin" ]] && echo "$PATH" | tr ':' '\n' | grep -Fxq "$HOME/.local/bin"; then + install_dir="$HOME/.local/bin" +elif [[ -d "$HOME/bin" ]] && echo "$PATH" | tr ':' '\n' | grep -Fxq "$HOME/bin"; then + install_dir="$HOME/bin" +else + install_dir="$HOME/.sentry/bin" + needs_path_modification=true +fi + install_path="${install_dir}/sentry${suffix}" +# Create directory if needed mkdir -p "$install_dir" + +# Download binary echo -e "${MUTED}Downloading sentry v${version}...${NC}" curl -fsSL --progress-bar "$url" -o "$install_path" chmod +x "$install_path" +# Record installation metadata +"$install_path" cli record-install --method curl 2>/dev/null || true + +# Add to PATH (helper function) +add_to_path() { + local config_file=$1 + local command=$2 + + if grep -Fxq "$command" "$config_file"; then + echo -e "${MUTED}PATH already configured in ${NC}$config_file" + elif [[ -w $config_file ]]; then + echo -e "\n# sentry" >> "$config_file" + echo "$command" >> "$config_file" + echo -e "${MUTED}Added ${NC}sentry ${MUTED}to PATH in ${NC}$config_file" + else + echo -e "${MUTED}Manually add the directory to $config_file (or similar):${NC}" + echo " $command" + fi +} + +# Only modify PATH if needed (fallback to ~/.sentry/bin or custom non-PATH dir) +if [[ "$needs_path_modification" == "true" ]] && [[ "$no_modify_path" != "true" ]]; then + XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config} + + current_shell=$(basename "${SHELL:-sh}") + case $current_shell in + fish) + config_files=("$XDG_CONFIG_HOME/fish/config.fish") + ;; + zsh) + config_files=("$HOME/.zshrc" "$HOME/.zshenv" "$XDG_CONFIG_HOME/zsh/.zshrc" "$XDG_CONFIG_HOME/zsh/.zshenv") + ;; + bash) + config_files=("$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.profile" "$XDG_CONFIG_HOME/bash/.bash_profile" "$XDG_CONFIG_HOME/bash/.bashrc") + ;; + ash|sh) + config_files=("$HOME/.profile" "/etc/profile") + ;; + *) + config_files=("$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.profile") + ;; + esac + + config_file="" + for file in "${config_files[@]}"; do + if [[ -f $file ]]; then + config_file=$file + break + fi + done + + if [[ -z $config_file ]]; then + echo -e "${MUTED}No config file found. Manually add to PATH:${NC}" + case $current_shell in + fish) + echo " fish_add_path \"$install_dir\"" + ;; + *) + echo " export PATH=\"$install_dir\":\$PATH" + ;; + esac + else + case $current_shell in + fish) + add_to_path "$config_file" "fish_add_path \"$install_dir\"" + ;; + *) + add_to_path "$config_file" "export PATH=\"$install_dir\":\$PATH" + ;; + esac + fi +elif [[ "$needs_path_modification" == "true" ]] && [[ "$no_modify_path" == "true" ]]; then + echo -e "${MUTED}Skipping PATH modification. Manually add to your shell config:${NC}" + current_shell=$(basename "${SHELL:-sh}") + case $current_shell in + fish) + echo " fish_add_path \"$install_dir\"" + ;; + *) + echo " export PATH=\"$install_dir\":\$PATH" + ;; + esac +fi + +# GitHub Actions support +if [[ -n "${GITHUB_ACTIONS:-}" ]] && [[ "${GITHUB_ACTIONS}" == "true" ]] && [[ -n "${GITHUB_PATH:-}" ]]; then + echo "$install_dir" >> "$GITHUB_PATH" + echo -e "${MUTED}Added to \$GITHUB_PATH${NC}" +fi + # Success message echo "" echo -e "Installed ${NC}sentry v${version}${MUTED} to ${NC}${install_path}" echo "" -echo -e "${MUTED}Add to PATH (add to ~/.zshrc or ~/.bashrc):${NC}" -echo " export PATH=\"\$HOME/.sentry/bin:\$PATH\"" -echo "" -echo -e "${MUTED}Get started:${NC}" +if [[ "$needs_path_modification" == "true" ]]; then + echo -e "${MUTED}Get started (restart your shell or open a new terminal):${NC}" +else + echo -e "${MUTED}Get started:${NC}" +fi echo " sentry --help" echo "" echo -e "${MUTED}https://cli.sentry.dev${NC}" diff --git a/src/commands/cli/index.ts b/src/commands/cli/index.ts index 4e46fc8f..4ecce653 100644 --- a/src/commands/cli/index.ts +++ b/src/commands/cli/index.ts @@ -1,12 +1,14 @@ import { buildRouteMap } from "@stricli/core"; import { feedbackCommand } from "./feedback.js"; import { fixCommand } from "./fix.js"; +import { recordInstallCommand } from "./record-install.js"; import { upgradeCommand } from "./upgrade.js"; export const cliRoute = buildRouteMap({ routes: { feedback: feedbackCommand, fix: fixCommand, + "record-install": recordInstallCommand, upgrade: upgradeCommand, }, docs: { diff --git a/src/commands/cli/record-install.ts b/src/commands/cli/record-install.ts new file mode 100644 index 00000000..5c8bb373 --- /dev/null +++ b/src/commands/cli/record-install.ts @@ -0,0 +1,64 @@ +/** + * sentry cli record-install + * + * Record installation metadata for use by upgrade command. + * This is typically called automatically by installation scripts + * and should not need to be run manually. + */ + +import { buildCommand } from "@stricli/core"; +import type { SentryContext } from "../../context.js"; +import { CLI_VERSION } from "../../lib/constants.js"; +import { setInstallInfo } from "../../lib/db/install-info.js"; +import { + type InstallationMethod, + parseInstallationMethod, +} from "../../lib/upgrade.js"; + +type RecordInstallFlags = { + readonly method: InstallationMethod; + readonly path?: string; +}; + +export const recordInstallCommand = buildCommand({ + docs: { + brief: "Record installation metadata (used by installers)", + fullDescription: + "Records how this CLI was installed. This is typically called automatically\n" + + "by installation scripts (curl, package managers) and should not need to be\n" + + "run manually.\n\n" + + "The recorded information is used by 'sentry cli upgrade' to determine\n" + + "the appropriate upgrade method without re-detecting every time.\n\n" + + "Examples:\n" + + " sentry cli record-install --method curl\n" + + " sentry cli record-install --method npm --path /usr/local/bin/sentry", + }, + parameters: { + flags: { + method: { + kind: "parsed", + parse: parseInstallationMethod, + brief: "Installation method (curl, npm, pnpm, bun, yarn)", + placeholder: "method", + }, + path: { + kind: "parsed", + parse: String, + brief: "Binary path (defaults to current executable)", + optional: true, + placeholder: "path", + }, + }, + }, + func(this: SentryContext, flags: RecordInstallFlags): void { + const installPath = flags.path ?? this.process.execPath; + + setInstallInfo({ + method: flags.method, + path: installPath, + version: CLI_VERSION, + }); + + // Silent success for scripted usage - installers don't need output + }, +}); diff --git a/src/commands/cli/upgrade.ts b/src/commands/cli/upgrade.ts index 79f0cbaa..25cd4b6b 100644 --- a/src/commands/cli/upgrade.ts +++ b/src/commands/cli/upgrade.ts @@ -7,6 +7,7 @@ import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; import { CLI_VERSION } from "../../lib/constants.js"; +import { setInstallInfo } from "../../lib/db/install-info.js"; import { UpgradeError } from "../../lib/errors.js"; import { detectInstallationMethod, @@ -23,6 +24,28 @@ type UpgradeFlags = { readonly method?: InstallationMethod; }; +/** + * Resolve installation method: use user-specified method or detect automatically. + * When user specifies method, persist it for future upgrades. + */ +async function resolveMethod( + flags: UpgradeFlags, + execPath: string +): Promise { + const method = flags.method ?? (await detectInstallationMethod()); + + // Persist user-specified method for future upgrades + if (flags.method) { + setInstallInfo({ + method: flags.method, + path: execPath, + version: CLI_VERSION, + }); + } + + return method; +} + export const upgradeCommand = buildCommand({ docs: { brief: "Update the Sentry CLI to the latest version", @@ -69,8 +92,8 @@ export const upgradeCommand = buildCommand({ ): Promise { const { stdout } = this; - // Detect or use specified installation method - const method = flags.method ?? (await detectInstallationMethod()); + // Resolve installation method (detects or uses user-specified, persists if specified) + const method = await resolveMethod(flags, this.process.execPath); if (method === "unknown") { throw new UpgradeError("unknown_method"); diff --git a/src/lib/db/install-info.ts b/src/lib/db/install-info.ts new file mode 100644 index 00000000..5e7fc14b --- /dev/null +++ b/src/lib/db/install-info.ts @@ -0,0 +1,96 @@ +/** + * Installation info persistence. + * + * Stores how the CLI was installed (method, path, version) in the metadata table. + * This is used by the upgrade command to determine the appropriate upgrade method + * without re-detecting every time. + */ + +import type { InstallationMethod } from "../upgrade.js"; +import { getDatabase } from "./index.js"; +import { runUpsert } from "./utils.js"; + +const KEY_METHOD = "install.method"; +const KEY_PATH = "install.path"; +const KEY_VERSION = "install.version"; +const KEY_RECORDED_AT = "install.recorded_at"; + +export type StoredInstallInfo = { + /** How the CLI was installed */ + method: InstallationMethod; + /** Absolute path to the binary */ + path: string; + /** Version when installed or last upgraded */ + version: string; + /** Unix timestamp (ms) when this info was recorded */ + recordedAt: number; +}; + +/** + * Get the stored installation info. + * + * @returns Installation info if recorded, null otherwise + */ +export function getInstallInfo(): StoredInstallInfo | null { + const db = getDatabase(); + + const methodRow = db + .query("SELECT value FROM metadata WHERE key = ?") + .get(KEY_METHOD) as { value: string } | undefined; + + // If no method is stored, we have no install info + if (!methodRow) { + return null; + } + + const pathRow = db + .query("SELECT value FROM metadata WHERE key = ?") + .get(KEY_PATH) as { value: string } | undefined; + + const versionRow = db + .query("SELECT value FROM metadata WHERE key = ?") + .get(KEY_VERSION) as { value: string } | undefined; + + const recordedAtRow = db + .query("SELECT value FROM metadata WHERE key = ?") + .get(KEY_RECORDED_AT) as { value: string } | undefined; + + return { + method: methodRow.value as InstallationMethod, + path: pathRow?.value ?? "", + version: versionRow?.value ?? "", + recordedAt: recordedAtRow ? Number(recordedAtRow.value) : 0, + }; +} + +/** + * Store installation info. + * + * @param info - Installation info to store (recordedAt is auto-set to now) + */ +export function setInstallInfo( + info: Omit +): void { + const db = getDatabase(); + const now = Date.now(); + + runUpsert(db, "metadata", { key: KEY_METHOD, value: info.method }, ["key"]); + runUpsert(db, "metadata", { key: KEY_PATH, value: info.path }, ["key"]); + runUpsert(db, "metadata", { key: KEY_VERSION, value: info.version }, ["key"]); + runUpsert(db, "metadata", { key: KEY_RECORDED_AT, value: String(now) }, [ + "key", + ]); +} + +/** + * Clear stored installation info. + * Useful for testing or when user wants to re-detect. + */ +export function clearInstallInfo(): void { + const db = getDatabase(); + + db.query("DELETE FROM metadata WHERE key = ?").run(KEY_METHOD); + db.query("DELETE FROM metadata WHERE key = ?").run(KEY_PATH); + db.query("DELETE FROM metadata WHERE key = ?").run(KEY_VERSION); + db.query("DELETE FROM metadata WHERE key = ?").run(KEY_RECORDED_AT); +} diff --git a/src/lib/upgrade.ts b/src/lib/upgrade.ts index 13ad8a5d..86cb52ef 100644 --- a/src/lib/upgrade.ts +++ b/src/lib/upgrade.ts @@ -15,7 +15,8 @@ import { import { unlink } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; -import { getUserAgent } from "./constants.js"; +import { CLI_VERSION, getUserAgent } from "./constants.js"; +import { getInstallInfo, setInstallInfo } from "./db/install-info.js"; import { UpgradeError } from "./errors.js"; // Types @@ -76,18 +77,14 @@ export function getBinaryDownloadUrl(version: string): string { } /** - * Get file paths for curl-installed binary. - * - * @returns Object with install, temp, old, and lock file paths + * Build paths object from an install path. */ -export function getCurlInstallPaths(): { +function buildPaths(installPath: string): { installPath: string; tempPath: string; oldPath: string; lockPath: string; } { - const suffix = process.platform === "win32" ? ".exe" : ""; - const installPath = join(homedir(), ".sentry", "bin", `sentry${suffix}`); return { installPath, tempPath: `${installPath}.download`, @@ -96,6 +93,41 @@ export function getCurlInstallPaths(): { }; } +/** + * Get file paths for curl-installed binary. + * + * Priority for determining install path: + * 1. Stored install path from DB (if method is curl) + * 2. process.execPath if it's in a known curl install location + * 3. Default to ~/.sentry/bin/sentry (fallback for fresh installs) + * + * @returns Object with install, temp, old, and lock file paths + */ +export function getCurlInstallPaths(): { + installPath: string; + tempPath: string; + oldPath: string; + lockPath: string; +} { + // Check stored install path + const stored = getInstallInfo(); + if (stored?.path && stored.method === "curl") { + return buildPaths(stored.path); + } + + // Check if we're running from a known curl install location + for (const dir of KNOWN_CURL_PATHS) { + if (process.execPath.startsWith(dir)) { + return buildPaths(process.execPath); + } + } + + // Fallback to default path (for fresh installs or non-curl runs like tests) + const suffix = process.platform === "win32" ? ".exe" : ""; + const defaultPath = join(homedir(), ".sentry", "bin", `sentry${suffix}`); + return buildPaths(defaultPath); +} + /** * Clean up leftover .old files from previous upgrades. * Called on CLI startup to remove .old files left over from Windows upgrades @@ -273,16 +305,27 @@ async function isInstalledWith(pm: PackageManager): Promise { } /** - * Detect how the CLI was installed by checking executable path and package managers. + * Known directories where the curl installer may place the binary. + * Used for legacy detection (when no install info is stored). + */ +const KNOWN_CURL_PATHS = [ + join(homedir(), ".local", "bin"), + join(homedir(), "bin"), + join(homedir(), ".sentry", "bin"), +]; + +/** + * Legacy detection for existing installs that don't have stored install info. + * Checks known curl install paths and package managers. * * @returns Detected installation method, or "unknown" if unable to determine */ -export async function detectInstallationMethod(): Promise { - const sentryBinPath = join(homedir(), ".sentry", "bin"); - - // curl installer places binary in ~/.sentry/bin - if (process.execPath.startsWith(sentryBinPath)) { - return "curl"; +async function detectLegacyInstallationMethod(): Promise { + // Check known curl install paths + for (const dir of KNOWN_CURL_PATHS) { + if (process.execPath.startsWith(dir)) { + return "curl"; + } } // Check package managers in order of popularity @@ -297,6 +340,38 @@ export async function detectInstallationMethod(): Promise { return "unknown"; } +/** + * Detect how the CLI was installed. + * + * Priority: + * 1. Check stored install info in DB (fast path) + * 2. Fall back to legacy detection for existing installs + * 3. Auto-save detected method for future runs + * + * @returns Detected installation method, or "unknown" if unable to determine + */ +export async function detectInstallationMethod(): Promise { + // 1. Check stored info (fast path) + const stored = getInstallInfo(); + if (stored?.method) { + return stored.method; + } + + // 2. Legacy detection for existing installs (pre-record-install) + const legacyMethod = await detectLegacyInstallationMethod(); + + // 3. Auto-save detected method for future runs + if (legacyMethod !== "unknown") { + setInstallInfo({ + method: legacyMethod, + path: process.execPath, + version: CLI_VERSION, + }); + } + + return legacyMethod; +} + // Version Fetching /** Extract error message from unknown caught value */ @@ -518,6 +593,13 @@ async function executeUpgradeCurl(version: string): Promise { // Unix: Atomic rename overwrites target renameSync(tempPath, installPath); } + + // Update stored install info with new version + setInstallInfo({ + method: "curl", + path: installPath, + version, + }); } finally { releaseUpgradeLock(lockPath); } @@ -544,6 +626,12 @@ function executeUpgradePackageManager( proc.on("close", (code) => { if (code === 0) { + // Update stored install info with new version + setInstallInfo({ + method: pm, + path: process.execPath, + version, + }); resolve(); } else { reject( diff --git a/test/commands/cli.test.ts b/test/commands/cli.test.ts index 7b658e00..aaccda9e 100644 --- a/test/commands/cli.test.ts +++ b/test/commands/cli.test.ts @@ -6,7 +6,11 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { feedbackCommand } from "../../src/commands/cli/feedback.js"; +import { recordInstallCommand } from "../../src/commands/cli/record-install.js"; import { upgradeCommand } from "../../src/commands/cli/upgrade.js"; +import { closeDatabase } from "../../src/lib/db/index.js"; +import { getInstallInfo } from "../../src/lib/db/install-info.js"; +import { cleanupTestDir, createTestConfigDir } from "../helpers.js"; describe("feedbackCommand.func", () => { test("throws ValidationError for empty message", async () => { @@ -81,6 +85,7 @@ describe("upgradeCommand.func", () => { const func = await upgradeCommand.loader(); const stdoutWrite = mock(() => true); const mockContext = { + process: { execPath: "/test/path/sentry" }, stdout: { write: stdoutWrite }, stderr: { write: mock(() => true) }, }; @@ -106,6 +111,7 @@ describe("upgradeCommand.func", () => { const func = await upgradeCommand.loader(); const stdoutWrite = mock(() => true); const mockContext = { + process: { execPath: "/test/path/sentry" }, stdout: { write: stdoutWrite }, stderr: { write: mock(() => true) }, }; @@ -127,6 +133,7 @@ describe("upgradeCommand.func", () => { const func = await upgradeCommand.loader(); const stdoutWrite = mock(() => true); const mockContext = { + process: { execPath: "/test/path/sentry" }, stdout: { write: stdoutWrite }, stderr: { write: mock(() => true) }, }; @@ -149,6 +156,7 @@ describe("upgradeCommand.func", () => { const func = await upgradeCommand.loader(); const stdoutWrite = mock(() => true); const mockContext = { + process: { execPath: "/test/path/sentry" }, stdout: { write: stdoutWrite }, stderr: { write: mock(() => true) }, }; @@ -180,6 +188,7 @@ describe("upgradeCommand.func", () => { const func = await upgradeCommand.loader(); const stdoutWrite = mock(() => true); const mockContext = { + process: { execPath: "/test/path/sentry" }, stdout: { write: stdoutWrite }, stderr: { write: mock(() => true) }, }; @@ -190,3 +199,79 @@ describe("upgradeCommand.func", () => { ).rejects.toThrow("Version 999.0.0 not found"); }); }); + +// Test the record-install command func +describe("recordInstallCommand.func", () => { + let testConfigDir: string; + + beforeEach(async () => { + testConfigDir = await createTestConfigDir("test-record-install-"); + process.env.SENTRY_CONFIG_DIR = testConfigDir; + }); + + afterEach(async () => { + closeDatabase(); + delete process.env.SENTRY_CONFIG_DIR; + await cleanupTestDir(testConfigDir); + }); + + test("records curl install info", async () => { + const func = await recordInstallCommand.loader(); + const mockContext = { + process: { execPath: "/home/user/.local/bin/sentry" }, + stdout: { write: mock(() => true) }, + stderr: { write: mock(() => true) }, + }; + + func.call(mockContext, { method: "curl" }); + + const info = getInstallInfo(); + expect(info?.method).toBe("curl"); + expect(info?.path).toBe("/home/user/.local/bin/sentry"); + }); + + test("records npm install info", async () => { + const func = await recordInstallCommand.loader(); + const mockContext = { + process: { execPath: "/usr/local/bin/sentry" }, + stdout: { write: mock(() => true) }, + stderr: { write: mock(() => true) }, + }; + + func.call(mockContext, { method: "npm" }); + + const info = getInstallInfo(); + expect(info?.method).toBe("npm"); + }); + + test("uses provided path over execPath", async () => { + const func = await recordInstallCommand.loader(); + const mockContext = { + process: { execPath: "/default/path" }, + stdout: { write: mock(() => true) }, + stderr: { write: mock(() => true) }, + }; + + func.call(mockContext, { method: "curl", path: "/custom/path/sentry" }); + + const info = getInstallInfo(); + expect(info?.path).toBe("/custom/path/sentry"); + }); + + test("overwrites existing install info", async () => { + const func = await recordInstallCommand.loader(); + const mockContext = { + process: { execPath: "/path1" }, + stdout: { write: mock(() => true) }, + stderr: { write: mock(() => true) }, + }; + + func.call(mockContext, { method: "curl" }); + expect(getInstallInfo()?.method).toBe("curl"); + + mockContext.process.execPath = "/path2"; + func.call(mockContext, { method: "npm" }); + expect(getInstallInfo()?.method).toBe("npm"); + expect(getInstallInfo()?.path).toBe("/path2"); + }); +}); diff --git a/test/lib/db/install-info.test.ts b/test/lib/db/install-info.test.ts new file mode 100644 index 00000000..7278e608 --- /dev/null +++ b/test/lib/db/install-info.test.ts @@ -0,0 +1,162 @@ +/** + * Install Info Storage Tests + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { closeDatabase } from "../../../src/lib/db/index.js"; +import { + clearInstallInfo, + getInstallInfo, + setInstallInfo, +} from "../../../src/lib/db/install-info.js"; +import { cleanupTestDir, createTestConfigDir } from "../../helpers.js"; + +let testConfigDir: string; + +beforeEach(async () => { + testConfigDir = await createTestConfigDir("test-install-info-"); + process.env.SENTRY_CONFIG_DIR = testConfigDir; +}); + +afterEach(async () => { + closeDatabase(); + delete process.env.SENTRY_CONFIG_DIR; + await cleanupTestDir(testConfigDir); +}); + +describe("getInstallInfo", () => { + test("returns null when no install info stored", () => { + const result = getInstallInfo(); + expect(result).toBeNull(); + }); + + test("returns stored install info", () => { + setInstallInfo({ + method: "curl", + path: "/home/user/.local/bin/sentry", + version: "1.0.0", + }); + + const result = getInstallInfo(); + expect(result).not.toBeNull(); + expect(result?.method).toBe("curl"); + expect(result?.path).toBe("/home/user/.local/bin/sentry"); + expect(result?.version).toBe("1.0.0"); + expect(result?.recordedAt).toBeGreaterThan(0); + }); +}); + +describe("setInstallInfo", () => { + test("stores curl install info", () => { + setInstallInfo({ + method: "curl", + path: "/home/user/.sentry/bin/sentry", + version: "0.5.0", + }); + + const result = getInstallInfo(); + expect(result?.method).toBe("curl"); + expect(result?.path).toBe("/home/user/.sentry/bin/sentry"); + expect(result?.version).toBe("0.5.0"); + }); + + test("stores npm install info", () => { + setInstallInfo({ + method: "npm", + path: "/usr/local/bin/sentry", + version: "0.6.0", + }); + + const result = getInstallInfo(); + expect(result?.method).toBe("npm"); + expect(result?.path).toBe("/usr/local/bin/sentry"); + expect(result?.version).toBe("0.6.0"); + }); + + test("stores pnpm install info", () => { + setInstallInfo({ + method: "pnpm", + path: "/home/user/.local/share/pnpm/sentry", + version: "0.7.0", + }); + + const result = getInstallInfo(); + expect(result?.method).toBe("pnpm"); + }); + + test("stores bun install info", () => { + setInstallInfo({ + method: "bun", + path: "/home/user/.bun/bin/sentry", + version: "0.8.0", + }); + + const result = getInstallInfo(); + expect(result?.method).toBe("bun"); + }); + + test("stores yarn install info", () => { + setInstallInfo({ + method: "yarn", + path: "/home/user/.yarn/bin/sentry", + version: "0.9.0", + }); + + const result = getInstallInfo(); + expect(result?.method).toBe("yarn"); + }); + + test("overwrites existing install info", () => { + setInstallInfo({ + method: "curl", + path: "/first/path", + version: "1.0.0", + }); + setInstallInfo({ + method: "npm", + path: "/second/path", + version: "2.0.0", + }); + + const result = getInstallInfo(); + expect(result?.method).toBe("npm"); + expect(result?.path).toBe("/second/path"); + expect(result?.version).toBe("2.0.0"); + }); + + test("sets recordedAt timestamp", () => { + const before = Date.now(); + setInstallInfo({ + method: "curl", + path: "/test/path", + version: "1.0.0", + }); + const after = Date.now(); + + const result = getInstallInfo(); + expect(result?.recordedAt).toBeGreaterThanOrEqual(before); + expect(result?.recordedAt).toBeLessThanOrEqual(after); + }); +}); + +describe("clearInstallInfo", () => { + test("removes stored install info", () => { + setInstallInfo({ + method: "curl", + path: "/test/path", + version: "1.0.0", + }); + + expect(getInstallInfo()).not.toBeNull(); + + clearInstallInfo(); + + expect(getInstallInfo()).toBeNull(); + }); + + test("does nothing when no info stored", () => { + // Should not throw + clearInstallInfo(); + expect(getInstallInfo()).toBeNull(); + }); +}); From ad4b692fb54f5b1d181966ba3cd4902532b55e35 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 6 Feb 2026 16:24:24 +0000 Subject: [PATCH 02/10] feat(cli): add setup command for shell integration New 'sentry cli setup' command that: - Configures PATH by modifying shell config files - Installs shell completions (bash, zsh, fish) - Records installation metadata for upgrades This moves PATH modification logic from bash to TypeScript for: - Better error handling and testability - Reusability for users who download binary manually - Shell completion installation in the same step Install script now simply calls 'sentry cli setup --method curl' after downloading the binary. New files: - src/lib/shell.ts - Shell detection and PATH utilities - src/lib/completions.ts - Shell completion generation - src/commands/cli/setup.ts - The setup command --- install | 117 ++----------- src/commands/cli/index.ts | 2 + src/commands/cli/setup.ts | 188 +++++++++++++++++++++ src/lib/completions.ts | 311 +++++++++++++++++++++++++++++++++++ src/lib/shell.ts | 268 ++++++++++++++++++++++++++++++ test/lib/completions.test.ts | 170 +++++++++++++++++++ test/lib/shell.test.ts | 259 +++++++++++++++++++++++++++++ 7 files changed, 1215 insertions(+), 100 deletions(-) create mode 100644 src/commands/cli/setup.ts create mode 100644 src/lib/completions.ts create mode 100644 src/lib/shell.ts create mode 100644 test/lib/completions.test.ts create mode 100644 test/lib/shell.test.ts diff --git a/install b/install index b2ebb7ca..a0f6cfbc 100755 --- a/install +++ b/install @@ -15,6 +15,7 @@ Options: -h, --help Display this help message -v, --version Install a specific version (e.g., 0.2.0) --no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.) + --no-completions Don't install shell completions Environment Variables: SENTRY_INSTALL_DIR Override the installation directory @@ -28,6 +29,7 @@ EOF requested_version="" no_modify_path=false +no_completions=false while [[ $# -gt 0 ]]; do case "$1" in -h|--help) usage; exit 0 ;; @@ -44,6 +46,10 @@ while [[ $# -gt 0 ]]; do no_modify_path=true shift ;; + --no-completions) + no_completions=true + shift + ;; *) shift ;; esac done @@ -95,12 +101,10 @@ url="https://github.com/getsentry/cli/releases/download/${version}/${filename}" # 1. SENTRY_INSTALL_DIR environment variable (if set and writable) # 2. ~/.local/bin (if exists AND in $PATH) # 3. ~/bin (if exists AND in $PATH) -# 4. ~/.sentry/bin (fallback, will modify PATH) -needs_path_modification=false +# 4. ~/.sentry/bin (fallback, setup command will handle PATH) install_dir="" if [[ -n "${SENTRY_INSTALL_DIR:-}" ]]; then - # User explicitly specified install directory install_dir="$SENTRY_INSTALL_DIR" if [[ ! -d "$install_dir" ]]; then mkdir -p "$install_dir" 2>/dev/null || true @@ -110,17 +114,12 @@ if [[ -n "${SENTRY_INSTALL_DIR:-}" ]]; then echo -e "${MUTED}Try running with sudo or choose a different directory.${NC}" exit 1 fi - # Check if it's in PATH - if ! echo "$PATH" | tr ':' '\n' | grep -Fxq "$install_dir"; then - needs_path_modification=true - fi elif [[ -d "$HOME/.local/bin" ]] && echo "$PATH" | tr ':' '\n' | grep -Fxq "$HOME/.local/bin"; then install_dir="$HOME/.local/bin" elif [[ -d "$HOME/bin" ]] && echo "$PATH" | tr ':' '\n' | grep -Fxq "$HOME/bin"; then install_dir="$HOME/bin" else install_dir="$HOME/.sentry/bin" - needs_path_modification=true fi install_path="${install_dir}/sentry${suffix}" @@ -133,105 +132,23 @@ echo -e "${MUTED}Downloading sentry v${version}...${NC}" curl -fsSL --progress-bar "$url" -o "$install_path" chmod +x "$install_path" -# Record installation metadata -"$install_path" cli record-install --method curl 2>/dev/null || true - -# Add to PATH (helper function) -add_to_path() { - local config_file=$1 - local command=$2 - - if grep -Fxq "$command" "$config_file"; then - echo -e "${MUTED}PATH already configured in ${NC}$config_file" - elif [[ -w $config_file ]]; then - echo -e "\n# sentry" >> "$config_file" - echo "$command" >> "$config_file" - echo -e "${MUTED}Added ${NC}sentry ${MUTED}to PATH in ${NC}$config_file" - else - echo -e "${MUTED}Manually add the directory to $config_file (or similar):${NC}" - echo " $command" - fi -} - -# Only modify PATH if needed (fallback to ~/.sentry/bin or custom non-PATH dir) -if [[ "$needs_path_modification" == "true" ]] && [[ "$no_modify_path" != "true" ]]; then - XDG_CONFIG_HOME=${XDG_CONFIG_HOME:-$HOME/.config} - - current_shell=$(basename "${SHELL:-sh}") - case $current_shell in - fish) - config_files=("$XDG_CONFIG_HOME/fish/config.fish") - ;; - zsh) - config_files=("$HOME/.zshrc" "$HOME/.zshenv" "$XDG_CONFIG_HOME/zsh/.zshrc" "$XDG_CONFIG_HOME/zsh/.zshenv") - ;; - bash) - config_files=("$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.profile" "$XDG_CONFIG_HOME/bash/.bash_profile" "$XDG_CONFIG_HOME/bash/.bashrc") - ;; - ash|sh) - config_files=("$HOME/.profile" "/etc/profile") - ;; - *) - config_files=("$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.profile") - ;; - esac - - config_file="" - for file in "${config_files[@]}"; do - if [[ -f $file ]]; then - config_file=$file - break - fi - done - - if [[ -z $config_file ]]; then - echo -e "${MUTED}No config file found. Manually add to PATH:${NC}" - case $current_shell in - fish) - echo " fish_add_path \"$install_dir\"" - ;; - *) - echo " export PATH=\"$install_dir\":\$PATH" - ;; - esac - else - case $current_shell in - fish) - add_to_path "$config_file" "fish_add_path \"$install_dir\"" - ;; - *) - add_to_path "$config_file" "export PATH=\"$install_dir\":\$PATH" - ;; - esac - fi -elif [[ "$needs_path_modification" == "true" ]] && [[ "$no_modify_path" == "true" ]]; then - echo -e "${MUTED}Skipping PATH modification. Manually add to your shell config:${NC}" - current_shell=$(basename "${SHELL:-sh}") - case $current_shell in - fish) - echo " fish_add_path \"$install_dir\"" - ;; - *) - echo " export PATH=\"$install_dir\":\$PATH" - ;; - esac +# Run setup command to configure PATH, completions, and record install info +setup_args="--method curl" +if [[ "$no_modify_path" == "true" ]]; then + setup_args="$setup_args --noModifyPath" fi - -# GitHub Actions support -if [[ -n "${GITHUB_ACTIONS:-}" ]] && [[ "${GITHUB_ACTIONS}" == "true" ]] && [[ -n "${GITHUB_PATH:-}" ]]; then - echo "$install_dir" >> "$GITHUB_PATH" - echo -e "${MUTED}Added to \$GITHUB_PATH${NC}" +if [[ "$no_completions" == "true" ]]; then + setup_args="$setup_args --noCompletions" fi +# shellcheck disable=SC2086 +"$install_path" cli setup $setup_args 2>/dev/null || true + # Success message echo "" echo -e "Installed ${NC}sentry v${version}${MUTED} to ${NC}${install_path}" echo "" -if [[ "$needs_path_modification" == "true" ]]; then - echo -e "${MUTED}Get started (restart your shell or open a new terminal):${NC}" -else - echo -e "${MUTED}Get started:${NC}" -fi +echo -e "${MUTED}Get started:${NC}" echo " sentry --help" echo "" echo -e "${MUTED}https://cli.sentry.dev${NC}" diff --git a/src/commands/cli/index.ts b/src/commands/cli/index.ts index 4ecce653..3d3a19a9 100644 --- a/src/commands/cli/index.ts +++ b/src/commands/cli/index.ts @@ -2,6 +2,7 @@ import { buildRouteMap } from "@stricli/core"; import { feedbackCommand } from "./feedback.js"; import { fixCommand } from "./fix.js"; import { recordInstallCommand } from "./record-install.js"; +import { setupCommand } from "./setup.js"; import { upgradeCommand } from "./upgrade.js"; export const cliRoute = buildRouteMap({ @@ -9,6 +10,7 @@ export const cliRoute = buildRouteMap({ feedback: feedbackCommand, fix: fixCommand, "record-install": recordInstallCommand, + setup: setupCommand, upgrade: upgradeCommand, }, docs: { diff --git a/src/commands/cli/setup.ts b/src/commands/cli/setup.ts new file mode 100644 index 00000000..548e45f9 --- /dev/null +++ b/src/commands/cli/setup.ts @@ -0,0 +1,188 @@ +/** + * sentry cli setup + * + * Configure shell integration: PATH, completions, and install metadata. + * This command is called by the install script and can also be run manually. + */ + +import { dirname } from "node:path"; +import { buildCommand } from "@stricli/core"; +import type { SentryContext } from "../../context.js"; +import { installCompletions } from "../../lib/completions.js"; +import { CLI_VERSION } from "../../lib/constants.js"; +import { setInstallInfo } from "../../lib/db/install-info.js"; +import { + addToGitHubPath, + addToPath, + detectShell, + getPathCommand, + isInPath, + type ShellInfo, +} from "../../lib/shell.js"; +import { + type InstallationMethod, + parseInstallationMethod, +} from "../../lib/upgrade.js"; + +type SetupFlags = { + readonly method?: InstallationMethod; + readonly noModifyPath: boolean; + readonly noCompletions: boolean; + readonly quiet: boolean; +}; + +type Logger = (msg: string) => void; + +/** + * Handle PATH modification for a directory. + */ +async function handlePathModification( + binaryDir: string, + shell: ShellInfo, + env: NodeJS.ProcessEnv, + log: Logger +): Promise { + const alreadyInPath = isInPath(binaryDir, env.PATH); + + if (alreadyInPath) { + log(`PATH: ${binaryDir} is already in PATH`); + return; + } + + if (shell.configFile) { + const result = await addToPath(shell.configFile, binaryDir, shell.type); + + if (result.modified) { + log(`PATH: ${result.message}`); + log(` Restart your shell or run: source ${shell.configFile}`); + } else if (result.manualCommand) { + log(`PATH: ${result.message}`); + log(` Add manually: ${result.manualCommand}`); + } else { + log(`PATH: ${result.message}`); + } + } else { + const cmd = getPathCommand(shell.type, binaryDir); + log("PATH: No shell config file found"); + log(` Add manually to your shell config: ${cmd}`); + } + + // Handle GitHub Actions + const addedToGitHub = await addToGitHubPath(binaryDir, env); + if (addedToGitHub) { + log("PATH: Added to $GITHUB_PATH"); + } +} + +/** + * Handle shell completion installation. + */ +async function handleCompletions( + shell: ShellInfo, + homeDir: string, + xdgDataHome: string | undefined, + log: Logger +): Promise { + const location = await installCompletions(shell.type, homeDir, xdgDataHome); + + if (location) { + const action = location.created ? "Installed to" : "Updated"; + log(`Completions: ${action} ${location.path}`); + + // Zsh may need fpath hint + if (shell.type === "zsh") { + const completionDir = dirname(location.path); + log( + ` You may need to add to .zshrc: fpath=(${completionDir} $fpath)` + ); + } + } else if (shell.type !== "sh" && shell.type !== "ash") { + log(`Completions: Not supported for ${shell.type} shell`); + } +} + +export const setupCommand = buildCommand({ + docs: { + brief: "Configure shell integration", + fullDescription: + "Sets up shell integration for the Sentry CLI:\n\n" + + "- Adds binary directory to PATH (if not already in PATH)\n" + + "- Installs shell completions (bash, zsh, fish)\n" + + "- Records installation metadata for upgrades\n\n" + + "This command is called automatically by the install script,\n" + + "but can also be run manually after downloading the binary.\n\n" + + "Examples:\n" + + " sentry cli setup # Auto-detect and configure\n" + + " sentry cli setup --method curl # Record install method\n" + + " sentry cli setup --no-modify-path # Skip PATH modification\n" + + " sentry cli setup --no-completions # Skip shell completions", + }, + parameters: { + flags: { + method: { + kind: "parsed", + parse: parseInstallationMethod, + brief: "Installation method (curl, npm, pnpm, bun, yarn)", + placeholder: "method", + optional: true, + }, + noModifyPath: { + kind: "boolean", + brief: "Skip PATH modification", + default: false, + }, + noCompletions: { + kind: "boolean", + brief: "Skip shell completion installation", + default: false, + }, + quiet: { + kind: "boolean", + brief: "Suppress output (for scripted usage)", + default: false, + }, + }, + }, + async func(this: SentryContext, flags: SetupFlags): Promise { + const { process, homeDir } = this; + const { stdout } = process; + + const log: Logger = (msg: string) => { + if (!flags.quiet) { + stdout.write(`${msg}\n`); + } + }; + + const binaryPath = process.execPath; + const binaryDir = dirname(binaryPath); + const shell = detectShell( + process.env.SHELL, + homeDir, + process.env.XDG_CONFIG_HOME + ); + + // 1. Record installation info + if (flags.method) { + setInstallInfo({ + method: flags.method, + path: binaryPath, + version: CLI_VERSION, + }); + log(`Recorded installation method: ${flags.method}`); + } + + // 2. Handle PATH modification + if (!flags.noModifyPath) { + await handlePathModification(binaryDir, shell, process.env, log); + } + + // 3. Install shell completions + if (!flags.noCompletions) { + await handleCompletions(shell, homeDir, process.env.XDG_DATA_HOME, log); + } + + if (!flags.quiet) { + stdout.write("\nSetup complete!\n"); + } + }, +}); diff --git a/src/lib/completions.ts b/src/lib/completions.ts new file mode 100644 index 00000000..cbc5b90c --- /dev/null +++ b/src/lib/completions.ts @@ -0,0 +1,311 @@ +/** + * Shell completion script generation. + * + * Generates completion scripts for bash, zsh, and fish shells. + * These scripts enable tab-completion for sentry CLI commands. + */ + +import { existsSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import type { ShellType } from "./shell.js"; + +/** Where completions are installed */ +export type CompletionLocation = { + /** Path where the completion file was installed */ + path: string; + /** Whether the file was created or already existed */ + created: boolean; +}; + +/** + * Generate bash completion script. + */ +export function generateBashCompletion(binaryName: string): string { + return `# bash completion for ${binaryName} +# Install: ${binaryName} cli setup +_${binaryName}_completions() { + local cur prev words cword + _init_completion || return + + local commands="auth api event issue org project cli help version" + local auth_commands="login logout status refresh" + local cli_commands="feedback fix setup upgrade" + local event_commands="view" + local issue_commands="list view explain plan" + local org_commands="list view" + local project_commands="list view" + + case "\${COMP_CWORD}" in + 1) + COMPREPLY=($(compgen -W "\${commands}" -- "\${cur}")) + ;; + 2) + case "\${prev}" in + auth) + COMPREPLY=($(compgen -W "\${auth_commands}" -- "\${cur}")) + ;; + cli) + COMPREPLY=($(compgen -W "\${cli_commands}" -- "\${cur}")) + ;; + event) + COMPREPLY=($(compgen -W "\${event_commands}" -- "\${cur}")) + ;; + issue) + COMPREPLY=($(compgen -W "\${issue_commands}" -- "\${cur}")) + ;; + org) + COMPREPLY=($(compgen -W "\${org_commands}" -- "\${cur}")) + ;; + project) + COMPREPLY=($(compgen -W "\${project_commands}" -- "\${cur}")) + ;; + esac + ;; + esac +} + +complete -F _${binaryName}_completions ${binaryName} +`; +} + +/** + * Generate zsh completion script. + */ +export function generateZshCompletion(binaryName: string): string { + return `#compdef ${binaryName} +# zsh completion for ${binaryName} +# Install: ${binaryName} cli setup + +_${binaryName}() { + local -a commands + commands=( + 'auth:Authentication commands' + 'api:Make authenticated API requests' + 'event:Event-related commands' + 'issue:Issue-related commands' + 'org:Organization commands' + 'project:Project commands' + 'cli:CLI management commands' + 'help:Show help' + 'version:Show version' + ) + + local -a auth_commands + auth_commands=( + 'login:Authenticate with Sentry' + 'logout:Clear stored credentials' + 'status:Check authentication status' + 'refresh:Refresh access token' + ) + + local -a cli_commands + cli_commands=( + 'feedback:Send feedback' + 'fix:Repair local database' + 'setup:Configure shell integration' + 'upgrade:Upgrade to latest version' + ) + + local -a issue_commands + issue_commands=( + 'list:List issues' + 'view:View issue details' + 'explain:AI explanation of issue' + 'plan:AI fix plan for issue' + ) + + local -a org_commands + org_commands=( + 'list:List organizations' + 'view:View organization details' + ) + + local -a project_commands + project_commands=( + 'list:List projects' + 'view:View project details' + ) + + local -a event_commands + event_commands=( + 'view:View event details' + ) + + _arguments -C \\ + '1: :->command' \\ + '2: :->subcommand' \\ + '*::arg:->args' + + case "$state" in + command) + _describe -t commands 'command' commands + ;; + subcommand) + case "$words[1]" in + auth) + _describe -t commands 'auth command' auth_commands + ;; + cli) + _describe -t commands 'cli command' cli_commands + ;; + event) + _describe -t commands 'event command' event_commands + ;; + issue) + _describe -t commands 'issue command' issue_commands + ;; + org) + _describe -t commands 'org command' org_commands + ;; + project) + _describe -t commands 'project command' project_commands + ;; + esac + ;; + esac +} + +_${binaryName} +`; +} + +/** + * Generate fish completion script. + */ +export function generateFishCompletion(binaryName: string): string { + return `# fish completion for ${binaryName} +# Install: ${binaryName} cli setup + +# Disable file completion by default +complete -c ${binaryName} -f + +# Top-level commands +complete -c ${binaryName} -n "__fish_use_subcommand" -a "auth" -d "Authentication commands" +complete -c ${binaryName} -n "__fish_use_subcommand" -a "api" -d "Make authenticated API requests" +complete -c ${binaryName} -n "__fish_use_subcommand" -a "event" -d "Event-related commands" +complete -c ${binaryName} -n "__fish_use_subcommand" -a "issue" -d "Issue-related commands" +complete -c ${binaryName} -n "__fish_use_subcommand" -a "org" -d "Organization commands" +complete -c ${binaryName} -n "__fish_use_subcommand" -a "project" -d "Project commands" +complete -c ${binaryName} -n "__fish_use_subcommand" -a "cli" -d "CLI management commands" +complete -c ${binaryName} -n "__fish_use_subcommand" -a "help" -d "Show help" +complete -c ${binaryName} -n "__fish_use_subcommand" -a "version" -d "Show version" + +# auth subcommands +complete -c ${binaryName} -n "__fish_seen_subcommand_from auth" -a "login" -d "Authenticate with Sentry" +complete -c ${binaryName} -n "__fish_seen_subcommand_from auth" -a "logout" -d "Clear stored credentials" +complete -c ${binaryName} -n "__fish_seen_subcommand_from auth" -a "status" -d "Check authentication status" +complete -c ${binaryName} -n "__fish_seen_subcommand_from auth" -a "refresh" -d "Refresh access token" + +# cli subcommands +complete -c ${binaryName} -n "__fish_seen_subcommand_from cli" -a "feedback" -d "Send feedback" +complete -c ${binaryName} -n "__fish_seen_subcommand_from cli" -a "fix" -d "Repair local database" +complete -c ${binaryName} -n "__fish_seen_subcommand_from cli" -a "setup" -d "Configure shell integration" +complete -c ${binaryName} -n "__fish_seen_subcommand_from cli" -a "upgrade" -d "Upgrade to latest version" + +# event subcommands +complete -c ${binaryName} -n "__fish_seen_subcommand_from event" -a "view" -d "View event details" + +# issue subcommands +complete -c ${binaryName} -n "__fish_seen_subcommand_from issue" -a "list" -d "List issues" +complete -c ${binaryName} -n "__fish_seen_subcommand_from issue" -a "view" -d "View issue details" +complete -c ${binaryName} -n "__fish_seen_subcommand_from issue" -a "explain" -d "AI explanation of issue" +complete -c ${binaryName} -n "__fish_seen_subcommand_from issue" -a "plan" -d "AI fix plan for issue" + +# org subcommands +complete -c ${binaryName} -n "__fish_seen_subcommand_from org" -a "list" -d "List organizations" +complete -c ${binaryName} -n "__fish_seen_subcommand_from org" -a "view" -d "View organization details" + +# project subcommands +complete -c ${binaryName} -n "__fish_seen_subcommand_from project" -a "list" -d "List projects" +complete -c ${binaryName} -n "__fish_seen_subcommand_from project" -a "view" -d "View project details" +`; +} + +/** + * Get the completion script for a shell type. + */ +export function getCompletionScript( + shellType: ShellType, + binaryName = "sentry" +): string | null { + switch (shellType) { + case "bash": + return generateBashCompletion(binaryName); + case "zsh": + return generateZshCompletion(binaryName); + case "fish": + return generateFishCompletion(binaryName); + default: + return null; + } +} + +/** + * Get the default completion file path for a shell type. + * + * @param shellType - The shell type + * @param homeDir - User's home directory + * @param xdgDataHome - XDG_DATA_HOME or undefined for default + */ +export function getCompletionPath( + shellType: ShellType, + homeDir: string, + xdgDataHome?: string +): string | null { + const dataHome = xdgDataHome || join(homeDir, ".local", "share"); + + switch (shellType) { + case "bash": + // bash-completion user directory + return join(dataHome, "bash-completion", "completions", "sentry"); + + case "zsh": + // Site-functions in user's local share + return join(dataHome, "zsh", "site-functions", "_sentry"); + + case "fish": + // Fish completions directory + return join(homeDir, ".config", "fish", "completions", "sentry.fish"); + + default: + return null; + } +} + +/** + * Install completion script for a shell type. + * + * @param shellType - The shell type + * @param homeDir - User's home directory + * @param xdgDataHome - XDG_DATA_HOME or undefined for default + * @returns Location info if installed, null if shell not supported + */ +export async function installCompletions( + shellType: ShellType, + homeDir: string, + xdgDataHome?: string +): Promise { + const script = getCompletionScript(shellType); + if (!script) { + return null; + } + + const path = getCompletionPath(shellType, homeDir, xdgDataHome); + if (!path) { + return null; + } + + // Create directory if needed + const dir = dirname(path); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o755 }); + } + + const alreadyExists = existsSync(path); + await Bun.write(path, script); + + return { + path, + created: !alreadyExists, + }; +} diff --git a/src/lib/shell.ts b/src/lib/shell.ts new file mode 100644 index 00000000..6ed934a4 --- /dev/null +++ b/src/lib/shell.ts @@ -0,0 +1,268 @@ +/** + * Shell detection and configuration utilities. + * + * Provides functions for detecting the current shell, finding config files, + * and modifying PATH in shell configuration. + */ + +import { existsSync } from "node:fs"; +import { basename, join } from "node:path"; + +/** Supported shell types */ +export type ShellType = "bash" | "zsh" | "fish" | "sh" | "ash" | "unknown"; + +/** Result of shell detection */ +export type ShellInfo = { + /** Detected shell type */ + type: ShellType; + /** Path to shell config file, if found */ + configFile: string | null; + /** All candidate config files for this shell */ + configCandidates: string[]; +}; + +/** Result of PATH modification */ +export type PathModificationResult = { + /** Whether modification was performed */ + modified: boolean; + /** The config file that was modified, if any */ + configFile: string | null; + /** Message describing what happened */ + message: string; + /** Command to add manually if auto-modification failed */ + manualCommand: string | null; +}; + +/** + * Detect the current shell from SHELL environment variable. + */ +export function detectShellType(shellPath: string | undefined): ShellType { + if (!shellPath) { + return "unknown"; + } + + const shellName = basename(shellPath).toLowerCase(); + + switch (shellName) { + case "bash": + return "bash"; + case "zsh": + return "zsh"; + case "fish": + return "fish"; + case "sh": + return "sh"; + case "ash": + return "ash"; + default: + return "unknown"; + } +} + +/** + * Get candidate config files for a shell type. + * + * @param shellType - The shell type + * @param homeDir - User's home directory + * @param xdgConfigHome - XDG_CONFIG_HOME or default + */ +export function getConfigCandidates( + shellType: ShellType, + homeDir: string, + xdgConfigHome?: string +): string[] { + const xdg = xdgConfigHome || join(homeDir, ".config"); + + switch (shellType) { + case "fish": + return [join(xdg, "fish", "config.fish")]; + + case "zsh": + return [ + join(homeDir, ".zshrc"), + join(homeDir, ".zshenv"), + join(xdg, "zsh", ".zshrc"), + join(xdg, "zsh", ".zshenv"), + ]; + + case "bash": + return [ + join(homeDir, ".bashrc"), + join(homeDir, ".bash_profile"), + join(homeDir, ".profile"), + join(xdg, "bash", ".bashrc"), + join(xdg, "bash", ".bash_profile"), + ]; + + case "sh": + case "ash": + return [join(homeDir, ".profile")]; + + default: + // Fall back to common files for unknown shells + return [ + join(homeDir, ".bashrc"), + join(homeDir, ".bash_profile"), + join(homeDir, ".profile"), + ]; + } +} + +/** + * Find the first existing config file from candidates. + */ +export function findExistingConfigFile(candidates: string[]): string | null { + for (const file of candidates) { + if (existsSync(file)) { + return file; + } + } + return null; +} + +/** + * Detect shell and find config file. + */ +export function detectShell( + shellPath: string | undefined, + homeDir: string, + xdgConfigHome?: string +): ShellInfo { + const type = detectShellType(shellPath); + const configCandidates = getConfigCandidates(type, homeDir, xdgConfigHome); + const configFile = findExistingConfigFile(configCandidates); + + return { + type, + configFile, + configCandidates, + }; +} + +/** + * Generate the PATH export command for a shell. + */ +export function getPathCommand( + shellType: ShellType, + directory: string +): string { + if (shellType === "fish") { + return `fish_add_path "${directory}"`; + } + return `export PATH="${directory}:$PATH"`; +} + +/** + * Check if a directory is in PATH. + */ +export function isInPath( + directory: string, + pathEnv: string | undefined +): boolean { + if (!pathEnv) { + return false; + } + const paths = pathEnv.split(":"); + return paths.includes(directory); +} + +/** + * Add a directory to PATH in a shell config file. + * + * @param configFile - Path to the config file + * @param directory - Directory to add to PATH + * @param shellType - The shell type (for correct syntax) + * @returns Result of the modification attempt + */ +export async function addToPath( + configFile: string, + directory: string, + shellType: ShellType +): Promise { + const pathCommand = getPathCommand(shellType, directory); + + // Read current content + const file = Bun.file(configFile); + const exists = await file.exists(); + + if (!exists) { + // Create the file with the PATH command + try { + await Bun.write(configFile, `# sentry\n${pathCommand}\n`); + return { + modified: true, + configFile, + message: `Created ${configFile} with PATH configuration`, + manualCommand: null, + }; + } catch { + return { + modified: false, + configFile: null, + message: `Could not create ${configFile}`, + manualCommand: pathCommand, + }; + } + } + + const content = await file.text(); + + // Check if already configured + if (content.includes(pathCommand) || content.includes(`"${directory}"`)) { + return { + modified: false, + configFile, + message: `PATH already configured in ${configFile}`, + manualCommand: null, + }; + } + + // Append to file + try { + const newContent = content.endsWith("\n") + ? `${content}\n# sentry\n${pathCommand}\n` + : `${content}\n\n# sentry\n${pathCommand}\n`; + + await Bun.write(configFile, newContent); + return { + modified: true, + configFile, + message: `Added sentry to PATH in ${configFile}`, + manualCommand: null, + }; + } catch { + return { + modified: false, + configFile: null, + message: `Could not write to ${configFile}`, + manualCommand: pathCommand, + }; + } +} + +/** + * Add to GitHub Actions PATH if running in CI. + */ +export async function addToGitHubPath( + directory: string, + env: NodeJS.ProcessEnv +): Promise { + if (env.GITHUB_ACTIONS !== "true" || !env.GITHUB_PATH) { + return false; + } + + try { + const file = Bun.file(env.GITHUB_PATH); + const content = (await file.exists()) ? await file.text() : ""; + + if (!content.includes(directory)) { + const newContent = content.endsWith("\n") + ? `${content}${directory}\n` + : `${content}\n${directory}\n`; + await Bun.write(env.GITHUB_PATH, newContent); + } + return true; + } catch { + return false; + } +} diff --git a/test/lib/completions.test.ts b/test/lib/completions.test.ts new file mode 100644 index 00000000..1050c040 --- /dev/null +++ b/test/lib/completions.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { + generateBashCompletion, + generateFishCompletion, + generateZshCompletion, + getCompletionPath, + getCompletionScript, + installCompletions, +} from "../../src/lib/completions.js"; + +describe("completions", () => { + describe("generateBashCompletion", () => { + test("generates valid bash completion script", () => { + const script = generateBashCompletion("sentry"); + + expect(script).toContain("_sentry_completions()"); + expect(script).toContain("complete -F _sentry_completions sentry"); + expect(script).toContain("auth"); + expect(script).toContain("issue"); + expect(script).toContain("cli"); + }); + + test("uses custom binary name", () => { + const script = generateBashCompletion("my-cli"); + + expect(script).toContain("_my-cli_completions()"); + expect(script).toContain("complete -F _my-cli_completions my-cli"); + }); + }); + + describe("generateZshCompletion", () => { + test("generates valid zsh completion script", () => { + const script = generateZshCompletion("sentry"); + + expect(script).toContain("#compdef sentry"); + expect(script).toContain("_sentry()"); + expect(script).toContain("'auth:Authentication commands'"); + expect(script).toContain("'issue:Issue-related commands'"); + }); + }); + + describe("generateFishCompletion", () => { + test("generates valid fish completion script", () => { + const script = generateFishCompletion("sentry"); + + expect(script).toContain("complete -c sentry"); + expect(script).toContain('__fish_use_subcommand" -a "auth"'); + expect(script).toContain('__fish_seen_subcommand_from auth" -a "login"'); + }); + }); + + describe("getCompletionScript", () => { + test("returns bash script for bash", () => { + const script = getCompletionScript("bash"); + expect(script).toContain("_sentry_completions"); + }); + + test("returns zsh script for zsh", () => { + const script = getCompletionScript("zsh"); + expect(script).toContain("#compdef sentry"); + }); + + test("returns fish script for fish", () => { + const script = getCompletionScript("fish"); + expect(script).toContain("complete -c sentry"); + }); + + test("returns null for unsupported shells", () => { + expect(getCompletionScript("sh")).toBeNull(); + expect(getCompletionScript("ash")).toBeNull(); + expect(getCompletionScript("unknown")).toBeNull(); + }); + }); + + describe("getCompletionPath", () => { + const homeDir = "/home/user"; + + test("returns bash completion path", () => { + const path = getCompletionPath("bash", homeDir); + expect(path).toBe( + "/home/user/.local/share/bash-completion/completions/sentry" + ); + }); + + test("returns zsh completion path", () => { + const path = getCompletionPath("zsh", homeDir); + expect(path).toBe("/home/user/.local/share/zsh/site-functions/_sentry"); + }); + + test("returns fish completion path", () => { + const path = getCompletionPath("fish", homeDir); + expect(path).toBe("/home/user/.config/fish/completions/sentry.fish"); + }); + + test("uses custom XDG_DATA_HOME", () => { + const path = getCompletionPath("bash", homeDir, "/custom/data"); + expect(path).toBe("/custom/data/bash-completion/completions/sentry"); + }); + + test("returns null for unsupported shells", () => { + expect(getCompletionPath("sh", homeDir)).toBeNull(); + expect(getCompletionPath("unknown", homeDir)).toBeNull(); + }); + }); + + describe("installCompletions", () => { + let testDir: string; + + beforeEach(() => { + testDir = join( + "/tmp", + `completions-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + test("installs bash completions", async () => { + const result = await installCompletions("bash", testDir); + + expect(result).not.toBeNull(); + expect(result!.created).toBe(true); + expect(result!.path).toContain("bash-completion"); + expect(existsSync(result!.path)).toBe(true); + + const content = await Bun.file(result!.path).text(); + expect(content).toContain("_sentry_completions"); + }); + + test("installs zsh completions", async () => { + const result = await installCompletions("zsh", testDir); + + expect(result).not.toBeNull(); + expect(result!.path).toContain("_sentry"); + expect(existsSync(result!.path)).toBe(true); + }); + + test("installs fish completions", async () => { + // Fish uses ~/.config/fish, so we need to create the structure + const fishDir = join(testDir, ".config", "fish", "completions"); + mkdirSync(fishDir, { recursive: true }); + + const result = await installCompletions("fish", testDir); + + expect(result).not.toBeNull(); + expect(result!.path).toContain("sentry.fish"); + }); + + test("returns null for unsupported shells", async () => { + const result = await installCompletions("sh", testDir); + expect(result).toBeNull(); + }); + + test("reports update when file already exists", async () => { + // Install once + const first = await installCompletions("bash", testDir); + expect(first!.created).toBe(true); + + // Install again + const second = await installCompletions("bash", testDir); + expect(second!.created).toBe(false); + expect(second!.path).toBe(first!.path); + }); + }); +}); diff --git a/test/lib/shell.test.ts b/test/lib/shell.test.ts new file mode 100644 index 00000000..cfdb4148 --- /dev/null +++ b/test/lib/shell.test.ts @@ -0,0 +1,259 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { + addToPath, + detectShell, + detectShellType, + findExistingConfigFile, + getConfigCandidates, + getPathCommand, + isInPath, +} from "../../src/lib/shell.js"; + +describe("shell utilities", () => { + describe("detectShellType", () => { + test("detects bash", () => { + expect(detectShellType("/bin/bash")).toBe("bash"); + expect(detectShellType("/usr/bin/bash")).toBe("bash"); + }); + + test("detects zsh", () => { + expect(detectShellType("/bin/zsh")).toBe("zsh"); + expect(detectShellType("/usr/local/bin/zsh")).toBe("zsh"); + }); + + test("detects fish", () => { + expect(detectShellType("/usr/bin/fish")).toBe("fish"); + }); + + test("detects sh", () => { + expect(detectShellType("/bin/sh")).toBe("sh"); + }); + + test("detects ash", () => { + expect(detectShellType("/bin/ash")).toBe("ash"); + }); + + test("returns unknown for unrecognized shells", () => { + expect(detectShellType("/bin/tcsh")).toBe("unknown"); + expect(detectShellType("/bin/csh")).toBe("unknown"); + }); + + test("returns unknown for undefined", () => { + expect(detectShellType(undefined)).toBe("unknown"); + }); + }); + + describe("getConfigCandidates", () => { + const homeDir = "/home/user"; + const xdgConfigHome = "/home/user/.config"; + + test("returns bash config candidates", () => { + const candidates = getConfigCandidates("bash", homeDir, xdgConfigHome); + expect(candidates).toContain("/home/user/.bashrc"); + expect(candidates).toContain("/home/user/.bash_profile"); + expect(candidates).toContain("/home/user/.profile"); + }); + + test("returns zsh config candidates", () => { + const candidates = getConfigCandidates("zsh", homeDir, xdgConfigHome); + expect(candidates).toContain("/home/user/.zshrc"); + expect(candidates).toContain("/home/user/.zshenv"); + }); + + test("returns fish config candidates", () => { + const candidates = getConfigCandidates("fish", homeDir, xdgConfigHome); + expect(candidates).toContain("/home/user/.config/fish/config.fish"); + }); + + test("returns profile for sh", () => { + const candidates = getConfigCandidates("sh", homeDir, xdgConfigHome); + expect(candidates).toContain("/home/user/.profile"); + }); + + test("uses default XDG_CONFIG_HOME when not provided", () => { + const candidates = getConfigCandidates("fish", homeDir); + expect(candidates).toContain("/home/user/.config/fish/config.fish"); + }); + }); + + describe("findExistingConfigFile", () => { + let testDir: string; + + beforeEach(() => { + testDir = join( + "/tmp", + `shell-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + test("returns first existing file", () => { + const file1 = join(testDir, ".bashrc"); + const file2 = join(testDir, ".bash_profile"); + writeFileSync(file2, "# bash profile"); + + const result = findExistingConfigFile([file1, file2]); + expect(result).toBe(file2); + }); + + test("returns null when no files exist", () => { + const result = findExistingConfigFile([ + join(testDir, ".nonexistent1"), + join(testDir, ".nonexistent2"), + ]); + expect(result).toBeNull(); + }); + }); + + describe("detectShell", () => { + let testDir: string; + + beforeEach(() => { + testDir = join( + "/tmp", + `shell-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + test("detects shell type and finds config file", () => { + const zshrc = join(testDir, ".zshrc"); + writeFileSync(zshrc, "# zshrc"); + + const result = detectShell("/bin/zsh", testDir); + expect(result.type).toBe("zsh"); + expect(result.configFile).toBe(zshrc); + }); + + test("returns null configFile when none exist", () => { + const result = detectShell("/bin/zsh", testDir); + expect(result.type).toBe("zsh"); + expect(result.configFile).toBeNull(); + }); + }); + + describe("getPathCommand", () => { + test("returns fish command for fish shell", () => { + const cmd = getPathCommand("fish", "/home/user/.local/bin"); + expect(cmd).toBe('fish_add_path "/home/user/.local/bin"'); + }); + + test("returns export command for other shells", () => { + expect(getPathCommand("bash", "/home/user/.local/bin")).toBe( + 'export PATH="/home/user/.local/bin:$PATH"' + ); + expect(getPathCommand("zsh", "/home/user/.local/bin")).toBe( + 'export PATH="/home/user/.local/bin:$PATH"' + ); + expect(getPathCommand("sh", "/home/user/.local/bin")).toBe( + 'export PATH="/home/user/.local/bin:$PATH"' + ); + }); + }); + + describe("isInPath", () => { + test("returns true when directory is in PATH", () => { + const path = "/usr/bin:/home/user/.local/bin:/bin"; + expect(isInPath("/home/user/.local/bin", path)).toBe(true); + }); + + test("returns false when directory is not in PATH", () => { + const path = "/usr/bin:/bin"; + expect(isInPath("/home/user/.local/bin", path)).toBe(false); + }); + + test("returns false for undefined PATH", () => { + expect(isInPath("/home/user/.local/bin", undefined)).toBe(false); + }); + }); + + describe("addToPath", () => { + let testDir: string; + + beforeEach(() => { + testDir = join( + "/tmp", + `shell-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + test("creates config file if it doesn't exist", async () => { + const configFile = join(testDir, ".bashrc"); + const result = await addToPath( + configFile, + "/home/user/.sentry/bin", + "bash" + ); + + expect(result.modified).toBe(true); + expect(result.configFile).toBe(configFile); + + const content = await Bun.file(configFile).text(); + expect(content).toContain('export PATH="/home/user/.sentry/bin:$PATH"'); + }); + + test("appends to existing config file", async () => { + const configFile = join(testDir, ".bashrc"); + writeFileSync(configFile, "# existing content\n"); + + const result = await addToPath( + configFile, + "/home/user/.sentry/bin", + "bash" + ); + + expect(result.modified).toBe(true); + + const content = await Bun.file(configFile).text(); + expect(content).toContain("# existing content"); + expect(content).toContain("# sentry"); + expect(content).toContain('export PATH="/home/user/.sentry/bin:$PATH"'); + }); + + test("skips if already configured", async () => { + const configFile = join(testDir, ".bashrc"); + writeFileSync( + configFile, + '# sentry\nexport PATH="/home/user/.sentry/bin:$PATH"\n' + ); + + const result = await addToPath( + configFile, + "/home/user/.sentry/bin", + "bash" + ); + + expect(result.modified).toBe(false); + expect(result.message).toContain("already configured"); + }); + + test("uses fish syntax for fish shell", async () => { + const configFile = join(testDir, "config.fish"); + const result = await addToPath( + configFile, + "/home/user/.sentry/bin", + "fish" + ); + + expect(result.modified).toBe(true); + + const content = await Bun.file(configFile).text(); + expect(content).toContain('fish_add_path "/home/user/.sentry/bin"'); + }); + }); +}); From f2137a516ca9fb2a1ac0a645585c24665a6683fc Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 6 Feb 2026 16:34:50 +0000 Subject: [PATCH 03/10] refactor(cli): remove redundant record-install command The setup command now handles recording install info via --method flag, making record-install redundant. Removed to simplify the codebase. --- src/commands/cli/index.ts | 2 - src/commands/cli/record-install.ts | 64 ------------------------ src/lib/upgrade.ts | 2 +- test/commands/cli.test.ts | 80 ------------------------------ 4 files changed, 1 insertion(+), 147 deletions(-) delete mode 100644 src/commands/cli/record-install.ts diff --git a/src/commands/cli/index.ts b/src/commands/cli/index.ts index 3d3a19a9..7d72f63d 100644 --- a/src/commands/cli/index.ts +++ b/src/commands/cli/index.ts @@ -1,7 +1,6 @@ import { buildRouteMap } from "@stricli/core"; import { feedbackCommand } from "./feedback.js"; import { fixCommand } from "./fix.js"; -import { recordInstallCommand } from "./record-install.js"; import { setupCommand } from "./setup.js"; import { upgradeCommand } from "./upgrade.js"; @@ -9,7 +8,6 @@ export const cliRoute = buildRouteMap({ routes: { feedback: feedbackCommand, fix: fixCommand, - "record-install": recordInstallCommand, setup: setupCommand, upgrade: upgradeCommand, }, diff --git a/src/commands/cli/record-install.ts b/src/commands/cli/record-install.ts deleted file mode 100644 index 5c8bb373..00000000 --- a/src/commands/cli/record-install.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * sentry cli record-install - * - * Record installation metadata for use by upgrade command. - * This is typically called automatically by installation scripts - * and should not need to be run manually. - */ - -import { buildCommand } from "@stricli/core"; -import type { SentryContext } from "../../context.js"; -import { CLI_VERSION } from "../../lib/constants.js"; -import { setInstallInfo } from "../../lib/db/install-info.js"; -import { - type InstallationMethod, - parseInstallationMethod, -} from "../../lib/upgrade.js"; - -type RecordInstallFlags = { - readonly method: InstallationMethod; - readonly path?: string; -}; - -export const recordInstallCommand = buildCommand({ - docs: { - brief: "Record installation metadata (used by installers)", - fullDescription: - "Records how this CLI was installed. This is typically called automatically\n" + - "by installation scripts (curl, package managers) and should not need to be\n" + - "run manually.\n\n" + - "The recorded information is used by 'sentry cli upgrade' to determine\n" + - "the appropriate upgrade method without re-detecting every time.\n\n" + - "Examples:\n" + - " sentry cli record-install --method curl\n" + - " sentry cli record-install --method npm --path /usr/local/bin/sentry", - }, - parameters: { - flags: { - method: { - kind: "parsed", - parse: parseInstallationMethod, - brief: "Installation method (curl, npm, pnpm, bun, yarn)", - placeholder: "method", - }, - path: { - kind: "parsed", - parse: String, - brief: "Binary path (defaults to current executable)", - optional: true, - placeholder: "path", - }, - }, - }, - func(this: SentryContext, flags: RecordInstallFlags): void { - const installPath = flags.path ?? this.process.execPath; - - setInstallInfo({ - method: flags.method, - path: installPath, - version: CLI_VERSION, - }); - - // Silent success for scripted usage - installers don't need output - }, -}); diff --git a/src/lib/upgrade.ts b/src/lib/upgrade.ts index 86cb52ef..9d7e7fd8 100644 --- a/src/lib/upgrade.ts +++ b/src/lib/upgrade.ts @@ -357,7 +357,7 @@ export async function detectInstallationMethod(): Promise { return stored.method; } - // 2. Legacy detection for existing installs (pre-record-install) + // 2. Legacy detection for existing installs (pre-setup command) const legacyMethod = await detectLegacyInstallationMethod(); // 3. Auto-save detected method for future runs diff --git a/test/commands/cli.test.ts b/test/commands/cli.test.ts index aaccda9e..db7eeaa5 100644 --- a/test/commands/cli.test.ts +++ b/test/commands/cli.test.ts @@ -6,11 +6,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { feedbackCommand } from "../../src/commands/cli/feedback.js"; -import { recordInstallCommand } from "../../src/commands/cli/record-install.js"; import { upgradeCommand } from "../../src/commands/cli/upgrade.js"; -import { closeDatabase } from "../../src/lib/db/index.js"; -import { getInstallInfo } from "../../src/lib/db/install-info.js"; -import { cleanupTestDir, createTestConfigDir } from "../helpers.js"; describe("feedbackCommand.func", () => { test("throws ValidationError for empty message", async () => { @@ -199,79 +195,3 @@ describe("upgradeCommand.func", () => { ).rejects.toThrow("Version 999.0.0 not found"); }); }); - -// Test the record-install command func -describe("recordInstallCommand.func", () => { - let testConfigDir: string; - - beforeEach(async () => { - testConfigDir = await createTestConfigDir("test-record-install-"); - process.env.SENTRY_CONFIG_DIR = testConfigDir; - }); - - afterEach(async () => { - closeDatabase(); - delete process.env.SENTRY_CONFIG_DIR; - await cleanupTestDir(testConfigDir); - }); - - test("records curl install info", async () => { - const func = await recordInstallCommand.loader(); - const mockContext = { - process: { execPath: "/home/user/.local/bin/sentry" }, - stdout: { write: mock(() => true) }, - stderr: { write: mock(() => true) }, - }; - - func.call(mockContext, { method: "curl" }); - - const info = getInstallInfo(); - expect(info?.method).toBe("curl"); - expect(info?.path).toBe("/home/user/.local/bin/sentry"); - }); - - test("records npm install info", async () => { - const func = await recordInstallCommand.loader(); - const mockContext = { - process: { execPath: "/usr/local/bin/sentry" }, - stdout: { write: mock(() => true) }, - stderr: { write: mock(() => true) }, - }; - - func.call(mockContext, { method: "npm" }); - - const info = getInstallInfo(); - expect(info?.method).toBe("npm"); - }); - - test("uses provided path over execPath", async () => { - const func = await recordInstallCommand.loader(); - const mockContext = { - process: { execPath: "/default/path" }, - stdout: { write: mock(() => true) }, - stderr: { write: mock(() => true) }, - }; - - func.call(mockContext, { method: "curl", path: "/custom/path/sentry" }); - - const info = getInstallInfo(); - expect(info?.path).toBe("/custom/path/sentry"); - }); - - test("overwrites existing install info", async () => { - const func = await recordInstallCommand.loader(); - const mockContext = { - process: { execPath: "/path1" }, - stdout: { write: mock(() => true) }, - stderr: { write: mock(() => true) }, - }; - - func.call(mockContext, { method: "curl" }); - expect(getInstallInfo()?.method).toBe("curl"); - - mockContext.process.execPath = "/path2"; - func.call(mockContext, { method: "npm" }); - expect(getInstallInfo()?.method).toBe("npm"); - expect(getInstallInfo()?.path).toBe("/path2"); - }); -}); From b2ea784ea3e14ba37bf5e9b2da40eaa6fe9a68cd Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 6 Feb 2026 16:51:22 +0000 Subject: [PATCH 04/10] chore: regenerate SKILL.md for setup command --- plugins/sentry-cli/skills/sentry-cli/SKILL.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 61c713d9..677f3057 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -402,6 +402,16 @@ Diagnose and repair CLI database issues **Flags:** - `--dry-run - Show what would be fixed without making changes` +#### `sentry cli setup` + +Configure shell integration + +**Flags:** +- `--method - Installation method (curl, npm, pnpm, bun, yarn)` +- `--noModifyPath - Skip PATH modification` +- `--noCompletions - Skip shell completion installation` +- `--quiet - Suppress output (for scripted usage)` + #### `sentry cli upgrade ` Update the Sentry CLI to the latest version From faedb9e328b748a715659651ea48d12b661470c4 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 6 Feb 2026 16:59:32 +0000 Subject: [PATCH 05/10] fix(test): compute paths fresh in upgrade tests to avoid stale DB state The executeUpgrade and cleanupOldBinary tests were failing in CI because paths were computed once at module load time, potentially capturing stale database state from previous test runs. --- test/lib/upgrade.test.ts | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/test/lib/upgrade.test.ts b/test/lib/upgrade.test.ts index 1da25de5..d38b4667 100644 --- a/test/lib/upgrade.test.ts +++ b/test/lib/upgrade.test.ts @@ -9,6 +9,7 @@ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { unlink } from "node:fs/promises"; import { homedir, platform } from "node:os"; import { join } from "node:path"; +import { clearInstallInfo } from "../../src/lib/db/install-info.js"; import { UpgradeError } from "../../src/lib/errors.js"; import { acquireUpgradeLock, @@ -627,16 +628,23 @@ describe("releaseUpgradeLock", () => { describe("executeUpgrade with curl method", () => { const binDir = join(homedir(), ".sentry", "bin"); - const paths = getCurlInstallPaths(); + + // Compute paths fresh for each test to avoid stale database state issues + function getTestPaths() { + return getCurlInstallPaths(); + } beforeEach(() => { + // Clear any stored install info to ensure we use default paths + clearInstallInfo(); // Ensure directory exists mkdirSync(binDir, { recursive: true }); }); afterEach(async () => { globalThis.fetch = originalFetch; - // Clean up test files + // Clean up test files - get fresh paths in case DB changed + const paths = getTestPaths(); for (const path of [ paths.installPath, paths.tempPath, @@ -660,7 +668,8 @@ describe("executeUpgrade with curl method", () => { // Run the actual executeUpgrade with curl method await executeUpgrade("curl", "1.0.0"); - // Verify the binary was installed + // Verify the binary was installed - get paths fresh after upgrade + const paths = getTestPaths(); expect(await Bun.file(paths.installPath).exists()).toBe(true); const content = await Bun.file(paths.installPath).arrayBuffer(); expect(new Uint8Array(content)).toEqual(mockBinaryContent); @@ -699,6 +708,7 @@ describe("executeUpgrade with curl method", () => { await executeUpgrade("curl", "1.0.0"); // Temp file should not exist after successful install + const paths = getTestPaths(); expect(await Bun.file(paths.tempPath).exists()).toBe(false); }); @@ -712,18 +722,26 @@ describe("executeUpgrade with curl method", () => { } // Lock should be released even on failure + const paths = getTestPaths(); expect(await Bun.file(paths.lockPath).exists()).toBe(false); }); }); describe("cleanupOldBinary", () => { - const suffix = process.platform === "win32" ? ".exe" : ""; - const oldPath = join(homedir(), ".sentry", "bin", `sentry${suffix}.old`); - const binDir = join(homedir(), ".sentry", "bin"); + // Get paths fresh to match what cleanupOldBinary() uses + function getOldPath() { + return getCurlInstallPaths().oldPath; + } + + beforeEach(() => { + // Clear any stored install info to ensure we use default paths + clearInstallInfo(); + }); test("removes .old file if it exists", async () => { + const oldPath = getOldPath(); // Create the directory and file - mkdirSync(binDir, { recursive: true }); + mkdirSync(join(oldPath, ".."), { recursive: true }); writeFileSync(oldPath, "test content"); // Verify file exists From 2e830bfe9f9ff38b244bae62b3ef8077ccc8b67b Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 9 Feb 2026 10:26:43 +0000 Subject: [PATCH 06/10] feat(telemetry): auto-capture command flags as Sentry tags Add buildCommand wrapper that automatically captures flag values as telemetry tags (e.g., flag.verbose, flag.no-modify-path). Commands just need to import buildCommand from lib/command.js instead of @stricli/core - no other changes needed. - Add setFlagContext() helper in telemetry.ts - Add buildCommand wrapper in new lib/command.ts - Update all 19 commands to use the wrapper --- src/commands/auth/login.ts | 2 +- src/commands/auth/logout.ts | 2 +- src/commands/auth/refresh.ts | 2 +- src/commands/auth/status.ts | 2 +- src/commands/auth/token.ts | 2 +- src/commands/cli/feedback.ts | 2 +- src/commands/cli/fix.ts | 2 +- src/commands/cli/setup.ts | 2 +- src/commands/cli/upgrade.ts | 2 +- src/commands/event/view.ts | 2 +- src/commands/issue/explain.ts | 2 +- src/commands/issue/list.ts | 2 +- src/commands/issue/plan.ts | 2 +- src/commands/issue/view.ts | 2 +- src/commands/log/list.ts | 2 +- src/commands/org/list.ts | 2 +- src/commands/org/view.ts | 2 +- src/commands/project/list.ts | 2 +- src/commands/project/view.ts | 2 +- src/lib/command.ts | 95 +++++++++++++++++++++++++++++++++++ src/lib/telemetry.ts | 56 +++++++++++++++++++++ test/lib/command.test.ts | 85 +++++++++++++++++++++++++++++++ test/lib/telemetry.test.ts | 72 ++++++++++++++++++++++++++ 23 files changed, 327 insertions(+), 19 deletions(-) create mode 100644 src/lib/command.ts create mode 100644 test/lib/command.test.ts diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 4c960b64..d33c67a5 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -1,6 +1,6 @@ -import { buildCommand, numberParser } from "@stricli/core"; import type { SentryContext } from "../../context.js"; import { getCurrentUser, getUserRegions } from "../../lib/api-client.js"; +import { buildCommand, numberParser } from "../../lib/command.js"; import { clearAuth, isAuthenticated, setAuthToken } from "../../lib/db/auth.js"; import { getDbPath } from "../../lib/db/index.js"; import { setUserInfo } from "../../lib/db/user.js"; diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts index 66236367..ea58a00a 100644 --- a/src/commands/auth/logout.ts +++ b/src/commands/auth/logout.ts @@ -4,8 +4,8 @@ * Clear stored authentication credentials. */ -import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; +import { buildCommand } from "../../lib/command.js"; import { clearAuth, isAuthenticated } from "../../lib/db/auth.js"; import { getDbPath } from "../../lib/db/index.js"; import { success } from "../../lib/formatters/colors.js"; diff --git a/src/commands/auth/refresh.ts b/src/commands/auth/refresh.ts index ab400db1..776f22c6 100644 --- a/src/commands/auth/refresh.ts +++ b/src/commands/auth/refresh.ts @@ -4,8 +4,8 @@ * Manually refresh the authentication token. */ -import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; +import { buildCommand } from "../../lib/command.js"; import { getAuthConfig, refreshToken } from "../../lib/db/auth.js"; import { AuthError } from "../../lib/errors.js"; import { success } from "../../lib/formatters/colors.js"; diff --git a/src/commands/auth/status.ts b/src/commands/auth/status.ts index f8052a09..72e4bf0d 100644 --- a/src/commands/auth/status.ts +++ b/src/commands/auth/status.ts @@ -4,9 +4,9 @@ * Display authentication status and verify credentials. */ -import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; import { listOrganizations } from "../../lib/api-client.js"; +import { buildCommand } from "../../lib/command.js"; import { type AuthConfig, getAuthConfig, diff --git a/src/commands/auth/token.ts b/src/commands/auth/token.ts index ff19675f..30b4a8e2 100644 --- a/src/commands/auth/token.ts +++ b/src/commands/auth/token.ts @@ -5,8 +5,8 @@ * Useful for piping to other commands or scripts. */ -import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; +import { buildCommand } from "../../lib/command.js"; import { getAuthToken } from "../../lib/db/auth.js"; import { AuthError } from "../../lib/errors.js"; diff --git a/src/commands/cli/feedback.ts b/src/commands/cli/feedback.ts index 6173d445..615f1385 100644 --- a/src/commands/cli/feedback.ts +++ b/src/commands/cli/feedback.ts @@ -10,8 +10,8 @@ // biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import import * as Sentry from "@sentry/bun"; -import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; +import { buildCommand } from "../../lib/command.js"; import { ValidationError } from "../../lib/errors.js"; export const feedbackCommand = buildCommand({ diff --git a/src/commands/cli/fix.ts b/src/commands/cli/fix.ts index dd68d1bc..c0c2ca7a 100644 --- a/src/commands/cli/fix.ts +++ b/src/commands/cli/fix.ts @@ -4,8 +4,8 @@ * Diagnose and repair CLI database issues. */ -import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; +import { buildCommand } from "../../lib/command.js"; import { getDbPath, getRawDatabase } from "../../lib/db/index.js"; import { CURRENT_SCHEMA_VERSION, diff --git a/src/commands/cli/setup.ts b/src/commands/cli/setup.ts index 548e45f9..57ef9bc7 100644 --- a/src/commands/cli/setup.ts +++ b/src/commands/cli/setup.ts @@ -6,8 +6,8 @@ */ import { dirname } from "node:path"; -import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; +import { buildCommand } from "../../lib/command.js"; import { installCompletions } from "../../lib/completions.js"; import { CLI_VERSION } from "../../lib/constants.js"; import { setInstallInfo } from "../../lib/db/install-info.js"; diff --git a/src/commands/cli/upgrade.ts b/src/commands/cli/upgrade.ts index 25cd4b6b..3b106ac9 100644 --- a/src/commands/cli/upgrade.ts +++ b/src/commands/cli/upgrade.ts @@ -4,8 +4,8 @@ * Self-update the Sentry CLI to the latest or a specific version. */ -import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; +import { buildCommand } from "../../lib/command.js"; import { CLI_VERSION } from "../../lib/constants.js"; import { setInstallInfo } from "../../lib/db/install-info.js"; import { UpgradeError } from "../../lib/errors.js"; diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts index 5c7c0e7f..be8b273e 100644 --- a/src/commands/event/view.ts +++ b/src/commands/event/view.ts @@ -4,7 +4,6 @@ * View detailed information about a Sentry event. */ -import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; import { findProjectsBySlug, getEvent } from "../../lib/api-client.js"; import { @@ -13,6 +12,7 @@ import { spansFlag, } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; +import { buildCommand } from "../../lib/command.js"; import { ContextError, ValidationError } from "../../lib/errors.js"; import { formatEventDetails, writeJson } from "../../lib/formatters/index.js"; import { resolveOrgAndProject } from "../../lib/resolve-target.js"; diff --git a/src/commands/issue/explain.ts b/src/commands/issue/explain.ts index d51482e7..baf8a467 100644 --- a/src/commands/issue/explain.ts +++ b/src/commands/issue/explain.ts @@ -4,8 +4,8 @@ * Get root cause analysis for a Sentry issue using Seer AI. */ -import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; +import { buildCommand } from "../../lib/command.js"; import { ApiError } from "../../lib/errors.js"; import { writeFooter, writeJson } from "../../lib/formatters/index.js"; import { diff --git a/src/commands/issue/list.ts b/src/commands/issue/list.ts index 32a1641b..e1bba9b1 100644 --- a/src/commands/issue/list.ts +++ b/src/commands/issue/list.ts @@ -5,7 +5,6 @@ * Supports monorepos with multiple detected projects. */ -import { buildCommand, numberParser } from "@stricli/core"; import type { SentryContext } from "../../context.js"; import { buildOrgAwareAliases } from "../../lib/alias.js"; import { @@ -14,6 +13,7 @@ import { listProjects, } from "../../lib/api-client.js"; import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; +import { buildCommand, numberParser } from "../../lib/command.js"; import { clearProjectAliases, setProjectAliases, diff --git a/src/commands/issue/plan.ts b/src/commands/issue/plan.ts index e251a32b..48ad529f 100644 --- a/src/commands/issue/plan.ts +++ b/src/commands/issue/plan.ts @@ -5,9 +5,9 @@ * Automatically runs root cause analysis if not already done. */ -import { buildCommand, numberParser } from "@stricli/core"; import type { SentryContext } from "../../context.js"; import { triggerSolutionPlanning } from "../../lib/api-client.js"; +import { buildCommand, numberParser } from "../../lib/command.js"; import { ApiError, ValidationError } from "../../lib/errors.js"; import { muted } from "../../lib/formatters/colors.js"; import { writeJson } from "../../lib/formatters/index.js"; diff --git a/src/commands/issue/view.ts b/src/commands/issue/view.ts index 052c263c..a11b5d09 100644 --- a/src/commands/issue/view.ts +++ b/src/commands/issue/view.ts @@ -4,11 +4,11 @@ * View detailed information about a Sentry issue. */ -import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; import { getLatestEvent } from "../../lib/api-client.js"; import { spansFlag } from "../../lib/arg-parsing.js"; import { openInBrowser } from "../../lib/browser.js"; +import { buildCommand } from "../../lib/command.js"; import { formatEventDetails, formatIssueDetails, diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts index 0e18b89a..17b61562 100644 --- a/src/commands/log/list.ts +++ b/src/commands/log/list.ts @@ -7,10 +7,10 @@ // biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import import * as Sentry from "@sentry/bun"; -import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; import { findProjectsBySlug, listLogs } from "../../lib/api-client.js"; import { parseOrgProjectArg } from "../../lib/arg-parsing.js"; +import { buildCommand } from "../../lib/command.js"; import { AuthError, ContextError } from "../../lib/errors.js"; import { formatLogRow, diff --git a/src/commands/org/list.ts b/src/commands/org/list.ts index 4288cec3..b7b35cbe 100644 --- a/src/commands/org/list.ts +++ b/src/commands/org/list.ts @@ -4,9 +4,9 @@ * List organizations the user has access to. */ -import { buildCommand, numberParser } from "@stricli/core"; import type { SentryContext } from "../../context.js"; import { listOrganizations } from "../../lib/api-client.js"; +import { buildCommand, numberParser } from "../../lib/command.js"; import { DEFAULT_SENTRY_HOST } from "../../lib/constants.js"; import { getAllOrgRegions } from "../../lib/db/regions.js"; import { diff --git a/src/commands/org/view.ts b/src/commands/org/view.ts index d1117dff..8a7c39e6 100644 --- a/src/commands/org/view.ts +++ b/src/commands/org/view.ts @@ -4,10 +4,10 @@ * View detailed information about a Sentry organization. */ -import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; import { getOrganization } from "../../lib/api-client.js"; import { openInBrowser } from "../../lib/browser.js"; +import { buildCommand } from "../../lib/command.js"; import { ContextError } from "../../lib/errors.js"; import { formatOrgDetails, writeOutput } from "../../lib/formatters/index.js"; import { resolveOrg } from "../../lib/resolve-target.js"; diff --git a/src/commands/project/list.ts b/src/commands/project/list.ts index 39aef7bc..b22aa124 100644 --- a/src/commands/project/list.ts +++ b/src/commands/project/list.ts @@ -4,9 +4,9 @@ * List projects in an organization. */ -import { buildCommand, numberParser } from "@stricli/core"; import type { SentryContext } from "../../context.js"; import { listOrganizations, listProjects } from "../../lib/api-client.js"; +import { buildCommand, numberParser } from "../../lib/command.js"; import { getDefaultOrganization } from "../../lib/db/defaults.js"; import { AuthError } from "../../lib/errors.js"; import { diff --git a/src/commands/project/view.ts b/src/commands/project/view.ts index edd9ca5b..8c5208e7 100644 --- a/src/commands/project/view.ts +++ b/src/commands/project/view.ts @@ -5,10 +5,10 @@ * Supports monorepos with multiple detected projects. */ -import { buildCommand } from "@stricli/core"; import type { SentryContext } from "../../context.js"; import { getProject, getProjectKeys } from "../../lib/api-client.js"; import { openInBrowser } from "../../lib/browser.js"; +import { buildCommand } from "../../lib/command.js"; import { AuthError, ContextError } from "../../lib/errors.js"; import { divider, diff --git a/src/lib/command.ts b/src/lib/command.ts new file mode 100644 index 00000000..e89736bd --- /dev/null +++ b/src/lib/command.ts @@ -0,0 +1,95 @@ +/** + * Command Builder with Telemetry + * + * Wraps Stricli's buildCommand to automatically capture flag usage for telemetry. + * Commands should import buildCommand from this module instead of @stricli/core. + */ + +import { + type Command, + type CommandContext, + type CommandFunction, + buildCommand as stricliCommand, + numberParser as stricliNumberParser, +} from "@stricli/core"; +import { setFlagContext } from "./telemetry.js"; + +/** + * Parse a string input as a number. + * Re-exported from Stricli for convenience. + */ +export const numberParser = stricliNumberParser; + +/** Base flags type from Stricli */ +type BaseFlags = Readonly>>; + +/** Base args type from Stricli */ +type BaseArgs = readonly unknown[]; + +/** Command documentation */ +type CommandDocumentation = { + readonly brief: string; + readonly fullDescription?: string; +}; + +/** + * Arguments for building a command with a local function. + * This is the subset of Stricli's CommandBuilderArguments that we support. + */ +type LocalCommandBuilderArguments< + FLAGS extends BaseFlags, + ARGS extends BaseArgs, + CONTEXT extends CommandContext, +> = { + readonly parameters?: Record; + readonly docs: CommandDocumentation; + readonly func: CommandFunction; +}; + +/** + * Build a command with automatic flag telemetry. + * + * This is a drop-in replacement for Stricli's buildCommand that wraps the + * command function to automatically capture flag values as Sentry tags. + * + * Usage is identical to Stricli's buildCommand - just change the import: + * ```ts + * // Before: + * import { buildCommand } from "@stricli/core"; + * + * // After: + * import { buildCommand } from "../../lib/command.js"; + * ``` + * + * @param builderArgs - Same arguments as Stricli's buildCommand + * @returns A Command with automatic flag telemetry + */ +export function buildCommand< + const FLAGS extends BaseFlags = NonNullable, + const ARGS extends BaseArgs = [], + const CONTEXT extends CommandContext = CommandContext, +>( + builderArgs: LocalCommandBuilderArguments +): Command { + const originalFunc = builderArgs.func; + + // Wrap the function to capture flags before execution + const wrappedFunc = function ( + this: CONTEXT, + flags: FLAGS, + ...args: ARGS + ): ReturnType { + // Capture flag values as telemetry tags + setFlagContext(flags as Record); + + // Call the original function with the same context and arguments + return originalFunc.call(this, flags, ...args); + } as typeof originalFunc; + + // Build the command with the wrapped function + return stricliCommand({ + ...builderArgs, + func: wrappedFunc, + // biome-ignore lint/suspicious/noExplicitAny: Stricli types are complex unions + } as any); +} diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index 0cce1c48..c92a6b92 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -214,6 +214,62 @@ export function setOrgProjectContext(orgs: string[], projects: string[]): void { } } +/** + * Set command flags as telemetry tags. + * + * Converts flag names from camelCase to kebab-case and sets them as tags + * with the `flag.` prefix (e.g., `flag.no-modify-path`). + * + * Only sets tags for flags with non-default/meaningful values: + * - Boolean flags: only when true + * - String/number flags: only when defined and non-empty + * - Array flags: only when non-empty + * + * Call this at the start of command func() to instrument flag usage. + * + * @param flags - The parsed flags object from Stricli + * + * @example + * ```ts + * async func(this: SentryContext, flags: MyFlags): Promise { + * setFlagContext(flags); + * // ... command implementation + * } + * ``` + */ +export function setFlagContext(flags: Record): void { + for (const [key, value] of Object.entries(flags)) { + // Skip undefined/null values + if (value === undefined || value === null) { + continue; + } + + // Skip false booleans (default state) + if (value === false) { + continue; + } + + // Skip empty strings + if (value === "") { + continue; + } + + // Skip empty arrays + if (Array.isArray(value) && value.length === 0) { + continue; + } + + // Convert camelCase to kebab-case for consistency with CLI flag names + const kebabKey = key.replace(/([A-Z])/g, "-$1").toLowerCase(); + + // Set the tag with flag. prefix + // For booleans, just set "true"; for other types, convert to string + const tagValue = + typeof value === "boolean" ? "true" : String(value).slice(0, 200); // Truncate long values + Sentry.setTag(`flag.${kebabKey}`, tagValue); + } +} + /** * Wrap an operation with a Sentry span for tracing. * diff --git a/test/lib/command.test.ts b/test/lib/command.test.ts new file mode 100644 index 00000000..58d33d64 --- /dev/null +++ b/test/lib/command.test.ts @@ -0,0 +1,85 @@ +/** + * Command Builder Tests + * + * Tests for the buildCommand wrapper that adds automatic flag telemetry. + */ + +import { describe, expect, test } from "bun:test"; +import { buildCommand } from "../../src/lib/command.js"; + +describe("buildCommand", () => { + test("wraps command and returns a valid command object", () => { + const command = buildCommand({ + docs: { brief: "Test command" }, + parameters: { + flags: { + verbose: { kind: "boolean", brief: "Verbose output", default: false }, + limit: { + kind: "parsed", + parse: Number, + brief: "Limit", + default: "10", + }, + }, + }, + func(_flags: { verbose: boolean; limit: number }) { + // Command functions return void + }, + }); + + // The command should be built successfully + expect(command).toBeDefined(); + }); + + test("handles commands with empty parameters", () => { + const command = buildCommand({ + docs: { brief: "Simple command" }, + parameters: {}, + func() { + // No-op + }, + }); + + expect(command).toBeDefined(); + }); + + test("handles async command functions", () => { + const command = buildCommand({ + docs: { brief: "Async command" }, + parameters: { + flags: { + delay: { + kind: "parsed", + parse: Number, + brief: "Delay", + default: "1", + }, + }, + }, + async func(_flags: { delay: number }) { + await Bun.sleep(1); + }, + }); + + expect(command).toBeDefined(); + }); + + test("handles command functions that return Error", () => { + const command = buildCommand({ + docs: { brief: "Error command" }, + parameters: { + flags: { + shouldFail: { kind: "boolean", brief: "Fail", default: false }, + }, + }, + func(_flags: { shouldFail: boolean }): Error | undefined { + if (_flags.shouldFail) { + return new Error("Failed"); + } + return; + }, + }); + + expect(command).toBeDefined(); + }); +}); diff --git a/test/lib/telemetry.test.ts b/test/lib/telemetry.test.ts index 15e972be..35eb43db 100644 --- a/test/lib/telemetry.test.ts +++ b/test/lib/telemetry.test.ts @@ -10,6 +10,7 @@ import { createTracedDatabase, initSentry, setCommandSpanName, + setFlagContext, setOrgProjectContext, withDbSpan, withFsSpan, @@ -149,6 +150,77 @@ describe("setOrgProjectContext", () => { }); }); +describe("setFlagContext", () => { + test("handles empty flags object", () => { + expect(() => setFlagContext({})).not.toThrow(); + }); + + test("handles boolean flags (true sets tag)", () => { + expect(() => setFlagContext({ verbose: true, debug: true })).not.toThrow(); + }); + + test("handles boolean flags (false is skipped)", () => { + expect(() => + setFlagContext({ verbose: false, debug: false }) + ).not.toThrow(); + }); + + test("handles string flags", () => { + expect(() => + setFlagContext({ output: "json", format: "table" }) + ).not.toThrow(); + }); + + test("handles number flags", () => { + expect(() => setFlagContext({ limit: 10, offset: 0 })).not.toThrow(); + }); + + test("handles undefined and null values (skipped)", () => { + expect(() => + setFlagContext({ value: undefined, other: null }) + ).not.toThrow(); + }); + + test("handles empty string values (skipped)", () => { + expect(() => setFlagContext({ name: "" })).not.toThrow(); + }); + + test("handles empty array values (skipped)", () => { + expect(() => setFlagContext({ items: [] })).not.toThrow(); + }); + + test("handles non-empty array values", () => { + expect(() => + setFlagContext({ projects: ["proj1", "proj2"] }) + ).not.toThrow(); + }); + + test("handles mixed flag types", () => { + expect(() => + setFlagContext({ + verbose: true, + quiet: false, + limit: 50, + output: "json", + projects: ["a", "b"], + empty: "", + missing: undefined, + }) + ).not.toThrow(); + }); + + test("converts camelCase to kebab-case", () => { + // This test verifies the function doesn't throw with camelCase keys + // The actual conversion is tested implicitly - the tag would be flag.no-modify-path + expect(() => + setFlagContext({ + noModifyPath: true, + someVeryLongFlagName: "value", + }) + ).not.toThrow(); + }); +}); + describe("withHttpSpan", () => { test("executes function and returns result", async () => { const result = await withHttpSpan("GET", "/test", async () => "success"); From 67e779ea81bc4d1df5f51a2c2443529571b67421 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 9 Feb 2026 10:53:05 +0000 Subject: [PATCH 07/10] fix: address PR review comments - Use POSIX-style flags in install script (--no-modify-path, --no-completions) - Add positional args capture via setArgsContext() as Sentry context - Improve telemetry tests with spyOn to verify actual Sentry.setTag calls --- install | 4 +- src/lib/command.ts | 9 +- src/lib/telemetry.ts | 21 +++++ test/lib/telemetry.test.ts | 163 ++++++++++++++++++++++++++----------- 4 files changed, 146 insertions(+), 51 deletions(-) diff --git a/install b/install index a0f6cfbc..fa0ff9a7 100755 --- a/install +++ b/install @@ -135,10 +135,10 @@ chmod +x "$install_path" # Run setup command to configure PATH, completions, and record install info setup_args="--method curl" if [[ "$no_modify_path" == "true" ]]; then - setup_args="$setup_args --noModifyPath" + setup_args="$setup_args --no-modify-path" fi if [[ "$no_completions" == "true" ]]; then - setup_args="$setup_args --noCompletions" + setup_args="$setup_args --no-completions" fi # shellcheck disable=SC2086 diff --git a/src/lib/command.ts b/src/lib/command.ts index e89736bd..877161b1 100644 --- a/src/lib/command.ts +++ b/src/lib/command.ts @@ -12,7 +12,7 @@ import { buildCommand as stricliCommand, numberParser as stricliNumberParser, } from "@stricli/core"; -import { setFlagContext } from "./telemetry.js"; +import { setArgsContext, setFlagContext } from "./telemetry.js"; /** * Parse a string input as a number. @@ -73,7 +73,7 @@ export function buildCommand< ): Command { const originalFunc = builderArgs.func; - // Wrap the function to capture flags before execution + // Wrap the function to capture flags and args before execution const wrappedFunc = function ( this: CONTEXT, flags: FLAGS, @@ -82,6 +82,11 @@ export function buildCommand< // Capture flag values as telemetry tags setFlagContext(flags as Record); + // Capture positional arguments as context + if (args.length > 0) { + setArgsContext(args); + } + // Call the original function with the same context and arguments return originalFunc.call(this, flags, ...args); } as typeof originalFunc; diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index c92a6b92..35104372 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -270,6 +270,27 @@ export function setFlagContext(flags: Record): void { } } +/** + * Set positional arguments as Sentry context. + * + * Stores positional arguments in a structured context for debugging. + * Unlike tags, context is not indexed but provides richer data. + * + * @param args - The positional arguments passed to the command + */ +export function setArgsContext(args: readonly unknown[]): void { + if (args.length === 0) { + return; + } + + Sentry.setContext("args", { + values: args.map((arg) => + typeof arg === "string" ? arg : JSON.stringify(arg) + ), + count: args.length, + }); +} + /** * Wrap an operation with a Sentry span for tracing. * diff --git a/test/lib/telemetry.test.ts b/test/lib/telemetry.test.ts index 35eb43db..e75f2b4b 100644 --- a/test/lib/telemetry.test.ts +++ b/test/lib/telemetry.test.ts @@ -5,10 +5,13 @@ */ import { Database } from "bun:sqlite"; -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as Sentry from "@sentry/bun"; import { createTracedDatabase, initSentry, + setArgsContext, setCommandSpanName, setFlagContext, setOrgProjectContext, @@ -151,73 +154,139 @@ describe("setOrgProjectContext", () => { }); describe("setFlagContext", () => { - test("handles empty flags object", () => { - expect(() => setFlagContext({})).not.toThrow(); + let setTagSpy: ReturnType; + + beforeEach(() => { + setTagSpy = spyOn(Sentry, "setTag"); }); - test("handles boolean flags (true sets tag)", () => { - expect(() => setFlagContext({ verbose: true, debug: true })).not.toThrow(); + afterEach(() => { + setTagSpy.mockRestore(); }); - test("handles boolean flags (false is skipped)", () => { - expect(() => - setFlagContext({ verbose: false, debug: false }) - ).not.toThrow(); + test("does not set tags for empty flags object", () => { + setFlagContext({}); + expect(setTagSpy).not.toHaveBeenCalled(); }); - test("handles string flags", () => { - expect(() => - setFlagContext({ output: "json", format: "table" }) - ).not.toThrow(); + test("sets tags for boolean flags when true", () => { + setFlagContext({ verbose: true, debug: true }); + expect(setTagSpy).toHaveBeenCalledTimes(2); + expect(setTagSpy).toHaveBeenCalledWith("flag.verbose", "true"); + expect(setTagSpy).toHaveBeenCalledWith("flag.debug", "true"); }); - test("handles number flags", () => { - expect(() => setFlagContext({ limit: 10, offset: 0 })).not.toThrow(); + test("does not set tags for boolean flags when false", () => { + setFlagContext({ verbose: false, debug: false }); + expect(setTagSpy).not.toHaveBeenCalled(); }); - test("handles undefined and null values (skipped)", () => { - expect(() => - setFlagContext({ value: undefined, other: null }) - ).not.toThrow(); + test("sets tags for string flags with values", () => { + setFlagContext({ output: "json", format: "table" }); + expect(setTagSpy).toHaveBeenCalledTimes(2); + expect(setTagSpy).toHaveBeenCalledWith("flag.output", "json"); + expect(setTagSpy).toHaveBeenCalledWith("flag.format", "table"); }); - test("handles empty string values (skipped)", () => { - expect(() => setFlagContext({ name: "" })).not.toThrow(); + test("sets tags for number flags", () => { + setFlagContext({ limit: 10, offset: 5 }); + expect(setTagSpy).toHaveBeenCalledTimes(2); + expect(setTagSpy).toHaveBeenCalledWith("flag.limit", "10"); + expect(setTagSpy).toHaveBeenCalledWith("flag.offset", "5"); }); - test("handles empty array values (skipped)", () => { - expect(() => setFlagContext({ items: [] })).not.toThrow(); + test("does not set tags for undefined or null values", () => { + setFlagContext({ value: undefined, other: null }); + expect(setTagSpy).not.toHaveBeenCalled(); }); - test("handles non-empty array values", () => { - expect(() => - setFlagContext({ projects: ["proj1", "proj2"] }) - ).not.toThrow(); + test("does not set tags for empty string values", () => { + setFlagContext({ name: "" }); + expect(setTagSpy).not.toHaveBeenCalled(); }); - test("handles mixed flag types", () => { - expect(() => - setFlagContext({ - verbose: true, - quiet: false, - limit: 50, - output: "json", - projects: ["a", "b"], - empty: "", - missing: undefined, - }) - ).not.toThrow(); + test("does not set tags for empty array values", () => { + setFlagContext({ items: [] }); + expect(setTagSpy).not.toHaveBeenCalled(); + }); + + test("sets tags for non-empty array values", () => { + setFlagContext({ projects: ["proj1", "proj2"] }); + expect(setTagSpy).toHaveBeenCalledTimes(1); + expect(setTagSpy).toHaveBeenCalledWith("flag.projects", "proj1,proj2"); + }); + + test("only sets tags for meaningful values in mixed flags", () => { + setFlagContext({ + verbose: true, + quiet: false, + limit: 50, + output: "json", + projects: ["a", "b"], + empty: "", + missing: undefined, + }); + // Should set: verbose, limit, output, projects (4 tags) + // Should skip: quiet (false), empty (""), missing (undefined) + expect(setTagSpy).toHaveBeenCalledTimes(4); + expect(setTagSpy).toHaveBeenCalledWith("flag.verbose", "true"); + expect(setTagSpy).toHaveBeenCalledWith("flag.limit", "50"); + expect(setTagSpy).toHaveBeenCalledWith("flag.output", "json"); + expect(setTagSpy).toHaveBeenCalledWith("flag.projects", "a,b"); }); test("converts camelCase to kebab-case", () => { - // This test verifies the function doesn't throw with camelCase keys - // The actual conversion is tested implicitly - the tag would be flag.no-modify-path - expect(() => - setFlagContext({ - noModifyPath: true, - someVeryLongFlagName: "value", - }) - ).not.toThrow(); + setFlagContext({ + noModifyPath: true, + someVeryLongFlagName: "value", + }); + expect(setTagSpy).toHaveBeenCalledTimes(2); + expect(setTagSpy).toHaveBeenCalledWith("flag.no-modify-path", "true"); + expect(setTagSpy).toHaveBeenCalledWith( + "flag.some-very-long-flag-name", + "value" + ); + }); + + test("truncates long string values to 200 characters", () => { + const longValue = "x".repeat(250); + setFlagContext({ longFlag: longValue }); + expect(setTagSpy).toHaveBeenCalledTimes(1); + expect(setTagSpy).toHaveBeenCalledWith("flag.long-flag", "x".repeat(200)); + }); +}); + +describe("setArgsContext", () => { + let setContextSpy: ReturnType; + + beforeEach(() => { + setContextSpy = spyOn(Sentry, "setContext"); + }); + + afterEach(() => { + setContextSpy.mockRestore(); + }); + + test("does not set context for empty args", () => { + setArgsContext([]); + expect(setContextSpy).not.toHaveBeenCalled(); + }); + + test("sets context for string args", () => { + setArgsContext(["PROJECT-123", "my-org"]); + expect(setContextSpy).toHaveBeenCalledTimes(1); + expect(setContextSpy).toHaveBeenCalledWith("args", { + values: ["PROJECT-123", "my-org"], + count: 2, + }); + }); + + test("converts non-string args to JSON", () => { + setArgsContext([123, { key: "value" }]); + expect(setContextSpy).toHaveBeenCalledWith("args", { + values: ["123", '{"key":"value"}'], + count: 2, + }); }); }); From ed554d24d1758149aa9279f57600f9bd5bfbe4d4 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 9 Feb 2026 11:11:21 +0000 Subject: [PATCH 08/10] refactor(completions): generate shell completions dynamically from Stricli route map Walk the route map with getAllEntries() to discover commands, subcommands, and aliases automatically. No manual maintenance needed when commands change. Also fixes missing completions for log/list, auth/token, and shortcut aliases (issues, orgs, projects, logs). --- src/lib/completions.ts | 299 +++++++++++++++++++---------------- test/lib/completions.test.ts | 120 +++++++++++++- 2 files changed, 282 insertions(+), 137 deletions(-) diff --git a/src/lib/completions.ts b/src/lib/completions.ts index cbc5b90c..d87d162b 100644 --- a/src/lib/completions.ts +++ b/src/lib/completions.ts @@ -1,12 +1,13 @@ /** * Shell completion script generation. * - * Generates completion scripts for bash, zsh, and fish shells. - * These scripts enable tab-completion for sentry CLI commands. + * Dynamically generates completion scripts from the Stricli route map. + * When commands are added or removed, completions update automatically. */ import { existsSync, mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; +import { routes } from "../app.js"; import type { ShellType } from "./shell.js"; /** Where completions are installed */ @@ -17,23 +18,115 @@ export type CompletionLocation = { created: boolean; }; +/** A command with its description */ +type CommandEntry = { name: string; brief: string }; + +/** A command group (route map) containing subcommands */ +type CommandGroup = { + name: string; + brief: string; + subcommands: CommandEntry[]; +}; + +/** Extracted command tree from the Stricli route map */ +type CommandTree = { + /** Command groups with subcommands (auth, issue, org, etc.) */ + groups: CommandGroup[]; + /** Standalone top-level commands (api, help, version, etc.) */ + standalone: CommandEntry[]; +}; + +/** + * Check if a routing target is a route map (has subcommands). + * + * Stricli route maps have an `getAllEntries()` method while commands don't. + */ +function isRouteMap(target: unknown): target is { + getAllEntries: () => readonly { + name: Record; + target: { brief: string }; + hidden: boolean; + }[]; +} & { + brief: string; +} { + return ( + typeof target === "object" && + target !== null && + typeof (target as Record).getAllEntries === "function" + ); +} + +/** + * Extract the command tree from the Stricli route map. + * + * Walks the route map recursively to build a structured command tree + * that can be used to generate completion scripts for any shell. + */ +export function extractCommandTree(): CommandTree { + const groups: CommandGroup[] = []; + const standalone: CommandEntry[] = []; + + for (const entry of routes.getAllEntries()) { + const name = entry.name.original; + if (entry.hidden) { + continue; + } + + if (isRouteMap(entry.target)) { + const subcommands: CommandEntry[] = []; + for (const sub of entry.target.getAllEntries()) { + if (!sub.hidden) { + subcommands.push({ + name: sub.name.original, + brief: sub.target.brief, + }); + } + } + groups.push({ name, brief: entry.target.brief, subcommands }); + } else { + standalone.push({ name, brief: entry.target.brief }); + } + } + + return { groups, standalone }; +} + /** * Generate bash completion script. */ export function generateBashCompletion(binaryName: string): string { + const { groups, standalone } = extractCommandTree(); + + const allTopLevel = [ + ...groups.map((g) => g.name), + ...standalone.map((s) => s.name), + ]; + + // Build subcommand variables + const subVars = groups + .map((g) => { + const subs = g.subcommands.map((s) => s.name).join(" "); + return ` local ${g.name}_commands="${subs}"`; + }) + .join("\n"); + + // Build case branches for subcommand completion + const caseBranches = groups + .map( + (g) => + ` ${g.name})\n COMPREPLY=($(compgen -W "\${${g.name}_commands}" -- "\${cur}"))\n ;;` + ) + .join("\n"); + return `# bash completion for ${binaryName} -# Install: ${binaryName} cli setup +# Auto-generated from command definitions _${binaryName}_completions() { local cur prev words cword _init_completion || return - local commands="auth api event issue org project cli help version" - local auth_commands="login logout status refresh" - local cli_commands="feedback fix setup upgrade" - local event_commands="view" - local issue_commands="list view explain plan" - local org_commands="list view" - local project_commands="list view" + local commands="${allTopLevel.join(" ")}" +${subVars} case "\${COMP_CWORD}" in 1) @@ -41,24 +134,7 @@ _${binaryName}_completions() { ;; 2) case "\${prev}" in - auth) - COMPREPLY=($(compgen -W "\${auth_commands}" -- "\${cur}")) - ;; - cli) - COMPREPLY=($(compgen -W "\${cli_commands}" -- "\${cur}")) - ;; - event) - COMPREPLY=($(compgen -W "\${event_commands}" -- "\${cur}")) - ;; - issue) - COMPREPLY=($(compgen -W "\${issue_commands}" -- "\${cur}")) - ;; - org) - COMPREPLY=($(compgen -W "\${org_commands}" -- "\${cur}")) - ;; - project) - COMPREPLY=($(compgen -W "\${project_commands}" -- "\${cur}")) - ;; +${caseBranches} esac ;; esac @@ -72,64 +148,43 @@ complete -F _${binaryName}_completions ${binaryName} * Generate zsh completion script. */ export function generateZshCompletion(binaryName: string): string { + const { groups, standalone } = extractCommandTree(); + + // Build top-level commands array + const topLevelItems = [ + ...groups.map((g) => ` '${g.name}:${g.brief}'`), + ...standalone.map((s) => ` '${s.name}:${s.brief}'`), + ].join("\n"); + + // Build subcommand arrays + const subArrays = groups + .map((g) => { + const items = g.subcommands + .map((s) => ` '${s.name}:${s.brief}'`) + .join("\n"); + return ` local -a ${g.name}_commands\n ${g.name}_commands=(\n${items}\n )`; + }) + .join("\n\n"); + + // Build case branches + const caseBranches = groups + .map( + (g) => + ` ${g.name})\n _describe -t commands '${g.name} command' ${g.name}_commands\n ;;` + ) + .join("\n"); + return `#compdef ${binaryName} # zsh completion for ${binaryName} -# Install: ${binaryName} cli setup +# Auto-generated from command definitions _${binaryName}() { local -a commands commands=( - 'auth:Authentication commands' - 'api:Make authenticated API requests' - 'event:Event-related commands' - 'issue:Issue-related commands' - 'org:Organization commands' - 'project:Project commands' - 'cli:CLI management commands' - 'help:Show help' - 'version:Show version' - ) - - local -a auth_commands - auth_commands=( - 'login:Authenticate with Sentry' - 'logout:Clear stored credentials' - 'status:Check authentication status' - 'refresh:Refresh access token' - ) - - local -a cli_commands - cli_commands=( - 'feedback:Send feedback' - 'fix:Repair local database' - 'setup:Configure shell integration' - 'upgrade:Upgrade to latest version' +${topLevelItems} ) - local -a issue_commands - issue_commands=( - 'list:List issues' - 'view:View issue details' - 'explain:AI explanation of issue' - 'plan:AI fix plan for issue' - ) - - local -a org_commands - org_commands=( - 'list:List organizations' - 'view:View organization details' - ) - - local -a project_commands - project_commands=( - 'list:List projects' - 'view:View project details' - ) - - local -a event_commands - event_commands=( - 'view:View event details' - ) +${subArrays} _arguments -C \\ '1: :->command' \\ @@ -142,24 +197,7 @@ _${binaryName}() { ;; subcommand) case "$words[1]" in - auth) - _describe -t commands 'auth command' auth_commands - ;; - cli) - _describe -t commands 'cli command' cli_commands - ;; - event) - _describe -t commands 'event command' event_commands - ;; - issue) - _describe -t commands 'issue command' issue_commands - ;; - org) - _describe -t commands 'org command' org_commands - ;; - project) - _describe -t commands 'project command' project_commands - ;; +${caseBranches} esac ;; esac @@ -173,51 +211,42 @@ _${binaryName} * Generate fish completion script. */ export function generateFishCompletion(binaryName: string): string { + const { groups, standalone } = extractCommandTree(); + + // Top-level command completions + const topLevelLines = [ + ...groups.map( + (g) => + `complete -c ${binaryName} -n "__fish_use_subcommand" -a "${g.name}" -d "${g.brief}"` + ), + ...standalone.map( + (s) => + `complete -c ${binaryName} -n "__fish_use_subcommand" -a "${s.name}" -d "${s.brief}"` + ), + ].join("\n"); + + // Subcommand completions + const subLines = groups + .map((g) => { + const lines = g.subcommands + .map( + (s) => + `complete -c ${binaryName} -n "__fish_seen_subcommand_from ${g.name}" -a "${s.name}" -d "${s.brief}"` + ) + .join("\n"); + return `\n# ${g.name} subcommands\n${lines}`; + }) + .join("\n"); + return `# fish completion for ${binaryName} -# Install: ${binaryName} cli setup +# Auto-generated from command definitions # Disable file completion by default complete -c ${binaryName} -f # Top-level commands -complete -c ${binaryName} -n "__fish_use_subcommand" -a "auth" -d "Authentication commands" -complete -c ${binaryName} -n "__fish_use_subcommand" -a "api" -d "Make authenticated API requests" -complete -c ${binaryName} -n "__fish_use_subcommand" -a "event" -d "Event-related commands" -complete -c ${binaryName} -n "__fish_use_subcommand" -a "issue" -d "Issue-related commands" -complete -c ${binaryName} -n "__fish_use_subcommand" -a "org" -d "Organization commands" -complete -c ${binaryName} -n "__fish_use_subcommand" -a "project" -d "Project commands" -complete -c ${binaryName} -n "__fish_use_subcommand" -a "cli" -d "CLI management commands" -complete -c ${binaryName} -n "__fish_use_subcommand" -a "help" -d "Show help" -complete -c ${binaryName} -n "__fish_use_subcommand" -a "version" -d "Show version" - -# auth subcommands -complete -c ${binaryName} -n "__fish_seen_subcommand_from auth" -a "login" -d "Authenticate with Sentry" -complete -c ${binaryName} -n "__fish_seen_subcommand_from auth" -a "logout" -d "Clear stored credentials" -complete -c ${binaryName} -n "__fish_seen_subcommand_from auth" -a "status" -d "Check authentication status" -complete -c ${binaryName} -n "__fish_seen_subcommand_from auth" -a "refresh" -d "Refresh access token" - -# cli subcommands -complete -c ${binaryName} -n "__fish_seen_subcommand_from cli" -a "feedback" -d "Send feedback" -complete -c ${binaryName} -n "__fish_seen_subcommand_from cli" -a "fix" -d "Repair local database" -complete -c ${binaryName} -n "__fish_seen_subcommand_from cli" -a "setup" -d "Configure shell integration" -complete -c ${binaryName} -n "__fish_seen_subcommand_from cli" -a "upgrade" -d "Upgrade to latest version" - -# event subcommands -complete -c ${binaryName} -n "__fish_seen_subcommand_from event" -a "view" -d "View event details" - -# issue subcommands -complete -c ${binaryName} -n "__fish_seen_subcommand_from issue" -a "list" -d "List issues" -complete -c ${binaryName} -n "__fish_seen_subcommand_from issue" -a "view" -d "View issue details" -complete -c ${binaryName} -n "__fish_seen_subcommand_from issue" -a "explain" -d "AI explanation of issue" -complete -c ${binaryName} -n "__fish_seen_subcommand_from issue" -a "plan" -d "AI fix plan for issue" - -# org subcommands -complete -c ${binaryName} -n "__fish_seen_subcommand_from org" -a "list" -d "List organizations" -complete -c ${binaryName} -n "__fish_seen_subcommand_from org" -a "view" -d "View organization details" - -# project subcommands -complete -c ${binaryName} -n "__fish_seen_subcommand_from project" -a "list" -d "List projects" -complete -c ${binaryName} -n "__fish_seen_subcommand_from project" -a "view" -d "View project details" +${topLevelLines} +${subLines} `; } diff --git a/test/lib/completions.test.ts b/test/lib/completions.test.ts index 1050c040..4bb7de9c 100644 --- a/test/lib/completions.test.ts +++ b/test/lib/completions.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { + extractCommandTree, generateBashCompletion, generateFishCompletion, generateZshCompletion, @@ -11,6 +12,84 @@ import { } from "../../src/lib/completions.js"; describe("completions", () => { + describe("extractCommandTree", () => { + test("returns groups and standalone commands", () => { + const tree = extractCommandTree(); + + expect(tree.groups.length).toBeGreaterThan(0); + expect(tree.standalone.length).toBeGreaterThan(0); + }); + + test("includes all known command groups", () => { + const tree = extractCommandTree(); + const groupNames = tree.groups.map((g) => g.name); + + expect(groupNames).toContain("auth"); + expect(groupNames).toContain("cli"); + expect(groupNames).toContain("org"); + expect(groupNames).toContain("project"); + expect(groupNames).toContain("issue"); + expect(groupNames).toContain("event"); + expect(groupNames).toContain("log"); + }); + + test("includes all auth subcommands including token", () => { + const tree = extractCommandTree(); + const auth = tree.groups.find((g) => g.name === "auth"); + const subNames = auth!.subcommands.map((s) => s.name); + + expect(subNames).toContain("login"); + expect(subNames).toContain("logout"); + expect(subNames).toContain("status"); + expect(subNames).toContain("refresh"); + expect(subNames).toContain("token"); + }); + + test("includes log subcommands", () => { + const tree = extractCommandTree(); + const log = tree.groups.find((g) => g.name === "log"); + const subNames = log!.subcommands.map((s) => s.name); + + expect(subNames).toContain("list"); + }); + + test("includes shortcut aliases as standalone commands", () => { + const tree = extractCommandTree(); + const standaloneNames = tree.standalone.map((s) => s.name); + + expect(standaloneNames).toContain("issues"); + expect(standaloneNames).toContain("orgs"); + expect(standaloneNames).toContain("projects"); + expect(standaloneNames).toContain("logs"); + }); + + test("includes api and help as standalone", () => { + const tree = extractCommandTree(); + const standaloneNames = tree.standalone.map((s) => s.name); + + expect(standaloneNames).toContain("api"); + expect(standaloneNames).toContain("help"); + }); + + test("every group has a non-empty brief", () => { + const tree = extractCommandTree(); + + for (const group of tree.groups) { + expect(group.brief.length).toBeGreaterThan(0); + } + }); + + test("every subcommand has a non-empty brief", () => { + const tree = extractCommandTree(); + + for (const group of tree.groups) { + for (const sub of group.subcommands) { + expect(sub.brief.length).toBeGreaterThan(0); + } + } + }); + }); + describe("generateBashCompletion", () => { test("generates valid bash completion script", () => { const script = generateBashCompletion("sentry"); @@ -20,6 +99,7 @@ describe("completions", () => { expect(script).toContain("auth"); expect(script).toContain("issue"); expect(script).toContain("cli"); + expect(script).toContain("log"); }); test("uses custom binary name", () => { @@ -28,6 +108,19 @@ describe("completions", () => { expect(script).toContain("_my-cli_completions()"); expect(script).toContain("complete -F _my-cli_completions my-cli"); }); + + test("includes all subcommands in case branches", () => { + const script = generateBashCompletion("sentry"); + + // Verify case branches exist for each group + expect(script).toContain("auth)"); + expect(script).toContain("cli)"); + expect(script).toContain("issue)"); + expect(script).toContain("org)"); + expect(script).toContain("project)"); + expect(script).toContain("event)"); + expect(script).toContain("log)"); + }); }); describe("generateZshCompletion", () => { @@ -36,8 +129,15 @@ describe("completions", () => { expect(script).toContain("#compdef sentry"); expect(script).toContain("_sentry()"); - expect(script).toContain("'auth:Authentication commands'"); - expect(script).toContain("'issue:Issue-related commands'"); + expect(script).toContain("'auth:Authenticate with Sentry'"); + expect(script).toContain("'issue:Manage Sentry issues'"); + expect(script).toContain("'log:View Sentry logs'"); + }); + + test("includes token subcommand in auth", () => { + const script = generateZshCompletion("sentry"); + + expect(script).toContain("'token:Print the stored authentication token'"); }); }); @@ -49,6 +149,22 @@ describe("completions", () => { expect(script).toContain('__fish_use_subcommand" -a "auth"'); expect(script).toContain('__fish_seen_subcommand_from auth" -a "login"'); }); + + test("includes log group and subcommands", () => { + const script = generateFishCompletion("sentry"); + + expect(script).toContain('__fish_use_subcommand" -a "log"'); + expect(script).toContain('__fish_seen_subcommand_from log" -a "list"'); + }); + + test("includes aliases as top-level commands", () => { + const script = generateFishCompletion("sentry"); + + expect(script).toContain('__fish_use_subcommand" -a "issues"'); + expect(script).toContain('__fish_use_subcommand" -a "orgs"'); + expect(script).toContain('__fish_use_subcommand" -a "projects"'); + expect(script).toContain('__fish_use_subcommand" -a "logs"'); + }); }); describe("getCompletionScript", () => { From ae14b3ae9ec3097715eaa98b7eafcb4883e74b1c Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 9 Feb 2026 11:34:06 +0000 Subject: [PATCH 09/10] fix(app): enable kebab-case flags and improve test coverage Enable allow-kebab-for-camel in Stricli scanner config so flags like --no-modify-path work (fixes install script regression). Add tests for setup command (11 tests, 96% coverage), command wrapper integration (5 tests exercising actual Stricli run), shell utilities (GitHub Actions PATH, unknown shells, write failures). --- src/app.ts | 3 + test/commands/cli/setup.test.ts | 281 ++++++++++++++++++++++++++++++++ test/lib/command.test.ts | 243 +++++++++++++++++++++++---- test/lib/shell.test.ts | 146 +++++++++++++++++ 4 files changed, 637 insertions(+), 36 deletions(-) create mode 100644 test/commands/cli/setup.test.ts diff --git a/src/app.ts b/src/app.ts index 4b0d5020..7119cedb 100644 --- a/src/app.ts +++ b/src/app.ts @@ -85,6 +85,9 @@ export const app = buildApplication(routes, { versionInfo: { currentVersion: CLI_VERSION, }, + scanner: { + caseStyle: "allow-kebab-for-camel", + }, determineExitCode: getExitCode, localization: { loadText: () => customText, diff --git a/test/commands/cli/setup.test.ts b/test/commands/cli/setup.test.ts new file mode 100644 index 00000000..474f2e30 --- /dev/null +++ b/test/commands/cli/setup.test.ts @@ -0,0 +1,281 @@ +/** + * Setup Command Tests + * + * Tests the `sentry cli setup` command end-to-end through Stricli's run(). + */ + +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { run } from "@stricli/core"; +import { app } from "../../../src/app.js"; +import type { SentryContext } from "../../../src/context.js"; + +/** Create a mock SentryContext for testing */ +function createMockContext( + overrides: Partial<{ + homeDir: string; + env: Record; + execPath: string; + }> = {} +): { context: SentryContext; output: string[] } { + const output: string[] = []; + const env: Record = { + PATH: "/usr/bin:/bin", + SHELL: "/bin/bash", + ...overrides.env, + }; + + const context = { + process: { + stdout: { + write: (s: string) => { + output.push(s); + return true; + }, + }, + stderr: { + write: (s: string) => { + output.push(s); + return true; + }, + }, + stdin: process.stdin, + env, + cwd: () => "/tmp", + execPath: overrides.execPath ?? "/usr/local/bin/sentry", + exit: mock(() => { + // no-op for tests + }), + exitCode: 0, + }, + homeDir: overrides.homeDir ?? "/tmp/test-home", + cwd: "/tmp", + configDir: "/tmp/test-config", + env, + stdout: { + write: (s: string) => { + output.push(s); + return true; + }, + }, + stderr: { + write: (s: string) => { + output.push(s); + return true; + }, + }, + stdin: process.stdin, + setContext: () => { + // no-op for tests + }, + setFlags: () => { + // no-op for tests + }, + } as unknown as SentryContext; + + return { context, output }; +} + +describe("sentry cli setup", () => { + let testDir: string; + + beforeEach(() => { + testDir = join( + "/tmp", + `setup-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + test("runs with --quiet and skips all output", async () => { + const { context, output } = createMockContext({ homeDir: testDir }); + + await run( + app, + ["cli", "setup", "--quiet", "--no-modify-path", "--no-completions"], + context + ); + + // With --quiet, no output should be produced + expect(output.join("")).toBe(""); + }); + + test("outputs 'Setup complete!' without --quiet", async () => { + const { context, output } = createMockContext({ homeDir: testDir }); + + await run( + app, + ["cli", "setup", "--no-modify-path", "--no-completions"], + context + ); + + const combined = output.join(""); + expect(combined).toContain("Setup complete!"); + }); + + test("records install method when --method is provided", async () => { + const { context, output } = createMockContext({ homeDir: testDir }); + + await run( + app, + [ + "cli", + "setup", + "--method", + "curl", + "--no-modify-path", + "--no-completions", + ], + context + ); + + const combined = output.join(""); + expect(combined).toContain("Recorded installation method: curl"); + }); + + test("handles PATH modification when binary not in PATH", async () => { + // Create a .bashrc for the shell config to find + const bashrc = join(testDir, ".bashrc"); + writeFileSync(bashrc, "# existing config\n"); + + const { context, output } = createMockContext({ + homeDir: testDir, + execPath: join(testDir, "bin", "sentry"), + env: { + PATH: "/usr/bin:/bin", + SHELL: "/bin/bash", + }, + }); + + await run(app, ["cli", "setup", "--no-completions"], context); + + const combined = output.join(""); + expect(combined).toContain("PATH:"); + }); + + test("reports PATH already configured when binary dir is in PATH", async () => { + const binDir = join(testDir, "bin"); + mkdirSync(binDir, { recursive: true }); + + const { context, output } = createMockContext({ + homeDir: testDir, + execPath: join(binDir, "sentry"), + env: { + PATH: `/usr/bin:${binDir}:/bin`, + SHELL: "/bin/bash", + }, + }); + + await run(app, ["cli", "setup", "--no-completions"], context); + + const combined = output.join(""); + expect(combined).toContain("already in PATH"); + }); + + test("reports no config file found for unknown shell", async () => { + const { context, output } = createMockContext({ + homeDir: testDir, + env: { + PATH: "/usr/bin:/bin", + SHELL: "/bin/tcsh", + }, + }); + + await run(app, ["cli", "setup", "--no-completions"], context); + + const combined = output.join(""); + expect(combined).toContain("No shell config file found"); + expect(combined).toContain("Add manually"); + }); + + test("installs completions when not skipped", async () => { + const bashrc = join(testDir, ".bashrc"); + writeFileSync(bashrc, "# existing\n"); + + const { context, output } = createMockContext({ + homeDir: testDir, + execPath: join(testDir, "bin", "sentry"), + env: { + PATH: `/usr/bin:${join(testDir, "bin")}:/bin`, + SHELL: "/bin/bash", + }, + }); + + await run(app, ["cli", "setup", "--no-modify-path"], context); + + const combined = output.join(""); + expect(combined).toContain("Completions:"); + }); + + test("shows zsh fpath hint for zsh completions", async () => { + const { context, output } = createMockContext({ + homeDir: testDir, + execPath: join(testDir, "bin", "sentry"), + env: { + PATH: `/usr/bin:${join(testDir, "bin")}:/bin`, + SHELL: "/bin/zsh", + }, + }); + + await run(app, ["cli", "setup", "--no-modify-path"], context); + + const combined = output.join(""); + expect(combined).toContain("fpath="); + }); + + test("handles GitHub Actions PATH when GITHUB_ACTIONS is set", async () => { + const ghPathFile = join(testDir, "github_path"); + writeFileSync(ghPathFile, ""); + + const { context, output } = createMockContext({ + homeDir: testDir, + execPath: join(testDir, "bin", "sentry"), + env: { + PATH: "/usr/bin:/bin", + SHELL: "/bin/bash", + GITHUB_ACTIONS: "true", + GITHUB_PATH: ghPathFile, + }, + }); + + await run(app, ["cli", "setup", "--no-completions"], context); + + const combined = output.join(""); + expect(combined).toContain("GITHUB_PATH"); + }); + + test("shows unsupported message for sh shell completions", async () => { + const { context, output } = createMockContext({ + homeDir: testDir, + execPath: join(testDir, "bin", "sentry"), + env: { + PATH: `/usr/bin:${join(testDir, "bin")}:/bin`, + SHELL: "/bin/tcsh", + }, + }); + + await run(app, ["cli", "setup", "--no-modify-path"], context); + + const combined = output.join(""); + expect(combined).toContain("Not supported for"); + }); + + test("supports kebab-case flags", async () => { + const { context, output } = createMockContext({ homeDir: testDir }); + + // Verify kebab-case works (--no-modify-path instead of --noModifyPath) + await run( + app, + ["cli", "setup", "--no-modify-path", "--no-completions", "--quiet"], + context + ); + + // Should not error + expect(output.join("")).toBe(""); + }); +}); diff --git a/test/lib/command.test.ts b/test/lib/command.test.ts index 58d33d64..e2c13768 100644 --- a/test/lib/command.test.ts +++ b/test/lib/command.test.ts @@ -2,32 +2,61 @@ * Command Builder Tests * * Tests for the buildCommand wrapper that adds automatic flag telemetry. + * Uses Stricli's run() to invoke commands end-to-end, verifying the wrapper + * captures flags/args and calls the original function. */ -import { describe, expect, test } from "bun:test"; -import { buildCommand } from "../../src/lib/command.js"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +// biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking +import * as Sentry from "@sentry/bun"; +import { + buildApplication, + buildRouteMap, + type CommandContext, + run, +} from "@stricli/core"; +import { buildCommand, numberParser } from "../../src/lib/command.js"; + +/** Minimal context for test commands */ +type TestContext = CommandContext & { + process: { stdout: { write: (s: string) => boolean } }; +}; + +/** Creates a minimal writable stream for testing */ +function createTestProcess() { + const output: string[] = []; + return { + process: { + stdout: { + write: (s: string) => { + output.push(s); + return true; + }, + }, + stderr: { + write: (s: string) => { + output.push(s); + return true; + }, + }, + }, + output, + }; +} describe("buildCommand", () => { - test("wraps command and returns a valid command object", () => { + test("builds a valid command object", () => { const command = buildCommand({ docs: { brief: "Test command" }, parameters: { flags: { - verbose: { kind: "boolean", brief: "Verbose output", default: false }, - limit: { - kind: "parsed", - parse: Number, - brief: "Limit", - default: "10", - }, + verbose: { kind: "boolean", brief: "Verbose", default: false }, }, }, - func(_flags: { verbose: boolean; limit: number }) { - // Command functions return void + func(_flags: { verbose: boolean }) { + // no-op }, }); - - // The command should be built successfully expect(command).toBeDefined(); }); @@ -36,50 +65,192 @@ describe("buildCommand", () => { docs: { brief: "Simple command" }, parameters: {}, func() { - // No-op + // no-op }, }); - expect(command).toBeDefined(); }); - test("handles async command functions", () => { - const command = buildCommand({ - docs: { brief: "Async command" }, + test("re-exports numberParser from Stricli", () => { + expect(numberParser).toBeDefined(); + expect(typeof numberParser).toBe("function"); + }); +}); + +describe("buildCommand telemetry integration", () => { + let setTagSpy: ReturnType; + let setContextSpy: ReturnType; + + beforeEach(() => { + setTagSpy = spyOn(Sentry, "setTag"); + setContextSpy = spyOn(Sentry, "setContext"); + }); + + afterEach(() => { + setTagSpy.mockRestore(); + setContextSpy.mockRestore(); + }); + + test("captures flags as Sentry tags when command runs", async () => { + let calledWith: unknown = null; + + const command = buildCommand< + { verbose: boolean; limit: number }, + [], + TestContext + >({ + docs: { brief: "Test" }, parameters: { flags: { - delay: { + verbose: { kind: "boolean", brief: "Verbose", default: false }, + limit: { kind: "parsed", - parse: Number, - brief: "Delay", - default: "1", + parse: numberParser, + brief: "Limit", + default: "10", }, }, }, - async func(_flags: { delay: number }) { - await Bun.sleep(1); + func(this: TestContext, flags: { verbose: boolean; limit: number }) { + calledWith = flags; }, }); - expect(command).toBeDefined(); + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const { process } = createTestProcess(); + + await run(app, ["test", "--verbose", "--limit", "50"], { + process, + } as TestContext); + + // Original func was called with parsed flags + expect(calledWith).toEqual({ verbose: true, limit: 50 }); + + // Sentry.setTag was called for meaningful flag values + expect(setTagSpy).toHaveBeenCalledWith("flag.verbose", "true"); + expect(setTagSpy).toHaveBeenCalledWith("flag.limit", "50"); }); - test("handles command functions that return Error", () => { - const command = buildCommand({ - docs: { brief: "Error command" }, + test("skips false boolean flags in telemetry", async () => { + const command = buildCommand<{ json: boolean }, [], TestContext>({ + docs: { brief: "Test" }, parameters: { flags: { - shouldFail: { kind: "boolean", brief: "Fail", default: false }, + json: { kind: "boolean", brief: "JSON output", default: false }, }, }, - func(_flags: { shouldFail: boolean }): Error | undefined { - if (_flags.shouldFail) { - return new Error("Failed"); - } - return; + func(_flags: { json: boolean }) { + // no-op }, }); - expect(command).toBeDefined(); + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const { process } = createTestProcess(); + + await run(app, ["test"], { process } as TestContext); + + // Should not set tag for default false boolean + const flagCalls = setTagSpy.mock.calls.filter( + (call) => typeof call[0] === "string" && call[0].startsWith("flag.") + ); + expect(flagCalls).toHaveLength(0); + }); + + test("captures positional args as Sentry context", async () => { + let calledArgs: unknown = null; + + const command = buildCommand, [string], TestContext>({ + docs: { brief: "Test" }, + parameters: { + positional: { + kind: "tuple", + parameters: [{ brief: "Issue ID", parse: String }], + }, + }, + func(this: TestContext, _flags: Record, issueId: string) { + calledArgs = issueId; + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const { process } = createTestProcess(); + + await run(app, ["test", "PROJECT-123"], { process } as TestContext); + + expect(calledArgs).toBe("PROJECT-123"); + expect(setContextSpy).toHaveBeenCalledWith("args", { + values: ["PROJECT-123"], + count: 1, + }); + }); + + test("preserves this context for command functions", async () => { + let capturedStdout = false; + + const command = buildCommand, [], TestContext>({ + docs: { brief: "Test" }, + parameters: {}, + func(this: TestContext) { + // Verify 'this' is correctly bound to context + capturedStdout = typeof this.process.stdout.write === "function"; + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const { process } = createTestProcess(); + + await run(app, ["test"], { process } as TestContext); + + expect(capturedStdout).toBe(true); + }); + + test("handles async command functions", async () => { + let executed = false; + + const command = buildCommand<{ delay: number }, [], TestContext>({ + docs: { brief: "Test" }, + parameters: { + flags: { + delay: { + kind: "parsed", + parse: numberParser, + brief: "Delay ms", + default: "1", + }, + }, + }, + async func(_flags: { delay: number }) { + await Bun.sleep(1); + executed = true; + }, + }); + + const routeMap = buildRouteMap({ + routes: { test: command }, + docs: { brief: "Test app" }, + }); + const app = buildApplication(routeMap, { name: "test" }); + const { process } = createTestProcess(); + + await run(app, ["test", "--delay", "1"], { process } as TestContext); + + expect(executed).toBe(true); + expect(setTagSpy).toHaveBeenCalledWith("flag.delay", "1"); }); }); diff --git a/test/lib/shell.test.ts b/test/lib/shell.test.ts index cfdb4148..477accdc 100644 --- a/test/lib/shell.test.ts +++ b/test/lib/shell.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { + addToGitHubPath, addToPath, detectShell, detectShellType, @@ -76,6 +77,13 @@ describe("shell utilities", () => { const candidates = getConfigCandidates("fish", homeDir); expect(candidates).toContain("/home/user/.config/fish/config.fish"); }); + + test("returns fallback candidates for unknown shell", () => { + const candidates = getConfigCandidates("unknown", homeDir, xdgConfigHome); + expect(candidates).toContain("/home/user/.bashrc"); + expect(candidates).toContain("/home/user/.bash_profile"); + expect(candidates).toContain("/home/user/.profile"); + }); }); describe("findExistingConfigFile", () => { @@ -255,5 +263,143 @@ describe("shell utilities", () => { const content = await Bun.file(configFile).text(); expect(content).toContain('fish_add_path "/home/user/.sentry/bin"'); }); + + test("appends newline separator when file doesn't end with newline", async () => { + const configFile = join(testDir, ".bashrc"); + writeFileSync(configFile, "# existing content without newline"); + + const result = await addToPath( + configFile, + "/home/user/.sentry/bin", + "bash" + ); + + expect(result.modified).toBe(true); + + const content = await Bun.file(configFile).text(); + // Should have double newline separator before sentry block + expect(content).toContain( + "# existing content without newline\n\n# sentry\n" + ); + }); + + test("returns manualCommand when config file cannot be created", async () => { + // Point to a path inside a nonexistent, deeply nested directory that can't be auto-created + const configFile = "/dev/null/impossible/path/.bashrc"; + const result = await addToPath( + configFile, + "/home/user/.sentry/bin", + "bash" + ); + + expect(result.modified).toBe(false); + expect(result.manualCommand).toBe( + 'export PATH="/home/user/.sentry/bin:$PATH"' + ); + }); + + test("returns manualCommand when existing config file is not writable", async () => { + const configFile = join(testDir, ".bashrc"); + writeFileSync(configFile, "# existing\n"); + const { chmodSync } = await import("node:fs"); + chmodSync(configFile, 0o444); + + const result = await addToPath( + configFile, + "/home/user/.sentry/bin", + "bash" + ); + + // On failure to write, should return manual command + // Restore permissions for cleanup + chmodSync(configFile, 0o644); + + // The file is read-only, but Bun.write may override on some systems + // At minimum, verify the result is valid + expect( + result.configFile === configFile || result.manualCommand !== null + ).toBe(true); + }); + }); + + describe("addToGitHubPath", () => { + let testDir: string; + + beforeEach(() => { + testDir = join( + "/tmp", + `shell-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + test("returns false when not in GitHub Actions", async () => { + const result = await addToGitHubPath("/usr/local/bin", {}); + expect(result).toBe(false); + }); + + test("returns false when GITHUB_PATH is not set", async () => { + const result = await addToGitHubPath("/usr/local/bin", { + GITHUB_ACTIONS: "true", + }); + expect(result).toBe(false); + }); + + test("writes directory to GITHUB_PATH file", async () => { + const pathFile = join(testDir, "github_path"); + writeFileSync(pathFile, ""); + + const result = await addToGitHubPath("/usr/local/bin", { + GITHUB_ACTIONS: "true", + GITHUB_PATH: pathFile, + }); + + expect(result).toBe(true); + const content = await Bun.file(pathFile).text(); + expect(content).toContain("/usr/local/bin"); + }); + + test("appends to existing GITHUB_PATH content", async () => { + const pathFile = join(testDir, "github_path"); + writeFileSync(pathFile, "/existing/path\n"); + + const result = await addToGitHubPath("/usr/local/bin", { + GITHUB_ACTIONS: "true", + GITHUB_PATH: pathFile, + }); + + expect(result).toBe(true); + const content = await Bun.file(pathFile).text(); + expect(content).toContain("/existing/path"); + expect(content).toContain("/usr/local/bin"); + }); + + test("does not duplicate existing directory", async () => { + const pathFile = join(testDir, "github_path"); + writeFileSync(pathFile, "/usr/local/bin\n"); + + const result = await addToGitHubPath("/usr/local/bin", { + GITHUB_ACTIONS: "true", + GITHUB_PATH: pathFile, + }); + + expect(result).toBe(true); + const content = await Bun.file(pathFile).text(); + // Should still be just the one entry + expect(content).toBe("/usr/local/bin\n"); + }); + + test("returns false when GITHUB_PATH file is not writable", async () => { + const result = await addToGitHubPath("/usr/local/bin", { + GITHUB_ACTIONS: "true", + GITHUB_PATH: "/dev/null/impossible", + }); + + expect(result).toBe(false); + }); }); }); From f2470fef9f65157f82d47af6a5cdd6dd98fa5875 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 9 Feb 2026 11:55:35 +0000 Subject: [PATCH 10/10] test: add property-based tests for shell and completions Add property-based tests using fast-check for shell utilities and completion generation, including a real bash simulation that sources the generated completion script and verifies COMPREPLY output. New test files: - test/lib/shell.property.test.ts (14 property tests) - test/lib/completions.property.test.ts (12 tests incl. bash sim) Trim existing unit tests to remove overlap with property tests, keeping only I/O and dispatch tests in the original files. --- test/lib/completions.property.test.ts | 288 ++++++++++++++++++++++++++ test/lib/completions.test.ts | 170 +-------------- test/lib/shell.property.test.ts | 265 ++++++++++++++++++++++++ test/lib/shell.test.ts | 170 ++------------- 4 files changed, 573 insertions(+), 320 deletions(-) create mode 100644 test/lib/completions.property.test.ts create mode 100644 test/lib/shell.property.test.ts diff --git a/test/lib/completions.property.test.ts b/test/lib/completions.property.test.ts new file mode 100644 index 00000000..67dc12cb --- /dev/null +++ b/test/lib/completions.property.test.ts @@ -0,0 +1,288 @@ +/** + * Property-Based Tests for Shell Completions + * + * Verifies invariants of the completion generation system: + * - Cross-shell consistency (every command appears in all three scripts) + * - Binary name parametrization + * - Structural invariants of the command tree + * + * Also includes integration tests using Stricli's proposeCompletions API + * and a real bash simulation of the generated completion script. + */ + +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import { proposeCompletions } from "@stricli/core"; +import { constantFrom, assert as fcAssert, property } from "fast-check"; +import { app } from "../../src/app.js"; +import { + extractCommandTree, + generateBashCompletion, + generateFishCompletion, + generateZshCompletion, +} from "../../src/lib/completions.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +// -- Arbitraries -- + +/** Generate valid binary names (lowercase alphanumeric + hyphens) */ +const binaryNameArb = constantFrom( + "sentry", + "my-cli", + "test-tool", + "acme", + "dev-helper", + "s" +); + +// -- Helpers -- + +/** Minimal context for proposeCompletions */ +const completionContext = { + process: { + stdout: { + write: () => true, + }, + stderr: { + write: () => true, + }, + stdin: process.stdin, + }, + forCommand: () => ({}), +}; + +// -- Tests -- + +describe("property: extractCommandTree invariants", () => { + const tree = extractCommandTree(); + + test("every group has at least one subcommand", () => { + for (const group of tree.groups) { + expect(group.subcommands.length).toBeGreaterThan(0); + } + }); + + test("no duplicate group names", () => { + const names = tree.groups.map((g) => g.name); + expect(new Set(names).size).toBe(names.length); + }); + + test("no duplicate subcommand names within a group", () => { + for (const group of tree.groups) { + const names = group.subcommands.map((s) => s.name); + expect(new Set(names).size).toBe(names.length); + } + }); + + test("all names are non-empty strings", () => { + for (const group of tree.groups) { + expect(group.name.length).toBeGreaterThan(0); + expect(group.brief.length).toBeGreaterThan(0); + for (const sub of group.subcommands) { + expect(sub.name.length).toBeGreaterThan(0); + expect(sub.brief.length).toBeGreaterThan(0); + } + } + for (const cmd of tree.standalone) { + expect(cmd.name.length).toBeGreaterThan(0); + expect(cmd.brief.length).toBeGreaterThan(0); + } + }); +}); + +describe("property: cross-shell consistency", () => { + const tree = extractCommandTree(); + + test("every group name appears in all three shell scripts", () => { + fcAssert( + property(binaryNameArb, (name) => { + const bash = generateBashCompletion(name); + const zsh = generateZshCompletion(name); + const fish = generateFishCompletion(name); + + for (const group of tree.groups) { + expect(bash).toContain(group.name); + expect(zsh).toContain(group.name); + expect(fish).toContain(group.name); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("every subcommand name appears in all three shell scripts", () => { + fcAssert( + property(binaryNameArb, (name) => { + const bash = generateBashCompletion(name); + const zsh = generateZshCompletion(name); + const fish = generateFishCompletion(name); + + for (const group of tree.groups) { + for (const sub of group.subcommands) { + expect(bash).toContain(sub.name); + expect(zsh).toContain(sub.name); + expect(fish).toContain(sub.name); + } + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("every standalone command appears in all three shell scripts", () => { + fcAssert( + property(binaryNameArb, (name) => { + const bash = generateBashCompletion(name); + const zsh = generateZshCompletion(name); + const fish = generateFishCompletion(name); + + for (const cmd of tree.standalone) { + expect(bash).toContain(cmd.name); + expect(zsh).toContain(cmd.name); + expect(fish).toContain(cmd.name); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: binary name parametrization", () => { + test("generated scripts reference the given binary name", () => { + fcAssert( + property(binaryNameArb, (name) => { + const bash = generateBashCompletion(name); + const zsh = generateZshCompletion(name); + const fish = generateFishCompletion(name); + + // Each script should reference the binary name + expect(bash).toContain(`_${name}_completions`); + expect(zsh).toContain(`_${name}()`); + expect(fish).toContain(`complete -c ${name}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("proposeCompletions: Stricli integration", () => { + const tree = extractCommandTree(); + + test("subcommands match extractCommandTree for each group", async () => { + for (const group of tree.groups) { + const completions = await proposeCompletions( + app, + [group.name, ""], + completionContext + ); + const actual = completions.map((c) => c.completion).sort(); + const expected = group.subcommands.map((s) => s.name).sort(); + expect(actual).toEqual(expected); + } + }); + + test("partial prefix filters subcommands correctly", async () => { + for (const group of tree.groups) { + if (group.subcommands.length === 0) { + continue; + } + // Pick the first subcommand's first character as prefix + const prefix = group.subcommands[0].name[0]; + const completions = await proposeCompletions( + app, + [group.name, prefix], + completionContext + ); + + // Every returned completion should start with the prefix + for (const c of completions) { + expect(c.completion.startsWith(prefix)).toBe(true); + } + + // Every expected subcommand starting with the prefix should be returned + const expected = group.subcommands + .filter((s) => s.name.startsWith(prefix)) + .map((s) => s.name) + .sort(); + const actual = completions.map((c) => c.completion).sort(); + expect(actual).toEqual(expected); + } + }); +}); + +describe("bash completion: real shell simulation", () => { + test("top-level completion returns all known commands", async () => { + const tree = extractCommandTree(); + const script = generateBashCompletion("sentry"); + + const tmpScript = join("/tmp", `completion-test-${Date.now()}.bash`); + await Bun.write(tmpScript, script); + + const result = Bun.spawnSync({ + cmd: [ + "bash", + "-c", + ` +# Stub _init_completion (not available outside bash-completion package) +_init_completion() { + cur="\${COMP_WORDS[COMP_CWORD]}" + prev="\${COMP_WORDS[COMP_CWORD-1]}" + return 0 +} +source "${tmpScript}" + +COMP_WORDS=(sentry "") +COMP_CWORD=1 +_sentry_completions +echo "\${COMPREPLY[*]}" +`, + ], + }); + const output = result.stdout.toString().trim(); + const completions = output.split(/\s+/); + + // Verify all groups and standalone commands appear + for (const group of tree.groups) { + expect(completions).toContain(group.name); + } + for (const cmd of tree.standalone) { + expect(completions).toContain(cmd.name); + } + }); + + test("subcommand completion returns correct subcommands", async () => { + const tree = extractCommandTree(); + const script = generateBashCompletion("sentry"); + + const tmpScript = join("/tmp", `completion-test-${Date.now()}.bash`); + await Bun.write(tmpScript, script); + + // Test a few representative groups + for (const group of tree.groups) { + const result = Bun.spawnSync({ + cmd: [ + "bash", + "-c", + ` +_init_completion() { + cur="\${COMP_WORDS[COMP_CWORD]}" + prev="\${COMP_WORDS[COMP_CWORD-1]}" + return 0 +} +source "${tmpScript}" + +COMP_WORDS=(sentry "${group.name}" "") +COMP_CWORD=2 +_sentry_completions +echo "\${COMPREPLY[*]}" +`, + ], + }); + const output = result.stdout.toString().trim(); + const completions = output.split(/\s+/); + + const expected = group.subcommands.map((s) => s.name).sort(); + expect(completions.sort()).toEqual(expected); + } + }); +}); diff --git a/test/lib/completions.test.ts b/test/lib/completions.test.ts index 4bb7de9c..eee21b15 100644 --- a/test/lib/completions.test.ts +++ b/test/lib/completions.test.ts @@ -1,172 +1,21 @@ +/** + * Completion Utilities Tests + * + * Unit tests for completion dispatch logic, path resolution, and file + * installation. Command tree invariants, cross-shell consistency, and + * bash simulation are in completions.property.test.ts. + */ + import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { - extractCommandTree, - generateBashCompletion, - generateFishCompletion, - generateZshCompletion, getCompletionPath, getCompletionScript, installCompletions, } from "../../src/lib/completions.js"; describe("completions", () => { - describe("extractCommandTree", () => { - test("returns groups and standalone commands", () => { - const tree = extractCommandTree(); - - expect(tree.groups.length).toBeGreaterThan(0); - expect(tree.standalone.length).toBeGreaterThan(0); - }); - - test("includes all known command groups", () => { - const tree = extractCommandTree(); - const groupNames = tree.groups.map((g) => g.name); - - expect(groupNames).toContain("auth"); - expect(groupNames).toContain("cli"); - expect(groupNames).toContain("org"); - expect(groupNames).toContain("project"); - expect(groupNames).toContain("issue"); - expect(groupNames).toContain("event"); - expect(groupNames).toContain("log"); - }); - - test("includes all auth subcommands including token", () => { - const tree = extractCommandTree(); - const auth = tree.groups.find((g) => g.name === "auth"); - const subNames = auth!.subcommands.map((s) => s.name); - - expect(subNames).toContain("login"); - expect(subNames).toContain("logout"); - expect(subNames).toContain("status"); - expect(subNames).toContain("refresh"); - expect(subNames).toContain("token"); - }); - - test("includes log subcommands", () => { - const tree = extractCommandTree(); - const log = tree.groups.find((g) => g.name === "log"); - const subNames = log!.subcommands.map((s) => s.name); - - expect(subNames).toContain("list"); - }); - - test("includes shortcut aliases as standalone commands", () => { - const tree = extractCommandTree(); - const standaloneNames = tree.standalone.map((s) => s.name); - - expect(standaloneNames).toContain("issues"); - expect(standaloneNames).toContain("orgs"); - expect(standaloneNames).toContain("projects"); - expect(standaloneNames).toContain("logs"); - }); - - test("includes api and help as standalone", () => { - const tree = extractCommandTree(); - const standaloneNames = tree.standalone.map((s) => s.name); - - expect(standaloneNames).toContain("api"); - expect(standaloneNames).toContain("help"); - }); - - test("every group has a non-empty brief", () => { - const tree = extractCommandTree(); - - for (const group of tree.groups) { - expect(group.brief.length).toBeGreaterThan(0); - } - }); - - test("every subcommand has a non-empty brief", () => { - const tree = extractCommandTree(); - - for (const group of tree.groups) { - for (const sub of group.subcommands) { - expect(sub.brief.length).toBeGreaterThan(0); - } - } - }); - }); - - describe("generateBashCompletion", () => { - test("generates valid bash completion script", () => { - const script = generateBashCompletion("sentry"); - - expect(script).toContain("_sentry_completions()"); - expect(script).toContain("complete -F _sentry_completions sentry"); - expect(script).toContain("auth"); - expect(script).toContain("issue"); - expect(script).toContain("cli"); - expect(script).toContain("log"); - }); - - test("uses custom binary name", () => { - const script = generateBashCompletion("my-cli"); - - expect(script).toContain("_my-cli_completions()"); - expect(script).toContain("complete -F _my-cli_completions my-cli"); - }); - - test("includes all subcommands in case branches", () => { - const script = generateBashCompletion("sentry"); - - // Verify case branches exist for each group - expect(script).toContain("auth)"); - expect(script).toContain("cli)"); - expect(script).toContain("issue)"); - expect(script).toContain("org)"); - expect(script).toContain("project)"); - expect(script).toContain("event)"); - expect(script).toContain("log)"); - }); - }); - - describe("generateZshCompletion", () => { - test("generates valid zsh completion script", () => { - const script = generateZshCompletion("sentry"); - - expect(script).toContain("#compdef sentry"); - expect(script).toContain("_sentry()"); - expect(script).toContain("'auth:Authenticate with Sentry'"); - expect(script).toContain("'issue:Manage Sentry issues'"); - expect(script).toContain("'log:View Sentry logs'"); - }); - - test("includes token subcommand in auth", () => { - const script = generateZshCompletion("sentry"); - - expect(script).toContain("'token:Print the stored authentication token'"); - }); - }); - - describe("generateFishCompletion", () => { - test("generates valid fish completion script", () => { - const script = generateFishCompletion("sentry"); - - expect(script).toContain("complete -c sentry"); - expect(script).toContain('__fish_use_subcommand" -a "auth"'); - expect(script).toContain('__fish_seen_subcommand_from auth" -a "login"'); - }); - - test("includes log group and subcommands", () => { - const script = generateFishCompletion("sentry"); - - expect(script).toContain('__fish_use_subcommand" -a "log"'); - expect(script).toContain('__fish_seen_subcommand_from log" -a "list"'); - }); - - test("includes aliases as top-level commands", () => { - const script = generateFishCompletion("sentry"); - - expect(script).toContain('__fish_use_subcommand" -a "issues"'); - expect(script).toContain('__fish_use_subcommand" -a "orgs"'); - expect(script).toContain('__fish_use_subcommand" -a "projects"'); - expect(script).toContain('__fish_use_subcommand" -a "logs"'); - }); - }); - describe("getCompletionScript", () => { test("returns bash script for bash", () => { const script = getCompletionScript("bash"); @@ -257,7 +106,6 @@ describe("completions", () => { }); test("installs fish completions", async () => { - // Fish uses ~/.config/fish, so we need to create the structure const fishDir = join(testDir, ".config", "fish", "completions"); mkdirSync(fishDir, { recursive: true }); @@ -273,11 +121,9 @@ describe("completions", () => { }); test("reports update when file already exists", async () => { - // Install once const first = await installCompletions("bash", testDir); expect(first!.created).toBe(true); - // Install again const second = await installCompletions("bash", testDir); expect(second!.created).toBe(false); expect(second!.path).toBe(first!.path); diff --git a/test/lib/shell.property.test.ts b/test/lib/shell.property.test.ts new file mode 100644 index 00000000..29b02f65 --- /dev/null +++ b/test/lib/shell.property.test.ts @@ -0,0 +1,265 @@ +/** + * Property-Based Tests for Shell Utilities + * + * Uses fast-check to verify invariants that should hold for any valid input, + * catching edge cases that hand-picked unit tests would miss. + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { + asyncProperty, + constantFrom, + assert as fcAssert, + property, + uniqueArray, +} from "fast-check"; +import { + addToPath, + detectShellType, + getConfigCandidates, + getPathCommand, + isInPath, + type ShellType, +} from "../../src/lib/shell.js"; +import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; + +// -- Arbitraries -- + +/** All valid ShellType values */ +const allShellTypes: ShellType[] = [ + "bash", + "zsh", + "fish", + "sh", + "ash", + "unknown", +]; + +/** Known shells that map to a named type (excludes "unknown") */ +const knownShells = ["bash", "zsh", "fish", "sh", "ash"] as const; + +const shellTypeArb = constantFrom(...allShellTypes); +const knownShellArb = constantFrom(...knownShells); + +/** Generate directory-like path prefixes */ +const pathPrefixArb = constantFrom( + "/bin", + "/usr/bin", + "/usr/local/bin", + "/home/user/.local/bin", + "/opt/homebrew/bin", + "/nix/store/abc123-bash-5.2/bin", + "/snap/bin" +); + +/** Generate absolute directory paths */ +const directoryArb = constantFrom( + "/home/user/.sentry/bin", + "/home/user/.local/bin", + "/usr/local/bin", + "/opt/sentry/bin", + "/home/user/bin", + "/tmp/test/bin" +); + +/** Generate home directory paths */ +const homeDirArb = constantFrom( + "/home/user", + "/home/alice", + "/Users/bob", + "/root" +); + +/** Generate PATH strings from a set of directories */ +const pathStringArb = uniqueArray(directoryArb, { + minLength: 1, + maxLength: 6, +}).map((dirs) => dirs.join(":")); + +// -- Tests -- + +describe("property: detectShellType", () => { + test("known shells are detected regardless of path prefix", () => { + fcAssert( + property(pathPrefixArb, knownShellArb, (prefix, shell) => { + const result = detectShellType(`${prefix}/${shell}`); + expect(result).toBe(shell); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("result depends only on the basename of the path", () => { + fcAssert( + property( + pathPrefixArb, + pathPrefixArb, + knownShellArb, + (prefix1, prefix2, shell) => { + expect(detectShellType(`${prefix1}/${shell}`)).toBe( + detectShellType(`${prefix2}/${shell}`) + ); + } + ), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("always returns a valid ShellType", () => { + fcAssert( + property(pathPrefixArb, (prefix) => { + // Even for unrecognized shells, should return a valid type + const result = detectShellType(`${prefix}/xonsh`); + expect(allShellTypes).toContain(result); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("undefined always returns 'unknown'", () => { + expect(detectShellType(undefined)).toBe("unknown"); + }); +}); + +describe("property: getConfigCandidates", () => { + test("always returns a non-empty array", () => { + fcAssert( + property(shellTypeArb, homeDirArb, (shellType, homeDir) => { + const candidates = getConfigCandidates(shellType, homeDir); + expect(candidates.length).toBeGreaterThan(0); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("every path starts with the home directory", () => { + fcAssert( + property(shellTypeArb, homeDirArb, (shellType, homeDir) => { + const candidates = getConfigCandidates(shellType, homeDir); + for (const path of candidates) { + expect(path.startsWith(homeDir)).toBe(true); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: getPathCommand", () => { + test("always contains the directory in the output", () => { + fcAssert( + property(shellTypeArb, directoryArb, (shellType, dir) => { + const cmd = getPathCommand(shellType, dir); + expect(cmd).toContain(dir); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("directory is always quoted", () => { + fcAssert( + property(shellTypeArb, directoryArb, (shellType, dir) => { + const cmd = getPathCommand(shellType, dir); + expect(cmd).toContain(`"${dir}`); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("fish returns fish_add_path, others return export PATH=", () => { + fcAssert( + property(directoryArb, (dir) => { + expect(getPathCommand("fish", dir)).toContain("fish_add_path"); + for (const shell of ["bash", "zsh", "sh", "ash", "unknown"] as const) { + expect(getPathCommand(shell, dir)).toContain("export PATH="); + } + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: isInPath", () => { + test("a directory in a PATH string is always found", () => { + fcAssert( + property(directoryArb, pathStringArb, (dir, pathStr) => { + // Construct a PATH that definitely contains dir + const fullPath = `${pathStr}:${dir}`; + expect(isInPath(dir, fullPath)).toBe(true); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("a directory not in PATH is never found", () => { + // Use a directory that's guaranteed not to be in our set + const absentDir = "/this/path/is/never/in/the/set"; + fcAssert( + property(pathStringArb, (pathStr) => { + expect(isInPath(absentDir, pathStr)).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("undefined PATH always returns false", () => { + fcAssert( + property(directoryArb, (dir) => { + expect(isInPath(dir, undefined)).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); + +describe("property: addToPath", () => { + let testDir: string; + + beforeEach(() => { + testDir = join( + "/tmp", + `shell-prop-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + test("idempotent: second call returns modified=false", async () => { + let fileCounter = 0; + const shellArb = constantFrom(...(["bash", "zsh", "fish"] as const)); + await fcAssert( + asyncProperty(shellArb, directoryArb, async (shellType, dir) => { + fileCounter += 1; + const configFile = join(testDir, `.rc-${fileCounter}`); + const first = await addToPath(configFile, dir, shellType); + expect(first.modified).toBe(true); + + const second = await addToPath(configFile, dir, shellType); + expect(second.modified).toBe(false); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); + + test("round-trip: file contains the path command after addToPath", async () => { + let fileCounter = 0; + const shellArb = constantFrom(...(["bash", "zsh", "fish"] as const)); + await fcAssert( + asyncProperty(shellArb, directoryArb, async (shellType, dir) => { + fileCounter += 1; + const configFile = join(testDir, `.rc-rt-${fileCounter}`); + await addToPath(configFile, dir, shellType); + + const content = await Bun.file(configFile).text(); + const expectedCmd = getPathCommand(shellType, dir); + expect(content).toContain(expectedCmd); + }), + { numRuns: DEFAULT_NUM_RUNS } + ); + }); +}); diff --git a/test/lib/shell.test.ts b/test/lib/shell.test.ts index 477accdc..f0545b84 100644 --- a/test/lib/shell.test.ts +++ b/test/lib/shell.test.ts @@ -1,3 +1,10 @@ +/** + * Shell Utilities Tests + * + * Unit tests for I/O-dependent shell operations (file creation, writing, + * GitHub Actions). Pure function tests are in shell.property.test.ts. + */ + import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; @@ -5,81 +12,18 @@ import { addToGitHubPath, addToPath, detectShell, - detectShellType, findExistingConfigFile, getConfigCandidates, - getPathCommand, - isInPath, } from "../../src/lib/shell.js"; describe("shell utilities", () => { - describe("detectShellType", () => { - test("detects bash", () => { - expect(detectShellType("/bin/bash")).toBe("bash"); - expect(detectShellType("/usr/bin/bash")).toBe("bash"); - }); - - test("detects zsh", () => { - expect(detectShellType("/bin/zsh")).toBe("zsh"); - expect(detectShellType("/usr/local/bin/zsh")).toBe("zsh"); - }); - - test("detects fish", () => { - expect(detectShellType("/usr/bin/fish")).toBe("fish"); - }); - - test("detects sh", () => { - expect(detectShellType("/bin/sh")).toBe("sh"); - }); - - test("detects ash", () => { - expect(detectShellType("/bin/ash")).toBe("ash"); - }); - - test("returns unknown for unrecognized shells", () => { - expect(detectShellType("/bin/tcsh")).toBe("unknown"); - expect(detectShellType("/bin/csh")).toBe("unknown"); - }); - - test("returns unknown for undefined", () => { - expect(detectShellType(undefined)).toBe("unknown"); - }); - }); - describe("getConfigCandidates", () => { - const homeDir = "/home/user"; - const xdgConfigHome = "/home/user/.config"; - - test("returns bash config candidates", () => { - const candidates = getConfigCandidates("bash", homeDir, xdgConfigHome); - expect(candidates).toContain("/home/user/.bashrc"); - expect(candidates).toContain("/home/user/.bash_profile"); - expect(candidates).toContain("/home/user/.profile"); - }); - - test("returns zsh config candidates", () => { - const candidates = getConfigCandidates("zsh", homeDir, xdgConfigHome); - expect(candidates).toContain("/home/user/.zshrc"); - expect(candidates).toContain("/home/user/.zshenv"); - }); - - test("returns fish config candidates", () => { - const candidates = getConfigCandidates("fish", homeDir, xdgConfigHome); - expect(candidates).toContain("/home/user/.config/fish/config.fish"); - }); - - test("returns profile for sh", () => { - const candidates = getConfigCandidates("sh", homeDir, xdgConfigHome); - expect(candidates).toContain("/home/user/.profile"); - }); - - test("uses default XDG_CONFIG_HOME when not provided", () => { - const candidates = getConfigCandidates("fish", homeDir); - expect(candidates).toContain("/home/user/.config/fish/config.fish"); - }); - test("returns fallback candidates for unknown shell", () => { - const candidates = getConfigCandidates("unknown", homeDir, xdgConfigHome); + const candidates = getConfigCandidates( + "unknown", + "/home/user", + "/home/user/.config" + ); expect(candidates).toContain("/home/user/.bashrc"); expect(candidates).toContain("/home/user/.bash_profile"); expect(candidates).toContain("/home/user/.profile"); @@ -150,41 +94,6 @@ describe("shell utilities", () => { }); }); - describe("getPathCommand", () => { - test("returns fish command for fish shell", () => { - const cmd = getPathCommand("fish", "/home/user/.local/bin"); - expect(cmd).toBe('fish_add_path "/home/user/.local/bin"'); - }); - - test("returns export command for other shells", () => { - expect(getPathCommand("bash", "/home/user/.local/bin")).toBe( - 'export PATH="/home/user/.local/bin:$PATH"' - ); - expect(getPathCommand("zsh", "/home/user/.local/bin")).toBe( - 'export PATH="/home/user/.local/bin:$PATH"' - ); - expect(getPathCommand("sh", "/home/user/.local/bin")).toBe( - 'export PATH="/home/user/.local/bin:$PATH"' - ); - }); - }); - - describe("isInPath", () => { - test("returns true when directory is in PATH", () => { - const path = "/usr/bin:/home/user/.local/bin:/bin"; - expect(isInPath("/home/user/.local/bin", path)).toBe(true); - }); - - test("returns false when directory is not in PATH", () => { - const path = "/usr/bin:/bin"; - expect(isInPath("/home/user/.local/bin", path)).toBe(false); - }); - - test("returns false for undefined PATH", () => { - expect(isInPath("/home/user/.local/bin", undefined)).toBe(false); - }); - }); - describe("addToPath", () => { let testDir: string; @@ -250,20 +159,6 @@ describe("shell utilities", () => { expect(result.message).toContain("already configured"); }); - test("uses fish syntax for fish shell", async () => { - const configFile = join(testDir, "config.fish"); - const result = await addToPath( - configFile, - "/home/user/.sentry/bin", - "fish" - ); - - expect(result.modified).toBe(true); - - const content = await Bun.file(configFile).text(); - expect(content).toContain('fish_add_path "/home/user/.sentry/bin"'); - }); - test("appends newline separator when file doesn't end with newline", async () => { const configFile = join(testDir, ".bashrc"); writeFileSync(configFile, "# existing content without newline"); @@ -277,14 +172,12 @@ describe("shell utilities", () => { expect(result.modified).toBe(true); const content = await Bun.file(configFile).text(); - // Should have double newline separator before sentry block expect(content).toContain( "# existing content without newline\n\n# sentry\n" ); }); test("returns manualCommand when config file cannot be created", async () => { - // Point to a path inside a nonexistent, deeply nested directory that can't be auto-created const configFile = "/dev/null/impossible/path/.bashrc"; const result = await addToPath( configFile, @@ -297,29 +190,6 @@ describe("shell utilities", () => { 'export PATH="/home/user/.sentry/bin:$PATH"' ); }); - - test("returns manualCommand when existing config file is not writable", async () => { - const configFile = join(testDir, ".bashrc"); - writeFileSync(configFile, "# existing\n"); - const { chmodSync } = await import("node:fs"); - chmodSync(configFile, 0o444); - - const result = await addToPath( - configFile, - "/home/user/.sentry/bin", - "bash" - ); - - // On failure to write, should return manual command - // Restore permissions for cleanup - chmodSync(configFile, 0o644); - - // The file is read-only, but Bun.write may override on some systems - // At minimum, verify the result is valid - expect( - result.configFile === configFile || result.manualCommand !== null - ).toBe(true); - }); }); describe("addToGitHubPath", () => { @@ -363,21 +233,6 @@ describe("shell utilities", () => { expect(content).toContain("/usr/local/bin"); }); - test("appends to existing GITHUB_PATH content", async () => { - const pathFile = join(testDir, "github_path"); - writeFileSync(pathFile, "/existing/path\n"); - - const result = await addToGitHubPath("/usr/local/bin", { - GITHUB_ACTIONS: "true", - GITHUB_PATH: pathFile, - }); - - expect(result).toBe(true); - const content = await Bun.file(pathFile).text(); - expect(content).toContain("/existing/path"); - expect(content).toContain("/usr/local/bin"); - }); - test("does not duplicate existing directory", async () => { const pathFile = join(testDir, "github_path"); writeFileSync(pathFile, "/usr/local/bin\n"); @@ -389,7 +244,6 @@ describe("shell utilities", () => { expect(result).toBe(true); const content = await Bun.file(pathFile).text(); - // Should still be just the one entry expect(content).toBe("/usr/local/bin\n"); });