@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.
Files changed (62) 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 +376 -2
  8. package/dist/commands/builtins.js +118 -0
  9. package/dist/commands/perf.d.ts +5 -1
  10. package/dist/commands/perf.js +43 -1
  11. package/dist/commands/registry.d.ts +17 -0
  12. package/dist/config/app-paths.d.ts +31 -0
  13. package/dist/config/app-paths.js +37 -0
  14. package/dist/config/cli-commands.d.ts +27 -1
  15. package/dist/config/cli-commands.js +51 -2
  16. package/dist/config/env.d.ts +11 -1
  17. package/dist/config/env.js +58 -1
  18. package/dist/config/load.d.ts +30 -0
  19. package/dist/config/load.js +56 -1
  20. package/dist/config/schema.d.ts +91 -0
  21. package/dist/config/schema.js +107 -0
  22. package/dist/config/store.js +13 -1
  23. package/dist/launcher.d.ts +14 -0
  24. package/dist/launcher.js +43 -0
  25. package/dist/logging/logger.d.ts +1 -1
  26. package/dist/ui/App.d.ts +11 -1
  27. package/dist/ui/App.js +88 -2
  28. package/dist/ui/BottomStatusRow.d.ts +25 -3
  29. package/dist/ui/BottomStatusRow.js +10 -7
  30. package/dist/ui/PromptInput.d.ts +10 -0
  31. package/dist/ui/PromptInput.js +41 -48
  32. package/dist/ui/UpdateLine.d.ts +44 -0
  33. package/dist/ui/UpdateLine.js +129 -0
  34. package/dist/ui/editor-reducer.d.ts +62 -0
  35. package/dist/ui/editor-reducer.js +90 -0
  36. package/dist/ui/frame-differ.d.ts +95 -0
  37. package/dist/ui/frame-differ.js +230 -0
  38. package/dist/ui/stdout-frame-writer.d.ts +41 -0
  39. package/dist/ui/stdout-frame-writer.js +102 -0
  40. package/dist/update/classify-failure.d.ts +32 -0
  41. package/dist/update/classify-failure.js +60 -0
  42. package/dist/update/install-lock.d.ts +55 -0
  43. package/dist/update/install-lock.js +184 -0
  44. package/dist/update/install-source.d.ts +97 -0
  45. package/dist/update/install-source.js +218 -0
  46. package/dist/update/installer.d.ts +108 -0
  47. package/dist/update/installer.js +242 -0
  48. package/dist/update/limits.d.ts +111 -0
  49. package/dist/update/limits.js +111 -0
  50. package/dist/update/npm-view.d.ts +62 -0
  51. package/dist/update/npm-view.js +133 -0
  52. package/dist/update/registry.d.ts +52 -0
  53. package/dist/update/registry.js +161 -0
  54. package/dist/update/semver.d.ts +89 -0
  55. package/dist/update/semver.js +295 -0
  56. package/dist/update/service.d.ts +193 -0
  57. package/dist/update/service.js +636 -0
  58. package/dist/update/state.d.ts +103 -0
  59. package/dist/update/state.js +142 -0
  60. package/dist/update/types.d.ts +169 -0
  61. package/dist/update/types.js +68 -0
  62. package/package.json +3 -3
