Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions script/node-polyfills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -163,6 +164,10 @@ const BunPolyfill = {
randomUUIDv7(): string {
return uuidv7();
},

semver: {
order: semverCompare,
},
};

globalThis.Bun = BunPolyfill as typeof Bun;
23 changes: 23 additions & 0 deletions src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const args = process.argv.slice(2);
const suppressNotification = shouldSuppressNotification(args);

// Start background update check (non-blocking)
if (!suppressNotification) {
maybeCheckForUpdateInBackground();
}

try {
await withTelemetry(async (span) =>
Expand All @@ -15,6 +27,17 @@ async function main(): Promise<void> {
} 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);
}
}
}

Expand Down
55 changes: 55 additions & 0 deletions src/lib/db/version-check.ts
Original file line number Diff line number Diff line change
@@ -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",
]);
}
13 changes: 11 additions & 2 deletions src/lib/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)}`
Expand All @@ -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<string> {
export async function fetchLatestFromGitHub(
signal?: AbortSignal
): Promise<string> {
const response = await fetchWithUpgradeError(
`${GITHUB_RELEASES_URL}/latest`,
{ headers: GITHUB_HEADERS },
{ headers: GITHUB_HEADERS, signal },
"GitHub"
);

Expand Down
162 changes: 162 additions & 0 deletions src/lib/version-check.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading
Loading