@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,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>;
|