@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,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
|
+
}
|