@zerotal/core 1.11.0 → 1.11.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.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,27 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.11.1] — 2026-08-31
12
+
13
+ ### Added
14
+
15
+ - **`zt version`** — which Zerotal, which Bun, which app. The first question anyone
16
+ asks when filing a bug, and the CLI had no answer: `zt version` was an unknown
17
+ command, so the version had to be dug out of `package.json` or `node_modules` —
18
+ both of which report what is _installed_ rather than what is _running_, and those
19
+ differ for any process that has been up since before an upgrade.
20
+
21
+ `--version` and `-v` are answered earlier still, before the runtime check, the
22
+ config load and the app import, because those are the things someone is asking the
23
+ version _about_. A version flag that only works when the app already boots answers
24
+ a question nobody has. Add `--json` for a script.
25
+
26
+ Two notes on the output. It is uncoloured, unlike every other `Command` helper,
27
+ because it gets pasted into bug reports and piped into parsers more than it is read
28
+ on a terminal. And prefer `zt --version --json` over `zt version --json` in a
29
+ script: the application's boot log is written to stdout, so the second form emits a
30
+ log line ahead of the JSON while the first never boots at all.
31
+
11
32
  ## [1.11.0] — 2026-08-31
12
33
 
13
34
  ### Fixed
package/api-surface.md CHANGED
@@ -2151,6 +2151,34 @@ class UpgradeCommand = {
2151
2151
  write: (msg: string) => void
2152
2152
  }
2153
2153
 
