@aragon-agent/cli 0.5.9 → 0.5.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +1157 -1026
  2. package/README.md +123 -0
  3. package/dist/boot/guard.d.ts +135 -0
  4. package/dist/boot/guard.js +218 -0
  5. package/dist/boot/rollback.d.ts +86 -0
  6. package/dist/boot/rollback.js +185 -0
  7. package/dist/cli.js +283 -2
  8. package/dist/commands/builtins.js +118 -0
  9. package/dist/commands/registry.d.ts +17 -0
  10. package/dist/config/app-paths.d.ts +31 -0
  11. package/dist/config/app-paths.js +37 -0
  12. package/dist/config/cli-commands.d.ts +27 -1
  13. package/dist/config/cli-commands.js +51 -2
  14. package/dist/config/env.d.ts +10 -1
  15. package/dist/config/env.js +41 -1
  16. package/dist/config/load.d.ts +17 -0
  17. package/dist/config/load.js +25 -1
  18. package/dist/config/schema.d.ts +56 -0
  19. package/dist/config/schema.js +81 -0
  20. package/dist/config/store.js +13 -1
  21. package/dist/launcher.d.ts +14 -0
  22. package/dist/launcher.js +43 -0
  23. package/dist/logging/logger.d.ts +1 -1
  24. package/dist/ui/App.d.ts +11 -1
  25. package/dist/ui/App.js +88 -2
  26. package/dist/ui/BottomStatusRow.d.ts +25 -3
  27. package/dist/ui/BottomStatusRow.js +10 -7
  28. package/dist/ui/UpdateLine.d.ts +44 -0
  29. package/dist/ui/UpdateLine.js +129 -0
  30. package/dist/update/classify-failure.d.ts +32 -0
  31. package/dist/update/classify-failure.js +60 -0
  32. package/dist/update/install-lock.d.ts +55 -0
  33. package/dist/update/install-lock.js +184 -0
  34. package/dist/update/install-source.d.ts +97 -0
  35. package/dist/update/install-source.js +218 -0
  36. package/dist/update/installer.d.ts +108 -0
  37. package/dist/update/installer.js +242 -0
  38. package/dist/update/limits.d.ts +111 -0
  39. package/dist/update/limits.js +111 -0
  40. package/dist/update/npm-view.d.ts +62 -0
  41. package/dist/update/npm-view.js +133 -0
  42. package/dist/update/registry.d.ts +52 -0
  43. package/dist/update/registry.js +161 -0
  44. package/dist/update/semver.d.ts +89 -0
  45. package/dist/update/semver.js +295 -0
  46. package/dist/update/service.d.ts +193 -0
  47. package/dist/update/service.js +636 -0
  48. package/dist/update/state.d.ts +103 -0
  49. package/dist/update/state.js +142 -0
  50. package/dist/update/types.d.ts +169 -0
  51. package/dist/update/types.js +68 -0
  52. package/package.json +3 -3
