@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,184 @@
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
+ import { closeSync, openSync, readFileSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
22
+ import { randomUUID } from 'node:crypto';
23
+ import { hostname } from 'node:os';
24
+ import process from 'node:process';
25
+ import { getUpdateLockPath } from '../config/app-paths.js';
26
+ import { LOCK_UNREADABLE_CONFIRMATIONS } from '../skills/lock.js';
27
+ import { UPDATE_LIMITS } from './limits.js';
28
+ /**
29
+ * `ESRCH` means gone. `EPERM` means the process EXISTS but belongs to another
30
+ * user - treating that as dead would let one user steal another's lock, so it
31
+ * reports alive. Verbatim from `skills/lock.ts`.
32
+ */
33
+ function defaultIsProcessAlive(pid) {
34
+ try {
35
+ process.kill(pid, 0);
36
+ return true;
37
+ }
38
+ catch (err) {
39
+ return err.code !== 'ESRCH';
40
+ }
41
+ }
42
+ function readLock(path) {
43
+ try {
44
+ const parsed = JSON.parse(readFileSync(path, 'utf-8'));
45
+ if (!parsed || typeof parsed !== 'object')
46
+ return null;
47
+ if (typeof parsed.pid !== 'number' || typeof parsed.startedAt !== 'number')
48
+ return null;
49
+ if (typeof parsed.uuid !== 'string' || typeof parsed.host !== 'string')
50
+ return null;
51
+ return parsed;
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ /** `wx` - atomic exclusive create. THIS SINGLE CALL IS THE MUTUAL EXCLUSION;
58
+ * everything else in this file is recovery policy. */
59
+ function tryCreate(path, payload) {
60
+ let fd;
61
+ try {
62
+ fd = openSync(path, 'wx');
63
+ }
64
+ catch (err) {
65
+ if (err.code === 'EEXIST')
66
+ return false;
67
+ // An unwritable home is not a lock conflict, but it is equally a reason not
68
+ // to install; the caller cannot act on the difference.
69
+ return false;
70
+ }
71
+ try {
72
+ writeSync(fd, JSON.stringify(payload));
73
+ }
74
+ finally {
75
+ // Closed IMMEDIATELY: on Windows an open handle blocks `unlink`, so holding
76
+ // it would make our own release fail and turn every lock into a
77
+ // ten-minute stall for the next process.
78
+ closeSync(fd);
79
+ }
80
+ return true;
81
+ }
82
+ /**
83
+ * Take the install lock, or report that someone else has it. NEVER SLEEPS,
84
+ * NEVER THROWS (U-5b / AC-27).
85
+ *
86
+ * The payload written here carries the PARENT's pid, and that is not a mistake:
87
+ * INVARIANT U-4 says the lock must name the CHILD, but the lock has to be held
88
+ * BEFORE the spawn (or the race it exists to close is wide open) and `child.pid`
89
+ * does not exist UNTIL AFTER it. `skills/lock.ts::tryCreate` writes its payload
90
+ * at create time, so a literal mirror can only ever write the parent's. Hence
91
+ * two phases: this call, then `adoptChild(child.pid)` the moment `execFile`
92
+ * returns.
93
+ *
94
+ * The window between them is SAFE rather than merely narrow: a crash inside it
95
+ * leaves a lock naming a DEAD PARENT, which is exactly the case the liveness
96
+ * probe below already reclaims.
97
+ */
98
+ export function tryAcquireInstallLock(opts = {}) {
99
+ const now = opts.now ?? Date.now;
100
+ const isAlive = opts.isProcessAlive ?? defaultIsProcessAlive;
101
+ const path = opts.path ?? getUpdateLockPath();
102
+ const payload = {
103
+ pid: process.pid,
104
+ host: hostname(),
105
+ startedAt: now(),
106
+ uuid: randomUUID(),
107
+ };
108
+ if (tryCreate(path, payload))
109
+ return makeHandle(path, payload);
110
+ // Held. The ONLY thing we may do about it is decide whether the holder is
111
+ // plausibly dead - and even then we get exactly one more `wx` attempt, because
112
+ // two processes can judge the same lock stale at the same moment and `wx` is
113
+ // the only thing entitled to decide between them.
114
+ const holder = readLock(path);
115
+ let stale;
116
+ if (holder === null) {
117
+ // One unreadable read is indistinguishable from a lock that is mid-creation
118
+ // (create and write are two syscalls), so it is NOT evidence of abandonment.
119
+ // With a single attempt there is no second probe to accumulate, so an
120
+ // unreadable lock is left alone unless the discipline says one look is
121
+ // enough - which, at `LOCK_UNREADABLE_CONFIRMATIONS = 2`, it is not.
122
+ stale = LOCK_UNREADABLE_CONFIRMATIONS <= 1;
123
+ }
124
+ else {
125
+ stale =
126
+ now() - holder.startedAt > UPDATE_LIMITS.lockTtlMs ||
127
+ (holder.host === payload.host && !isAlive(holder.pid));
128
+ }
129
+ if (!stale)
130
+ return null;
131
+ try {
132
+ unlinkSync(path);
133
+ }
134
+ catch {
135
+ // Another process won the race to clean it up, or the file is pinned by a
136
+ // scanner holding a handle open on Windows. Either way: not ours this tick.
137
+ return null;
138
+ }
139
+ return tryCreate(path, payload) ? makeHandle(path, payload) : null;
140
+ }
141
+ function makeHandle(path, payload) {
142
+ let released = false;
143
+ let current = payload;
144
+ return {
145
+ adoptChild(pid) {
146
+ if (released)
147
+ return;
148
+ const next = { ...current, pid };
149
+ try {
150
+ // A plain overwrite and NOT `wx`: we already own this file, and a
151
+ // rename-based atomic swap would break the `wx` invariant for whoever is
152
+ // probing it at this instant.
153
+ writeFileSync(path, JSON.stringify(next), 'utf-8');
154
+ current = next;
155
+ }
156
+ catch {
157
+ // The lock keeps naming the parent. That degrades to the pre-U-4
158
+ // behaviour for this one install - a lock that may be judged stale early
159
+ // - and is strictly better than aborting an install that is already
160
+ // running.
161
+ }
162
+ },
163
+ release() {
164
+ if (released)
165
+ return;
166
+ released = true;
167
+ try {
168
+ const holder = readLock(path);
169
+ // THE UUID CHECK IS NOT DEFENSIVE PADDING (D-A10's argument, verbatim).
170
+ // If this process was judged stale and preempted, an unconditional
171
+ // `unlink` here would delete the NEW holder's lock and open a second
172
+ // concurrency window - the classic file-lock bug, and one that only
173
+ // shows up under exactly the contention the lock exists to handle.
174
+ if (holder?.uuid !== current.uuid)
175
+ return;
176
+ unlinkSync(path);
177
+ }
178
+ catch {
179
+ // A failed release costs at most one TTL of waiting for the next
180
+ // process. Letting it throw would end a SUCCESSFUL install in an error.
181
+ }
182
+ },
183
+ };
184
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The eligibility ladder (cli-auto-update section 3.2).
3
+ *
4
+ * ASCII ONLY - this tree is inside the glyph scanner's scope (C-2 / C-3).
5
+ *
6
+ * THE DANGEROUS VERSION OF THIS FEATURE RUNS `npm i -g` ON A MACHINE WHERE
7
+ * `aragon` CAME FROM SOMEWHERE ELSE. From `npx`, from a pnpm store, from a
8
+ * project's `node_modules`, from volta, or from a clone of this monorepo - in
9
+ * every one of those the global install either does not exist, is not what is
10
+ * running, or is actively wrong to touch. This module classifies the running
11
+ * installation from its own real path and reports which of those it is; the
12
+ * service auto-installs for exactly one classification and degrades everything
13
+ * else to a one-line notice carrying the correct command for THAT manager.
14
+ */
15
+ import type { InstallSource } from './types.js';
16
+ /** The package this CLI publishes as. Read from the manifest, never hardcoded
17
+ * into the registry URL, so a fork keeps working (U-1). */
18
+ export interface SelfManifest {
19
+ name: string;
20
+ version: string;
21
+ }
22
+ /**
23
+ * The package root, resolved from THIS MODULE's own URL.
24
+ *
25
+ * INVARIANT U-1 - THE LEVEL COUNT IS LOAD-BEARING AND THE FAILURE IS SILENT.
26
+ *
27
+ * The count is only correct because this package is compiled by plain `tsc` with
28
+ * `rootDir: ./src` and NO BUNDLER (C-16), so `src/update/install-source.ts`
29
+ * really does land at `dist/update/install-source.js`. If a bundler is ever
30
+ * introduced, `import.meta.url` collapses to `dist/cli.js` and this ascent
31
+ * becomes off-by-one - so that build property is a dependency of this module,
32
+ * not a background detail. `cli.tsx::readVersion()` goes up ONE level because it
33
+ * lives at `dist/cli.js`; this module lives two deep and goes up TWO.
34
+ *
35
+ * An off-by-one yields a directory whose `package.json` may still parse (the
36
+ * monorepo root's does), so the updater would silently check a package that is
37
+ * not us. `readSelfManifest()` therefore refuses a manifest without both a
38
+ * `name` and a `version`, and AC-3 asserts the name it reads.
39
+ *
40
+ * `realpathSync` is MANDATORY: nvm, volta and `npm link` all put symlinks on
41
+ * this path, and every rung of the ladder below is path-shaped.
42
+ */
43
+ export declare function selfPackageRoot(): string | null;
44
+ /**
45
+ * `<root>/package.json`, or `null` when it is absent, unparseable, or missing
46
+ * either of the two fields that make it OURS rather than some ancestor's.
47
+ */
48
+ export declare function readSelfManifest(root?: string | null): SelfManifest | null;
49
+ /**
50
+ * Classify the running installation. FIRST MATCH WINS, and the order is the
51
+ * table in section 3.2 read top to bottom.
52
+ *
53
+ * D-4 - THE GLOBAL/LOCAL CRITERION IS "DOES A MANIFEST PIN US", NOT "IS THIS THE
54
+ * NPM PREFIX". The obvious implementation derives the global prefix from
55
+ * `process.execPath`. That is wrong on Windows, where the bundled npm's default
56
+ * prefix is `%APPDATA%\npm` and has nothing to do with `C:\Program Files\nodejs`.
57
+ * It is also semantically weaker than what we actually need to know: auto-update
58
+ * is legitimate exactly when NO PROJECT MANIFEST PINS OUR VERSION. A
59
+ * `node_modules` with a sibling `package.json` is a project; one without is a
60
+ * global root (`%APPDATA%\npm\node_modules`, `/usr/local/lib/node_modules`,
61
+ * `~/.nvm/versions/node/vX/lib/node_modules` - none of them has a sibling
62
+ * manifest). The rule needs no subprocess, no prefix arithmetic and no platform
63
+ * branch.
64
+ */
65
+ export declare function classifyInstallSource(root: string, env?: NodeJS.ProcessEnv): InstallSource;
66
+ /**
67
+ * Can we actually write the directory the install would replace?
68
+ *
69
+ * A path can look global and still be unwritable - the ordinary Linux case,
70
+ * where the global root belongs to root and the user has no sudo (R-2). Probing
71
+ * BEFORE spawning npm turns a confusing EACCES deep in npm's output into an
72
+ * actionable one-line notice.
73
+ */
74
+ export declare function probeWritable(root: string): boolean;
75
+ /** Whether this classification may be auto-installed at all (D-5). */
76
+ export declare function isAutoInstallable(source: InstallSource): boolean;
77
+ /**
78
+ * Whether a classification is worth telling the user about when it is NOT
79
+ * auto-installable.
80
+ *
81
+ * THREE ARE SILENT, and the third is the one a reader is most likely to drop.
82
+ * `dev-monorepo` and `npx` are the obvious pair (AC-6 / AC-7): a developer's
83
+ * clone is not out of date, it is checked out; and an `npx` invocation already
84
+ * resolved `latest` seconds ago. `npm-local` belongs with them for the reason
85
+ * D-4 exists at all - a project manifest PINS our version, so upgrading is not
86
+ * the user's decision to make from inside this session, and `adviceFor` returns
87
+ * `undefined` for it because there is no command we could honestly suggest.
88
+ *
89
+ * WITHOUT `npm-local` HERE THE ROW RENDERS `0.6.0 available` WITH NO COMMAND
90
+ * AFTER IT, once per session, forever, on every project that depends on this
91
+ * package - a persistent notice carrying no action, which is precisely the
92
+ * notification fatigue D-7 and R-8 spend the rest of this feature avoiding.
93
+ * The section 3.2 table and `manual-test.md` row 10 both say "silent".
94
+ */
95
+ export declare function isReportableSource(source: InstallSource): boolean;
96
+ /** The copy-pasteable command for a source we will not install ourselves. */
97
+ export declare function adviceFor(source: InstallSource, packageName: string): string | undefined;
@@ -0,0 +1,218 @@
1
+ /**
2
+ * The eligibility ladder (cli-auto-update section 3.2).
3
+ *
4
+ * ASCII ONLY - this tree is inside the glyph scanner's scope (C-2 / C-3).
5
+ *
6
+ * THE DANGEROUS VERSION OF THIS FEATURE RUNS `npm i -g` ON A MACHINE WHERE
7
+ * `aragon` CAME FROM SOMEWHERE ELSE. From `npx`, from a pnpm store, from a
8
+ * project's `node_modules`, from volta, or from a clone of this monorepo - in
9
+ * every one of those the global install either does not exist, is not what is
10
+ * running, or is actively wrong to touch. This module classifies the running
11
+ * installation from its own real path and reports which of those it is; the
12
+ * service auto-installs for exactly one classification and degrades everything
13
+ * else to a one-line notice carrying the correct command for THAT manager.
14
+ */
15
+ import { accessSync, constants, existsSync, readFileSync, realpathSync } from 'node:fs';
16
+ import { basename, dirname, resolve, sep } from 'node:path';
17
+ import process from 'node:process';
18
+ import { fileURLToPath } from 'node:url';
19
+ /**
20
+ * The package root, resolved from THIS MODULE's own URL.
21
+ *
22
+ * INVARIANT U-1 - THE LEVEL COUNT IS LOAD-BEARING AND THE FAILURE IS SILENT.
23
+ *
24
+ * The count is only correct because this package is compiled by plain `tsc` with
25
+ * `rootDir: ./src` and NO BUNDLER (C-16), so `src/update/install-source.ts`
26
+ * really does land at `dist/update/install-source.js`. If a bundler is ever
27
+ * introduced, `import.meta.url` collapses to `dist/cli.js` and this ascent
28
+ * becomes off-by-one - so that build property is a dependency of this module,
29
+ * not a background detail. `cli.tsx::readVersion()` goes up ONE level because it
30
+ * lives at `dist/cli.js`; this module lives two deep and goes up TWO.
31
+ *
32
+ * An off-by-one yields a directory whose `package.json` may still parse (the
33
+ * monorepo root's does), so the updater would silently check a package that is
34
+ * not us. `readSelfManifest()` therefore refuses a manifest without both a
35
+ * `name` and a `version`, and AC-3 asserts the name it reads.
36
+ *
37
+ * `realpathSync` is MANDATORY: nvm, volta and `npm link` all put symlinks on
38
+ * this path, and every rung of the ladder below is path-shaped.
39
+ */
40
+ export function selfPackageRoot() {
41
+ try {
42
+ const here = dirname(fileURLToPath(import.meta.url));
43
+ return realpathSync(resolve(here, '..', '..'));
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ /**
50
+ * `<root>/package.json`, or `null` when it is absent, unparseable, or missing
51
+ * either of the two fields that make it OURS rather than some ancestor's.
52
+ */
53
+ export function readSelfManifest(root = selfPackageRoot()) {
54
+ if (!root)
55
+ return null;
56
+ try {
57
+ const parsed = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf-8'));
58
+ if (typeof parsed?.name !== 'string' || parsed.name.length === 0)
59
+ return null;
60
+ if (typeof parsed?.version !== 'string' || parsed.version.length === 0)
61
+ return null;
62
+ return { name: parsed.name, version: parsed.version };
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ function segments(path) {
69
+ return path.split(/[\\/]/).filter((s) => s.length > 0);
70
+ }
71
+ /** The nearest ancestor directory named `node_modules`, or `null`. */
72
+ function nearestNodeModules(root) {
73
+ let current = root;
74
+ for (;;) {
75
+ if (basename(current) === 'node_modules')
76
+ return current;
77
+ const parent = dirname(current);
78
+ if (parent === current)
79
+ return null;
80
+ current = parent;
81
+ }
82
+ }
83
+ function isMonorepoCheckout(root) {
84
+ if (basename(root) !== 'cli')
85
+ return false;
86
+ if (basename(dirname(root)) !== 'packages')
87
+ return false;
88
+ try {
89
+ const parsed = JSON.parse(readFileSync(resolve(root, '..', '..', 'package.json'), 'utf-8'));
90
+ return Array.isArray(parsed?.workspaces);
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ }
96
+ /**
97
+ * Classify the running installation. FIRST MATCH WINS, and the order is the
98
+ * table in section 3.2 read top to bottom.
99
+ *
100
+ * D-4 - THE GLOBAL/LOCAL CRITERION IS "DOES A MANIFEST PIN US", NOT "IS THIS THE
101
+ * NPM PREFIX". The obvious implementation derives the global prefix from
102
+ * `process.execPath`. That is wrong on Windows, where the bundled npm's default
103
+ * prefix is `%APPDATA%\npm` and has nothing to do with `C:\Program Files\nodejs`.
104
+ * It is also semantically weaker than what we actually need to know: auto-update
105
+ * is legitimate exactly when NO PROJECT MANIFEST PINS OUR VERSION. A
106
+ * `node_modules` with a sibling `package.json` is a project; one without is a
107
+ * global root (`%APPDATA%\npm\node_modules`, `/usr/local/lib/node_modules`,
108
+ * `~/.nvm/versions/node/vX/lib/node_modules` - none of them has a sibling
109
+ * manifest). The rule needs no subprocess, no prefix arithmetic and no platform
110
+ * branch.
111
+ */
112
+ export function classifyInstallSource(root, env = process.env) {
113
+ const parts = segments(root);
114
+ if (isMonorepoCheckout(root))
115
+ return 'dev-monorepo';
116
+ // `npx` already resolves `latest` on every run, so there is nothing here that
117
+ // could be out of date tomorrow.
118
+ if (parts.includes('_npx'))
119
+ return 'npx';
120
+ if (parts.includes('.pnpm') || existsSync(resolve(root, '..', '..', '.pnpm')))
121
+ return 'pnpm';
122
+ // `.yarn` (berry) or the classic `yarn/global` PAIR - adjacency matters, or a
123
+ // `/opt/yarn/lib/node_modules` install plus an unrelated `global` segment
124
+ // somewhere else on the path would misclassify.
125
+ if (parts.includes('.yarn'))
126
+ return 'yarn';
127
+ if (parts.some((part, i) => part === 'yarn' && parts[i + 1] === 'global'))
128
+ return 'yarn';
129
+ if (parts.includes('.bun'))
130
+ return 'bun';
131
+ if (parts.includes('.volta'))
132
+ return 'volta';
133
+ const voltaHome = env.VOLTA_HOME?.trim();
134
+ if (voltaHome && voltaHome.length > 0) {
135
+ const prefix = realpathQuiet(voltaHome);
136
+ if (prefix && (root === prefix || root.startsWith(prefix + sep)))
137
+ return 'volta';
138
+ }
139
+ const nm = nearestNodeModules(root);
140
+ if (!nm)
141
+ return 'unknown';
142
+ // THE WHOLE OF D-4, in one line: a sibling manifest means a project pins our
143
+ // version and an auto-upgrade would fight it.
144
+ return existsSync(resolve(dirname(nm), 'package.json')) ? 'npm-local' : 'npm-global';
145
+ }
146
+ function realpathQuiet(path) {
147
+ try {
148
+ return realpathSync(resolve(path));
149
+ }
150
+ catch {
151
+ return null;
152
+ }
153
+ }
154
+ /**
155
+ * Can we actually write the directory the install would replace?
156
+ *
157
+ * A path can look global and still be unwritable - the ordinary Linux case,
158
+ * where the global root belongs to root and the user has no sudo (R-2). Probing
159
+ * BEFORE spawning npm turns a confusing EACCES deep in npm's output into an
160
+ * actionable one-line notice.
161
+ */
162
+ export function probeWritable(root) {
163
+ try {
164
+ accessSync(dirname(root), constants.W_OK);
165
+ return true;
166
+ }
167
+ catch {
168
+ return false;
169
+ }
170
+ }
171
+ /** Whether this classification may be auto-installed at all (D-5). */
172
+ export function isAutoInstallable(source) {
173
+ return source === 'npm-global';
174
+ }
175
+ /**
176
+ * Whether a classification is worth telling the user about when it is NOT
177
+ * auto-installable.
178
+ *
179
+ * THREE ARE SILENT, and the third is the one a reader is most likely to drop.
180
+ * `dev-monorepo` and `npx` are the obvious pair (AC-6 / AC-7): a developer's
181
+ * clone is not out of date, it is checked out; and an `npx` invocation already
182
+ * resolved `latest` seconds ago. `npm-local` belongs with them for the reason
183
+ * D-4 exists at all - a project manifest PINS our version, so upgrading is not
184
+ * the user's decision to make from inside this session, and `adviceFor` returns
185
+ * `undefined` for it because there is no command we could honestly suggest.
186
+ *
187
+ * WITHOUT `npm-local` HERE THE ROW RENDERS `0.6.0 available` WITH NO COMMAND
188
+ * AFTER IT, once per session, forever, on every project that depends on this
189
+ * package - a persistent notice carrying no action, which is precisely the
190
+ * notification fatigue D-7 and R-8 spend the rest of this feature avoiding.
191
+ * The section 3.2 table and `manual-test.md` row 10 both say "silent".
192
+ */
193
+ export function isReportableSource(source) {
194
+ return source !== 'dev-monorepo' && source !== 'npx' && source !== 'npm-local';
195
+ }
196
+ /** The copy-pasteable command for a source we will not install ourselves. */
197
+ export function adviceFor(source, packageName) {
198
+ switch (source) {
199
+ case 'pnpm':
200
+ return `pnpm add -g ${packageName}`;
201
+ case 'yarn':
202
+ return `yarn global add ${packageName}`;
203
+ case 'bun':
204
+ return `bun add -g ${packageName}`;
205
+ case 'volta':
206
+ return `volta install ${packageName}`;
207
+ case 'npm-global':
208
+ case 'unknown':
209
+ return `npm i -g ${packageName}`;
210
+ // A project manifest pins us; upgrading behind its back is the one thing a
211
+ // package manager exists to prevent (D-4).
212
+ case 'npm-local':
213
+ case 'dev-monorepo':
214
+ case 'npx':
215
+ default:
216
+ return undefined;
217
+ }
218
+ }
@@ -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>;