@widgetic/cli 0.0.0-stage → 0.1.1

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 (50) hide show
  1. package/LICENSE +122 -0
  2. package/README.md +184 -2
  3. package/bin/widgetic.js +5 -0
  4. package/dist/cli.js +106 -0
  5. package/dist/commands/auth.js +269 -0
  6. package/dist/commands/call.js +97 -0
  7. package/dist/commands/completion.js +148 -0
  8. package/dist/commands/doctor.js +291 -0
  9. package/dist/commands/schema.js +214 -0
  10. package/dist/commands/update.js +159 -0
  11. package/dist/commands/verify.js +64 -0
  12. package/dist/config.js +98 -0
  13. package/dist/confirm.js +15 -0
  14. package/dist/constants.js +51 -0
  15. package/dist/contract-note.js +42 -0
  16. package/dist/credentials.js +111 -0
  17. package/dist/environment.js +150 -0
  18. package/dist/errors.js +67 -0
  19. package/dist/http.js +122 -0
  20. package/dist/input.js +37 -0
  21. package/dist/main.js +30 -0
  22. package/dist/manifest/load.js +39 -0
  23. package/dist/manifest/types.js +14 -0
  24. package/dist/mcp/plan.js +32 -0
  25. package/dist/mcp/register.js +112 -0
  26. package/dist/mcp/session.js +185 -0
  27. package/dist/mcp/wait.js +68 -0
  28. package/dist/options.js +228 -0
  29. package/dist/output.js +90 -0
  30. package/dist/overlay.js +31 -0
  31. package/dist/patterns.js +11 -0
  32. package/dist/rest/execute.js +67 -0
  33. package/dist/rest/mime.js +37 -0
  34. package/dist/rest/plan.js +185 -0
  35. package/dist/rest/register.js +139 -0
  36. package/dist/retry.js +49 -0
  37. package/dist/secrets.js +149 -0
  38. package/dist/state.js +37 -0
  39. package/dist/update/installation.js +83 -0
  40. package/dist/update/lookup.js +128 -0
  41. package/dist/update/offer.js +165 -0
  42. package/dist/update/perform.js +33 -0
  43. package/dist/update/registry.js +68 -0
  44. package/dist/update/version.js +39 -0
  45. package/dist/validate.js +93 -0
  46. package/dist/version.js +59 -0
  47. package/generated/contracts.json +1 -0
  48. package/generated/mcp-manifest.json +1 -0
  49. package/generated/rest-manifest.json +1 -0
  50. package/package.json +56 -5
