@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
package/README.md CHANGED
@@ -117,6 +117,7 @@ Exit codes: `0` success · `1` agent/runtime error · `2` config/usage error ·
117
117
  | `/todo [on\|off\|panel on\|off\|follow <mode>\|clear\|continue]` | Todo planning: status, the on/off switch, the rail, follow-through, and picking an unfinished plan back up |
118
118
  | `/retry [show\|on\|off\|max <n>]` | API retry: the effective ladder, the on/off switch, the retry count. Takes effect **in the running session**, children included |
119
119
  | `/fast [on\|off\|model <id>\|provider <id>\|same\|review <n\|off>\|delegate on\|off]` | Fast model tier: status and this session's totals, the on/off switch, which model, the review cadence |
120
+ | `/update [status\|now\|skip\|off]` | Auto-update: what is running, what is available, where it came from, when it looks again. `now` forces a check past the throttle; `skip` declines the offered version until a newer one appears |
120
121
  | `/theme <auto\|warm\|cool\|light>` | Switch the color theme live (persisted) |
121
122
  | `/expand [n]` | Expand / collapse the n-th-from-last (default last) tool card |
122
123
  | `/copy` | Copy the last answer to the clipboard |
@@ -1095,6 +1096,8 @@ env / `.env` → CLI flags**.
1095
1096
  `ARAGON_TODO_FOLLOW` (`notify` / `auto` / `off`),
1096
1097
  `ARAGON_RETRY` (`0` disables API retry), `ARAGON_RETRY_MAX` (retry count; `0`
1097
1098
  also disables),
1099
+ `ARAGON_UPDATE` (`0` / `off` disables auto-update, `notify` reports without
1100
+ installing), `ARAGON_UPDATE_REGISTRY` (registry base URL for the update check),
1098
1101
  `ARAGON_SKILLS` (`0` disables), `ARAGON_SKILLS_PATH`,
1099
1102
  `ARAGON_SKILLS_DISABLED` (comma-separated names), `ARAGON_LOG_LEVEL`,
1100
1103
  `ARAGON_LOG_FILE` (`0` disables), `ARAGON_LOG_DIR`,
@@ -1227,6 +1230,124 @@ greater than it, and the two are resolved together rather than left to collide.
1227
1230
  These are additive: a config file written by an earlier version loads
1228
1231
  unchanged, with no migration and no version bump.
1229
1232
 