@@ -0,0 +1,185 @@
1
+ /**
2
+ * The rollback (cli-auto-update-hardening H1 / section 5.1.3).
3
+ *
4
+ * ASCII ONLY - this tree is inside the glyph scanner's scope (C-19).
5
+ *
6
+ * EVERY IMPORT THAT DOES REAL WORK IS DYNAMIC, AND FOR A REASON THAT IS NOT THE
7
+ * USUAL ONE. This function runs inside a build we have CONCRETE EVIDENCE is
8
+ * broken - it was reached because that build failed to start twice - so every
9
+ * one of `install-lock`, `installer` and `install-source` can reject on import.
10
+ * The whole body is inside one `try`, and the `catch` records one line and
11
+ * returns. Static `import type` specifiers below are erased by tsc and are not
12
+ * runtime edges, which is what keeps `guard.ts`'s own graph inside the list its
13
+ * header pins down.
14
+ *
15
+ * `../update/state.js` IS THE ONE EXCEPTION and is imported statically: the
16
+ * guard has already loaded and read it before it can possibly call us, so a
17
+ * static edge here adds no failure mode that has not already been survived.
18
+ */
19
+ import process from 'node:process';
20
+ import { readUpdateState, updateUpdateState } from '../update/state.js';
21
+ /** Best-effort, and deliberately so: a logger that fails must not stop a
22
+ * rollback that is the user's only way back to a working CLI. */
23
+ async function record(deps, level, msg, data) {
24
+ try {
25
+ if (deps.log) {
26
+ deps.log(level, msg, data);
27
+ return;
28
+ }
29
+ const { getLogger } = await import('../logging/logger.js');
30
+ getLogger().child('update')[level](msg, data);
31
+ }
32
+ catch {
33
+ // No logger, or a broken one. The stderr line the guard already wrote is
34
+ // the part the user needs.
35
+ }
36
+ }
37
+ async function defaultResolveTarget() {
38
+ const { classifyInstallSource, isAutoInstallable, probeWritable, readSelfManifest, selfPackageRoot } = await import('../update/install-source.js');
39
+ const root = selfPackageRoot();
40
+ if (!root)
41
+ return { root: null, packageName: '', autoInstallable: false, writable: false };
42
+ return {
43
+ root,
44
+ packageName: readSelfManifest(root)?.name ?? '',
45
+ autoInstallable: isAutoInstallable(classifyInstallSource(root)),
46
+ writable: probeWritable(root),
47
+ };
48
+ }
49
+ /**
50
+ * Reinstall `good` and latch `bad` so nothing puts it back.
51
+ *
52
+ * THE ORDER OF OPERATIONS IS LOAD-BEARING.
53
+ *
54
+ * 1. LATCH FIRST, INSTALL SECOND. If the process dies between the latch and the
55
+ * spawn, the machine is left running a broken version that WILL NOT BE
56
+ * REINSTALLED - recoverable by hand. In the other order, a death between the
57
+ * spawn and the latch leaves a good version installed with no latch, and the
58
+ * next check reinstalls the bad one.
59
+ *
60
+ * `skippedVersion` is part of that write and is the reason it works across
61
+ * versions (C-17 / D-28): `readUpdateState` rebuilds from a fixed field list,
62
+ * so an OLDER `aragon` writing this file erases every field it does not know.
63
+ * `skippedVersion` is schema-1 and every shipped version honours it through
64
+ * `decideUpdate`. Per section 1.2 the ordinary rollback target is itself an H1
65
+ * build and preserves all four new fields; this covers the cases that
66
+ * argument does not reach - a hand-edited state file, a user who manually
67
+ * installs a pre-H1 version, and any future field that matters across the
68
+ * boundary.
69
+ *
70
+ * `rolledBackFrom` IS WRITTEN HERE AND NOWHERE ELSE (P0-1d). It is consumed
71
+ * exactly once, by `UpdateService.start()`, which is the only thing that can
72
+ * decide the notice has actually been shown.
73
+ *
74
+ * 2. RE-CLASSIFY BEFORE SPAWNING. The machine may have changed since the
75
+ * install; we do not run `npm i -g` on a tree we would not have installed
76
+ * into.
77
+ *
78
+ * 3. ONE NON-BLOCKING LOCK ATTEMPT (U-5b). If another process holds it, return:
79
+ * it is either installing or already rolling back, and two concurrent
80
+ * `npm i -g` on one prefix is the one thing the lock exists to stop.
81
+ *
82
+ * 4. `runNpmInstall` VERBATIM. It already validates the version against
83
+ * `STRICT_VERSION_RE`, spawns detached through `process.execPath`, writes to
84
+ * a real fd and verifies the classified root afterwards (U-6). A rollback is
85
+ * an install; it does not deserve a second code path.
86
+ *
87
+ * The stderr line belongs to `runBootGuard` and is written before this is called
88
+ * (P2-9). NEVER THROWS: the guard invokes it without `await` and cannot handle a
89
+ * rejection.
90
+ */
91
+ export async function performRollback(bad, good, deps = {}) {
92
+ const write = deps.writeState ?? updateUpdateState;
93
+ const read = deps.readState ?? readUpdateState;
94
+ try {
95
+ // Read BEFORE the latch: the write below zeroes `bootFailures`, and the
96
+ // count is the one number worth keeping in the log record.
97
+ const failures = read().bootFailures;
98
+ // Step 1 - the latch, in ONE write.
99
+ write({
100
+ skippedVersion: bad,
101
+ pendingRestartVersion: '',
102
+ bootFailures: 0,
103
+ autoInstalledVersion: '',
104
+ rolledBackFrom: bad,
105
+ });
106
+ // Step 2 - re-classify.
107
+ const target = await (deps.resolveTarget ?? defaultResolveTarget)();
108
+ if (!target.root || target.packageName.length === 0) {
109
+ await record(deps, 'warn', 'update_rollback_failed', {
110
+ from: bad,
111
+ to: good,
112
+ reason: 'no-package-root',
113
+ });
114
+ return { ok: false, failure: 'no-package-root' };
115
+ }
116
+ if (!target.autoInstallable) {
117
+ await record(deps, 'warn', 'update_rollback_failed', {
118
+ from: bad,
119
+ to: good,
120
+ reason: 'source-ineligible',
121
+ });
122
+ return { ok: false, failure: 'source-ineligible' };
123
+ }
124
+ if (!target.writable) {
125
+ await record(deps, 'warn', 'update_rollback_failed', {
126
+ from: bad,
127
+ to: good,
128
+ reason: 'not-writable',
129
+ });
130
+ return { ok: false, failure: 'not-writable' };
131
+ }
132
+ // Step 3 - the ordinary install lock.
133
+ const acquire = deps.acquireLock ??
134
+ (await import('../update/install-lock.js')).tryAcquireInstallLock;
135
+ const lock = acquire();
136
+ if (!lock) {
137
+ await record(deps, 'warn', 'update_rollback_failed', {
138
+ from: bad,
139
+ to: good,
140
+ reason: 'locked',
141
+ });
142
+ return { ok: false, failure: 'locked' };
143
+ }
144
+ // Step 4 - the install, reused.
145
+ const install = deps.install ?? (await import('../update/installer.js')).runNpmInstall;
146
+ let result;
147
+ try {
148
+ result = await install({
149
+ packageName: target.packageName,
150
+ version: good,
151
+ root: target.root,
152
+ // Phase two of U-4, exactly as `performInstall` does it: the lock had to
153
+ // precede the spawn, and it is rewritten to the child's pid the instant
154
+ // the child exists. `adoptChild` closes over its own state and may be
155
+ // passed unbound.
156
+ onSpawn: lock.adoptChild,
157
+ });
158
+ }
159
+ finally {
160
+ lock.release();
161
+ }
162
+ if (!result.ok) {
163
+ await record(deps, 'warn', 'update_rollback_failed', {
164
+ from: bad,
165
+ to: good,
166
+ reason: result.failure ?? 'install-failed',
167
+ });
168
+ return { ok: false, failure: 'install-failed' };
169
+ }
170
+ await record(deps, 'warn', 'update_rolled_back', { from: bad, to: good, failures });
171
+ return { ok: true };
172
+ }
173
+ catch (err) {
174
+ // The one place this module writes to the terminal itself, and only because
175
+ // the alternative is a silent no-op on the path the user is already stuck on.
176
+ try {
177
+ process.stderr.write(`aragon: rollback to ${good} could not run (${err instanceof Error ? err.message : String(err)}).\n`);
178
+ }
179
+ catch {
180
+ // stderr is gone too. Nothing further is available to us.
181
+ }
182
+ await record(deps, 'warn', 'update_rollback_failed', { from: bad, to: good, reason: 'error' });
183
+ return { ok: false, failure: 'error' };
184
+ }
185
+ }
package/dist/cli.js CHANGED
@@ -23,7 +23,7 @@ import { formatMigrationNotice, migrateLegacyState } from './config/migrate-lega
23
23
  import { formatHomeMigrationNotice, migrateToHome } from './config/migrate-home.js';