@@ -0,0 +1,636 @@
1
+ /**
2
+ * `UpdateService` - the state machine, the scheduler and the event stream
3
+ * (cli-auto-update section 3.6).
4
+ *
5
+ * ASCII ONLY - this tree is inside the glyph scanner's scope (C-2 / C-3).
6
+ *
7
+ * idle --check()--> checking --none--> idle
8
+ * |
9
+ * |-notify--> available (terminal for the session)
10
+ * `-install-> installing --ok--> ready (terminal)
11
+ * `-err--> failed --backoff--> idle
12
+ *
13
+ * EVERY FAILURE IS SILENT (D-7). A background updater that cannot reach the
14
+ * registry behind a corporate proxy, or cannot write to `/usr/lib/node_modules`
15
+ * without sudo, must not turn into a recurring interruption: it logs, backs off
16
+ * exponentially, and stays quiet. The single exception is a failure the user can
17
+ * actually fix, after `UPDATE_LIMITS.failuresBeforeNotice` consecutive
18
+ * occurrences.
19
+ *
20
+ * THIS MODULE IS REACHED ONLY THROUGH A DYNAMIC `import()` (section 3.8 / D-25).
21
+ * `runOneShot` and `runInteractive` live in the same `cli.tsx` module and there
22
+ * is no bundler (C-16), so a static import here would put the whole subsystem in
23
+ * `dist/cli.js`'s graph and `aragon -p` would evaluate every module under
24
+ * `update/`.
25
+ */
26
+ import process from 'node:process';
27
+ import { getLogger } from '../logging/logger.js';
28
+ import { adviceFor, classifyInstallSource, isAutoInstallable, isReportableSource, probeWritable, readSelfManifest, selfPackageRoot, } from './install-source.js';
29
+ import { classifyInstallFailure } from './classify-failure.js';
30
+ import { UPDATE_LIMITS } from './limits.js';
31
+ import { fetchLatestViaNpm } from './npm-view.js';
32
+ import { fetchLatestManifest, resolveRegistryUrl } from './registry.js';
33
+ import { decideUpdate, STRICT_VERSION_RE } from './semver.js';
34
+ import { tryAcquireInstallLock } from './install-lock.js';
35
+ import { runNpmInstall } from './installer.js';
36
+ import { readUpdateState, updateUpdateState } from './state.js';
37
+ export class UpdateService {
38
+ deps;
39
+ now;
40
+ log;
41
+ listeners = new Set();
42
+ phase = 'idle';
43
+ latestVersion = null;
44
+ reason;
45
+ advice;
46
+ /** Set only on the `node-too-old` path; both, or neither (section 6.2). */
47
+ requiredNode;
48
+ runningNode;
49
+ nextAt = null;
50
+ failures = 0;
51
+ /**
52
+ * The rollback notice, held for THIS SESSION and cleared from the file the
53
+ * first time it is read (section 5.2a / D-38). Both, or neither.
54
+ */
55
+ rolledBackFrom;
56
+ rolledBackTo;
57
+ /** What `aragon update --rollback` would reinstall, for `/update status`. */
58
+ lastGoodVersion;
59
+ /** Which probe answered the last successful check (H3). */
60
+ probe;
61
+ timer = null;
62
+ started = false;
63
+ disposed = false;
64
+ inFlight = null;
65
+ resolved = null;
66
+ constructor(deps) {
67
+ this.deps = deps;
68
+ this.now = deps.now ?? Date.now;
69
+ this.log = deps.logger ?? getLogger().child('update');
70
+ this.failures = readUpdateState().consecutiveFailures;
71
+ }
72
+ // -------------------------------------------------------------------------
73
+ // Public surface
74
+ // -------------------------------------------------------------------------
75
+ /**
76
+ * Schedule the first check. Idempotent.
77
+ *
78
+ * The first check fires `UPDATE_LIMITS.startupDelayMs` after this call so it
79
+ * can never contend with first paint, and every timer is `unref()`ed so a
80
+ * pending check never keeps `aragon` from exiting.
81
+ */
82
+ start() {
83
+ if (this.started || this.disposed)
84
+ return;
85
+ this.started = true;
86
+ this.reconcilePendingRestart();
87
+ this.consumeRollbackNotice();
88
+ this.schedule(UPDATE_LIMITS.startupDelayMs);
89
+ }
90
+ snapshot() {
91
+ return {
92
+ phase: this.phase,
93
+ currentVersion: this.deps.currentVersion,
94
+ latestVersion: this.latestVersion,
95
+ source: this.resolved?.source ?? this.deps.source ?? 'unknown',
96
+ ...(this.reason ? { reason: this.reason } : {}),
97
+ ...(this.advice ? { advice: this.advice } : {}),
98
+ ...(this.requiredNode ? { requiredNode: this.requiredNode } : {}),
99
+ ...(this.runningNode ? { runningNode: this.runningNode } : {}),
100
+ ...(this.rolledBackFrom ? { rolledBackFrom: this.rolledBackFrom } : {}),
101
+ ...(this.rolledBackTo ? { rolledBackTo: this.rolledBackTo } : {}),
102
+ ...(this.lastGoodVersion ? { lastGoodVersion: this.lastGoodVersion } : {}),
103
+ ...(this.probe ? { probe: this.probe } : {}),
104
+ nextCheckAt: this.nextAt,
105
+ consecutiveFailures: this.failures,
106
+ };
107
+ }
108
+ subscribe(fn) {
109
+ this.listeners.add(fn);
110
+ return () => {
111
+ this.listeners.delete(fn);
112
+ };
113
+ }
114
+ nextCheckAt() {
115
+ return this.nextAt;
116
+ }
117
+ /**
118
+ * Run a check now, subject to the machine-wide throttle unless forced.
119
+ *
120
+ * SINGLE-FLIGHT. Two `/update now` in a row, or a slash command landing on top
121
+ * of the scheduled tick, must produce one registry request and one install -
122
+ * the in-process half of what `lastCheckAt` does across processes.
123
+ */
124
+ checkNow(opts = {}) {
125
+ if (this.disposed)
126
+ return Promise.resolve(this.snapshot());
127
+ if (this.inFlight)
128
+ return this.inFlight;
129
+ const run = this.runCheck(opts.force === true).finally(() => {
130
+ this.inFlight = null;
131
+ });
132
+ this.inFlight = run;
133
+ return run;
134
+ }
135
+ /**
136
+ * Install a specific version now, bypassing the decision gates.
137
+ *
138
+ * The explicit escape hatch behind `aragon update --to <version>` (non-goal 4:
139
+ * there is no AUTOMATIC downgrade, but a deliberate one is exactly what a user
140
+ * needs when a release misbehaves).
141
+ */
142
+ async installNow(version) {
143
+ if (this.disposed)
144
+ return this.snapshot();
145
+ const target = version ?? this.latestVersion;
146
+ if (!target)
147
+ return this.snapshot();
148
+ // VALIDATED BEFORE IT TOUCHES ANYTHING. `runNpmInstall` rejects a malformed
149
+ // version too (AC-24), but by then it has already been written into the
150
+ // snapshot and the lock has been taken and released - so `aragon update --to
151
+ // "1.0.0; rm -rf /"` would render the attacker's string in the bottom row
152
+ // and burn a consecutive-failure. This is the one entry point whose target
153
+ // comes straight from argv rather than from a parsed manifest.
154
+ if (!STRICT_VERSION_RE.test(target)) {
155
+ this.log.warn('update_install_rejected', { target: 'malformed' });
156
+ return this.snapshot();
157
+ }
158
+ const resolved = this.resolve();
159
+ if (!resolved.root)
160
+ return this.snapshot();
161
+ // `{ arm: false }` IS THE WHOLE OF D-32. A user who explicitly asked for
162
+ // version X and gets a crash has made a decision, and an updater that
163
+ // silently reverses it is a worse actor than one that does nothing. This is
164
+ // the only call site that differs from the automatic path.
165
+ await this.performInstall(resolved, target, { arm: false });
166
+ return this.snapshot();
167
+ }
168
+ /** Persist `skippedVersion`; the line disappears until a newer one appears. */
169
+ skip(version) {
170
+ if (!version)
171
+ return;
172
+ updateUpdateState({ skippedVersion: version });
173
+ this.log.info('update_skipped', { version });
174
+ this.setPhase('idle', { reason: 'skipped', advice: undefined });
175
+ }
176
+ /**
177
+ * Clear timers and drop listeners. NEVER KILLS THE INSTALLER CHILD (U-3).
178
+ *
179
+ * Killing a running `npm install -g` is the one action in this feature that
180
+ * can leave the user's global installation broken, and `dispose()` is called
181
+ * by the most ordinary event there is - the user pressing Ctrl+C. The child is
182
+ * detached and `unref()`ed precisely so it can finish without us.
183
+ *
184
+ * Idempotent, and called on BOTH of `runInteractive`'s teardown branches.
185
+ */
186
+ dispose() {
187
+ if (this.disposed)
188
+ return;
189
+ this.disposed = true;
190
+ this.cancelTimer();
191
+ this.listeners.clear();
192
+ }
193
+ // -------------------------------------------------------------------------
194
+ // Scheduling
195
+ // -------------------------------------------------------------------------
196
+ cancelTimer() {
197
+ if (!this.timer)
198
+ return;
199
+ const clear = this.deps.clearTimeoutImpl ?? ((h) => clearTimeout(h));
200
+ clear(this.timer);
201
+ this.timer = null;
202
+ }
203
+ /**
204
+ * Arm the next check.
205
+ *
206
+ * The jitter is not decoration (D-12 / R-6): machine fleets started by the
207
+ * same automation otherwise arrive at the registry together, and the throttle
208
+ * in `update-state.json` only de-duplicates within ONE machine.
209
+ */
210
+ schedule(delayMs) {
211
+ if (this.disposed)
212
+ return;
213
+ this.cancelTimer();
214
+ const jitter = this.deps.random ?? Math.random;
215
+ const spread = delayMs * UPDATE_LIMITS.jitterRatio;
216
+ const delay = Math.max(0, Math.round(delayMs + (jitter() * 2 - 1) * spread));
217
+ this.nextAt = this.now() + delay;
218
+ const set = this.deps.setTimeoutImpl ??
219
+ ((fn, ms) => setTimeout(fn, ms));
220
+ const handle = set(() => {
221
+ this.timer = null;
222
+ void this.checkNow();
223
+ }, delay);
224
+ // A pending check must never be the reason `aragon` will not exit.
225
+ handle.unref?.();
226
+ this.timer = handle;
227
+ }
228
+ /** `backoffBaseMs * 2^(n-1)`, capped. `n` is the consecutive-failure count. */
229
+ backoffMs(failures) {
230
+ if (failures <= 0)
231
+ return this.deps.config.checkIntervalMs;
232
+ const grown = UPDATE_LIMITS.backoffBaseMs * 2 ** (failures - 1);
233
+ return Math.min(UPDATE_LIMITS.backoffMaxMs, grown);
234
+ }
235
+ scheduleNext() {
236
+ this.schedule(this.failures > 0 ? this.backoffMs(this.failures) : this.deps.config.checkIntervalMs);
237
+ }
238
+ // -------------------------------------------------------------------------
239
+ // State
240
+ // -------------------------------------------------------------------------
241
+ setPhase(phase, patch = {}) {
242
+ this.phase = phase;
243
+ if ('reason' in patch)
244
+ this.reason = patch.reason;
245
+ if ('advice' in patch)
246
+ this.advice = patch.advice;
247
+ const snapshot = this.snapshot();
248
+ for (const listener of this.listeners) {
249
+ try {
250
+ listener(snapshot);
251
+ }
252
+ catch {
253
+ // A subscriber that throws is a renderer bug, not a reason to break the
254
+ // state machine for every other subscriber.
255
+ }
256
+ }
257
+ }
258
+ /**
259
+ * Step 11 of the happy path: the new version is now the running one, so the
260
+ * pending marker has served its purpose.
261
+ *
262
+ * Silent by design - the user restarted, which is the acknowledgement. The log
263
+ * record is what makes "when did this machine actually pick up 0.6.0"
264
+ * answerable after the fact.
265
+ */
266
+ reconcilePendingRestart() {
267
+ const state = readUpdateState();
268
+ if (!state.pendingRestartVersion)
269
+ return;
270
+ if (state.pendingRestartVersion !== this.deps.currentVersion)
271
+ return;
272
+ updateUpdateState({ pendingRestartVersion: '' });
273
+ this.log.info('update_applied', { version: this.deps.currentVersion });
274
+ }
275
+ /**
276
+ * Read the rollback notice, hold it for this session, and clear the file
277
+ * (cli-auto-update-hardening section 5.2a / D-38 / D-40).
278
+ *
279
+ * CONSUMING ON READ IS WHAT MAKES "ONE SESSION" TRUE WITHOUT A SECOND
280
+ * TIMESTAMP. It also means a launch that never constructs a service -
281
+ * `update.mode: 'off'`, a non-TTY, CI - does NOT burn the notice, so it
282
+ * survives to the first session that could actually have shown it.
283
+ *
284
+ * `rolledBackTo` is the running version and is therefore DERIVED, not stored:
285
+ * we are, by definition, the target of the rollback that wrote this field. The
286
+ * "both or neither" rule the pair inherits from `requiredNode` / `runningNode`
287
+ * is satisfied by construction rather than by discipline.
288
+ *
289
+ * `lastGoodVersion` is picked up in the same read - it is what
290
+ * `aragon update --rollback` would reinstall, and `/update status` is the only
291
+ * place a user can find that out before running the command.
292
+ */
293
+ consumeRollbackNotice() {
294
+ const state = readUpdateState();
295
+ if (state.lastGoodVersion)
296
+ this.lastGoodVersion = state.lastGoodVersion;
297
+ if (!state.rolledBackFrom)
298
+ return;
299
+ this.rolledBackFrom = state.rolledBackFrom;
300
+ this.rolledBackTo = this.deps.currentVersion;
301
+ updateUpdateState({ rolledBackFrom: '' });
302
+ this.log.warn('update_rollback_notice', {
303
+ from: this.rolledBackFrom,
304
+ to: this.rolledBackTo,
305
+ });
306
+ }
307
+ /** Classify this installation once. Cheap, but it stats the filesystem. */
308
+ resolve() {
309
+ if (this.resolved)
310
+ return this.resolved;
311
+ const root = this.deps.packageRoot !== undefined ? this.deps.packageRoot : selfPackageRoot();
312
+ const manifest = root ? readSelfManifest(root) : null;
313
+ const source = this.deps.source ?? (root ? classifyInstallSource(root, this.deps.env ?? process.env) : 'unknown');
314
+ this.resolved = {
315
+ root,
316
+ packageName: this.deps.packageName ?? manifest?.name ?? '',
317
+ source,
318
+ // A path can look global and still be unwritable - the EACCES-on-Linux
319
+ // case (R-2). Probing BEFORE spawning npm turns a confusing failure deep in
320
+ // npm's output into an actionable one-line notice.
321
+ writable: root ? probeWritable(root) : false,
322
+ };
323
+ return this.resolved;
324
+ }
325
+ // -------------------------------------------------------------------------
326
+ // The check
327
+ // -------------------------------------------------------------------------
328
+ async runCheck(force) {
329
+ // `installing` is single-flight and `ready` is terminal for the session:
330
+ // there is nothing a further check could learn that would change either.
331
+ if (this.phase === 'installing' || this.phase === 'ready')
332
+ return this.snapshot();
333
+ const state = readUpdateState();
334
+ const now = this.now();
335
+ if (!force) {
336
+ // Ten terminals opened at once produce ONE registry request, not ten.
337
+ if (now - state.lastCheckAt < this.deps.config.checkIntervalMs) {
338
+ this.scheduleNext();
339
+ return this.snapshot();
340
+ }
341
+ if (state.consecutiveFailures > 0 &&
342
+ now < state.lastFailureAt + this.backoffMs(state.consecutiveFailures)) {
343
+ this.scheduleNext();
344
+ return this.snapshot();
345
+ }
346
+ }
347
+ const resolved = this.resolve();
348
+ if (!resolved.root || resolved.packageName.length === 0) {
349
+ // U-1's guard fired: we could not establish which package we are, so there
350
+ // is nothing safe to ask the registry about.
351
+ this.setPhase('idle', { reason: 'source-ineligible', advice: undefined });
352
+ this.scheduleNext();
353
+ return this.snapshot();
354
+ }
355
+ const registry = resolveRegistryUrl(this.deps.config.registry, this.deps.env ?? process.env);
356
+ this.setPhase('checking');
357
+ this.log.info('update_check_start', {
358
+ registry,
359
+ distTag: this.deps.config.distTag,
360
+ source: resolved.source,
361
+ });
362
+ let manifest = await fetchLatestManifest(registry, resolved.packageName, this.deps.config.distTag, {
363
+ ...(this.deps.fetchImpl ? { fetchImpl: this.deps.fetchImpl } : {}),
364
+ userAgent: `aragon-agent-cli/${this.deps.currentVersion}`,
365
+ });
366
+ // H3 - THE PROXY-AWARE FALLBACK (section 5.4 / D-35 / R-19).
367
+ //
368
+ // UNCONDITIONAL ON A FAILED FETCH: no "three consecutive network failures"
369
+ // precondition and no sticky mode. A precondition would make a proxied
370
+ // machine wait out a 30 min -> 60 min -> 120 min backoff before its FIRST
371
+ // successful check, and a sticky `probeMode` field would be a fifth thing to
372
+ // keep correct across versions under C-17. The unconditional form costs one
373
+ // `execFile` on a check that has ALREADY FAILED, which on a healthy machine
374
+ // is never.
375
+ //
376
+ // The SAME resolved `registry` string goes to both probes (P1-4 / D-41), so
377
+ // they describe one registry by construction.
378
+ let via = 'http';
379
+ if (!manifest) {
380
+ manifest = await fetchLatestViaNpm(registry, resolved.packageName, this.deps.config.distTag, {
381
+ ...(this.deps.env ? { env: this.deps.env } : {}),
382
+ ...(this.deps.npmView ?? {}),
383
+ });
384
+ if (manifest)
385
+ via = 'npm';
386
+ this.log.info('update_check_via_npm', {
387
+ packageName: resolved.packageName,
388
+ distTag: this.deps.config.distTag,
389
+ ok: manifest !== null,
390
+ });
391
+ }
392
+ // WRITTEN ONCE, AFTER BOTH ATTEMPTS, exactly as before: it means "somebody
393
+ // asked", and the throttle it feeds must not fire twice for one check.
394
+ updateUpdateState({ lastCheckAt: this.now() });
395
+ if (!manifest) {
396
+ this.recordFailure('network');
397
+ this.scheduleNext();
398
+ return this.snapshot();
399
+ }
400
+ this.probe = via;
401
+ this.latestVersion = manifest.version;
402
+ updateUpdateState({ lastKnownVersion: manifest.version });
403
+ const decision = decideUpdate({
404
+ local: this.deps.currentVersion,
405
+ manifest,
406
+ nodeVersion: this.deps.nodeVersion ?? process.versions.node,
407
+ skippedVersion: readUpdateState().skippedVersion,
408
+ });
409
+ // `via` belongs on THIS record and not on `update_check_start` (P2-3): the
410
+ // value is not known until the fetch has failed, so on the start record it
411
+ // would be the constant `'http'`, which is worse than absent. It is what
412
+ // makes "this machine updates through the npm fallback" answerable from the
413
+ // log rather than only from `/update status`.
414
+ this.log.info('update_check_result', {
415
+ local: this.deps.currentVersion,
416
+ remote: manifest.version,
417
+ action: decision.action,
418
+ reason: decision.reason,
419
+ source: resolved.source,
420
+ via,
421
+ });
422
+ // A reachable registry is a working check, whatever it answered.
423
+ this.clearFailures();
424
+ if (decision.action === 'none') {
425
+ this.setPhase('idle', {
426
+ reason: decision.reason,
427
+ advice: undefined,
428
+ });
429
+ this.scheduleNext();
430
+ return this.snapshot();
431
+ }
432
+ if (decision.action === 'notify') {
433
+ // Both, or neither: section 6.2's row names the range AND the running
434
+ // version, and half of that pair is a notice nobody can act on.
435
+ const required = manifest.engines?.node;
436
+ if (required) {
437
+ this.requiredNode = required;
438
+ this.runningNode = this.deps.nodeVersion ?? process.versions.node;
439
+ }
440
+ this.offer(resolved, 'node-too-old');
441
+ return this.snapshot();
442
+ }
443
+ // `install` - now the machine's own gates, which are deliberately NOT part
444
+ // of `decideUpdate` (a pure decision must not depend on the filesystem).
445
+ if (this.deps.config.mode !== 'auto') {
446
+ this.offer(resolved, undefined);
447
+ return this.snapshot();
448
+ }
449
+ if (!isAutoInstallable(resolved.source)) {
450
+ this.offer(resolved, 'source-ineligible');
451
+ return this.snapshot();
452
+ }
453
+ if (!resolved.writable) {
454
+ this.offer(resolved, 'not-writable');
455
+ return this.snapshot();
456
+ }
457
+ await this.performInstall(resolved, manifest.version, { arm: true });
458
+ return this.snapshot();
459
+ }
460
+ /**
461
+ * Report an available version without installing it.
462
+ *
463
+ * `dev-monorepo` and `npx` are SILENT even here (AC-6 / AC-7): a developer's
464
+ * clone is not out of date, it is checked out, and `npx` resolved `latest`
465
+ * seconds ago. Both fall back to `idle`, which `shouldRenderUpdateLine`
466
+ * answers `false` for.
467
+ */
468
+ offer(resolved, reason) {
469
+ const advice = adviceFor(resolved.source, resolved.packageName);
470
+ if (!isReportableSource(resolved.source)) {
471
+ this.setPhase('idle', { reason: 'source-ineligible', advice: undefined });
472
+ this.scheduleNext();
473
+ return;
474
+ }
475
+ this.setPhase('available', { reason, advice });
476
+ this.scheduleNext();
477
+ }
478
+ // -------------------------------------------------------------------------
479
+ // The install
480
+ // -------------------------------------------------------------------------
481
+ async performInstall(resolved, target, opts) {
482
+ const lock = tryAcquireInstallLock({
483
+ now: this.now,
484
+ ...(this.deps.lockPath ? { path: this.deps.lockPath } : {}),
485
+ ...(this.deps.isProcessAlive ? { isProcessAlive: this.deps.isProcessAlive } : {}),
486
+ });
487
+ if (!lock) {
488
+ // NOT AN ERROR: another process is already doing the work, so this one
489
+ // re-checks on the next tick, by which point the version is installed.
490
+ this.setPhase('idle', { reason: 'locked', advice: undefined });
491
+ this.scheduleNext();
492
+ return;
493
+ }
494
+ this.setPhase('installing', { reason: undefined, advice: undefined });
495
+ this.latestVersion = target;
496
+ this.log.info('update_install_start', { version: target, root: resolved.root });
497
+ const result = await runNpmInstall({
498
+ packageName: resolved.packageName,
499
+ version: target,
500
+ root: resolved.root,
501
+ // Phase two of U-4: the lock was created with OUR pid because the lock has
502
+ // to precede the spawn, and it is rewritten to the child's the instant the
503
+ // child exists. The child outlives us by design, so a lock naming the
504
+ // parent would be released - or judged stale - while npm is still writing.
505
+ onSpawn: (pid) => lock.adoptChild(pid),
506
+ deps: {
507
+ now: this.now,
508
+ ...(this.deps.spawnImpl ? { spawnImpl: this.deps.spawnImpl } : {}),
509
+ ...(this.deps.env ? { env: this.deps.env } : {}),
510
+ ...(this.deps.install ?? {}),
511
+ },
512
+ });
513
+ lock.release();
514
+ if (result.ok) {
515
+ updateUpdateState({
516
+ pendingRestartVersion: target,
517
+ consecutiveFailures: 0,
518
+ lastFailureAt: 0,
519
+ // ------------------------------------------------------------------
520
+ // H1's ARMING WRITE (cli-auto-update-hardening section 5.2).
521
+ //
522
+ // `lastGoodVersion` is the version DOING the installing. That is the
523
+ // strongest available evidence of "this one works": it is executing.
524
+ // Reading it from the registry or from a list of published versions
525
+ // would be a guess.
526
+ //
527
+ // `bootFailures: 0` IS NOT DEFENSIVE TIDYING; WITHOUT IT THE GUARD
528
+ // DOWNGRADES HEALTHY RELEASES (P0-2 / D-30b). Arming and the counter are
529
+ // two fields with two lifetimes, and nothing else resets the counter
530
+ // except a rollback that actually fires. Follow an ordinary sequence:
531
+ // 0.6.0 auto-installs and arms, crashes twice (`bootFailures: 2`), and
532
+ // the user gives up and runs `npm i -g @aragon-agent/cli@0.5.9` by hand
533
+ // rather than launching a third time. The guard now takes its fast path
534
+ // forever - `autoInstalledVersion` is 0.6.0, the running version is
535
+ // 0.5.9 - so the counter is STRANDED at 2. Weeks later 0.7.0, a
536
+ // perfectly good release, auto-installs and arms ON TOP OF IT, and its
537
+ // very first launch already satisfies the threshold. The guard rolls a
538
+ // working version back and latches it into `skippedVersion`, so it is
539
+ // never offered again. One field in a write that was already happening;
540
+ // its absence is silent, destructive, and the exact inverse of what H1
541
+ // is for.
542
+ //
543
+ // `arm` IS FALSE FOR `installNow` (`aragon update --to`) - see D-32 at
544
+ // that call site.
545
+ // ------------------------------------------------------------------
546
+ ...(opts.arm
547
+ ? {
548
+ autoInstalledVersion: target,
549
+ lastGoodVersion: this.deps.currentVersion,
550
+ bootFailures: 0,
551
+ }
552
+ : {}),
553
+ });
554
+ if (opts.arm)
555
+ this.lastGoodVersion = this.deps.currentVersion;
556
+ this.failures = 0;
557
+ this.log.info('update_install_done', { version: target, ms: result.elapsedMs ?? 0 });
558
+ this.setPhase('ready', { reason: undefined, advice: undefined });
559
+ return;
560
+ }
561
+ if (result.failure === 'ineffective') {
562
+ // U-6 / R-12. npm succeeded, but into a prefix that is not the root we
563
+ // classified. `skippedVersion` LATCHES so the same version is never
564
+ // retried - without it this is an unbounded silent reinstall, once per
565
+ // check interval, forever. A newer release clears the latch in the normal
566
+ // way, so the user is not opted out permanently, only out of the loop.
567
+ updateUpdateState({ skippedVersion: target, consecutiveFailures: 0, lastFailureAt: 0 });
568
+ this.failures = 0;
569
+ this.log.warn('update_install_ineffective', {
570
+ target,
571
+ observed: result.observedVersion ?? null,
572
+ root: resolved.root,
573
+ });
574
+ this.setPhase('available', {
575
+ reason: 'install-ineffective',
576
+ advice: adviceFor(resolved.source, resolved.packageName),
577
+ });
578
+ this.scheduleNext();
579
+ return;
580
+ }
581
+ // H2 - the tail decides the reason, and the reason decides the advice
582
+ // (section 5.3). `recordFailure` itself is unchanged: it still increments,
583
+ // still persists, still attaches `adviceFor(...)`, still sets
584
+ // `phase: 'failed'`. What changes is that three of the possible reasons are
585
+ // in `IMMEDIATE_NOTICE_REASONS` and so bypass the three-strike silence.
586
+ const reason = classifyInstallFailure(result.stderrTail);
587
+ // `stderrTail` goes through the logger's own redactor on the way to disk
588
+ // (`Logger.emit` -> `redactRecord`): an npm error can echo a registry URL
589
+ // carrying an auth token.
590
+ //
591
+ // A DISTINCT RECORD FOR THE BLOCKED CASE (section 5.6), because it is the
592
+ // one failure whose remedy is not "wait": on Windows the shim that launched
593
+ // us is held open by the shell running it for as long as our process lives,
594
+ // so it will fail identically until the user closes something.
595
+ this.log.warn(reason === 'blocked-by-os' ? 'update_install_blocked' : 'update_install_failed', {
596
+ version: target,
597
+ code: result.exitCode ?? null,
598
+ stderrTail: result.stderrTail ?? '',
599
+ });
600
+ this.recordFailure(reason);
601
+ this.scheduleNext();
602
+ }
603
+ // -------------------------------------------------------------------------
604
+ // Failure bookkeeping
605
+ // -------------------------------------------------------------------------
606
+ recordFailure(reason) {
607
+ const next = updateUpdateState({
608
+ consecutiveFailures: readUpdateState().consecutiveFailures + 1,
609
+ lastFailureAt: this.now(),
610
+ });
611
+ this.failures = next.consecutiveFailures;
612
+ // THE ADVICE IS THE WHOLE POINT OF THE ONE FAILURE THE USER EVER SEES.
613
+ // Section 1.2 promises that after `failuresBeforeNotice` the line becomes
614
+ // "muted and ACTIONABLE", and section 6.2 spells that row as
615
+ // `<warn> update failed <dot> npm i -g @aragon-agent/cli`. Passing
616
+ // `undefined` here leaves `UpdateLine` on its `!advice` fallback, so the row
617
+ // reads a bare "update failed" - a warning that names no way out, on the one
618
+ // path where a corporate proxy or a root-owned prefix means the user's only
619
+ // remaining move is the manual command. `undefined` for the three silent
620
+ // sources is correct and stays: `adviceFor` has nothing honest to offer them.
621
+ //
622
+ // The phase changes on every failure, but `shouldRenderUpdateLine` keeps the
623
+ // row empty until `failuresBeforeNotice` - so the first two are invisible
624
+ // and only the third says anything (D-7 / R-8).
625
+ const advice = this.resolved
626
+ ? adviceFor(this.resolved.source, this.resolved.packageName)
627
+ : undefined;
628
+ this.setPhase('failed', { reason, advice });
629
+ }
630
+ clearFailures() {
631
+ if (this.failures === 0 && readUpdateState().consecutiveFailures === 0)
632
+ return;
633
+ updateUpdateState({ consecutiveFailures: 0, lastFailureAt: 0 });
634
+ this.failures = 0;
635
+ }
636
+ }