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..fa0ff9a7 100755 --- a/install +++ b/install @@ -14,14 +14,22 @@ 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.) + --no-completions Don't install shell completions + +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 +no_completions=false while [[ $# -gt 0 ]]; do case "$1" in -h|--help) usage; exit 0 ;; @@ -34,6 +42,14 @@ while [[ $# -gt 0 ]]; do exit 1 fi ;; + --no-modify-path) + no_modify_path=true + shift + ;; + --no-completions) + no_completions=true + shift + ;; *) shift ;; esac done @@ -80,22 +96,58 @@ 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, setup command will handle PATH) +install_dir="" + +if [[ -n "${SENTRY_INSTALL_DIR:-}" ]]; then + 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 +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" +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" +# 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 --no-modify-path" +fi +if [[ "$no_completions" == "true" ]]; then + setup_args="$setup_args --no-completions" +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 "" -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}" echo " sentry --help" echo "" 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 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/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/index.ts b/src/commands/cli/index.ts index 4e46fc8f..7d72f63d 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 { setupCommand } from "./setup.js"; import { upgradeCommand } from "./upgrade.js"; export const cliRoute = buildRouteMap({ routes: { feedback: feedbackCommand, fix: fixCommand, + 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..57ef9bc7 --- /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 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"; +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/commands/cli/upgrade.ts b/src/commands/cli/upgrade.ts index 79f0cbaa..3b106ac9 100644 --- a/src/commands/cli/upgrade.ts +++ b/src/commands/cli/upgrade.ts @@ -4,9 +4,10 @@ * 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"; 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/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..877161b1 --- /dev/null +++ b/src/lib/command.ts @@ -0,0 +1,100 @@ +/** + * 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 { setArgsContext, 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 and args before execution + const wrappedFunc = function ( + this: CONTEXT, + flags: FLAGS, + ...args: ARGS + ): ReturnType { + // 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; + + // 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/completions.ts b/src/lib/completions.ts new file mode 100644 index 00000000..d87d162b --- /dev/null +++ b/src/lib/completions.ts @@ -0,0 +1,340 @@ +/** + * Shell completion script generation. + * + * 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 */ +export type CompletionLocation = { + /** Path where the completion file was installed */ + path: string; + /** Whether the file was created or already existed */ + 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} +# Auto-generated from command definitions +_${binaryName}_completions() { + local cur prev words cword + _init_completion || return + + local commands="${allTopLevel.join(" ")}" +${subVars} + + case "\${COMP_CWORD}" in + 1) + COMPREPLY=($(compgen -W "\${commands}" -- "\${cur}")) + ;; + 2) + case "\${prev}" in +${caseBranches} + esac + ;; + esac +} + +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} +# Auto-generated from command definitions + +_${binaryName}() { + local -a commands + commands=( +${topLevelItems} + ) + +${subArrays} + + _arguments -C \\ + '1: :->command' \\ + '2: :->subcommand' \\ + '*::arg:->args' + + case "$state" in + command) + _describe -t commands 'command' commands + ;; + subcommand) + case "$words[1]" in +${caseBranches} + esac + ;; + esac +} + +_${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} +# Auto-generated from command definitions + +# Disable file completion by default +complete -c ${binaryName} -f + +# Top-level commands +${topLevelLines} +${subLines} +`; +} + +/** + * 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/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/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/src/lib/telemetry.ts b/src/lib/telemetry.ts index 0cce1c48..35104372 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -214,6 +214,83 @@ 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); + } +} + +/** + * 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/src/lib/upgrade.ts b/src/lib/upgrade.ts index 13ad8a5d..9d7e7fd8 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-setup command) + 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..db7eeaa5 100644 --- a/test/commands/cli.test.ts +++ b/test/commands/cli.test.ts @@ -81,6 +81,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 +107,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 +129,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 +152,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 +184,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) }, }; 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 new file mode 100644 index 00000000..e2c13768 --- /dev/null +++ b/test/lib/command.test.ts @@ -0,0 +1,256 @@ +/** + * 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 { 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("builds a valid command object", () => { + const command = buildCommand({ + docs: { brief: "Test command" }, + parameters: { + flags: { + verbose: { kind: "boolean", brief: "Verbose", default: false }, + }, + }, + func(_flags: { verbose: boolean }) { + // no-op + }, + }); + expect(command).toBeDefined(); + }); + + test("handles commands with empty parameters", () => { + const command = buildCommand({ + docs: { brief: "Simple command" }, + parameters: {}, + func() { + // no-op + }, + }); + expect(command).toBeDefined(); + }); + + 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: { + verbose: { kind: "boolean", brief: "Verbose", default: false }, + limit: { + kind: "parsed", + parse: numberParser, + brief: "Limit", + default: "10", + }, + }, + }, + func(this: TestContext, flags: { verbose: boolean; limit: number }) { + calledWith = flags; + }, + }); + + 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("skips false boolean flags in telemetry", async () => { + const command = buildCommand<{ json: boolean }, [], TestContext>({ + docs: { brief: "Test" }, + parameters: { + flags: { + json: { kind: "boolean", brief: "JSON output", default: false }, + }, + }, + func(_flags: { json: boolean }) { + // no-op + }, + }); + + 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/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 new file mode 100644 index 00000000..eee21b15 --- /dev/null +++ b/test/lib/completions.test.ts @@ -0,0 +1,132 @@ +/** + * 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 { + getCompletionPath, + getCompletionScript, + installCompletions, +} from "../../src/lib/completions.js"; + +describe("completions", () => { + 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 () => { + 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 () => { + const first = await installCompletions("bash", testDir); + expect(first!.created).toBe(true); + + const second = await installCompletions("bash", testDir); + expect(second!.created).toBe(false); + expect(second!.path).toBe(first!.path); + }); + }); +}); 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(); + }); +}); 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 new file mode 100644 index 00000000..f0545b84 --- /dev/null +++ b/test/lib/shell.test.ts @@ -0,0 +1,259 @@ +/** + * 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"; +import { + addToGitHubPath, + addToPath, + detectShell, + findExistingConfigFile, + getConfigCandidates, +} from "../../src/lib/shell.js"; + +describe("shell utilities", () => { + describe("getConfigCandidates", () => { + test("returns fallback candidates for unknown shell", () => { + 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"); + }); + }); + + 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("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("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(); + expect(content).toContain( + "# existing content without newline\n\n# sentry\n" + ); + }); + + test("returns manualCommand when config file cannot be created", async () => { + 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"' + ); + }); + }); + + 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("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(); + 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); + }); + }); +}); diff --git a/test/lib/telemetry.test.ts b/test/lib/telemetry.test.ts index 15e972be..e75f2b4b 100644 --- a/test/lib/telemetry.test.ts +++ b/test/lib/telemetry.test.ts @@ -5,11 +5,15 @@ */ 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, withDbSpan, withFsSpan, @@ -149,6 +153,143 @@ describe("setOrgProjectContext", () => { }); }); +describe("setFlagContext", () => { + let setTagSpy: ReturnType; + + beforeEach(() => { + setTagSpy = spyOn(Sentry, "setTag"); + }); + + afterEach(() => { + setTagSpy.mockRestore(); + }); + + test("does not set tags for empty flags object", () => { + setFlagContext({}); + expect(setTagSpy).not.toHaveBeenCalled(); + }); + + 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("does not set tags for boolean flags when false", () => { + setFlagContext({ verbose: false, debug: false }); + expect(setTagSpy).not.toHaveBeenCalled(); + }); + + 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("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("does not set tags for undefined or null values", () => { + setFlagContext({ value: undefined, other: null }); + expect(setTagSpy).not.toHaveBeenCalled(); + }); + + test("does not set tags for empty string values", () => { + setFlagContext({ name: "" }); + expect(setTagSpy).not.toHaveBeenCalled(); + }); + + 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", () => { + 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, + }); + }); +}); + describe("withHttpSpan", () => { test("executes function and returns result", async () => { const result = await withHttpSpan("GET", "/test", async () => "success"); 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