@aragon-agent/cli 0.5.9 → 0.5.11
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.
- package/CHANGELOG.md +1157 -1026
- package/README.md +123 -0
- package/dist/boot/guard.d.ts +135 -0
- package/dist/boot/guard.js +218 -0
- package/dist/boot/rollback.d.ts +86 -0
- package/dist/boot/rollback.js +185 -0
- package/dist/cli.js +376 -2
- package/dist/commands/builtins.js +118 -0
- package/dist/commands/perf.d.ts +5 -1
- package/dist/commands/perf.js +43 -1
- package/dist/commands/registry.d.ts +17 -0
- package/dist/config/app-paths.d.ts +31 -0
- package/dist/config/app-paths.js +37 -0
- package/dist/config/cli-commands.d.ts +27 -1
- package/dist/config/cli-commands.js +51 -2
- package/dist/config/env.d.ts +11 -1
- package/dist/config/env.js +58 -1
- package/dist/config/load.d.ts +30 -0
- package/dist/config/load.js +56 -1
- package/dist/config/schema.d.ts +91 -0
- package/dist/config/schema.js +107 -0
- package/dist/config/store.js +13 -1
- package/dist/launcher.d.ts +14 -0
- package/dist/launcher.js +43 -0
- package/dist/logging/logger.d.ts +1 -1
- package/dist/ui/App.d.ts +11 -1
- package/dist/ui/App.js +88 -2
- package/dist/ui/BottomStatusRow.d.ts +25 -3
- package/dist/ui/BottomStatusRow.js +10 -7
- package/dist/ui/PromptInput.d.ts +10 -0
- package/dist/ui/PromptInput.js +41 -48
- package/dist/ui/UpdateLine.d.ts +44 -0
- package/dist/ui/UpdateLine.js +129 -0
- package/dist/ui/editor-reducer.d.ts +62 -0
- package/dist/ui/editor-reducer.js +90 -0
- package/dist/ui/frame-differ.d.ts +95 -0
- package/dist/ui/frame-differ.js +230 -0
- package/dist/ui/stdout-frame-writer.d.ts +41 -0
- package/dist/ui/stdout-frame-writer.js +102 -0
- package/dist/update/classify-failure.d.ts +32 -0
- package/dist/update/classify-failure.js +60 -0
- package/dist/update/install-lock.d.ts +55 -0
- package/dist/update/install-lock.js +184 -0
- package/dist/update/install-source.d.ts +97 -0
- package/dist/update/install-source.js +218 -0
- package/dist/update/installer.d.ts +108 -0
- package/dist/update/installer.js +242 -0
- package/dist/update/limits.d.ts +111 -0
- package/dist/update/limits.js +111 -0
- package/dist/update/npm-view.d.ts +62 -0
- package/dist/update/npm-view.js +133 -0
- package/dist/update/registry.d.ts +52 -0
- package/dist/update/registry.js +161 -0
- package/dist/update/semver.d.ts +89 -0
- package/dist/update/semver.js +295 -0
- package/dist/update/service.d.ts +193 -0
- package/dist/update/service.js +636 -0
- package/dist/update/state.d.ts +103 -0
- package/dist/update/state.js +142 -0
- package/dist/update/types.d.ts +169 -0
- package/dist/update/types.js +68 -0
- 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>;
|