From 30e9bc779c25eb50296a6dedcd9787c2f08f6072 Mon Sep 17 00:00:00 2001 From: Peter Krenesky Date: Fri, 26 Jun 2026 19:39:45 -0700 Subject: [PATCH 1/2] feat(self-update): add maybeOfferUpdate notifier (FR-024) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A shared, framework-agnostic update notifier so every npm-distributed IX CLI can tell the user a newer version is published and offer to install it — without re-implementing the throttled check or prompt. maybeOfferUpdate(): - never throws into or blocks the host: skips in CI, on NO_UPDATE_NOTIFIER, when non-interactive, or within a 24h per-package throttle cache (/update-check.json), and swallows registry/cache failures; - queries latest via the FR-023 registry helper (same ambient-config default and scope-specific --registry override); - offers only strictly-newer versions (numeric major.minor.patch; -dirty/ pre-release ignored) so a dev build ahead of the release isn't downgraded; - prompts [Y/n] (Enter = yes) and delegates to runSelfUpdate on accept. Exports the helper + result/option types; reuses the existing defaultConfirm (now exported) for an identical prompt. Adds FR-024 + matrix coverage and 10 unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- spec/functional/FR-024-update-notifier.md | 61 +++++++ spec/tests.md | 17 +- src/commands/self-update.tsx | 169 +++++++++++++++++++ src/index.ts | 3 + src/runtime/agent.ts | 4 +- tests/update-notifier.test.ts | 196 ++++++++++++++++++++++ 6 files changed, 444 insertions(+), 6 deletions(-) create mode 100644 spec/functional/FR-024-update-notifier.md create mode 100644 tests/update-notifier.test.ts diff --git a/spec/functional/FR-024-update-notifier.md b/spec/functional/FR-024-update-notifier.md new file mode 100644 index 0000000..2864da1 --- /dev/null +++ b/spec/functional/FR-024-update-notifier.md @@ -0,0 +1,61 @@ +--- +id: FR-024 +title: "Update Notifier" +type: FR +relationships: + - target: "ix://agent-ix/ix-cli-core/spec/stakeholder/StR-003" + type: "implements" + cardinality: "1:1" +--- + +## Description + +ix-cli-core SHALL provide a framework-agnostic `maybeOfferUpdate` helper so that +any npm-distributed consuming CLI can notify the user — and offer to install — +when a newer version is published, without re-implementing the throttled +registry check or the prompt. It builds on the same registry query and install +path as [FR-023](./FR-023-self-update-helper.md)'s `runSelfUpdate`. + +`maybeOfferUpdate(options)` takes the caller's `packageName`, `currentVersion`, +an optional `registry` override, and injectable `interactive`/`ttlMs`/ +`cachePath`/`now`/`env`/`confirm` seams (for host control and tests). It is +designed to **never throw into or block the host CLI**: + +- It SHALL skip the check (querying nothing) when running in CI (`env.CI`), when + opted out (`env.NO_UPDATE_NOTIFIER`), when non-interactive (stdin/stdout not + both TTYs), or when a prior check falls within the throttle window. +- It SHALL throttle checks with a per-package cache + (`/update-check.json` by default), recording the last-check time and + latest version, and SHALL record the time even when the query fails so a flaky + or unreachable registry is not queried on every invocation. +- It SHALL query the latest published version via the FR-023 registry helper + (honouring the same ambient-config default and scope-specific `registry` + override), and SHALL swallow any registry/cache failure, returning a skip + result rather than propagating an error. +- It SHALL treat a version as updatable only when the latest is strictly newer by + numeric `major.minor.patch` (pre-release/`-dirty` suffixes ignored), so a + local dev build ahead of the published release is not offered a downgrade. +- When a newer version exists and the session is interactive, it SHALL prompt + `[Y/n]` (Enter = yes) and, on accept, delegate to `runSelfUpdate` to install. + +It returns an `UpdateNotifierResult` (`{ checked, reason?, latest?, +updateAvailable?, updated? }`) for callers that branch on the outcome. + +## Acceptance Criteria + +| ID | Criteria | Verification | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | +| FR-024-AC-1 | The check is skipped (no registry query) in CI, when `NO_UPDATE_NOTIFIER` is set, and when non-interactive, returning the matching `reason` | Test | +| FR-024-AC-2 | A prior check within `ttlMs` (seeded cache) is throttled — no registry query — returning `{ checked: false, reason: "throttled" }` | Test | +| FR-024-AC-3 | When a strictly-newer version exists and the user accepts, it delegates to `runSelfUpdate` (install) and returns `updated: true` | Test | +| FR-024-AC-4 | When newer and the user declines, it returns `updateAvailable: true, updated: false` and performs no install | Test | +| FR-024-AC-5 | An equal version, or a local dev build ahead of the latest, yields `updateAvailable: false` and never prompts | Test | +| FR-024-AC-6 | A successful or failed check records `lastCheck` in the cache (throttling the next call); a registry failure returns `reason: "error"` without throwing | Test | + +## Dependencies + +- **Upstream**: [StR-003](../stakeholder/StR-003-reusable-cli-runtime.md) + (reusable CLI runtime — "no bespoke per-CLI re-implementation"); + [FR-023](./FR-023-self-update-helper.md) (registry query + install path reused). +- **Downstream**: consuming CLIs that call `maybeOfferUpdate` early in dispatch + (e.g. `@agent-ix/quoin`, `@agent-ix/ix-flow`). diff --git a/spec/tests.md b/spec/tests.md index 774c548..20e0033 100644 --- a/spec/tests.md +++ b/spec/tests.md @@ -52,16 +52,17 @@ and run only on the GitHub Actions platform matrix (`macos-latest`, | `tests/auth-token-store.test.ts` | FR-017, NFR-005, NFR-006 | | `tests/agent.test.ts` | FR-020, FR-021, FR-022, NFR-007 | | `tests/self-update.test.ts` | FR-023 | +| `tests/update-notifier.test.ts` | FR-024 | --- ## Stakeholder Requirement Coverage -| Stakeholder Req | Trace to FR/NFR | Coverage Status | -| --------------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| StR-001 (pluggable config) | FR-001, FR-002, FR-003, FR-004, FR-008, NFR-003 | ✅ Unit + static | -| StR-002 (secrets never plaintext) | FR-005, FR-006, FR-007, FR-009, NFR-001, NFR-002, NFR-004 | ✅ Unit + static (keyring round-trip via CI matrix) | -| StR-003 (reusable runtime) | FR-010, FR-011, FR-012, FR-013, FR-014, FR-015, FR-016, FR-017, FR-018, FR-019, FR-020, FR-021, FR-022, FR-023 | ⚠️ FR-013/14/15/16/17/18/19 unit-covered; FR-020/021/022 (agent bootstrap) + FR-023 (self-update) unit-covered; FR-010/011/012 BaseCommand wiring covered at host-binary level | +| Stakeholder Req | Trace to FR/NFR | Coverage Status | +| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| StR-001 (pluggable config) | FR-001, FR-002, FR-003, FR-004, FR-008, NFR-003 | ✅ Unit + static | +| StR-002 (secrets never plaintext) | FR-005, FR-006, FR-007, FR-009, NFR-001, NFR-002, NFR-004 | ✅ Unit + static (keyring round-trip via CI matrix) | +| StR-003 (reusable runtime) | FR-010, FR-011, FR-012, FR-013, FR-014, FR-015, FR-016, FR-017, FR-018, FR-019, FR-020, FR-021, FR-022, FR-023, FR-024 | ⚠️ FR-013/14/15/16/17/18/19 unit-covered; FR-020/021/022 (agent bootstrap) + FR-023 (self-update) + FR-024 (update notifier) unit-covered; FR-010/011/012 BaseCommand wiring covered at host-binary level | ## User Story Coverage @@ -201,6 +202,12 @@ and run only on the GitHub Actions platform matrix (`macos-latest`, | FR-023 | AC-4: no override → no registry flag; scoped override → `--:registry=`; unscoped → `--registry` | `self-update.test.ts` — "reports up-to-date and never installs…" (no flag) + "forces a custom registry via the SCOPE override…" + "uses plain --registry for an unscoped package" | ✅ Unit | | FR-023 | AC-5: `npm view` unreachable → rejects (no false success) | `self-update.test.ts` — "throws when the registry is unreachable" | ✅ Unit | | FR-023 | AC-6: no oclif API; invoked from a plain async dispatcher | static — `src/commands/self-update.tsx` imports no `@oclif/*`; `runSelfUpdate` is a plain async fn (no `BaseCommand`) | ✅ Static (inspection) | +| FR-024 | AC-1: skip in CI / `NO_UPDATE_NOTIFIER` / non-interactive with matching `reason` | `update-notifier.test.ts` — "skips in CI…" / "skips when opted out…" / "skips when non-interactive" | ✅ Unit | +| FR-024 | AC-2: prior check within `ttlMs` (seeded cache) → throttled, no query | `update-notifier.test.ts` — "skips within the throttle window using a seeded cache" | ✅ Unit | +| FR-024 | AC-3: newer + accept → delegates to `runSelfUpdate`, `updated:true` | `update-notifier.test.ts` — "offers and installs when a newer version exists and the user accepts" | ✅ Unit | +| FR-024 | AC-4: newer + decline → `updateAvailable:true, updated:false`, no install | `update-notifier.test.ts` — "reports availability but does not install when the user declines" | ✅ Unit | +| FR-024 | AC-5: equal or dev-build-ahead → `updateAvailable:false`, never prompts | `update-notifier.test.ts` — "does not prompt when already on the latest" / "does not prompt a dev build that is ahead of the published version" | ✅ Unit | +| FR-024 | AC-6: success/failure records `lastCheck` (throttles next); registry failure → `reason:"error"` | `update-notifier.test.ts` — "records the check in the cache…" / "swallows a registry failure and throttles without breaking the host" | ✅ Unit | ## Non-Functional Requirement Coverage diff --git a/src/commands/self-update.tsx b/src/commands/self-update.tsx index 80cc6ed..b44ff50 100644 --- a/src/commands/self-update.tsx +++ b/src/commands/self-update.tsx @@ -1,7 +1,12 @@ import { spawn } from "node:child_process"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; import type * as IxUiCli from "@agent-ix/ix-ui-cli"; +import { cacheRoot } from "../config/paths.js"; +import { defaultConfirm } from "../runtime/agent.js"; + let _ixUi: typeof IxUiCli | undefined; async function loadIxUi(): Promise { return (_ixUi ??= await import("@agent-ix/ix-ui-cli")); @@ -204,3 +209,167 @@ export async function runSelfUpdate( ); return { updated: true, latest }; } + +// ── Update notifier ──────────────────────────────────────────────────────── + +const DAY_MS = 24 * 60 * 60 * 1000; + +export interface UpdateNotifierOptions { + /** npm package name, e.g. `@agent-ix/quoin`. */ + packageName: string; + /** Currently-running version. */ + currentVersion: string; + /** Registry override; defaults like {@link runSelfUpdate} to the ambient config. */ + registry?: string; + /** Whether to prompt. Default: stdin and stdout are both TTYs. */ + interactive?: boolean; + /** Throttle window between registry checks. Default 24h. */ + ttlMs?: number; + /** Cache file path. Default `/update-check.json`. */ + cachePath?: string; + /** Clock injection for tests. Default `Date.now`. */ + now?: () => number; + /** Environment injection for tests. Default `process.env`. */ + env?: NodeJS.ProcessEnv; + /** `[Y/n]` confirm (Enter = yes). Default {@link defaultConfirm}. */ + confirm?: (question: string) => boolean; +} + +export interface UpdateNotifierResult { + /** True when the registry was actually queried this run. */ + checked: boolean; + /** Why the check was skipped, when `checked` is false. */ + reason?: "ci" | "opted-out" | "non-interactive" | "throttled" | "error"; + /** Latest published version, when checked. */ + latest?: string; + /** True when `latest` is newer than the running version. */ + updateAvailable?: boolean; + /** True when the user accepted and the install succeeded. */ + updated?: boolean; +} + +interface UpdateCacheEntry { + lastCheck: number; + latest: string; +} +type UpdateCache = Record; + +function parseCore(v: string): [number, number, number] | null { + const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim()); + return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null; +} + +/** True when `latest` is strictly newer than `current` by numeric major.minor.patch. + * Pre-release/`-dirty` suffixes are ignored (equal cores → not newer), and an + * unparseable version is treated conservatively as "not newer" (no prompt). */ +function isNewer(latest: string, current: string): boolean { + const a = parseCore(latest); + const b = parseCore(current); + if (!a || !b) return false; + for (let i = 0; i < 3; i++) { + if (a[i] !== b[i]) return a[i] > b[i]; + } + return false; +} + +function readCache(path: string): UpdateCache { + try { + const data: unknown = JSON.parse(readFileSync(path, "utf8")); + return data && typeof data === "object" ? (data as UpdateCache) : {}; + } catch { + return {}; + } +} + +function writeCache(path: string, cache: UpdateCache): void { + try { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(cache)); + } catch { + // Best-effort throttle; never break the host CLI over a cache write. + } +} + +/** + * Best-effort "a newer version is available — update?" check for a host CLI to + * call early in its dispatch. Designed to never throw into or block the host: + * it self-skips in CI, when opted out (`NO_UPDATE_NOTIFIER`), when + * non-interactive, or within the throttle window, and swallows any + * registry/cache failure. When a newer version exists and we're interactive, it + * prompts `[Y/n]` (Enter = yes) and, on accept, delegates to + * {@link runSelfUpdate}. + */ +export async function maybeOfferUpdate( + opts: UpdateNotifierOptions, +): Promise { + const env = opts.env ?? process.env; + const now = opts.now ?? Date.now; + const ttlMs = opts.ttlMs ?? DAY_MS; + const interactive = + opts.interactive ?? + (Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY)); + + if (env.CI) return { checked: false, reason: "ci" }; + if (env.NO_UPDATE_NOTIFIER) return { checked: false, reason: "opted-out" }; + if (!interactive) return { checked: false, reason: "non-interactive" }; + + try { + const cachePath = opts.cachePath ?? join(cacheRoot(), "update-check.json"); + const cache = readCache(cachePath); + const prev = cache[opts.packageName]; + if (prev && now() - prev.lastCheck < ttlMs) { + return { checked: false, reason: "throttled" }; + } + + let latest: string; + try { + latest = await spawnCapture("npm", [ + "view", + opts.packageName, + "version", + ...registryArgs(opts.packageName, opts.registry), + ]); + } catch { + // Throttle even on failure so a flaky/unreachable registry isn't queried + // on every invocation; keep any previously-known latest. + writeCache(cachePath, { + ...cache, + [opts.packageName]: { lastCheck: now(), latest: prev?.latest ?? "" }, + }); + return { checked: false, reason: "error" }; + } + + writeCache(cachePath, { + ...cache, + [opts.packageName]: { lastCheck: now(), latest }, + }); + + if (!isNewer(latest, opts.currentVersion)) { + return { checked: true, latest, updateAvailable: false }; + } + + const confirm = opts.confirm ?? defaultConfirm; + const accepted = confirm( + `Update available: ${opts.packageName} ${opts.currentVersion} → ${latest}. Update now?`, + ); + if (!accepted) { + return { checked: true, latest, updateAvailable: true, updated: false }; + } + + const result = await runSelfUpdate({ + packageName: opts.packageName, + currentVersion: opts.currentVersion, + registry: opts.registry, + }); + return { + checked: true, + latest, + updateAvailable: true, + updated: result.updated, + }; + } catch { + // Any unexpected failure (cache root resolution, install render, etc.) must + // never break the host CLI. + return { checked: false, reason: "error" }; + } +} diff --git a/src/index.ts b/src/index.ts index eaab1ce..2b5cf8f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -205,6 +205,9 @@ export { runSelfUpdate, type SelfUpdateOptions, type SelfUpdateResult, + maybeOfferUpdate, + type UpdateNotifierOptions, + type UpdateNotifierResult, } from "./commands/self-update.js"; // ── Auth engine (FR-015..FR-018) ───────────────────────────────────────── diff --git a/src/runtime/agent.ts b/src/runtime/agent.ts index 6783b33..0f967a8 100644 --- a/src/runtime/agent.ts +++ b/src/runtime/agent.ts @@ -270,7 +270,9 @@ function readLineSync(): string { return Buffer.from(bytes).toString("utf8"); } -function defaultConfirm(question: string): boolean { +/** Synchronous `[Y/n]` confirm (Enter = yes). Shared by the agent chooser and + * the update notifier so both prompt identically. */ +export function defaultConfirm(question: string): boolean { process.stdout.write(`${question} [Y/n] `); return parseConfirmAnswer(readLineSync()); } diff --git a/tests/update-notifier.test.ts b/tests/update-notifier.test.ts new file mode 100644 index 0000000..20f2528 --- /dev/null +++ b/tests/update-notifier.test.ts @@ -0,0 +1,196 @@ +import { EventEmitter } from "node:events"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// runSelfUpdate (invoked on accept) dynamically imports the renderer; stub it so +// the tests assert behavior (spawn calls + return value) rather than output. +const renderStatic = vi.fn(async () => {}); +vi.mock("@agent-ix/ix-ui-cli", () => { + const passthrough = (s: unknown) => s; + const Noop = () => null; + return { + FlowLine: Noop, + Listing: Noop, + Note: Noop, + blue: passthrough, + colors: { dim: passthrough }, + renderStatic, + }; +}); + +interface SpawnPlan { + stdout?: string; + code?: number; + error?: Error; +} +let spawnQueue: SpawnPlan[]; +const spawnCalls: { cmd: string; args: string[] }[] = []; + +vi.mock("node:child_process", () => ({ + spawn: (cmd: string, args: string[]) => { + spawnCalls.push({ cmd, args }); + const plan = spawnQueue.shift() ?? { code: 0 }; + const proc = new EventEmitter() as EventEmitter & { stdout: EventEmitter }; + proc.stdout = new EventEmitter(); + queueMicrotask(() => { + if (plan.error) { + proc.emit("error", plan.error); + return; + } + if (plan.stdout) proc.stdout.emit("data", Buffer.from(plan.stdout)); + proc.emit("close", plan.code ?? 0); + }); + return proc; + }, +})); + +const { maybeOfferUpdate } = await import("../src/commands/self-update.js"); + +const PKG = "@agent-ix/quoin"; +const NOW = 1_700_000_000_000; + +function tempCache(): string { + return join(mkdtempSync(join(tmpdir(), "ixupd-")), "update-check.json"); +} + +/** Base options: interactive, fixed clock, temp cache, no real env/prompt. */ +function opts(over: Partial[0]> = {}) { + return { + packageName: PKG, + currentVersion: "0.4.0", + interactive: true, + now: () => NOW, + env: {} as NodeJS.ProcessEnv, + cachePath: tempCache(), + confirm: () => true, + ...over, + }; +} + +beforeEach(() => { + spawnQueue = []; + spawnCalls.length = 0; + renderStatic.mockClear(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("maybeOfferUpdate — skips", () => { + it("skips in CI without querying the registry", async () => { + const r = await maybeOfferUpdate(opts({ env: { CI: "true" } })); + expect(r).toEqual({ checked: false, reason: "ci" }); + expect(spawnCalls).toHaveLength(0); + }); + + it("skips when opted out via NO_UPDATE_NOTIFIER", async () => { + const r = await maybeOfferUpdate( + opts({ env: { NO_UPDATE_NOTIFIER: "1" } }), + ); + expect(r).toEqual({ checked: false, reason: "opted-out" }); + expect(spawnCalls).toHaveLength(0); + }); + + it("skips when non-interactive", async () => { + const r = await maybeOfferUpdate(opts({ interactive: false })); + expect(r).toEqual({ checked: false, reason: "non-interactive" }); + expect(spawnCalls).toHaveLength(0); + }); + + it("skips within the throttle window using a seeded cache", async () => { + const cachePath = tempCache(); + writeFileSync( + cachePath, + JSON.stringify({ [PKG]: { lastCheck: NOW - 1000, latest: "0.9.0" } }), + ); + const r = await maybeOfferUpdate(opts({ cachePath, ttlMs: 10_000 })); + expect(r).toEqual({ checked: false, reason: "throttled" }); + expect(spawnCalls).toHaveLength(0); + }); +}); + +describe("maybeOfferUpdate — checks", () => { + it("offers and installs when a newer version exists and the user accepts", async () => { + // notifier `npm view`, then runSelfUpdate's own `npm view` + install. + spawnQueue = [{ stdout: "0.5.1\n" }, { stdout: "0.5.1" }, { code: 0 }]; + const r = await maybeOfferUpdate(opts({ confirm: () => true })); + expect(r).toEqual({ + checked: true, + latest: "0.5.1", + updateAvailable: true, + updated: true, + }); + expect(spawnCalls[0].args).toEqual(["view", PKG, "version"]); + expect(spawnCalls.at(-1)!.args).toEqual(["install", "-g", `${PKG}@0.5.1`]); + }); + + it("reports availability but does not install when the user declines", async () => { + spawnQueue = [{ stdout: "0.5.1" }]; + const r = await maybeOfferUpdate(opts({ confirm: () => false })); + expect(r).toEqual({ + checked: true, + latest: "0.5.1", + updateAvailable: true, + updated: false, + }); + expect(spawnCalls).toHaveLength(1); // view only + }); + + it("does not prompt when already on the latest", async () => { + let prompted = false; + spawnQueue = [{ stdout: "0.4.0" }]; + const r = await maybeOfferUpdate( + opts({ + currentVersion: "0.4.0", + confirm: () => { + prompted = true; + return true; + }, + }), + ); + expect(r).toEqual({ + checked: true, + latest: "0.4.0", + updateAvailable: false, + }); + expect(prompted).toBe(false); + }); + + it("does not prompt a dev build that is ahead of the published version", async () => { + spawnQueue = [{ stdout: "0.5.1" }]; + const r = await maybeOfferUpdate( + opts({ currentVersion: "0.5.2-3-gabc123-dirty", confirm: () => true }), + ); + expect(r).toEqual({ + checked: true, + latest: "0.5.1", + updateAvailable: false, + }); + }); + + it("records the check in the cache so the next call is throttled", async () => { + const cachePath = tempCache(); + spawnQueue = [{ stdout: "0.5.1" }]; + await maybeOfferUpdate(opts({ cachePath, confirm: () => false })); + const cache = JSON.parse(readFileSync(cachePath, "utf8")) as Record< + string, + { lastCheck: number; latest: string } + >; + expect(cache[PKG]).toEqual({ lastCheck: NOW, latest: "0.5.1" }); + }); + + it("swallows a registry failure and throttles without breaking the host", async () => { + const cachePath = tempCache(); + spawnQueue = [{ error: new Error("ENOTFOUND") }]; + const r = await maybeOfferUpdate(opts({ cachePath })); + expect(r).toEqual({ checked: false, reason: "error" }); + const cache = JSON.parse(readFileSync(cachePath, "utf8")) as Record< + string, + { lastCheck: number; latest: string } + >; + expect(cache[PKG].lastCheck).toBe(NOW); // throttled despite the failure + }); +}); From 91493992954a3de37a072acbe2d29ca4d8c36863 Mon Sep 17 00:00:00 2001 From: Peter Krenesky Date: Fri, 26 Jun 2026 21:09:47 -0700 Subject: [PATCH 2/2] fix(self-update): accurate result when accepted install fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review finding: when the user accepted the offer but the install failed, the generic outer catch returned {checked:false, reason:"error"}, dropping latest/updateAvailable and mislabeling the check as not-run. Catch the install failure locally and return {checked:true, updateAvailable:true, updated:false}. Add a test for the accept→install-failure path. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/commands/self-update.tsx | 25 ++++++++++++++----------- tests/update-notifier.test.ts | 13 +++++++++++++ 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/src/commands/self-update.tsx b/src/commands/self-update.tsx index b44ff50..7717309 100644 --- a/src/commands/self-update.tsx +++ b/src/commands/self-update.tsx @@ -356,17 +356,20 @@ export async function maybeOfferUpdate( return { checked: true, latest, updateAvailable: true, updated: false }; } - const result = await runSelfUpdate({ - packageName: opts.packageName, - currentVersion: opts.currentVersion, - registry: opts.registry, - }); - return { - checked: true, - latest, - updateAvailable: true, - updated: result.updated, - }; + let updated = false; + try { + const result = await runSelfUpdate({ + packageName: opts.packageName, + currentVersion: opts.currentVersion, + registry: opts.registry, + }); + updated = result.updated; + } catch { + // runSelfUpdate already rendered the failure; report the offer was made + // but the install did not complete (rather than a generic skip). + updated = false; + } + return { checked: true, latest, updateAvailable: true, updated }; } catch { // Any unexpected failure (cache root resolution, install render, etc.) must // never break the host CLI. diff --git a/tests/update-notifier.test.ts b/tests/update-notifier.test.ts index 20f2528..57a7df4 100644 --- a/tests/update-notifier.test.ts +++ b/tests/update-notifier.test.ts @@ -127,6 +127,19 @@ describe("maybeOfferUpdate — checks", () => { expect(spawnCalls.at(-1)!.args).toEqual(["install", "-g", `${PKG}@0.5.1`]); }); + it("reports updated:false when the user accepts but the install fails", async () => { + // notifier view → ok; runSelfUpdate view → ok; install → non-zero (throws). + spawnQueue = [{ stdout: "0.5.1" }, { stdout: "0.5.1" }, { code: 1 }]; + const r = await maybeOfferUpdate(opts({ confirm: () => true })); + expect(r).toEqual({ + checked: true, + latest: "0.5.1", + updateAvailable: true, + updated: false, + }); + expect(spawnCalls.at(-1)!.args).toEqual(["install", "-g", `${PKG}@0.5.1`]); + }); + it("reports availability but does not install when the user declines", async () => { spawnQueue = [{ stdout: "0.5.1" }]; const r = await maybeOfferUpdate(opts({ confirm: () => false }));