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
61 changes: 61 additions & 0 deletions spec/functional/FR-024-update-notifier.md
Original file line number Diff line number Diff line change
@@ -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
(`<cacheRoot>/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`).
17 changes: 12 additions & 5 deletions spec/tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 → `--<scope>: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

Expand Down
172 changes: 172 additions & 0 deletions src/commands/self-update.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof IxUiCli> {
return (_ixUi ??= await import("@agent-ix/ix-ui-cli"));
Expand Down Expand Up @@ -204,3 +209,170 @@ 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 `<cacheRoot>/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<string, UpdateCacheEntry>;

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<UpdateNotifierResult> {
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 };
}

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.
return { checked: false, reason: "error" };
}
}
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ─────────────────────────────────────────
Expand Down
4 changes: 3 additions & 1 deletion src/runtime/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand Down
Loading
Loading