From 45be435123d37c7bed6e3bf164f3ab7772c329fc Mon Sep 17 00:00:00 2001 From: Agent IX Date: Sat, 20 Jun 2026 13:46:58 -0700 Subject: [PATCH] feat: add framework-agnostic runSelfUpdate helper Adds runSelfUpdate({ packageName, currentVersion, header?, registry?, check? }) so any npm-distributed consuming CLI can offer an update/install-latest command without re-implementing the registry query, version compare, and global install. No oclif dependency -- callable from a plain dispatcher (e.g. quoin) as readily as from a BaseCommand. Renders via ix-ui-cli and returns { updated, latest }. Registry defaults to the ambient npm config (mirrors however the caller was installed); a registry override is applied as the scope-specific --:registry= flag for scoped packages, because a plain --registry is silently ignored for a scoped package when an npmrc pins a @scope:registry. Adds FR-023 + tests.md traceability. Co-Authored-By: Claude Opus 4.8 --- spec/functional/FR-023-self-update-helper.md | 59 +++++ spec/functional/index.md | 1 + spec/log.md | 1 + spec/tests.md | 259 ++++++++++--------- src/commands/self-update.tsx | 206 +++++++++++++++ src/index.ts | 7 + tests/self-update.test.ts | 147 +++++++++++ 7 files changed, 554 insertions(+), 126 deletions(-) create mode 100644 spec/functional/FR-023-self-update-helper.md create mode 100644 src/commands/self-update.tsx create mode 100644 tests/self-update.test.ts diff --git a/spec/functional/FR-023-self-update-helper.md b/spec/functional/FR-023-self-update-helper.md new file mode 100644 index 0000000..2d116c9 --- /dev/null +++ b/spec/functional/FR-023-self-update-helper.md @@ -0,0 +1,59 @@ +--- +id: FR-023 +title: "Self-Update Helper" +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 `runSelfUpdate` helper so that +any npm-distributed consuming CLI can offer an `update`/install-latest command +without re-implementing the registry query, version comparison, and global +install. The helper has no oclif dependency and is callable from a plain command +dispatcher as readily as from a `BaseCommand`. + +`runSelfUpdate(options)` takes the caller's `packageName`, `currentVersion`, an +optional listing `header`, an optional `registry` override, and a `check` flag. +It: + +- queries the latest published version with `npm view version`; +- when the running version already equals the latest, reports up-to-date and + installs nothing; +- under `check`, reports whether an update is available and installs nothing; +- otherwise runs `npm install -g @` (inherited stdio so npm + draws its own progress). + +Registry resolution SHALL default to the **ambient npm config** — i.e. however +the caller was installed (its `@scope:registry`, or the npm default) — rather +than a hardcoded registry. When a `registry` override is supplied, it SHALL be +applied as the **scope-specific** `--:registry=` flag for a scoped +package (a plain `--registry` is silently ignored for a scoped package when an +npmrc pins a `@scope:registry`), and as a plain `--registry ` for an +unscoped package. + +The helper renders its result through `@agent-ix/ix-ui-cli` (the same +`Listing`/`FlowLine`/`Note` surface as the other command runners) so every +consuming CLI gets identical output, and also returns a `SelfUpdateResult` +(`{ updated, latest }`) for callers that branch on the outcome. + +## Acceptance Criteria + +| ID | Criteria | Verification | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | +| FR-023-AC-1 | When the running version equals the registry's latest, `runSelfUpdate` reports up-to-date, performs no install, and returns `{ updated: false, latest }` | Test | +| FR-023-AC-2 | With `check: true` and a newer latest available, it reports the available update, performs no install, and returns `{ updated: false, latest }` | Test | +| FR-023-AC-3 | When out of date and `check` is unset, it runs `npm install -g @` and returns `{ updated: true, latest }` | Test | +| FR-023-AC-4 | With no `registry` override, no registry flag is passed (ambient config resolves the package); with an override, a scoped package uses `--:registry=` and an unscoped package uses `--registry ` | Test | +| FR-023-AC-5 | When `npm view` cannot reach the registry, the helper surfaces a failure (rejects) rather than reporting success | Test | +| FR-023-AC-6 | The helper imports no oclif API and is invoked from a plain async dispatcher (e.g. quoin's `update`), not only from `BaseCommand` | Inspection | + +## Dependencies + +- **Upstream**: [StR-003](../stakeholder/StR-003-reusable-cli-runtime.md) + (reusable CLI runtime — "no bespoke per-CLI re-implementation"). +- **Downstream**: consuming CLIs that expose an `update` command over this + helper (e.g. `@agent-ix/quoin`). diff --git a/spec/functional/index.md b/spec/functional/index.md index 949e6cf..1ea7225 100644 --- a/spec/functional/index.md +++ b/spec/functional/index.md @@ -30,3 +30,4 @@ description: "Index of artifacts in this directory." - [FR-020: Agent-Context Detection](./FR-020-agent-context-detection.md) - [FR-021: Bootstrap Into Preferred Agent](./FR-021-bootstrap-into-agent.md) - [FR-022: Preferred-Agent Config and Interactive Chooser](./FR-022-agent-config-chooser.md) +- [FR-023: Self-Update Helper](./FR-023-self-update-helper.md) diff --git a/spec/log.md b/spec/log.md index f9b675b..a2e3069 100644 --- a/spec/log.md +++ b/spec/log.md @@ -9,3 +9,4 @@ description: "Chronological log of structural changes to this bundle." ## History - **2026-06-15** — Adopted OKF-compatible bundle structure with directory indexes. +- **2026-06-20** — Added [FR-023](./functional/FR-023-self-update-helper.md) (`runSelfUpdate` helper): framework-agnostic self-update for npm-distributed consuming CLIs (npm view → compare → `npm install -g`), rendering via ix-ui-cli and returning a `SelfUpdateResult`. Registry defaults to ambient npm config; an override is applied as the scope-specific `--:registry=` flag for scoped packages. Consumed by `@agent-ix/quoin`'s `update` command. diff --git a/spec/tests.md b/spec/tests.md index 2c2de39..774c548 100644 --- a/spec/tests.md +++ b/spec/tests.md @@ -51,16 +51,17 @@ and run only on the GitHub Actions platform matrix (`macos-latest`, | `tests/auth-device-flow.test.ts` | FR-016, FR-018 | | `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 | --- ## 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-013/14/15/16/17/18/19 unit-covered; FR-020/021/022 (agent bootstrap) 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-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 | ## User Story Coverage @@ -73,127 +74,133 @@ and run only on the GitHub Actions platform matrix (`macos-latest`, ## Functional Requirement Coverage -| Functional Req | Acceptance Criteria | Test File · Case | Coverage Status | -| -------------- | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| FR-001 | AC-1: forPlugin scopes reads to its own file | `config-service.test.ts` — "reads only the requesting plugin's file" | ✅ Unit | -| FR-001 | AC-2: atomic temp+rename write | `atomic-write.test.ts` — "FR-010-AC-2 atomic temp+rename"; `config-service.test.ts` — "creates a 0o600 file via atomic temp+rename" | ✅ Unit | -| FR-001 | AC-3: mode 0o600 regardless of umask | `config-service.test.ts` — "creates a 0o600 file"; `atomic-write.test.ts` — "mode 0o600 regardless of umask" | ✅ Unit | -| FR-001 | AC-4: unknown key in strict schema → ConfigSchemaError | `config-service.test.ts` — "set() with an unknown top-level/nested key throws ConfigSchemaError" | ✅ Unit | -| FR-001 | AC-5: reset() deletes file; defaults thereafter | `config-service.test.ts` — "reset() deletes the file; next get() returns schema defaults" | ✅ Unit | -| FR-001 | AC-6: filePath() returns absolute path | `config-service.test.ts` — "filePath returns the absolute path" | ✅ Unit | -| FR-001 | AC-7: read-only target → ConfigWriteError; content intact | `atomic-write.test.ts` — "FR-010-AC-7 read-only target → ConfigWriteError" | ✅ Unit | -| FR-001 | AC-8: temp file is sibling (not in os.tmpdir) | `atomic-write.test.ts` — "FR-010-AC-8 temp file is a sibling of target" | ✅ Unit | -| FR-001 | AC-9: orphan temp pruning (>30s old) | `atomic-write.test.ts` — "FR-010-AC-9 orphan temp pruning (>30s old)" | ✅ Unit | -| FR-001 | AC-10: replace() writes verbatim (deletions) | `config-service.test.ts` — re-set merge / replace semantics | ✅ Unit | -| FR-002 | AC-1: malformed file in one plugin doesn't block another | `config-service.test.ts` — "malformed YAML → get() returns defaults" | ✅ Unit | -| FR-002 | AC-2: defaulted load → first set() rewrites valid | `config-service.test.ts` — "set() after a defaulted load overwrites the malformed file" | ✅ Unit | -| FR-002 | AC-3: doctor() returns scoped errors, doesn't throw | `doctor.test.ts` — "reports invalid for a malformed file but does not throw" | ✅ Unit | -| FR-002 | AC-4: same-plugin concurrent writes serialized | `lock.test.ts` — "creates the lockfile during fn, removes it after" | ✅ Unit | -| FR-002 | AC-5: different-plugin writes don't contend | `lock.test.ts` (per-file lock scope) | ✅ Unit | -| FR-002 | AC-6: stale lock from non-running pid reaped | `lock.test.ts` — "FR-011-AC-6 stale lock from non-running pid is reaped" | ✅ Unit | -| FR-002 | AC-7: lock timeout → ConfigLockTimeoutError | `lock.test.ts` — "FR-011-AC-7 timeout → ConfigLockTimeoutError" | ✅ Unit | -| FR-003 | AC-1: env var beats file value | `env-layer.test.ts` — "env > file when both set" | ✅ Unit | -| FR-003 | AC-2: file value beats default | `env-layer.test.ts` — "file value wins when env is unset" | ✅ Unit | -| FR-003 | AC-3: defaults when env+file absent | `env-layer.test.ts` — "returns schema defaults" | ✅ Unit | -| FR-003 | AC-4: invalid env value → schema error / defaulted | `env-layer.test.ts` — "invalid enum value via env → falls back to defaults; incident kind=schema" | ✅ Unit | -| FR-003 | AC-5: static lint — no cross-plugin forPlugin call sites | static check (soft-isolation lint) | ⚠️ Static (enforced at consuming-binary lint) | -| FR-004 | AC-1: config schema enforced on writes | `config-service.test.ts` / `registry.test.ts` — first-wins registration + strict validation | ✅ Unit | -| FR-004 | AC-2: non-strict schema → logged + skipped | `plugin-schema.test.ts` — "rejects non-strict config schemas" | ✅ Unit | -| FR-004 | AC-3: duplicate id → second skipped, first preserved | `registry.test.ts` — "FR-013-AC-3 first-wins"; `plugin-schema.test.ts` — "preserves the first registration on duplicate package names" | ✅ Unit | -| FR-004 | AC-4: third-party using "core" → skipped, core preserved | `registry.test.ts` (reserved-id handling) | ✅ Unit | -| FR-004 | AC-5: doctor surfaces failed plugin id/reason/source | `doctor.test.ts` + `registry.test.ts` | ✅ Unit | -| FR-004 | AC-6: secrets envVar honored ahead of backend | `secrets-service.test.ts` — "AC-1: env beats backend" | ✅ Unit | -| FR-004 | AC-7: invalid plugin id (regex) → skipped | `config-service.test.ts` — "rejects empty, uppercase, leading-digit, and path-traversal ids" | ✅ Unit | -| FR-005 | AC-1: env beats backend in get() | `secrets-service.test.ts` — "AC-1: env beats backend" | ✅ Unit | -| FR-005 | AC-2: backend value when env unset | `secrets-service.test.ts` — "AC-2: backend wins when env unset" | ✅ Unit | -| FR-005 | AC-3: prompt path persists collected value | `secrets-service.test.ts` (prompt resolution) | ⚠️ Unit (TTY prompt path) | -| FR-005 | AC-4: no-prompt + non-TTY → null | `secrets-service.test.ts` — "AC-4: returns null when no env and no backend value" | ✅ Unit | -| FR-005 | AC-5: set then delete → which()==="unset" | `secrets-service.test.ts` — "AC-5: set then delete → which() === unset" | ✅ Unit | -| FR-005 | AC-6: set on env-bound + env set → SecretBackendImmutableError | `secrets-service.test.ts` — "AC-6: set throws SecretBackendImmutableError when env is set" | ✅ Unit | -| FR-005 | AC-7: zero secret values in logged output | `redaction.test.ts` + `secrets-service.test.ts` (list never renders values) | ✅ Unit | -| FR-005 | AC-8: malformed SecretId → InvalidSecretIdError | `secrets-service.test.ts` — "SecretId validation — FR-014-AC-8" | ✅ Unit | -| FR-006 | AC-1: macOS Keychain round-trip | `keyring-backend.test.ts` — REAL `@napi-rs/keyring` smoke | ⚠️ Integration (CI matrix macos-latest) | -| FR-006 | AC-2: Linux libsecret round-trip | `keyring-backend.test.ts` — REAL `@napi-rs/keyring` smoke | ⚠️ Integration (CI matrix ubuntu-latest + gnome-keyring) | -| FR-006 | AC-3: probe fail → active backend becomes age-file | `secrets-service.test.ts` — "auto: falls through to age-file when keyring probe fails" | ✅ Unit | -| FR-006 | AC-4: list() filters to service "ix-cli" only | `keyring-backend.test.ts` — "list returns only well-formed accounts under our service" | ✅ Unit (mocked) | -| FR-006 | AC-5: denied prompt → KeyringAccessError + remediation | `keyring-backend.test.ts` — "set failure surfaces KeyringAccessError" | ✅ Unit (mocked) | -| FR-006 | AC-6: probe runs at most once per process | `keyring-backend.test.ts` — "probe is cached: second call does not retry" | ✅ Unit | -| FR-007 | AC-1: secrets.d/.age + secrets.key created 0600 | `age-file-backend.test.ts` — "FR-016-AC-1 file creation" | ✅ Unit | -| FR-007 | AC-2a: blob bytes do not contain plaintext | `age-file-backend.test.ts` — "FR-016-AC-2a blob does not contain plaintext" | ✅ Unit | -| FR-007 | AC-2b: secrets.key is exactly one AGE-SECRET-KEY-1 + \n | `age-file-backend.test.ts` — "FR-016-AC-2b identity file is well-formed" | ✅ Unit | -| FR-007 | AC-3: AEAD-tag corruption isolates failure to one plugin | `age-file-backend.test.ts` — "FR-016-AC-3 corruption isolated to one plugin" | ✅ Unit | -| FR-007 | AC-4: every write produces 0o600 post-rename | `age-file-backend.test.ts` — round-trip / lifecycle | ✅ Unit | -| FR-007 | AC-5: wide-perm secrets.key → SecretsIdentityPermissionsError | `age-file-backend.test.ts` — "FR-016-AC-5 perm check is exact 0o600" | ✅ Unit | -| FR-007 | AC-6: zero plaintext leaks across full lifecycle | `age-file-backend.test.ts` — "FR-016-AC-6: full lifecycle leaves zero plaintext on disk" | ✅ Unit | -| FR-008 | AC-1: get omits plugin → defaults to "core" | `commands.test.ts` — "default plugin id is core when omitted" | ✅ Unit | -| FR-008 | AC-2: set scalar coercion persists | `commands.test.ts` — "FR-018-AC-2 scalar coercion" | ✅ Unit | -| FR-008 | AC-3: invalid set surfaces four-tuple error | `commands.test.ts` — "FR-018-AC-3 schema error" | ✅ Unit | -| FR-008 | AC-4: edit re-prompts on validation failure | (edit runner) | ⚠️ Review / TTY | -| FR-008 | AC-5: doctor mixed valid/invalid → non-zero exit | `commands.test.ts` — "runConfigDoctor — FR-018-AC-5 mixed valid/invalid" | ✅ Unit | -| FR-008 | AC-6: unknown plugin → UnknownPluginError | `commands.test.ts` — "FR-018-AC-6 unknown plugin" | ✅ Unit | -| FR-008 | AC-7: concurrent set serialized via lock | `lock.test.ts` (per-file lock) | ✅ Unit | -| FR-008 | AC-8: non-JSON for array key → ConfigSetParseError | `commands.test.ts` — "FR-018-AC-8 non-JSON for array key throws" | ✅ Unit | -| FR-009 | AC-1: list never renders secret values | `secrets-service.test.ts` — "FR-019-AC-1 never renders values" | ✅ Unit | -| FR-009 | AC-2: set prints "stored in " only | `commands.test.ts` — "runSecretsSet — FR-019-AC-2" | ✅ Unit | -| FR-009 | AC-3: which transitions env/keyring/age-file/unset | `commands.test.ts` — "runSecretsWhich — FR-019-AC-3" | ✅ Unit | -| FR-009 | AC-4: rm clears persisted value; warns if env set | `commands.test.ts` — "runSecretsRm — FR-019-AC-4 + warn-when-env-still-set" | ✅ Unit | -| FR-009 | AC-5: unknown id → UnknownSecretError | `commands.test.ts` — "rejects unknown id with UnknownSecretError"; `secrets-service.test.ts` — "FR-019-AC-5 unknown id handling" | ✅ Unit | -| FR-009 | AC-6: zero secret values across lifecycle output | `commands.test.ts` + `redaction.test.ts` | ✅ Unit | -| FR-009 | AC-7: keyring denial → remediation; not echoed | `keyring-backend.test.ts` — KeyringAccessError surface | ✅ Unit (mocked) | -| FR-010 | AC-1..AC-4: oclif-native CLI binary composition | exercised by the consuming binary (`ix://agent-ix/ix-cli`) | ⚠️ Integration (host binary) | -| FR-011 | AC-1..AC-6: --config-root / IX_CONFIG_ROOT base flag | `BaseCommand` base flags (`src/commands/base-command.ts`) | ⚠️ Integration (host binary command tests) | -| FR-012 | AC-1..AC-4: oclif-native plugin discovery, no manifest | static — no manifest loader in `src/` | ✅ Static (no on-disk manifest reads) | -| FR-019 | AC-1: install options carry cacheRoot/ts-plugin-kit + target | `marketplace.test.ts` — "marketplaceInstallOptions derives cache under cacheRoot" | ✅ Unit | -| FR-019 | AC-2: reconcileDefaultSet installs enabled entries | `marketplace.test.ts` — "reconcileDefaultSet installs the enabled set" | ✅ Unit | -| FR-019 | AC-3: oclif bridge maps npm→install, every other source→link | `marketplace.test.ts` — "resolveOclifPluginInstall maps npm→install and git/path→link" | ⚠️ Unit (npm + path permutations; github/git-subdir/git/url share the one non-npm branch, untested) | -| FR-019 | AC-4: no @oclif/plugin-plugins / no in-tree installer | static — adapter delegates to `@agent-ix/ts-plugin-kit` | ✅ Static (delegation) | -| FR-013 | AC-1: command declares static capabilities | `capabilities.test.ts` — "surfaces available required and optional capabilities" | ✅ Unit | -| FR-013 | AC-2: required-unavailable short-circuits before side effects | `capabilities.test.ts` — "fails required capabilities that have no provider" | ✅ Unit | -| FR-013 | AC-3: optional missing never blocks; resolved surfaced | `capabilities.test.ts` — "does not block on missing optional capabilities" | ✅ Unit | -| FR-013 | AC-4: resolver reads through Config/Secrets context | `capabilities.test.ts` — provider context | ✅ Unit | -| FR-013 | AC-5: capability errors carry machine-readable code | `capabilities.test.ts` — "preserves provider errors"; `capabilityErrorToJson` | ✅ Unit | -| FR-014 | AC-1..AC-7: ixSchema convention + registration | `plugin-schema.test.ts` — "registerPluginSchema — FR-025 oclif ixSchema convention" | ✅ Unit | -| FR-015 | AC-1/AC-2: host normalization (https default, port preserved) | `auth-discovery.test.ts` — "assumes https for a bare host…", "preserves an explicit port" | ✅ Unit | -| FR-015 | AC-3: http refused unless dev.ix / insecure | `auth-discovery.test.ts` — "allows http for \*.dev.ix hosts", "rejects http for non-dev.ix hosts unless insecure" | ✅ Unit | -| FR-015 | AC-4: GET well-known returns parsed doc | `auth-discovery.test.ts` — "GETs the well-known path and returns the parsed doc" | ✅ Unit | -| FR-015 | AC-5: non-2xx / transport → DiscoveryFetchError | `auth-discovery.test.ts` — "raises DiscoveryFetchError on a non-2xx status", "…on a transport error" | ✅ Unit | -| FR-015 | AC-6: missing field → DiscoverySchemaError naming it | `auth-discovery.test.ts` — "raises DiscoverySchemaError naming missing fields" | ✅ Unit | -| FR-015 | AC-7: insecure http refused before any fetch | `auth-discovery.test.ts` — "refuses plain http for non-dev.ix without insecure (no fetch)" | ✅ Unit | -| FR-016 | AC-1: pending→token returns normalized bundle | `auth-device-flow.test.ts` — "authorizes, presents, polls past pending, returns a token bundle" | ✅ Unit | -| FR-016 | AC-2: authorize body carries client_id + audience/scope | `auth-device-flow.test.ts` — "authorizes, presents, polls past pending…" (authCall body assertions) | ✅ Unit | -| FR-016 | AC-3: prompter.showVerification called once with uri+code | `auth-device-flow.test.ts` — same happy-path test (shown assertions) | ✅ Unit | -| FR-016 | AC-4: slow_down grows interval by 5000ms | `auth-device-flow.test.ts` — "backs off on slow_down (interval grows by 5s)" | ✅ Unit | -| FR-016 | AC-5: access_denied / expired_token / deadline | `auth-device-flow.test.ts` — "throws access_denied…", "throws expired_token on the expired_token error code", "…when the deadline passes" | ✅ Unit | -| FR-016 | AC-6: non-2xx authorize → authorize_failed, no poll | `auth-device-flow.test.ts` — "fails authorize on a non-2xx authorize response" | ✅ Unit | -| FR-016 | AC-7: browser-open failure is non-fatal | `auth-device-flow.test.ts` — "browser-open failure is non-fatal" | ✅ Unit | -| FR-016 | AC-8: verification_uri_complete used for the open | `auth-device-flow.test.ts` — "uses verification_uri_complete for the browser open when present" | ✅ Unit | -| FR-017 | AC-1: host isolation on save/clear | `auth-token-store.test.ts` — "keys secrets per host so logins don't collide" | ✅ Unit | -| FR-017 | AC-2: token in backend, metadata holds no value | `auth-token-store.test.ts` — "stores metadata (expiry/audience) separately from the secret" | ✅ Unit | -| FR-017 | AC-3: fresh token returned with no fetch | `auth-token-store.test.ts` — "returns the stored token when it is fresh (no refresh, no fetch)" | ✅ Unit | -| FR-017 | AC-4: refresh-before-expiry + rotation persisted | `auth-token-store.test.ts` — "refreshes before expiry and rotates the stored refresh token" | ✅ Unit | -| FR-017 | AC-5: no new refresh token preserves the old | `auth-token-store.test.ts` — "keeps the old refresh token when the issuer doesn't rotate" | ✅ Unit | -| FR-017 | AC-6: NotAuthenticatedError / TokenRefreshError | `auth-token-store.test.ts` — "throws NotAuthenticatedError…", "surfaces a refresh failure as TokenRefreshError" | ✅ Unit | -| FR-017 | AC-7: hostSlug always matches SecretId name regex | `auth-token-store.test.ts` — "slugifies a dotted host into a SecretId-name-safe segment" | ✅ Unit | -| FR-018 | AC-1/AC-2: opener never rejects; opt-out env returns false | exercised by `auth-device-flow.test.ts` (injected opener) + opener opt-out logic | ⚠️ Unit (injected) / Review | -| FR-018 | AC-3: thrown opener doesn't abort the flow | `auth-device-flow.test.ts` — "browser-open failure is non-fatal" | ✅ Unit | -| FR-020 | AC-1: marker presence → true; none → false | `agent.test.ts` — "is true when … is present" / "is false when no marker is set" | ✅ Unit | -| FR-020 | AC-2: non-1 marker value detected; empty = absent | `agent.test.ts` — "is true for a non-1 marker value …" / "treats an empty-string marker as absent" | ✅ Unit | -| FR-020 | AC-3: isInteractiveHuman gating (TTY + no marker + no opt-out) | `agent.test.ts` — "is true for both-TTY …" + the negative gating cases | ✅ Unit | -| FR-020 | AC-4: IX_NO_AUTO_AGENT=1 opts out; =0 does not | `agent.test.ts` — "is false when IX_NO_AUTO_AGENT is truthy" / "is NOT opted out by IX_NO_AUTO_AGENT=0" | ✅ Unit | -| FR-021 | AC-1: mode off → no-op | `agent.test.ts` — "mode 'off' is a no-op" | ✅ Unit | -| FR-021 | AC-2: non-interactive → no spawn | `agent.test.ts` — "does nothing when not an interactive human" | ✅ Unit | -| FR-021 | AC-3: agent marker → no spawn (guard) | `agent.test.ts` — "does nothing under an agent marker (fork-bomb guard)" | ✅ Unit | -| FR-021 | AC-4: auto launches w/ stdio inherit + guard + seed last | `agent.test.ts` — "auto mode launches interactively with the guard + seed" | ✅ Unit | -| FR-021 | AC-5: multi-word command split; seed appended; no shell | `agent.test.ts` — "splits a multi-word command and appends the seed last" | ✅ Unit | -| FR-021 | AC-6: prompt confirm gating | `agent.test.ts` — "prompt mode: declined…/accepted… confirm" | ✅ Unit | -| FR-021 | AC-7: exit status forwarded; null→0 | `agent.test.ts` — "forwards the child's exit status" / "maps a signal death (null status) to exit 0" | ✅ Unit | -| FR-021 | AC-8: ENOENT non-fatal | `agent.test.ts` — "ENOENT is non-fatal: returns false, no exit, logs a hint" | ✅ Unit | -| FR-022 | AC-1: defaults + file>default | `agent.test.ts` — "defaults: autoLaunch=prompt…" / "file value wins over default" | ✅ Unit | -| FR-022 | AC-2: env override + invalid enum → default + incident | `agent.test.ts` — "env beats file…" / "invalid autoLaunch via env → falls back…" | ✅ Unit | -| FR-022 | AC-3: chooser pick launches | `agent.test.ts` — "launches the chosen agent and offers to persist it" | ✅ Unit | -| FR-022 | AC-4: persist on save; declined still launches | `agent.test.ts` — "launches the chosen agent…" / "launches but does not persist when the save prompt is declined" | ✅ Unit | -| FR-022 | AC-5: cancelled chooser no-op | `agent.test.ts` — "cancelled chooser is a no-op" | ✅ Unit | -| FR-022 | AC-6: strict schema rejects unknown keys | `agent.test.ts` — "rejects unknown keys (strict schema)" | ✅ Unit | +| Functional Req | Acceptance Criteria | Test File · Case | Coverage Status | +| -------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| FR-001 | AC-1: forPlugin scopes reads to its own file | `config-service.test.ts` — "reads only the requesting plugin's file" | ✅ Unit | +| FR-001 | AC-2: atomic temp+rename write | `atomic-write.test.ts` — "FR-010-AC-2 atomic temp+rename"; `config-service.test.ts` — "creates a 0o600 file via atomic temp+rename" | ✅ Unit | +| FR-001 | AC-3: mode 0o600 regardless of umask | `config-service.test.ts` — "creates a 0o600 file"; `atomic-write.test.ts` — "mode 0o600 regardless of umask" | ✅ Unit | +| FR-001 | AC-4: unknown key in strict schema → ConfigSchemaError | `config-service.test.ts` — "set() with an unknown top-level/nested key throws ConfigSchemaError" | ✅ Unit | +| FR-001 | AC-5: reset() deletes file; defaults thereafter | `config-service.test.ts` — "reset() deletes the file; next get() returns schema defaults" | ✅ Unit | +| FR-001 | AC-6: filePath() returns absolute path | `config-service.test.ts` — "filePath returns the absolute path" | ✅ Unit | +| FR-001 | AC-7: read-only target → ConfigWriteError; content intact | `atomic-write.test.ts` — "FR-010-AC-7 read-only target → ConfigWriteError" | ✅ Unit | +| FR-001 | AC-8: temp file is sibling (not in os.tmpdir) | `atomic-write.test.ts` — "FR-010-AC-8 temp file is a sibling of target" | ✅ Unit | +| FR-001 | AC-9: orphan temp pruning (>30s old) | `atomic-write.test.ts` — "FR-010-AC-9 orphan temp pruning (>30s old)" | ✅ Unit | +| FR-001 | AC-10: replace() writes verbatim (deletions) | `config-service.test.ts` — re-set merge / replace semantics | ✅ Unit | +| FR-002 | AC-1: malformed file in one plugin doesn't block another | `config-service.test.ts` — "malformed YAML → get() returns defaults" | ✅ Unit | +| FR-002 | AC-2: defaulted load → first set() rewrites valid | `config-service.test.ts` — "set() after a defaulted load overwrites the malformed file" | ✅ Unit | +| FR-002 | AC-3: doctor() returns scoped errors, doesn't throw | `doctor.test.ts` — "reports invalid for a malformed file but does not throw" | ✅ Unit | +| FR-002 | AC-4: same-plugin concurrent writes serialized | `lock.test.ts` — "creates the lockfile during fn, removes it after" | ✅ Unit | +| FR-002 | AC-5: different-plugin writes don't contend | `lock.test.ts` (per-file lock scope) | ✅ Unit | +| FR-002 | AC-6: stale lock from non-running pid reaped | `lock.test.ts` — "FR-011-AC-6 stale lock from non-running pid is reaped" | ✅ Unit | +| FR-002 | AC-7: lock timeout → ConfigLockTimeoutError | `lock.test.ts` — "FR-011-AC-7 timeout → ConfigLockTimeoutError" | ✅ Unit | +| FR-003 | AC-1: env var beats file value | `env-layer.test.ts` — "env > file when both set" | ✅ Unit | +| FR-003 | AC-2: file value beats default | `env-layer.test.ts` — "file value wins when env is unset" | ✅ Unit | +| FR-003 | AC-3: defaults when env+file absent | `env-layer.test.ts` — "returns schema defaults" | ✅ Unit | +| FR-003 | AC-4: invalid env value → schema error / defaulted | `env-layer.test.ts` — "invalid enum value via env → falls back to defaults; incident kind=schema" | ✅ Unit | +| FR-003 | AC-5: static lint — no cross-plugin forPlugin call sites | static check (soft-isolation lint) | ⚠️ Static (enforced at consuming-binary lint) | +| FR-004 | AC-1: config schema enforced on writes | `config-service.test.ts` / `registry.test.ts` — first-wins registration + strict validation | ✅ Unit | +| FR-004 | AC-2: non-strict schema → logged + skipped | `plugin-schema.test.ts` — "rejects non-strict config schemas" | ✅ Unit | +| FR-004 | AC-3: duplicate id → second skipped, first preserved | `registry.test.ts` — "FR-013-AC-3 first-wins"; `plugin-schema.test.ts` — "preserves the first registration on duplicate package names" | ✅ Unit | +| FR-004 | AC-4: third-party using "core" → skipped, core preserved | `registry.test.ts` (reserved-id handling) | ✅ Unit | +| FR-004 | AC-5: doctor surfaces failed plugin id/reason/source | `doctor.test.ts` + `registry.test.ts` | ✅ Unit | +| FR-004 | AC-6: secrets envVar honored ahead of backend | `secrets-service.test.ts` — "AC-1: env beats backend" | ✅ Unit | +| FR-004 | AC-7: invalid plugin id (regex) → skipped | `config-service.test.ts` — "rejects empty, uppercase, leading-digit, and path-traversal ids" | ✅ Unit | +| FR-005 | AC-1: env beats backend in get() | `secrets-service.test.ts` — "AC-1: env beats backend" | ✅ Unit | +| FR-005 | AC-2: backend value when env unset | `secrets-service.test.ts` — "AC-2: backend wins when env unset" | ✅ Unit | +| FR-005 | AC-3: prompt path persists collected value | `secrets-service.test.ts` (prompt resolution) | ⚠️ Unit (TTY prompt path) | +| FR-005 | AC-4: no-prompt + non-TTY → null | `secrets-service.test.ts` — "AC-4: returns null when no env and no backend value" | ✅ Unit | +| FR-005 | AC-5: set then delete → which()==="unset" | `secrets-service.test.ts` — "AC-5: set then delete → which() === unset" | ✅ Unit | +| FR-005 | AC-6: set on env-bound + env set → SecretBackendImmutableError | `secrets-service.test.ts` — "AC-6: set throws SecretBackendImmutableError when env is set" | ✅ Unit | +| FR-005 | AC-7: zero secret values in logged output | `redaction.test.ts` + `secrets-service.test.ts` (list never renders values) | ✅ Unit | +| FR-005 | AC-8: malformed SecretId → InvalidSecretIdError | `secrets-service.test.ts` — "SecretId validation — FR-014-AC-8" | ✅ Unit | +| FR-006 | AC-1: macOS Keychain round-trip | `keyring-backend.test.ts` — REAL `@napi-rs/keyring` smoke | ⚠️ Integration (CI matrix macos-latest) | +| FR-006 | AC-2: Linux libsecret round-trip | `keyring-backend.test.ts` — REAL `@napi-rs/keyring` smoke | ⚠️ Integration (CI matrix ubuntu-latest + gnome-keyring) | +| FR-006 | AC-3: probe fail → active backend becomes age-file | `secrets-service.test.ts` — "auto: falls through to age-file when keyring probe fails" | ✅ Unit | +| FR-006 | AC-4: list() filters to service "ix-cli" only | `keyring-backend.test.ts` — "list returns only well-formed accounts under our service" | ✅ Unit (mocked) | +| FR-006 | AC-5: denied prompt → KeyringAccessError + remediation | `keyring-backend.test.ts` — "set failure surfaces KeyringAccessError" | ✅ Unit (mocked) | +| FR-006 | AC-6: probe runs at most once per process | `keyring-backend.test.ts` — "probe is cached: second call does not retry" | ✅ Unit | +| FR-007 | AC-1: secrets.d/.age + secrets.key created 0600 | `age-file-backend.test.ts` — "FR-016-AC-1 file creation" | ✅ Unit | +| FR-007 | AC-2a: blob bytes do not contain plaintext | `age-file-backend.test.ts` — "FR-016-AC-2a blob does not contain plaintext" | ✅ Unit | +| FR-007 | AC-2b: secrets.key is exactly one AGE-SECRET-KEY-1 + \n | `age-file-backend.test.ts` — "FR-016-AC-2b identity file is well-formed" | ✅ Unit | +| FR-007 | AC-3: AEAD-tag corruption isolates failure to one plugin | `age-file-backend.test.ts` — "FR-016-AC-3 corruption isolated to one plugin" | ✅ Unit | +| FR-007 | AC-4: every write produces 0o600 post-rename | `age-file-backend.test.ts` — round-trip / lifecycle | ✅ Unit | +| FR-007 | AC-5: wide-perm secrets.key → SecretsIdentityPermissionsError | `age-file-backend.test.ts` — "FR-016-AC-5 perm check is exact 0o600" | ✅ Unit | +| FR-007 | AC-6: zero plaintext leaks across full lifecycle | `age-file-backend.test.ts` — "FR-016-AC-6: full lifecycle leaves zero plaintext on disk" | ✅ Unit | +| FR-008 | AC-1: get omits plugin → defaults to "core" | `commands.test.ts` — "default plugin id is core when omitted" | ✅ Unit | +| FR-008 | AC-2: set scalar coercion persists | `commands.test.ts` — "FR-018-AC-2 scalar coercion" | ✅ Unit | +| FR-008 | AC-3: invalid set surfaces four-tuple error | `commands.test.ts` — "FR-018-AC-3 schema error" | ✅ Unit | +| FR-008 | AC-4: edit re-prompts on validation failure | (edit runner) | ⚠️ Review / TTY | +| FR-008 | AC-5: doctor mixed valid/invalid → non-zero exit | `commands.test.ts` — "runConfigDoctor — FR-018-AC-5 mixed valid/invalid" | ✅ Unit | +| FR-008 | AC-6: unknown plugin → UnknownPluginError | `commands.test.ts` — "FR-018-AC-6 unknown plugin" | ✅ Unit | +| FR-008 | AC-7: concurrent set serialized via lock | `lock.test.ts` (per-file lock) | ✅ Unit | +| FR-008 | AC-8: non-JSON for array key → ConfigSetParseError | `commands.test.ts` — "FR-018-AC-8 non-JSON for array key throws" | ✅ Unit | +| FR-009 | AC-1: list never renders secret values | `secrets-service.test.ts` — "FR-019-AC-1 never renders values" | ✅ Unit | +| FR-009 | AC-2: set prints "stored in " only | `commands.test.ts` — "runSecretsSet — FR-019-AC-2" | ✅ Unit | +| FR-009 | AC-3: which transitions env/keyring/age-file/unset | `commands.test.ts` — "runSecretsWhich — FR-019-AC-3" | ✅ Unit | +| FR-009 | AC-4: rm clears persisted value; warns if env set | `commands.test.ts` — "runSecretsRm — FR-019-AC-4 + warn-when-env-still-set" | ✅ Unit | +| FR-009 | AC-5: unknown id → UnknownSecretError | `commands.test.ts` — "rejects unknown id with UnknownSecretError"; `secrets-service.test.ts` — "FR-019-AC-5 unknown id handling" | ✅ Unit | +| FR-009 | AC-6: zero secret values across lifecycle output | `commands.test.ts` + `redaction.test.ts` | ✅ Unit | +| FR-009 | AC-7: keyring denial → remediation; not echoed | `keyring-backend.test.ts` — KeyringAccessError surface | ✅ Unit (mocked) | +| FR-010 | AC-1..AC-4: oclif-native CLI binary composition | exercised by the consuming binary (`ix://agent-ix/ix-cli`) | ⚠️ Integration (host binary) | +| FR-011 | AC-1..AC-6: --config-root / IX_CONFIG_ROOT base flag | `BaseCommand` base flags (`src/commands/base-command.ts`) | ⚠️ Integration (host binary command tests) | +| FR-012 | AC-1..AC-4: oclif-native plugin discovery, no manifest | static — no manifest loader in `src/` | ✅ Static (no on-disk manifest reads) | +| FR-019 | AC-1: install options carry cacheRoot/ts-plugin-kit + target | `marketplace.test.ts` — "marketplaceInstallOptions derives cache under cacheRoot" | ✅ Unit | +| FR-019 | AC-2: reconcileDefaultSet installs enabled entries | `marketplace.test.ts` — "reconcileDefaultSet installs the enabled set" | ✅ Unit | +| FR-019 | AC-3: oclif bridge maps npm→install, every other source→link | `marketplace.test.ts` — "resolveOclifPluginInstall maps npm→install and git/path→link" | ⚠️ Unit (npm + path permutations; github/git-subdir/git/url share the one non-npm branch, untested) | +| FR-019 | AC-4: no @oclif/plugin-plugins / no in-tree installer | static — adapter delegates to `@agent-ix/ts-plugin-kit` | ✅ Static (delegation) | +| FR-013 | AC-1: command declares static capabilities | `capabilities.test.ts` — "surfaces available required and optional capabilities" | ✅ Unit | +| FR-013 | AC-2: required-unavailable short-circuits before side effects | `capabilities.test.ts` — "fails required capabilities that have no provider" | ✅ Unit | +| FR-013 | AC-3: optional missing never blocks; resolved surfaced | `capabilities.test.ts` — "does not block on missing optional capabilities" | ✅ Unit | +| FR-013 | AC-4: resolver reads through Config/Secrets context | `capabilities.test.ts` — provider context | ✅ Unit | +| FR-013 | AC-5: capability errors carry machine-readable code | `capabilities.test.ts` — "preserves provider errors"; `capabilityErrorToJson` | ✅ Unit | +| FR-014 | AC-1..AC-7: ixSchema convention + registration | `plugin-schema.test.ts` — "registerPluginSchema — FR-025 oclif ixSchema convention" | ✅ Unit | +| FR-015 | AC-1/AC-2: host normalization (https default, port preserved) | `auth-discovery.test.ts` — "assumes https for a bare host…", "preserves an explicit port" | ✅ Unit | +| FR-015 | AC-3: http refused unless dev.ix / insecure | `auth-discovery.test.ts` — "allows http for \*.dev.ix hosts", "rejects http for non-dev.ix hosts unless insecure" | ✅ Unit | +| FR-015 | AC-4: GET well-known returns parsed doc | `auth-discovery.test.ts` — "GETs the well-known path and returns the parsed doc" | ✅ Unit | +| FR-015 | AC-5: non-2xx / transport → DiscoveryFetchError | `auth-discovery.test.ts` — "raises DiscoveryFetchError on a non-2xx status", "…on a transport error" | ✅ Unit | +| FR-015 | AC-6: missing field → DiscoverySchemaError naming it | `auth-discovery.test.ts` — "raises DiscoverySchemaError naming missing fields" | ✅ Unit | +| FR-015 | AC-7: insecure http refused before any fetch | `auth-discovery.test.ts` — "refuses plain http for non-dev.ix without insecure (no fetch)" | ✅ Unit | +| FR-016 | AC-1: pending→token returns normalized bundle | `auth-device-flow.test.ts` — "authorizes, presents, polls past pending, returns a token bundle" | ✅ Unit | +| FR-016 | AC-2: authorize body carries client_id + audience/scope | `auth-device-flow.test.ts` — "authorizes, presents, polls past pending…" (authCall body assertions) | ✅ Unit | +| FR-016 | AC-3: prompter.showVerification called once with uri+code | `auth-device-flow.test.ts` — same happy-path test (shown assertions) | ✅ Unit | +| FR-016 | AC-4: slow_down grows interval by 5000ms | `auth-device-flow.test.ts` — "backs off on slow_down (interval grows by 5s)" | ✅ Unit | +| FR-016 | AC-5: access_denied / expired_token / deadline | `auth-device-flow.test.ts` — "throws access_denied…", "throws expired_token on the expired_token error code", "…when the deadline passes" | ✅ Unit | +| FR-016 | AC-6: non-2xx authorize → authorize_failed, no poll | `auth-device-flow.test.ts` — "fails authorize on a non-2xx authorize response" | ✅ Unit | +| FR-016 | AC-7: browser-open failure is non-fatal | `auth-device-flow.test.ts` — "browser-open failure is non-fatal" | ✅ Unit | +| FR-016 | AC-8: verification_uri_complete used for the open | `auth-device-flow.test.ts` — "uses verification_uri_complete for the browser open when present" | ✅ Unit | +| FR-017 | AC-1: host isolation on save/clear | `auth-token-store.test.ts` — "keys secrets per host so logins don't collide" | ✅ Unit | +| FR-017 | AC-2: token in backend, metadata holds no value | `auth-token-store.test.ts` — "stores metadata (expiry/audience) separately from the secret" | ✅ Unit | +| FR-017 | AC-3: fresh token returned with no fetch | `auth-token-store.test.ts` — "returns the stored token when it is fresh (no refresh, no fetch)" | ✅ Unit | +| FR-017 | AC-4: refresh-before-expiry + rotation persisted | `auth-token-store.test.ts` — "refreshes before expiry and rotates the stored refresh token" | ✅ Unit | +| FR-017 | AC-5: no new refresh token preserves the old | `auth-token-store.test.ts` — "keeps the old refresh token when the issuer doesn't rotate" | ✅ Unit | +| FR-017 | AC-6: NotAuthenticatedError / TokenRefreshError | `auth-token-store.test.ts` — "throws NotAuthenticatedError…", "surfaces a refresh failure as TokenRefreshError" | ✅ Unit | +| FR-017 | AC-7: hostSlug always matches SecretId name regex | `auth-token-store.test.ts` — "slugifies a dotted host into a SecretId-name-safe segment" | ✅ Unit | +| FR-018 | AC-1/AC-2: opener never rejects; opt-out env returns false | exercised by `auth-device-flow.test.ts` (injected opener) + opener opt-out logic | ⚠️ Unit (injected) / Review | +| FR-018 | AC-3: thrown opener doesn't abort the flow | `auth-device-flow.test.ts` — "browser-open failure is non-fatal" | ✅ Unit | +| FR-020 | AC-1: marker presence → true; none → false | `agent.test.ts` — "is true when … is present" / "is false when no marker is set" | ✅ Unit | +| FR-020 | AC-2: non-1 marker value detected; empty = absent | `agent.test.ts` — "is true for a non-1 marker value …" / "treats an empty-string marker as absent" | ✅ Unit | +| FR-020 | AC-3: isInteractiveHuman gating (TTY + no marker + no opt-out) | `agent.test.ts` — "is true for both-TTY …" + the negative gating cases | ✅ Unit | +| FR-020 | AC-4: IX_NO_AUTO_AGENT=1 opts out; =0 does not | `agent.test.ts` — "is false when IX_NO_AUTO_AGENT is truthy" / "is NOT opted out by IX_NO_AUTO_AGENT=0" | ✅ Unit | +| FR-021 | AC-1: mode off → no-op | `agent.test.ts` — "mode 'off' is a no-op" | ✅ Unit | +| FR-021 | AC-2: non-interactive → no spawn | `agent.test.ts` — "does nothing when not an interactive human" | ✅ Unit | +| FR-021 | AC-3: agent marker → no spawn (guard) | `agent.test.ts` — "does nothing under an agent marker (fork-bomb guard)" | ✅ Unit | +| FR-021 | AC-4: auto launches w/ stdio inherit + guard + seed last | `agent.test.ts` — "auto mode launches interactively with the guard + seed" | ✅ Unit | +| FR-021 | AC-5: multi-word command split; seed appended; no shell | `agent.test.ts` — "splits a multi-word command and appends the seed last" | ✅ Unit | +| FR-021 | AC-6: prompt confirm gating | `agent.test.ts` — "prompt mode: declined…/accepted… confirm" | ✅ Unit | +| FR-021 | AC-7: exit status forwarded; null→0 | `agent.test.ts` — "forwards the child's exit status" / "maps a signal death (null status) to exit 0" | ✅ Unit | +| FR-021 | AC-8: ENOENT non-fatal | `agent.test.ts` — "ENOENT is non-fatal: returns false, no exit, logs a hint" | ✅ Unit | +| FR-022 | AC-1: defaults + file>default | `agent.test.ts` — "defaults: autoLaunch=prompt…" / "file value wins over default" | ✅ Unit | +| FR-022 | AC-2: env override + invalid enum → default + incident | `agent.test.ts` — "env beats file…" / "invalid autoLaunch via env → falls back…" | ✅ Unit | +| FR-022 | AC-3: chooser pick launches | `agent.test.ts` — "launches the chosen agent and offers to persist it" | ✅ Unit | +| FR-022 | AC-4: persist on save; declined still launches | `agent.test.ts` — "launches the chosen agent…" / "launches but does not persist when the save prompt is declined" | ✅ Unit | +| FR-022 | AC-5: cancelled chooser no-op | `agent.test.ts` — "cancelled chooser is a no-op" | ✅ Unit | +| FR-022 | AC-6: strict schema rejects unknown keys | `agent.test.ts` — "rejects unknown keys (strict schema)" | ✅ Unit | +| FR-023 | AC-1: up-to-date → no install, returns `{updated:false,latest}` | `self-update.test.ts` — "reports up-to-date and never installs (no registry override → ambient config)" | ✅ Unit | +| FR-023 | AC-2: `check` + newer latest → no install, returns `{updated:false,latest}` | `self-update.test.ts` — "with check, reports an available update without installing" | ✅ Unit | +| FR-023 | AC-3: out-of-date → `npm install -g @`, returns `{updated:true,latest}` | `self-update.test.ts` — "installs the latest version when out of date" | ✅ Unit | +| 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) | ## Non-Functional Requirement Coverage diff --git a/src/commands/self-update.tsx b/src/commands/self-update.tsx new file mode 100644 index 0000000..80cc6ed --- /dev/null +++ b/src/commands/self-update.tsx @@ -0,0 +1,206 @@ +import { spawn } from "node:child_process"; + +import type * as IxUiCli from "@agent-ix/ix-ui-cli"; + +let _ixUi: typeof IxUiCli | undefined; +async function loadIxUi(): Promise { + return (_ixUi ??= await import("@agent-ix/ix-ui-cli")); +} + +export interface SelfUpdateOptions { + /** npm package name to upgrade, e.g. `@agent-ix/quoin`. */ + packageName: string; + /** Currently-running version (typically read from the caller's package.json). */ + currentVersion: string; + /** Listing header, e.g. `quoin update`. Defaults to ` update`. */ + header?: string; + /** + * Override the npm registry. When omitted, the ambient npm config is used — + * i.e. however the user originally installed the package (their + * `@scope:registry` setting, or the npm default). Pass a URL (e.g. + * `https://registry.npmjs.org/` or `http://npm.ix/`) to force one. + */ + registry?: string; + /** Check for an update without installing. */ + check?: boolean; +} + +export interface SelfUpdateResult { + /** True when an install was performed (false for up-to-date or `check`). */ + updated: boolean; + /** The latest version reported by the registry. */ + latest: string; +} + +/** + * npm flags that force `registry` for `packageName`. A plain `--registry` is + * silently ignored for a scoped package when the user's npmrc pins a + * `@scope:registry`; the scope-specific override is the one npm actually + * honors. Returns an empty array when no override is requested (ambient + * config resolves the package). + */ +function registryArgs(packageName: string, registry?: string): string[] { + if (!registry) return []; + const scope = packageName.startsWith("@") + ? packageName.split("/")[0] + : undefined; + return scope ? [`--${scope}:registry=${registry}`] : ["--registry", registry]; +} + +/** Spawn a command, capturing stdout. Rejects on non-zero exit, surfacing the + * child's stderr (npm's real error — auth, 404 — rather than a bare code). */ +function spawnCapture(cmd: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const out: Buffer[] = []; + const err: Buffer[] = []; + const proc = spawn(cmd, args, { shell: false }); + proc.stdout?.on("data", (d: Buffer) => out.push(d)); + proc.stderr?.on("data", (d: Buffer) => err.push(d)); + proc.on("close", (code) => { + if (code === 0) { + resolve(Buffer.concat(out).toString().trim()); + } else { + const detail = Buffer.concat(err).toString().trim(); + reject( + new Error( + `${cmd} exited with code ${String(code)}${detail ? `: ${detail}` : ""}`, + ), + ); + } + }); + proc.on("error", reject); + }); +} + +/** Spawn a command with inherited stdio (lets npm draw its own progress). */ +function spawnInherited(cmd: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(cmd, args, { stdio: "inherit", shell: false }); + proc.on("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`${cmd} exited with code ${String(code)}`)); + }); + proc.on("error", reject); + }); +} + +/** + * Generic self-update for an npm-distributed IX CLI: query the registry for the + * latest published version, compare to the running version, and (unless + * `check`) `npm install -g @`. Rendering of the result listing is + * done here so every CLI gets identical output; the resolved + * {@link SelfUpdateResult} is also returned for callers that want to branch. + * + * This is framework-agnostic: it has no oclif dependency and is callable from a + * plain command dispatcher (e.g. quoin) as easily as from a BaseCommand. + */ +export async function runSelfUpdate( + opts: SelfUpdateOptions, +): Promise { + const { FlowLine, Listing, Note, blue, colors, renderStatic } = + await loadIxUi(); + + const header = opts.header ?? `${opts.packageName} update`; + const current = opts.currentVersion; + const regArgs = registryArgs(opts.packageName, opts.registry); + const registryLabel = opts.registry ?? "npm config (ambient)"; + + const baseNotes = ( + <> + {`registry ${blue(registryLabel)}`} + {`current ${blue(current)}`} + + ); + + let latest: string; + try { + latest = await spawnCapture("npm", [ + "view", + opts.packageName, + "version", + ...regArgs, + ]); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await renderStatic( + + {baseNotes} + , + ); + throw err; + } + + if (current === latest) { + await renderStatic( + {`${blue(current)} from ${blue(registryLabel)}`} + } + tail={`Already up to date · ${blue(latest)}`} + />, + ); + return { updated: false, latest }; + } + + if (opts.check) { + await renderStatic( + {`${blue(current)} from ${blue(registryLabel)}`} + } + tail={`Update available · ${blue(latest)}`} + tailVariant="warn" + />, + ); + return { updated: false, latest }; + } + + // `npm install -g` writes its own progress to stdout — let it inherit, then + // render a final summary listing. + try { + await spawnInherited("npm", [ + "install", + "-g", + `${opts.packageName}@${latest}`, + ...regArgs, + ]); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + await renderStatic( + + {baseNotes} + {`latest ${blue(latest)}`} + , + ); + throw err; + } + + await renderStatic( + {`${colors.dim(current)} → ${blue(latest)} via ${blue(registryLabel)}`} + } + tail={`Updated to ${blue(latest)}.`} + />, + ); + return { updated: true, latest }; +} diff --git a/src/index.ts b/src/index.ts index 61641b2..eaab1ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -200,6 +200,13 @@ export { type SecretsCommandDeps, } from "./commands/secrets.js"; +// ── Generic self-update for npm-distributed IX CLIs ────────────────────── +export { + runSelfUpdate, + type SelfUpdateOptions, + type SelfUpdateResult, +} from "./commands/self-update.js"; + // ── Auth engine (FR-015..FR-018) ───────────────────────────────────────── // Generic, service-agnostic device-flow login: discovery client, runner, // host-keyed token store, and a non-fatal browser opener. Service identity diff --git a/tests/self-update.test.ts b/tests/self-update.test.ts new file mode 100644 index 0000000..77ac2b0 --- /dev/null +++ b/tests/self-update.test.ts @@ -0,0 +1,147 @@ +import { EventEmitter } from "node:events"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ── Mocks ──────────────────────────────────────────────────────────────── +// runSelfUpdate dynamically imports the renderer; stub it so the tests assert +// behavior (spawn calls + return value) rather than terminal 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, + }; +}); + +// spawn is mocked per-test via this queue: each entry decides how the next +// spawned process resolves. +interface SpawnPlan { + /** stdout payload emitted before close (for the capturing `npm view`). */ + stdout?: string; + /** exit code; non-zero rejects the spawn helper. */ + code?: number; + /** emit an 'error' event instead of closing. */ + 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; + }, +})); + +// Import AFTER mocks are registered. +const { runSelfUpdate } = await import("../src/commands/self-update.js"); + +const PKG = "@agent-ix/quoin"; + +beforeEach(() => { + spawnQueue = []; + spawnCalls.length = 0; + renderStatic.mockClear(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("runSelfUpdate", () => { + it("reports up-to-date and never installs (no registry override → ambient config)", async () => { + spawnQueue = [{ stdout: "1.2.3\n" }]; + const result = await runSelfUpdate({ + packageName: PKG, + currentVersion: "1.2.3", + }); + expect(result).toEqual({ updated: false, latest: "1.2.3" }); + // Only `npm view`, no install. With no override, NO registry flag is + // passed — npm resolves the package via the ambient config. + expect(spawnCalls).toHaveLength(1); + expect(spawnCalls[0].args).toEqual(["view", PKG, "version"]); + }); + + it("with check, reports an available update without installing", async () => { + spawnQueue = [{ stdout: "2.0.0" }]; + const result = await runSelfUpdate({ + packageName: PKG, + currentVersion: "1.2.3", + check: true, + }); + expect(result).toEqual({ updated: false, latest: "2.0.0" }); + expect(spawnCalls).toHaveLength(1); // view only + }); + + it("installs the latest version when out of date", async () => { + spawnQueue = [{ stdout: "2.0.0" }, { code: 0 }]; + const result = await runSelfUpdate({ + packageName: PKG, + currentVersion: "1.2.3", + }); + expect(result).toEqual({ updated: true, latest: "2.0.0" }); + expect(spawnCalls).toHaveLength(2); + expect(spawnCalls[1].args).toEqual(["install", "-g", `${PKG}@2.0.0`]); + }); + + it("forces a custom registry via the SCOPE override (not plain --registry)", async () => { + spawnQueue = [{ stdout: "2.0.0" }, { code: 0 }]; + await runSelfUpdate({ + packageName: PKG, + currentVersion: "1.2.3", + registry: "http://npm.ix/", + }); + // A plain --registry is ignored for scoped packages when an npmrc pins a + // scope registry, so we must use the @scope:registry form for it to win. + const scopeFlag = "--@agent-ix:registry=http://npm.ix/"; + expect(spawnCalls[0].args).toEqual(["view", PKG, "version", scopeFlag]); + expect(spawnCalls[1].args).toEqual([ + "install", + "-g", + `${PKG}@2.0.0`, + scopeFlag, + ]); + }); + + it("uses plain --registry for an unscoped package", async () => { + spawnQueue = [{ stdout: "1.0.0" }]; + await runSelfUpdate({ + packageName: "some-cli", + currentVersion: "0.9.0", + registry: "https://registry.npmjs.org/", + }); + expect(spawnCalls[0].args).toEqual([ + "view", + "some-cli", + "version", + "--registry", + "https://registry.npmjs.org/", + ]); + }); + + it("throws when the registry is unreachable", async () => { + spawnQueue = [{ error: new Error("ENOTFOUND") }]; + await expect( + runSelfUpdate({ packageName: PKG, currentVersion: "1.2.3" }), + ).rejects.toThrow("ENOTFOUND"); + expect(spawnCalls).toHaveLength(1); // never reached install + }); +});