@aragon-agent/cli 0.5.9 → 0.5.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1157 -1026
- package/README.md +123 -0
- package/dist/boot/guard.d.ts +135 -0
- package/dist/boot/guard.js +218 -0
- package/dist/boot/rollback.d.ts +86 -0
- package/dist/boot/rollback.js +185 -0
- package/dist/cli.js +376 -2
- package/dist/commands/builtins.js +118 -0
- package/dist/commands/perf.d.ts +5 -1
- package/dist/commands/perf.js +43 -1
- package/dist/commands/registry.d.ts +17 -0
- package/dist/config/app-paths.d.ts +31 -0
- package/dist/config/app-paths.js +37 -0
- package/dist/config/cli-commands.d.ts +27 -1
- package/dist/config/cli-commands.js +51 -2
- package/dist/config/env.d.ts +11 -1
- package/dist/config/env.js +58 -1
- package/dist/config/load.d.ts +30 -0
- package/dist/config/load.js +56 -1
- package/dist/config/schema.d.ts +91 -0
- package/dist/config/schema.js +107 -0
- package/dist/config/store.js +13 -1
- package/dist/launcher.d.ts +14 -0
- package/dist/launcher.js +43 -0
- package/dist/logging/logger.d.ts +1 -1
- package/dist/ui/App.d.ts +11 -1
- package/dist/ui/App.js +88 -2
- package/dist/ui/BottomStatusRow.d.ts +25 -3
- package/dist/ui/BottomStatusRow.js +10 -7
- package/dist/ui/PromptInput.d.ts +10 -0
- package/dist/ui/PromptInput.js +41 -48
- package/dist/ui/UpdateLine.d.ts +44 -0
- package/dist/ui/UpdateLine.js +129 -0
- package/dist/ui/editor-reducer.d.ts +62 -0
- package/dist/ui/editor-reducer.js +90 -0
- package/dist/ui/frame-differ.d.ts +95 -0
- package/dist/ui/frame-differ.js +230 -0
- package/dist/ui/stdout-frame-writer.d.ts +41 -0
- package/dist/ui/stdout-frame-writer.js +102 -0
- package/dist/update/classify-failure.d.ts +32 -0
- package/dist/update/classify-failure.js +60 -0
- package/dist/update/install-lock.d.ts +55 -0
- package/dist/update/install-lock.js +184 -0
- package/dist/update/install-source.d.ts +97 -0
- package/dist/update/install-source.js +218 -0
- package/dist/update/installer.d.ts +108 -0
- package/dist/update/installer.js +242 -0
- package/dist/update/limits.d.ts +111 -0
- package/dist/update/limits.js +111 -0
- package/dist/update/npm-view.d.ts +62 -0
- package/dist/update/npm-view.js +133 -0
- package/dist/update/registry.d.ts +52 -0
- package/dist/update/registry.js +161 -0
- package/dist/update/semver.d.ts +89 -0
- package/dist/update/semver.js +295 -0
- package/dist/update/service.d.ts +193 -0
- package/dist/update/service.js +636 -0
- package/dist/update/state.d.ts +103 -0
- package/dist/update/state.js +142 -0
- package/dist/update/types.d.ts +169 -0
- package/dist/update/types.js +68 -0
- package/package.json +3 -3
|
@@ -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;
|