@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,230 @@
1
+ /**
2
+ * Frame differ — F1 of tui-input-flicker-fix (§3.2).
3
+ *
4
+ * Ink 5.2.1 has exactly one repaint strategy: on every commit it serialises the
5
+ * WHOLE frame and hands it to `log-update`, which writes
6
+ * `eraseLines(previousLineCount) + frame` in a single `stdout.write`
7
+ * (`ink/build/log-update.js:17`). `ansiEscapes.eraseLines` walks BOTTOM-UP, so
8
+ * the composer — the last row of the frame — is erased near the start of every
9
+ * repaint and rewritten at the very end of it. During a run that happens 15–30
10
+ * times a second with the composer's bytes unchanged, and the user sees the
11
+ * bottom of their screen flicker while they type (§2 R1/R2).
12
+ *
13
+ * This module recognises that write shape, diffs the new frame against the
14
+ * previous one line by line, and emits only the rows that actually changed, each
15
+ * addressed absolutely. It is PURE: no I/O, no Node globals, no event emitter —
16
+ * `stdout-frame-writer.ts` is what binds it to a real stream. That is what keeps
17
+ * it unit-testable with plain string literals and no TTY.
18
+ *
19
+ * THREE INVARIANTS LIVE HERE AND EACH ONE FAILS SILENTLY IF DROPPED:
20
+ *
21
+ * - I-4 · An emitted line is `CUP · SGR0 · text · SGR0 · CSI K`, IN THAT ORDER,
22
+ * and it never contains `CSI 2K`. Erasing first would blank the row and refill
23
+ * it, which is R1's defect at 1/H the scale but on the one row the user is
24
+ * watching. Overwriting in place means no cell is ever presentably empty; the
25
+ * trailing `CSI K` removes only the residue a longer previous line left, and
26
+ * the trailing SGR reset before it stops a BCE terminal painting that residue
27
+ * in whatever background colour the line ended inside.
28
+ * - I-5 · Every emitted batch ends parked on row `H+1`. Ink's own
29
+ * `previousLineCount` is computed from the string IT produced and is
30
+ * unaffected by what we actually wrote, so a later pass-through's
31
+ * `eraseLines(H+1)` must find the cursor exactly where log-update left it last
32
+ * time. Drop this and one fallback shifts the frame up a row for the rest of
33
+ * the session.
34
+ * - I-6 · Any unrecognised write invalidates the cache. Absolute addressing is
35
+ * valid only while nothing else writes to stdout. Full-screen mode already
36
+ * forbids that, but "forbidden" is not "impossible", and the failure mode of
37
+ * the guard being absent is a permanently misaligned screen while the failure
38
+ * mode of it firing spuriously is one extra full repaint.
39
+ */
40
+ import { MIN_FULLSCREEN_ROWS } from './layout/frame.js';
41
+ // ---------------------------------------------------------------------------
42
+ // Constants — exact, so no call site ever re-derives an escape sequence.
43
+ // ---------------------------------------------------------------------------
44
+ /** Control Sequence Introducer. Spelled out once; nothing below re-escapes it. */
45
+ const CSI = '\x1b[';
46
+ /** Erase the WHOLE line. Ink uses it on every repaint; this module never emits it (I-4). */
47
+ const ERASE_LINE = `${CSI}2K`;
48
+ /** Erase cursor → end of line. This is what an emitted line ends with. */
49
+ const ERASE_TO_EOL = `${CSI}K`;
50
+ const CURSOR_UP_1 = `${CSI}1A`;
51
+ const CURSOR_LEFT = `${CSI}G`;
52
+ const SGR_RESET = `${CSI}0m`;
53
+ /** Erase cursor → end of screen. One sequence instead of H+1 line erases. */
54
+ const ERASE_DOWN = `${CSI}J`;
55
+ /** DEC private mode 2026 — begin/end synchronized update (F2, §3.3). */
56
+ const SYNC_BEGIN = `${CSI}?2026h`;
57
+ const SYNC_END = `${CSI}?2026l`;
58
+ /**
59
+ * `eraseLines(n)` for any n ≥ 1, anchored at the start of the chunk.
60
+ *
61
+ * FALLBACK ONLY (P2-3). Whenever `prev !== null` the differ already knows the
62
+ * exact prefix it expects — `eraseLinesPrefix(prev.length + 1)` is a
63
+ * deterministic string — so the hot path is `chunk.startsWith(expected)`:
64
+ * linear, unambiguous, and it collapses step 7's `erased !== prev.length + 1`
65
+ * test into the same comparison. This regex is reached only on the first
66
+ * diffable chunk after a session start or an `invalidate()`, where no
67
+ * expectation exists yet.
68
+ */
69
+ const ERASE_PREFIX_RE = /^(?:\x1b\[2K(?:\x1b\[1A)?)+\x1b\[G/;
70
+ function cursorTo(row) {
71
+ return `${CSI}${row};1H`;
72
+ }
73
+ /**
74
+ * Rebuild `ansiEscapes.eraseLines(count)` byte for byte.
75
+ *
76
+ * Exported for the upgrade tripwire (`frame-differ-ink-shape.test.ts`), which
77
+ * drives the real `ink/build/log-update.js` and asserts this reconstruction is
78
+ * byte-equal to what it produced. If a future Ink changes the shape, that test
79
+ * fails loudly instead of this module silently degrading to pass-through for the
80
+ * rest of the package's life (K-2).
81
+ */
82
+ export function eraseLinesPrefix(count) {
83
+ if (count <= 0)
84
+ return '';
85
+ let out = '';
86
+ for (let i = 0; i < count; i += 1) {
87
+ out += ERASE_LINE + (i < count - 1 ? CURSOR_UP_1 : '');
88
+ }
89
+ return out + CURSOR_LEFT;
90
+ }
91
+ function countErases(prefix) {
92
+ return prefix.split(ERASE_LINE).length - 1;
93
+ }
94
+ export function createFrameDiffer(options) {
95
+ /** Last frame, one entry per row. `null` = no cache, next frame is a full repaint. */
96
+ let prev = null;
97
+ /**
98
+ * Whether any chunk has been seen at all.
99
+ *
100
+ * The session's FIRST write has `previousLineCount === 0`, so `eraseLines(0)`
101
+ * is the empty string and the chunk carries no erase prefix (§3.2.1). That is
102
+ * expected, not a desync, so it must not raise the §5.6 notice on every launch.
103
+ * After it, every chunk Ink produces carries a prefix, so a prefix-less chunk
104
+ * from then on really is foreign.
105
+ */
106
+ let seenAnyChunk = false;
107
+ const counters = {
108
+ framesTotal: 0,
109
+ framesDiffed: 0,
110
+ framesFull: 0,
111
+ linesWritten: 0,
112
+ bytesWritten: 0,
113
+ fallbacks: 0,
114
+ };
115
+ const invalidate = () => {
116
+ prev = null;
117
+ };
118
+ /**
119
+ * Write `chunk` verbatim — today's behaviour, and the only failure mode.
120
+ *
121
+ * `counted` is false for the two cases the differ inflicts on itself and knows
122
+ * are harmless (the seed write, degenerate geometry); true means a foreign
123
+ * write desynced absolute addressing, which is what `fallbacks` reports.
124
+ */
125
+ const passThrough = (counted) => {
126
+ invalidate();
127
+ if (counted) {
128
+ counters.fallbacks += 1;
129
+ if (counters.fallbacks === 1)
130
+ options.onFirstFallback?.();
131
+ }
132
+ return null;
133
+ };
134
+ const wrapSync = (payload) => options.sync ? SYNC_BEGIN + payload + SYNC_END : payload;
135
+ const emit = (payload, lines, addressed, full) => {
136
+ prev = lines;
137
+ counters.framesTotal += 1;
138
+ if (full)
139
+ counters.framesFull += 1;
140
+ else
141
+ counters.framesDiffed += 1;
142
+ counters.linesWritten += addressed;
143
+ counters.bytesWritten += payload.length;
144
+ return payload;
145
+ };
146
+ /** `undefined` ⇒ the I-9 stand-down: a viewport too small to address safely. */
147
+ const usableRows = () => {
148
+ const rows = options.rows();
149
+ if (rows === undefined || !Number.isFinite(rows))
150
+ return undefined;
151
+ return rows < MIN_FULLSCREEN_ROWS ? undefined : rows;
152
+ };
153
+ const matchErasePrefix = (chunk) => {
154
+ if (prev !== null) {
155
+ const expected = eraseLinesPrefix(prev.length + 1);
156
+ if (chunk.startsWith(expected))
157
+ return { prefix: expected, erased: prev.length + 1 };
158
+ }
159
+ const m = ERASE_PREFIX_RE.exec(chunk);
160
+ if (m === null)
161
+ return null;
162
+ return { prefix: m[0], erased: countErases(m[0]) };
163
+ };
164
+ /**
165
+ * Full repaint — the first diffable frame of a session, every invalidation
166
+ * (including the resize invalidation of I-8), and any line-count change.
167
+ *
168
+ * `CSI J` replaces the H+1 individual line erases with one sequence, and the
169
+ * alternate screen is exactly the region being erased. `\r\n` rather than `\n`
170
+ * is deliberate: Ink relies on the tty's `ONLCR` post-processing to turn `\n`
171
+ * into CRLF, and that dependency is invisible until it is not there.
172
+ *
173
+ * P0-1's blank-then-paint objection does not apply here and the asymmetry with
174
+ * the per-line path is deliberate: a full repaint rewrites every row, so there
175
+ * is no unchanged content that has to survive, and full repaints are rare by
176
+ * construction — one per session, one per resize, one per invalidation.
177
+ */
178
+ const fullRepaint = (lines) => {
179
+ const payload = cursorTo(1) +
180
+ SGR_RESET +
181
+ ERASE_DOWN +
182
+ lines.join('\r\n') +
183
+ '\r\n' +
184
+ cursorTo(lines.length + 1);
185
+ return emit(wrapSync(payload), lines, lines.length, true);
186
+ };
187
+ const diffRepaint = (lines, previous) => {
188
+ const out = [];
189
+ for (let i = 0; i < lines.length; i += 1) {
190
+ // I-4: paint, THEN clear the tail. Never `ERASE_LINE` first.
191
+ if (lines[i] !== previous[i]) {
192
+ out.push(cursorTo(i + 1) + SGR_RESET + lines[i] + SGR_RESET + ERASE_TO_EOL);
193
+ }
194
+ }
195
+ // DEFENSIVE ONLY, not a live path (P2-4). `log-update.js:13-15` returns early
196
+ // on an identical frame, so a chunk that reaches here differs by at least one
197
+ // line; the only way to observe zero is a cache that disagrees with the
198
+ // screen, which is the state `invalidate()` exists to prevent.
199
+ if (out.length === 0)
200
+ return emit('', lines, 0, false);
201
+ out.push(cursorTo(lines.length + 1)); // I-5
202
+ return emit(wrapSync(out.join('')), lines, out.length - 1, false);
203
+ };
204
+ const transform = (chunk) => {
205
+ const firstChunk = !seenAnyChunk;
206
+ seenAnyChunk = true;
207
+ const rows = usableRows();
208
+ if (rows === undefined)
209
+ return passThrough(false); // I-9
210
+ const matched = matchErasePrefix(chunk);
211
+ // A prefix-less chunk is the session's seed write (P1-1) or a foreign one.
212
+ if (matched === null)
213
+ return passThrough(!firstChunk);
214
+ const body = chunk.slice(matched.prefix.length);
215
+ if (!body.endsWith('\n'))
216
+ return passThrough(true); // `log.clear()` and friends
217
+ const lines = body.slice(0, -1).split('\n');
218
+ if (lines.length + 1 > rows)
219
+ return passThrough(false); // I-9
220
+ if (prev === null || prev.length !== lines.length || matched.erased !== prev.length + 1) {
221
+ return fullRepaint(lines);
222
+ }
223
+ return diffRepaint(lines, prev);
224
+ };
225
+ return {
226
+ transform,
227
+ invalidate,
228
+ stats: () => ({ ...counters }),
229
+ };
230
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Stdout frame writer — the `Proxy` that binds `frame-differ.ts` to a real
3
+ * stream (tui-input-flicker-fix §3.2 / §5.5).
4
+ *
5
+ * Ink offers no hook for "how to write a frame": `Ink#log` is constructed
6
+ * internally and `log-update` is a private module. It DOES accept a `stdout` in
7
+ * `render()`'s options, so the whole interposition is one `Proxy` whose only
8
+ * trapped behaviour is `write` — everything else is the real stream.
9
+ *
10
+ * THREE THINGS IN HERE ARE LOAD-BEARING:
11
+ *
12
+ * - The `get` trap MUST bind non-`write` function members to the REAL stream.
13
+ * Without the bind, `stdout.on('resize', …)` executes with `this === proxy`,
14
+ * and `useTerminalSize` registers and removes its listener against a
15
+ * different `_events` object than the one Node emits on — the frame stops
16
+ * resizing and nothing reports why.
17
+ * - The UNTYPED BOUNDARY LIVES HERE, not in the differ (P1-4). A `Uint8Array`
18
+ * chunk, and any `write` whose `encoding` is present and is not UTF-8, is
19
+ * passed straight through after `invalidate()`: in both cases the bytes on
20
+ * the wire are not the string a differ could reason about, and diffing one
21
+ * encoding while writing another is the kind of bug that only shows up on
22
+ * someone else's machine.
23
+ * - The `'resize'` SUBSCRIPTION (I-8 / P0-2). Ink renders SYNCHRONOUSLY from its
24
+ * own `'resize'` handler while the frame's height is still the pre-resize one
25
+ * — `useTerminalSize` debounces by 50 ms — so the line count is unchanged
26
+ * across that window and the differ's line-count guard cannot see the resize.
27
+ * Meanwhile the emulator has re-flowed the alternate screen, so every row the
28
+ * differ believes unchanged is a row it will not repaint, for the rest of the
29
+ * drag. It goes on the REAL stream, not the `Proxy`, and `dispose()` removes
30
+ * it.
31
+ */
32
+ import type { FrameDiffer, FrameWriterStats } from './frame-differ.js';
33
+ export interface FrameWriterHandle {
34
+ /** Hand this to `render(…, { stdout })`. */
35
+ stdout: NodeJS.WriteStream;
36
+ /** Hand this to `setFrameStatsProvider`. */
37
+ stats(): FrameWriterStats;
38
+ /** Removes the `'resize'` subscription of I-8. Idempotent. */
39
+ dispose(): void;
40
+ }
41
+ export declare function wrapStdoutForFrames(stdout: NodeJS.WriteStream, differ: FrameDiffer): FrameWriterHandle;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Stdout frame writer — the `Proxy` that binds `frame-differ.ts` to a real
3
+ * stream (tui-input-flicker-fix §3.2 / §5.5).
4
+ *
5
+ * Ink offers no hook for "how to write a frame": `Ink#log` is constructed
6
+ * internally and `log-update` is a private module. It DOES accept a `stdout` in
7
+ * `render()`'s options, so the whole interposition is one `Proxy` whose only
8
+ * trapped behaviour is `write` — everything else is the real stream.
9
+ *
10
+ * THREE THINGS IN HERE ARE LOAD-BEARING:
11
+ *
12
+ * - The `get` trap MUST bind non-`write` function members to the REAL stream.
13
+ * Without the bind, `stdout.on('resize', …)` executes with `this === proxy`,
14
+ * and `useTerminalSize` registers and removes its listener against a
15
+ * different `_events` object than the one Node emits on — the frame stops
16
+ * resizing and nothing reports why.
17
+ * - The UNTYPED BOUNDARY LIVES HERE, not in the differ (P1-4). A `Uint8Array`
18
+ * chunk, and any `write` whose `encoding` is present and is not UTF-8, is
19
+ * passed straight through after `invalidate()`: in both cases the bytes on
20
+ * the wire are not the string a differ could reason about, and diffing one
21
+ * encoding while writing another is the kind of bug that only shows up on
22
+ * someone else's machine.
23
+ * - The `'resize'` SUBSCRIPTION (I-8 / P0-2). Ink renders SYNCHRONOUSLY from its
24
+ * own `'resize'` handler while the frame's height is still the pre-resize one
25
+ * — `useTerminalSize` debounces by 50 ms — so the line count is unchanged
26
+ * across that window and the differ's line-count guard cannot see the resize.
27
+ * Meanwhile the emulator has re-flowed the alternate screen, so every row the
28
+ * differ believes unchanged is a row it will not repaint, for the rest of the
29
+ * drag. It goes on the REAL stream, not the `Proxy`, and `dispose()` removes
30
+ * it.
31
+ */
32
+ /** The only encodings for which the chunk string IS the bytes on the wire. */
33
+ function isUtf8Encoding(encoding) {
34
+ const e = encoding.toLowerCase();
35
+ return e === 'utf8' || e === 'utf-8';
36
+ }
37
+ export function wrapStdoutForFrames(stdout, differ) {
38
+ const bound = new Map();
39
+ const writeReal = (chunk, encoding, cb) => {
40
+ if (encoding !== undefined)
41
+ return stdout.write(chunk, encoding, cb);
42
+ return stdout.write(chunk, cb);
43
+ };
44
+ /**
45
+ * All three Node overloads: `write(chunk)`, `write(chunk, cb)`,
46
+ * `write(chunk, encoding, cb)`.
47
+ */
48
+ const write = (chunk, encodingOrCb, maybeCb) => {
49
+ const encoding = typeof encodingOrCb === 'string' ? encodingOrCb : undefined;
50
+ const cb = typeof encodingOrCb === 'function'
51
+ ? encodingOrCb
52
+ : typeof maybeCb === 'function'
53
+ ? maybeCb
54
+ : undefined;
55
+ // Foreign by construction: Ink never writes Buffers, and a non-UTF-8
56
+ // encoding means the string is not the bytes (P1-4).
57
+ if (typeof chunk !== 'string' || (encoding !== undefined && !isUtf8Encoding(encoding))) {
58
+ differ.invalidate();
59
+ return writeReal(chunk, encoding, cb);
60
+ }
61
+ const out = differ.transform(chunk);
62
+ if (out === null)
63
+ return writeReal(chunk, encoding, cb);
64
+ // Nothing to write, but the callback is still part of the contract — a
65
+ // skipped `cb` strands whatever was waiting on the drain.
66
+ if (out.length === 0) {
67
+ cb?.();
68
+ return true;
69
+ }
70
+ return writeReal(out, encoding, cb);
71
+ };
72
+ const onResize = () => differ.invalidate();
73
+ stdout.on('resize', onResize);
74
+ let disposed = false;
75
+ const proxy = new Proxy(stdout, {
76
+ get(target, prop) {
77
+ if (prop === 'write')
78
+ return write;
79
+ const value = Reflect.get(target, prop, target);
80
+ if (typeof value !== 'function')
81
+ return value;
82
+ // Cached so repeated property access does not allocate a new bound
83
+ // function on a hot path (K-9).
84
+ let fn = bound.get(prop);
85
+ if (fn === undefined) {
86
+ fn = value.bind(target);
87
+ bound.set(prop, fn);
88
+ }
89
+ return fn;
90
+ },
91
+ });
92
+ return {
93
+ stdout: proxy,
94
+ stats: () => differ.stats(),
95
+ dispose() {
96
+ if (disposed)
97
+ return;
98
+ disposed = true;
99
+ stdout.off('resize', onResize);
100
+ },
101
+ };
102
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Turning a tail of npm output into a reason (cli-auto-update-hardening H2 /
3
+ * section 5.3).
4
+ *
5
+ * ASCII ONLY - this tree is inside the glyph scanner's scope (C-2 / C-3).
6
+ *
7
+ * PURE, AND THE ONLY IMPORT IS A TYPE. The interesting half of H2 is not this
8
+ * table, it is the REPORTING rule that decides which of the resulting reasons
9
+ * may break the three-strike silence - and that lives in `types.ts` beside
10
+ * `shouldRenderUpdateLine`, as `IMMEDIATE_NOTICE_REASONS`.
11
+ *
12
+ * WHY THIS EXISTS AT ALL. The one line a user is ever allowed to see on the
13
+ * failure path reads `update failed <dot> npm i -g @aragon-agent/cli` for EVERY
14
+ * cause. For `EPERM` / `EBUSY` - the Windows self-replacement case, R-3 - that
15
+ * advice hits the same held handle and fails identically, so the single visible
16
+ * failure is also a wrong instruction.
17
+ */
18
+ import type { UpdateReason } from './types.js';
19
+ /**
20
+ * Classify an install failure from the bounded stderr tail `runNpmInstall`
21
+ * already returns.
22
+ *
23
+ * ONE PARAMETER, NOT TWO. An earlier draft took `exitCode` and never read it:
24
+ * npm exits `1` for every one of these, so the code carries no information the
25
+ * tail does not, and a parameter that no branch and no test consumes is a
26
+ * maintenance liability that reads like a promise (P2-2).
27
+ *
28
+ * An absent or unrecognised tail is `install-failed` - the pre-H2 behaviour, so
29
+ * a machine whose npm says something we have never seen degrades to exactly what
30
+ * it did before rather than to a confident wrong answer.
31
+ */
32
+ export declare function classifyInstallFailure(tail: string | undefined): UpdateReason;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Turning a tail of npm output into a reason (cli-auto-update-hardening H2 /
3
+ * section 5.3).
4
+ *
5
+ * ASCII ONLY - this tree is inside the glyph scanner's scope (C-2 / C-3).
6
+ *
7
+ * PURE, AND THE ONLY IMPORT IS A TYPE. The interesting half of H2 is not this
8
+ * table, it is the REPORTING rule that decides which of the resulting reasons
9
+ * may break the three-strike silence - and that lives in `types.ts` beside
10
+ * `shouldRenderUpdateLine`, as `IMMEDIATE_NOTICE_REASONS`.
11
+ *
12
+ * WHY THIS EXISTS AT ALL. The one line a user is ever allowed to see on the
13
+ * failure path reads `update failed <dot> npm i -g @aragon-agent/cli` for EVERY
14
+ * cause. For `EPERM` / `EBUSY` - the Windows self-replacement case, R-3 - that
15
+ * advice hits the same held handle and fails identically, so the single visible
16
+ * failure is also a wrong instruction.
17
+ */
18
+ /**
19
+ * FIRST MATCH WINS, AND THE ORDER IS NOT ALPHABETICAL.
20
+ *
21
+ * `EPERM` is tested before `EACCES` because npm's Windows output frequently
22
+ * carries BOTH - the `EPERM` on the shim rename plus an `EACCES` from a retry -
23
+ * and the actionable one is the first. `network` is last because "proxy" and
24
+ * "network" appear inside unrelated npm advisory text often enough that an
25
+ * earlier position would swallow the three specific cases above it.
26
+ */
27
+ const TABLE = [
28
+ [
29
+ /\bEPERM\b|\bEBUSY\b|\bETXTBSY\b|operation not permitted|resource busy or locked/i,
30
+ 'blocked-by-os',
31
+ ],
32
+ [/\bEACCES\b|permission denied/i, 'not-writable'],
33
+ [/\bENOSPC\b|no space left/i, 'no-space'],
34
+ [
35
+ /\bENOTFOUND\b|\bEAI_AGAIN\b|\bECONNRESET\b|\bETIMEDOUT\b|network|proxy|tunneling socket/i,
36
+ 'network',
37
+ ],
38
+ ];
39
+ /**
40
+ * Classify an install failure from the bounded stderr tail `runNpmInstall`
41
+ * already returns.
42
+ *
43
+ * ONE PARAMETER, NOT TWO. An earlier draft took `exitCode` and never read it:
44
+ * npm exits `1` for every one of these, so the code carries no information the
45
+ * tail does not, and a parameter that no branch and no test consumes is a
46
+ * maintenance liability that reads like a promise (P2-2).
47
+ *
48
+ * An absent or unrecognised tail is `install-failed` - the pre-H2 behaviour, so
49
+ * a machine whose npm says something we have never seen degrades to exactly what
50
+ * it did before rather than to a confident wrong answer.
51
+ */
52
+ export function classifyInstallFailure(tail) {
53
+ if (!tail)
54
+ return 'install-failed';
55
+ for (const [re, reason] of TABLE) {
56
+ if (re.test(tail))
57
+ return reason;
58
+ }
59
+ return 'install-failed';
60
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The cross-process install lock (cli-auto-update section 3.7).
3
+ *
4
+ * ASCII ONLY - this tree is inside the glyph scanner's scope (C-2 / C-3).
5
+ *
6
+ * INHERITS `skills/lock.ts`'s PAYLOAD DISCIPLINE, NOT ITS ACQUISITION LOOP
7
+ * (C-9). The payload shape, the TTL, the liveness probe, the
8
+ * `LOCK_UNREADABLE_CONFIRMATIONS` rule and the uuid-guarded release are all the
9
+ * same and are imported rather than restated. The retry loop is NOT:
10
+ *
11
+ * INVARIANT U-5b - ACQUISITION IS A SINGLE NON-BLOCKING ATTEMPT.
12
+ * `acquireRootLock` busy-waits SYNCHRONOUSLY through `Atomics.wait` for up to
13
+ * `LOCK_MAX_WAIT_MS = 5_000` and then THROWS. That is right for `aragon skills
14
+ * install`, where a human typed the command and there is nothing else to do.
15
+ * Here it would freeze the Ink render loop for five seconds, from a background
16
+ * timer, in the feature whose entire requirement is that it is invisible. So:
17
+ * one `wx` attempt, no sleep, no retry, no throw - `tryAcquireInstallLock()`
18
+ * returns `null` on `EEXIST` after the staleness check, and the caller treats
19
+ * that as INFORMATION (someone else is already installing) rather than failure.
20
+ */
21
+ export interface InstallLockHandle {
22
+ /**
23
+ * Phase two of U-4: rewrite the payload in place with the CHILD's pid.
24
+ *
25
+ * `uuid` and `startedAt` are preserved - the uuid is what `release()` matches
26
+ * on, and `startedAt` is what the TTL measures, so replacing either here would
27
+ * silently break one of the two recovery paths.
28
+ */
29
+ adoptChild(pid: number): void;
30
+ release(): void;
31
+ }
32
+ export interface InstallLockOptions {
33
+ now?: () => number;
34
+ /** Injected for tests; defaults to `process.kill(pid, 0)` liveness probing. */
35
+ isProcessAlive?: (pid: number) => boolean;
36
+ /** Injected for tests; defaults to `<home>/update-install.lock`. */
37
+ path?: string;
38
+ }
39
+ /**
40
+ * Take the install lock, or report that someone else has it. NEVER SLEEPS,
41
+ * NEVER THROWS (U-5b / AC-27).
42
+ *
43
+ * The payload written here carries the PARENT's pid, and that is not a mistake:
44
+ * INVARIANT U-4 says the lock must name the CHILD, but the lock has to be held
45
+ * BEFORE the spawn (or the race it exists to close is wide open) and `child.pid`
46
+ * does not exist UNTIL AFTER it. `skills/lock.ts::tryCreate` writes its payload
47
+ * at create time, so a literal mirror can only ever write the parent's. Hence
48
+ * two phases: this call, then `adoptChild(child.pid)` the moment `execFile`
49
+ * returns.
50
+ *
51
+ * The window between them is SAFE rather than merely narrow: a crash inside it
52
+ * leaves a lock naming a DEAD PARENT, which is exactly the case the liveness
53
+ * probe below already reclaims.
54
+ */
55
+ export declare function tryAcquireInstallLock(opts?: InstallLockOptions): InstallLockHandle | null;