Skip to content
Draft
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
10 changes: 10 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, satisfies as semverSatisfies } from "semver";
import { glob } from "tinyglobby";
import { uuidv7 } from "uuidv7";

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

semver: {
order(a: string, b: string): -1 | 0 | 1 {
return semverCompare(a, b);
},
satisfies(version: string, range: string): boolean {
return semverSatisfies(version, range);
},
},
};

globalThis.Bun = BunPolyfill as typeof Bun;
19 changes: 19 additions & 0 deletions src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,20 @@ 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 {
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 @@ -16,6 +27,14 @@ async function main(): Promise<void> {
process.stderr.write(`${error("Error:")} ${formatError(err)}\n`);
process.exit(getExitCode(err));
}

// Show update notification after command completes
if (!suppressNotification) {
const notification = getUpdateNotification();
if (notification) {
process.stderr.write(notification);
}
}
}

main();
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",
]);
}
132 changes: 132 additions & 0 deletions src/lib/version-check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* 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"]);

/**
* 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(lastChecked: number | null): boolean {
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) || arg === "--json");
}

/**
* 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.
*/
function checkForUpdateInBackgroundImpl(): void {
const { lastChecked } = getVersionCheckInfo();

if (!shouldCheckForUpdate(lastChecked)) {
return;
}

// Start a detached span for the background update check
Sentry.startSpanManual(
{
name: "version-check",
op: "version.check",
forceTransaction: true,
},
(span) => {
fetchLatestFromGitHub()
.then((latestVersion) => {
setVersionCheckInfo(latestVersion);
span.setStatus({ code: 1 }); // OK
})
.catch((error) => {
Sentry.captureException(error);
span.setStatus({ code: 2 }); // Error
})
.finally(() => {
span.end();
});
}
);
}

/**
* Get the update notification message if a new version is available.
* Returns null if up-to-date or no cached version info.
*/
function getUpdateNotificationImpl(): string | null {
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`;
}

// 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;
52 changes: 52 additions & 0 deletions test/lib/db/version-check.test.ts
Original file line number Diff line number Diff line change
@@ -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!);
});
});
33 changes: 33 additions & 0 deletions test/lib/version-check.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Version Check Logic Tests
*/

import { describe, expect, test } from "bun:test";
import { shouldSuppressNotification } from "../../src/lib/version-check.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);
});
});
Loading