24
24
  import { migrateStateOutOfConfig } from './config/migrate-state-out-of-config.js';
25
25
  import { runHistoryCommand } from './config/history-commands.js';
26
- import { FAST_CONFIG_SET_KEYS, LOG_CONFIG_SET_KEYS, RETRY_CONFIG_SET_KEYS, TEAM_CONFIG_SET_KEYS, applyFastConfigSet, applyLogConfigSet, applyRetryConfigSet, applyTeamConfigSet, runConfigEdit, runConfigGet, runConfigHome, runConfigList, warnIfRedactionDisabled, } from './config/cli-commands.js';
26
+ import { FAST_CONFIG_SET_KEYS, LOG_CONFIG_SET_KEYS, RETRY_CONFIG_SET_KEYS, TEAM_CONFIG_SET_KEYS, UPDATE_CONFIG_SET_KEYS, applyFastConfigSet, applyLogConfigSet, applyRetryConfigSet, applyTeamConfigSet, applyUpdateConfigSet, runConfigEdit, runConfigGet, runConfigHome, runConfigList, warnIfRedactionDisabled, } from './config/cli-commands.js';
27
27
  import { attachAgentEvents, attachTeamEvents, installLogging, setScreenRestore, setSignalTerminator, } from './logging/install.js';
28
28
  import { getLogger } from './logging/logger.js';
