@aragon-agent/cli 0.5.8 → 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.
- package/CHANGELOG.md +1157 -747
- package/README.md +1552 -1163
- package/dist/agent/coalesce.d.ts +13 -0
- package/dist/agent/coalesce.js +50 -11
- package/dist/agent/controller.d.ts +141 -2
- package/dist/agent/controller.js +396 -4
- package/dist/agent/headless.d.ts +8 -0
- package/dist/agent/headless.js +40 -0
- package/dist/agent/reducer.d.ts +245 -3
- package/dist/agent/reducer.js +490 -17
- package/dist/agent/retry-view.d.ts +68 -0
- package/dist/agent/retry-view.js +95 -0
- package/dist/agent/system-prompt.d.ts +11 -0
- package/dist/agent/system-prompt.js +2 -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 +368 -3
- package/dist/commands/builtins.d.ts +1 -1
- package/dist/commands/builtins.js +426 -2
- 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 +79 -2
- package/dist/config/cli-commands.js +185 -3
- package/dist/config/env.d.ts +15 -3
- package/dist/config/env.js +143 -3
- package/dist/config/load.d.ts +66 -0
- package/dist/config/load.js +107 -1
- package/dist/config/schema.d.ts +268 -2
- package/dist/config/schema.js +288 -1
- package/dist/config/store.js +32 -1
- package/dist/fast/critique.d.ts +43 -0
- package/dist/fast/critique.js +62 -0
- package/dist/fast/digest.d.ts +42 -0
- package/dist/fast/digest.js +85 -0
- package/dist/fast/frames.d.ts +99 -0
- package/dist/fast/frames.js +180 -0
- package/dist/fast/limits.d.ts +98 -0
- package/dist/fast/limits.js +98 -0
- package/dist/fast/prompt.d.ts +72 -0
- package/dist/fast/prompt.js +94 -0
- package/dist/fast/resolve.d.ts +43 -0
- package/dist/fast/resolve.js +105 -0
- package/dist/fast/review-call.d.ts +99 -0
- package/dist/fast/review-call.js +130 -0
- package/dist/fast/reviewer.d.ts +248 -0
- package/dist/fast/reviewer.js +664 -0
- package/dist/fast/types.d.ts +128 -0
- package/dist/fast/types.js +14 -0
- package/dist/fast/wiring.d.ts +170 -0
- package/dist/fast/wiring.js +316 -0
- package/dist/launcher.d.ts +14 -0
- package/dist/launcher.js +43 -0
- package/dist/logging/install.d.ts +15 -1
- package/dist/logging/install.js +37 -4
- package/dist/logging/logger.d.ts +1 -1
- package/dist/session/persist.js +57 -1
- package/dist/team/normalize.d.ts +26 -1
- package/dist/team/normalize.js +23 -3
- package/dist/team/prompt.d.ts +11 -0
- package/dist/team/prompt.js +10 -0
- package/dist/team/report.d.ts +18 -0
- package/dist/team/report.js +27 -1
- package/dist/team/runtime.d.ts +13 -2
- package/dist/team/runtime.js +33 -6
- package/dist/team/subagent.d.ts +19 -1
- package/dist/team/subagent.js +72 -10
- package/dist/team/task-tool.d.ts +26 -0
- package/dist/team/task-tool.js +48 -4
- package/dist/team/types.d.ts +80 -1
- package/dist/tools/bash-tool.js +19 -3
- package/dist/tools/diff.d.ts +25 -5
- package/dist/tools/diff.js +72 -41
- package/dist/tools/file-change-store.d.ts +34 -0
- package/dist/tools/file-change-store.js +57 -0
- package/dist/tools/fs-tools.d.ts +26 -0
- package/dist/tools/fs-tools.js +62 -5
- package/dist/tools/index.d.ts +31 -0
- package/dist/tools/index.js +7 -1
- package/dist/tools/patch.d.ts +115 -0
- package/dist/tools/patch.js +453 -0
- package/dist/tools/terminal-output.d.ts +107 -0
- package/dist/tools/terminal-output.js +198 -0
- package/dist/tools/tool-output-store.d.ts +68 -0
- package/dist/tools/tool-output-store.js +102 -0
- package/dist/ui/ActivityLine.d.ts +48 -0
- package/dist/ui/ActivityLine.js +22 -0
- package/dist/ui/App.d.ts +11 -4
- package/dist/ui/App.js +359 -37
- package/dist/ui/BottomStatusRow.d.ts +65 -0
- package/dist/ui/BottomStatusRow.js +17 -0
- package/dist/ui/Composer.d.ts +1 -6
- package/dist/ui/Composer.js +2 -2
- package/dist/ui/PromptInput.d.ts +1 -15
- package/dist/ui/PromptInput.js +1 -38
- package/dist/ui/StatusBar.d.ts +37 -0
- package/dist/ui/StatusBar.js +5 -3
- package/dist/ui/TeamPanel.d.ts +5 -4
- package/dist/ui/TeamPanel.js +1 -1
- package/dist/ui/TodoPanel.d.ts +5 -4
- package/dist/ui/Transcript.d.ts +26 -3
- package/dist/ui/Transcript.js +76 -10
- package/dist/ui/UpdateLine.d.ts +44 -0
- package/dist/ui/UpdateLine.js +129 -0
- package/dist/ui/activity-phrases.d.ts +33 -0
- package/dist/ui/activity-phrases.js +50 -0
- package/dist/ui/entries/AssistantEntry.d.ts +20 -0
- package/dist/ui/entries/AssistantEntry.js +3 -2
- package/dist/ui/entries/DiffView.d.ts +67 -0
- package/dist/ui/entries/DiffView.js +103 -0
- package/dist/ui/entries/FastCard.d.ts +53 -0
- package/dist/ui/entries/FastCard.js +87 -0
- package/dist/ui/entries/RetryCard.d.ts +56 -0
- package/dist/ui/entries/RetryCard.js +60 -0
- package/dist/ui/entries/TeamCard.js +6 -1
- package/dist/ui/entries/ToolCard.d.ts +38 -1
- package/dist/ui/entries/ToolCard.js +68 -5
- package/dist/ui/glyphs.d.ts +9 -0
- package/dist/ui/glyphs.js +2 -0
- package/dist/ui/layout/AppShell.d.ts +13 -22
- package/dist/ui/layout/AppShell.js +3 -38
- package/dist/ui/layout/virtual-window.d.ts +1 -1
- package/dist/ui/layout/virtual-window.js +109 -5
- package/dist/ui/overlays/HelpOverlay.js +16 -2
- package/dist/ui/overlays/SettingsScreen.d.ts +82 -1
- package/dist/ui/overlays/SettingsScreen.js +147 -1
- package/dist/ui/transcript-text.js +27 -0
- package/dist/ui/use-wheel-routing.d.ts +16 -32
- package/dist/ui/use-wheel-routing.js +21 -38
- 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
- package/dist/ui/layout/regions.d.ts +0 -55
- package/dist/ui/layout/regions.js +0 -52
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The registry check (cli-auto-update section 3.3).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY - this tree is inside the glyph scanner's scope (C-2 / C-3).
|
|
5
|
+
*
|
|
6
|
+
* One `GET`, one small JSON document, and NEVER A THROW. Transport rules follow
|
|
7
|
+
* C-8 verbatim - `AbortController` timeout, `redirect: 'manual'` with the
|
|
8
|
+
* allow-check repeated on every hop, a running byte ceiling, an injectable
|
|
9
|
+
* `fetchImpl` so every test is offline.
|
|
10
|
+
*
|
|
11
|
+
* NO CREDENTIALS, EVER (D-13). An updater that reads `_authToken` out of
|
|
12
|
+
* `.npmrc` and attaches it to a user-configurable URL is a credential
|
|
13
|
+
* exfiltration primitive the moment `update.registry` is mis-set. Private
|
|
14
|
+
* registry users point `update.registry` at a mirror they can read anonymously,
|
|
15
|
+
* or set `update.mode: off`.
|
|
16
|
+
*/
|
|
17
|
+
import type { LatestManifest } from './types.js';
|
|
18
|
+
export declare const DEFAULT_REGISTRY = "https://registry.npmjs.org";
|
|
19
|
+
export interface RegistryDeps {
|
|
20
|
+
fetchImpl?: typeof fetch;
|
|
21
|
+
/** `aragon-agent-cli/<version>`; identifies us to the registry's logs. */
|
|
22
|
+
userAgent?: string;
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* `config.update.registry` -> `ARAGON_UPDATE_REGISTRY` -> `npm_config_registry`
|
|
27
|
+
* -> the default.
|
|
28
|
+
*
|
|
29
|
+
* A value that does not parse as `http(s):` FALLS BACK rather than throwing:
|
|
30
|
+
* this runs on a background timer, and a typo in a config file must degrade to
|
|
31
|
+
* "checks the public registry" and not to "the CLI reports an error it cannot
|
|
32
|
+
* explain". `npm_config_registry` is read last and is what makes `aragon`
|
|
33
|
+
* inherit an enterprise mirror with no configuration at all.
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveRegistryUrl(configured: string, env?: NodeJS.ProcessEnv): string;
|
|
36
|
+
/**
|
|
37
|
+
* `<registry>/@scope%2Fname/<distTag>`.
|
|
38
|
+
*
|
|
39
|
+
* THE SCOPE SEPARATOR MUST BE PERCENT-ENCODED. `encodeURIComponent` does that
|
|
40
|
+
* and also makes the name safe to interpolate, which matters because the name
|
|
41
|
+
* comes from a manifest on disk rather than from a literal here (U-1).
|
|
42
|
+
*/
|
|
43
|
+
export declare function manifestUrl(registry: string, name: string, distTag: string): string;
|
|
44
|
+
/**
|
|
45
|
+
* Fetch the dist-tag document, or `null`.
|
|
46
|
+
*
|
|
47
|
+
* NEVER THROWS. Any non-2xx, any parse failure, any timeout, any redirect loop
|
|
48
|
+
* and any oversize body all resolve to `null`, because every caller is a
|
|
49
|
+
* background timer whose entire contract is that the user does not find out. The
|
|
50
|
+
* caller counts a `null` as one consecutive failure and backs off.
|
|
51
|
+
*/
|
|
52
|
+
export declare function fetchLatestManifest(registry: string, name: string, distTag: string, deps?: RegistryDeps): Promise<LatestManifest | null>;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The registry check (cli-auto-update section 3.3).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY - this tree is inside the glyph scanner's scope (C-2 / C-3).
|
|
5
|
+
*
|
|
6
|
+
* One `GET`, one small JSON document, and NEVER A THROW. Transport rules follow
|
|
7
|
+
* C-8 verbatim - `AbortController` timeout, `redirect: 'manual'` with the
|
|
8
|
+
* allow-check repeated on every hop, a running byte ceiling, an injectable
|
|
9
|
+
* `fetchImpl` so every test is offline.
|
|
10
|
+
*
|
|
11
|
+
* NO CREDENTIALS, EVER (D-13). An updater that reads `_authToken` out of
|
|
12
|
+
* `.npmrc` and attaches it to a user-configurable URL is a credential
|
|
13
|
+
* exfiltration primitive the moment `update.registry` is mis-set. Private
|
|
14
|
+
* registry users point `update.registry` at a mirror they can read anonymously,
|
|
15
|
+
* or set `update.mode: off`.
|
|
16
|
+
*/
|
|
17
|
+
import process from 'node:process';
|
|
18
|
+
import { UPDATE_LIMITS } from './limits.js';
|
|
19
|
+
export const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
|
|
20
|
+
/**
|
|
21
|
+
* `config.update.registry` -> `ARAGON_UPDATE_REGISTRY` -> `npm_config_registry`
|
|
22
|
+
* -> the default.
|
|
23
|
+
*
|
|
24
|
+
* A value that does not parse as `http(s):` FALLS BACK rather than throwing:
|
|
25
|
+
* this runs on a background timer, and a typo in a config file must degrade to
|
|
26
|
+
* "checks the public registry" and not to "the CLI reports an error it cannot
|
|
27
|
+
* explain". `npm_config_registry` is read last and is what makes `aragon`
|
|
28
|
+
* inherit an enterprise mirror with no configuration at all.
|
|
29
|
+
*/
|
|
30
|
+
export function resolveRegistryUrl(configured, env = process.env) {
|
|
31
|
+
const candidates = [configured, env.ARAGON_UPDATE_REGISTRY, env.npm_config_registry];
|
|
32
|
+
for (const candidate of candidates) {
|
|
33
|
+
const text = candidate?.trim();
|
|
34
|
+
if (!text)
|
|
35
|
+
continue;
|
|
36
|
+
try {
|
|
37
|
+
const url = new URL(text);
|
|
38
|
+
// `http:` is accepted for a LAN mirror; nothing else is a registry.
|
|
39
|
+
if (url.protocol === 'https:' || url.protocol === 'http:') {
|
|
40
|
+
return text.replace(/\/+$/, '');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Fall through to the next candidate.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return DEFAULT_REGISTRY;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* `<registry>/@scope%2Fname/<distTag>`.
|
|
51
|
+
*
|
|
52
|
+
* THE SCOPE SEPARATOR MUST BE PERCENT-ENCODED. `encodeURIComponent` does that
|
|
53
|
+
* and also makes the name safe to interpolate, which matters because the name
|
|
54
|
+
* comes from a manifest on disk rather than from a literal here (U-1).
|
|
55
|
+
*/
|
|
56
|
+
export function manifestUrl(registry, name, distTag) {
|
|
57
|
+
return `${registry.replace(/\/+$/, '')}/${encodeURIComponent(name)}/${encodeURIComponent(distTag)}`;
|
|
58
|
+
}
|
|
59
|
+
function isRegistryUrl(raw) {
|
|
60
|
+
try {
|
|
61
|
+
const url = new URL(raw);
|
|
62
|
+
return url.protocol === 'https:' || url.protocol === 'http:';
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Read a response body, aborting as soon as it passes `maxBytes` (C-8). */
|
|
69
|
+
async function readCapped(response, maxBytes) {
|
|
70
|
+
const declared = Number(response.headers.get('content-length') ?? '0');
|
|
71
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
72
|
+
throw new Error(`registry response is ${declared} bytes (max ${maxBytes})`);
|
|
73
|
+
}
|
|
74
|
+
const body = response.body;
|
|
75
|
+
// No stream (a mocked `Response`, or a 204): fall back to `text()`, which the
|
|
76
|
+
// Content-Length gate above has already bounded when the header was honest.
|
|
77
|
+
if (!body) {
|
|
78
|
+
const text = await response.text();
|
|
79
|
+
if (text.length > maxBytes)
|
|
80
|
+
throw new Error(`registry response exceeded ${maxBytes} bytes`);
|
|
81
|
+
return text;
|
|
82
|
+
}
|
|
83
|
+
const chunks = [];
|
|
84
|
+
let total = 0;
|
|
85
|
+
// Content-Length is advisory; the running total is the real gate.
|
|
86
|
+
for await (const chunk of body) {
|
|
87
|
+
total += chunk.byteLength;
|
|
88
|
+
if (total > maxBytes)
|
|
89
|
+
throw new Error(`registry response exceeded ${maxBytes} bytes`);
|
|
90
|
+
chunks.push(chunk);
|
|
91
|
+
}
|
|
92
|
+
return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf-8');
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Fetch the dist-tag document, or `null`.
|
|
96
|
+
*
|
|
97
|
+
* NEVER THROWS. Any non-2xx, any parse failure, any timeout, any redirect loop
|
|
98
|
+
* and any oversize body all resolve to `null`, because every caller is a
|
|
99
|
+
* background timer whose entire contract is that the user does not find out. The
|
|
100
|
+
* caller counts a `null` as one consecutive failure and backs off.
|
|
101
|
+
*/
|
|
102
|
+
export async function fetchLatestManifest(registry, name, distTag, deps = {}) {
|
|
103
|
+
const doFetch = deps.fetchImpl ?? fetch;
|
|
104
|
+
const timeoutMs = deps.timeoutMs ?? UPDATE_LIMITS.checkTimeoutMs;
|
|
105
|
+
let current = manifestUrl(registry, name, distTag);
|
|
106
|
+
try {
|
|
107
|
+
for (let hop = 0; hop <= UPDATE_LIMITS.maxRedirects; hop += 1) {
|
|
108
|
+
// Re-checked on EVERY hop: a registry that 302s to `file:` or to an
|
|
109
|
+
// internal address would otherwise be a free SSRF (C-8).
|
|
110
|
+
if (!isRegistryUrl(current))
|
|
111
|
+
return null;
|
|
112
|
+
const controller = new AbortController();
|
|
113
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
114
|
+
let response;
|
|
115
|
+
try {
|
|
116
|
+
response = await doFetch(current, {
|
|
117
|
+
redirect: 'manual',
|
|
118
|
+
signal: controller.signal,
|
|
119
|
+
headers: {
|
|
120
|
+
Accept: 'application/json',
|
|
121
|
+
'User-Agent': deps.userAgent ?? 'aragon-agent-cli',
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
}
|
|
128
|
+
if (response.status >= 300 && response.status < 400) {
|
|
129
|
+
const location = response.headers.get('location');
|
|
130
|
+
if (!location)
|
|
131
|
+
return null;
|
|
132
|
+
current = new URL(location, current).toString();
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (!response.ok)
|
|
136
|
+
return null;
|
|
137
|
+
const text = await readCapped(response, UPDATE_LIMITS.manifestMaxBytes);
|
|
138
|
+
const parsed = JSON.parse(text);
|
|
139
|
+
if (typeof parsed?.name !== 'string' || typeof parsed?.version !== 'string')
|
|
140
|
+
return null;
|
|
141
|
+
return {
|
|
142
|
+
name: parsed.name,
|
|
143
|
+
version: parsed.version,
|
|
144
|
+
...(parsed.engines && typeof parsed.engines === 'object'
|
|
145
|
+
? { engines: { ...(typeof parsed.engines.node === 'string'
|
|
146
|
+
? { node: parsed.engines.node }
|
|
147
|
+
: {}) } }
|
|
148
|
+
: {}),
|
|
149
|
+
// Present ONLY when the version is deprecated. npm writes a string, but
|
|
150
|
+
// `true` appears in the wild; both mean "do not auto-install this".
|
|
151
|
+
...(parsed.deprecated !== undefined
|
|
152
|
+
? { deprecated: String(parsed.deprecated) }
|
|
153
|
+
: {}),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semver comparison and the update decision (cli-auto-update section 3.4).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY - this tree is inside the glyph scanner's scope (C-2 / C-3).
|
|
5
|
+
*
|
|
6
|
+
* HAND-WRITTEN AND NOT `semver`. The package has no such dependency
|
|
7
|
+
* (`package.json`), and this feature needs three questions answered - "is B
|
|
8
|
+
* newer than A", "is B a prerelease", "does this Node satisfy that range" - on a
|
|
9
|
+
* document the registry writes and npm itself only warns about. Adding a
|
|
10
|
+
* transitive dependency to an auto-updater, of all things, is the one place the
|
|
11
|
+
* supply-chain argument in R-1 cuts hardest.
|
|
12
|
+
*
|
|
13
|
+
* PURE: no I/O, no clock, no process. Every function here is total and none of
|
|
14
|
+
* them throws.
|
|
15
|
+
*/
|
|
16
|
+
import type { LatestManifest } from './types.js';
|
|
17
|
+
/**
|
|
18
|
+
* The form a version must have before it is interpolated into `npm install`'s
|
|
19
|
+
* argv (section 3.5 / AC-24).
|
|
20
|
+
*
|
|
21
|
+
* ANCHORED AND WITHOUT `v`: a registry that answered `"1.0.0; rm -rf /"` must
|
|
22
|
+
* never reach `execFile`. With `shell: false` this is not an injection surface
|
|
23
|
+
* to begin with, but the rejection is what makes that claim checkable rather
|
|
24
|
+
* than reasoned, and it also satisfies `fetch-source.ts` rule 3 (no leading `-`)
|
|
25
|
+
* by construction.
|
|
26
|
+
*/
|
|
27
|
+
export declare const STRICT_VERSION_RE: RegExp;
|
|
28
|
+
export interface Semver {
|
|
29
|
+
major: number;
|
|
30
|
+
minor: number;
|
|
31
|
+
patch: number;
|
|
32
|
+
/** Dot-separated prerelease identifiers, or `[]` for a release. */
|
|
33
|
+
prerelease: string[];
|
|
34
|
+
}
|
|
35
|
+
/** Parse a semver string, or `null` when it is not one. Never throws. */
|
|
36
|
+
export declare function parseSemver(raw: unknown): Semver | null;
|
|
37
|
+
export declare function isPrerelease(raw: string): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* `-1 | 0 | 1`. An UNPARSEABLE version sorts BELOW a parseable one and equal to
|
|
40
|
+
* another unparseable one, so a garbage registry answer can never read as newer
|
|
41
|
+
* than what is installed.
|
|
42
|
+
*/
|
|
43
|
+
export declare function compareSemver(a: string, b: string): number;
|
|
44
|
+
/**
|
|
45
|
+
* Does `nodeVersion` satisfy `engines.node`?
|
|
46
|
+
*
|
|
47
|
+
* FAILS OPEN, and that is a decision rather than an oversight (D-15). Handled
|
|
48
|
+
* forms are `>=X`, `>=X.Y.Z`, `>X`, `<X`, `<=X`, `=X`, `^X`, `~X`, `X.x`, and
|
|
49
|
+
* `||`-joined unions of those, with space-separated clauses inside a union
|
|
50
|
+
* ANDed together. Anything this cannot parse returns `true`, matching npm's own
|
|
51
|
+
* default of warning rather than refusing.
|
|
52
|
+
*
|
|
53
|
+
* Failing CLOSED here would mean a syntax we did not anticipate silently freezes
|
|
54
|
+
* every user's updates forever, with no message and no way to find out - the
|
|
55
|
+
* worse of the two errors by a wide margin. A range we DID understand and that
|
|
56
|
+
* genuinely excludes this Node yields `false`, and the caller turns that into a
|
|
57
|
+
* `node-too-old` notice so the user is told to upgrade Node rather than left
|
|
58
|
+
* wondering.
|
|
59
|
+
*/
|
|
60
|
+
export declare function satisfiesNodeRange(range: string | undefined, nodeVersion: string): boolean;
|
|
61
|
+
export type UpdateAction = 'install' | 'notify' | 'none';
|
|
62
|
+
export interface UpdateDecision {
|
|
63
|
+
action: UpdateAction;
|
|
64
|
+
reason: 'up-to-date' | 'prerelease' | 'deprecated' | 'node-too-old' | 'skipped' | 'newer-available';
|
|
65
|
+
}
|
|
66
|
+
export interface DecideUpdateInput {
|
|
67
|
+
/** The version this process is running. */
|
|
68
|
+
local: string;
|
|
69
|
+
manifest: Pick<LatestManifest, 'version' | 'engines' | 'deprecated'>;
|
|
70
|
+
/** `process.versions.node`, injected so the decision is testable offline. */
|
|
71
|
+
nodeVersion: string;
|
|
72
|
+
/** `state.skippedVersion`; `''` when the user has skipped nothing. */
|
|
73
|
+
skippedVersion: string;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Should we install, merely report, or say nothing? (section 3.4)
|
|
77
|
+
*
|
|
78
|
+
* ORDER IS SEMANTIC. `up-to-date` is answered first because it is the
|
|
79
|
+
* overwhelmingly common case and the cheapest; `skipped` comes before the
|
|
80
|
+
* quality gates so a user who typed `/update skip` is not told about a
|
|
81
|
+
* prerelease they already declined. Only a range we UNDERSTOOD and that excludes
|
|
82
|
+
* this Node produces `notify`; every other rejection is silent (`none`).
|
|
83
|
+
*
|
|
84
|
+
* The SOURCE and the MODE are deliberately NOT inputs here. This function
|
|
85
|
+
* answers "is that version worth having"; whether this machine may install it is
|
|
86
|
+
* the service's question, and folding the two would make a pure decision depend
|
|
87
|
+
* on the filesystem.
|
|
88
|
+
*/
|
|
89
|
+
export declare function decideUpdate(input: DecideUpdateInput): UpdateDecision;
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semver comparison and the update decision (cli-auto-update section 3.4).
|
|
3
|
+
*
|
|
4
|
+
* ASCII ONLY - this tree is inside the glyph scanner's scope (C-2 / C-3).
|
|
5
|
+
*
|
|
6
|
+
* HAND-WRITTEN AND NOT `semver`. The package has no such dependency
|
|
7
|
+
* (`package.json`), and this feature needs three questions answered - "is B
|
|
8
|
+
* newer than A", "is B a prerelease", "does this Node satisfy that range" - on a
|
|
9
|
+
* document the registry writes and npm itself only warns about. Adding a
|
|
10
|
+
* transitive dependency to an auto-updater, of all things, is the one place the
|
|
11
|
+
* supply-chain argument in R-1 cuts hardest.
|
|
12
|
+
*
|
|
13
|
+
* PURE: no I/O, no clock, no process. Every function here is total and none of
|
|
14
|
+
* them throws.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* The form a version must have before it is interpolated into `npm install`'s
|
|
18
|
+
* argv (section 3.5 / AC-24).
|
|
19
|
+
*
|
|
20
|
+
* ANCHORED AND WITHOUT `v`: a registry that answered `"1.0.0; rm -rf /"` must
|
|
21
|
+
* never reach `execFile`. With `shell: false` this is not an injection surface
|
|
22
|
+
* to begin with, but the rejection is what makes that claim checkable rather
|
|
23
|
+
* than reasoned, and it also satisfies `fetch-source.ts` rule 3 (no leading `-`)
|
|
24
|
+
* by construction.
|
|
25
|
+
*/
|
|
26
|
+
export const STRICT_VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
27
|
+
const SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
28
|
+
/** Parse a semver string, or `null` when it is not one. Never throws. */
|
|
29
|
+
export function parseSemver(raw) {
|
|
30
|
+
if (typeof raw !== 'string')
|
|
31
|
+
return null;
|
|
32
|
+
const match = SEMVER_RE.exec(raw.trim());
|
|
33
|
+
if (!match)
|
|
34
|
+
return null;
|
|
35
|
+
const [, major, minor, patch, pre] = match;
|
|
36
|
+
return {
|
|
37
|
+
major: Number.parseInt(major, 10),
|
|
38
|
+
minor: Number.parseInt(minor, 10),
|
|
39
|
+
patch: Number.parseInt(patch, 10),
|
|
40
|
+
prerelease: pre ? pre.split('.') : [],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export function isPrerelease(raw) {
|
|
44
|
+
return (parseSemver(raw)?.prerelease.length ?? 0) > 0;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Compare prerelease identifier lists per semver section 11.
|
|
48
|
+
*
|
|
49
|
+
* THE EMPTY LIST SORTS HIGHEST, which is the rule that keeps `1.0.0-rc.1` below
|
|
50
|
+
* `1.0.0`. Getting this backwards would let a `latest` tag pointing at a release
|
|
51
|
+
* candidate read as newer than the release it precedes.
|
|
52
|
+
*/
|
|
53
|
+
function comparePrerelease(a, b) {
|
|
54
|
+
if (a.length === 0 && b.length === 0)
|
|
55
|
+
return 0;
|
|
56
|
+
if (a.length === 0)
|
|
57
|
+
return 1;
|
|
58
|
+
if (b.length === 0)
|
|
59
|
+
return -1;
|
|
60
|
+
const len = Math.max(a.length, b.length);
|
|
61
|
+
for (let i = 0; i < len; i += 1) {
|
|
62
|
+
const x = a[i];
|
|
63
|
+
const y = b[i];
|
|
64
|
+
if (x === undefined)
|
|
65
|
+
return -1;
|
|
66
|
+
if (y === undefined)
|
|
67
|
+
return 1;
|
|
68
|
+
const xNum = /^\d+$/.test(x);
|
|
69
|
+
const yNum = /^\d+$/.test(y);
|
|
70
|
+
if (xNum && yNum) {
|
|
71
|
+
const diff = Number.parseInt(x, 10) - Number.parseInt(y, 10);
|
|
72
|
+
if (diff !== 0)
|
|
73
|
+
return diff < 0 ? -1 : 1;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
// Numeric identifiers always sort below alphanumeric ones.
|
|
77
|
+
if (xNum !== yNum)
|
|
78
|
+
return xNum ? -1 : 1;
|
|
79
|
+
if (x !== y)
|
|
80
|
+
return x < y ? -1 : 1;
|
|
81
|
+
}
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* `-1 | 0 | 1`. An UNPARSEABLE version sorts BELOW a parseable one and equal to
|
|
86
|
+
* another unparseable one, so a garbage registry answer can never read as newer
|
|
87
|
+
* than what is installed.
|
|
88
|
+
*/
|
|
89
|
+
export function compareSemver(a, b) {
|
|
90
|
+
const left = parseSemver(a);
|
|
91
|
+
const right = parseSemver(b);
|
|
92
|
+
if (!left && !right)
|
|
93
|
+
return 0;
|
|
94
|
+
if (!left)
|
|
95
|
+
return -1;
|
|
96
|
+
if (!right)
|
|
97
|
+
return 1;
|
|
98
|
+
if (left.major !== right.major)
|
|
99
|
+
return left.major < right.major ? -1 : 1;
|
|
100
|
+
if (left.minor !== right.minor)
|
|
101
|
+
return left.minor < right.minor ? -1 : 1;
|
|
102
|
+
if (left.patch !== right.patch)
|
|
103
|
+
return left.patch < right.patch ? -1 : 1;
|
|
104
|
+
return comparePrerelease(left.prerelease, right.prerelease);
|
|
105
|
+
}
|
|
106
|
+
function parseClause(raw) {
|
|
107
|
+
const text = raw.trim();
|
|
108
|
+
if (text.length === 0)
|
|
109
|
+
return null;
|
|
110
|
+
const ops = [
|
|
111
|
+
['>=', '>='],
|
|
112
|
+
['<=', '<='],
|
|
113
|
+
['>', '>'],
|
|
114
|
+
['<', '<'],
|
|
115
|
+
['^', '^'],
|
|
116
|
+
['~', '~'],
|
|
117
|
+
['=', '='],
|
|
118
|
+
];
|
|
119
|
+
for (const [prefix, op] of ops) {
|
|
120
|
+
if (text.startsWith(prefix)) {
|
|
121
|
+
const rest = text.slice(prefix.length).trim();
|
|
122
|
+
const parsed = parsePartial(rest);
|
|
123
|
+
return parsed ? { op, ...parsed } : null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const parsed = parsePartial(text);
|
|
127
|
+
return parsed ? { op: '=', ...parsed } : null;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* `18`, `18.x`, `18.19`, `18.19.0` - the partial forms `engines.node` uses in
|
|
131
|
+
* practice. Missing components read as `0`, which is what makes `>=18` mean
|
|
132
|
+
* `>=18.0.0`, and `specified` records how many were really named.
|
|
133
|
+
*/
|
|
134
|
+
function parsePartial(raw) {
|
|
135
|
+
const text = raw.trim().replace(/^v/, '');
|
|
136
|
+
if (text.length === 0)
|
|
137
|
+
return null;
|
|
138
|
+
const parts = text.split('.');
|
|
139
|
+
if (parts.length > 3)
|
|
140
|
+
return null;
|
|
141
|
+
const nums = [];
|
|
142
|
+
let specified = 0;
|
|
143
|
+
let sawWildcard = false;
|
|
144
|
+
for (const part of parts) {
|
|
145
|
+
if (part === 'x' || part === 'X' || part === '*') {
|
|
146
|
+
sawWildcard = true;
|
|
147
|
+
nums.push(0);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (!/^\d+$/.test(part))
|
|
151
|
+
return null;
|
|
152
|
+
nums.push(Number.parseInt(part, 10));
|
|
153
|
+
// A concrete component AFTER a wildcard (`18.x.3`) names nothing further:
|
|
154
|
+
// the wildcard has already made everything below it free.
|
|
155
|
+
if (!sawWildcard)
|
|
156
|
+
specified += 1;
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
version: {
|
|
160
|
+
major: nums[0] ?? 0,
|
|
161
|
+
minor: nums[1] ?? 0,
|
|
162
|
+
patch: nums[2] ?? 0,
|
|
163
|
+
prerelease: [],
|
|
164
|
+
},
|
|
165
|
+
specified,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
function compareParsed(a, b) {
|
|
169
|
+
if (a.major !== b.major)
|
|
170
|
+
return a.major < b.major ? -1 : 1;
|
|
171
|
+
if (a.minor !== b.minor)
|
|
172
|
+
return a.minor < b.minor ? -1 : 1;
|
|
173
|
+
if (a.patch !== b.patch)
|
|
174
|
+
return a.patch < b.patch ? -1 : 1;
|
|
175
|
+
return comparePrerelease(a.prerelease, b.prerelease);
|
|
176
|
+
}
|
|
177
|
+
function satisfiesClause(actual, clause) {
|
|
178
|
+
const cmp = compareParsed(actual, clause.version);
|
|
179
|
+
switch (clause.op) {
|
|
180
|
+
case '>=':
|
|
181
|
+
return cmp >= 0;
|
|
182
|
+
case '>':
|
|
183
|
+
return cmp > 0;
|
|
184
|
+
case '<=':
|
|
185
|
+
return cmp <= 0;
|
|
186
|
+
case '<':
|
|
187
|
+
return cmp < 0;
|
|
188
|
+
case '=':
|
|
189
|
+
// A BARE PARTIAL IS A RANGE, NOT AN EQUALITY. `18` and `18.x` mean "any
|
|
190
|
+
// 18.y.z"; only a fully-specified `18.19.0` means that exact version.
|
|
191
|
+
if (clause.specified === 0)
|
|
192
|
+
return true;
|
|
193
|
+
if (actual.major !== clause.version.major)
|
|
194
|
+
return false;
|
|
195
|
+
if (clause.specified === 1)
|
|
196
|
+
return true;
|
|
197
|
+
if (actual.minor !== clause.version.minor)
|
|
198
|
+
return false;
|
|
199
|
+
if (clause.specified === 2)
|
|
200
|
+
return true;
|
|
201
|
+
return actual.patch === clause.version.patch;
|
|
202
|
+
case '^':
|
|
203
|
+
return cmp >= 0 && actual.major === clause.version.major;
|
|
204
|
+
case '~':
|
|
205
|
+
return (cmp >= 0 &&
|
|
206
|
+
actual.major === clause.version.major &&
|
|
207
|
+
actual.minor === clause.version.minor);
|
|
208
|
+
default:
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Does `nodeVersion` satisfy `engines.node`?
|
|
214
|
+
*
|
|
215
|
+
* FAILS OPEN, and that is a decision rather than an oversight (D-15). Handled
|
|
216
|
+
* forms are `>=X`, `>=X.Y.Z`, `>X`, `<X`, `<=X`, `=X`, `^X`, `~X`, `X.x`, and
|
|
217
|
+
* `||`-joined unions of those, with space-separated clauses inside a union
|
|
218
|
+
* ANDed together. Anything this cannot parse returns `true`, matching npm's own
|
|
219
|
+
* default of warning rather than refusing.
|
|
220
|
+
*
|
|
221
|
+
* Failing CLOSED here would mean a syntax we did not anticipate silently freezes
|
|
222
|
+
* every user's updates forever, with no message and no way to find out - the
|
|
223
|
+
* worse of the two errors by a wide margin. A range we DID understand and that
|
|
224
|
+
* genuinely excludes this Node yields `false`, and the caller turns that into a
|
|
225
|
+
* `node-too-old` notice so the user is told to upgrade Node rather than left
|
|
226
|
+
* wondering.
|
|
227
|
+
*/
|
|
228
|
+
export function satisfiesNodeRange(range, nodeVersion) {
|
|
229
|
+
if (range === undefined)
|
|
230
|
+
return true;
|
|
231
|
+
const text = range.trim();
|
|
232
|
+
if (text.length === 0 || text === '*')
|
|
233
|
+
return true;
|
|
234
|
+
const actual = parseSemver(nodeVersion);
|
|
235
|
+
if (!actual)
|
|
236
|
+
return true;
|
|
237
|
+
for (const union of text.split('||')) {
|
|
238
|
+
const clauses = union.trim().split(/\s+/).filter((c) => c.length > 0);
|
|
239
|
+
if (clauses.length === 0)
|
|
240
|
+
continue;
|
|
241
|
+
let allParsed = true;
|
|
242
|
+
let allSatisfied = true;
|
|
243
|
+
for (const raw of clauses) {
|
|
244
|
+
const clause = parseClause(raw);
|
|
245
|
+
if (!clause) {
|
|
246
|
+
allParsed = false;
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
if (!satisfiesClause(actual, clause))
|
|
250
|
+
allSatisfied = false;
|
|
251
|
+
}
|
|
252
|
+
// An unparseable clause makes the WHOLE range unknown, not just this union:
|
|
253
|
+
// a partially-understood range is exactly the input that would make a
|
|
254
|
+
// fail-open guard silently strict.
|
|
255
|
+
if (!allParsed)
|
|
256
|
+
return true;
|
|
257
|
+
if (allSatisfied)
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Should we install, merely report, or say nothing? (section 3.4)
|
|
264
|
+
*
|
|
265
|
+
* ORDER IS SEMANTIC. `up-to-date` is answered first because it is the
|
|
266
|
+
* overwhelmingly common case and the cheapest; `skipped` comes before the
|
|
267
|
+
* quality gates so a user who typed `/update skip` is not told about a
|
|
268
|
+
* prerelease they already declined. Only a range we UNDERSTOOD and that excludes
|
|
269
|
+
* this Node produces `notify`; every other rejection is silent (`none`).
|
|
270
|
+
*
|
|
271
|
+
* The SOURCE and the MODE are deliberately NOT inputs here. This function
|
|
272
|
+
* answers "is that version worth having"; whether this machine may install it is
|
|
273
|
+
* the service's question, and folding the two would make a pure decision depend
|
|
274
|
+
* on the filesystem.
|
|
275
|
+
*/
|
|
276
|
+
export function decideUpdate(input) {
|
|
277
|
+
const { local, manifest, nodeVersion, skippedVersion } = input;
|
|
278
|
+
const remote = manifest.version;
|
|
279
|
+
if (compareSemver(remote, local) <= 0)
|
|
280
|
+
return { action: 'none', reason: 'up-to-date' };
|
|
281
|
+
if (skippedVersion.length > 0 && remote === skippedVersion) {
|
|
282
|
+
return { action: 'none', reason: 'skipped' };
|
|
283
|
+
}
|
|
284
|
+
// A stable install is never moved onto a prerelease, even if someone points
|
|
285
|
+
// `latest` at one. A local prerelease is already opted in, so it may move.
|
|
286
|
+
if (isPrerelease(remote) && !isPrerelease(local)) {
|
|
287
|
+
return { action: 'none', reason: 'prerelease' };
|
|
288
|
+
}
|
|
289
|
+
if (manifest.deprecated !== undefined)
|
|
290
|
+
return { action: 'none', reason: 'deprecated' };
|
|
291
|
+
if (!satisfiesNodeRange(manifest.engines?.node, nodeVersion)) {
|
|
292
|
+
return { action: 'notify', reason: 'node-too-old' };
|
|
293
|
+
}
|
|
294
|
+
return { action: 'install', reason: 'newer-available' };
|
|
295
|
+
}
|