diff --git a/bun.lock b/bun.lock index 1fa6aea4..0d041fe0 100644 --- a/bun.lock +++ b/bun.lock @@ -14,10 +14,12 @@ "@types/bun": "latest", "@types/node": "^22", "@types/qrcode-terminal": "^0.12.2", + "@types/semver": "^7.7.1", "chalk": "^5.6.2", "esbuild": "^0.25.0", "ky": "^1.14.2", "qrcode-terminal": "^0.12.0", + "semver": "^7.7.3", "tinyglobby": "^0.2.15", "typescript": "^5", "ultracite": "6.3.10", @@ -273,6 +275,8 @@ "@types/qrcode-terminal": ["@types/qrcode-terminal@0.12.2", "", {}, "sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q=="], + "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], + "@types/tedious": ["@types/tedious@4.0.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw=="], "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], @@ -425,7 +429,7 @@ "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], @@ -471,8 +475,12 @@ "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@prisma/instrumentation/@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.207.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.207.0", "import-in-the-middle": "^2.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA=="], "@sentry/bundler-plugin-core/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], diff --git a/package.json b/package.json index 494ebbe4..eba73eaf 100644 --- a/package.json +++ b/package.json @@ -33,10 +33,12 @@ "@types/bun": "latest", "@types/node": "^22", "@types/qrcode-terminal": "^0.12.2", + "@types/semver": "^7.7.1", "chalk": "^5.6.2", "esbuild": "^0.25.0", "ky": "^1.14.2", "qrcode-terminal": "^0.12.0", + "semver": "^7.7.3", "tinyglobby": "^0.2.15", "typescript": "^5", "ultracite": "6.3.10", diff --git a/script/node-polyfills.ts b/script/node-polyfills.ts index fa230636..0ad72dd1 100644 --- a/script/node-polyfills.ts +++ b/script/node-polyfills.ts @@ -5,6 +5,7 @@ import { execSync, spawn as nodeSpawn } from "node:child_process"; import { access, readFile, writeFile } from "node:fs/promises"; import { DatabaseSync } from "node:sqlite"; +import { compare as semverCompare } from "semver"; import { glob } from "tinyglobby"; import { uuidv7 } from "uuidv7"; @@ -163,6 +164,10 @@ const BunPolyfill = { randomUUIDv7(): string { return uuidv7(); }, + + semver: { + order: semverCompare, + }, }; globalThis.Bun = BunPolyfill as typeof Bun; diff --git a/src/bin.ts b/src/bin.ts index 19358a5f..f08ed33d 100755 --- a/src/bin.ts +++ b/src/bin.ts @@ -4,9 +4,21 @@ import { buildContext } from "./context.js"; import { formatError, getExitCode } from "./lib/errors.js"; import { error } from "./lib/formatters/colors.js"; import { withTelemetry } from "./lib/telemetry.js"; +import { + abortPendingVersionCheck, + getUpdateNotification, + maybeCheckForUpdateInBackground, + shouldSuppressNotification, +} from "./lib/version-check.js"; async function main(): Promise { const args = process.argv.slice(2); + const suppressNotification = shouldSuppressNotification(args); + + // Start background update check (non-blocking) + if (!suppressNotification) { + maybeCheckForUpdateInBackground(); + } try { await withTelemetry(async (span) => @@ -15,6 +27,17 @@ async function main(): Promise { } catch (err) { process.stderr.write(`${error("Error:")} ${formatError(err)}\n`); process.exit(getExitCode(err)); + } finally { + // Abort any pending version check to allow clean exit + abortPendingVersionCheck(); + } + + // Show update notification after command completes + if (!suppressNotification) { + const notification = getUpdateNotification(); + if (notification) { + process.stderr.write(notification); + } } } diff --git a/src/lib/db/version-check.ts b/src/lib/db/version-check.ts new file mode 100644 index 00000000..a03edb9b --- /dev/null +++ b/src/lib/db/version-check.ts @@ -0,0 +1,55 @@ +/** + * Version check state persistence. + * + * Stores the last time we checked for updates and the latest known version + * in the metadata table for the "new version available" notification. + */ + +import { getDatabase } from "./index.js"; +import { runUpsert } from "./utils.js"; + +const KEY_LAST_CHECKED = "version_check.last_checked"; +const KEY_LATEST_VERSION = "version_check.latest_version"; + +export type VersionCheckInfo = { + /** Unix timestamp (ms) of last check, or null if never checked */ + lastChecked: number | null; + /** Latest version string from GitHub, or null if never fetched */ + latestVersion: string | null; +}; + +/** + * Get the stored version check state. + */ +export function getVersionCheckInfo(): VersionCheckInfo { + const db = getDatabase(); + + const lastCheckedRow = db + .query("SELECT value FROM metadata WHERE key = ?") + .get(KEY_LAST_CHECKED) as { value: string } | undefined; + + const latestVersionRow = db + .query("SELECT value FROM metadata WHERE key = ?") + .get(KEY_LATEST_VERSION) as { value: string } | undefined; + + return { + lastChecked: lastCheckedRow ? Number(lastCheckedRow.value) : null, + latestVersion: latestVersionRow?.value ?? null, + }; +} + +/** + * Store the version check result. + * Updates both the last checked timestamp and the latest known version. + */ +export function setVersionCheckInfo(latestVersion: string): void { + const db = getDatabase(); + const now = Date.now(); + + runUpsert(db, "metadata", { key: KEY_LAST_CHECKED, value: String(now) }, [ + "key", + ]); + runUpsert(db, "metadata", { key: KEY_LATEST_VERSION, value: latestVersion }, [ + "key", + ]); +} diff --git a/src/lib/upgrade.ts b/src/lib/upgrade.ts index 019d3223..0ea2b577 100644 --- a/src/lib/upgrade.ts +++ b/src/lib/upgrade.ts @@ -148,6 +148,7 @@ function getErrorMessage(error: unknown): string { * @param serviceName - Service name for error messages (e.g., "GitHub") * @returns Response object * @throws {UpgradeError} On network failure + * @throws {Error} AbortError if signal is aborted (re-thrown as-is) */ async function fetchWithUpgradeError( url: string, @@ -157,6 +158,10 @@ async function fetchWithUpgradeError( try { return await fetch(url, init); } catch (error) { + // Re-throw AbortError as-is so callers can handle it specifically + if (error instanceof Error && error.name === "AbortError") { + throw error; + } throw new UpgradeError( "network_error", `Failed to connect to ${serviceName}: ${getErrorMessage(error)}` @@ -167,13 +172,17 @@ async function fetchWithUpgradeError( /** * Fetch the latest version from GitHub releases. * + * @param signal - Optional AbortSignal to cancel the request * @returns Latest version string (without 'v' prefix) * @throws {UpgradeError} When fetch fails or response is invalid + * @throws {Error} AbortError if signal is aborted */ -export async function fetchLatestFromGitHub(): Promise { +export async function fetchLatestFromGitHub( + signal?: AbortSignal +): Promise { const response = await fetchWithUpgradeError( `${GITHUB_RELEASES_URL}/latest`, - { headers: GITHUB_HEADERS }, + { headers: GITHUB_HEADERS, signal }, "GitHub" ); diff --git a/src/lib/version-check.ts b/src/lib/version-check.ts new file mode 100644 index 00000000..82ab21d0 --- /dev/null +++ b/src/lib/version-check.ts @@ -0,0 +1,162 @@ +/** + * Background version check for "new version available" notifications. + * + * Checks GitHub releases for new versions without blocking CLI execution. + * Results are cached in the database and shown on subsequent runs. + */ + +// biome-ignore lint/performance/noNamespaceImport: Sentry SDK recommends namespace import +import * as Sentry from "@sentry/bun"; +import { CLI_VERSION } from "./constants.js"; +import { + getVersionCheckInfo, + setVersionCheckInfo, +} from "./db/version-check.js"; +import { cyan, muted } from "./formatters/colors.js"; +import { fetchLatestFromGitHub } from "./upgrade.js"; + +/** Target check interval: ~24 hours */ +const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; + +/** Jitter factor for probabilistic checking (±20%) */ +const JITTER_FACTOR = 0.2; + +/** Commands/flags that should not show update notifications */ +const SUPPRESSED_ARGS = new Set(["upgrade", "--version", "-V", "--json"]); + +/** AbortController for pending version check fetch */ +let pendingAbortController: AbortController | null = null; + +/** + * Determine if we should check for updates based on time since last check. + * Uses probabilistic approach: probability increases as we approach/pass the interval. + */ +function shouldCheckForUpdate(): boolean { + const { lastChecked } = getVersionCheckInfo(); + + if (lastChecked === null) { + return true; + } + + const elapsed = Date.now() - lastChecked; + + // Add jitter to the interval (±20%) + const jitter = (Math.random() - 0.5) * 2 * JITTER_FACTOR; + const effectiveInterval = CHECK_INTERVAL_MS * (1 + jitter); + + // Probability ramps up as we approach/exceed the interval + // At 0% of interval: ~0% chance + // At 100% of interval: ~63% chance (1 - 1/e) + // At 200% of interval: ~86% chance + const probability = 1 - Math.exp(-elapsed / effectiveInterval); + + return Math.random() < probability; +} + +/** + * Check if update notifications should be suppressed for these args. + */ +export function shouldSuppressNotification(args: string[]): boolean { + return args.some((arg) => SUPPRESSED_ARGS.has(arg)); +} + +/** + * Abort any pending version check to allow process exit. + * Call this when main CLI work is complete. + */ +export function abortPendingVersionCheck(): void { + pendingAbortController?.abort(); + pendingAbortController = null; +} + +/** + * Start a background check for new versions. + * Does not block - fires a fetch and lets it complete in the background. + * Reports errors to Sentry in a detached span for visibility. + * Never throws - errors are caught and reported to Sentry. + */ +function checkForUpdateInBackgroundImpl(): void { + try { + if (!shouldCheckForUpdate()) { + return; + } + } catch (error) { + // DB access failed - report to Sentry but don't crash CLI + Sentry.captureException(error); + return; + } + + pendingAbortController = new AbortController(); + const { signal } = pendingAbortController; + + Sentry.startSpanManual( + { + name: "version-check", + op: "version.check", + forceTransaction: true, + }, + async (span) => { + try { + const latestVersion = await fetchLatestFromGitHub(signal); + setVersionCheckInfo(latestVersion); + span.setStatus({ code: 1 }); // OK + } catch (error) { + // Don't report abort errors - they're expected when process exits + if (error instanceof Error && error.name !== "AbortError") { + Sentry.captureException(error); + } + span.setStatus({ code: 2 }); // Error + } finally { + pendingAbortController = null; + span.end(); + } + } + ); +} + +/** + * Get the update notification message if a new version is available. + * Returns null if up-to-date, no cached version info, or on error. + * Never throws - errors are caught and reported to Sentry. + */ +function getUpdateNotificationImpl(): string | null { + try { + const { latestVersion } = getVersionCheckInfo(); + + if (!latestVersion) { + return null; + } + + // Use Bun's native semver comparison (polyfilled for Node.js) + // order() returns 1 if first arg is greater than second + if (Bun.semver.order(latestVersion, CLI_VERSION) !== 1) { + return null; + } + + return `\n${muted("Update available:")} ${cyan(CLI_VERSION)} → ${cyan(latestVersion)} Run ${cyan('"sentry upgrade"')} to update.\n`; + } catch (error) { + // DB access failed - report to Sentry but don't crash CLI + Sentry.captureException(error); + return null; + } +} + +// No-op implementations for when update check is disabled +function noop(): void { + // Intentionally empty - used when update check is disabled +} + +function noopNull(): null { + return null; +} + +// Export either real implementations or no-ops based on environment variable +const isDisabled = process.env.SENTRY_CLI_NO_UPDATE_CHECK === "1"; + +export const maybeCheckForUpdateInBackground = isDisabled + ? noop + : checkForUpdateInBackgroundImpl; + +export const getUpdateNotification = isDisabled + ? noopNull + : getUpdateNotificationImpl; diff --git a/test/lib/db/version-check.test.ts b/test/lib/db/version-check.test.ts new file mode 100644 index 00000000..1ee7c8ae --- /dev/null +++ b/test/lib/db/version-check.test.ts @@ -0,0 +1,52 @@ +/** + * Version Check Storage Tests + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + getVersionCheckInfo, + setVersionCheckInfo, +} from "../../../src/lib/db/version-check.js"; +import { cleanupTestDir, createTestConfigDir } from "../../helpers.js"; + +let testConfigDir: string; + +beforeEach(async () => { + testConfigDir = await createTestConfigDir("test-version-check-"); + process.env.SENTRY_CONFIG_DIR = testConfigDir; +}); + +afterEach(async () => { + delete process.env.SENTRY_CONFIG_DIR; + await cleanupTestDir(testConfigDir); +}); + +describe("getVersionCheckInfo", () => { + test("returns null values when no data stored", () => { + const info = getVersionCheckInfo(); + expect(info.lastChecked).toBeNull(); + expect(info.latestVersion).toBeNull(); + }); +}); + +describe("setVersionCheckInfo", () => { + test("stores and retrieves version check info", () => { + setVersionCheckInfo("1.2.3"); + const info = getVersionCheckInfo(); + + expect(info.latestVersion).toBe("1.2.3"); + expect(info.lastChecked).toBeGreaterThan(0); + expect(info.lastChecked).toBeLessThanOrEqual(Date.now()); + }); + + test("updates existing version check info", () => { + setVersionCheckInfo("1.0.0"); + const first = getVersionCheckInfo(); + + setVersionCheckInfo("2.0.0"); + const second = getVersionCheckInfo(); + + expect(second.latestVersion).toBe("2.0.0"); + expect(second.lastChecked).toBeGreaterThanOrEqual(first.lastChecked!); + }); +}); diff --git a/test/lib/version-check.test.ts b/test/lib/version-check.test.ts new file mode 100644 index 00000000..0316d801 --- /dev/null +++ b/test/lib/version-check.test.ts @@ -0,0 +1,136 @@ +/** + * Version Check Logic Tests + */ + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { setVersionCheckInfo } from "../../src/lib/db/version-check.js"; +import { + getUpdateNotification, + maybeCheckForUpdateInBackground, + shouldSuppressNotification, +} from "../../src/lib/version-check.js"; +import { cleanupTestDir, createTestConfigDir } from "../helpers.js"; + +describe("shouldSuppressNotification", () => { + test("suppresses for upgrade command", () => { + expect(shouldSuppressNotification(["upgrade"])).toBe(true); + expect(shouldSuppressNotification(["upgrade", "--check"])).toBe(true); + }); + + test("suppresses for --version flag", () => { + expect(shouldSuppressNotification(["--version"])).toBe(true); + expect(shouldSuppressNotification(["-V"])).toBe(true); + }); + + test("suppresses for --json flag", () => { + expect(shouldSuppressNotification(["issue", "list", "--json"])).toBe(true); + expect(shouldSuppressNotification(["--json", "issue", "list"])).toBe(true); + }); + + test("does not suppress for regular commands", () => { + expect(shouldSuppressNotification(["issue", "list"])).toBe(false); + expect(shouldSuppressNotification(["auth", "status"])).toBe(false); + expect(shouldSuppressNotification(["help"])).toBe(false); + }); + + test("does not suppress for empty args", () => { + expect(shouldSuppressNotification([])).toBe(false); + }); +}); + +// Note: shouldCheckForUpdate is now an internal function that reads from the DB. +// Its probabilistic behavior is tested indirectly through maybeCheckForUpdateInBackground. + +describe("getUpdateNotification", () => { + let testConfigDir: string; + + beforeEach(async () => { + testConfigDir = await createTestConfigDir("test-version-notif-"); + process.env.SENTRY_CONFIG_DIR = testConfigDir; + }); + + afterEach(async () => { + delete process.env.SENTRY_CONFIG_DIR; + await cleanupTestDir(testConfigDir); + }); + + test("returns null when no version info is cached", () => { + const notification = getUpdateNotification(); + expect(notification).toBeNull(); + }); + + test("returns null when cached version is same as current", () => { + // CLI_VERSION is "0.0.0-dev" in test environment + setVersionCheckInfo("0.0.0-dev"); + const notification = getUpdateNotification(); + expect(notification).toBeNull(); + }); + + test("returns null when cached version is older than current", () => { + // Any version older than 0.0.0-dev (which is essentially "no version") + // Actually 0.0.0 is equal to 0.0.0-dev in semver (pre-release is older) + // So let's use something clearly older + setVersionCheckInfo("0.0.0-alpha"); + const notification = getUpdateNotification(); + expect(notification).toBeNull(); + }); + + test("returns notification message when newer version is available", () => { + setVersionCheckInfo("99.0.0"); + const notification = getUpdateNotification(); + + expect(notification).not.toBeNull(); + expect(notification).toContain("Update available:"); + expect(notification).toContain("99.0.0"); + expect(notification).toContain("sentry upgrade"); + }); +}); + +describe("maybeCheckForUpdateInBackground", () => { + let testConfigDir: string; + + beforeEach(async () => { + testConfigDir = await createTestConfigDir("test-version-bg-"); + process.env.SENTRY_CONFIG_DIR = testConfigDir; + }); + + afterEach(async () => { + delete process.env.SENTRY_CONFIG_DIR; + await cleanupTestDir(testConfigDir); + }); + + test("does not throw when called", () => { + // This test verifies the function can be called without errors + // The actual network call happens in the background and may fail, + // but the function itself should not throw + expect(() => maybeCheckForUpdateInBackground()).not.toThrow(); + }); +}); + +describe("opt-out behavior", () => { + test("functions are no-ops when SENTRY_CLI_NO_UPDATE_CHECK=1", () => { + // We need to test this in a subprocess because the env var is checked + // at module load time. The current process has already loaded the module. + const { spawnSync } = require("node:child_process"); + const { join } = require("node:path"); + + const testScript = ` + const { getUpdateNotification, maybeCheckForUpdateInBackground } = await import('./src/lib/version-check.js'); + + // These should be no-ops + maybeCheckForUpdateInBackground(); + const notification = getUpdateNotification(); + + console.log(notification === null ? 'PASS' : 'FAIL'); + `; + + const cwd = join(import.meta.dir, "../.."); + const proc = spawnSync("bun", ["-e", testScript], { + cwd, + env: { ...process.env, SENTRY_CLI_NO_UPDATE_CHECK: "1" }, + encoding: "utf-8", + }); + + expect(proc.stdout.trim()).toBe("PASS"); + }); +});