29
29
  import { runLogsCommand } from './logging/cli-commands.js';
@@ -41,6 +41,15 @@ import { renderTranscriptText } from './ui/transcript-text.js';
41
41
  import { detectCapabilities } from './ui/capabilities.js';
42
42
  import { pickGlyphs } from './ui/glyphs.js';
43
43
  import { DENY_ALL_HUMAN_INPUT } from './tools/human-input.js';
44
+ // A VALUE import, and the one deliberate exception to the paragraph above
45
+ // (cli-auto-update-hardening P2-7). `boot/guard.js` statically pulls
46
+ // `update/state.js`, so `aragon -p` now EVALUATES that module - it is pure at
47
+ // module scope, calls nothing, and `update-wiring.test.ts` walks the transitive
48
+ // graph against an allow-list so it cannot quietly grow. The alternative, a
49
+ // dynamic import for a two-line disarm on the hottest path in the file, buys
50
+ // nothing: the guard has already loaded and read the same module milliseconds
51
+ // earlier, in the launcher.
52
+ import { markBootHealthy } from './boot/guard.js';
44
53
  // ---------------------------------------------------------------------------
45
54
  // Version (read from the shipped package.json next to dist/)
46
55
  // ---------------------------------------------------------------------------
@@ -131,6 +140,15 @@ function toFlags(opts) {
131
140
  transcriptRetain: opts.transcriptRetain,
132
141
  renderGovernor: opts.renderGovernor,
133
142
  maxRenderInterval: opts.maxRenderInterval,
143
+ // Site three of three for `--update` / `--no-update` — SEVEN features, one
144
+ // trap (`mouse` P1-2, `team` P1-4, `todo` C-9, render L4, retry, fast, now
145
+ // update). A flag needs the `.option()` declaration below, a field on
146
+ // `CliFlags`, AND a line here; miss this one and the flag is inert with no
147
+ // compile error and no runtime error. It bites hardest here of all: this is
148
+ // the KILL SWITCH for a feature that installs software, so a missing line
149
+ // means `--no-update` reports nothing, does nothing, and leaves the updater
150
+ // running.
151
+ update: opts.update,
134
152
  };
135
153
  }
136
154
  // ---------------------------------------------------------------------------
@@ -302,7 +320,26 @@ function runInteractive(flags, extras = {}) {
302
320
  process.exit(128 + signo);
303
321
  });
304
322
  }