1233
+ ## Auto-update
1234
+
1235
+ `aragon` checks npm for a newer release in the background, installs it silently
1236
+ into the same global installation you already have, and tells you on one line
1237
+ above the composer:
1238
+
1239
+ ```
1240
+ * 0.6.0 installed - restart aragon to apply
1241
+ ```
1242
+
1243
+ **The running session is never touched.** The new version lands on disk and
1244
+ takes effect the next time you start `aragon`. A restart in the middle of a turn
1245
+ would destroy a live run, a subagent dispatch and an open plan review, so the
1246
+ line waits and you decide when. Nothing else about the check is visible: it
1247
+ happens at most once every four hours, at most once per machine per interval,
1248
+ and every failure is silent. Only after three consecutive failures does the line
1249
+ say anything, and then it says something you can act on.
1250
+
1251
+ **It only ever installs for one kind of installation**: a global `npm i -g`,
1252
+ where nothing else claims to own the version. If `aragon` came from pnpm, yarn,
1253
+ bun or volta it will not run a command on your behalf — it prints the right one
1254
+ for your manager and stops. If it came from `npx`, from a project's
1255
+ `node_modules`, or from a clone of this repo it says **nothing at all**: an
1256
+ `npx` run resolved `latest` seconds ago, a project's manifest deliberately pins
1257
+ the version, and a checkout is not out of date. A global directory it cannot
1258
+ write to (the usual Linux case) is detected before anything is spawned.
1259
+
1260
+ ### The four keys
1261
+
1262
+ | Key | Default | Meaning |
1263
+ | --- | --- | --- |
1264
+ | `update.mode` | `auto` | `auto` installs · `notify` only reports · `off` disables the subsystem entirely |
1265
+ | `update.checkIntervalMs` | `14400000` (4 h) | Minimum time between registry checks, across every `aragon` on this machine |
1266
+ | `update.registry` | `''` | Registry base URL. Empty derives it: `ARAGON_UPDATE_REGISTRY`, then `npm_config_registry`, then npmjs |
1267
+ | `update.distTag` | `latest` | The dist-tag to track |
1268
+
1269
+ ```bash
1270
+ aragon config set update.mode notify # tell me, do not install
1271
+ aragon config set update.mode off # never check at all
1272
+ ARAGON_UPDATE=0 aragon # off for this run
1273
+ aragon --no-update # the same, as a flag
1274
+ aragon update --check # ask right now, install nothing
1275
+ aragon update # ask right now, install if eligible
1276
+ aragon update --to 0.5.8 # go back to a specific version
1277
+ aragon update --rollback # undo the last auto-update
1278
+ ```
1279
+
1280
+ `aragon update` is also the answer for headless users: the background updater is
1281
+ only ever constructed for an interactive session, so `aragon -p "…"`,
1282
+ `aragon config set`, CI (any truthy `CI`) and any non-TTY execute **zero** lines
1283
+ of update code.
1284
+
1285
+ ### If an update breaks the CLI
1286
+
1287
+ The failure an auto-updater has to answer for is the one where the version it
1288
+ installed will not start — an undeclared dependency, a Node API that moved, a
1289
+ syntax error past the transpile target. None of those reach any line inside
1290
+ `main()`, so no amount of care there helps.
1291
+
1292
+ So the `aragon` command is a small launcher that runs **before** the CLI itself.
1293
+ It reads one file, and on almost every launch that is all it does. But if the
1294
+ version it is about to start is one **this updater installed**, and that version
1295
+ has already exited non-zero twice, it reinstalls the version you were running
1296
+ before the update and marks the bad one so nothing puts it back:
1297
+
1298
+ ```
1299
+ aragon 0.6.0 failed to start twice; rolling back to 0.5.9.
1300
+ ```
1301
+
1302
+ It boots anyway — if the crash was transient, refusing to start would lock you
1303
+ out on a guess. The next session tells you once what happened, and then stops
1304
+ mentioning it. A **newer** release clears the mark and installs normally: you
1305
+ were never opted out of updates, only out of the loop.
1306
+
1307
+ Three things this deliberately does not do. It never touches a version **you**
1308
+ installed yourself — `npm i -g @aragon-agent/cli@something` is your decision, and
1309
+ an updater that silently reverts it is a worse actor than one that does nothing.
1310
+ It does not roll back a release that starts and is merely *wrong*; that is not
1311
+ detectable from inside, and a heuristic that guessed would eventually revert a
1312
+ working version on a bad day. Use `aragon update --rollback` for that — it is the
1313
+ same mechanism, run deliberately. And **`update.mode: off` disables this too**:
1314
+ the guard is armed only by an auto-install, so a machine that never auto-installs
1315
+ never arms it. That is the honest reading of the kill switch — it turns off the
1316
+ whole subsystem, recovery included.
1317
+
1318
+ ### Behind a proxy
1319
+
1320
+ Node's own `fetch` ignores `HTTP_PROXY`, so on a proxied machine the direct check
1321
+ simply fails. When it does, `aragon` asks **the npm you already have** instead,
1322
+ which honours `proxy`, `https-proxy` and your `.npmrc`. It runs only after the
1323
+ direct request has already failed, so on an ordinary machine it never runs at
1324
+ all. `/update status` shows `probe: npm` when this is what answered.
1325
+
1326
+ No credentials are read or forwarded by either path — we run the command you
1327
+ could have typed and read one version string out of its JSON.
1328
+
1329
+ ### The trade this makes
1330
+
1331
+ Auto-update widens the window in which a compromised release could reach you
1332
+ without a human deciding to fetch it. That is a real cost, and it is worth
1333
+ stating plainly rather than burying:
1334
+
1335
+ - npm verifies each package against the registry's `dist.integrity` SRI hash, so
1336
+ what is installed is what was published. That protects the transport, not the
1337
+ publisher.
1338
+ - Only the `latest` dist-tag is tracked, prereleases are never installed onto a
1339
+ stable version, and a release its own author has `npm deprecate`d is skipped.
1340
+ - `update.mode: off` and `ARAGON_UPDATE=0` are kill switches, and they are
1341
+ documented rather than incidental.
1342
+ - Organisations that want a review gate point `update.registry` at a mirror they
1343
+ control.
1344
+
1345
+ No credentials are ever sent with the check. The updater does not read
1346
+ `_authToken` out of your `.npmrc` — an updater that attached a token to a
1347
+ user-configurable URL would be a credential-exfiltration primitive the moment
1348
+ that URL was mis-set. Private-registry users should point `update.registry` at a
1349
+ mirror they can read anonymously, or turn the feature off.
1350
+
1230
1351
  ## Files & logs
