@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.
- 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 +283 -2
- package/dist/commands/builtins.js +118 -0
- 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 +10 -1
- package/dist/config/env.js +41 -1
- package/dist/config/load.d.ts +17 -0
- package/dist/config/load.js +25 -1
- package/dist/config/schema.d.ts +56 -0
- package/dist/config/schema.js +81 -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/UpdateLine.d.ts +44 -0
- package/dist/ui/UpdateLine.js +129 -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
|
@@ -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
|
-
|
|
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')
|