diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ecac4c2b..b6114f6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,11 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: true +env: + # Commit timestamp used for deterministic nightly version strings. + # Defined at workflow level so build-binary and publish-nightly always agree. + COMMIT_TIMESTAMP: ${{ github.event.head_commit.timestamp }} + jobs: changes: name: Detect Changes @@ -178,6 +183,20 @@ jobs: done echo "All install attempts failed" exit 1 + - name: Set nightly version + # Inject a nightly version into package.json before the build so it gets + # baked into the binary. Only runs on direct pushes to main. + # Uses the commit timestamp (seconds since epoch) as a deterministic + # value so every matrix leg and publish-nightly produce the same version + # string for a given commit. + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + shell: bash + run: | + TS=$(date -d "$COMMIT_TIMESTAMP" +%s 2>/dev/null || date -jf "%Y-%m-%dT%H:%M:%SZ" "$COMMIT_TIMESTAMP" +%s) + CURRENT=$(jq -r .version package.json) + NIGHTLY=$(echo "$CURRENT" | sed "s/-dev\.[0-9]*$/-dev.${TS}/") + jq --arg v "$NIGHTLY" '.version = $v' package.json > package.json.tmp + mv package.json.tmp package.json - name: Build env: SENTRY_CLIENT_ID: ${{ vars.SENTRY_CLIENT_ID }} @@ -282,17 +301,80 @@ jobs: name: gh-pages path: gh-pages.zip + publish-nightly: + name: Publish Nightly + # Only publish after a successful main-branch build+test. Skipped on PRs + # and release branches. + needs: [build-binary, test-e2e] + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: package.json + - name: Compute nightly version + # Uses the commit timestamp — the same value as build-binary — so + # version.json exactly matches the version baked into the binaries. + id: version + run: | + TS=$(date -d "$COMMIT_TIMESTAMP" +%s) + CURRENT=$(jq -r .version package.json) + VERSION=$(echo "$CURRENT" | sed "s/-dev\.[0-9]*$/-dev.${TS}/") + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + - name: Download all binary artifacts + uses: actions/download-artifact@v4 + with: + pattern: sentry-* + path: artifacts + merge-multiple: true + - name: Create version.json + run: | + cat > version.json </dev/null || true + + # Update release notes with the latest version + commit + gh release edit nightly \ + --prerelease \ + --notes "Latest nightly build from the \`main\` branch. + + **Version:** \`${{ steps.version.outputs.version }}\` + **Commit:** ${{ github.sha }}" + + # Delete all existing assets first so removed/renamed files don't linger + gh release view nightly --json assets --jq '.assets[].name' | while read -r name; do + gh release delete-asset nightly "$name" --yes + done + + # Upload the new .gz binaries and the version manifest + gh release upload nightly \ + artifacts/dist-bin/*.gz \ + version.json + ci-status: name: CI Status if: always() - needs: [changes, check-skill, build-binary, build-npm, build-docs, test-e2e] + needs: [changes, check-skill, build-binary, build-npm, build-docs, test-e2e, publish-nightly] runs-on: ubuntu-latest permissions: {} steps: - name: Check CI status run: | # Check for explicit failures or cancellations in all jobs - results="${{ needs.check-skill.result }} ${{ needs.build-binary.result }} ${{ needs.build-npm.result }} ${{ needs.build-docs.result }} ${{ needs.test-e2e.result }}" + results="${{ needs.check-skill.result }} ${{ needs.build-binary.result }} ${{ needs.build-npm.result }} ${{ needs.build-docs.result }} ${{ needs.test-e2e.result }} ${{ needs.publish-nightly.result }}" for result in $results; do if [[ "$result" == "failure" || "$result" == "cancelled" ]]; then echo "::error::CI failed" diff --git a/docs/src/content/docs/commands/cli/upgrade.md b/docs/src/content/docs/commands/cli/upgrade.md index 9f1c3941..ae095861 100644 --- a/docs/src/content/docs/commands/cli/upgrade.md +++ b/docs/src/content/docs/commands/cli/upgrade.md @@ -8,9 +8,12 @@ Self-update the Sentry CLI to the latest or a specific version. ## Usage ```bash -sentry cli upgrade # Update to latest version -sentry cli upgrade 0.5.0 # Update to specific version +sentry cli upgrade # Update using the persisted channel (default: stable) +sentry cli upgrade nightly # Switch to nightly channel and update +sentry cli upgrade stable # Switch back to stable channel and update +sentry cli upgrade 0.5.0 # Update to a specific stable version sentry cli upgrade --check # Check for updates without installing +sentry cli upgrade --force # Force re-download even if already up to date sentry cli upgrade --method npm # Force using npm to upgrade ``` @@ -18,10 +21,24 @@ sentry cli upgrade --method npm # Force using npm to upgrade | Option | Description | |--------|-------------| -| `` | Target version to install (defaults to latest) | +| `` | Target version, or `nightly`/`stable` to switch channel (defaults to latest) | | `--check` | Check for updates without installing | +| `--force` | Re-download even if already on the latest version | +| `--channel ` | Set release channel: `stable` or `nightly` | | `--method ` | Force installation method: curl, brew, npm, pnpm, bun, yarn | +## Release Channels + +The CLI supports two release channels: + +| Channel | Description | +|---------|-------------| +| `stable` | Latest stable release (default) | +| `nightly` | Built from `main`, updated on every commit | + +The chosen channel is persisted locally so that subsequent bare `sentry cli upgrade` +calls use the same channel without requiring a flag. + ## Installation Detection The CLI auto-detects how it was installed and uses the same method to upgrade: @@ -35,6 +52,11 @@ The CLI auto-detects how it was installed and uses the same method to upgrade: | bun | Globally installed via `bun install -g sentry` | | yarn | Globally installed via `yarn global add sentry` | +> **Note:** Nightly builds are only available as standalone binaries (via the curl +> install method). If you switch to the nightly channel from a package manager or +> Homebrew install, the CLI will automatically migrate to a standalone binary and +> warn you about the existing package-manager installation. + ## Examples ### Check for updates @@ -46,12 +68,13 @@ sentry cli upgrade --check ``` Installation method: curl Current version: 0.4.0 +Channel: stable Latest version: 0.5.0 Run 'sentry cli upgrade' to update. ``` -### Upgrade to latest +### Upgrade to latest stable ```bash sentry cli upgrade @@ -60,6 +83,7 @@ sentry cli upgrade ``` Installation method: curl Current version: 0.4.0 +Channel: stable Latest version: 0.5.0 Upgrading to 0.5.0... @@ -67,12 +91,36 @@ Upgrading to 0.5.0... Successfully upgraded to 0.5.0. ``` +### Switch to nightly channel + +```bash +sentry cli upgrade nightly +# or equivalently: +sentry cli upgrade --channel nightly +``` + +After switching, bare `sentry cli upgrade` will continue tracking nightly. + +### Switch back to stable + +```bash +sentry cli upgrade stable +# or equivalently: +sentry cli upgrade --channel stable +``` + ### Upgrade to specific version ```bash sentry cli upgrade 0.5.0 ``` +### Force re-download + +```bash +sentry cli upgrade --force +``` + ### Force installation method If auto-detection fails or you want to switch installation methods: diff --git a/docs/src/content/docs/getting-started.mdx b/docs/src/content/docs/getting-started.mdx index e298feaa..ee9261b9 100644 --- a/docs/src/content/docs/getting-started.mdx +++ b/docs/src/content/docs/getting-started.mdx @@ -9,12 +9,21 @@ import PackageManagerCode from "../../components/PackageManagerCode.astro"; ### Install Script -Install the Sentry CLI using the install script: +Install the latest stable release: ```bash curl https://cli.sentry.dev/install -fsS | bash ``` +Install the nightly build (built from `main`, updated on every commit): + +```bash +curl https://cli.sentry.dev/install -fsS | bash -s -- --version nightly +``` + +The chosen channel is persisted so that `sentry cli upgrade` automatically +tracks the same channel on future updates. + ### Homebrew ```bash diff --git a/install b/install index 8289ce00..d5bb4c5f 100755 --- a/install +++ b/install @@ -13,7 +13,7 @@ Usage: install [options] Options: -h, --help Display this help message - -v, --version Install a specific version (e.g., 0.2.0) + -v, --version Install a specific version (e.g., 0.2.0) or "nightly" --no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.) --no-completions Don't install shell completions @@ -22,6 +22,7 @@ Environment Variables: Examples: curl -fsSL https://cli.sentry.dev/install | bash + curl -fsSL https://cli.sentry.dev/install | bash -s -- --version nightly 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 @@ -80,21 +81,38 @@ if [[ "$os" == "windows" ]]; then fi fi -# Resolve version +# Resolve version and download tag. +# +# "nightly" is a special value that installs from the rolling nightly prerelease +# built from the main branch. In this case both `version` and `download_tag` +# are set to the literal string "nightly". +# +# For stable releases both are the same version string (e.g. "0.5.0"). +channel="stable" +download_tag="" + 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 + download_tag="$version" +elif [[ "$requested_version" == "nightly" ]]; then + channel="nightly" + download_tag="nightly" + version="nightly" else version="$requested_version" + download_tag="$requested_version" fi # Strip leading 'v' if present (releases use version without 'v' prefix) version="${version#v}" +download_tag="${download_tag#v}" + filename="sentry-${os}-${arch}${suffix}" -url="https://github.com/getsentry/cli/releases/download/${version}/${filename}" +url="https://github.com/getsentry/cli/releases/download/${download_tag}/${filename}" # Download binary to a temp location tmpdir="${TMPDIR:-${TMP:-${TEMP:-/tmp}}}" @@ -103,7 +121,13 @@ tmp_binary="${tmpdir}/sentry-install-$$${suffix}" # Clean up temp binary on failure (setup handles cleanup on success) trap 'rm -f "$tmp_binary"' EXIT -echo -e "${MUTED}Downloading sentry v${version}...${NC}" +# For nightly the version string is literally "nightly", not a semver, so +# skip the "v" prefix that's only meaningful for numbered releases. +if [[ "$version" == "nightly" ]]; then + echo -e "${MUTED}Downloading sentry nightly...${NC}" +else + echo -e "${MUTED}Downloading sentry v${version}...${NC}" +fi # Try gzip-compressed download first (~60% smaller, ~37 MB vs ~99 MB). # gunzip is POSIX and available on all Unix systems. @@ -119,7 +143,9 @@ chmod +x "$tmp_binary" # Delegate installation and configuration to the binary itself. # setup --install handles: directory selection, binary placement, PATH, # completions, agent skills, and the welcome message. -setup_args="--install --method curl" +# --channel persists the release channel so future `sentry cli upgrade` +# calls track the same channel without requiring a flag. +setup_args="--install --method curl --channel $channel" if [[ "$no_modify_path" == "true" ]]; then setup_args="$setup_args --no-modify-path" fi diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index c7e6b769..27ced2f4 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -15,6 +15,7 @@ The CLI must be installed and authenticated before use. ```bash curl https://cli.sentry.dev/install -fsS | bash +curl https://cli.sentry.dev/install -fsS | bash -s -- --version nightly brew install getsentry/tools/sentry # Or install via npm/pnpm/bun @@ -426,6 +427,7 @@ Configure shell integration **Flags:** - `--install - Install the binary from a temp location to the system path` - `--method - Installation method (curl, npm, pnpm, bun, yarn)` +- `--channel - Release channel to persist (stable or nightly)` - `--no-modify-path - Skip PATH modification` - `--no-completions - Skip shell completion installation` - `--no-agent-skills - Skip agent skill installation for AI coding assistants` @@ -437,6 +439,7 @@ Update the Sentry CLI to the latest version **Flags:** - `--check - Check for updates without installing` +- `--force - Force upgrade even if already on the latest version` - `--method - Installation method to use (curl, brew, npm, pnpm, bun, yarn)` ### Repo diff --git a/src/commands/cli/setup.ts b/src/commands/cli/setup.ts index 8bf720d8..a573b000 100644 --- a/src/commands/cli/setup.ts +++ b/src/commands/cli/setup.ts @@ -15,6 +15,11 @@ 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 { + parseReleaseChannel, + type ReleaseChannel, + setReleaseChannel, +} from "../../lib/db/release-channel.js"; import { addToGitHubPath, addToPath, @@ -32,6 +37,7 @@ import { type SetupFlags = { readonly install: boolean; readonly method?: InstallationMethod; + readonly channel?: ReleaseChannel; readonly "no-modify-path": boolean; readonly "no-completions": boolean; readonly "no-agent-skills": boolean; @@ -292,6 +298,21 @@ async function runConfigurationSteps(opts: ConfigStepOptions): Promise { ); } + // 1b. Persist release channel (set by install script or upgrade command) + const channel = flags.channel; + if (channel) { + await bestEffort( + "Recording release channel", + () => { + setReleaseChannel(channel); + if (!flags.install) { + log(`Recorded release channel: ${channel}`); + } + }, + warn + ); + } + // 2. Handle PATH modification if (!flags["no-modify-path"]) { await bestEffort( @@ -365,6 +386,13 @@ export const setupCommand = buildCommand({ placeholder: "method", optional: true, }, + channel: { + kind: "parsed", + parse: parseReleaseChannel, + brief: "Release channel to persist (stable or nightly)", + placeholder: "channel", + optional: true, + }, "no-modify-path": { kind: "boolean", brief: "Skip PATH modification", diff --git a/src/commands/cli/upgrade.ts b/src/commands/cli/upgrade.ts index 829142e3..7b73a577 100644 --- a/src/commands/cli/upgrade.ts +++ b/src/commands/cli/upgrade.ts @@ -4,13 +4,27 @@ * Self-update the Sentry CLI to the latest or a specific version. * After upgrading, spawns the NEW binary with `cli setup` to update * completions, agent skills, and record installation metadata. + * + * Supports two release channels: + * - stable (default): tracks the latest GitHub release + * - nightly: tracks the rolling nightly prerelease built from main + * + * The channel can be set via --channel or by passing "nightly"/"stable" + * as the version argument. The choice is persisted in the local database + * so that subsequent bare `sentry cli upgrade` calls use the same channel. */ +import { homedir } from "node:os"; import { dirname } from "node:path"; import type { SentryContext } from "../../context.js"; -import { releaseLock } from "../../lib/binary.js"; +import { determineInstallDir, releaseLock } from "../../lib/binary.js"; import { buildCommand } from "../../lib/command.js"; import { CLI_VERSION } from "../../lib/constants.js"; +import { + getReleaseChannel, + type ReleaseChannel, + setReleaseChannel, +} from "../../lib/db/release-channel.js"; import { UpgradeError } from "../../lib/errors.js"; import { detectInstallationMethod, @@ -18,55 +32,93 @@ import { fetchLatestVersion, getCurlInstallPaths, type InstallationMethod, + NIGHTLY_TAG, parseInstallationMethod, VERSION_PREFIX_REGEX, versionExists, } from "../../lib/upgrade.js"; +/** Special version strings that select a channel rather than a specific release. */ +const CHANNEL_VERSIONS = new Set(["nightly", "stable"]); + type UpgradeFlags = { readonly check: boolean; + readonly force: boolean; readonly method?: InstallationMethod; }; +/** + * Resolve effective channel and version arg from the positional `version` + * parameter. "nightly" and "stable" are treated as channel selectors, not + * literal version strings. + * + * @returns `{ channel, versionArg }` where versionArg is undefined when the + * positional was a channel name (so we resolve to latest) or was omitted. + */ +function resolveChannelAndVersion(positional: string | undefined): { + channel: ReleaseChannel; + versionArg: string | undefined; +} { + // "nightly" and "stable" as positional args select the channel rather than + // installing a specific version. Match case-insensitively for convenience. + const lower = positional?.toLowerCase(); + if (lower === "nightly" || lower === "stable") { + return { + channel: lower, + versionArg: undefined, + }; + } + + return { + channel: getReleaseChannel(), + versionArg: positional, + }; +} + +type ResolveTargetOptions = { + method: InstallationMethod; + channel: ReleaseChannel; + versionArg: string | undefined; + channelChanged: boolean; + flags: UpgradeFlags; + stdout: { write: (s: string) => void }; +}; + /** * Resolve the target version and handle check-only mode. * * @returns The target version string, or null if no upgrade should proceed - * (check-only mode or already up to date). + * (check-only mode, already up to date without --force, or channel unchanged). */ async function resolveTargetVersion( - method: InstallationMethod, - version: string | undefined, - stdout: { write: (s: string) => void }, - check: boolean + opts: ResolveTargetOptions ): Promise { - const latest = await fetchLatestVersion(method); - const target = version?.replace(VERSION_PREFIX_REGEX, "") ?? latest; + const { method, channel, versionArg, channelChanged, flags, stdout } = opts; + const latest = await fetchLatestVersion(method, channel); + const target = versionArg?.replace(VERSION_PREFIX_REGEX, "") ?? latest; + stdout.write(`Channel: ${channel}\n`); stdout.write(`Latest version: ${latest}\n`); - if (version) { + if (versionArg) { stdout.write(`Target version: ${target}\n`); } - if (check) { - if (CLI_VERSION === target) { - stdout.write("\nYou are already on the target version.\n"); - } else { - const cmd = version - ? `sentry cli upgrade ${target}` - : "sentry cli upgrade"; - stdout.write(`\nRun '${cmd}' to update.\n`); - } - return null; + if (flags.check) { + return handleCheckMode(target, versionArg, stdout); } - if (CLI_VERSION === target) { + // Skip if already on target — unless forced or switching channels + if (CLI_VERSION === target && !flags.force && !channelChanged) { stdout.write("\nAlready up to date.\n"); return null; } - if (version) { - const exists = await versionExists(method, target); + // Validate that a specific pinned version actually exists. + // Nightly builds are GitHub-only, so always use curl (GitHub) lookup for + // nightly channel regardless of the current install method. + if (versionArg && !CHANNEL_VERSIONS.has(versionArg)) { + const lookupMethod = channel === "nightly" ? "curl" : method; + const exists = await versionExists(lookupMethod, target); if (!exists) { throw new UpgradeError( "version_not_found", @@ -78,6 +130,40 @@ async function resolveTargetVersion( return target; } +/** + * Print check-only output and return null (no upgrade to perform). + */ +function handleCheckMode( + target: string, + versionArg: string | undefined, + stdout: { write: (s: string) => void } +): null { + if (CLI_VERSION === target) { + stdout.write("\nYou are already on the target version.\n"); + } else { + const cmd = + versionArg && !CHANNEL_VERSIONS.has(versionArg) + ? `sentry cli upgrade ${target}` + : "sentry cli upgrade"; + stdout.write(`\nRun '${cmd}' to update.\n`); + } + return null; +} + +/** + * Spawn the new binary with `cli setup` to update completions, agent skills, + * and record installation metadata. + */ +type SetupOptions = { + binaryPath: string; + method: InstallationMethod; + channel: ReleaseChannel; + /** Whether setup should handle binary placement (curl --install flow) */ + install: boolean; + /** Pin the install directory (prevents relocation during upgrade) */ + installDir?: string; +}; + /** * Spawn the new binary with `cli setup` to update completions, agent skills, * and record installation metadata. @@ -89,19 +175,18 @@ async function resolveTargetVersion( * * For package manager upgrades: the binary is already in place, so setup only * updates completions, agent skills, and records metadata. - * - * @param binaryPath - Path to the new binary to spawn - * @param method - Installation method to pass through to setup - * @param install - Whether setup should handle binary placement (curl only) - * @param installDir - Pin the install directory (prevents relocation during upgrade) */ -async function runSetupOnNewBinary( - binaryPath: string, - method: InstallationMethod, - install: boolean, - installDir?: string -): Promise { - const args = ["cli", "setup", "--method", method, "--no-modify-path"]; +async function runSetupOnNewBinary(opts: SetupOptions): Promise { + const { binaryPath, method, channel, install, installDir } = opts; + const args = [ + "cli", + "setup", + "--method", + method, + "--channel", + channel, + "--no-modify-path", + ]; if (install) { args.push("--install"); } @@ -124,16 +209,92 @@ async function runSetupOnNewBinary( } } +/** + * Migrate from a package-manager or Homebrew install to a standalone binary + * when the user switches to the nightly channel. + * + * Nightly builds are distributed as standalone binaries only (GitHub release + * assets). When a user on brew/npm/pnpm/bun/yarn switches to nightly we: + * 1. Download the nightly binary to a temp path + * 2. Install it to `determineInstallDir()` (same logic as the curl installer) + * 3. Run setup on the new binary to update completions, PATH, and metadata + * 4. Warn about the old package-manager installation that may still be in PATH + * + * @param versionArg - Specific version requested by the user, or undefined for + * latest nightly. When a specific version is given, its release tag is used + * instead of the rolling "nightly" tag so the correct binary is downloaded. + */ +async function migrateToStandaloneForNightly( + method: InstallationMethod, + target: string, + stdout: { write: (s: string) => void }, + versionArg: string | undefined +): Promise { + stdout.write("\nNightly builds are only available as standalone binaries.\n"); + stdout.write("Migrating to standalone installation...\n\n"); + + // Use the rolling "nightly" tag for latest nightly; use the specific version + // tag if the user requested a pinned version. + const downloadTag = versionArg ? undefined : NIGHTLY_TAG; + const downloadResult = await executeUpgrade("curl", target, downloadTag); + if (!downloadResult) { + throw new UpgradeError( + "execution_failed", + "Failed to download nightly binary" + ); + } + + const installDir = determineInstallDir(homedir(), process.env); + + try { + await runSetupOnNewBinary({ + binaryPath: downloadResult.tempBinaryPath, + method: "curl", + channel: "nightly", + install: true, + installDir, + }); + } finally { + releaseLock(downloadResult.lockPath); + } + + // Warn about the potentially shadowing old installation + // Note: install info is already recorded by the child `setup --install` + // process, so no redundant setInstallInfo call is needed here. + const uninstallHints: Record = { + npm: "npm uninstall -g sentry", + pnpm: "pnpm remove -g sentry", + bun: "bun remove -g sentry", + yarn: "yarn global remove sentry", + brew: "brew uninstall getsentry/tools/sentry", + }; + const hint = uninstallHints[method]; + stdout.write( + `\nNote: your ${method}-installed sentry may still appear earlier in PATH.\n` + ); + if (hint) { + stdout.write(` Consider removing it: ${hint}\n`); + } +} + export const upgradeCommand = buildCommand({ docs: { brief: "Update the Sentry CLI to the latest version", fullDescription: "Check for updates and upgrade the Sentry CLI to the latest or a specific version.\n\n" + "By default, detects how the CLI was installed (npm, curl, etc.) and uses the same method to upgrade.\n\n" + + "Two release channels are supported:\n" + + " stable (default) Latest stable release\n" + + " nightly Built from main, updated on every commit\n\n" + + "The channel is persisted so that subsequent bare `sentry cli upgrade` calls\n" + + "use the same channel.\n\n" + "Examples:\n" + - " sentry cli upgrade # Update to latest version\n" + - " sentry cli upgrade 0.5.0 # Update to specific version\n" + + " sentry cli upgrade # Update to latest (using persisted channel)\n" + + " sentry cli upgrade nightly # Switch to nightly channel and update\n" + + " sentry cli upgrade stable # Switch back to stable channel and update\n" + + " sentry cli upgrade 0.5.0 # Install a specific stable version\n" + " sentry cli upgrade --check # Check for updates without installing\n" + + " sentry cli upgrade --force # Force re-download even if up to date\n" + " sentry cli upgrade --method npm # Force using npm to upgrade", }, parameters: { @@ -141,7 +302,8 @@ export const upgradeCommand = buildCommand({ kind: "tuple", parameters: [ { - brief: "Target version to install (defaults to latest)", + brief: + 'Specific version (e.g. 0.5.0), or "nightly"/"stable" to switch channel; omit to update within current channel', parse: String, placeholder: "version", optional: true, @@ -154,6 +316,11 @@ export const upgradeCommand = buildCommand({ brief: "Check for updates without installing", default: false, }, + force: { + kind: "boolean", + brief: "Force upgrade even if already on the latest version", + default: false, + }, method: { kind: "parsed", parse: parseInstallationMethod, @@ -170,6 +337,20 @@ export const upgradeCommand = buildCommand({ ): Promise { const { stdout } = this; + // Resolve effective channel and version from positional + const { channel, versionArg } = resolveChannelAndVersion(version); + + // Track whether the user is deliberately switching channels + const currentChannel = getReleaseChannel(); + const channelChanged = channel !== currentChannel; + + // Persist the channel so version-check and future upgrades respect it. + // We do this upfront — even if the download is skipped (e.g. --check) — + // so the preference is always recorded. + if (channelChanged || CHANNEL_VERSIONS.has(version ?? "")) { + setReleaseChannel(channel); + } + // Resolve installation method (detects or uses user-specified) const method = flags.method ?? (await detectInstallationMethod()); @@ -177,9 +358,9 @@ export const upgradeCommand = buildCommand({ throw new UpgradeError("unknown_method"); } - // Homebrew manages versioning through the formula in the tap — the installed - // version is always whatever the formula specifies, not an arbitrary release. - if (method === "brew" && version) { + // Homebrew manages versioning through the formula — pinning a specific + // stable version is not supported via this command. + if (method === "brew" && versionArg && channel === "stable") { throw new UpgradeError( "unsupported_operation", "Homebrew does not support installing a specific version. Run 'brew upgrade getsentry/tools/sentry' to upgrade to the latest formula version." @@ -189,19 +370,36 @@ export const upgradeCommand = buildCommand({ stdout.write(`Installation method: ${method}\n`); stdout.write(`Current version: ${CLI_VERSION}\n`); - const target = await resolveTargetVersion( + const target = await resolveTargetVersion({ method, - version, + channel, + versionArg, + channelChanged, + flags, stdout, - flags.check - ); + }); if (!target) { return; } - // Execute upgrade: downloads new binary (curl) or installs via package manager stdout.write(`\nUpgrading to ${target}...\n`); - const downloadResult = await executeUpgrade(method, target); + + // Nightly is GitHub-only. If the current install method is not curl, + // migrate to a standalone binary first then return — the migration + // handles setup internally. + if (channel === "nightly" && method !== "curl") { + await migrateToStandaloneForNightly(method, target, stdout, versionArg); + stdout.write(`\nSuccessfully installed nightly ${target}.\n`); + return; + } + + // Standard upgrade path: download (curl) or package manager. + // Use the rolling "nightly" tag only when upgrading to latest nightly + // (no specific version was requested). A specific version arg always + // uses its own tag so the correct release is downloaded. + const downloadTag = + channel === "nightly" && !versionArg ? NIGHTLY_TAG : undefined; + const downloadResult = await executeUpgrade(method, target, downloadTag); // Run setup on the new binary to update completions, agent skills, // and record installation metadata. @@ -213,19 +411,25 @@ export const upgradeCommand = buildCommand({ // the same lock path (ppid takeover), this is a harmless no-op. const currentInstallDir = dirname(getCurlInstallPaths().installPath); try { - await runSetupOnNewBinary( - downloadResult.tempBinaryPath, + await runSetupOnNewBinary({ + binaryPath: downloadResult.tempBinaryPath, method, - true, - currentInstallDir - ); + channel, + install: true, + installDir: currentInstallDir, + }); } finally { releaseLock(downloadResult.lockPath); } } else if (method !== "brew") { // Package manager: binary already in place, just run setup. // Skip brew — Homebrew's post_install hook already runs setup. - await runSetupOnNewBinary(this.process.execPath, method, false); + await runSetupOnNewBinary({ + binaryPath: this.process.execPath, + method, + channel, + install: false, + }); } stdout.write(`\nSuccessfully upgraded to ${target}.\n`); diff --git a/src/lib/db/release-channel.ts b/src/lib/db/release-channel.ts new file mode 100644 index 00000000..cddb78f1 --- /dev/null +++ b/src/lib/db/release-channel.ts @@ -0,0 +1,69 @@ +/** + * Release channel persistence. + * + * Stores the user's chosen release channel ("stable" or "nightly") in the + * metadata table. Defaults to "stable" if not set. + * + * The channel controls which version stream `upgrade` and `version-check` use: + * - "stable": tracks the latest GitHub release (default) + * - "nightly": tracks the rolling nightly prerelease built from main + */ + +import { getDatabase } from "./index.js"; +import { runUpsert } from "./utils.js"; +import { clearVersionCheckCache } from "./version-check.js"; + +const KEY = "release_channel"; + +/** The release channel a user tracks for upgrades and version-check notifications. */ +export type ReleaseChannel = "stable" | "nightly"; + +/** + * Get the persisted release channel. + * + * @returns The stored channel, or "stable" if not yet set. + */ +export function getReleaseChannel(): ReleaseChannel { + const db = getDatabase(); + const row = db.query("SELECT value FROM metadata WHERE key = ?").get(KEY) as + | { value: string } + | undefined; + + if (row?.value === "nightly") { + return "nightly"; + } + return "stable"; +} + +/** + * Persist the release channel. + * + * If the channel has changed, the cached version-check result is also cleared + * so the next notification does not display a stale version from the old channel + * (e.g. a cached stable version labelled as a nightly update after switching). + * + * @param channel - Channel to store + */ +export function setReleaseChannel(channel: ReleaseChannel): void { + const db = getDatabase(); + const current = getReleaseChannel(); + runUpsert(db, "metadata", { key: KEY, value: channel }, ["key"]); + if (channel !== current) { + clearVersionCheckCache(); + } +} + +/** + * Parse and validate a release channel from user input. + * + * @param value - Raw string from --channel flag or "nightly"/"stable" positional + * @returns Validated ReleaseChannel + * @throws {Error} When the value is not a recognized channel + */ +export function parseReleaseChannel(value: string): ReleaseChannel { + const normalized = value.toLowerCase(); + if (normalized === "stable" || normalized === "nightly") { + return normalized; + } + throw new Error(`Invalid channel: ${value}. Must be one of: stable, nightly`); +} diff --git a/src/lib/db/version-check.ts b/src/lib/db/version-check.ts index a03edb9b..4eff3ffe 100644 --- a/src/lib/db/version-check.ts +++ b/src/lib/db/version-check.ts @@ -38,6 +38,22 @@ export function getVersionCheckInfo(): VersionCheckInfo { }; } +/** + * Clear the cached latest version. + * + * Should be called when the release channel changes so that stale version + * data from the previous channel is not shown in update notifications. + * The last-checked timestamp is also reset so a fresh check is triggered + * on the next CLI invocation. + */ +export function clearVersionCheckCache(): void { + const db = getDatabase(); + db.query("DELETE FROM metadata WHERE key = ? OR key = ?").run( + KEY_LAST_CHECKED, + KEY_LATEST_VERSION + ); +} + /** * Store the version check result. * Updates both the last checked timestamp and the latest known version. diff --git a/src/lib/upgrade.ts b/src/lib/upgrade.ts index bc390a0e..45c90dff 100644 --- a/src/lib/upgrade.ts +++ b/src/lib/upgrade.ts @@ -23,6 +23,7 @@ import { } from "./binary.js"; import { CLI_VERSION } from "./constants.js"; import { getInstallInfo, setInstallInfo } from "./db/install-info.js"; +import type { ReleaseChannel } from "./db/release-channel.js"; import { UpgradeError } from "./errors.js"; // Types @@ -45,6 +46,16 @@ type PackageManager = "npm" | "pnpm" | "bun" | "yarn"; const GITHUB_RELEASES_URL = "https://api.github.com/repos/getsentry/cli/releases"; +/** + * Public URL for the nightly release's version manifest. + * This is a GitHub release asset on the rolling `nightly` prerelease. + */ +const NIGHTLY_VERSION_URL = + "https://github.com/getsentry/cli/releases/download/nightly/version.json"; + +/** The git tag used for the rolling nightly release. */ +export const NIGHTLY_TAG = "nightly"; + /** npm registry base URL */ const NPM_REGISTRY_URL = "https://registry.npmjs.org/sentry"; @@ -316,16 +327,65 @@ export async function fetchLatestFromNpm(): Promise { } /** - * Fetch the latest available version based on installation method. - * curl and brew installations check GitHub releases; package managers check npm. + * Fetch the latest nightly version from the nightly release's version manifest. + * + * Downloads a small JSON file (`version.json`) from the rolling `nightly` + * GitHub prerelease. This file is uploaded by CI on every main-branch build + * and contains `{version, sha, date}`. + * + * The URL is a public GitHub release asset — no API token required. + * + * @param signal - Optional AbortSignal to cancel the request + * @returns Latest nightly version string (e.g. "0.12.0-dev.1740393600") + * @throws {UpgradeError} When fetch fails or response is invalid + */ +export async function fetchLatestNightlyVersion( + signal?: AbortSignal +): Promise { + const response = await fetchWithUpgradeError( + NIGHTLY_VERSION_URL, + { signal }, + "GitHub" + ); + + if (!response.ok) { + throw new UpgradeError( + "network_error", + `Failed to fetch nightly version: ${response.status}` + ); + } + + const data = (await response.json()) as { version?: string }; + + if (!data.version) { + throw new UpgradeError( + "network_error", + "No version found in nightly version.json" + ); + } + + return data.version; +} + +/** + * Fetch the latest available version based on installation method and channel. + * + * - nightly channel: fetches version.json from the rolling nightly release + * - curl/brew on stable: checks GitHub /releases/latest + * - package managers on stable: checks npm registry * * @param method - How the CLI was installed + * @param channel - Release channel ("stable" or "nightly"), defaults to "stable" * @returns Latest version string (without 'v' prefix) * @throws {UpgradeError} When version fetch fails */ export function fetchLatestVersion( - method: InstallationMethod + method: InstallationMethod, + channel: ReleaseChannel = "stable" ): Promise { + if (channel === "nightly") { + return fetchLatestNightlyVersion(); + } return method === "curl" || method === "brew" ? fetchLatestFromGitHub() : fetchLatestFromNpm(); @@ -414,14 +474,18 @@ async function streamDecompressToFile( * process.ppid recognition in acquireLock — the parent's subsequent release * is then a harmless no-op. * - * @param version - Target version to download + * @param version - Target version to download (used for display and comparison) + * @param downloadTag - Git tag to use in the download URL. Defaults to `version`. + * Pass `NIGHTLY_TAG` ("nightly") when installing from the rolling nightly release + * so the URL points to the prerelease assets regardless of the version string. * @returns The downloaded binary path and lock path to release * @throws {UpgradeError} When download fails */ export async function downloadBinaryToTemp( - version: string + version: string, + downloadTag?: string ): Promise { - const url = getBinaryDownloadUrl(version); + const url = getBinaryDownloadUrl(downloadTag ?? version); const { tempPath, lockPath } = getCurlInstallPaths(); acquireLock(lockPath); @@ -572,17 +636,20 @@ function executeUpgradePackageManager( * spawn `setup` on the new binary for completions/agent skills. * * @param method - How the CLI was installed - * @param version - Target version to install + * @param version - Target version to install (used for display) + * @param downloadTag - Git tag to download from. Defaults to `version`. + * Pass `NIGHTLY_TAG` for nightly installs so the URL uses the "nightly" tag. * @returns Download result with paths (curl), or null (package manager) * @throws {UpgradeError} When method is unknown or installation fails */ export async function executeUpgrade( method: InstallationMethod, - version: string + version: string, + downloadTag?: string ): Promise { switch (method) { case "curl": - return downloadBinaryToTemp(version); + return downloadBinaryToTemp(version, downloadTag); case "brew": await executeUpgradeHomebrew(); return null; diff --git a/src/lib/version-check.ts b/src/lib/version-check.ts index 6118d69e..c75aedef 100644 --- a/src/lib/version-check.ts +++ b/src/lib/version-check.ts @@ -8,12 +8,13 @@ // biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import import * as Sentry from "@sentry/bun"; import { CLI_VERSION } from "./constants.js"; +import { getReleaseChannel } from "./db/release-channel.js"; import { getVersionCheckInfo, setVersionCheckInfo, } from "./db/version-check.js"; import { cyan, muted } from "./formatters/colors.js"; -import { fetchLatestFromGitHub } from "./upgrade.js"; +import { fetchLatestFromGitHub, fetchLatestNightlyVersion } from "./upgrade.js"; /** Target check interval: ~24 hours */ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; @@ -109,6 +110,8 @@ function checkForUpdateInBackgroundImpl(): void { pendingAbortController = new AbortController(); const { signal } = pendingAbortController; + const channel = getReleaseChannel(); + Sentry.startSpanManual( { name: "version-check", @@ -117,7 +120,10 @@ function checkForUpdateInBackgroundImpl(): void { }, async (span) => { try { - const latestVersion = await fetchLatestFromGitHub(signal); + const latestVersion = + channel === "nightly" + ? await fetchLatestNightlyVersion(signal) + : await fetchLatestFromGitHub(signal); setVersionCheckInfo(latestVersion); span.setStatus({ code: 1 }); // OK } catch (error) { @@ -158,7 +164,10 @@ function getUpdateNotificationImpl(): string | null { return null; } - return `\n${muted("Update available:")} ${cyan(CLI_VERSION)} -> ${cyan(latestVersion)} Run ${cyan('"sentry cli upgrade"')} to update.\n`; + const channel = getReleaseChannel(); + const label = + channel === "nightly" ? "New nightly available:" : "Update available:"; + return `\n${muted(label)} ${cyan(CLI_VERSION)} -> ${cyan(latestVersion)} Run ${cyan('"sentry cli upgrade"')} to update.\n`; } catch (error) { // DB access failed - report to Sentry but don't crash CLI Sentry.captureException(error); diff --git a/test/commands/cli/setup.test.ts b/test/commands/cli/setup.test.ts index 99edccca..1349443d 100644 --- a/test/commands/cli/setup.test.ts +++ b/test/commands/cli/setup.test.ts @@ -10,6 +10,8 @@ import { join } from "node:path"; import { run } from "@stricli/core"; import { app } from "../../../src/app.js"; import type { SentryContext } from "../../../src/context.js"; +import { getReleaseChannel } from "../../../src/lib/db/release-channel.js"; +import { useTestConfigDir } from "../../helpers.js"; /** Store original fetch for restoration */ let originalFetch: typeof globalThis.fetch; @@ -675,3 +677,111 @@ describe("sentry cli setup", () => { }); }); }); + +describe("sentry cli setup — --channel flag", () => { + useTestConfigDir("test-setup-channel-"); + + let testDir: string; + + beforeEach(() => { + testDir = join( + "/tmp", + `setup-channel-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + test("persists 'nightly' channel when --channel nightly is passed", async () => { + const { context } = createMockContext({ homeDir: testDir }); + + expect(getReleaseChannel()).toBe("stable"); + + await run( + app, + [ + "cli", + "setup", + "--channel", + "nightly", + "--no-modify-path", + "--no-completions", + "--no-agent-skills", + ], + context + ); + + expect(getReleaseChannel()).toBe("nightly"); + }); + + test("persists 'stable' channel when --channel stable is passed", async () => { + const { context } = createMockContext({ homeDir: testDir }); + + await run( + app, + [ + "cli", + "setup", + "--channel", + "stable", + "--no-modify-path", + "--no-completions", + "--no-agent-skills", + ], + context + ); + + expect(getReleaseChannel()).toBe("stable"); + }); + + test("logs channel when not in --install mode", async () => { + const { context, output } = createMockContext({ homeDir: testDir }); + + await run( + app, + [ + "cli", + "setup", + "--channel", + "nightly", + "--no-modify-path", + "--no-completions", + "--no-agent-skills", + ], + context + ); + + const combined = output.join(""); + expect(combined).toContain("Recorded release channel: nightly"); + }); + + test("does not log channel in --install mode", async () => { + // In --install mode, the setup is silent about the channel + // (it's set during binary placement, before user sees output) + const { context, output } = createMockContext({ + homeDir: testDir, + execPath: join(testDir, "sentry.download"), + }); + + await run( + app, + [ + "cli", + "setup", + "--install", + "--channel", + "nightly", + "--no-modify-path", + "--no-completions", + "--no-agent-skills", + ], + context + ); + + const combined = output.join(""); + expect(combined).not.toContain("Recorded release channel: nightly"); + }); +}); diff --git a/test/commands/cli/upgrade.test.ts b/test/commands/cli/upgrade.test.ts index f50cde78..0d60e872 100644 --- a/test/commands/cli/upgrade.test.ts +++ b/test/commands/cli/upgrade.test.ts @@ -8,11 +8,18 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { mkdirSync, rmSync } from "node:fs"; +import { unlink } from "node:fs/promises"; +import { homedir } from "node:os"; import { join } from "node:path"; import { run } from "@stricli/core"; import { app } from "../../../src/app.js"; import type { SentryContext } from "../../../src/context.js"; import { CLI_VERSION } from "../../../src/lib/constants.js"; +import { + getReleaseChannel, + setReleaseChannel, +} from "../../../src/lib/db/release-channel.js"; +import { useTestConfigDir } from "../../helpers.js"; /** Store original fetch for restoration */ let originalFetch: typeof globalThis.fetch; @@ -131,6 +138,19 @@ function mockGitHubVersion(version: string): void { }); } +/** + * Mock fetch for the nightly version.json endpoint. + */ +function mockNightlyVersion(version: string): void { + mockFetch( + async () => + new Response(JSON.stringify({ version }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + ); +} + describe("sentry cli upgrade", () => { let testDir: string; @@ -293,3 +313,394 @@ describe("sentry cli upgrade", () => { }); }); }); + +describe("sentry cli upgrade — nightly channel", () => { + useTestConfigDir("test-upgrade-nightly-"); + + let testDir: string; + + beforeEach(() => { + testDir = join( + "/tmp", + `upgrade-nightly-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + rmSync(testDir, { recursive: true, force: true }); + }); + + describe("resolveChannelAndVersion", () => { + test("'nightly' positional sets channel to nightly", async () => { + mockNightlyVersion(CLI_VERSION); + + const { context, output } = createMockContext({ homeDir: testDir }); + + await run( + app, + ["cli", "upgrade", "--check", "--method", "curl", "nightly"], + context + ); + + const combined = output.join(""); + expect(combined).toContain("Channel: nightly"); + }); + + test("'stable' positional sets channel to stable", async () => { + mockGitHubVersion(CLI_VERSION); + setReleaseChannel("nightly"); + + const { context, output } = createMockContext({ homeDir: testDir }); + + await run( + app, + ["cli", "upgrade", "--check", "--method", "curl", "stable"], + context + ); + + const combined = output.join(""); + expect(combined).toContain("Channel: stable"); + }); + + test("without positional, uses persisted channel", async () => { + setReleaseChannel("nightly"); + mockNightlyVersion(CLI_VERSION); + + const { context, output } = createMockContext({ homeDir: testDir }); + + await run( + app, + ["cli", "upgrade", "--check", "--method", "curl"], + context + ); + + const combined = output.join(""); + expect(combined).toContain("Channel: nightly"); + }); + }); + + describe("channel persistence", () => { + test("persists nightly channel when 'nightly' positional is passed", async () => { + mockNightlyVersion(CLI_VERSION); + + const { context } = createMockContext({ homeDir: testDir }); + + expect(getReleaseChannel()).toBe("stable"); + + await run( + app, + ["cli", "upgrade", "--check", "--method", "curl", "nightly"], + context + ); + + expect(getReleaseChannel()).toBe("nightly"); + }); + + test("persists stable channel when 'stable' positional resets from nightly", async () => { + setReleaseChannel("nightly"); + mockGitHubVersion(CLI_VERSION); + + const { context } = createMockContext({ homeDir: testDir }); + + await run( + app, + ["cli", "upgrade", "--check", "--method", "curl", "stable"], + context + ); + + expect(getReleaseChannel()).toBe("stable"); + }); + }); + + describe("nightly --check mode", () => { + test("shows 'already on target' when current matches nightly latest", async () => { + mockNightlyVersion(CLI_VERSION); + + const { context, output } = createMockContext({ homeDir: testDir }); + + await run( + app, + ["cli", "upgrade", "--check", "--method", "curl", "nightly"], + context + ); + + const combined = output.join(""); + expect(combined).toContain("Channel: nightly"); + expect(combined).toContain(`Latest version: ${CLI_VERSION}`); + expect(combined).toContain("You are already on the target version"); + }); + + test("shows upgrade hint when newer nightly available", async () => { + mockNightlyVersion("0.99.0-dev.9999999999"); + + const { context, output } = createMockContext({ homeDir: testDir }); + + await run( + app, + ["cli", "upgrade", "--check", "--method", "curl", "nightly"], + context + ); + + const combined = output.join(""); + expect(combined).toContain("Channel: nightly"); + expect(combined).toContain("Latest version: 0.99.0-dev.9999999999"); + expect(combined).toContain("Run 'sentry cli upgrade' to update."); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Download + setup paths (Option B: Bun.spawn spy) +// +// These tests cover runSetupOnNewBinary and the full executeUpgrade flow by: +// 1. Mocking fetch to return a fake binary payload for downloadBinaryToTemp +// 2. Replacing Bun.spawn with a spy that resolves immediately with exit 0 +// +// Bun.spawn is writable on the global Bun object, so it can be temporarily +// replaced without mock.module. +// --------------------------------------------------------------------------- + +describe("sentry cli upgrade — curl full upgrade path (Bun.spawn spy)", () => { + useTestConfigDir("test-upgrade-spawn-"); + + let testDir: string; + let originalSpawn: typeof Bun.spawn; + let spawnedArgs: string[][]; + + /** Default install paths (default curl dir) */ + const defaultBinDir = join(homedir(), ".sentry", "bin"); + + beforeEach(() => { + testDir = join( + "/tmp", + `upgrade-spawn-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + mkdirSync(defaultBinDir, { recursive: true }); + + originalFetch = globalThis.fetch; + originalSpawn = Bun.spawn; + spawnedArgs = []; + + // Replace Bun.spawn with a spy that immediately resolves with exit 0 + Bun.spawn = ((cmd: string[], _opts: unknown) => { + spawnedArgs.push(cmd); + return { exited: Promise.resolve(0) }; + }) as typeof Bun.spawn; + }); + + afterEach(async () => { + globalThis.fetch = originalFetch; + Bun.spawn = originalSpawn; + rmSync(testDir, { recursive: true, force: true }); + + // Clean up any temp binary files written to the default curl install path + const binName = process.platform === "win32" ? "sentry.exe" : "sentry"; + for (const suffix of ["", ".download", ".old", ".lock"]) { + try { + await unlink(join(defaultBinDir, `${binName}${suffix}`)); + } catch { + // Ignore + } + } + }); + + /** + * Mock fetch to serve both the GitHub latest-release version endpoint and a + * minimal valid gzipped binary for downloadBinaryToTemp. + */ + function mockBinaryDownloadWithVersion(version: string): void { + const fakeContent = new Uint8Array([0x7f, 0x45, 0x4c, 0x46]); // ELF magic + const gzipped = Bun.gzipSync(fakeContent); + mockFetch(async (url) => { + const urlStr = String(url); + if (urlStr.includes("releases/latest")) { + return new Response(JSON.stringify({ tag_name: version }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + // Binary download (.gz or raw) + return new Response(gzipped, { status: 200 }); + }); + } + + test("runs setup on downloaded binary after curl upgrade", async () => { + mockBinaryDownloadWithVersion("99.99.99"); + + const { context, output } = createMockContext({ homeDir: testDir }); + + await run(app, ["cli", "upgrade", "--method", "curl"], context); + + const combined = output.join(""); + expect(combined).toContain("Upgrading to 99.99.99..."); + expect(combined).toContain("Successfully upgraded to 99.99.99."); + + // Verify Bun.spawn was called with the downloaded binary + setup args + expect(spawnedArgs.length).toBeGreaterThan(0); + const setupCall = spawnedArgs.find((args) => args.includes("setup")); + expect(setupCall).toBeDefined(); + expect(setupCall).toContain("cli"); + expect(setupCall).toContain("setup"); + expect(setupCall).toContain("--method"); + expect(setupCall).toContain("curl"); + expect(setupCall).toContain("--install"); + }); + + test("reports setup failure when Bun.spawn exits non-zero", async () => { + // Use a unified mock that handles both the version endpoint and binary download + const fakeContent = new Uint8Array([0x7f, 0x45, 0x4c, 0x46]); + const gzipped = Bun.gzipSync(fakeContent); + mockFetch(async (url) => { + const urlStr = String(url); + if (urlStr.includes("releases/latest")) { + return new Response(JSON.stringify({ tag_name: "99.99.99" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + // Binary download (both .gz and raw URLs) + return new Response(gzipped, { status: 200 }); + }); + + Bun.spawn = ((_cmd: string[], _opts: unknown) => ({ + exited: Promise.resolve(1), + })) as typeof Bun.spawn; + + const { context, errors } = createMockContext({ homeDir: testDir }); + + await run(app, ["cli", "upgrade", "--method", "curl"], context); + + expect(errors.join("")).toContain("Setup failed with exit code 1"); + }); + + test("uses NIGHTLY_TAG download URL for nightly channel without versionArg", async () => { + const capturedUrls: string[] = []; + const fakeContent = new Uint8Array([0x7f, 0x45, 0x4c, 0x46]); + const gzipped = Bun.gzipSync(fakeContent); + + // Single unified mock: captures all URLs, serves version.json and binary download + mockFetch(async (url) => { + const urlStr = String(url); + capturedUrls.push(urlStr); + if (urlStr.includes("version.json")) { + return new Response( + JSON.stringify({ version: "0.99.0-dev.1234567890" }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + // Binary download — return gzipped content for all other URLs + return new Response(gzipped, { status: 200 }); + }); + + // Set nightly channel; "nightly" positional triggers NIGHTLY_TAG for download + setReleaseChannel("nightly"); + + const { context } = createMockContext({ homeDir: testDir }); + + await run(app, ["cli", "upgrade", "--method", "curl", "nightly"], context); + + // Download URL should use the rolling "nightly" tag + const downloadUrl = capturedUrls.find((u) => u.includes("/download/")); + expect(downloadUrl).toBeDefined(); + expect(downloadUrl).toContain("/download/nightly/"); + }); + + test("--force bypasses 'already up to date' and proceeds to download", async () => { + mockBinaryDownloadWithVersion(CLI_VERSION); // Same version — would normally short-circuit + + const { context, output } = createMockContext({ homeDir: testDir }); + + await run(app, ["cli", "upgrade", "--method", "curl", "--force"], context); + + const combined = output.join(""); + // With --force, should NOT show "Already up to date" + expect(combined).not.toContain("Already up to date."); + // Should proceed to upgrade and succeed + expect(combined).toContain(`Upgrading to ${CLI_VERSION}...`); + expect(combined).toContain(`Successfully upgraded to ${CLI_VERSION}.`); + }); +}); + +describe("sentry cli upgrade — migrateToStandaloneForNightly (Bun.spawn spy)", () => { + useTestConfigDir("test-upgrade-migrate-"); + + let testDir: string; + let originalSpawn: typeof Bun.spawn; + + const defaultBinDir = join(homedir(), ".sentry", "bin"); + + beforeEach(() => { + testDir = join( + "/tmp", + `upgrade-migrate-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + mkdirSync(defaultBinDir, { recursive: true }); + + originalFetch = globalThis.fetch; + originalSpawn = Bun.spawn; + + Bun.spawn = ((_cmd: string[], _opts: unknown) => ({ + exited: Promise.resolve(0), + })) as typeof Bun.spawn; + }); + + afterEach(async () => { + globalThis.fetch = originalFetch; + Bun.spawn = originalSpawn; + rmSync(testDir, { recursive: true, force: true }); + + const binName = process.platform === "win32" ? "sentry.exe" : "sentry"; + for (const suffix of ["", ".download", ".old", ".lock"]) { + try { + await unlink(join(defaultBinDir, `${binName}${suffix}`)); + } catch { + // Ignore + } + } + }); + + test("migrates npm install to standalone binary for nightly channel", async () => { + const fakeContent = new Uint8Array([0x7f, 0x45, 0x4c, 0x46]); + const gzipped = Bun.gzipSync(fakeContent); + + mockFetch(async (url) => { + const urlStr = String(url); + // nightly version.json + if (urlStr.includes("version.json")) { + return new Response( + JSON.stringify({ version: "0.99.0-dev.1234567890" }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + // binary download (.gz) + if (urlStr.includes(".gz")) { + return new Response(gzipped, { status: 200 }); + } + return new Response("Not Found", { status: 404 }); + }); + + // Switch to nightly and use npm method → triggers migration + setReleaseChannel("nightly"); + + const { context, output } = createMockContext({ homeDir: testDir }); + + await run(app, ["cli", "upgrade", "--method", "npm", "nightly"], context); + + const combined = output.join(""); + expect(combined).toContain( + "Nightly builds are only available as standalone binaries." + ); + expect(combined).toContain("Migrating to standalone installation..."); + expect(combined).toContain("Successfully installed nightly"); + // Warns about old npm install + expect(combined).toContain( + "npm-installed sentry may still appear earlier in PATH" + ); + expect(combined).toContain("npm uninstall -g sentry"); + }); +}); diff --git a/test/isolated/brew-upgrade.test.ts b/test/isolated/brew-upgrade.test.ts index 525fd913..131b4583 100644 --- a/test/isolated/brew-upgrade.test.ts +++ b/test/isolated/brew-upgrade.test.ts @@ -1,11 +1,14 @@ /** - * Isolated tests for Homebrew upgrade execution. + * Isolated tests for subprocess-based upgrade execution. * * Uses mock.module() to stub node:child_process/spawn, which leaks module * state — kept isolated so it doesn't affect other test files. + * + * Covers: executeUpgrade (brew + package managers), runCommand, isInstalledWith, + * detectLegacyInstallationMethod, and detectInstallationMethod legacy path. */ -import { describe, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { execFile, execFileSync, @@ -15,23 +18,86 @@ import { } from "node:child_process"; import { EventEmitter } from "node:events"; import { UpgradeError } from "../../src/lib/errors.js"; +import { useTestConfigDir } from "../helpers.js"; + +// --------------------------------------------------------------------------- +// Fake ChildProcess helpers +// --------------------------------------------------------------------------- + +type FakeStdio = { + on: (event: string, cb: (chunk: Buffer) => void) => FakeStdio; + resume: () => void; +}; + +type FakeProc = EventEmitter & { + stdout: FakeStdio; + stderr: FakeStdio; +}; /** * Build a minimal fake ChildProcess EventEmitter that emits 'close' * with the given exit code after a microtask tick. + * + * @param exitCode - Exit code to emit on 'close' + * @param stdoutData - Optional data to emit on stdout before close + */ +function fakeProcess(exitCode: number, stdoutData = ""): FakeProc { + const emitter = new EventEmitter() as FakeProc; + + const listeners: Array<(chunk: Buffer) => void> = []; + emitter.stdout = { + on: (_event: string, cb: (chunk: Buffer) => void) => { + listeners.push(cb); + return emitter.stdout; + }, + // biome-ignore lint/suspicious/noEmptyBlockStatements: stub + resume: () => {}, + }; + emitter.stderr = { + on: (_event: string, _cb: (chunk: Buffer) => void) => emitter.stderr, + // biome-ignore lint/suspicious/noEmptyBlockStatements: stub + resume: () => {}, + }; + + queueMicrotask(() => { + if (stdoutData) { + for (const cb of listeners) { + cb(Buffer.from(stdoutData)); + } + } + emitter.emit("close", exitCode); + }); + + return emitter; +} + +/** + * Build a fake ChildProcess that emits an 'error' event instead of closing. */ -function fakeProcess(exitCode: number): EventEmitter { - const emitter = new EventEmitter(); - // Emit close asynchronously so the Promise can attach listeners first - queueMicrotask(() => emitter.emit("close", exitCode)); +function fakeErrorProcess(message: string): FakeProc { + const emitter = new EventEmitter() as FakeProc; + emitter.stdout = { + on: (_e: string, _cb: (chunk: Buffer) => void) => emitter.stdout, + // biome-ignore lint/suspicious/noEmptyBlockStatements: stub + resume: () => {}, + }; + emitter.stderr = { + on: (_e: string, _cb: (chunk: Buffer) => void) => emitter.stderr, + // biome-ignore lint/suspicious/noEmptyBlockStatements: stub + resume: () => {}, + }; + queueMicrotask(() => emitter.emit("error", new Error(message))); return emitter; } -// Mock node:child_process before importing the module under test. -// Bun hoists mock.module() calls, so this runs before any imports below. +// --------------------------------------------------------------------------- +// mock.module — must be declared before any imports of the module under test. +// Bun hoists mock.module() calls so they run before top-level awaits. // Pass through real non-spawn exports so transitive deps are unaffected. -let spawnImpl: (cmd: string, args: string[], opts: object) => EventEmitter = - () => fakeProcess(0); +// --------------------------------------------------------------------------- + +let spawnImpl: (cmd: string, args: string[], opts: object) => FakeProc = () => + fakeProcess(0); mock.module("node:child_process", () => ({ execFile, @@ -44,21 +110,24 @@ mock.module("node:child_process", () => ({ })); // Import after mock is registered -const { executeUpgrade } = await import("../../src/lib/upgrade.js"); +const { detectInstallationMethod, executeUpgrade } = await import( + "../../src/lib/upgrade.js" +); + +const { clearInstallInfo } = await import("../../src/lib/db/install-info.js"); + +// --------------------------------------------------------------------------- +// executeUpgrade — brew +// --------------------------------------------------------------------------- describe("executeUpgrade (brew)", () => { test("returns null on successful brew upgrade", async () => { spawnImpl = () => fakeProcess(0); - - const result = await executeUpgrade("brew", "1.0.0"); - expect(result).toBeNull(); + expect(await executeUpgrade("brew", "1.0.0")).toBeNull(); }); - test("throws UpgradeError with execution_failed reason on non-zero exit", async () => { + test("throws UpgradeError on non-zero brew exit", async () => { spawnImpl = () => fakeProcess(1); - - await expect(executeUpgrade("brew", "1.0.0")).rejects.toThrow(UpgradeError); - try { await executeUpgrade("brew", "1.0.0"); expect.unreachable("should have thrown"); @@ -69,13 +138,8 @@ describe("executeUpgrade (brew)", () => { } }); - test("throws UpgradeError with execution_failed reason on spawn error", async () => { - spawnImpl = () => { - const emitter = new EventEmitter(); - queueMicrotask(() => emitter.emit("error", new Error("brew not found"))); - return emitter; - }; - + test("throws UpgradeError on brew spawn error", async () => { + spawnImpl = () => fakeErrorProcess("brew not found"); try { await executeUpgrade("brew", "1.0.0"); expect.unreachable("should have thrown"); @@ -89,16 +153,194 @@ describe("executeUpgrade (brew)", () => { test("invokes brew with correct arguments", async () => { let capturedCmd = ""; let capturedArgs: string[] = []; - spawnImpl = (cmd, args) => { capturedCmd = cmd; capturedArgs = args; return fakeProcess(0); }; - await executeUpgrade("brew", "1.0.0"); - expect(capturedCmd).toBe("brew"); expect(capturedArgs).toEqual(["upgrade", "getsentry/tools/sentry"]); }); }); + +// --------------------------------------------------------------------------- +// executeUpgrade — package managers (npm, pnpm, bun, yarn) +// --------------------------------------------------------------------------- + +describe("executeUpgrade (package managers)", () => { + test("npm: returns null on success", async () => { + spawnImpl = () => fakeProcess(0); + expect(await executeUpgrade("npm", "1.0.0")).toBeNull(); + }); + + test("npm: uses correct install arguments", async () => { + let capturedCmd = ""; + let capturedArgs: string[] = []; + spawnImpl = (cmd, args) => { + capturedCmd = cmd; + capturedArgs = args; + return fakeProcess(0); + }; + await executeUpgrade("npm", "1.2.3"); + expect(capturedCmd).toBe("npm"); + expect(capturedArgs).toEqual(["install", "-g", "sentry@1.2.3"]); + }); + + test("pnpm: uses correct install arguments", async () => { + let capturedArgs: string[] = []; + spawnImpl = (_cmd, args) => { + capturedArgs = args; + return fakeProcess(0); + }; + await executeUpgrade("pnpm", "1.2.3"); + expect(capturedArgs).toEqual(["install", "-g", "sentry@1.2.3"]); + }); + + test("bun: uses correct install arguments", async () => { + let capturedArgs: string[] = []; + spawnImpl = (_cmd, args) => { + capturedArgs = args; + return fakeProcess(0); + }; + await executeUpgrade("bun", "1.2.3"); + expect(capturedArgs).toEqual(["install", "-g", "sentry@1.2.3"]); + }); + + test("yarn: uses 'global add' arguments", async () => { + let capturedCmd = ""; + let capturedArgs: string[] = []; + spawnImpl = (cmd, args) => { + capturedCmd = cmd; + capturedArgs = args; + return fakeProcess(0); + }; + await executeUpgrade("yarn", "1.2.3"); + expect(capturedCmd).toBe("yarn"); + expect(capturedArgs).toEqual(["global", "add", "sentry@1.2.3"]); + }); + + test("npm: throws UpgradeError on non-zero exit", async () => { + spawnImpl = () => fakeProcess(1); + try { + await executeUpgrade("npm", "1.0.0"); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(UpgradeError); + expect((err as UpgradeError).reason).toBe("execution_failed"); + expect((err as UpgradeError).message).toContain("npm install failed"); + } + }); + + test("npm: throws UpgradeError on spawn error", async () => { + spawnImpl = () => fakeErrorProcess("npm not found"); + try { + await executeUpgrade("npm", "1.0.0"); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(UpgradeError); + expect((err as UpgradeError).reason).toBe("execution_failed"); + expect((err as UpgradeError).message).toContain("npm not found"); + } + }); +}); + +// --------------------------------------------------------------------------- +// executeUpgrade — unknown method (default switch case) +// --------------------------------------------------------------------------- + +describe("executeUpgrade (unknown method)", () => { + test("throws UpgradeError with unknown_method reason", async () => { + try { + await executeUpgrade("unknown" as never, "1.0.0"); + expect.unreachable("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(UpgradeError); + expect((err as UpgradeError).reason).toBe("unknown_method"); + } + }); +}); + +// --------------------------------------------------------------------------- +// runCommand via isInstalledWith (indirect coverage of runCommand) +// --------------------------------------------------------------------------- + +describe("detectInstallationMethod — legacy pm detection via isInstalledWith", () => { + useTestConfigDir("test-detect-legacy-"); + + let originalExecPath: string; + + beforeEach(() => { + originalExecPath = process.execPath; + // Non-Homebrew, non-known-curl execPath so detection falls through to pm checks + Object.defineProperty(process, "execPath", { + value: "/usr/bin/sentry", + configurable: true, + }); + clearInstallInfo(); + }); + + afterEach(() => { + Object.defineProperty(process, "execPath", { + value: originalExecPath, + configurable: true, + }); + clearInstallInfo(); + }); + + test("detects npm when 'npm list -g sentry' output includes 'sentry@'", async () => { + spawnImpl = (_cmd, args) => + fakeProcess(0, args.includes("sentry") ? "sentry@1.0.0" : ""); + const method = await detectInstallationMethod(); + expect(method).toBe("npm"); + }); + + test("detects yarn when 'yarn global list' output includes 'sentry@'", async () => { + // npm is checked first — make npm/pnpm/bun return empty; only yarn matches + spawnImpl = (cmd) => { + if (cmd === "yarn") return fakeProcess(0, "sentry@1.0.0"); + return fakeProcess(0, ""); + }; + const method = await detectInstallationMethod(); + expect(method).toBe("yarn"); + }); + + test("returns 'unknown' when no package manager lists sentry", async () => { + spawnImpl = () => fakeProcess(0, ""); // all return empty stdout + const method = await detectInstallationMethod(); + expect(method).toBe("unknown"); + }); + + test("returns 'unknown' when all package manager spawns error", async () => { + spawnImpl = () => fakeErrorProcess("command not found"); + const method = await detectInstallationMethod(); + expect(method).toBe("unknown"); + }); + + test("auto-saves detected method when non-unknown", async () => { + spawnImpl = (_cmd, args) => + fakeProcess(0, args.includes("sentry") ? "sentry@2.0.0" : ""); + await detectInstallationMethod(); + // After detection, install info should be auto-saved with method=npm + const { getInstallInfo } = await import("../../src/lib/db/install-info.js"); + const stored = getInstallInfo(); + expect(stored?.method).toBe("npm"); + }); + + test("returns stored method on second call (auto-save fast path)", async () => { + // First call: npm detected and auto-saved + spawnImpl = (_cmd, args) => + fakeProcess(0, args.includes("sentry") ? "sentry@1.0.0" : ""); + await detectInstallationMethod(); + + // Second call: spawn should not be called again (stored info takes precedence) + let spawnCalled = false; + spawnImpl = () => { + spawnCalled = true; + return fakeProcess(0, "sentry@1.0.0"); + }; + const method = await detectInstallationMethod(); + expect(method).toBe("npm"); + expect(spawnCalled).toBe(false); + }); +}); diff --git a/test/lib/db/release-channel.test.ts b/test/lib/db/release-channel.test.ts new file mode 100644 index 00000000..0f818f62 --- /dev/null +++ b/test/lib/db/release-channel.test.ts @@ -0,0 +1,130 @@ +/** + * Release Channel Storage Tests + */ + +import { describe, expect, test } from "bun:test"; +import { + getReleaseChannel, + parseReleaseChannel, + setReleaseChannel, +} from "../../../src/lib/db/release-channel.js"; +import { + clearVersionCheckCache, + getVersionCheckInfo, + setVersionCheckInfo, +} from "../../../src/lib/db/version-check.js"; +import { useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("test-release-channel-"); + +describe("getReleaseChannel", () => { + test("returns 'stable' when nothing is stored", () => { + expect(getReleaseChannel()).toBe("stable"); + }); + + test("returns stored channel after set", () => { + setReleaseChannel("nightly"); + expect(getReleaseChannel()).toBe("nightly"); + }); + + test("returns 'stable' after explicitly setting stable", () => { + setReleaseChannel("nightly"); + setReleaseChannel("stable"); + expect(getReleaseChannel()).toBe("stable"); + }); +}); + +describe("setReleaseChannel", () => { + test("persists 'nightly'", () => { + setReleaseChannel("nightly"); + expect(getReleaseChannel()).toBe("nightly"); + }); + + test("persists 'stable'", () => { + setReleaseChannel("stable"); + expect(getReleaseChannel()).toBe("stable"); + }); + + test("overwrites previous channel", () => { + setReleaseChannel("nightly"); + setReleaseChannel("stable"); + expect(getReleaseChannel()).toBe("stable"); + + setReleaseChannel("nightly"); + expect(getReleaseChannel()).toBe("nightly"); + }); +}); + +describe("parseReleaseChannel", () => { + test("accepts 'stable'", () => { + expect(parseReleaseChannel("stable")).toBe("stable"); + }); + + test("accepts 'nightly'", () => { + expect(parseReleaseChannel("nightly")).toBe("nightly"); + }); + + test("is case-insensitive", () => { + expect(parseReleaseChannel("STABLE")).toBe("stable"); + expect(parseReleaseChannel("Nightly")).toBe("nightly"); + expect(parseReleaseChannel("NIGHTLY")).toBe("nightly"); + }); + + test("throws on unrecognized value", () => { + expect(() => parseReleaseChannel("beta")).toThrow( + "Invalid channel: beta. Must be one of: stable, nightly" + ); + }); + + test("throws on empty string", () => { + expect(() => parseReleaseChannel("")).toThrow(); + }); +}); + +describe("clearVersionCheckCache", () => { + test("clears cached lastChecked and latestVersion", () => { + setVersionCheckInfo("1.0.0"); + const before = getVersionCheckInfo(); + expect(before.lastChecked).not.toBeNull(); + expect(before.latestVersion).toBe("1.0.0"); + + clearVersionCheckCache(); + + const after = getVersionCheckInfo(); + expect(after.lastChecked).toBeNull(); + expect(after.latestVersion).toBeNull(); + }); + + test("is idempotent (no error on double-clear)", () => { + clearVersionCheckCache(); + expect(() => clearVersionCheckCache()).not.toThrow(); + const info = getVersionCheckInfo(); + expect(info.lastChecked).toBeNull(); + expect(info.latestVersion).toBeNull(); + }); +}); + +describe("setReleaseChannel — cache-clearing side effect", () => { + test("clears version check cache when channel changes", () => { + setVersionCheckInfo("1.0.0"); + expect(getVersionCheckInfo().latestVersion).toBe("1.0.0"); + + // Switching from default "stable" to "nightly" should clear the cache + setReleaseChannel("nightly"); + + const info = getVersionCheckInfo(); + expect(info.lastChecked).toBeNull(); + expect(info.latestVersion).toBeNull(); + }); + + test("does not clear cache when channel is unchanged", () => { + setVersionCheckInfo("2.0.0"); + expect(getVersionCheckInfo().latestVersion).toBe("2.0.0"); + + // Setting same channel again — cache should remain intact + setReleaseChannel("stable"); // default is stable, so this is unchanged + setReleaseChannel("stable"); // second set: now stored=stable, setting=stable → no change + + expect(getVersionCheckInfo().latestVersion).toBe("2.0.0"); + }); +}); diff --git a/test/lib/upgrade.test.ts b/test/lib/upgrade.test.ts index 12984b80..add32b3c 100644 --- a/test/lib/upgrade.test.ts +++ b/test/lib/upgrade.test.ts @@ -15,19 +15,24 @@ import { isProcessRunning, releaseLock, } from "../../src/lib/binary.js"; -import { clearInstallInfo } from "../../src/lib/db/install-info.js"; +import { + clearInstallInfo, + setInstallInfo, +} from "../../src/lib/db/install-info.js"; import { UpgradeError } from "../../src/lib/errors.js"; import { detectInstallationMethod, executeUpgrade, fetchLatestFromGitHub, fetchLatestFromNpm, + fetchLatestNightlyVersion, fetchLatestVersion, getCurlInstallPaths, parseInstallationMethod, startCleanupOldBinary, versionExists, } from "../../src/lib/upgrade.js"; +import { useTestConfigDir } from "../helpers.js"; // Store original fetch for restoration let originalFetch: typeof globalThis.fetch; @@ -224,6 +229,90 @@ describe("fetchLatestFromNpm", () => { }); }); +describe("fetchLatestNightlyVersion", () => { + test("returns version from nightly version.json", async () => { + mockFetch( + async () => + new Response( + JSON.stringify({ + version: "0.12.0-dev.1740393600", + sha: "abc123", + date: "2026-01-01", + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ) + ); + + const version = await fetchLatestNightlyVersion(); + expect(version).toBe("0.12.0-dev.1740393600"); + }); + + test("throws on HTTP error", async () => { + mockFetch( + async () => + new Response("Not Found", { + status: 404, + }) + ); + + await expect(fetchLatestNightlyVersion()).rejects.toThrow(UpgradeError); + await expect(fetchLatestNightlyVersion()).rejects.toThrow( + "Failed to fetch nightly version: 404" + ); + }); + + test("throws on network failure", async () => { + mockFetch(async () => { + throw new TypeError("fetch failed"); + }); + + await expect(fetchLatestNightlyVersion()).rejects.toThrow(UpgradeError); + await expect(fetchLatestNightlyVersion()).rejects.toThrow( + "Failed to connect to GitHub: fetch failed" + ); + }); + + test("throws when no version in response", async () => { + mockFetch( + async () => + new Response(JSON.stringify({ sha: "abc123" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + await expect(fetchLatestNightlyVersion()).rejects.toThrow( + "No version found in nightly version.json" + ); + }); + + test("respects AbortSignal", async () => { + const controller = new AbortController(); + mockFetch(async (_url, init) => { + // Simulate a slow request that checks the signal + if (init?.signal?.aborted) { + const err = new Error("The operation was aborted"); + err.name = "AbortError"; + throw err; + } + return new Response( + JSON.stringify({ version: "0.12.0-dev.1740393600" }), + { status: 200 } + ); + }); + + // Abort before calling + controller.abort(); + + await expect( + fetchLatestNightlyVersion(controller.signal) + ).rejects.toMatchObject({ name: "AbortError" }); + }); +}); + describe("UpgradeError", () => { test("creates error with default message for unknown_method", () => { const error = new UpgradeError("unknown_method"); @@ -357,6 +446,46 @@ describe("fetchLatestVersion", () => { const version = await fetchLatestVersion("unknown"); expect(version).toBe("2.0.0"); }); + + test("uses nightly version.json when channel is nightly (curl method)", async () => { + mockFetch( + async () => + new Response(JSON.stringify({ version: "0.12.0-dev.1740393600" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + const version = await fetchLatestVersion("curl", "nightly"); + expect(version).toBe("0.12.0-dev.1740393600"); + }); + + test("uses nightly version.json when channel is nightly (npm method)", async () => { + mockFetch( + async () => + new Response(JSON.stringify({ version: "0.12.0-dev.1740393600" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + // Even npm method should use nightly endpoint when channel=nightly + const version = await fetchLatestVersion("npm", "nightly"); + expect(version).toBe("0.12.0-dev.1740393600"); + }); + + test("defaults to stable channel (uses GitHub) when channel omitted", async () => { + mockFetch( + async () => + new Response(JSON.stringify({ tag_name: "v3.0.0" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + + const version = await fetchLatestVersion("curl"); + expect(version).toBe("3.0.0"); + }); }); describe("versionExists", () => { @@ -467,6 +596,52 @@ describe("executeUpgrade", () => { }); }); +describe("detectInstallationMethod — stored info path", () => { + useTestConfigDir("test-detect-stored-"); + + let originalExecPath: string; + + beforeEach(() => { + originalExecPath = process.execPath; + // Set execPath to a non-Homebrew, non-known-curl path so detection falls + // through to stored info + Object.defineProperty(process, "execPath", { + value: "/usr/bin/sentry", + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(process, "execPath", { + value: originalExecPath, + configurable: true, + }); + clearInstallInfo(); + }); + + test("returns stored method when install info has been persisted", async () => { + setInstallInfo({ + method: "npm", + path: "/usr/bin/sentry", + version: "1.0.0", + }); + + const method = await detectInstallationMethod(); + expect(method).toBe("npm"); + }); + + test("returns stored curl method", async () => { + setInstallInfo({ + method: "curl", + path: "/usr/bin/sentry", + version: "1.0.0", + }); + + const method = await detectInstallationMethod(); + expect(method).toBe("curl"); + }); +}); + describe("Homebrew detection (detectInstallationMethod)", () => { let originalExecPath: string; @@ -505,7 +680,6 @@ describe("Homebrew detection (detectInstallationMethod)", () => { test("Homebrew detection overrides stale stored install info", async () => { // Simulate a user who previously had curl recorded in the DB but then // switched to Homebrew — the /Cellar/ check should win. - const { setInstallInfo } = await import("../../src/lib/db/install-info.js"); setInstallInfo({ method: "curl", path: "/old/path", version: "0.0.1" }); Object.defineProperty(process, "execPath", { @@ -566,6 +740,63 @@ describe("getCurlInstallPaths", () => { expect(paths.installPath).toEndWith(`sentry${suffix}`); }); + + describe("stored path branch", () => { + useTestConfigDir("test-curl-paths-stored-"); + + afterEach(() => { + clearInstallInfo(); + }); + + test("uses stored path when install info has method=curl", () => { + const customPath = join(homedir(), "custom", "bin", "sentry"); + setInstallInfo({ method: "curl", path: customPath, version: "1.0.0" }); + + const paths = getCurlInstallPaths(); + expect(paths.installPath).toBe(customPath); + expect(paths.tempPath).toBe(`${customPath}.download`); + }); + + test("ignores stored path when method is not curl", () => { + // If stored method is e.g. "npm", the stored path branch is skipped + setInstallInfo({ + method: "npm", + path: "/some/npm/path", + version: "1.0.0", + }); + + const paths = getCurlInstallPaths(); + // Should NOT use the npm path + expect(paths.installPath).not.toBe("/some/npm/path"); + }); + }); + + describe("known curl path branch", () => { + let originalExecPath: string; + + beforeEach(() => { + originalExecPath = process.execPath; + }); + + afterEach(() => { + Object.defineProperty(process, "execPath", { + value: originalExecPath, + configurable: true, + }); + clearInstallInfo(); + }); + + test("uses execPath when it starts with a known curl install dir", () => { + const knownCurlPath = join(homedir(), ".local", "bin", "sentry"); + Object.defineProperty(process, "execPath", { + value: knownCurlPath, + configurable: true, + }); + + const paths = getCurlInstallPaths(); + expect(paths.installPath).toBe(knownCurlPath); + }); + }); }); describe("isProcessRunning", () => { diff --git a/test/lib/version-check.test.ts b/test/lib/version-check.test.ts index d93e06cf..a2b745b3 100644 --- a/test/lib/version-check.test.ts +++ b/test/lib/version-check.test.ts @@ -3,6 +3,7 @@ */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { setReleaseChannel } from "../../src/lib/db/release-channel.js"; import { getVersionCheckInfo, setVersionCheckInfo, @@ -119,6 +120,27 @@ describe("getUpdateNotification", () => { expect(notification).toContain("99.0.0"); expect(notification).toContain("sentry cli upgrade"); }); + + test("uses 'New nightly available:' label when on nightly channel", () => { + setReleaseChannel("nightly"); + setVersionCheckInfo("99.0.0"); + const notification = getUpdateNotification(); + + expect(notification).not.toBeNull(); + expect(notification).toContain("New nightly available:"); + expect(notification).not.toContain("Update available:"); + expect(notification).toContain("99.0.0"); + }); + + test("uses 'Update available:' label when on stable channel", () => { + setReleaseChannel("stable"); + setVersionCheckInfo("99.0.0"); + const notification = getUpdateNotification(); + + expect(notification).not.toBeNull(); + expect(notification).toContain("Update available:"); + expect(notification).not.toContain("New nightly available:"); + }); }); describe("abortPendingVersionCheck", () => {