@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.
Files changed (52) 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 +283 -2
  8. package/dist/commands/builtins.js +118 -0
  9. package/dist/commands/registry.d.ts +17 -0
  10. package/dist/config/app-paths.d.ts +31 -0
  11. package/dist/config/app-paths.js +37 -0
  12. package/dist/config/cli-commands.d.ts +27 -1
  13. package/dist/config/cli-commands.js +51 -2
  14. package/dist/config/env.d.ts +10 -1
  15. package/dist/config/env.js +41 -1
  16. package/dist/config/load.d.ts +17 -0
  17. package/dist/config/load.js +25 -1
  18. package/dist/config/schema.d.ts +56 -0
  19. package/dist/config/schema.js +81 -0
  20. package/dist/config/store.js +13 -1
  21. package/dist/launcher.d.ts +14 -0
  22. package/dist/launcher.js +43 -0
  23. package/dist/logging/logger.d.ts +1 -1
  24. package/dist/ui/App.d.ts +11 -1
  25. package/dist/ui/App.js +88 -2
  26. package/dist/ui/BottomStatusRow.d.ts +25 -3
  27. package/dist/ui/BottomStatusRow.js +10 -7
  28. package/dist/ui/UpdateLine.d.ts +44 -0
  29. package/dist/ui/UpdateLine.js +129 -0
  30. package/dist/update/classify-failure.d.ts +32 -0
  31. package/dist/update/classify-failure.js +60 -0
  32. package/dist/update/install-lock.d.ts +55 -0
  33. package/dist/update/install-lock.js +184 -0
  34. package/dist/update/install-source.d.ts +97 -0
  35. package/dist/update/install-source.js +218 -0
  36. package/dist/update/installer.d.ts +108 -0
  37. package/dist/update/installer.js +242 -0
  38. package/dist/update/limits.d.ts +111 -0
  39. package/dist/update/limits.js +111 -0
  40. package/dist/update/npm-view.d.ts +62 -0
  41. package/dist/update/npm-view.js +133 -0
  42. package/dist/update/registry.d.ts +52 -0
  43. package/dist/update/registry.js +161 -0
  44. package/dist/update/semver.d.ts +89 -0
  45. package/dist/update/semver.js +295 -0
  46. package/dist/update/service.d.ts +193 -0
  47. package/dist/update/service.js +636 -0
  48. package/dist/update/state.d.ts +103 -0
  49. package/dist/update/state.js +142 -0
  50. package/dist/update/types.d.ts +169 -0
  51. package/dist/update/types.js +68 -0
  52. package/package.json +3 -3
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The install (cli-auto-update section 3.5).
3
+ *
4
+ * ASCII ONLY - this tree is inside the glyph scanner's scope (C-2 / C-3).
5
+ *
6
+ * INVARIANT U-2 - INVOKE NPM'S JS ENTRY THROUGH `process.execPath`, NEVER THE
7
+ * `npm` SHIM. On Windows `npm` is `npm.cmd`, and since the CVE-2024-27980 fix
8
+ * Node refuses to spawn a `.cmd` without `shell: true` - which C-7 forbids
9
+ * outright. Running node's own binary also guarantees the update is installed by
10
+ * the same runtime that will execute it.
11
+ *
12
+ * INVARIANT U-5 - THE CHILD'S OUTPUT GOES TO A FILE, AND ITS LIFETIME IS NEVER
13
+ * BOUNDED BY US. `'ignore'` leaves nothing to put in `update_install_failed`'s
14
+ * `stderrTail`, which is the one diagnostic a feature whose contract is silence
15
+ * actually needs; a PIPE would break D-10, because a detached child whose parent
16
+ * has exited takes `EPIPE` on its next write. A real file descriptor is
17
+ * inherited across the detach and keeps working after the parent is gone.
18
+ *
19
+ * `spawn`, NOT `execFile`, AND THAT IS A CORRECTION TO THE DESIGN (IF-3).
20
+ * Section 3.5 spelled this call as `execFile(..., { detached: true, stdio:
21
+ * [...], timeout: 0 })`, but `execFile` forwards NEITHER `detached` NOR `stdio`
22
+ * to `spawn` - it always builds its own pipes so it can buffer the output it
23
+ * hands to its callback. Measured on this package's own Node: with
24
+ * `stdio: ['ignore', fd, fd]` the log file stays EMPTY and the bytes arrive in
25
+ * the callback's `stdout` instead. Written as designed, U-5 loses its
26
+ * `stderrTail` AND D-10 loses the detach, silently and on every platform.
27
+ * `spawn` with an argv array and an explicit `shell: false` satisfies C-7's
28
+ * actual requirement ("argv array, never a shell, never a concatenated command
29
+ * line"), and it has no `timeout` option at all - so P1-5's "the limit must
30
+ * never become the child's bound" holds STRUCTURALLY here rather than by
31
+ * remembering to leave a field at `0`.
32
+ */
33
+ import { type SpawnOptions } from 'node:child_process';
34
+ /** The child handle this module needs. Injectable so no test spawns npm. */
35
+ export interface SpawnedChild {
36
+ pid?: number | undefined;
37
+ on(event: 'exit', listener: (code: number | null) => void): unknown;
38
+ on(event: 'error', listener: (err: Error) => void): unknown;
39
+ unref?: () => void;
40
+ }
41
+ export type SpawnImpl = (file: string, args: string[], options: SpawnOptions) => SpawnedChild;
42
+ export interface InstallResult {
43
+ /** `true` only when npm exited 0 AND the classified root actually moved. */
44
+ ok: boolean;
45
+ /**
46
+ * Why not, when not. `'ineffective'` is U-6's case and is NOT a failure of
47
+ * npm - it is a failure of our assumption about WHERE npm writes.
48
+ */
49
+ failure?: 'spawn' | 'exit' | 'ineffective';
50
+ /** What `<root>/package.json` reported after the install. */
51
+ observedVersion?: string;
52
+ /** Bounded tail of the install log. The caller redacts before logging it. */
53
+ stderrTail?: string;
54
+ exitCode?: number;
55
+ elapsedMs?: number;
56
+ }
57
+ export interface InstallDeps {
58
+ spawnImpl?: SpawnImpl;
59
+ now?: () => number;
60
+ /** `null` forces the "no npm entry point" branch; `undefined` probes. */
61
+ npmCliPath?: string | null;
62
+ logPath?: string;
63
+ /** Injected so U-6's re-read can be exercised without a real install. */
64
+ readVersionAt?: (root: string) => string | null;
65
+ env?: NodeJS.ProcessEnv;
66
+ }
67
+ export interface RunNpmInstallInput {
68
+ packageName: string;
69
+ version: string;
70
+ /** The root section 3.2 classified; U-6 re-reads its manifest afterwards. */
71
+ root: string;
72
+ /** Called with the child's pid the instant it exists - phase two of U-4. */
73
+ onSpawn?: (pid: number) => void;
74
+ deps?: InstallDeps;
75
+ }
76
+ /**
77
+ * Where npm's JS entry point lives, or `null`.
78
+ *
79
+ * Probes the Windows / nvm4w layout first and the POSIX one second. There is
80
+ * deliberately NO bare-`npm`-on-PATH fallback: on Windows that resolves to
81
+ * `npm.cmd`, which is exactly what U-2 forbids, and a fallback that is correct
82
+ * on one platform and forbidden on another is the kind of branch that gets
83
+ * copied without its condition. A machine with no reachable `npm-cli.js`
84
+ * degrades to notify-only, which is the honest outcome.
85
+ */
86
+ export declare function resolveNpmCli(execPath?: string): string | null;
87
+ /**
88
+ * `env` minus `NODE_OPTIONS`, plus the three switches that keep npm's
89
+ * postinstall banners from spawning anything interactive.
90
+ *
91
+ * `NODE_OPTIONS` is dropped because a `--require` hook inherited into npm is an
92
+ * unnecessary surface on a process we start unattended and never watch.
93
+ */
94
+ export declare function cleanInstallEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
95
+ /** The argv this feature is allowed to build. Exported for AC-24's assertion. */
96
+ export declare function buildInstallArgs(npmCliJs: string, packageName: string, version: string): string[];
97
+ /**
98
+ * Install `packageName@version` globally, detached, and verify it landed HERE.
99
+ *
100
+ * Resolves when the child exits (or fails to start). The child is `unref()`ed
101
+ * either way, so if the user quits mid-install the install COMPLETES rather than
102
+ * leaving a global package half-written; the next launch's check then finds
103
+ * local === remote and does nothing. Convergence, not bookkeeping (D-10).
104
+ *
105
+ * NEVER REJECTS. Every failure is an `InstallResult` with `ok: false`, because
106
+ * the only caller is a background timer whose contract is silence.
107
+ */
108
+ export declare function runNpmInstall(input: RunNpmInstallInput): Promise<InstallResult>;
@@ -0,0 +1,242 @@
1
+ /**
2
+ * The install (cli-auto-update section 3.5).
3
+ *
4
+ * ASCII ONLY - this tree is inside the glyph scanner's scope (C-2 / C-3).
5
+ *
6
+ * INVARIANT U-2 - INVOKE NPM'S JS ENTRY THROUGH `process.execPath`, NEVER THE
7
+ * `npm` SHIM. On Windows `npm` is `npm.cmd`, and since the CVE-2024-27980 fix
8
+ * Node refuses to spawn a `.cmd` without `shell: true` - which C-7 forbids
9
+ * outright. Running node's own binary also guarantees the update is installed by
10
+ * the same runtime that will execute it.
11
+ *
12
+ * INVARIANT U-5 - THE CHILD'S OUTPUT GOES TO A FILE, AND ITS LIFETIME IS NEVER
13
+ * BOUNDED BY US. `'ignore'` leaves nothing to put in `update_install_failed`'s
14
+ * `stderrTail`, which is the one diagnostic a feature whose contract is silence
15
+ * actually needs; a PIPE would break D-10, because a detached child whose parent
16
+ * has exited takes `EPIPE` on its next write. A real file descriptor is
17
+ * inherited across the detach and keeps working after the parent is gone.
18
+ *
19
+ * `spawn`, NOT `execFile`, AND THAT IS A CORRECTION TO THE DESIGN (IF-3).
20
+ * Section 3.5 spelled this call as `execFile(..., { detached: true, stdio:
21
+ * [...], timeout: 0 })`, but `execFile` forwards NEITHER `detached` NOR `stdio`
22
+ * to `spawn` - it always builds its own pipes so it can buffer the output it
23
+ * hands to its callback. Measured on this package's own Node: with
24
+ * `stdio: ['ignore', fd, fd]` the log file stays EMPTY and the bytes arrive in
25
+ * the callback's `stdout` instead. Written as designed, U-5 loses its
26
+ * `stderrTail` AND D-10 loses the detach, silently and on every platform.
27
+ * `spawn` with an argv array and an explicit `shell: false` satisfies C-7's
28
+ * actual requirement ("argv array, never a shell, never a concatenated command
29
+ * line"), and it has no `timeout` option at all - so P1-5's "the limit must
30
+ * never become the child's bound" holds STRUCTURALLY here rather than by
31
+ * remembering to leave a field at `0`.
32
+ */
33
+ import { spawn } from 'node:child_process';
34
+ import { closeSync, existsSync, openSync, readFileSync, rmSync, statSync } from 'node:fs';
35
+ import { dirname, resolve } from 'node:path';
36
+ import process from 'node:process';
37
+ import { getUpdateInstallLogPath } from '../config/app-paths.js';
38
+ import { UPDATE_LIMITS } from './limits.js';
39
+ import { readSelfManifest } from './install-source.js';
40
+ import { STRICT_VERSION_RE } from './semver.js';
41
+ /**
42
+ * Where npm's JS entry point lives, or `null`.
43
+ *
44
+ * Probes the Windows / nvm4w layout first and the POSIX one second. There is
45
+ * deliberately NO bare-`npm`-on-PATH fallback: on Windows that resolves to
46
+ * `npm.cmd`, which is exactly what U-2 forbids, and a fallback that is correct
47
+ * on one platform and forbidden on another is the kind of branch that gets
48
+ * copied without its condition. A machine with no reachable `npm-cli.js`
49
+ * degrades to notify-only, which is the honest outcome.
50
+ */
51
+ export function resolveNpmCli(execPath = process.execPath) {
52
+ const nodeDir = dirname(execPath);
53
+ const candidates = [
54
+ resolve(nodeDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
55
+ resolve(nodeDir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
56
+ ];
57
+ for (const candidate of candidates) {
58
+ try {
59
+ if (existsSync(candidate) && statSync(candidate).isFile())
60
+ return candidate;
61
+ }
62
+ catch {
63
+ // Unreadable candidate; try the next.
64
+ }
65
+ }
66
+ return null;
67
+ }
68
+ /**
69
+ * `env` minus `NODE_OPTIONS`, plus the three switches that keep npm's
70
+ * postinstall banners from spawning anything interactive.
71
+ *
72
+ * `NODE_OPTIONS` is dropped because a `--require` hook inherited into npm is an
73
+ * unnecessary surface on a process we start unattended and never watch.
74
+ */
75
+ export function cleanInstallEnv(env) {
76
+ const next = { ...env };
77
+ delete next.NODE_OPTIONS;
78
+ next.npm_config_yes = 'true';
79
+ next.ADBLOCK = '1';
80
+ next.DISABLE_OPENCOLLECTIVE = '1';
81
+ return next;
82
+ }
83
+ /** The argv this feature is allowed to build. Exported for AC-24's assertion. */
84
+ export function buildInstallArgs(npmCliJs, packageName, version) {
85
+ return [
86
+ npmCliJs,
87
+ 'install',
88
+ '--global',
89
+ `${packageName}@${version}`,
90
+ '--no-fund',
91
+ '--no-audit',
92
+ '--loglevel=error',
93
+ ];
94
+ }
95
+ function tailOf(path, maxChars) {
96
+ try {
97
+ const text = readFileSync(path, 'utf-8');
98
+ return text.length <= maxChars ? text : text.slice(-maxChars);
99
+ }
100
+ catch {
101
+ return '';
102
+ }
103
+ }
104
+ /**
105
+ * Install `packageName@version` globally, detached, and verify it landed HERE.
106
+ *
107
+ * Resolves when the child exits (or fails to start). The child is `unref()`ed
108
+ * either way, so if the user quits mid-install the install COMPLETES rather than
109
+ * leaving a global package half-written; the next launch's check then finds
110
+ * local === remote and does nothing. Convergence, not bookkeeping (D-10).
111
+ *
112
+ * NEVER REJECTS. Every failure is an `InstallResult` with `ok: false`, because
113
+ * the only caller is a background timer whose contract is silence.
114
+ */
115
+ export function runNpmInstall(input) {
116
+ const { packageName, version, root, onSpawn } = input;
117
+ const deps = input.deps ?? {};
118
+ const now = deps.now ?? Date.now;
119
+ const doSpawn = (deps.spawnImpl ?? spawn);
120
+ const logPath = deps.logPath ?? getUpdateInstallLogPath();
121
+ const readVersionAt = deps.readVersionAt ?? ((where) => readSelfManifest(where)?.version ?? null);
122
+ return new Promise((settle) => {
123
+ // The version came back from the registry and has already been through
124
+ // `parseSemver`; this is the gate that makes "not an injection surface" a
125
+ // checkable claim rather than a reasoned one (AC-24). It also satisfies
126
+ // `fetch-source.ts` rule 3 (no leading `-`) by construction.
127
+ if (!STRICT_VERSION_RE.test(version)) {
128
+ settle({ ok: false, failure: 'spawn' });
129
+ return;
130
+ }
131
+ const npmCliJs = deps.npmCliPath !== undefined ? deps.npmCliPath : resolveNpmCli();
132
+ if (!npmCliJs) {
133
+ settle({ ok: false, failure: 'spawn' });
134
+ return;
135
+ }
136
+ // A stale log from a parent that died before its child is unlinked here, so
137
+ // the tail read later can only be THIS install's (U-5).
138
+ try {
139
+ rmSync(logPath, { force: true });
140
+ }
141
+ catch {
142
+ // Best-effort; an unremovable stale log costs one misleading tail.
143
+ }
144
+ let logFd = null;
145
+ try {
146
+ logFd = openSync(logPath, 'w');
147
+ }
148
+ catch {
149
+ // No log file means no `stderrTail`, which is a diagnostic loss and not a
150
+ // reason to skip the install.
151
+ logFd = null;
152
+ }
153
+ const startedAt = now();
154
+ let settled = false;
155
+ const finish = (result) => {
156
+ if (settled)
157
+ return;
158
+ settled = true;
159
+ if (logFd !== null) {
160
+ try {
161
+ closeSync(logFd);
162
+ }
163
+ catch {
164
+ // Already closed; the child holds its own duplicated descriptor.
165
+ }
166
+ }
167
+ settle({ ...result, elapsedMs: now() - startedAt });
168
+ };
169
+ const options = {
170
+ detached: true,
171
+ stdio: ['ignore', logFd ?? 'ignore', logFd ?? 'ignore'],
172
+ env: cleanInstallEnv(deps.env ?? process.env),
173
+ // C-7, spelled out rather than inherited from the default: this argv is
174
+ // built from a manifest on disk and a registry response, and a `true` here
175
+ // would turn both into shell input.
176
+ shell: false,
177
+ windowsHide: true,
178
+ };
179
+ let child;
180
+ try {
181
+ child = doSpawn(process.execPath, buildInstallArgs(npmCliJs, packageName, version), options);
182
+ }
183
+ catch {
184
+ finish({ ok: false, failure: 'spawn' });
185
+ return;
186
+ }
187
+ child.on('error', () => finish({ ok: false, failure: 'spawn' }));
188
+ child.on('exit', (code) => {
189
+ const tail = tailOf(logPath, UPDATE_LIMITS.stderrTailChars);
190
+ try {
191
+ rmSync(logPath, { force: true });
192
+ }
193
+ catch {
194
+ // The next install unlinks it before opening.
195
+ }
196
+ if (code !== 0) {
197
+ finish({
198
+ ok: false,
199
+ failure: 'exit',
200
+ ...(tail ? { stderrTail: tail } : {}),
201
+ ...(typeof code === 'number' ? { exitCode: code } : {}),
202
+ });
203
+ return;
204
+ }
205
+ // ---------------------------------------------------------------------
206
+ // U-6 - `npm` EXITING 0 DOES NOT MEAN THIS INSTALLATION MOVED.
207
+ //
208
+ // Section 3.2 classifies the running copy by PATH; `npm install --global`
209
+ // writes to the prefix NPM'S OWN CONFIG resolves. Those are the same
210
+ // directory on an ordinary machine and different directories on one with
211
+ // `prefix=` in `.npmrc`, with two Node installations, or with a global
212
+ // root inherited from a version manager the user has since switched away
213
+ // from. Left unchecked the state machine reaches `ready` on a lie,
214
+ // `pendingRestartVersion` never matches, and the next check re-decides
215
+ // `install` - an UNBOUNDED SILENT REINSTALL, once per interval, forever
216
+ // (R-12). The check costs one `readFileSync` of a file we already know how
217
+ // to parse, and it is the only thing standing between this design and the
218
+ // worst failure shape available to a feature that promised to be invisible.
219
+ // ---------------------------------------------------------------------
220
+ const observed = readVersionAt(root);
221
+ if (observed !== version) {
222
+ finish({
223
+ ok: false,
224
+ failure: 'ineffective',
225
+ ...(observed ? { observedVersion: observed } : {}),
226
+ ...(tail ? { stderrTail: tail } : {}),
227
+ });
228
+ return;
229
+ }
230
+ finish({ ok: true, observedVersion: observed, exitCode: 0 });
231
+ });
232
+ // Phase two of U-4, and it must happen HERE: the lock had to be taken before
233
+ // the spawn, but `child.pid` does not exist until after it.
234
+ if (typeof child.pid === 'number' && onSpawn)
235
+ onSpawn(child.pid);
236
+ // Survives our own exit (D-10). `dispose()` deliberately never kills it
237
+ // (U-3): killing a running `npm install -g` is the one action in this
238
+ // feature that can leave the user's global installation broken, and it would
239
+ // be triggered by the most ordinary event there is - Ctrl+C.
240
+ child.unref?.();
241
+ });
242
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * UPDATE_LIMITS - the single authority on every structural bound the updater
3
+ * enforces (cli-auto-update section 5.4).
4
+ *
5
+ * ASCII ONLY: `src/update/**` is inside the glyph scanner's scope
6
+ * (`glyphs.test.ts`), so no literal in this tree may hold a non-ASCII byte. The
7
+ * regex there is a hardcoded directory list, which is why adding this tree and
8
+ * adding `update` to that list are the SAME change (C-2 / AC-18) - a scanner
9
+ * that silently stops scanning is worse than no scanner.
10
+ *
11
+ * TWO KINDS OF NUMBER, AND THEY ARE NOT INTERCHANGEABLE - the distinction
12
+ * `TEAM_LIMITS`, `TODO_LIMITS` and `FAST_LIMITS` all state:
13
+ *
14
+ * - The entries below are STRUCTURAL. They describe what a background check, a
15
+ * detached install and a one-row readout can physically carry. A user has no
16
+ * business tuning them.
17
+ * - The four `update.*` keys in `config/schema.ts` are POLICY: whether to
18
+ * install at all, how often to ask, which registry, which dist-tag. Those are
19
+ * clamped, persisted and user-facing.
20
+ *
21
+ * NOTHING HERE IS IMPORTED AT RUNTIME BY `cli.tsx` (AC-1). This module and
22
+ * `types.ts` are pure, dependency-free and side-effect-free; every other module
23
+ * in this tree is reached only through the dynamic `import()` in section 3.8.
24
+ */
25
+ export declare const UPDATE_LIMITS: {
26
+ /** Never contend with first paint. */
27
+ readonly startupDelayMs: 3000;
28
+ /**
29
+ * A background check has nobody waiting on it.
30
+ *
31
+ * Below `fetch-source.ts`'s `HTTP_TIMEOUT_MS` (15 s) ON PURPOSE: that one
32
+ * bounds a download a human typed a command for, this one bounds a request the
33
+ * user never asked for and must never notice.
34
+ */
35
+ readonly checkTimeoutMs: 8000;
36
+ /**
37
+ * Wall-clock budget for the `installing` PHASE and the lock's staleness
38
+ * refresh - NEVER FOR THE CHILD (U-5 / P1-5).
39
+ *
40
+ * It decides when the UI stops saying "updating" and when a lock is treated as
41
+ * abandoned. It is deliberately NOT `execFile`'s `timeout`, which stays `0`:
42
+ * wiring it there makes Node `SIGTERM` npm mid-write - the broken global
43
+ * installation U-3 and D-10 exist to prevent - and it would fire on exactly
44
+ * the slow links where a five-minute install is the correct outcome.
45
+ */
46
+ readonly installTimeoutMs: 300000;
47
+ /** The `/latest` document is ~2 KB; this is the C-8 ceiling, not a target. */
48
+ readonly manifestMaxBytes: number;
49
+ /** Matches `fetch-source.ts`'s `MAX_REDIRECTS`. */
50
+ readonly maxRedirects: 3;
51
+ /**
52
+ * Longer than `skills`' 60 s, and the difference is the point: the holder here
53
+ * is an npm install on a cold cache, not a file rename.
54
+ */
55
+ readonly lockTtlMs: 600000;
56
+ /** First retry after a failed check or install. */
57
+ readonly backoffBaseMs: 1800000;
58
+ /** Ceiling of the exponential. */
59
+ readonly backoffMaxMs: 86400000;
60
+ /**
61
+ * Consecutive failures before the row says anything at all (D-7).
62
+ *
63
+ * Below this the failure is silent, because silence is the requirement and an
64
+ * unactionable recurring warning is the standard way updaters become hated.
65
+ */
66
+ readonly failuresBeforeNotice: 3;
67
+ /** Thundering-herd spread on the check interval (D-12). */
68
+ readonly jitterRatio: 0.15;
69
+ /**
70
+ * Bounded, redacted npm error tail, read from the install LOG FILE (U-5).
71
+ *
72
+ * There is a tail to read at all only because the child writes to a real file
73
+ * descriptor rather than to `'ignore'`; a pipe would have died with the parent
74
+ * and broken D-10.
75
+ */
76
+ readonly stderrTailChars: 500;
77
+ /**
78
+ * Below this many columns the update line renders its short form (section 6.2).
79
+ *
80
+ * DELIBERATELY NOT `FAST_LIMITS.statusCompactCols`' 100 (`fast/limits.ts`).
81
+ * That number governs a multi-part status chip competing with the context
82
+ * gauge for the same row; this one governs a single short clause that owns its
83
+ * row outright. Two numbers about two different things - do not unify them.
84
+ */
85
+ readonly statusCompactCols: 60;
86
+ /**
87
+ * Non-zero exits of a version WE installed before it is rolled back
88
+ * (cli-auto-update-hardening section 7.2 / D-30).
89
+ *
90
+ * Tested against the count of PREVIOUS launches, so `2` means "the third
91
+ * launch rolls back". One crash can be transient - a machine that ran out of
92
+ * memory once, or whose session file was corrupt, gets a second chance at the
93
+ * release it just installed. Two is a pattern.
94
+ *
95
+ * NOTHING ELSE IN THIS TABLE BOUNDS THE ROLLBACK. There is deliberately no
96
+ * `bootGuardMaxAgeMs`: the guard is armed only while `autoInstalledVersion`
97
+ * equals the RUNNING version, so "a machine offline for a year" is a machine
98
+ * still running the version we installed, and rolling it back to
99
+ * `lastGoodVersion` is exactly as correct on day 400 as on day 1 (P1-3).
100
+ */
101
+ readonly crashesBeforeRollback: 2;
102
+ /**
103
+ * Bound on the `npm view` fallback probe (H3).
104
+ *
105
+ * SAFE HERE AND FORBIDDEN FOR THE INSTALLER (C-20). A killed `npm view` has
106
+ * written nothing; a killed `npm install -g` leaves a broken global
107
+ * installation, which is why `installTimeoutMs` above bounds the PHASE and
108
+ * never the child.
109
+ */
110
+ readonly npmViewTimeoutMs: 10000;
111
+ };
@@ -0,0 +1,111 @@
1
+ /**
2
+ * UPDATE_LIMITS - the single authority on every structural bound the updater
3
+ * enforces (cli-auto-update section 5.4).
4
+ *
5
+ * ASCII ONLY: `src/update/**` is inside the glyph scanner's scope
6
+ * (`glyphs.test.ts`), so no literal in this tree may hold a non-ASCII byte. The
7
+ * regex there is a hardcoded directory list, which is why adding this tree and
8
+ * adding `update` to that list are the SAME change (C-2 / AC-18) - a scanner
9
+ * that silently stops scanning is worse than no scanner.
10
+ *
11
+ * TWO KINDS OF NUMBER, AND THEY ARE NOT INTERCHANGEABLE - the distinction
12
+ * `TEAM_LIMITS`, `TODO_LIMITS` and `FAST_LIMITS` all state:
13
+ *
14
+ * - The entries below are STRUCTURAL. They describe what a background check, a
15
+ * detached install and a one-row readout can physically carry. A user has no
16
+ * business tuning them.
17
+ * - The four `update.*` keys in `config/schema.ts` are POLICY: whether to
18
+ * install at all, how often to ask, which registry, which dist-tag. Those are
19
+ * clamped, persisted and user-facing.
20
+ *
21
+ * NOTHING HERE IS IMPORTED AT RUNTIME BY `cli.tsx` (AC-1). This module and
22
+ * `types.ts` are pure, dependency-free and side-effect-free; every other module
23
+ * in this tree is reached only through the dynamic `import()` in section 3.8.
24
+ */
25
+ export const UPDATE_LIMITS = {
26
+ /** Never contend with first paint. */
27
+ startupDelayMs: 3_000,
28
+ /**
29
+ * A background check has nobody waiting on it.
30
+ *
31
+ * Below `fetch-source.ts`'s `HTTP_TIMEOUT_MS` (15 s) ON PURPOSE: that one
32
+ * bounds a download a human typed a command for, this one bounds a request the
33
+ * user never asked for and must never notice.
34
+ */
35
+ checkTimeoutMs: 8_000,
36
+ /**
37
+ * Wall-clock budget for the `installing` PHASE and the lock's staleness
38
+ * refresh - NEVER FOR THE CHILD (U-5 / P1-5).
39
+ *
40
+ * It decides when the UI stops saying "updating" and when a lock is treated as
41
+ * abandoned. It is deliberately NOT `execFile`'s `timeout`, which stays `0`:
42
+ * wiring it there makes Node `SIGTERM` npm mid-write - the broken global
43
+ * installation U-3 and D-10 exist to prevent - and it would fire on exactly
44
+ * the slow links where a five-minute install is the correct outcome.
45
+ */
46
+ installTimeoutMs: 300_000,
47
+ /** The `/latest` document is ~2 KB; this is the C-8 ceiling, not a target. */
48
+ manifestMaxBytes: 256 * 1024,
49
+ /** Matches `fetch-source.ts`'s `MAX_REDIRECTS`. */
50
+ maxRedirects: 3,
51
+ /**
52
+ * Longer than `skills`' 60 s, and the difference is the point: the holder here
53
+ * is an npm install on a cold cache, not a file rename.
54
+ */
55
+ lockTtlMs: 600_000,
56
+ /** First retry after a failed check or install. */
57
+ backoffBaseMs: 1_800_000,
58
+ /** Ceiling of the exponential. */
59
+ backoffMaxMs: 86_400_000,
60
+ /**
61
+ * Consecutive failures before the row says anything at all (D-7).
62
+ *
63
+ * Below this the failure is silent, because silence is the requirement and an
64
+ * unactionable recurring warning is the standard way updaters become hated.
65
+ */
66
+ failuresBeforeNotice: 3,
67
+ /** Thundering-herd spread on the check interval (D-12). */
68
+ jitterRatio: 0.15,
69
+ /**
70
+ * Bounded, redacted npm error tail, read from the install LOG FILE (U-5).
71
+ *
72
+ * There is a tail to read at all only because the child writes to a real file
73
+ * descriptor rather than to `'ignore'`; a pipe would have died with the parent
74
+ * and broken D-10.
75
+ */
76
+ stderrTailChars: 500,
77
+ /**
78
+ * Below this many columns the update line renders its short form (section 6.2).
79
+ *
80
+ * DELIBERATELY NOT `FAST_LIMITS.statusCompactCols`' 100 (`fast/limits.ts`).
81
+ * That number governs a multi-part status chip competing with the context
82
+ * gauge for the same row; this one governs a single short clause that owns its
83
+ * row outright. Two numbers about two different things - do not unify them.
84
+ */
85
+ statusCompactCols: 60,
86
+ /**
87
+ * Non-zero exits of a version WE installed before it is rolled back
88
+ * (cli-auto-update-hardening section 7.2 / D-30).
89
+ *
90
+ * Tested against the count of PREVIOUS launches, so `2` means "the third
91
+ * launch rolls back". One crash can be transient - a machine that ran out of
92
+ * memory once, or whose session file was corrupt, gets a second chance at the
93
+ * release it just installed. Two is a pattern.
94
+ *
95
+ * NOTHING ELSE IN THIS TABLE BOUNDS THE ROLLBACK. There is deliberately no
96
+ * `bootGuardMaxAgeMs`: the guard is armed only while `autoInstalledVersion`
97
+ * equals the RUNNING version, so "a machine offline for a year" is a machine
98
+ * still running the version we installed, and rolling it back to
99
+ * `lastGoodVersion` is exactly as correct on day 400 as on day 1 (P1-3).
100
+ */
101
+ crashesBeforeRollback: 2,
102
+ /**
103
+ * Bound on the `npm view` fallback probe (H3).
104
+ *
105
+ * SAFE HERE AND FORBIDDEN FOR THE INSTALLER (C-20). A killed `npm view` has
106
+ * written nothing; a killed `npm install -g` leaves a broken global
107
+ * installation, which is why `installTimeoutMs` above bounds the PHASE and
108
+ * never the child.
109
+ */
110
+ npmViewTimeoutMs: 10_000,
111
+ };
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The proxy-aware fallback probe (cli-auto-update-hardening H3 / section 5.4).
3
+ *
4
+ * ASCII ONLY - this tree is inside the glyph scanner's scope (C-2 / C-3).
5
+ *
6
+ * NODE'S `fetch` (undici) IGNORES `HTTP_PROXY`. v1 recorded that as R-5 and
7
+ * accepted it: "the check fails, silently, forever". When the direct request
8
+ * returns `null`, the check now retries ONCE through the npm this machine
9
+ * already has, which honours `proxy`, `https-proxy`, `.npmrc` and any auth the
10
+ * user configured.
11
+ *
12
+ * WE DELEGATE RATHER THAN IMPLEMENT A PROXY (D-34). We do not read `.npmrc`, we
13
+ * do not parse `HTTP_PROXY`, and we attach credentials to nothing - D-13's "NO
14
+ * CREDENTIALS, EVER" is untouched because we never see them. We run the command
15
+ * the user could have typed and read one version string out of its JSON.
16
+ *
17
+ * `execFile` HERE, `spawn` IN `installer.ts`, AND THAT IS NOT AN INCONSISTENCY
18
+ * (D-36 / C-20). v1's IF-3 rejected `execFile` for the installer because it
19
+ * forwards neither `stdio` nor `detached`. This probe wants neither: it wants
20
+ * the buffered stdout `execFile` exists to provide, and it must NOT be detached.
21
+ * `timeout` is likewise safe here and forbidden there - a killed `npm view` has
22
+ * written nothing, a killed `npm install -g` has left a broken global install.
23
+ */
24
+ import { type ExecFileOptions } from 'node:child_process';
25
+ import type { LatestManifest } from './types.js';
26
+ /** The `execFile` shape this module needs. Injectable so no test spawns npm. */
27
+ export type ExecFileImpl = (file: string, args: string[], options: ExecFileOptions, callback: (error: Error | null, stdout: string, stderr: string) => void) => unknown;
28
+ export interface NpmViewDeps {
29
+ execFileImpl?: ExecFileImpl;
30
+ /** `null` forces the "no npm entry point" branch; `undefined` probes. */
31
+ npmCliPath?: string | null;
32
+ env?: NodeJS.ProcessEnv;
33
+ timeoutMs?: number;
34
+ }
35
+ /**
36
+ * The argv this probe is allowed to build. Exported for AC-47's assertion, on
37
+ * the `buildInstallArgs` precedent.
38
+ *
39
+ * `--registry` IS NOT OPTIONAL, and leaving it out was a real divergence (P1-4 /
40
+ * D-41). The HTTP path resolves `update.registry` through `resolveRegistryUrl`;
41
+ * `npm view` without the flag obeys whatever `.npmrc` says. The two would then
42
+ * answer about DIFFERENT REGISTRIES - and the population where they differ is a
43
+ * mirror-configured corporate machine, which is the same population this
44
+ * fallback exists for. The `registry` passed in is the already-resolved string
45
+ * `runCheck` computed, so the two probes describe one registry by construction.
46
+ *
47
+ * Auth is unaffected: `--registry` selects a URL, and npm still applies whatever
48
+ * `.npmrc` credentials are scoped to it. We neither read nor forward any (D-13).
49
+ */
50
+ export declare function buildNpmViewArgs(npmCliJs: string, packageName: string, distTag: string, registry: string): string[];
51
+ /**
52
+ * Ask the machine's own npm what the dist-tag points at, or `null`.
53
+ *
54
+ * NEVER THROWS - the same contract as `fetchLatestManifest`, for the same
55
+ * reason: the only caller is a background timer whose entire contract is that
56
+ * the user does not find out. Any non-zero exit, any timeout, any unparseable
57
+ * stdout and a `resolveNpmCli()` of `null` all resolve to `null`.
58
+ *
59
+ * NO BARE-`npm`-ON-PATH FALLBACK, for the reason `installer.ts` already gives:
60
+ * on Windows that resolves to `npm.cmd`, which U-2 forbids.
61
+ */
62
+ export declare function fetchLatestViaNpm(registry: string, packageName: string, distTag: string, deps?: NpmViewDeps): Promise<LatestManifest | null>;