@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,133 @@
1
+ /**
2
+ * The proxy-aware fallback probe (cli-auto-update-hardening H3 / section 5.4).
3
+ *
4
+ * ASCII ONLY - this tree is inside the glyph scanner's scope (C-2 / C-3).
5
+ *
6
+ * NODE'S `fetch` (undici) IGNORES `HTTP_PROXY`. v1 recorded that as R-5 and
7
+ * accepted it: "the check fails, silently, forever". When the direct request
8
+ * returns `null`, the check now retries ONCE through the npm this machine
9
+ * already has, which honours `proxy`, `https-proxy`, `.npmrc` and any auth the
10
+ * user configured.
11
+ *
12
+ * WE DELEGATE RATHER THAN IMPLEMENT A PROXY (D-34). We do not read `.npmrc`, we
13
+ * do not parse `HTTP_PROXY`, and we attach credentials to nothing - D-13's "NO
14
+ * CREDENTIALS, EVER" is untouched because we never see them. We run the command
15
+ * the user could have typed and read one version string out of its JSON.
16
+ *
17
+ * `execFile` HERE, `spawn` IN `installer.ts`, AND THAT IS NOT AN INCONSISTENCY
18
+ * (D-36 / C-20). v1's IF-3 rejected `execFile` for the installer because it
19
+ * forwards neither `stdio` nor `detached`. This probe wants neither: it wants
20
+ * the buffered stdout `execFile` exists to provide, and it must NOT be detached.
21
+ * `timeout` is likewise safe here and forbidden there - a killed `npm view` has
22
+ * written nothing, a killed `npm install -g` has left a broken global install.
23
+ */
24
+ import { execFile } from 'node:child_process';
25
+ import process from 'node:process';
26
+ import { cleanInstallEnv, resolveNpmCli } from './installer.js';
27
+ import { UPDATE_LIMITS } from './limits.js';
28
+ /**
29
+ * The argv this probe is allowed to build. Exported for AC-47's assertion, on
30
+ * the `buildInstallArgs` precedent.
31
+ *
32
+ * `--registry` IS NOT OPTIONAL, and leaving it out was a real divergence (P1-4 /
33
+ * D-41). The HTTP path resolves `update.registry` through `resolveRegistryUrl`;
34
+ * `npm view` without the flag obeys whatever `.npmrc` says. The two would then
35
+ * answer about DIFFERENT REGISTRIES - and the population where they differ is a
36
+ * mirror-configured corporate machine, which is the same population this
37
+ * fallback exists for. The `registry` passed in is the already-resolved string
38
+ * `runCheck` computed, so the two probes describe one registry by construction.
39
+ *
40
+ * Auth is unaffected: `--registry` selects a URL, and npm still applies whatever
41
+ * `.npmrc` credentials are scoped to it. We neither read nor forward any (D-13).
42
+ */
43
+ export function buildNpmViewArgs(npmCliJs, packageName, distTag, registry) {
44
+ return [
45
+ npmCliJs,
46
+ 'view',
47
+ `${packageName}@${distTag}`,
48
+ '--json',
49
+ '--loglevel=error',
50
+ `--registry=${registry}`,
51
+ ];
52
+ }
53
+ /**
54
+ * Normalise `npm view`'s object into the SAME shape `fetchLatestManifest`
55
+ * returns, so `decideUpdate` cannot tell the two sources apart.
56
+ *
57
+ * `npm view pkg@tag --json` on a dist-tag yields one object. An ARRAY comes back
58
+ * for a range, which we never send - but npm is not ours, so take the last
59
+ * element rather than failing the whole probe on a shape we did not ask for.
60
+ */
61
+ function normalise(parsed) {
62
+ const one = Array.isArray(parsed) ? parsed[parsed.length - 1] : parsed;
63
+ if (!one || typeof one !== 'object')
64
+ return null;
65
+ const doc = one;
66
+ if (typeof doc.name !== 'string' || typeof doc.version !== 'string')
67
+ return null;
68
+ return {
69
+ name: doc.name,
70
+ version: doc.version,
71
+ ...(doc.engines && typeof doc.engines === 'object'
72
+ ? { engines: { ...(typeof doc.engines.node === 'string' ? { node: doc.engines.node } : {}) } }
73
+ : {}),
74
+ // Present ONLY when the version is deprecated. npm writes a string, but
75
+ // `true` appears in the wild; both mean "do not auto-install this".
76
+ ...(doc.deprecated !== undefined ? { deprecated: String(doc.deprecated) } : {}),
77
+ };
78
+ }
79
+ /**
80
+ * Ask the machine's own npm what the dist-tag points at, or `null`.
81
+ *
82
+ * NEVER THROWS - the same contract as `fetchLatestManifest`, for the same
83
+ * reason: the only caller is a background timer whose entire contract is that
84
+ * the user does not find out. Any non-zero exit, any timeout, any unparseable
85
+ * stdout and a `resolveNpmCli()` of `null` all resolve to `null`.
86
+ *
87
+ * NO BARE-`npm`-ON-PATH FALLBACK, for the reason `installer.ts` already gives:
88
+ * on Windows that resolves to `npm.cmd`, which U-2 forbids.
89
+ */
90
+ export function fetchLatestViaNpm(registry, packageName, distTag, deps = {}) {
91
+ return new Promise((settle) => {
92
+ const npmCliJs = deps.npmCliPath !== undefined ? deps.npmCliPath : resolveNpmCli();
93
+ if (!npmCliJs) {
94
+ settle(null);
95
+ return;
96
+ }
97
+ const run = deps.execFileImpl ?? execFile;
98
+ const options = {
99
+ // C-7, spelled out rather than inherited: this argv carries a package name
100
+ // read from a manifest on disk and a registry URL from config.
101
+ shell: false,
102
+ windowsHide: true,
103
+ timeout: deps.timeoutMs ?? UPDATE_LIMITS.npmViewTimeoutMs,
104
+ maxBuffer: UPDATE_LIMITS.manifestMaxBytes,
105
+ env: cleanInstallEnv(deps.env ?? process.env),
106
+ };
107
+ let settled = false;
108
+ const finish = (value) => {
109
+ if (settled)
110
+ return;
111
+ settled = true;
112
+ settle(value);
113
+ };
114
+ try {
115
+ run(process.execPath, buildNpmViewArgs(npmCliJs, packageName, distTag, registry), options, (error, stdout) => {
116
+ if (error) {
117
+ finish(null);
118
+ return;
119
+ }
120
+ try {
121
+ finish(normalise(JSON.parse(String(stdout))));
122
+ }
123
+ catch {
124
+ finish(null);
125
+ }
126
+ });
127
+ }
128
+ catch {
129
+ // `execFile` can throw synchronously on a bad argv or a missing binary.
130
+ finish(null);
131
+ }
132
+ });
133
+ }
@@ -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;