@@ -0,0 +1,139 @@
1
+ import { resolveSettings, timeoutMilliseconds } from "../config.js";
2
+ import { confirmOrStop } from "../confirm.js";
3
+ import { requireApiKey } from "../credentials.js";
4
+ import { addCommonOptions, addFlag, commandHelp, readCommon, readFlags, shorten } from "../options.js";
5
+ import { note, printResult } from "../output.js";
6
+ import { retryNotifier } from "../retry.js";
7
+ import { sendRestRequest } from "./execute.js";
8
+ import { describeRestPlan, planRestRequest } from "./plan.js";
9
+ /**
10
+ * The `api` namespace: one command for every operation of the public OpenAPI description, built from the manifest the
11
+ * generator wrote. A command is a group (the operation's tag) and a name (its operationId in kebab case); a path,
12
+ * query or header parameter is a flag, and so is each simple property of a JSON document (`--data` takes the rest).
13
+ */
14
+ const PARAMETER_GROUPS = {
15
+ path: 'Path parameters:',
16
+ query: 'Query parameters:',
17
+ header: 'Headers:'
18
+ };
19
+ /** What a command prints when the server answered with something: the body as the server sent it. */
20
+ function printAnswer(environment, answer, json) {
21
+ if (answer.body === undefined) {
22
+ // An empty answer (204): the exit code says it worked, and `--json` stdout stays empty for a program to read.
23
+ if (!json)
24
+ note(environment, 'Done.');
25
+ return;
26
+ }
27
+ printResult(environment, answer.body, json);
28
+ }
29
+ /** Runs one command: plan, then (unless `--dry-run`) confirm, find the key, send, print. */
30
+ export async function runRestCommand(run, command, node, bindings) {
31
+ const { environment, manifest } = run;
32
+ const common = readCommon(node);
33
+ const values = readFlags(node, bindings);
34
+ const options = node.opts();
35
+ const data = typeof options.data === 'string' ? options.data : undefined;
36
+ const settings = resolveSettings(environment, { profile: common.profile, manifestApiUrl: manifest.apiUrl });
37
+ const plan = await planRestRequest({ environment, command, settings, values, data, canAsk: !common.json });
38
+ if (common.dryRun) {
39
+ printResult(environment, describeRestPlan(plan), common.json);
40
+ return;
41
+ }
42
+ if (command.confirm) {
43
+ await confirmOrStop(environment, {
44
+ what: `${plan.method} ${new URL(plan.url).pathname} changes or removes something`,
45
+ yes: common.yes,
46
+ json: common.json
47
+ });
48
+ }
49
+ const key = plan.authenticated ? await requireApiKey(environment, settings) : undefined;
50
+ const answer = await sendRestRequest(environment, plan, {
51
+ apiKey: key?.apiKey,
52
+ keySource: key?.source,
53
+ timeoutMilliseconds: timeoutMilliseconds(common.timeout),
54
+ onRetry: retryNotifier(environment, common.json)
55
+ });
56
+ printAnswer(environment, answer, common.json);
57
+ }
58
+ /**
59
+ * What the flag of an idempotency key says. The contract's own text starts with "Required: a call without it is refused",
60
+ * which is true of the API and not of the CLI, and it is too long for `--help` to show whole: the limits are checked
61
+ * against the contract and named by the error when a key is wrong, so they are not repeated here.
62
+ */
63
+ const IDEMPOTENCY_KEY_HELP = 'Names this one request, so a repeat of it is carried out once. The CLI makes a key when this is left out and sends the same one on every try; give your own (a UUID is ideal) to repeat the call by hand later.';
64
+ /**
65
+ * A parameter as the flag shows it (in `--help` and in the generated reference): the CLI makes the idempotency key
66
+ * itself, so it is never demanded.
67
+ */
68
+ export function flagSpecOf(parameter) {
69
+ if (parameter.role !== 'idempotency-key')
70
+ return parameter;
71
+ return { ...parameter, required: false, description: IDEMPOTENCY_KEY_HELP };
72
+ }
73
+ /**
74
+ * The kind of key an operation needs, when it is not just any: said at the end of its help (so a limit on the length of
75
+ * the description cannot cut it), because a 403 is the only other way to learn it.
76
+ */
77
+ export function accessNote(command) {
78
+ if (command.accessLevel === 'partner')
79
+ return 'Needs a partner API key (wgt_prt_…).';
80
+ if (!command.auth)
81
+ return 'Needs no API key: browsers call it, and none is sent.';
82
+ return undefined;
83
+ }
84
+ function registerCommand(parent, group, command, run) {
85
+ const access = accessNote(command);
86
+ const help = commandHelp(command.summary, command.description, `widgetic schema api ${group.name} ${command.name}`);
87
+ const node = parent
88
+ .command(command.name, { hidden: command.hidden })
89
+ .summary(command.summary)
90
+ .description(access === undefined ? help : `${help}\n\n${access}`);
91
+ for (const alias of command.aliases)
92
+ node.alias(alias);
93
+ const bindings = [];
94
+ for (const parameter of command.parameters) {
95
+ bindings.push(addFlag(node, flagSpecOf(parameter), { mandatory: true, group: PARAMETER_GROUPS[parameter.in] }));
96
+ }
97
+ const body = command.body;
98
+ if (body?.kind === 'json') {
99
+ for (const flag of body.flags)
100
+ bindings.push(addFlag(node, flag, { mandatory: false, group: 'Fields of the document:' }));
101
+ }
102
+ else if (body?.kind === 'multipart') {
103
+ for (const flag of body.flags)
104
+ bindings.push(addFlag(node, flag, { mandatory: true, group: 'Form fields:' }));
105
+ }
106
+ else if (body?.kind === 'binary') {
107
+ const accepted = body.contentTypes ?? [body.contentType];
108
+ bindings.push(addFlag(node, {
109
+ flag: 'file',
110
+ name: 'file',
111
+ type: 'file',
112
+ required: true,
113
+ description: 'The file to send: its bytes are the body of the request.'
114
+ }, { mandatory: true, group: 'The file:' }), addFlag(node, {
115
+ flag: 'content-type',
116
+ name: 'content-type',
117
+ type: 'string',
118
+ required: false,
119
+ description: 'What kind of file it is; guessed from the file name when left out.',
120
+ enum: accepted
121
+ }, { mandatory: false, group: 'The file:' }));
122
+ }
123
+ addCommonOptions(node, { asks: command.confirm, document: body?.kind === 'json' ? 'data' : undefined });
124
+ node.action(async () => {
125
+ await runRestCommand(run, command, node, bindings);
126
+ });
127
+ }
128
+ /** Adds the groups of the manifest, and their commands, under `api`. */
129
+ export function registerRestCommands(api, manifest, environment) {
130
+ const run = { environment, manifest };
131
+ for (const group of manifest.groups) {
132
+ const node = api
133
+ .command(group.name, { hidden: group.hidden })
134
+ .summary(group.title)
135
+ .description(shorten(group.description || group.title, 300));
136
+ for (const command of group.commands)
137
+ registerCommand(node, group, command, run);
138
+ }
139
+ }
package/dist/retry.js ADDED
@@ -0,0 +1,49 @@
1
+ import { RETRY_CONFIG } from "./constants.js";
2
+ import { CliError } from "./errors.js";
3
+ import { note } from "./output.js";
4
+ const UNPROCESSED = new Set([429]);
5
+ const LOST = new Set([502, 503, 504]);
6
+ /** Milliseconds to wait before the next try, or `undefined` when the failure is not one to try again. */
7
+ export function retryDelay(error, attempt, rules) {
8
+ if (attempt >= RETRY_CONFIG.MAX_ATTEMPTS)
9
+ return undefined;
10
+ const status = error.status;
11
+ const lost = error.code === 'NETWORK_ERROR' || error.code === 'TIMEOUT' || (status !== undefined && LOST.has(status));
12
+ const refusedFirst = (status !== undefined && UNPROCESSED.has(status)) ||
13
+ (status === 409 && rules.hasIdempotencyKey === true && error.code === 'CONFLICT_IDEMPOTENCY_IN_PROGRESS');
14
+ if (!refusedFirst && !(lost && rules.safeToRepeat))
15
+ return undefined;
16
+ if (error.retryAfter !== undefined) {
17
+ // A server that asks for a long wait is believed, and reported: the caller decides whether to wait that long.
18
+ if (error.retryAfter > RETRY_CONFIG.MAX_RETRY_AFTER_SECONDS)
19
+ return undefined;
20
+ return Math.max(error.retryAfter, 0) * 1000;
21
+ }
22
+ return RETRY_CONFIG.BASE_DELAY_MS * RETRY_CONFIG.GROWTH_FACTOR ** (attempt - 1);
23
+ }
24
+ /** The note a person reads while the CLI waits to try again; `--json` gets none, its stderr holds errors only. */
25
+ export function retryNotifier(environment, json) {
26
+ return (error, delayMilliseconds, nextAttempt) => {
27
+ if (json)
28
+ return;
29
+ const seconds = Math.max(1, Math.round(delayMilliseconds / 1000));
30
+ note(environment, `${error.message} Trying again in ${seconds}s (attempt ${nextAttempt} of ${RETRY_CONFIG.MAX_ATTEMPTS}).`);
31
+ };
32
+ }
33
+ /** Runs `attempt` until it succeeds or fails for good; `onRetry` hears about each wait (for a note on stderr). */
34
+ export async function withRetries(environment, rules, attempt, onRetry) {
35
+ for (let tries = 1;; tries += 1) {
36
+ try {
37
+ return await attempt();
38
+ }
39
+ catch (error) {
40
+ if (!(error instanceof CliError))
41
+ throw error;
42
+ const delay = retryDelay(error, tries, rules);
43
+ if (delay === undefined)
44
+ throw error;
45
+ onRetry?.(error, delay, tries + 1);
46
+ await environment.sleep(delay);
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,149 @@
1
+ import { resolve } from 'node:path';
2
+ import { readAll } from "./environment.js";
3
+ import { usageError } from "./errors.js";
4
+ /**
5
+ * Secrets. A value the contract says is a secret (`format: password`, or `writeOnly`: the server takes it and never
6
+ * gives it back) is the key of an adapter, the secret half of a storage key or a database connection string. It is
7
+ * handled in three ways, so that it does not end up where a person did not look:
8
+ *
9
+ * - it is never the text of a flag: what is typed on a command line stays in the shell's history and in the list of
10
+ * processes. The flag takes `-` (ask on the terminal, or read standard input) or `@path` (read a file), the way
11
+ * `--data` does, and a value typed on the command line is refused without being repeated;
12
+ * - `--dry-run` prints `[redacted]` in its place, whether it came from a flag or from the document of `--data`;
13
+ * - nothing else prints it: an error names the flag, never the value.
14
+ *
15
+ * Which values are secrets is the contract's word, not a list of names in this program: a new secret field is covered
16
+ * the day the gateway marks it, and the generator stops (`scripts/lib/`) at a secret it has no safe way to take.
17
+ */
18
+ /** What `--dry-run` prints in the place of a secret. */
19
+ export const REDACTED = '[redacted]';
20
+ /** A secret the person gave as the text of a flag: refused, and the text is not repeated. */
21
+ function typedOnTheCommandLine(flag) {
22
+ return usageError(`--${flag} takes a secret, and a secret typed on the command line stays in the shell's history: give @path to read it from a file, or - to type it where it is not shown (or to read it from standard input).`, 'Or put it in the document of --data, which a file or standard input can hold.');
23
+ }
24
+ /** Whether a `-` asks on the terminal (hidden): there is a terminal, and the command may ask (`--json` never does). */
25
+ function asksOnTerminal(environment, canAsk) {
26
+ return canAsk && environment.promptSecret !== undefined;
27
+ }
28
+ /**
29
+ * The secret a flag names: `-` asks on the terminal (hidden) when there is one and the command may ask, and reads
30
+ * standard input when not; `@path` reads a file. The line break at the end of a file or a pipe is not part of the secret.
31
+ */
32
+ export async function readSecret(environment, source, flag, canAsk) {
33
+ let text;
34
+ if (source === '-') {
35
+ text =
36
+ canAsk && environment.promptSecret !== undefined
37
+ ? await environment.promptSecret(`--${flag} (it is not shown): `)
38
+ : await readAll(environment.stdin);
39
+ }
40
+ else if (source.startsWith('@') && source.length > 1) {
41
+ const path = resolve(environment.cwd, source.slice(1));
42
+ try {
43
+ text = new TextDecoder().decode(await environment.fs.readFile(path));
44
+ }
45
+ catch {
46
+ throw usageError(`--${flag}: the file ${path} cannot be read.`);
47
+ }
48
+ }
49
+ else {
50
+ throw typedOnTheCommandLine(flag);
51
+ }
52
+ const secret = text.replace(/[\r\n]+$/, '');
53
+ if (secret === '')
54
+ throw usageError(`--${flag}: the secret is empty.`);
55
+ return secret;
56
+ }
57
+ /**
58
+ * The values the person typed, with the `-` or `@path` of each secret flag replaced by the secret it names. Only one
59
+ * source can be standard input: two readers of it are stopped before either reads. (A `-` that asks on the terminal
60
+ * does not read standard input, so nothing is shared then.)
61
+ */
62
+ export async function withSecrets(environment, flags, values, options) {
63
+ const resolved = new Map(values);
64
+ const given = flags.filter((flag) => flag.secret === true && typeof values.get(flag.flag) === 'string');
65
+ if (!asksOnTerminal(environment, options.canAsk)) {
66
+ const dashed = given.filter((flag) => values.get(flag.flag) === '-').map((flag) => flag.flag);
67
+ const readers = [...(options.others ?? []), ...dashed];
68
+ if (readers.length > 1) {
69
+ throw usageError(`${readers.map((name) => `--${name}`).join(' and ')} all read standard input, and only one of them can.`, 'Give the others as @path.');
70
+ }
71
+ }
72
+ for (const flag of given) {
73
+ resolved.set(flag.flag, await readSecret(environment, String(values.get(flag.flag)), flag.flag, options.canAsk));
74
+ }
75
+ return resolved;
76
+ }
77
+ // ------------------------------------------------------------------------------------------------------- REDACTION
78
+ /** How deep a schema is followed: a cycle of `$ref`s ends here instead of never. */
79
+ const MOST_NESTING = 16;
80
+ function isRecord(value) {
81
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
82
+ }
83
+ /** The schema a `$ref` of this document points at (`#`, `#/$defs/Name`, `#/definitions/Name`), or the schema itself. */
84
+ function followed(root, schema) {
85
+ let current = schema;
86
+ for (let hops = 0; hops < MOST_NESTING; hops += 1) {
87
+ const ref = current?.$ref;
88
+ if (current === undefined || typeof ref !== 'string')
89
+ return current;
90
+ if (ref === '#') {
91
+ current = root;
92
+ continue;
93
+ }
94
+ if (!ref.startsWith('#/'))
95
+ return undefined;
96
+ let node = root;
97
+ for (const segment of ref.slice(2).split('/')) {
98
+ node = isRecord(node) ? node[segment.replace(/~1/g, '/').replace(/~0/g, '~')] : undefined;
99
+ }
100
+ current = isRecord(node) ? node : undefined;
101
+ }
102
+ return undefined;
103
+ }
104
+ function redact(value, schema, root, depth) {
105
+ if (value === null || value === undefined || depth > MOST_NESTING)
106
+ return value;
107
+ const resolved = followed(root, schema);
108
+ if (resolved === undefined)
109
+ return value;
110
+ // The value itself is a secret, whatever its type.
111
+ if (resolved.format === 'password' || resolved.writeOnly === true)
112
+ return REDACTED;
113
+ let result = value;
114
+ // Alternatives: a secret in any of them is hidden, even when another one is the shape the value really has.
115
+ for (const keyword of ['allOf', 'anyOf', 'oneOf']) {
116
+ const alternatives = resolved[keyword];
117
+ if (!Array.isArray(alternatives))
118
+ continue;
119
+ for (const alternative of alternatives) {
120
+ if (isRecord(alternative))
121
+ result = redact(result, alternative, root, depth + 1);
122
+ }
123
+ }
124
+ if (Array.isArray(result) && isRecord(resolved.items)) {
125
+ const items = resolved.items;
126
+ result = result.map((item) => redact(item, items, root, depth + 1));
127
+ }
128
+ if (isRecord(result)) {
129
+ const properties = isRecord(resolved.properties) ? resolved.properties : {};
130
+ const rest = isRecord(resolved.additionalProperties) ? resolved.additionalProperties : undefined;
131
+ const copy = { ...result };
132
+ for (const [key, entry] of Object.entries(result)) {
133
+ const own = properties[key];
134
+ const schemaOfEntry = isRecord(own) ? own : rest;
135
+ if (schemaOfEntry !== undefined)
136
+ copy[key] = redact(entry, schemaOfEntry, root, depth + 1);
137
+ }
138
+ result = copy;
139
+ }
140
+ return result;
141
+ }
142
+ /**
143
+ * A copy of `value` in which every part the schema calls a secret is `[redacted]`: what `--dry-run` prints of a
144
+ * document. The schema is the contract's own (`$ref`s into its `$defs` are followed), so a secret is found wherever
145
+ * the contract puts it, not only at the top; a part the schema does not describe is left as it is.
146
+ */
147
+ export function redactSecrets(value, schema) {
148
+ return schema === undefined ? value : redact(value, schema, schema, 0);
149
+ }
package/dist/state.js ADDED
@@ -0,0 +1,37 @@
1
+ import { join } from 'node:path';
2
+ import { isRecord } from "./http.js";
3
+ /**
4
+ * `state.json`, beside the credentials: what the CLI remembers between two runs that is not a secret. It is one JSON
5
+ * object with a key for each thing remembered (`contractNote`, `updateCheck`), and every writer changes only its own key,
6
+ * so one feature never erases what another kept.
7
+ *
8
+ * Nothing in it is needed: a file that is missing, cannot be read or is not an object is the same as an empty one, and
9
+ * a write that fails is not an error of the command that asked for it (the CLI only says the thing again, or looks again).
10
+ */
11
+ const STATE_FILE = 'state.json';
12
+ export function statePath(configDir) {
13
+ return join(configDir, STATE_FILE);
14
+ }
15
+ /** Everything that was remembered; an empty object when there is no file or it is damaged. */
16
+ export async function readState(environment, configDir) {
17
+ const path = statePath(configDir);
18
+ try {
19
+ if (!(await environment.fs.stat(path)))
20
+ return {};
21
+ const parsed = JSON.parse(new TextDecoder().decode(await environment.fs.readFile(path)));
22
+ return isRecord(parsed) ? parsed : {};
23
+ }
24
+ catch {
25
+ // A damaged state file is the same as none.
26
+ return {};
27
+ }
28
+ }
29
+ /**
30
+ * Sets one key and leaves the others as they are (the file is read again just before it is written, so a record that
31
+ * another run saved a moment ago is kept). Rejects when the file cannot be written: the caller decides whether that matters.
32
+ */
33
+ export async function writeStateKey(environment, configDir, key, value) {
34
+ const state = await readState(environment, configDir);
35
+ state[key] = value;
36
+ await environment.fs.writeFilePrivately(statePath(configDir), `${JSON.stringify(state, null, 2)}\n`);
37
+ }
@@ -0,0 +1,83 @@
1
+ import { join } from 'node:path';
2
+ import { isRecord } from "../http.js";
3
+ import { isReleaseVersion } from "./version.js";
4
+ export function describeInstallation(packageFolder) {
5
+ // One separator for every platform, and no case: Windows paths and macOS volumes do not agree on either.
6
+ const path = `${packageFolder.replaceAll('\\', '/').toLowerCase().replace(/\/+$/, '')}/`;
7
+ if (!path.includes('/node_modules/'))
8
+ return { kind: 'checkout' };
9
+ if (path.includes('/_npx/') ||
10
+ path.includes('/pnpm/dlx/') ||
11
+ path.includes('/dlx-') ||
12
+ path.includes('/bunx-')) {
13
+ return { kind: 'transient' };
14
+ }
15
+ if (path.includes('/.bun/install/global/node_modules/'))
16
+ return { kind: 'global', manager: 'bun' };
17
+ if (path.includes('/pnpm/global/'))
18
+ return { kind: 'global', manager: 'pnpm' };
19
+ if (path.includes('/yarn/global/node_modules/') || path.includes('/yarn/data/global/node_modules/')) {
20
+ return { kind: 'global', manager: 'yarn' };
21
+ }
22
+ // npm: `<prefix>/lib/node_modules/@widgetic/cli` (macOS, Linux, nvm, fnm, Volta) or
23
+ // `%AppData%/npm/node_modules/@widgetic/cli` (Windows).
24
+ if (path.endsWith('/lib/node_modules/@widgetic/cli/') ||
25
+ path.endsWith('/npm/node_modules/@widgetic/cli/')) {
26
+ return { kind: 'global', manager: 'npm' };
27
+ }
28
+ return { kind: 'unknown' };
29
+ }
30
+ /**
31
+ * The command that installs exactly `version` (the one that was shown and agreed to, not "latest": a release that
32
+ * lands between the question and the answer is not what was said yes to).
33
+ */
34
+ export function updateCommand(manager, packageName, version) {
35
+ // A version comes from the network and the command may be run through a shell on Windows: three numbers only.
36
+ if (!isReleaseVersion(version) || !/^@?[a-z0-9][a-z0-9._-]*(\/[a-z0-9][a-z0-9._-]*)?$/.test(packageName)) {
37
+ throw new Error(`refusing to build an install command for ${packageName}@${version}`);
38
+ }
39
+ const target = `${packageName}@${version}`;
40
+ const args = {
41
+ npm: ['install', '-g', target],
42
+ pnpm: ['add', '-g', target],
43
+ yarn: ['global', 'add', target],
44
+ bun: ['add', '-g', target]
45
+ };
46
+ return { program: manager, args: args[manager], text: [manager, ...args[manager]].join(' ') };
47
+ }
48
+ /** The version in the installed package's own `package.json`, or undefined when it cannot be read. */
49
+ export async function installedVersionOf(environment, packageFolder) {
50
+ try {
51
+ const bytes = await environment.fs.readFile(join(packageFolder, 'package.json'));
52
+ const parsed = JSON.parse(new TextDecoder().decode(bytes));
53
+ return isRecord(parsed) && typeof parsed.version === 'string' ? parsed.version : undefined;
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ }
59
+ /**
60
+ * Runs the update command and checks the result by reading the installed copy again: a package manager that exits 0
61
+ * without changing the copy that is run (two Node versions, two prefixes) is the one failure that is easy to miss.
62
+ */
63
+ export async function installVersion(environment, command, expectedVersion, options) {
64
+ let exitCode;
65
+ try {
66
+ exitCode = await environment.runProgram(command.program, command.args, {
67
+ showOutput: options.showOutput
68
+ });
69
+ }
70
+ catch {
71
+ exitCode = null;
72
+ }
73
+ if (exitCode === null) {
74
+ return { ok: false, reason: 'not-started', exitCode: null, installedVersion: undefined };
75
+ }
76
+ const installedVersion = await installedVersionOf(environment, environment.packageFolder);
77
+ if (exitCode !== 0)
78
+ return { ok: false, reason: 'exit-code', exitCode, installedVersion };
79
+ if (installedVersion !== undefined && installedVersion !== expectedVersion) {
80
+ return { ok: false, reason: 'unchanged', exitCode, installedVersion };
81
+ }
82
+ return { ok: true, installedVersion };
83
+ }
@@ -0,0 +1,128 @@
1
+ import { configDirectory, npmRegistryUrl } from "../config.js";
2
+ import { UPDATE_CHECK_CONFIG } from "../constants.js";
3
+ import { isRecord } from "../http.js";
4
+ import { debug } from "../output.js";
5
+ import { readState, writeStateKey } from "../state.js";
6
+ import { cliIdentity, cliPackageName } from "../version.js";
7
+ import { fetchLatestVersion } from "./registry.js";
8
+ import { isReleaseVersion } from "./version.js";
9
+ /**
10
+ * What the CLI remembers about the newest version, and the one place that asks npm for it.
11
+ *
12
+ * Kept in `state.json` under `updateCheck` (see `src/state.ts`), so a run that already knows the answer asks nobody:
13
+ *
14
+ * "updateCheck": {
15
+ * "checkedAt": "2026-10-29T15:00:00.000Z", when npm was last asked
16
+ * "ok": true, whether that worked (a failed look is retried after an hour, not a day)
17
+ * "latest": "0.2.0", what npm said is the newest release
18
+ * "notifiedVersion": "0.2.0", the version the person was last asked about...
19
+ * "notifiedAt": "2026-10-29T15:00:02.000Z" ...and when: a "no" is remembered for a week
20
+ * }
21
+ */
22
+ const STATE_KEY = 'updateCheck';
23
+ const HOUR_MILLISECONDS = 60 * 60 * 1000;
24
+ const DAY_MILLISECONDS = 24 * HOUR_MILLISECONDS;
25
+ /** A record read from disk is only as good as its fields: anything else is the same as no record. */
26
+ export function parseUpdateRecord(value) {
27
+ if (!isRecord(value) || typeof value.checkedAt !== 'string' || Number.isNaN(Date.parse(value.checkedAt))) {
28
+ return undefined;
29
+ }
30
+ const record = { checkedAt: value.checkedAt, ok: value.ok === true };
31
+ // A version that is not three numbers is dropped, so a hand-edited file cannot put text in a command line.
32
+ if (typeof value.latest === 'string' && isReleaseVersion(value.latest))
33
+ record.latest = value.latest;
34
+ if (typeof value.notifiedVersion === 'string' && isReleaseVersion(value.notifiedVersion)) {
35
+ record.notifiedVersion = value.notifiedVersion;
36
+ if (typeof value.notifiedAt === 'string' && !Number.isNaN(Date.parse(value.notifiedAt))) {
37
+ record.notifiedAt = value.notifiedAt;
38
+ }
39
+ }
40
+ return record;
41
+ }
42
+ export async function readUpdateRecord(environment, configDir) {
43
+ return parseUpdateRecord((await readState(environment, configDir))[STATE_KEY]);
44
+ }
45
+ /** Saves the record; a folder that cannot be written is not an error, the next run just asks npm again. */
46
+ export async function saveUpdateRecord(environment, configDir, record) {
47
+ try {
48
+ await writeStateKey(environment, configDir, STATE_KEY, record);
49
+ return true;
50
+ }
51
+ catch (error) {
52
+ debug(environment, `could not save ${STATE_KEY} in the state file: ${error.message}`);
53
+ return false;
54
+ }
55
+ }
56
+ /** Whether npm should be asked again: never asked, or the last answer is older than its interval. */
57
+ export function isLookupDue(record, now) {
58
+ if (record === undefined)
59
+ return true;
60
+ const intervalHours = record.ok
61
+ ? UPDATE_CHECK_CONFIG.CHECK_INTERVAL_HOURS
62
+ : UPDATE_CHECK_CONFIG.RETRY_AFTER_FAILURE_HOURS;
63
+ const age = now.getTime() - Date.parse(record.checkedAt);
64
+ // A clock that was set back makes the age negative: ask again rather than stay quiet for ever.
65
+ return age < 0 || age >= intervalHours * HOUR_MILLISECONDS;
66
+ }
67
+ /** Whether the person already said no to this very version, less than `SNOOZE_DAYS` ago. */
68
+ export function isSnoozed(record, version, now) {
69
+ if (record?.notifiedVersion !== version || record.notifiedAt === undefined)
70
+ return false;
71
+ const age = now.getTime() - Date.parse(record.notifiedAt);
72
+ return age >= 0 && age < UPDATE_CHECK_CONFIG.SNOOZE_DAYS * DAY_MILLISECONDS;
73
+ }
74
+ /**
75
+ * The newest version of this package. Never rejects: whatever goes wrong comes back as `failure`, because the routine
76
+ * look runs beside a command and must not be able to change what the command does.
77
+ */
78
+ export async function lookUpLatestVersion(environment, options) {
79
+ try {
80
+ const configDir = configDirectory(environment);
81
+ const previous = await readUpdateRecord(environment, configDir);
82
+ const now = environment.now();
83
+ if (!options.force && !isLookupDue(previous, now)) {
84
+ debug(environment, `newest version from the saved look at ${previous?.checkedAt}: ${previous?.latest}`);
85
+ return { latest: previous?.latest, record: previous, asked: false, failure: undefined };
86
+ }
87
+ const registryUrl = npmRegistryUrl(environment);
88
+ debug(environment, `asking ${registryUrl} for the newest ${cliPackageName()}`);
89
+ const result = await fetchLatestVersion(environment, {
90
+ packageName: cliPackageName(),
91
+ registryUrl,
92
+ timeoutMilliseconds: options.timeoutMilliseconds,
93
+ userAgent: `${cliIdentity()} (update check)`
94
+ });
95
+ // The answer replaces what was known only when it is one; a failure keeps the last good version.
96
+ const record = {
97
+ checkedAt: now.toISOString(),
98
+ ok: result.ok,
99
+ ...(result.ok
100
+ ? { latest: result.version }
101
+ : previous?.latest !== undefined
102
+ ? { latest: previous.latest }
103
+ : {}),
104
+ ...(previous?.notifiedVersion !== undefined
105
+ ? {
106
+ notifiedVersion: previous.notifiedVersion,
107
+ ...(previous.notifiedAt !== undefined ? { notifiedAt: previous.notifiedAt } : {})
108
+ }
109
+ : {})
110
+ };
111
+ await saveUpdateRecord(environment, configDir, record);
112
+ if (!result.ok) {
113
+ debug(environment, `no newest version: ${result.detail}`);
114
+ return { latest: record.latest, record, asked: true, failure: result };
115
+ }
116
+ debug(environment, `npm says the newest is ${result.version}`);
117
+ return { latest: result.version, record, asked: true, failure: undefined };
118
+ }
119
+ catch (error) {
120
+ debug(environment, `looking for the newest version failed: ${error.message}`);
121
+ return {
122
+ latest: undefined,
123
+ record: undefined,
124
+ asked: false,
125
+ failure: { ok: false, reason: 'unexpected', detail: error.message }
126
+ };
127
+ }
128
+ }