@wirecat/cli-base 0.63.0 → 0.64.0

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 CHANGED
@@ -8,6 +8,119 @@ Versions before 0.8.0 are in the [git tags](https://github.com/WireCatLabs/cli-b
8
8
  Every entry says what changed as a caller sees it, why, and what to watch for — the rules are
9
9
  [`docs/dev/CONVENTIONS.md`](docs/dev/CONVENTIONS.md#the-changelog).
10
10
 
11
+ ## 0.64.0 — 27.10.2026
12
+
13
+ ### Added
14
+
15
+ - **`cli-dev bundle --bin dist/bin/<cli>.js` builds the published bin as an esbuild bundle, the way tg-cli 0.100.0
16
+ ships.** It moves tg's `scripts/bundle.ts` into cli-base so every CLI builds the same way: code splitting that keeps
17
+ each lazy `import()` lazy; `import.meta.url` and run-time package lookups that stay at the original module's place
18
+ in the CLI, cli-core and cli-base; CommonJS named exports for a dynamic `import("yaml")`; kept names; native and
19
+ by-name packages left outside. The bin becomes a loader that answers `--version`, turns the compile cache on and,
20
+ under `serve`, `watch` and `mcp`, preloads every chunk so a running server survives an in-place upgrade. The build
21
+ fails when a bundled module would look in the wrong place. Add `esbuild` (an optional peer, `^0.28.0`) as a
22
+ devDependency and name the CLI's own outside packages with `--external`. The rule is
23
+ [WC-LAY-10](docs/standards/layout.md#wc-lay-10--ship-the-bin-as-a-bundle-that-cli-dev-bundle-builds).
24
+ - **WC-PERM-22 on the [Permissions](docs/standards/permissions.md) page: a CLI that is not a messenger declares its
25
+ own permission keys and defaults with cli-core's `commandPermissions`** (cli-core 0.450.0). It says how such a CLI
26
+ keys its commands, picks built-in defaults for what its writes do, and runs writes through the read-only lock and the
27
+ write guard. The example is a calendar: event writes ask, deleting a whole series and mailing attendees are denied
28
+ until the owner allows them. The page covered messenger CLIs only, and WC-PERM-6's "ask only before what cannot be
29
+ undone" read as if it applied to a calendar too; it now says it is the messengers' list. drive, zoom and memo do
30
+ not declare `commandPermissions` yet; the rule names them as known gaps.
31
+ - **`cli-dev audit` compares gcal (`@wirecat/gcal-cli`, the Google Calendar CLI) in the `all` parity group**, next to
32
+ tg, max, memo, drive, zm and wa, so its shared root commands are held to the same options and summaries. A run
33
+ without gcal's command tree skips it, as before; the weekly audit adds it once gcal is published on npm.
34
+ - **The command vocabulary has the words a calendar CLI needs.** Verbs `unsync` (stop syncing a thing; what is
35
+ stored stays), `review` (list what waits for the owner's decision) and `resolve` (settle one such thing with the
36
+ owner's choice); collections `calendars`, `events` and `people`. `events` was only a view word, so a command group
37
+ named `events` was reported by `unknown-resource`.
38
+ - **The vocabulary has the flags and the verb of calendar writes** (cli-core's `events create | update | delete |
39
+ respond`). Flags `--end-time`, `--start-date`, `--end-date`, `--location`, `--attendee` (repeat it for more),
40
+ `--id` (the id a create is made with) as `value`; `--calendar` and `--series` as `target`; `--no-notify` as `mode`.
41
+ `--notify` now means telling the people a change concerns — a chat's members or an event's attendees. Verb
42
+ `respond` (answer an invitation). Without them `option-role` and `unknown-verb` reported every calendar CLI's
43
+ write commands.
44
+ - **The vocabulary has the words of free/busy and repeating events** (cli-core's `calendars busy` and
45
+ `events create | update --repeat`). View `busy` (when calendars or people are taken, without what takes them);
46
+ flags `--repeat <rule>` (an RFC 5545 rule) as `value` and `--hours <from-to>` (the working hours to look in) as
47
+ `target`. gcal-cli overrides the four lint reports they cause until it takes this release.
48
+
49
+ - **`requestSchema(operation, definitions)` in `@wirecat/cli-base/codegen` builds the JSON Schema of a generated
50
+ operation's request body**, every referenced definition under `$defs`, or returns `null` when the operation takes
51
+ no body. drive-cli and gcal-cli each kept an identical copy for `api show`. Pass a manifest operation and the
52
+ `definitions` that `definitionsGenerator` wrote.
53
+
54
+ ### Changed — may break callers
55
+
56
+ - **A lint baseline entry or override for a word that is now in the vocabulary goes stale, and `cli-dev lint` fails
57
+ on it.** This release adds `unsync`, `review`, `resolve`, `calendars`, `events` and `people`. zoom-cli lists
58
+ `events` in `lint/baseline/unknown-resource.json`; gcal-cli lists `calendars`, `events`, `people`,
59
+ `calendars unsync`, `links review` and `links resolve` in its baseline; drive-cli overrides `unknown-verb` for
60
+ `comments resolve`, and cli-memo for `search people`, in `lint/overrides.json`. Remove those entries in the same
61
+ change that takes this version.
62
+ - **The calendar write words make gcal-cli's write baseline stale too.** Its `lint/baseline/option-role.json` (20
63
+ entries under `events create | update | delete | respond`) and `events respond` in `unknown-verb.json` go stale
64
+ with this release; remove them when gcal takes it. No other CLI's baseline or overrides on `main` name these words.
65
+ - **The free/busy and repeat words make four of gcal-cli's overrides stale.** `lint/overrides.json` names
66
+ `calendars busy` (`unknown-verb`), `calendars busy --hours` and `events create | update --repeat` (`option-role`);
67
+ remove them when gcal takes this release.
68
+
69
+ - **`describeAlone` is no longer exported from `@wirecat/cli-base/commands`.** It was made public by accident in
70
+ 0.62.0; it is a helper of the shared-command check and only works with a map kept for one check. No known caller
71
+ imports it. Describe a whole tree with `describeProgram` instead.
72
+
73
+ ### Fixed
74
+
75
+ - **Handbook corrections from gcal-cli's first-release audit.**
76
+ - The Toolchain, CI, Security, Testing and Store pages listed "the ten repositories" and "the six CLIs" without
77
+ gcal. They now say "the WireCat repositories": every WireCat CLI, a new one from its first commit, and the shared
78
+ repositories. `gcal-cli` is a known repository name for "Enforced by".
79
+ - WC-SECR-4 said the keyring service is the command's name. It is the CLI's app name (`max-cli`, `gcal-cli`,
80
+ `cli-memo`), as cli-core passes it.
81
+ - WC-REL-16 says what a CLI's package `files` holds: `dist`, `skills`, `README.md`, `LICENSE` and what the CLI
82
+ reads or runs after install (`scenarios`, `install`, notices); `CHANGELOG.md` may ship. Never `src/`, tests,
83
+ scripts, workflows, `bin/release`, `config/`, `lint/`, `contracts/` or `template.json`.
84
+ - WC-REL-11 names its one exception: a package's first version, published by the owner with `bin/release --local`,
85
+ since npm sets a trusted publisher only for a package that exists. Step 2 of
86
+ [New CLI](docs/standards/new-cli.md) says the same.
87
+ - [New CLI](docs/standards/new-cli.md) says which copied files a CLI rewrites (seeds) and which it leaves to
88
+ cli-template (template-owned), and that a template-owned page wrong for the CLI is fixed in cli-template.
89
+ - "Done when" told authors to add a new CLI's shared audit findings to `audit/baseline`, which has not accepted
90
+ them since cli-base 0.51.0. It now says to fix each one, or declare an exact difference in the parity group.
91
+ - WC-CI-8 says the fresh-agent audit is given the pinned cli-base workflows.
92
+ - WC-TEST-12 has one exception: commands generated from an API description share one reason that names an
93
+ options-against-the-snapshot test and a test that drives a generated command through `run()`.
94
+
95
+ - **`syncDiscovery` writes the same snapshot for the same revision of a Google API.** Google serves one revision with
96
+ its object keys in a different order on each download, so two syncs gave different files, hashes and generated
97
+ code. The snapshot is now the parsed document with its keys sorted and 2-space indentation; values and array order
98
+ are unchanged. `sha256` is stable; `sourceSha256` is still the hash of the bytes Google served, so it can change
99
+ between syncs. The first sync after upgrading rewrites each snapshot once, and `pnpm generate` then reorders the
100
+ generated types, schemas and manifest once.
101
+ - **`skill show` now names every agent `skill install` serves: Claude Code, Codex, Gemini CLI, OpenClaw, and Hermes
102
+ on request.** Its help named only the first three, so OpenClaw and Hermes users could not tell the skill was meant
103
+ for them.
104
+ - **The [CI](docs/standards/ci.md) page lists the monthly cron minutes WireCat repositories already use**, under
105
+ WC-CI-9, and [New CLI](docs/standards/new-cli.md) step 3 points to it. Finding a free minute meant reading
106
+ `monthly.yml` in every repository. A new CLI adds its minute to the list in the same pull request that joins the
107
+ parity groups.
108
+ - **The [Kit surface](docs/standards/kit-surface.md) page lists every cli-core and cli-base export cli-template
109
+ 0.7.0 imports**, among them the ones for MCP over HTTP: `mcpServerCommand`, `serveOverHttp`, `httpLockFile` and
110
+ the rest. The page left them out, so a change to them would not have been seen as one that reaches every new CLI.
111
+ - **[New CLI](docs/standards/new-cli.md) lets the fresh audit agent read the cli-base workflow files a CLI's
112
+ workflows pin.** WC-CI-8 tells an audit to read them, but the audit was limited to the handbook and the
113
+ repository. A release audit prompt kept in a CLI repository needs the same change.
114
+ - **WC-TEST-5 on the [Testing](docs/standards/testing.md) page says that a test may wait for a local process or
115
+ server it started**, polling with a timeout shorter than the test's own; a fixed sleep is still not allowed. It
116
+ was unclear whether such a wait counts as waiting for real time.
117
+ - **WC-CMD-6 on the [Commands](docs/standards/commands.md) page names `mcp --revoke` as an exception.** The rule says
118
+ an option never turns a command into a different action, and `--revoke` does: it forgets every browser login
119
+ instead of starting a server. The owner kept it an option on `mcp` because it belongs with the HTTP server's login
120
+ state. No other option gets this exception.
121
+ - **The handbook's examples start `gcal-cli` with the command `gcal`, not `cal`**, the system calendar program on macOS
122
+ and many Linux systems. A CLI named after it would shadow it.
123
+
11
124
  ## 0.63.0 — 27.10.2026
12
125
 
13
126
  ### Added
@@ -33,7 +33,7 @@ export interface DiscoveryProvenance {
33
33
  version: string;
34
34
  /** Of the committed file; `readDiscovery` refuses a snapshot that no longer matches it. */
35
35
  sha256: string;
36
- /** Of the bytes Google served. Equal to `sha256` unless the file was normalized after download. */
36
+ /** Of the bytes Google served. Google varies its key order, so this one can change while `sha256` does not. */
37
37
  sourceSha256: string;
38
38
  normalization: string;
39
39
  }
@@ -43,8 +43,8 @@ export interface SyncDiscoveryOptions {
43
43
  fetch?: typeof globalThis.fetch;
44
44
  }
45
45
  /**
46
- * Downloads each Discovery document byte for byte and records where it came from. Run by hand, then review
47
- * the diff: the build never fetches. `provenance.json` keeps the order of `sources`, and that order is
46
+ * Downloads each Discovery document, sorts its object keys so the same revision always gives the same file, and
47
+ * records where it came from. Run by hand, then review the diff: the build never fetches. `provenance.json` keeps the order of `sources`, and that order is
48
48
  * the order of the generated schemas.
49
49
  */
50
50
  export declare const syncDiscovery: (sources: Readonly<Record<string, string>>, options: SyncDiscoveryOptions) => Promise<Record<string, DiscoveryProvenance>>;
@@ -240,9 +240,18 @@ export const adaptDiscovery = (documents, options = {}) => {
240
240
  };
241
241
  };
242
242
  const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
243
+ /** Google serves the same revision with its keys in a different order each time; arrays keep theirs. */
244
+ const sortKeys = (value) => Array.isArray(value)
245
+ ? value.map(sortKeys)
246
+ : object(value)
247
+ ? Object.fromEntries(Object.keys(value)
248
+ .sort()
249
+ .map((key) => [key, sortKeys(value[key])]))
250
+ : value;
251
+ const NORMALIZATION = "object keys sorted, 2-space JSON; values and array order unchanged";
243
252
  /**
244
- * Downloads each Discovery document byte for byte and records where it came from. Run by hand, then review
245
- * the diff: the build never fetches. `provenance.json` keeps the order of `sources`, and that order is
253
+ * Downloads each Discovery document, sorts its object keys so the same revision always gives the same file, and
254
+ * records where it came from. Run by hand, then review the diff: the build never fetches. `provenance.json` keeps the order of `sources`, and that order is
246
255
  * the order of the generated schemas.
247
256
  */
248
257
  export const syncDiscovery = async (sources, options) => {
@@ -257,20 +266,20 @@ export const syncDiscovery = async (sources, options) => {
257
266
  const document = JSON.parse(raw);
258
267
  if (!object(document) || document.name !== service)
259
268
  throw new Error(`${source} is not the Discovery document of ${service}`);
260
- const hash = sha256(raw);
261
- downloaded[service] = raw;
269
+ const written = `${JSON.stringify(sortKeys(document), null, 2)}\n`;
270
+ downloaded[service] = written;
262
271
  provenance[service] = {
263
272
  source,
264
273
  revision: String(document.revision),
265
274
  version: String(document.version),
266
- sha256: hash,
267
- sourceSha256: hash,
268
- normalization: "none",
275
+ sha256: sha256(written),
276
+ sourceSha256: sha256(raw),
277
+ normalization: NORMALIZATION,
269
278
  };
270
279
  }
271
280
  mkdirSync(options.dir, { recursive: true });
272
- for (const [service, raw] of Object.entries(downloaded))
273
- writeFileSync(join(options.dir, `${service}.json`), raw);
281
+ for (const [service, text] of Object.entries(downloaded))
282
+ writeFileSync(join(options.dir, `${service}.json`), text);
274
283
  writeFileSync(join(options.dir, "provenance.json"), `${JSON.stringify(provenance, null, 2)}\n`);
275
284
  return provenance;
276
285
  };
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * Turns an official API description into committed code: types, Valibot schemas, an operation
3
- * manifest and a coverage page. Build-time only, and format-neutral: it parses no file format and
3
+ * manifest and a coverage page. Build-time, apart from the small helpers a CLI reads the generated
4
+ * files with (`identifier`, `requestSchema`), and format-neutral: it parses no file format and
4
5
  * adds no dependency. `adaptOpenApi` reads an OpenAPI document and `adaptDiscovery` Google Discovery
5
6
  * documents the consumer has parsed; an adapter from Postman or anything else lives in the consumer.
6
7
  */
7
8
  export { adaptDiscovery, type DiscoveryMetadata, type DiscoveryOptions, type DiscoveryProvenance, type DiscoveryResult, type DiscoveryService, readDiscovery, type SyncDiscoveryOptions, syncDiscovery, } from "./discovery.js";
9
+ export { requestSchema } from "./json-schema.js";
8
10
  export { type CoverageOptions, coverageGenerator, definitionsGenerator, type ManifestOperation, manifestGenerator, } from "./manifest.js";
9
11
  export type { ApiBody, ApiModel, ApiOperation, ApiParameter, ApiSchema, Binding, CodeGenerator, Confidence, Discriminator, Effect, GeneratedArtifact, HttpBinding, HttpMethod, Override, RpcBinding, SchemaNode, SourceInfo, SourceReference, } from "./model.js";
10
12
  export { identifier, kebab } from "./names.js";
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * Turns an official API description into committed code: types, Valibot schemas, an operation
3
- * manifest and a coverage page. Build-time only, and format-neutral: it parses no file format and
3
+ * manifest and a coverage page. Build-time, apart from the small helpers a CLI reads the generated
4
+ * files with (`identifier`, `requestSchema`), and format-neutral: it parses no file format and
4
5
  * adds no dependency. `adaptOpenApi` reads an OpenAPI document and `adaptDiscovery` Google Discovery
5
6
  * documents the consumer has parsed; an adapter from Postman or anything else lives in the consumer.
6
7
  */
7
8
  export { adaptDiscovery, readDiscovery, syncDiscovery, } from "./discovery.js";
9
+ export { requestSchema } from "./json-schema.js";
8
10
  export { coverageGenerator, definitionsGenerator, manifestGenerator, } from "./manifest.js";
9
11
  export { identifier, kebab } from "./names.js";
10
12
  export { adaptOpenApi, OpenApiError } from "./openapi.js";
@@ -0,0 +1,8 @@
1
+ import type { ManifestOperation } from "./manifest.js";
2
+ import type { SchemaNode } from "./model.js";
3
+ /**
4
+ * The JSON Schema (draft 2020-12) of an operation's request body, built from the `definitions` that
5
+ * `definitionsGenerator` wrote, with every referenced definition under `$defs`; `null` when the operation takes no
6
+ * body. For a CLI's `api show`, so an agent sees what to send before it calls.
7
+ */
8
+ export declare const requestSchema: (operation: Pick<ManifestOperation, "request">, definitions: Readonly<Record<string, SchemaNode>>) => Record<string, unknown> | null;
@@ -0,0 +1,78 @@
1
+ import { identifier } from "./names.js";
2
+ /**
3
+ * The JSON Schema (draft 2020-12) of an operation's request body, built from the `definitions` that
4
+ * `definitionsGenerator` wrote, with every referenced definition under `$defs`; `null` when the operation takes no
5
+ * body. For a CLI's `api show`, so an agent sees what to send before it calls.
6
+ */
7
+ export const requestSchema = (operation, definitions) => {
8
+ if (!operation.request?.schema)
9
+ return null;
10
+ const defs = {};
11
+ const visiting = new Set();
12
+ const node = (value) => {
13
+ const common = { ...(value.description ? { description: value.description } : {}) };
14
+ switch (value.type) {
15
+ case "ref": {
16
+ const name = identifier(value.ref);
17
+ add(name);
18
+ return { ...common, $ref: `#/$defs/${name}` };
19
+ }
20
+ case "unknown":
21
+ return common;
22
+ case "union":
23
+ return { ...common, anyOf: value.of.map(node) };
24
+ case "allOf":
25
+ return { ...common, allOf: value.of.map(node) };
26
+ case "object":
27
+ return {
28
+ ...common,
29
+ type: "object",
30
+ properties: Object.fromEntries(Object.entries(value.properties).map(([key, item]) => [key, node(item)])),
31
+ required: value.required,
32
+ additionalProperties: value.additionalProperties ? node(value.additionalProperties) : true,
33
+ };
34
+ case "array":
35
+ return {
36
+ ...common,
37
+ type: "array",
38
+ items: node(value.items),
39
+ ...(value.minItems === undefined ? {} : { minItems: value.minItems }),
40
+ ...(value.maxItems === undefined ? {} : { maxItems: value.maxItems }),
41
+ };
42
+ case "string":
43
+ return {
44
+ ...common,
45
+ type: "string",
46
+ ...(value.enum ? { enum: value.enum } : {}),
47
+ ...(value.pattern ? { pattern: value.pattern } : {}),
48
+ ...(value.minLength === undefined ? {} : { minLength: value.minLength }),
49
+ ...(value.maxLength === undefined ? {} : { maxLength: value.maxLength }),
50
+ };
51
+ case "boolean":
52
+ return { ...common, type: "boolean" };
53
+ case "integer":
54
+ case "number":
55
+ return {
56
+ ...common,
57
+ type: value.type,
58
+ ...(value.minimum === undefined ? {} : { minimum: value.minimum }),
59
+ ...(value.maximum === undefined ? {} : { maximum: value.maximum }),
60
+ };
61
+ }
62
+ };
63
+ const add = (name) => {
64
+ if (visiting.has(name))
65
+ return;
66
+ visiting.add(name);
67
+ const value = definitions[name];
68
+ if (!value)
69
+ throw new Error(`generated schema reference is missing: ${name}`);
70
+ defs[name] = node(value);
71
+ };
72
+ add(operation.request.schema);
73
+ return {
74
+ $schema: "https://json-schema.org/draft/2020-12/schema",
75
+ $ref: `#/$defs/${operation.request.schema}`,
76
+ $defs: defs,
77
+ };
78
+ };
@@ -12,7 +12,7 @@
12
12
  import type { Command } from "commander";
13
13
  export { type CommandsDocument, type CommandsDocumentAbout, commandsDocument, commandsDocumentJsonSchema, commandsDocumentSchema, parseCommandsDocument, } from "./document.js";
14
14
  export { COMMANDS_PAGE_LABELS, type CommandsPage, type CommandsPageLabels, type CommandsPageText, commandsPage, } from "./page.js";
15
- export * from "./registry.js";
15
+ export { type ArgumentInfo, annotate, annotateOption, type CommandInfo, type CommandLifetime, type CommandMeta, type CommandOutput, type CommandReach, type CommandState, describeOptions, describeProgram, flatten, metaOf, type OptionInfo, type OptionMeta, type Origin, optionMetaOf, stepTargets, } from "./registry.js";
16
16
  /**
17
17
  * Refuses a command path that arrived as one argument — `"projects add" --help`, what a zsh loop over
18
18
  * `$c` passes. Commander settles `--help` for an unknown word before any hook runs and prints the root
@@ -1,7 +1,7 @@
1
1
  import { CliError } from "../errors.js";
2
2
  export { commandsDocument, commandsDocumentJsonSchema, commandsDocumentSchema, parseCommandsDocument, } from "./document.js";
3
3
  export { COMMANDS_PAGE_LABELS, commandsPage, } from "./page.js";
4
- export * from "./registry.js";
4
+ export { annotate, annotateOption, describeOptions, describeProgram, flatten, metaOf, optionMetaOf, stepTargets, } from "./registry.js";
5
5
  /**
6
6
  * Refuses a command path that arrived as one argument — `"projects add" --help`, what a zsh loop over
7
7
  * `$c` passes. Commander settles `--help` for an unknown word before any hook runs and prints the root
@@ -9,7 +9,7 @@ export interface StoredCredential {
9
9
  export interface CredentialsOptions {
10
10
  /** Where the fallback file lives when the keyring cannot be used. */
11
11
  configDir: string;
12
- /** The keyring service name — the command's name, e.g. `max-cli`. */
12
+ /** The keyring service name — the CLI's app name, e.g. `max-cli`, not its command word. */
13
13
  service: string;
14
14
  /**
15
15
  * **True when the config directory came from the environment rather than the OS convention.**
@@ -0,0 +1,25 @@
1
+ export interface BundleOptions {
2
+ /** The CLI's root: `dist/` and `package.json` are under it. */
3
+ root: string;
4
+ /** The compiled bin, relative to `root`, e.g. `dist/bin/tg.js`. It becomes the loader. */
5
+ bin: string;
6
+ /** Packages that stay outside the bundle, besides `DEFAULT_EXTERNAL`. `*` matches anything. */
7
+ external?: readonly string[];
8
+ /** Packages whose modules keep their installed place, besides `DEFAULT_ANCHORED`. */
9
+ anchored?: readonly string[];
10
+ /** Third-party files read and found harmless, besides `DEFAULT_HARMLESS` (a path suffix each). */
11
+ harmless?: readonly string[];
12
+ }
13
+ export interface BundleResult {
14
+ problems: string[];
15
+ /** The chunks a server preloads, as the loader names them. */
16
+ chunks: string[];
17
+ }
18
+ /** Native modules, Bun's SQLite, and what cli-core loads only by name or starts as a worker from its own file. */
19
+ export declare const DEFAULT_EXTERNAL: string[];
20
+ export declare const DEFAULT_ANCHORED: string[];
21
+ export declare const DEFAULT_HARMLESS: string[];
22
+ export declare const LOADER_MARK = "// cli-dev bundle loader\n";
23
+ /** The bin that replaces the compiled one: `name` is the bundle's entry, `chunks` what a server preloads. */
24
+ export declare const loaderSource: (name: string, version: string, chunks: readonly string[]) => string;
25
+ export declare const bundle: (options: BundleOptions) => Promise<BundleResult>;
@@ -0,0 +1,271 @@
1
+ /**
2
+ * `cli-dev bundle`: the published bin starts from an esbuild bundle of `dist/`, so a command loads a few dozen files
3
+ * instead of about a thousand. Moved from tg-cli's `scripts/bundle.ts`, where it was proven first.
4
+ *
5
+ * - `dist/bundle/` holds the bundle. Its chunks keep every lazy `import()` lazy.
6
+ * - The bin becomes a loader: it answers `--version`, turns the compile cache on before any of the bundle compiles,
7
+ * and for a server preloads every chunk so an in-place upgrade cannot pull one from under it.
8
+ * - A module moved into a chunk would look for files and packages from the chunk. In the CLI's own modules and in the
9
+ * anchored packages (cli-core, cli-base), `import.meta.url` becomes the module's own place, and a package they load
10
+ * at run time is resolved from there too: pnpm installs cli-core's dependencies beside cli-core, not beside the CLI.
11
+ * - The build fails when a bundled module would still look in the wrong place.
12
+ */
13
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
14
+ import { isBuiltin } from "node:module";
15
+ import { basename, dirname, join, relative, sep } from "node:path";
16
+ import { pathToFileURL } from "node:url";
17
+ /** Native modules, Bun's SQLite, and what cli-core loads only by name or starts as a worker from its own file. */
18
+ export const DEFAULT_EXTERNAL = [
19
+ "bun:sqlite",
20
+ "*.node",
21
+ "better-sqlite3",
22
+ "@napi-rs/*",
23
+ "@resvg/*",
24
+ "@huggingface/*",
25
+ "sherpa-onnx*",
26
+ "onnxruntime-*",
27
+ "@wirecat/cli-core-onnx",
28
+ "@wirecat/cli-core-sqlite",
29
+ "ogg-opus-decoder",
30
+ ];
31
+ export const DEFAULT_ANCHORED = ["@wirecat/cli-core", "@wirecat/cli-base"];
32
+ // commander and @colors name these in comments and messages; liquidjs resolves partials with explicit `paths`, so the
33
+ // requiring file does not matter.
34
+ export const DEFAULT_HARMLESS = [
35
+ "/commander/lib/command.js",
36
+ "/@colors/colors/lib/colors.js",
37
+ "/liquidjs/dist/liquid.node.js",
38
+ ];
39
+ export const LOADER_MARK = "// cli-dev bundle loader\n";
40
+ const posix = (path) => path.split(sep).join("/");
41
+ const matcher = (patterns) => {
42
+ const expressions = patterns.map((pattern) => new RegExp(`^${pattern.replaceAll(".", "\\.").replaceAll("*", ".*")}$`));
43
+ return (specifier) => expressions.some((expression) => expression.test(specifier));
44
+ };
45
+ // Node has no "resolve from this parent" call; a resolve hook for the one call gives Node's own answer. Bun has no
46
+ // hooks but has `Bun.resolveSync(specifier, parent)`; when it finds nothing, the plain lookup keeps Bun's own error.
47
+ const FROM = "cli-dev-bundle:from";
48
+ const FROM_SOURCE = `import * as module from "node:module"
49
+ import { dirname } from "node:path"
50
+ import { fileURLToPath, pathToFileURL } from "node:url"
51
+ export const resolveFrom = (parent, specifier) => {
52
+ if (globalThis.Bun) {
53
+ if (specifier.includes(":")) return import.meta.resolve(specifier)
54
+ try {
55
+ return pathToFileURL(globalThis.Bun.resolveSync(specifier, dirname(fileURLToPath(parent)))).href
56
+ } catch {
57
+ return import.meta.resolve(specifier)
58
+ }
59
+ }
60
+ if (!module.registerHooks) return import.meta.resolve(specifier)
61
+ const hooks = module.registerHooks({
62
+ resolve: (asked, context, next) => next(asked, asked === specifier ? { ...context, parentURL: parent } : context),
63
+ })
64
+ try {
65
+ return import.meta.resolve(specifier)
66
+ } finally {
67
+ hooks.deregister()
68
+ }
69
+ }
70
+ export const importFrom = async (parent, specifier) => import(resolveFrom(parent, specifier))
71
+ `;
72
+ const BY_NAME = /(?<![\w$.])import\(\s*(?!["'`.])/g;
73
+ const LITERAL = /(?<![\w$.])import\(\s*(["'])([^"'.][^"']*)\1\s*\)/g;
74
+ const ownPlaces = (dist, out, anchored, harmless, isExternal, problems) => {
75
+ const placeOf = (file) => {
76
+ if (file.startsWith(dist + sep))
77
+ return `new URL(${JSON.stringify(posix(relative(out, file)))}, import.meta.url).href`;
78
+ for (const name of anchored) {
79
+ const marker = `${sep}node_modules${sep}${name.replace("/", sep)}${sep}`;
80
+ const at = file.lastIndexOf(marker);
81
+ if (at < 0)
82
+ continue;
83
+ const packageRoot = file.slice(0, at + marker.length);
84
+ const manifest = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
85
+ const entry = manifest.exports?.["."]?.default;
86
+ if (typeof entry !== "string")
87
+ throw new Error(`${name}: package.json has no exports["."].default to anchor on`);
88
+ const path = posix(relative(dirname(join(packageRoot, entry)), file));
89
+ return `new URL(${JSON.stringify(path)}, import.meta.resolve(${JSON.stringify(name)})).href`;
90
+ }
91
+ return undefined;
92
+ };
93
+ return {
94
+ name: "own-places",
95
+ setup(context) {
96
+ context.onResolve({ filter: new RegExp(`^${FROM}$`) }, () => ({ path: FROM, namespace: FROM }));
97
+ context.onLoad({ filter: /.*/, namespace: FROM }, () => ({ contents: FROM_SOURCE, loader: "js" }));
98
+ context.onLoad({ filter: /\.[cm]?js$/ }, (args) => {
99
+ const source = readFileSync(args.path, "utf8");
100
+ const place = placeOf(args.path);
101
+ if (!place) {
102
+ const known = harmless.some((path) => posix(args.path).endsWith(path));
103
+ if (!known && /import\.meta|__dirname|__filename|require\.resolve/.test(source))
104
+ problems.push(args.path);
105
+ return undefined;
106
+ }
107
+ const loadsOutside = [...source.matchAll(LITERAL)].some(([, , specifier]) => specifier && isExternal(specifier));
108
+ if (!source.includes("import.meta") && source.search(BY_NAME) < 0 && !loadsOutside)
109
+ return undefined;
110
+ const other = source.match(/import\.meta\.(?!url\b|resolve\b)\w+/);
111
+ if (other)
112
+ problems.push(`${args.path}: ${other[0]}`);
113
+ // `place` holds `import.meta.url` or `import.meta.resolve`, so it goes in last.
114
+ const contents = source
115
+ .replaceAll(/import\.meta\.resolve\(/g, "resolveFrom(PLACE, ")
116
+ .replaceAll(BY_NAME, "importFrom(PLACE, ")
117
+ .replaceAll(LITERAL, (call, quote, specifier) => isExternal(specifier) ? `importFrom(PLACE, ${quote}${specifier}${quote})` : call)
118
+ .replaceAll(/import\.meta\.url\b/g, "PLACE")
119
+ .replaceAll(/\bPLACE\b/g, place);
120
+ return { contents: `import { importFrom, resolveFrom } from "${FROM}";\n${contents}`, loader: "js" };
121
+ });
122
+ },
123
+ };
124
+ };
125
+ const isCommonJs = (file) => {
126
+ if (file.endsWith(".cjs"))
127
+ return true;
128
+ if (!file.endsWith(".js"))
129
+ return false;
130
+ for (let directory = dirname(file); directory !== dirname(directory); directory = dirname(directory)) {
131
+ const manifest = join(directory, "package.json");
132
+ if (existsSync(manifest))
133
+ return JSON.parse(readFileSync(manifest, "utf8")).type !== "module";
134
+ }
135
+ return true;
136
+ };
137
+ /**
138
+ * `await import("yaml")` gets CommonJS's named exports from Node, but only `default` from a bundled chunk. A dynamic
139
+ * import of CommonJS goes through a module that exports the names Node itself finds in it.
140
+ */
141
+ const commonJsNames = {
142
+ name: "common-js-names",
143
+ setup(context) {
144
+ context.onResolve({ filter: /.*/ }, async (args) => {
145
+ if (args.kind !== "dynamic-import" || args.pluginData === "inner")
146
+ return undefined;
147
+ const resolved = await context.resolve(args.path, {
148
+ kind: "dynamic-import",
149
+ importer: args.importer,
150
+ resolveDir: args.resolveDir,
151
+ pluginData: "inner",
152
+ });
153
+ if (resolved.errors.length > 0 || resolved.external || !isCommonJs(resolved.path))
154
+ return undefined;
155
+ return { path: resolved.path, namespace: "common-js-names" };
156
+ });
157
+ context.onLoad({ filter: /.*/, namespace: "common-js-names" }, async (args) => {
158
+ const names = Object.keys(await import(pathToFileURL(args.path).href)).filter((name) => name !== "default");
159
+ const values = names.map((name, index) => `const n${index} = cjs[${JSON.stringify(name)}]`);
160
+ const exported = names.map((name, index) => `n${index} as ${JSON.stringify(name)}`);
161
+ return {
162
+ contents: `const cjs = require(${JSON.stringify(args.path)})\n${values.join("\n")}\nexport default cjs\nexport { ${exported.join(", ")} }\n`,
163
+ resolveDir: dirname(args.path),
164
+ loader: "js",
165
+ };
166
+ });
167
+ },
168
+ };
169
+ /** The bin that replaces the compiled one: `name` is the bundle's entry, `chunks` what a server preloads. */
170
+ export const loaderSource = (name, version, chunks) => `#!/usr/bin/env node
171
+ ${LOADER_MARK}import * as module from "node:module"
172
+
173
+ const words = process.argv.slice(2)
174
+ // As the CLI answers it, unless a test records the argv: then the CLI writes the record too.
175
+ if (
176
+ words.length === 1 &&
177
+ (words[0] === "--version" || words[0] === "-V") &&
178
+ !Object.keys(process.env).some((key) => key.endsWith("_TEST_ARGV_LOG"))
179
+ ) {
180
+ process.stdout.write(${JSON.stringify(`${version}\n`)})
181
+ } else {
182
+ // Before the bundle compiles: the bundle's own call comes after its chunks.
183
+ module.enableCompileCache()
184
+ const serves = words
185
+ .slice(0, 3)
186
+ .some((word, at) => ["serve", "watch"].includes(word) || (word === "mcp" && !/^[^-]/.test(words[at + 1] ?? "")))
187
+ if (serves && module.registerHooks) {
188
+ const bundle = new URL("../bundle/", import.meta.url).href
189
+ module.registerHooks({
190
+ resolve: (specifier, context, next) =>
191
+ specifier.startsWith("./") && context.parentURL?.startsWith(bundle)
192
+ ? { url: new URL(specifier, context.parentURL).href, format: "module", shortCircuit: true }
193
+ : next(specifier, context),
194
+ })
195
+ }
196
+ const running = import(${JSON.stringify(`../bundle/${name}.js`)})
197
+ const chunks = ${JSON.stringify(chunks)}
198
+ // After the server answers its first request, so it starts no later.
199
+ if (serves) setTimeout(() => void Promise.allSettled(chunks.map((chunk) => import(chunk))), 2000).unref()
200
+ await running
201
+ }
202
+ `;
203
+ export const bundle = async (options) => {
204
+ const { build } = await import("esbuild");
205
+ const root = options.root;
206
+ const dist = join(root, "dist");
207
+ const out = join(dist, "bundle");
208
+ const bin = join(root, options.bin);
209
+ const name = basename(bin).replace(/\.[cm]?js$/, "");
210
+ const external = [...DEFAULT_EXTERNAL, ...(options.external ?? [])];
211
+ const isExternal = matcher(external);
212
+ const problems = [];
213
+ if (!bin.startsWith(dist + sep))
214
+ return { problems: [`${options.bin} is not under dist/`], chunks: [] };
215
+ if (readFileSync(bin, "utf8").includes(LOADER_MARK))
216
+ return { problems: [`${options.bin} is already the loader — compile it again (tsc) first`], chunks: [] };
217
+ const { version } = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
218
+ rmSync(out, { recursive: true, force: true });
219
+ const { metafile } = await build({
220
+ absWorkingDir: root,
221
+ entryPoints: { [name]: bin },
222
+ outdir: out,
223
+ bundle: true,
224
+ splitting: true,
225
+ format: "esm",
226
+ platform: "node",
227
+ target: "node22",
228
+ chunkNames: "[name]-[hash]",
229
+ // Two modules with one name would otherwise become `name2`, and `name` reaches errors and `commands --json`.
230
+ keepNames: true,
231
+ external,
232
+ // Bundled CommonJS calls `require` for Node's own modules.
233
+ banner: {
234
+ js: 'import { createRequire as __bundleRequire } from "node:module";\nconst require = __bundleRequire(import.meta.url);',
235
+ },
236
+ plugins: [
237
+ commonJsNames,
238
+ ownPlaces(dist, out, [...DEFAULT_ANCHORED, ...(options.anchored ?? [])], [...DEFAULT_HARMLESS, ...(options.harmless ?? [])], isExternal, problems),
239
+ ],
240
+ metafile: true,
241
+ logLevel: "silent",
242
+ });
243
+ // Only the CLI's own modules may import a package that stays outside: it resolves from the bundle, the CLI's place.
244
+ const own = `${posix(relative(root, dist))}/`;
245
+ for (const [input, { imports }] of Object.entries(metafile.inputs)) {
246
+ if (input.startsWith(own))
247
+ continue;
248
+ for (const { path, external: outside } of imports) {
249
+ if (outside && !isBuiltin(path) && !["bun:sqlite", "<runtime>"].includes(path))
250
+ problems.push(`${input} imports ${path}, which would resolve from the bundle`);
251
+ }
252
+ }
253
+ if (problems.length > 0)
254
+ return { problems, chunks: [] };
255
+ // An upgrade replaces the chunks under a running server, and their names carry a hash: a server that had not yet
256
+ // loaded one would fail to find it. A server loads them all soon after it starts, and finds a chunk it holds without
257
+ // looking on disk, where Node's resolver would look again for each new importer.
258
+ const lazy = new Set();
259
+ for (const { imports } of Object.values(metafile.outputs)) {
260
+ for (const { path, kind, external: outside } of imports) {
261
+ if (kind === "dynamic-import" && !outside)
262
+ lazy.add(`../bundle/${basename(path)}`);
263
+ }
264
+ }
265
+ const chunks = [...lazy].sort();
266
+ const missing = chunks.filter((chunk) => !existsSync(join(out, basename(chunk))));
267
+ if (missing.length > 0)
268
+ return { problems: missing.map((chunk) => `the loader names ${chunk}, not built`), chunks };
269
+ writeFileSync(bin, loaderSource(name, version, chunks));
270
+ return { problems: [], chunks };
271
+ };