1231
1352
 
1232
1353
  Everything AragonAgent keeps for you lives under one directory in your home:
@@ -1237,6 +1358,8 @@ Everything AragonAgent keeps for you lives under one directory in your home:
1237
1358
  ├── config.json.bak written by `aragon config edit` before it opens
1238
1359
  ├── prompt-history.jsonl prompts you submitted, for ↑ recall (0600 on POSIX)
1239
1360
  ├── state.json UI bookkeeping: submit count, one-shot notices
1361
+ ├── update-state.json auto-update: last check, pending restart, skipped version,
1362
+ │ and the boot guard's rollback bookkeeping
1240
1363
  ├── logs/
1241
1364
  │ ├── aragon-2026-07-27.log today, JSON Lines
1242
1365
  │ └── aragon-2026-07-27.1.log rotated at log.maxFileBytes
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The boot guard (cli-auto-update-hardening H1 / section 5.1).
3
+ *
4
+ * ASCII ONLY - this tree is inside the glyph scanner's scope (C-19).
5
+ *
6
+ * WHY THIS LIVES BELOW `cli.tsx` AND NOT INSIDE `main()` (D-26). The instinct is
7
+ * to put crash recovery in `cli.tsx::main()`. That covers a crash AFTER the
8
+ * module graph loads and misses the class of failure that actually bricks npm
9
+ * CLIs: `dist/cli.js` statically imports ~60 modules, and a release that fails to
10
+ * IMPORT never reaches any line anyone wrote inside `main()`. An undeclared
11
+ * dependency, a Node API that moved, a syntax error past the transpile target -
12
+ * all of them raise before `main` exists.
13
+ *
14
+ * ==========================================================================
15
+ * THE IMPORT LIST HERE IS A CONSTRAINT, NOT A COINCIDENCE (section 4 rule 2).
16
+ *
17
+ * This module may import `node:*`, `../update/state.js` and its own sibling in
18
+ * `boot/` - AND NOTHING ELSE. Every module it pulls in is a module that can
19
+ * crash before the guard that exists to survive crashes has run.
20
+ *
21
+ * IN PARTICULAR IT MUST NOT IMPORT `logging/logger.js`. `getLogger()` resolves
22
+ * config on its way to a sink, so wiring it in here would drag the whole config
23
+ * graph into the one module that has to survive a broken build - which is
24
+ * exactly the failure this guard is for. So the guard is SILENT BY CONSTRUCTION
25
+ * (D-42); the two facts worth having after the event are recorded by
26
+ * `rollback.ts` and by `UpdateService`, both of which already have a logger.
27
+ *
28
+ * THE CHAIN IS NOT `node:*`-ONLY AND THE LIST MUST NOT BE READ AS CLAIMING SO
29
+ * (P1-2). `../update/state.js` imports `../config/app-paths.js`, which imports
30
+ * and CALLS `env-paths` at module scope. That third-party module is in this
31
+ * graph, which is why `launcher.ts` imports this file DYNAMICALLY and inside a
32
+ * `try` - so a release whose `node_modules` lost `env-paths` degrades to "no
33
+ * guard, and the CLI reports its own crash normally" rather than to an
34
+ * `ERR_MODULE_NOT_FOUND` from the bin entry. The residual hole is real and is
35
+ * stated in section 5.1.5 rather than argued away; this list is what keeps it
36
+ * from growing.
37
+ * ==========================================================================
38
+ */
39
+ import { type UpdateState } from '../update/state.js';
40
+ /**
41
+ * `<root>/package.json`'s `version`, or `''`.
42
+ *
43
+ * Resolves the root the way `install-source.ts` does - `dirname(fileURLToPath(
44
+ * import.meta.url))` then up two - which from `dist/boot/` lands on the package
45
+ * root exactly as it does from `dist/update/`. DUPLICATED RATHER THAN IMPORTED
46
+ * because the import list above keeps `install-source.js` out of this graph; it
47
+ * is four lines of `node:*` with no path NAME in it (P2-4).
48
+ *
49
+ * The level count is load-bearing for the same reason it is there: this package
50
+ * is compiled by plain `tsc` with `rootDir: ./src` and NO BUNDLER (C-16), so
51
+ * `src/boot/guard.ts` really does land at `dist/boot/guard.js`.
52
+ */
53
+ export declare function readOwnVersion(): string;
54
+ /** Injected wholesale by the tests; `runBootGuard()` uses the real ones. */
55
+ export interface BootGuardDeps {
56
+ readState?: () => UpdateState;
57
+ writeState?: (patch: Partial<Omit<UpdateState, 'schema'>>) => unknown;
58
+ version?: string;
59
+ /** Called instead of `performRollback`, so no test spawns npm. */
60
+ rollback?: (bad: string, good: string) => void;
61
+ onExit?: (listener: (code: number) => void) => void;
62
+ writeStderr?: (text: string) => void;
63
+ }
64
+ /**
65
+ * Arm, count, disarm, or decide a rollback. Called once, from `launcher.ts`,
66
+ * before `dist/cli.js` is imported.
67
+ *
68
+ * READ THE ORDER AGAINST THE FAILURE IT DEFENDS:
69
+ *
70
+ * - `autoInstalledVersion !== version` IS THE ARMING CONDITION AND IT IS FIRST.
71
+ * That field is written by `performInstall` on success and cleared by
72
+ * `markBootHealthy`, so the guard is armed for exactly the window between "we
73
+ * installed X" and "X has been seen to start", and for no other launch of any
74
+ * version. Steady state - every launch on every machine except the first one
75
+ * after an auto-update - is ONE `readFileSync` of ~300 bytes and one string
76
+ * compare, with NO WRITE AT ALL. Reordering this check after anything else
77
+ * costs a write on every `aragon --version`.
78
+ *
79
+ * - THE GUARD ONLY CLEANS UP DAMAGE WE CAUSED (D-29). If a user's own
80
+ * `npm i -g @aragon-agent/cli@broken` crashes, that is their install and
81
+ * their decision; an updater that silently reverts it is a worse actor than
82
+ * one that does nothing. It also bounds the blast radius of a bug in the
83
+ * guard itself to one unnecessary reinstall of a version the user was running
84
+ * an hour ago.
85
+ *
86
+ * - THE THRESHOLD IS TESTED BEFORE THIS LAUNCH CAN CONTRIBUTE, so `2` means
87
+ * "two PREVIOUS launches of X exited non-zero" and the rollback happens on the
88
+ * third (D-30).
89
+ *
90
+ * - THE ROLLBACK DOES NOT ABORT THE BOOT (D-31). Refusing to start would be the
91
+ * wrong trade: if the crash was transient the user is now locked out of a
92
+ * working CLI by our guess. The detached child completes regardless, so the
93
+ * worst case is one more crash followed by a good launch.
94
+ *
95
+ * - THE COUNTER MOVES IN THE EXIT HOOK AND ONLY ON A NON-ZERO CODE (D-30a /
96
+ * P1-5). See `UpdateState.bootFailures`. The cost is stated rather than
97
+ * hidden: a release that dies WITHOUT running exit handlers (`SIGKILL`, a
98
+ * native segfault, `process.abort()`) is never counted and never rolled back.
99
+ * For a pure-JS CLI whose target failure is a module that will not import -
100
+ * which raises, and therefore exits `1` through the launcher's `.catch` -
101
+ * that is the right side of the trade.
102
+ *
103
+ * NEVER THROWS. `launcher.ts` wraps the call anyway, because a guard that throws
104
+ * must never be the reason the CLI will not start.
105
+ */
106
+ export declare function runBootGuard(deps?: BootGuardDeps): void;
107
+ /**
108
+ * Disarm the guard and record this version as the one to roll back TO.
109
+ *
110
+ * Called from two places, and both matter:
111
+ *
112
+ * - `cli.tsx::runInteractive`, immediately after `render()` returns - the
113
+ * earliest moment at which "this build starts" is PROVEN: the module graph
114
+ * loaded, config resolved, Ink mounted. This is the primary, and it is what
115
+ * makes a long session that is later `SIGKILL`ed (terminal window closed,
116
+ * machine slept badly, OOM killer) still count as healthy.
117
+ * - the exit hook installed by `runBootGuard`, when the code is `0` - the
118
+ * backstop for `aragon -p`, `aragon config set`, `aragon --version` and every
119
+ * other path that never mounts Ink.
120
+ *
121
+ * IDEMPOTENT through the first-line guard, which is also the fast path: on every
122
+ * ordinary launch this is one read and one string compare.
123
+ *
124
+ * `lastGoodVersion` is set here as well as at install time, deliberately: a
125
+ * machine whose state file was deleted between the install and the next launch
126
+ * converges to a correct rollback target after one healthy run.
127
+ *
128
+ * IT DOES NOT TOUCH `rolledBackFrom`, AND IT MUST NOT (P0-1e). An earlier draft
129
+ * had this function clear the rollback notice; it could never have done so,
130
+ * because in the session AFTER a rollback `autoInstalledVersion` is `''` and this
131
+ * function returns on its first line before touching anything. The notice is
132
+ * consumed by `UpdateService.start()` instead, which is also the only place that
133
+ * can decide it has actually been shown (D-40).
134
+ */
135
+ export declare function markBootHealthy(version: string, deps?: BootGuardDeps): void;
@@ -0,0 +1,218 @@
1
+ /**
2
+ * The boot guard (cli-auto-update-hardening H1 / section 5.1).
3
+ *
4
+ * ASCII ONLY - this tree is inside the glyph scanner's scope (C-19).
5
+ *
6
+ * WHY THIS LIVES BELOW `cli.tsx` AND NOT INSIDE `main()` (D-26). The instinct is
7
+ * to put crash recovery in `cli.tsx::main()`. That covers a crash AFTER the
8
+ * module graph loads and misses the class of failure that actually bricks npm
9
+ * CLIs: `dist/cli.js` statically imports ~60 modules, and a release that fails to
10
+ * IMPORT never reaches any line anyone wrote inside `main()`. An undeclared
11
+ * dependency, a Node API that moved, a syntax error past the transpile target -
12
+ * all of them raise before `main` exists.
13
+ *
14
+ * ==========================================================================
15
+ * THE IMPORT LIST HERE IS A CONSTRAINT, NOT A COINCIDENCE (section 4 rule 2).
16
+ *
17
+ * This module may import `node:*`, `../update/state.js` and its own sibling in
18
+ * `boot/` - AND NOTHING ELSE. Every module it pulls in is a module that can
19
+ * crash before the guard that exists to survive crashes has run.
20
+ *
21
+ * IN PARTICULAR IT MUST NOT IMPORT `logging/logger.js`. `getLogger()` resolves
22
+ * config on its way to a sink, so wiring it in here would drag the whole config
23
+ * graph into the one module that has to survive a broken build - which is
24
+ * exactly the failure this guard is for. So the guard is SILENT BY CONSTRUCTION
25
+ * (D-42); the two facts worth having after the event are recorded by
26
+ * `rollback.ts` and by `UpdateService`, both of which already have a logger.
27
+ *
28
+ * THE CHAIN IS NOT `node:*`-ONLY AND THE LIST MUST NOT BE READ AS CLAIMING SO
29
+ * (P1-2). `../update/state.js` imports `../config/app-paths.js`, which imports
30
+ * and CALLS `env-paths` at module scope. That third-party module is in this
31
+ * graph, which is why `launcher.ts` imports this file DYNAMICALLY and inside a
32
+ * `try` - so a release whose `node_modules` lost `env-paths` degrades to "no
33
+ * guard, and the CLI reports its own crash normally" rather than to an
34
+ * `ERR_MODULE_NOT_FOUND` from the bin entry. The residual hole is real and is
35
+ * stated in section 5.1.5 rather than argued away; this list is what keeps it
36
+ * from growing.
37
+ * ==========================================================================
38
+ */
39
+ import { readFileSync } from 'node:fs';
40
+ import { dirname, resolve } from 'node:path';
41
+ import process from 'node:process';
42
+ import { fileURLToPath } from 'node:url';
43
+ import { readUpdateState, updateUpdateState } from '../update/state.js';
44
+ import { performRollback } from './rollback.js';
45
+ /**
46
+ * Non-zero exits of a version we installed before it is rolled back.
47
+ *
48
+ * DUPLICATED FROM `UPDATE_LIMITS.crashesBeforeRollback` RATHER THAN IMPORTED,
49
+ * and the duplication is the point: `update/limits.ts` is pure and dependency-
50
+ * free today, but it is a shared table that anyone may add an import to, and the
51
+ * import list above is worth more than one deduplicated integer. It is a bare
52
+ * number with no path and no name in it, so it is not the duplication
53
+ * `config/app-paths.ts`'s header forbids.
54
+ */
55
+ const CRASHES_BEFORE_ROLLBACK = 2;
56
+ let cachedVersion = null;
57
+ /**
58
+ * `<root>/package.json`'s `version`, or `''`.
59
+ *
60
+ * Resolves the root the way `install-source.ts` does - `dirname(fileURLToPath(
61
+ * import.meta.url))` then up two - which from `dist/boot/` lands on the package
62
+ * root exactly as it does from `dist/update/`. DUPLICATED RATHER THAN IMPORTED
63
+ * because the import list above keeps `install-source.js` out of this graph; it
64
+ * is four lines of `node:*` with no path NAME in it (P2-4).
65
+ *
66
+ * The level count is load-bearing for the same reason it is there: this package
67
+ * is compiled by plain `tsc` with `rootDir: ./src` and NO BUNDLER (C-16), so
68
+ * `src/boot/guard.ts` really does land at `dist/boot/guard.js`.
69
+ */
70
+ export function readOwnVersion() {
71
+ if (cachedVersion !== null)
72
+ return cachedVersion;
73
+ try {
74
+ const here = dirname(fileURLToPath(import.meta.url));
75
+ const parsed = JSON.parse(readFileSync(resolve(here, '..', '..', 'package.json'), 'utf-8'));
76
+ cachedVersion = typeof parsed.version === 'string' ? parsed.version : '';
77
+ }
78
+ catch {
79
+ cachedVersion = '';
80
+ }
81
+ return cachedVersion;
82
+ }
83
+ /**
84
+ * Arm, count, disarm, or decide a rollback. Called once, from `launcher.ts`,
85
+ * before `dist/cli.js` is imported.
86
+ *
87
+ * READ THE ORDER AGAINST THE FAILURE IT DEFENDS:
88
+ *
89
+ * - `autoInstalledVersion !== version` IS THE ARMING CONDITION AND IT IS FIRST.
90
+ * That field is written by `performInstall` on success and cleared by
91
+ * `markBootHealthy`, so the guard is armed for exactly the window between "we
92
+ * installed X" and "X has been seen to start", and for no other launch of any
93
+ * version. Steady state - every launch on every machine except the first one
94
+ * after an auto-update - is ONE `readFileSync` of ~300 bytes and one string
95
+ * compare, with NO WRITE AT ALL. Reordering this check after anything else
96
+ * costs a write on every `aragon --version`.
97
+ *
98
+ * - THE GUARD ONLY CLEANS UP DAMAGE WE CAUSED (D-29). If a user's own
99
+ * `npm i -g @aragon-agent/cli@broken` crashes, that is their install and
100
+ * their decision; an updater that silently reverts it is a worse actor than
101
+ * one that does nothing. It also bounds the blast radius of a bug in the
102
+ * guard itself to one unnecessary reinstall of a version the user was running
103
+ * an hour ago.
104
+ *
105
+ * - THE THRESHOLD IS TESTED BEFORE THIS LAUNCH CAN CONTRIBUTE, so `2` means
106
+ * "two PREVIOUS launches of X exited non-zero" and the rollback happens on the
107
+ * third (D-30).
108
+ *
109
+ * - THE ROLLBACK DOES NOT ABORT THE BOOT (D-31). Refusing to start would be the
110
+ * wrong trade: if the crash was transient the user is now locked out of a
111
+ * working CLI by our guess. The detached child completes regardless, so the
112
+ * worst case is one more crash followed by a good launch.
113
+ *
114
+ * - THE COUNTER MOVES IN THE EXIT HOOK AND ONLY ON A NON-ZERO CODE (D-30a /
115
+ * P1-5). See `UpdateState.bootFailures`. The cost is stated rather than
116
+ * hidden: a release that dies WITHOUT running exit handlers (`SIGKILL`, a
117
+ * native segfault, `process.abort()`) is never counted and never rolled back.
118
+ * For a pure-JS CLI whose target failure is a module that will not import -
119
+ * which raises, and therefore exits `1` through the launcher's `.catch` -
120
+ * that is the right side of the trade.
121
+ *
122
+ * NEVER THROWS. `launcher.ts` wraps the call anyway, because a guard that throws
123
+ * must never be the reason the CLI will not start.
124
+ */
125
+ export function runBootGuard(deps = {}) {
126
+ try {
127
+ const read = deps.readState ?? readUpdateState;
128
+ const write = deps.writeState ?? updateUpdateState;
129
+ const version = deps.version ?? readOwnVersion();
130
+ if (!version)
131
+ return;
132
+ const state = read();
133
+ // FAST PATH: not our install, or already seen healthy. No write, no hook.
134
+ if (state.autoInstalledVersion !== version)
135
+ return;
136
+ if (state.bootFailures >= CRASHES_BEFORE_ROLLBACK &&
137
+ state.lastGoodVersion &&
138
+ state.lastGoodVersion !== version) {
139
+ // WRITTEN HERE, SYNCHRONOUSLY, AND NOT INSIDE `performRollback` (P2-9).
140
+ // That function is `async` and its first statement is a dynamic `import`,
141
+ // which yields - so a line written in there would race the
142
+ // `await import('./cli.js')` that follows and could land below whatever
143
+ // the CLI prints. Written before the call, the ordering is a property of
144
+ // the code rather than of the module loader's timing. C-10 permits this
145
+ // one visible message because the TUI is not mounted yet: there is no
146
+ // fixed frame to shift.
147
+ const stderr = deps.writeStderr ?? ((text) => process.stderr.write(text));
148
+ stderr(`aragon ${version} failed to start twice; rolling back to ${state.lastGoodVersion}.\n`);
149
+ const roll = deps.rollback ??
150
+ ((bad, good) => {
151
+ void performRollback(bad, good);
152
+ });
153
+ roll(version, state.lastGoodVersion);
154
+ // No exit hook: the rollback has already disarmed the guard, and this
155
+ // launch's own crash must not be counted against a cycle that is over.
156
+ return;
157
+ }
158
+ const on = deps.onExit ?? ((listener) => process.on('exit', listener));
159
+ on((code) => {
160
+ try {
161
+ if (code === 0)
162
+ markBootHealthy(version, { readState: read, writeState: write });
163
+ else
164
+ write({ bootFailures: state.bootFailures + 1 });
165
+ }
166
+ catch {
167
+ // An exit hook that throws prints an unactionable stack over whatever
168
+ // the CLI last said. The bookkeeping is not worth that.
169
+ }
170
+ });
171
+ }
172
+ catch {
173
+ // See the contract above: never throws.
174
+ }
175
+ }
176
+ /**
177
+ * Disarm the guard and record this version as the one to roll back TO.
178
+ *
179
+ * Called from two places, and both matter:
180
+ *
181
+ * - `cli.tsx::runInteractive`, immediately after `render()` returns - the
182
+ * earliest moment at which "this build starts" is PROVEN: the module graph
183
+ * loaded, config resolved, Ink mounted. This is the primary, and it is what
184
+ * makes a long session that is later `SIGKILL`ed (terminal window closed,
185
+ * machine slept badly, OOM killer) still count as healthy.
186
+ * - the exit hook installed by `runBootGuard`, when the code is `0` - the
187
+ * backstop for `aragon -p`, `aragon config set`, `aragon --version` and every
188
+ * other path that never mounts Ink.
189
+ *
190
+ * IDEMPOTENT through the first-line guard, which is also the fast path: on every
191
+ * ordinary launch this is one read and one string compare.
192
+ *
193
+ * `lastGoodVersion` is set here as well as at install time, deliberately: a
194
+ * machine whose state file was deleted between the install and the next launch
195
+ * converges to a correct rollback target after one healthy run.
196
+ *
197
+ * IT DOES NOT TOUCH `rolledBackFrom`, AND IT MUST NOT (P0-1e). An earlier draft
198
+ * had this function clear the rollback notice; it could never have done so,
199
+ * because in the session AFTER a rollback `autoInstalledVersion` is `''` and this
200
+ * function returns on its first line before touching anything. The notice is
201
+ * consumed by `UpdateService.start()` instead, which is also the only place that
202
+ * can decide it has actually been shown (D-40).
203
+ */
204
+ export function markBootHealthy(version, deps = {}) {
205
+ try {
206
+ if (!version)
207
+ return;
208
+ const read = deps.readState ?? readUpdateState;
209
+ const write = deps.writeState ?? updateUpdateState;
210
+ const state = read();
211
+ if (state.autoInstalledVersion !== version)
212
+ return;
213
+ write({ autoInstalledVersion: '', bootFailures: 0, lastGoodVersion: version });
214
+ }
215
+ catch {
216
+ // Bookkeeping. Never a reason to fail a session that is otherwise healthy.
217
+ }
218
+ }
@@ -0,0 +1,86 @@
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 { type UpdateState } from '../update/state.js';
20
+ import type { InstallLockHandle } from '../update/install-lock.js';
21
+ import type { InstallResult, RunNpmInstallInput } from '../update/installer.js';
22
+ /** What stopped us, when something did. `undefined` on the happy path. */
23
+ export type RollbackFailure = 'no-package-root' | 'source-ineligible' | 'not-writable' | 'locked' | 'install-failed' | 'error';
24
+ export interface RollbackResult {
25
+ ok: boolean;
26
+ failure?: RollbackFailure;
27
+ }
28
+ /** What this module resolved about the machine, injected wholesale by tests. */
29
+ export interface RollbackTarget {
30
+ root: string | null;
31
+ packageName: string;
32
+ autoInstallable: boolean;
33
+ writable: boolean;
34
+ }
35
+ export interface RollbackDeps {
36
+ readState?: () => UpdateState;
37
+ writeState?: (patch: Partial<Omit<UpdateState, 'schema'>>) => unknown;
38
+ /** Injected together; when all four are present nothing is imported at all. */
39
+ resolveTarget?: () => Promise<RollbackTarget>;
40
+ acquireLock?: () => InstallLockHandle | null;
41
+ install?: (input: RunNpmInstallInput) => Promise<InstallResult>;
42
+ log?: (level: 'info' | 'warn', msg: string, data: Record<string, unknown>) => void;
43
+ }
44
+ /**
45
+ * Reinstall `good` and latch `bad` so nothing puts it back.
46
+ *
47
+ * THE ORDER OF OPERATIONS IS LOAD-BEARING.
48
+ *
49
+ * 1. LATCH FIRST, INSTALL SECOND. If the process dies between the latch and the
50
+ * spawn, the machine is left running a broken version that WILL NOT BE
51
+ * REINSTALLED - recoverable by hand. In the other order, a death between the
52
+ * spawn and the latch leaves a good version installed with no latch, and the
53
+ * next check reinstalls the bad one.
54
+ *
55
+ * `skippedVersion` is part of that write and is the reason it works across
56
+ * versions (C-17 / D-28): `readUpdateState` rebuilds from a fixed field list,
57
+ * so an OLDER `aragon` writing this file erases every field it does not know.
58
+ * `skippedVersion` is schema-1 and every shipped version honours it through
59
+ * `decideUpdate`. Per section 1.2 the ordinary rollback target is itself an H1
60
+ * build and preserves all four new fields; this covers the cases that
61
+ * argument does not reach - a hand-edited state file, a user who manually
62
+ * installs a pre-H1 version, and any future field that matters across the
63
+ * boundary.
64
+ *
65
+ * `rolledBackFrom` IS WRITTEN HERE AND NOWHERE ELSE (P0-1d). It is consumed
66
+ * exactly once, by `UpdateService.start()`, which is the only thing that can
67
+ * decide the notice has actually been shown.
68
+ *
69
+ * 2. RE-CLASSIFY BEFORE SPAWNING. The machine may have changed since the
70
+ * install; we do not run `npm i -g` on a tree we would not have installed
71
+ * into.
72
+ *
73
+ * 3. ONE NON-BLOCKING LOCK ATTEMPT (U-5b). If another process holds it, return:
74
+ * it is either installing or already rolling back, and two concurrent
75
+ * `npm i -g` on one prefix is the one thing the lock exists to stop.
76
+ *
77
+ * 4. `runNpmInstall` VERBATIM. It already validates the version against
78
+ * `STRICT_VERSION_RE`, spawns detached through `process.execPath`, writes to
79
+ * a real fd and verifies the classified root afterwards (U-6). A rollback is
80
+ * an install; it does not deserve a second code path.
81
+ *
82
+ * The stderr line belongs to `runBootGuard` and is written before this is called
83
+ * (P2-9). NEVER THROWS: the guard invokes it without `await` and cannot handle a
84
+ * rejection.
85
+ */
86
+ export declare function performRollback(bad: string, good: string, deps?: RollbackDeps): Promise<RollbackResult>;