@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,60 @@
1
+ /**
2
+ * Turning a tail of npm output into a reason (cli-auto-update-hardening H2 /
3
+ * section 5.3).
4
+ *
5
+ * ASCII ONLY - this tree is inside the glyph scanner's scope (C-2 / C-3).
6
+ *
7
+ * PURE, AND THE ONLY IMPORT IS A TYPE. The interesting half of H2 is not this
8
+ * table, it is the REPORTING rule that decides which of the resulting reasons
9
+ * may break the three-strike silence - and that lives in `types.ts` beside
10
+ * `shouldRenderUpdateLine`, as `IMMEDIATE_NOTICE_REASONS`.
11
+ *
12
+ * WHY THIS EXISTS AT ALL. The one line a user is ever allowed to see on the
13
+ * failure path reads `update failed <dot> npm i -g @aragon-agent/cli` for EVERY
14
+ * cause. For `EPERM` / `EBUSY` - the Windows self-replacement case, R-3 - that
15
+ * advice hits the same held handle and fails identically, so the single visible
16
+ * failure is also a wrong instruction.
17
+ */
18
+ /**
19
+ * FIRST MATCH WINS, AND THE ORDER IS NOT ALPHABETICAL.
20
+ *
21
+ * `EPERM` is tested before `EACCES` because npm's Windows output frequently
22
+ * carries BOTH - the `EPERM` on the shim rename plus an `EACCES` from a retry -
23
+ * and the actionable one is the first. `network` is last because "proxy" and
24
+ * "network" appear inside unrelated npm advisory text often enough that an
25
+ * earlier position would swallow the three specific cases above it.
26
+ */
27
+ const TABLE = [
28
+ [
29
+ /\bEPERM\b|\bEBUSY\b|\bETXTBSY\b|operation not permitted|resource busy or locked/i,
30
+ 'blocked-by-os',
31
+ ],
32
+ [/\bEACCES\b|permission denied/i, 'not-writable'],
33
+ [/\bENOSPC\b|no space left/i, 'no-space'],
34
+ [
35
+ /\bENOTFOUND\b|\bEAI_AGAIN\b|\bECONNRESET\b|\bETIMEDOUT\b|network|proxy|tunneling socket/i,
36
+ 'network',
37
+ ],
38
+ ];
39
+ /**
40
+ * Classify an install failure from the bounded stderr tail `runNpmInstall`
41
+ * already returns.
42
+ *
43
+ * ONE PARAMETER, NOT TWO. An earlier draft took `exitCode` and never read it:
44
+ * npm exits `1` for every one of these, so the code carries no information the
45
+ * tail does not, and a parameter that no branch and no test consumes is a
46
+ * maintenance liability that reads like a promise (P2-2).
47
+ *
48
+ * An absent or unrecognised tail is `install-failed` - the pre-H2 behaviour, so
49
+ * a machine whose npm says something we have never seen degrades to exactly what
50
+ * it did before rather than to a confident wrong answer.
51
+ */
52
+ export function classifyInstallFailure(tail) {
53
+ if (!tail)
54
+ return 'install-failed';
55
+ for (const [re, reason] of TABLE) {
56
+ if (re.test(tail))
57
+ return reason;
58
+ }
59
+ return 'install-failed';
60
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The cross-process install lock (cli-auto-update section 3.7).
3
+ *
4
+ * ASCII ONLY - this tree is inside the glyph scanner's scope (C-2 / C-3).
5
+ *
6
+ * INHERITS `skills/lock.ts`'s PAYLOAD DISCIPLINE, NOT ITS ACQUISITION LOOP
7
+ * (C-9). The payload shape, the TTL, the liveness probe, the
8
+ * `LOCK_UNREADABLE_CONFIRMATIONS` rule and the uuid-guarded release are all the
9
+ * same and are imported rather than restated. The retry loop is NOT:
10
+ *
11
+ * INVARIANT U-5b - ACQUISITION IS A SINGLE NON-BLOCKING ATTEMPT.
12
+ * `acquireRootLock` busy-waits SYNCHRONOUSLY through `Atomics.wait` for up to
13
+ * `LOCK_MAX_WAIT_MS = 5_000` and then THROWS. That is right for `aragon skills
14
+ * install`, where a human typed the command and there is nothing else to do.
15
+ * Here it would freeze the Ink render loop for five seconds, from a background
16
+ * timer, in the feature whose entire requirement is that it is invisible. So:
17
+ * one `wx` attempt, no sleep, no retry, no throw - `tryAcquireInstallLock()`
18
+ * returns `null` on `EEXIST` after the staleness check, and the caller treats
19
+ * that as INFORMATION (someone else is already installing) rather than failure.
20
+ */
21
+ export interface InstallLockHandle {
22
+ /**
23
+ * Phase two of U-4: rewrite the payload in place with the CHILD's pid.
24
+ *
25
+ * `uuid` and `startedAt` are preserved - the uuid is what `release()` matches
26
+ * on, and `startedAt` is what the TTL measures, so replacing either here would
27
+ * silently break one of the two recovery paths.
28
+ */
29
+ adoptChild(pid: number): void;
30
+ release(): void;
31
+ }
32
+ export interface InstallLockOptions {
33
+ now?: () => number;
34
+ /** Injected for tests; defaults to `process.kill(pid, 0)` liveness probing. */
35
+ isProcessAlive?: (pid: number) => boolean;
36
+ /** Injected for tests; defaults to `<home>/update-install.lock`. */
37
+ path?: string;
38
+ }
39
+ /**
40
+ * Take the install lock, or report that someone else has it. NEVER SLEEPS,
41
+ * NEVER THROWS (U-5b / AC-27).
42
+ *
43
+ * The payload written here carries the PARENT's pid, and that is not a mistake:
44
+ * INVARIANT U-4 says the lock must name the CHILD, but the lock has to be held
45
+ * BEFORE the spawn (or the race it exists to close is wide open) and `child.pid`
46
+ * does not exist UNTIL AFTER it. `skills/lock.ts::tryCreate` writes its payload
47
+ * at create time, so a literal mirror can only ever write the parent's. Hence
48
+ * two phases: this call, then `adoptChild(child.pid)` the moment `execFile`
49
+ * returns.
50
+ *
51
+ * The window between them is SAFE rather than merely narrow: a crash inside it
52
+ * leaves a lock naming a DEAD PARENT, which is exactly the case the liveness
53
+ * probe below already reclaims.
54
+ */
55
+ export declare function tryAcquireInstallLock(opts?: InstallLockOptions): InstallLockHandle | null;
@@ -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
+ }