305
- const instance = render(_jsx(App, { controller: controller, version: VERSION, mode: mode, confirmBridge: confirmBridge, humanInputBridge: humanInputBridge, initialPrompt: extras.initialPrompt, initialOverlay: extras.initialOverlay, mouseSource: mouseFilter?.source }),
323
+ // --- Auto-update (cli-auto-update section 3.8). --------------------------
324
+ //
325
+ // THE ONLY CONSTRUCTION SITE. `runOneShot` / `runHeadless` never reach this
326
+ // function, so `aragon -p` executes zero update code and its stdout is
327
+ // byte-identical to a pre-feature build (AC-1); `aragon config <subcommand>`,
328
+ // `aragon logs`, `aragon skills` and `aragon --version` likewise. Bare
329
+ // `aragon config` DOES construct it, because it is a full interactive session
330
+ // with a settings overlay on top - which is correct, and is why AC-1's claim
331
+ // names the SUBCOMMAND form.
332
+ //
333
+ // `CI` is excluded on top of the two TTY tests because a CI runner with a pty
334
+ // allocated is otherwise a machine that silently mutates its own global
335
+ // toolchain mid-pipeline (D-11).
336
+ const updateBridge = { service: null, onAttach: null };
337
+ let updateService = null;
338
+ const updateEligible = !!process.stdout.isTTY &&
339
+ !!process.stdin.isTTY &&
340
+ !process.env.CI &&
341
+ config.update.mode !== 'off';
342
+ const instance = render(_jsx(App, { controller: controller, version: VERSION, mode: mode, confirmBridge: confirmBridge, humanInputBridge: humanInputBridge, initialPrompt: extras.initialPrompt, initialOverlay: extras.initialOverlay, mouseSource: mouseFilter?.source, updateBridge: updateEligible ? updateBridge : undefined }),
306
343
  // Full-screen owns console.* itself (I-4): Ink's patchConsole writes
307
344
  // straight to stdout and permanently shifts the fixed frame's accounting.
308
345
  {
@@ -312,6 +349,42 @@ function runInteractive(flags, extras = {}) {
312
349
  // every existing path is provably unchanged.
313
350
  stdin: mouseFilter?.stdin ?? process.stdin,
314
351
  });
352
+ // H1's primary disarm, and this is the earliest moment at which "this build
353
+ // STARTS" is proven: the module graph loaded, config resolved, Ink mounted
354
+ // (cli-auto-update-hardening section 5.1.4). It is what makes a long session
355
+ // that is later `SIGKILL`ed - terminal window closed, machine slept badly, OOM
356
+ // killer - still count as healthy.
357
+ //
358
+ // UNGATED BY `updateEligible` ON PURPOSE. The guard is armed only by an
359
+ // auto-install, which `mode: 'off'` already prevents, so on a machine that
360
+ // never armed it this is one read and one string compare. Gating it would mean
361
+ // a user who switches `mode` to `off` between the install and the next launch
362
+ // leaves the guard armed with nothing able to disarm it.
363
+ markBootHealthy(VERSION);
364
+ if (updateEligible) {
365
+ // DYNAMIC, and fire-and-forget AFTER `render()` (D-25). Dynamic because a
366
+ // static import would make AC-1 false for `aragon -p`, which shares this
367
+ // module and has no bundler to shake the subsystem out; after `render()`
368
+ // because the service must never be able to delay or fail the first frame.
369
+ // The `.catch` is not decoration: a subsystem that cannot even be imported
370
+ // must degrade to "no updater", never to a crashed TUI.
371
+ void import('./update/service.js')
372
+ .then(({ UpdateService }) => {
373
+ const service = new UpdateService({ config: config.update, currentVersion: VERSION });
374
+ updateService = service;
375
+ updateBridge.service = service;
376
+ // `App` may have mounted first and parked its listener here; it may also
377
+ // not have mounted yet, in which case its own effect reads
378
+ // `bridge.service` directly. Both orders happen (IF-4).
379
+ updateBridge.onAttach?.(service);
380
+ service.start();
381
+ })
382
+ .catch((err) => {
383
+ logger.warn('update', 'update_service_unavailable', {
384
+ reason: err instanceof Error ? err.message : String(err),
385
+ });
386
+ });
387
+ }
315
388
  // The return value MUST be captured — `waitUntilExit()` is the normal-exit
316
389
  // restore path and it cannot be registered otherwise. `dispose()` runs on
317
390
  // BOTH branches (I-7): a live `'data'` listener on stdin keeps the event loop
@@ -333,12 +406,23 @@ function runInteractive(flags, extras = {}) {
333
406
  // the event loop open so `aragon` never returns to the shell (R-15). The
334
407
  // App's unmount cleanup also calls this; `dispose()` is idempotent.
335
408
  controller.dispose();
409
+ // BOTH BRANCHES, exactly as `controller.dispose()` is (section 3.6 / P2-6),
410
+ // and idempotent for the same reason. It clears the timers and drops the
411
+ // listeners; it NEVER signals the installer child (U-3), because killing a
412
+ // running `npm install -g` is the one action here that can leave the
413
+ // user's global installation broken - and it would be triggered by the
414
+ // most ordinary event there is, the user pressing Ctrl+C. The signal path
415
+ // above calls `process.exit()` and reaches neither branch, which is
416
+ // correct and is precisely WHY U-3 matters: the detached child survives
417
+ // that exit and completes.
418
+ updateService?.dispose();
336
419
  replayTranscript();
337
420
  })
338
421
  .catch(() => {
339
422
  screen?.restore();
340
423
  disposeMouse();
341
424
  controller.dispose();
425
+ updateService?.dispose();
342
426
  });
343
427
  }
