From 6e9f1d35396654a71a6e60e3ef4fb1b50f22d738 Mon Sep 17 00:00:00 2001 From: Devin Gould Date: Sat, 22 Aug 2026 13:29:54 -0400 Subject: [PATCH] feat(telemetry): give every failure an error code and report where a run stopped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Errors were collapsing into one bucket: 19 of 87 `CliError` sites carried no code, so 36.5% of init failures reported a generic `cli_error`. Adds 14 `ERROR_CODE` entries and applies them across init, update, deploy, env, users, and switch-env, so agent-mode JSON also gets a code where it previously fell through to plain text. Adds a `stage` dimension recording how far a multi-step command got, emitted on success, error, and abort — a drop-off funnel rather than an error-only field. `clerk init` is instrumented across ten markers. Splits npm registry failures so a bad `--channel` reports a usage error instead of an unreachable registry. Co-Authored-By: Claude Fable 5 --- .changeset/init-telemetry-stages.md | 6 ++ README.md | 16 ++-- .../cli-core/src/commands/deploy/index.ts | 1 + packages/cli-core/src/commands/env/pull.ts | 1 + .../cli-core/src/commands/init/bootstrap.ts | 16 +++- .../cli-core/src/commands/init/index.test.ts | 84 +++++++++++++++++++ packages/cli-core/src/commands/init/index.ts | 26 +++++- .../cli-core/src/commands/switch-env/index.ts | 4 +- .../cli-core/src/commands/update/index.ts | 29 +++++-- .../users/interactive/instance-context.ts | 8 +- packages/cli-core/src/lib/errors.ts | 26 ++++++ packages/cli-core/src/lib/telemetry.test.ts | 74 +++++++++++++++- packages/cli-core/src/lib/telemetry.ts | 39 ++++++++- .../cli-core/src/lib/update-check.test.ts | 60 +++++++++++++ packages/cli-core/src/lib/update-check.ts | 19 ++++- 15 files changed, 379 insertions(+), 30 deletions(-) create mode 100644 .changeset/init-telemetry-stages.md diff --git a/.changeset/init-telemetry-stages.md b/.changeset/init-telemetry-stages.md new file mode 100644 index 000000000..e3a6507b8 --- /dev/null +++ b/.changeset/init-telemetry-stages.md @@ -0,0 +1,6 @@ +--- +"clerk": minor +--- + +Give every failure a specific error code, and record which step a multi-step command reached. +Agent-mode JSON now carries a code for failures that previously reported only a message, and `clerk init` reports the step it stopped at rather than a single generic failure. diff --git a/README.md b/README.md index 8dbaee215..3ad226f51 100644 --- a/README.md +++ b/README.md @@ -60,14 +60,14 @@ Commands: ## Telemetry -The Clerk CLI collects usage telemetry: command name, flag names, duration, outcome, -environment signals (OS, install method, terminal), a random machine identifier — and -your workspace and app IDs when a project is linked. It never collects command -arguments, option values, file paths, or personal data. The first run only shows a -disclosure notice and sends nothing (CI environments send from the first run), and -`clerk --verbose` prints every event before it is sent. Shell completion (`clerk -completion ` and the `__complete` helper behind Tab) sends nothing at all. -See https://clerk.com/docs/telemetry for details. +The Clerk CLI collects usage telemetry: command name, flag names, duration, outcome, the +step a multi-step command reached, environment signals (OS, install method, terminal), a +random machine identifier — and your workspace and app IDs when a project is linked. It +never collects command arguments, option values, file paths, or personal data. The first +run only shows a disclosure notice and sends nothing (CI environments send from the +first run), and `clerk --verbose` prints every event before it is sent. Shell completion +(`clerk completion ` and the `__complete` helper behind Tab) sends nothing at +all. See https://clerk.com/docs/telemetry for details. Opt out with `clerk telemetry disable`, or by setting `CLERK_TELEMETRY_DISABLED=1` (the standard `DO_NOT_TRACK=1` also works). `clerk telemetry status` shows the diff --git a/packages/cli-core/src/commands/deploy/index.ts b/packages/cli-core/src/commands/deploy/index.ts index 3e691ec16..6f0958e0d 100644 --- a/packages/cli-core/src/commands/deploy/index.ts +++ b/packages/cli-core/src/commands/deploy/index.ts @@ -187,6 +187,7 @@ async function startNewDeploy(ctx: DeployContext): Promise { throw new CliError( "Production instance was created but Clerk did not return a domain. " + "Run `clerk deploy` again to retry domain provisioning.", + { code: ERROR_CODE.DEPLOY_DOMAIN_MISSING }, ); } diff --git a/packages/cli-core/src/commands/env/pull.ts b/packages/cli-core/src/commands/env/pull.ts index 50d4aa7d8..62b977fbd 100644 --- a/packages/cli-core/src/commands/env/pull.ts +++ b/packages/cli-core/src/commands/env/pull.ts @@ -147,6 +147,7 @@ async function pullKeylessKeys( throw new CliError( `The publishable key found locally doesn't belong to the application the secret key from \`${keyless.source}\` addresses. Writing this pair would leave the server trusting one app while the browser talks to another.\n` + `Remove the mismatched ${publishableKeyName} from your env files, or run \`clerk auth login\` to claim the intended application, then pull again.`, + { code: ERROR_CODE.KEY_PAIR_MISMATCH }, ); } } diff --git a/packages/cli-core/src/commands/init/bootstrap.ts b/packages/cli-core/src/commands/init/bootstrap.ts index 79f183eff..1aeb393f7 100644 --- a/packages/cli-core/src/commands/init/bootstrap.ts +++ b/packages/cli-core/src/commands/init/bootstrap.ts @@ -2,7 +2,7 @@ import { join } from "node:path"; import { statSync } from "node:fs"; import { confirm, text } from "../../lib/prompts.ts"; import { search, filterChoices } from "../../lib/listage.ts"; -import { throwUserAbort, throwUsageError, CliError } from "../../lib/errors.js"; +import { throwUserAbort, throwUsageError, CliError, ERROR_CODE } from "../../lib/errors.js"; import { log } from "../../lib/log.js"; import type { FrameworkInfo } from "../../lib/framework.js"; import { dirExists, hasPackageJson } from "./context.js"; @@ -49,6 +49,7 @@ async function pickFramework(frameworkOverride?: FrameworkInfo): Promise e.label).join(", "); throw new CliError( `Bootstrap is not supported for ${frameworkOverride.name}. Supported: ${supported}`, + { code: ERROR_CODE.BOOTSTRAP_UNSUPPORTED }, ); } @@ -109,7 +110,9 @@ export async function findAvailableProjectName(cwd: string, base: string): Promi const candidate = `${base}-${i}`; if (!(await dirExists(join(cwd, candidate)))) return candidate; } - throw new CliError(`Could not find an available project name based on '${base}'.`); + throw new CliError(`Could not find an available project name based on '${base}'.`, { + code: ERROR_CODE.PROJECT_DIR_EXISTS, + }); } async function askProjectName(entry: BootstrapEntry, cwd: string): Promise { @@ -137,7 +140,9 @@ async function generateProject(label: string, command: string[], cwd: string): P const exitCode = await spawnInherited(command, cwd); if (exitCode !== 0) { - throw new CliError(`Project generation failed (exit code ${exitCode}).`); + throw new CliError(`Project generation failed (exit code ${exitCode}).`, { + code: ERROR_CODE.GENERATOR_FAILED, + }); } } @@ -231,13 +236,16 @@ export async function promptAndBootstrap( if (await dirExists(projectDir)) { throw new CliError( `Directory '${projectName}' already exists. Pick a different name or remove it first.`, + { code: ERROR_CODE.PROJECT_DIR_EXISTS }, ); } await generateProject(entry.label, entry.buildCommand(pm, projectName), cwd); if (!(await hasPackageJson(projectDir))) { - throw new CliError("Generator did not create a package.json."); + throw new CliError("Generator did not create a package.json.", { + code: ERROR_CODE.GENERATOR_FAILED, + }); } await installDependencies(pm, projectDir); diff --git a/packages/cli-core/src/commands/init/index.test.ts b/packages/cli-core/src/commands/init/index.test.ts index 42b7765db..55b9c7099 100644 --- a/packages/cli-core/src/commands/init/index.test.ts +++ b/packages/cli-core/src/commands/init/index.test.ts @@ -19,7 +19,10 @@ import { skillsMod, bootstrapMod, nextStepsMod, + mockExistingProject, + mockMiddlewareScaffold, } from "../../test/lib/init-harness.ts"; +import * as telemetryMod from "../../lib/telemetry.ts"; import { init } from "./index.ts"; describe("init", () => { @@ -584,4 +587,85 @@ describe("init", () => { cwd: FAKE_BOOTSTRAP.projectDir, }); }); + + describe("telemetry stages", () => { + /** Spy registered with the harness so its calls reset between tests. */ + function trackStages() { + const stage = spyOn(telemetryMod, "setTelemetryStage"); + track(stage); + return () => stage.mock.calls.map((call) => call[0]); + } + + test("a completed run reports the terminal stage", async () => { + setup({ email: "test@test.com" }); + mockExistingProject(FAKE_CTX); + mockMiddlewareScaffold(); + const stages = trackStages(); + + await init({ yes: true }); + + expect(stages().at(-1)).toBe("done"); + }); + + test("a run with nothing to do stops at already_set_up", async () => { + setup({ email: "test@test.com" }); + mockExistingProject(FAKE_CTX); + const stages = trackStages(); + + await init({ yes: true }); + + expect(stages().at(-1)).toBe("already_set_up"); + }); + + // A run that dies in flag validation must not claim any later stage — + // that's what makes the funnel readable. + test("a rejected flag combination stops at the flags stage", async () => { + setup({ email: "test@test.com" }); + const stages = trackStages(); + + await expect(init({ keyless: true, login: true })).rejects.toThrow(); + + expect(stages()).toEqual(["flags"]); + }); + + test("declining the scaffold preview stops at the scaffold stage", async () => { + setup({ email: "test@test.com" }); + mockExistingProject(FAKE_CTX); + mockMiddlewareScaffold(); + track(spyOn(previewMod, "previewAndConfirm").mockResolvedValue(false)); + const stages = trackStages(); + + await expect(init({})).rejects.toThrow(); + + expect(stages().at(-1)).toBe("scaffold"); + }); + + // Declining the overwrite prompt is the default answer on --starter, so it + // is a common drop-off — and it happens before bootstrapAndDetect runs. + test("declining the starter overwrite prompt stops at the bootstrap stage", async () => { + setup({ email: "test@test.com" }); + spyOn(context, "gatherContext").mockResolvedValue(FAKE_CTX); + spyOn(config, "resolveProfile").mockResolvedValue({ profile: { appId: "app_123" } } as never); + track( + spyOn(bootstrapMod, "confirmOverwrite").mockRejectedValue( + Object.assign(new Error(), { name: "UserAbortError" }), + ), + ); + const stages = trackStages(); + + await expect(init({ starter: true })).rejects.toMatchObject({ name: "UserAbortError" }); + + expect(stages().at(-1)).toBe("bootstrap"); + }); + + test("a failure inside the generator stops at the bootstrap stage", async () => { + setup(); + track(spyOn(bootstrapMod, "promptAndBootstrap").mockRejectedValue(new Error("gen failed"))); + const stages = trackStages(); + + await expect(init({})).rejects.toThrow(); + + expect(stages().at(-1)).toBe("bootstrap"); + }); + }); }); diff --git a/packages/cli-core/src/commands/init/index.ts b/packages/cli-core/src/commands/init/index.ts index fbe208204..e8487ed8c 100644 --- a/packages/cli-core/src/commands/init/index.ts +++ b/packages/cli-core/src/commands/init/index.ts @@ -5,7 +5,13 @@ import { link } from "../link/index.js"; import { pull } from "../env/pull.js"; import { isAgent } from "../../mode.js"; import { dim, bold } from "../../lib/color.js"; -import { throwUserAbort, throwUsageError, CliError, errorMessage } from "../../lib/errors.js"; +import { + throwUserAbort, + throwUsageError, + CliError, + ERROR_CODE, + errorMessage, +} from "../../lib/errors.js"; import { lookupFramework, isNpmFramework, @@ -15,6 +21,7 @@ import { import { resolveProfile } from "../../lib/config.js"; import { deriveProjectName } from "../../lib/project-name.js"; import { log } from "../../lib/log.js"; +import { setTelemetryStage } from "../../lib/telemetry.js"; import { confirm } from "../../lib/prompts.ts"; import { createAccountlessApp, @@ -81,6 +88,7 @@ export async function init(options: InitOptions = {}) { const cwd = process.cwd(); const agent = isAgent(); + setTelemetryStage("flags"); await assertUsableFlags(options, agent); const frameworkOverride = options.framework @@ -96,6 +104,7 @@ export async function init(options: InitOptions = {}) { intro("Setting up Clerk"); + setTelemetryStage("detect"); const resolved = options.starter ? await handleStarter(cwd, frameworkOverride, overrides) : await resolveProjectContext(cwd, frameworkOverride, overrides); @@ -119,6 +128,7 @@ export async function init(options: InitOptions = {}) { // stale/broken credential ends up blocked on an interactive browser OAuth // round-trip it can never complete. So agent mode validates the credential // (it can fall back to keyless) instead of trusting mere presence. + setTelemetryStage("strategy"); const authed = optsKeyless ? false : agent @@ -141,6 +151,7 @@ export async function init(options: InitOptions = {}) { assertKeylessOnlyFlags(options, strategy); if (strategy === "authenticate") { + setTelemetryStage("link"); bar(); const createIfMissing = agent ? await deriveProjectName(ctx.cwd, bootstrap?.projectName) @@ -156,6 +167,7 @@ export async function init(options: InitOptions = {}) { const { alreadySetUp } = await detectAndInstall(ctx.cwd, ctx, skipScaffoldConfirm); if (alreadySetUp) { + setTelemetryStage("already_set_up"); log.success("\nClerk is already set up in this project."); if (agent && strategy === "manual") { printBootstrapManualSetupInfo(ctx.framework); @@ -164,6 +176,7 @@ export async function init(options: InitOptions = {}) { return; } + setTelemetryStage("keys"); bar(); await runStrategy(strategy, ctx, { template: options.template, @@ -173,6 +186,7 @@ export async function init(options: InitOptions = {}) { // Native platforms (iOS/Android) have no npx/Node toolchain to run `skills add` with. if (options.skills !== false && isNpmFramework(ctx.framework)) { + setTelemetryStage("skills"); bar(); await installSkills(ctx.cwd, ctx.framework.dep, ctx.packageManager, overrides.skipConfirm); } @@ -183,6 +197,7 @@ export async function init(options: InitOptions = {}) { printBootstrapNextSteps(bootstrap, strategy === "keyless"); } + setTelemetryStage("done"); await outro("Done"); } @@ -275,11 +290,14 @@ async function bootstrapAndDetect( frameworkOverride: FrameworkInfo | undefined, overrides: BootstrapOverrides, ): Promise { + setTelemetryStage("bootstrap"); const bootstrap = await promptAndBootstrap(cwd, frameworkOverride, overrides); const ctx = await gatherContext(bootstrap.projectDir); if (!ctx) { - throw new CliError("Project generation did not produce a detectable framework."); + throw new CliError("Project generation did not produce a detectable framework.", { + code: ERROR_CODE.FRAMEWORK_UNDETECTED, + }); } return { ctx, bootstrap }; } @@ -289,6 +307,7 @@ async function handleStarter( frameworkOverride: FrameworkInfo | undefined, overrides: BootstrapOverrides, ): Promise { + setTelemetryStage("bootstrap"); if (!overrides.skipConfirm) { await confirmOverwrite(cwd); } @@ -323,6 +342,7 @@ async function resolveProjectContext( if (!isBlank) { throw new CliError( `Could not detect a framework. Install the appropriate Clerk SDK manually: https://clerk.com/docs`, + { code: ERROR_CODE.FRAMEWORK_UNDETECTED }, ); } @@ -559,11 +579,13 @@ async function detectAndInstall( if (ctx.existingClerk) { log.info(dim(`${ctx.framework.sdk} is already installed`)); } else if (isNpmFramework(ctx.framework)) { + setTelemetryStage("install"); await installSdk(ctx); } // Non-npm ecosystems (Swift Package Manager, Gradle) can't be installed by a // package manager here — the framework's scaffold plan prints install steps. + setTelemetryStage("scaffold"); return scaffoldAndWrite(cwd, ctx, skipConfirm); } diff --git a/packages/cli-core/src/commands/switch-env/index.ts b/packages/cli-core/src/commands/switch-env/index.ts index a672b2bbc..96e7fcc07 100644 --- a/packages/cli-core/src/commands/switch-env/index.ts +++ b/packages/cli-core/src/commands/switch-env/index.ts @@ -16,7 +16,7 @@ import { isValidEnv, setCurrentEnv, } from "../../lib/environment.ts"; -import { CliError } from "../../lib/errors.ts"; +import { CliError, ERROR_CODE } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; import { isHuman } from "../../mode.ts"; import { select } from "../../lib/listage.ts"; @@ -44,6 +44,7 @@ export async function switchEnv(environmentArg: string | undefined): Promise 1 && !process.stdin.isTTY) { throw new CliError( "No interactive terminal available — pass an environment name explicitly: `clerk switch-env `", + { code: ERROR_CODE.NO_INTERACTIVE_TERMINAL }, ); } else if (available.length <= 1) { log.info(`Current environment: ${current}`); @@ -61,6 +62,7 @@ export async function switchEnv(environmentArg: string | undefined): Promise { if (isHuman()) intro("Checking for updates"); const [latest, installDirs] = await Promise.all([ - withSpinner("Checking for updates...", async () => fetchLatestVersion(channel)).catch(() => { - throw new CliError("Could not reach npm registry. Check your network connection."); - }), + withSpinner("Checking for updates...", async () => fetchLatestVersion(channel)).catch( + (error: unknown) => { + // A registry that answered — badly, or without the requested channel — + // already carries its own code. Only transport and timeout failures are + // genuinely "unreachable", and retrying is only right for those. + if (error instanceof CliError) throw error; + throw new CliError("Could not reach npm registry. Check your network connection.", { + code: ERROR_CODE.REGISTRY_UNREACHABLE, + }); + }, + ), getInstallerPackageDirs(), ]); diff --git a/packages/cli-core/src/commands/users/interactive/instance-context.ts b/packages/cli-core/src/commands/users/interactive/instance-context.ts index 4f6db1c64..e95a288fb 100644 --- a/packages/cli-core/src/commands/users/interactive/instance-context.ts +++ b/packages/cli-core/src/commands/users/interactive/instance-context.ts @@ -61,12 +61,16 @@ async function fetchCurrentBapiInstance(secretKey: string): Promise { expect(captured.err).not.toContain("usage telemetry"); }); }); + + describe("stage", () => { + /** Captures the payload of the single event a finalize call sends. */ + async function sendAndCapturePayload( + run: () => void | Promise, + result: TelemetryResult, + ): Promise> { + await markTelemetryNoticeShown(); // past the grace run — reach the send path + process.env.CLERK_TELEMETRY_URL = "https://capture.invalid/v1/event"; + let sent: string | undefined; + globalThis.fetch = (async (_url: unknown, init: { body?: string }) => { + sent = init.body; + return new Response("{}"); + }) as unknown as typeof fetch; + + startCommandTelemetry(fakeCommand()); + await run(); + await finalizeAndSendTelemetry(result); + + expect(sent).toBeDefined(); + const parsed = JSON.parse(sent as string) as { + events: { payload: Record }[]; + }; + return parsed.events[0]!.payload; + } + + test("reports the furthest stage reached on success", async () => { + const payload = await sendAndCapturePayload( + () => { + setTelemetryStage("detect"); + setTelemetryStage("scaffold"); + setTelemetryStage("done"); + }, + { outcome: "success", exitCode: 0 }, + ); + expect(payload.stage).toBe("done"); + }); + + test("reports where an error stopped the command", async () => { + const payload = await sendAndCapturePayload( + () => { + setTelemetryStage("detect"); + setTelemetryStage("bootstrap"); + }, + telemetryResultForError(new CliError("boom", { code: ERROR_CODE.GENERATOR_FAILED })), + ); + expect(payload.stage).toBe("bootstrap"); + expect(payload.error_code).toBe("generator_failed"); + }); + + // The whole point of stage: an abort is a drop-off, and drop-offs are only + // legible if you can see which step the user backed out of. + test("reports the stage on abort", async () => { + const payload = await sendAndCapturePayload( + () => setTelemetryStage("scaffold"), + telemetryResultForError(new UserAbortError()), + ); + expect(payload.outcome).toBe("abort"); + expect(payload.stage).toBe("scaffold"); + }); + + test("stage is null when the command never sets one", async () => { + const payload = await sendAndCapturePayload(() => {}, { outcome: "success", exitCode: 0 }); + expect(payload.stage).toBeNull(); + }); + + test("setting a stage with no active context is a no-op", () => { + expect(() => setTelemetryStage("flags")).not.toThrow(); + }); + }); }); diff --git a/packages/cli-core/src/lib/telemetry.ts b/packages/cli-core/src/lib/telemetry.ts index 4e9ee31cd..3e5f61a61 100644 --- a/packages/cli-core/src/lib/telemetry.ts +++ b/packages/cli-core/src/lib/telemetry.ts @@ -42,6 +42,25 @@ export type TelemetryResult = { errorCode?: string; }; +/** + * Closed set of drop-off points a command can report. A union rather than a + * bare string so a typo or a rename that misses a call site fails to compile + * instead of silently splitting the funnel into two buckets in the warehouse, + * and so no interpolated value (a path, a project name) can reach the payload. + */ +export type TelemetryStage = + | "flags" + | "detect" + | "strategy" + | "link" + | "bootstrap" + | "install" + | "scaffold" + | "keys" + | "skills" + | "already_set_up" + | "done"; + /** Structural slice of Commander's Command — avoids its generic types. */ export type TelemetryCommand = { name(): string; @@ -54,6 +73,8 @@ type TelemetryContext = { command: string; flags: string; startedAt: number; + /** Furthest stage reached — see setTelemetryStage. */ + stage: TelemetryStage | null; }; let context: TelemetryContext | null = null; @@ -152,12 +173,24 @@ export function startCommandTelemetry(actionCommand: TelemetryCommand): void { command, flags: collectSetFlagNames(actionCommand).join(","), startedAt: Date.now(), + stage: null, }; } catch (error) { log.debug(`telemetry: failed to start context: ${error}`); } } +/** + * Mark how far a multi-step command got. The last stage set is the one sent, + * on every outcome — a success reports where it finished, an error or abort + * reports where it stopped. That makes `stage` a drop-off funnel rather than + * an error-only dimension: a user declining the scaffold preview and a + * failure inside the generator are both legible, and distinguishable. + */ +export function setTelemetryStage(stage: TelemetryStage): void { + if (context) context.stage = stage; +} + export function telemetryResultForError(error: unknown): TelemetryResult { if (error instanceof UserAbortError) { return { outcome: "abort", exitCode: EXIT_CODE.SUCCESS }; @@ -246,6 +279,7 @@ async function buildAndSend( outcome: result.outcome, exit_code: result.exitCode, error_code: result.errorCode ?? null, + stage: current.stage, duration_ms: Date.now() - current.startedAt, machine_uuid: machineUuid, install_method: detectInstallMethod(process.env, process.execPath), @@ -297,9 +331,10 @@ async function maybeShowTelemetryNotice(): Promise { "The Clerk CLI collects usage telemetry to help improve the CLI: command name, flag names,", ); log.info( - "duration, outcome, a random machine identifier — and your workspace and app IDs when a", + "duration, outcome, the step a multi-step command reached, a random machine identifier —", ); - log.info("project is linked. Nothing has been sent during this run."); + log.info("and your workspace and app IDs when a project is linked."); + log.info("Nothing has been sent during this run."); log.info("Opt out: `clerk telemetry disable` — details: https://clerk.com/docs/telemetry"); log.blank(); return true; diff --git a/packages/cli-core/src/lib/update-check.test.ts b/packages/cli-core/src/lib/update-check.test.ts index 54295bd32..1a48573a5 100644 --- a/packages/cli-core/src/lib/update-check.test.ts +++ b/packages/cli-core/src/lib/update-check.test.ts @@ -4,8 +4,11 @@ import { getUpdateChannel, compareSemver, shouldCheckForUpdates, + fetchLatestVersion, } from "./update-check.ts"; import * as mode from "../mode.ts"; +import * as fetchMod from "./fetch.ts"; +import { ERROR_CODE } from "./errors.ts"; // ── inferChannelFromVersion ─────────────────────────────────────────────────── @@ -203,3 +206,60 @@ describe("shouldCheckForUpdates", () => { } }); }); + +// ── fetchLatestVersion ──────────────────────────────────────────────────────── + +describe("fetchLatestVersion", () => { + function mockRegistry(body: unknown, ok = true, status = 200) { + return spyOn(fetchMod, "loggedFetch").mockResolvedValue({ + ok, + status, + json: async () => body, + } as Response); + } + + afterEach(() => { + spyOn(fetchMod, "loggedFetch").mockRestore(); + }); + + test("returns the version published under the requested channel", async () => { + mockRegistry({ "dist-tags": { latest: "3.2.0", canary: "3.3.0-canary.1" } }); + + await expect(fetchLatestVersion("canary")).resolves.toBe("3.3.0-canary.1"); + }); + + // A channel that does not exist is bad input, not a broken network — telling + // the two apart is what stops an agent retrying a permanent failure. + test("a missing dist-tag is a usage error, not an unreachable registry", async () => { + mockRegistry({ "dist-tags": { latest: "3.2.0" } }); + + await expect(fetchLatestVersion("typo")).rejects.toMatchObject({ + code: ERROR_CODE.USAGE_ERROR, + }); + }); + + test("a malformed registry response is an update failure", async () => { + mockRegistry({ nope: true }); + + await expect(fetchLatestVersion("latest")).rejects.toMatchObject({ + code: ERROR_CODE.UPDATE_FAILED, + }); + }); + + test("a non-ok registry response is an update failure", async () => { + mockRegistry({}, false, 503); + + await expect(fetchLatestVersion("latest")).rejects.toMatchObject({ + code: ERROR_CODE.UPDATE_FAILED, + }); + }); + + // Transport failures stay untyped so the caller can label them unreachable. + test("a transport failure carries no error code", async () => { + spyOn(fetchMod, "loggedFetch").mockRejectedValue(new Error("ECONNREFUSED")); + + const error = await fetchLatestVersion("latest").catch((e: unknown) => e); + expect(error).toBeInstanceOf(Error); + expect((error as { code?: string }).code).toBeUndefined(); + }); +}); diff --git a/packages/cli-core/src/lib/update-check.ts b/packages/cli-core/src/lib/update-check.ts index 8295d898c..6005f27a1 100644 --- a/packages/cli-core/src/lib/update-check.ts +++ b/packages/cli-core/src/lib/update-check.ts @@ -8,6 +8,7 @@ import { UPDATE_PACKAGE_NAME, UPDATE_CACHE_FILE, } from "./constants.ts"; +import { CliError, ERROR_CODE } from "./errors.ts"; import { loggedFetch } from "./fetch.ts"; import { log } from "./log.ts"; import { CURRENT_VERSION, IS_DEV_BUILD } from "./version.ts"; @@ -106,11 +107,23 @@ export async function fetchLatestVersion(distTag: string, timeoutMs = 1500): Pro signal: controller.signal, headers: { Accept: "application/vnd.npm.install-v1+json" }, }); - if (!res.ok) throw new Error(`registry HTTP ${res.status}`); + if (!res.ok) { + throw new CliError(`Registry returned HTTP ${res.status}.`, { + code: ERROR_CODE.UPDATE_FAILED, + }); + } const data: unknown = await res.json(); - if (!isNpmDistTagsResponse(data)) throw new Error("unexpected registry response shape"); + if (!isNpmDistTagsResponse(data)) { + throw new CliError("Unexpected response shape from the npm registry.", { + code: ERROR_CODE.UPDATE_FAILED, + }); + } const version = data["dist-tags"][distTag]; - if (!version) throw new Error(`dist-tag "${distTag}" not found`); + if (!version) { + throw new CliError(`Release channel "${distTag}" does not exist.`, { + code: ERROR_CODE.USAGE_ERROR, + }); + } return version; } finally { clearTimeout(timer);