2154
+ class VersionCommand = {
2155
+ new (): VersionCommand
2156
+ static args: never[]
2157
+ static commandName: string
2158
+ static description: string
2159
+ static flags: { name: string; type: 'boolean'; description: string; default: boolean;}[]
2160
+ static needsApp: boolean
2161
+ _readLine: () => Promise<string>
2162
+ _writer: OutputWriter
2163
+ app: unknown
2164
+ args: Record<string, string>
2165
+ ask: (question: string, defaultValue?: string) => Promise<string>
2166
+ choice: (question: string, options: string[]) => Promise<string>
2167
+ confirm: (question: string, defaultValue?: boolean) => Promise<boolean>
2168
+ dim: (msg: string) => void
2169
+ error: (msg: string) => void
2170
+ flags: Record<string, string | number | boolean>
2171
+ info: (msg: string) => void
2172
+ line: (msg: string) => void
2173
+ newLine: () => void
2174
+ run: () => Promise<void>
2175
+ secret: (question: string) => Promise<string>
2176
+ section: (title: string) => void
2177
+ table: (rows: [string, string][], indent?: number) => void
2178
+ warn: (msg: string) => void
2179
+ write: (msg: string) => void
2180
+ }
2181
+
2154
2182
  class WorkerCommand = {
2155
2183
  new (): WorkerCommand
2156
2184
  static aliases: string[]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/core",
3
- "version": "1.11.0",
3
+ "version": "1.11.1",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -343,6 +343,7 @@ export class CommandRunner {
343
343
  KeyGenerateCommand,
344
344
  ReloadCommand,
345
345
  StatusCommand,
346
+ VersionCommand,
346
347
  MakeControllerCommand,
347
348
  MakeMiddlewareCommand,
348
349
  MakeCommandCommand,
@@ -381,6 +382,10 @@ export class CommandRunner {
381
382
  this.register(ReloadCommand);
382
383
  this.register(StatusCommand);
383
384
 
385
+ // Always registered, and deliberately not gated on the boot mode: the answer to
386
+ // "which version is this" must not depend on which environment is asking.
387
+ this.register(VersionCommand);
388
+
384
389
  // route:list and doctor are inspection commands — always available regardless of mode.
385
390
  this.register(RouteListCommand);
386
391
  this.register(DoctorCommand);
@@ -0,0 +1,53 @@
1
+ import { Command } from "../Command.ts";
2
+ import { _formatVersion, _versionInfo } from "../versionInfo.ts";
3
+
4
+ /**
5
+ * `bun zt version` — which Zerotal, which Bun, which app.
6
+ *
7
+ * The first question anyone asks when filing a bug or reading a field report,
8
+ * and until now the CLI had no answer: `zt version` was an unknown command, so
9
+ * the version had to be dug out of `package.json` or `node_modules`, which
10
+ * reports what is *installed* rather than what is *running* — not the same thing
11
+ * for a process that has been up since before an upgrade.
12
+ *
13
+ * Registered in every environment and needs no application, so it still answers
14
+ * when the app does not boot. `--version` and `-v` are intercepted even earlier,
15
+ * in `startZerotal`, before config is loaded — see there for why.
16
+ *
17
+ * @example
18
+ * ```bash
19
+ * bun zt version
20
+ * bun zt version --json # for a script
21
+ * ```
22
+ *
23
+ * @category Diagnostics
24
+ */
25
+ export class VersionCommand extends Command {
26
+ static commandName = "version";
27
+ static description = "Show the Zerotal, Bun and app versions";
28
+ static needsApp = false;
29
+ static args = [];
30
+ static flags = [
31
+ {
32
+ name: "json",
33
+ type: "boolean" as const,
34
+ description: "Print as JSON",
35
+ default: false,
36
+ },
37
+ ];
38
+
39
+ async run(): Promise<void> {
40
+ const info = _versionInfo();
41
+
42
+ // `write`, not `line`: every other output helper on Command wraps its argument
43
+ // in an ANSI colour, and `zt version --json | jq` chokes on an escape code.
44
+ // The plain report goes the same way, for the same reason it is formatted
45
+ // plainly — it gets pasted into bug reports.
46
+ if (this.flags["json"] === true) {
47
+ this.write(`${JSON.stringify(info, null, 2)}\n`);
48
+ return;
49
+ }
50
+
51
+ this.write(`${_formatVersion(info)}\n`);
52
+ }
53
+ }
@@ -33,6 +33,7 @@ export { CompileCommand } from "./CompileCommand.ts";
33
33
  export { KeyGenerateCommand } from "./KeyGenerateCommand.ts";
34
34
  export { ReloadCommand } from "./ReloadCommand.ts";
35
35
  export { StatusCommand } from "./StatusCommand.ts";
36
+ export { VersionCommand } from "./VersionCommand.ts";
36
37
  export { MakeControllerCommand } from "./MakeControllerCommand.ts";
37
38
  export { MakeMiddlewareCommand } from "./MakeMiddlewareCommand.ts";
38
39
  export { MakeCommandCommand } from "./MakeCommandCommand.ts";
@@ -17,6 +17,7 @@ import {
17
17
  import type { RuntimeMismatch } from "../support/runtime.ts";
18
18
  import { RuntimeMismatchError } from "../errors/RuntimeMismatchError.ts";
19
19
  import type { Application } from "../application/Application.ts";
20
+ import { _formatVersion, _versionInfo } from "./versionInfo.ts";
20
21
 
21
22
  /** Options for {@link startZerotal}. */
22
23
  export interface StartZerotalOptions {
@@ -44,6 +45,29 @@ export async function startZerotal(
44
45
  loadApp: () => Promise<{ default: Application }>,
45
46
  options: StartZerotalOptions = {},
46
47
  ): Promise<void> {
48
+ // Answer `--version` before anything else can fail to.
49
+ //
50
+ // Ahead of the runtime assertion, the config load and the app import, because
51
+ // those are exactly the things someone is asking the version *about*: a config
52
+ // that no longer validates and an app that will not boot are the two moments
53
+ // when "which version am I on" stops being idle curiosity. A version flag that
54
+ // only works when everything else already works answers a question nobody has.
55
+ //
56
+ // It reports a second runtime as a line of output rather than throwing about
57
+ // one, which is strictly more informative than the refusal it skips.
58
+ // `--json` is honoured here and not only on the command, because this is the
59
+ // path that produces clean output: the app's boot log goes to stdout, so
60
+ // `zt version --json` — which dispatches normally, and therefore boots — cannot
61
+ // be piped into a parser. This form never boots, so it can.
62
+ const first = process.argv[2] ?? "";
63
+ if (first === "--version" || first === "-v") {
64
+ const info = _versionInfo();
65
+ console.log(
66
+ process.argv.includes("--json") ? JSON.stringify(info, null, 2) : _formatVersion(info),
67
+ );
68
+ return;
69
+ }
70
+
47
71
  // Before anything else, because everything after this is an assertion about a
48
72
  // runtime — and if there are two of them, which one made the assertion is the
49
73
  // first thing worth knowing.
@@ -0,0 +1,89 @@
1
+ /**
2
+ * What `bun zt version` reports, and the formatting of it.
3
+ *
4
+ * Split out of the command because the same answer has to be available from two
5
+ * places: the registered {@link VersionCommand}, and the early intercept in
6
+ * `startZerotal` that answers `--version` before config is loaded or the app is
7
+ * imported. Those two must not drift — a version command that reports one thing
8
+ * when the app boots and another when it does not is worse than either.
9
+ *
10
+ * @module
11
+ */
12
+ import { readFileSync } from "node:fs";
13
+ import { ZEROTAL_VERSION } from "../support/version.ts";
14
+ import { runtimeMismatch } from "../support/runtime.ts";
15
+
16
+ /** Everything `zt version` knows. Fields are `null` when there is nothing to read. */
17
+ export interface _VersionInfo {
18
+ /** Version of the running framework, e.g. `1.11.0`. */
19
+ zerotal: string;
20
+ /** Version of the Bun executing this process. */
21
+ bun: string;
22
+ /** The app's own name and version from its `package.json`, when it has one. */
23
+ app: { name: string; version: string } | null;
24
+ /**
25
+ * A second Bun installed under `node_modules`, when one is there.
26
+ *
27
+ * Usually arrives as a transitive peer nothing executes, so it is reported
28
+ * rather than complained about — but it is the recurring cause of "which Bun
29
+ * actually ran this", and `version` is where someone goes to ask.
30
+ */
31
+ otherBun: string | null;
32
+ }
33
+
34
+ /**
35
+ * Collect the versions worth reporting.
36
+ *
37
+ * @param cwd - Project root to read the app manifest and `node_modules` from.
38
+ * @internal
39
+ */
40
+ export function _versionInfo(cwd: string = process.cwd()): _VersionInfo {
41
+ return {
42
+ zerotal: ZEROTAL_VERSION,
43
+ bun: Bun.version,
44
+ app: _appManifest(cwd),
45
+ otherBun: runtimeMismatch(cwd)?.installed ?? null,
46
+ };
47
+ }
48
+
49
+ /** The app's own name and version, or `null` when the manifest is missing or unreadable. */
50
+ function _appManifest(cwd: string): { name: string; version: string } | null {
51
+ try {
52
+ const raw = readFileSync(`${cwd}/package.json`, "utf8");
53
+ const { name, version } = JSON.parse(raw) as { name?: string; version?: string };
54
+ if (name === undefined && version === undefined) return null;
55
+ return { name: name ?? "(unnamed)", version: version ?? "(no version)" };
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Render {@link _VersionInfo} as the lines `zt version` prints.
63
+ *
64
+ * Plain text with no colour, because this output gets piped into scripts and
65
+ * pasted into bug reports more often than it gets read on a terminal.
66
+ *
67
+ * @internal
68
+ */
69
+ export function _formatVersion(info: _VersionInfo): string {
70
+ const rows: [string, string][] = [
71
+ ["Zerotal", info.zerotal],
72
+ ["Bun", info.bun],
73
+ ];
74
+ if (info.app) rows.push(["App", `${info.app.name} ${info.app.version}`]);
75
+
76
+ const width = Math.max(...rows.map(([label]) => label.length));
77
+ const lines = rows.map(([label, value]) => `${label.padEnd(width)} ${value}`);
78
+
79
+ if (info.otherBun !== null && info.otherBun !== info.bun) {
80
+ lines.push(
81
+ "",
82
+ `node_modules also contains bun ${info.otherBun}, which is not the one running.`,
83
+ `Nothing executes it — it arrives as a peer dependency — but that is the copy`,
84
+ `an install would use if anything ever did.`,
85
+ );
86
+ }
87
+
88
+ return lines.join("\n");
89
+ }