344
428
  async function runOneShot(flags, prompt, quiet) {
@@ -471,6 +555,8 @@ const CONFIG_SET_KEYS = new Set([
471
555
  ...RETRY_CONFIG_SET_KEYS,
472
556
  // `fast.*` keys are handled by `applyFastConfigSet`, same reasoning again.
473
557
  ...FAST_CONFIG_SET_KEYS,
558
+ // `update.*` keys are handled by `applyUpdateConfigSet`, same reasoning again.
559
+ ...UPDATE_CONFIG_SET_KEYS,
474
560
  ]);
475
561
  /**
476
562
  * `aragon config set maxTokens <n|auto|default>`.
@@ -546,6 +632,17 @@ function runConfigSet(key, value) {
546
632
  process.stdout.write(`Set ${key} = ${String(merged.fast[field])}\n`);
547
633
  return;
548
634
  }
635
+ const updatePatch = applyUpdateConfigSet(key, value);
636
+ if (updatePatch) {
637
+ // Echoed from the STORED value, so `config set update.checkIntervalMs 5`
638
+ // prints the clamped 900000 rather than reporting a number that was never
639
+ // written (AC-20) — the rule `team.*`, `retry.*` and `fast.*` each state
640
+ // above.
641
+ const merged = updatePersistedConfig(updatePatch);
642
+ const field = key.slice('update.'.length);
643
+ process.stdout.write(`Set ${key} = ${String(merged.update[field])}\n`);
644
+ return;
645
+ }
549
646
  const patch = {};
550
647
  switch (key) {
551
648
  case 'provider':
@@ -662,6 +759,166 @@ function runConfigSet(key, value) {
662
759
  updatePersistedConfig(patch);
663
760
  process.stdout.write(`Set ${key} = ${value}\n`);
664
761
  }
762
+ /**
763
+ * Exit codes (cli-auto-update section 4.3): `0` = up to date, or installed;
764
+ * `1` = the check or the install failed; `2` = an install source we will not
765
+ * install for (the advice line is printed); `4` = `--rollback` had nothing to
766
+ * roll back to (cli-auto-update-hardening section 6.2 / P2-8).
767
+ *
768
+ * A SOURCE WE DECLINE IS `2` AND NOT `1`, and the difference matters to the only
769
+ * consumer that reads an exit code - a script. `1` says "try again later"; `2`
770
+ * says "this machine will never auto-update, run the command we printed".
771
+ */
772
+ function updateExitCode(snapshot) {
773
+ // REASON BEFORE PHASE, and the order is the whole change (P1-1). The three
774
+ // classified failures all arrive with `phase: 'failed'` - they come from
775
+ // `recordFailure` - so leaving the phase test first, as it was, means these
776
+ // two lines can never be reached and the mapping is a silent no-op.
777
+ //
778
+ // THIS IS A BEHAVIOUR CHANGE FOR SCRIPTS AND IS CALLED ONE (R-23). A
779
+ // `blocked-by-os` install used to exit `1` ("try again later") and now exits
780
+ // `2` ("this machine will never auto-update, run the command we printed").
781
+ // That is the honest answer - on Windows the same `npm i -g` from the same
782
+ // shell fails the same way until the user closes something - and it is
783
+ // recorded in the CHANGELOG under the version that ships it.
784
+ if (snapshot.reason === 'blocked-by-os' || snapshot.reason === 'no-space')
785
+ return 2;
786
+ if (snapshot.reason === 'source-ineligible' || snapshot.reason === 'not-writable')
787
+ return 2;
788
+ if (snapshot.phase === 'failed')
789
+ return 1;
790
+ if (snapshot.reason === 'install-ineffective')
791
+ return 1;
792
+ return 0;
793
+ }
794
+ /** Human output: three lines at most (section 4.3). */
795
+ function formatUpdateResult(snapshot) {
796
+ const lines = [];
797
+ switch (snapshot.phase) {
798
+ case 'ready':
799
+ lines.push(`Installed ${snapshot.latestVersion ?? ''}. Restart aragon to apply.`);
800
+ break;
801
+ case 'available':
802
+ lines.push(snapshot.reason === 'install-ineffective'
803
+ ? `${snapshot.latestVersion ?? 'The new version'} installed somewhere else - ` +
804
+ 'check your npm prefix.'
805
+ : `${snapshot.latestVersion ?? 'A new version'} is available ` +
806
+ `(running ${snapshot.currentVersion}).`);
807
+ if (snapshot.advice)
808
+ lines.push(` ${snapshot.advice}`);
809
+ break;
810
+ case 'failed':
811
+ lines.push('Update failed. See the log for details.');
812
+ if (snapshot.advice)
813
+ lines.push(` ${snapshot.advice}`);
814
+ break;
815
+ default:
816
+ lines.push(`aragon ${snapshot.currentVersion} is up to date.`);
817
+ break;
818
+ }
819
+ if (snapshot.reason && snapshot.phase !== 'ready')
820
+ lines.push(` (${snapshot.reason})`);
821
+ return lines.slice(0, 3).join('\n');
822
+ }
823
+ /**
824
+ * `aragon update --rollback` - the MANUAL form of the boot guard's rollback
825
+ * (cli-auto-update-hardening section 6.2).
826
+ *
827
+ * It exists because a release can be BAD WITHOUT FAILING TO START, which is the
828
+ * case non-goal 2 declines to detect automatically precisely because a human
829
+ * detects it in one second. It shares `performRollback` verbatim, so the latch,
830
+ * the re-classification, the lock and the post-install verification are the same
831
+ * code the automatic path uses.
832
+ *
833
+ * Exit `4` for "nothing to roll back to" is a fourth code on a documented
834
+ * contract, so the contract's own comment above moves with it (P2-8). A rollback
835
+ * that RAN and failed is an ordinary failed install and keeps exit `1`.
836
+ */
837
+ async function runRollbackCommand() {
838
+ const { readUpdateState, updateUpdateState } = await import('./update/state.js');
839
+ const good = readUpdateState().lastGoodVersion;
840
+ if (!good) {
841
+ process.stderr.write('Nothing to roll back to: no previous version was recorded by an auto-update.\n');
842
+ return 4;
843
+ }
844
+ if (good === VERSION) {
845
+ process.stdout.write(`aragon ${VERSION} is already the last known-good version.\n`);
846
+ return 0;
847
+ }
848
+ const { performRollback } = await import('./boot/rollback.js');
849
+ const result = await performRollback(VERSION, good);
850
+ if (!result.ok) {
851
+ process.stderr.write(`Rollback to ${good} did not complete (${result.failure}).\n`);
852
+ return 1;
853
+ }
854
+ // THE NOTICE IS FOR A DOWNGRADE THE USER DID NOT ASK FOR, AND THIS ONE WAS
855
+ // TYPED. `performRollback` sets `rolledBackFrom` because it cannot tell which
856
+ // caller it has, and the field has exactly one rendering: `rolled back to <to>
857
+ // after <bad> FAILED TO START` (section 8). On this path that sentence is
858
+ // false - the version started fine, the user simply did not want it - and a
859
+ // bottom row asserting a crash that never happened is worse than no row.
860
+ //
861
+ // Clearing it here rather than teaching `performRollback` a mode keeps the
862
+ // "verbatim shared mechanism" of section 6.2 intact and adds no fifth state
863
+ // field under C-17. D-38's purpose survives: the notice exists so a SILENT
864
+ // downgrade cannot go unnoticed, and the line below is this one's notice.
865
+ // `/update status` still shows `lastgood`, and `update_rolled_back` is in the
866
+ // log either way.
867
+ updateUpdateState({ rolledBackFrom: '' });
868
+ process.stdout.write(`Rolled back to ${good}. Restart aragon to apply.\n`);
869
+ return 0;
870
+ }
871
+ /**
872
+ * `aragon update` - a deliberate, foreground check.
873
+ *
874
+ * PRINTS TO STDOUT, unlike every other update surface in this package: here the
875
+ * update IS the command's output, so C-10's "the TUI is the only legal visible
876
+ * channel" does not apply - there is no TUI.
877
+ *
878
+ * Reached through the SAME dynamic `import()` the interactive gate uses, so
879
+ * `dist/cli.js` still carries no static specifier under `update/` (AC-1).
880
+ */
881
+ async function runUpdateCommand(flags, opts) {
882
+ const config = loadConfig(flags);
883
+ getLogger().reconfigure(config.log);
884
+ const { UpdateService } = await import('./update/service.js');
885
+ const service = new UpdateService({
886
+ // `--check` is exactly `mode: 'notify'` FOR THIS RUN, and expressing it that
887
+ // way rather than with a second flag through the service means there is one
888
+ // code path deciding whether to install, not two.
889
+ config: opts.check ? { ...config.update, mode: 'notify' } : config.update,
890
+ currentVersion: VERSION,
891
+ });
892
+ try {
893
+ if (opts.rollback)
894
+ return await runRollbackCommand();
895
+ const target = opts.to?.trim();
896
+ if (target) {
897
+ // REPORTED, not silently ignored. `installNow` also refuses a malformed
898
+ // version (it is the one entry point whose target comes straight from
899
+ // argv), but there the refusal is a no-op snapshot that would exit 0 and
900
+ // tell the user they are up to date — which is a lie about the command
901
+ // they actually typed.
902
+ const { STRICT_VERSION_RE } = await import('./update/semver.js');
903
+ if (!STRICT_VERSION_RE.test(target)) {
904
+ process.stderr.write(`Not a version: "${target}". Use e.g. --to 0.6.0\n`);
905
+ return 1;
906
+ }
907
+ }
908
+ const snapshot = target
909
+ ? await service.installNow(target)
910
+ : await service.checkNow({ force: true });
911
+ process.stdout.write(opts.json ? `${JSON.stringify(snapshot)}\n` : `${formatUpdateResult(snapshot)}\n`);
912
+ return updateExitCode(snapshot);
913
+ }
914
+ catch (err) {
915
+ process.stderr.write(`update failed: ${err instanceof Error ? err.message : String(err)}\n`);
916
+ return 1;
917
+ }
918
+ finally {
919
+ service.dispose();
920
+ }
921
+ }
665
922
  // ---------------------------------------------------------------------------
666
923
  // CLI definition
667
924
  // ---------------------------------------------------------------------------
@@ -751,6 +1008,14 @@ function buildProgram() {
751
1008
  .option('--fast-model <id>', 'The fast model id (implies nothing about --fast)')
752
1009
  .option('--fast-provider <id>', 'Provider for the fast model (default: the main provider)')
753
1010
  .option('--fast-review <n|off>', 'Turns between automatic fast reviews, or "off"')
1011
+ // BOTH FORMS, positive first, for the reason recorded seven times above —
1012
+ // and this is the pair whose omission would be worst. `update.mode` is
1013
+ // PERSISTED and defaults to `'auto'`, so a lone `--no-update` would make
1014
+ // commander default `opts.update` to `true` and silently overwrite a stored
1015
+ // `off` on EVERY run that passed no flag at all: the kill switch for a
1016
+ // feature that installs software would quietly un-set itself (C-11).
1017
+ .option('--update', 'Check for and install updates in the background (the default)')
1018
+ .option('--no-update', 'Disable auto-update for this session')
754
1019
  .option('--no-skills', 'Disable the skill system entirely (no catalog, no skill tools)')
755
1020
  .option('--skill <name>', 'Force-load a skill at Level 2 for this run (repeatable)', (value, previous = []) => [...previous, value])
756
1021
  .option('--skills-yes', 'Approve skill installs for this run (CI / headless)')
@@ -881,6 +1146,22 @@ function buildProgram() {
881
1146
  const code = await runSkillsCommand(subcommand ?? 'list', argument, opts, toFlags(program.opts()), VERSION);
882
1147
  process.exitCode = code;
883
1148
  });
1149
+ // aragon update [--check] [--to <version>] [--json]
1150
+ //
1151
+ // THE GUARANTEED REPORTING SURFACE (C-12 / D-18), and the only update path
1152
+ // available to a headless user at all - `runInteractive` is the sole place the
1153
+ // background service is constructed, so without this command `aragon -p` users
1154
+ // could never update deliberately.
1155
+ program
1156
+ .command('update')
1157
+ .description('Check for a new release and install it')
1158
+ .option('--check', 'Report only; never install')
1159
+ .option('--to <version>', 'Install an exact version (an explicit escape hatch)')
1160
+ .option('--rollback', 'Reinstall the version that ran before the last auto-update')
1161
+ .option('--json', 'Machine-readable result on stdout')
1162
+ .action(async (opts) => {
1163
+ process.exitCode = await runUpdateCommand(toFlags(program.opts()), opts);
1164
+ });
884
1165
  // aragon models [--provider p]
885
1166
  program
886
1167
  .command('models')