@zerotal/core 1.14.0 → 1.14.2

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,33 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.14.2] — 2026-09-01
12
+
13
+ ### Fixed
14
+
15
+ - **`zt upgrade` now rewrites `zt.ts serve --dev`.** The `deprecated-aliases` codemod
16
+ anchored on `zt` followed by whitespace, so it matched `zt serve --dev` and missed
17
+ `bun zt.ts serve --dev` — the form in every scaffolded `package.json`, and the one an app
18
+ runs as `bun run dev`. The codemod reported nothing to do on the file that needed it most.
19
+
20
+ ## [1.14.1] — 2026-09-01
21
+
22
+ ### Fixed
23
+
24
+ - **A command that declares `needsApp = false` no longer boots the application.**
25
+ `CommandRunner.boot()` booted unconditionally, so `zt version`, `key:generate`, every
26
+ `make:*` scaffolder and the gate commands all paid for providers, a database connection
27
+ and a schedule registry they never touch — and could not run at all when booting was the
28
+ thing that was broken. A CLI you cannot reach when the app is down is missing exactly
29
+ when it is wanted.
30
+
31
+ Config is still bound, via the new `Application.bindConfig()`: `needsApp = false` is a
32
+ claim about the _application_, not about configuration, and every scaffolder reads config
33
+ for its output paths.
34
+
35
+ This also makes `zt version --json` pipeable, which previously needed the `--version`
36
+ form to avoid the boot log on stdout.
37
+
11
38
  ## [1.13.4] — 2026-09-01
12
39
 
13
40
  ### Fixed
package/api-surface.md CHANGED
@@ -23,6 +23,7 @@ class Application = {
23
23
  _userResolver: ((id: number) => Promise<AuthenticatedUser | null>) | undefined
24
24
  adoptAsCurrent: () => Application
25
25
  bind: (callback: (container: Container) => void) => Application
26
+ bindConfig: () => Promise<void>
26
27
  boot: () => Promise<void>
27
28
  bootAsWorker: () => Promise<void>
28
29
  bootDurationMs: number | undefined
@@ -179,7 +180,7 @@ class CommandRan = {
179
180
 
180
181
  class CommandRunner = {
181
182
  new (_app: Application): CommandRunner
182
- boot: () => Promise<void>
183
+ boot: (commandName?: string) => Promise<void>
183
184
  callInProcess: (argv: string[], parameters?: Record<string, string | boolean | number>) => Promise<{ code: number; output: string;}>
184
185
  command: (definition: CommandDefinition, aliases?: string[]) => CommandClass
185
186
  discover: (dir: string) => Promise<string[]>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/core",
3
- "version": "1.14.0",
3
+ "version": "1.14.2",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -1157,23 +1157,7 @@ export class Application {
1157
1157
  this.container._app = this;
1158
1158
 
1159
1159
  // Auto-discover config when useConfig() was not called explicitly.
1160
- // Scans <cwd>/config/*.ts and loads each file's default export.
1161
- // Gracefully skips missing or unreadable files.
1162
- if (this._configMap === undefined) {
1163
- this._configMap = await _discoverConfig(process.cwd());
1164
- }
1165
-
1166
- // Built-in core singletons (overridable by providers via last-write-wins)
1167
- this.container.singleton("config", () => new ConfigManager());
1168
- this.container.singleton("events", () => new Emitter(this.container));
1169
-
1170
- if (this._configMap !== undefined) {
1171
- const configManager = new ConfigManager();
1172
- for (const [key, value] of Object.entries(this._configMap)) {
1173
- configManager.load(key, value);
1174
- }
1175
- this.container.value("config", configManager);
1176
- }
1160
+ await this.bindConfig();
1177
1161
 
1178
1162
  // Auto-discover app/providers/* BEFORE the register phase so they run the full lifecycle.
1179
1163
  await this._discoverProviders(process.cwd());
@@ -1727,6 +1711,39 @@ export class Application {
1727
1711
  process.on("SIGUSR2", () => void this._reloadRoutes());
1728
1712
  }
1729
1713
 
1714
+ /**
1715
+ * Make `config` and `events` resolvable without booting the application.
1716
+ *
1717
+ * Split out of {@link boot} so a command that declares `needsApp = false` can
1718
+ * read configuration without paying for providers, a database connection and a
1719
+ * schedule registry it does not use. Every `make:*` scaffolder reads config for
1720
+ * its output paths, so "does not need the app" and "does not need config" are
1721
+ * different statements and only the first one is true of them.
1722
+ *
1723
+ * Idempotent: `boot()` calls it too, and calling it twice binds the same values.
1724
+ *
1725
+ * @category Configuration
1726
+ */
1727
+ async bindConfig(): Promise<void> {
1728
+ // Scans <cwd>/config/*.ts and loads each file's default export.
1729
+ // Gracefully skips missing or unreadable files.
1730
+ if (this._configMap === undefined) {
1731
+ this._configMap = await _discoverConfig(process.cwd());
1732
+ }
1733
+
1734
+ // Built-in core singletons (overridable by providers via last-write-wins)
1735
+ this.container.singleton("config", () => new ConfigManager());
1736
+ this.container.singleton("events", () => new Emitter(this.container));
1737
+
1738
+ if (this._configMap !== undefined) {
1739
+ const configManager = new ConfigManager();
1740
+ for (const [key, value] of Object.entries(this._configMap)) {
1741
+ configManager.load(key, value);
1742
+ }
1743
+ this.container.value("config", configManager);
1744
+ }
1745
+ }
1746
+
1730
1747
  /**
1731
1748
  * Boot the application in the `worker` environment and block for queue/worker use.
1732
1749
  *
@@ -330,9 +330,16 @@ export class CommandRunner {
330
330
  * is dynamically imported. Application.create() reads it, so _env is already
331
331
  * correct by the time boot() runs — no argv inspection needed here.
332
332
  */
333
- async boot(): Promise<void> {
333
+ async boot(commandName?: string): Promise<void> {
334
334
  this._app.container.value("commands", this);
335
- await this._app.boot();
335
+
336
+ // Config first, and separately from the application.
337
+ //
338
+ // `needsApp = false` is a claim about the *application* — providers, the
339
+ // database, the schedule registry — not about configuration. Every `make:*`
340
+ // scaffolder reads config for its output paths, so binding it here is what
341
+ // makes skipping the boot below safe rather than merely fast.
342
+ await this._app.bindConfig();
336
343
 
337
344
  const {
338
345
  ServeCommand,
@@ -409,6 +416,17 @@ export class CommandRunner {
409
416
  // The `routes:types` alias was retired in 1.13.0; `zt upgrade` rewrites it.
410
417
  this.register(RouteTypesCommand);
411
418
 
419
+ // Boot the application only when the command actually needs one.
420
+ //
421
+ // This used to be unconditional, so `zt version`, `zt key:generate`, every
422
+ // `make:*` scaffolder and the gate commands all paid for providers, a database
423
+ // connection and a schedule registry they never touch — and, worse, could not
424
+ // run at all when booting was the thing that was broken. A CLI you cannot reach
425
+ // when the app is down is missing exactly when it is wanted.
426
+ //
427
+ // Resolved against the built-ins registered above. A name that is not among
428
+ // them may still be an app command from `app/commands/`, which is discovered
429
+ // below and needs the boot — so an unknown name boots rather than assuming.
412
430
  // Non-web commands (console, worker, test).
413
431
  if (this._app._env !== "web") {
414
432
  this.register(ReplCommand);
@@ -439,7 +457,20 @@ export class CommandRunner {
439
457
  LintPackagesCommand,
440
458
  MakePackageCommand,
441
459
  ]);
460
+ }
461
+
462
+ // Everything above is a pure `register()` call, so it happens before the
463
+ // decision — which is what lets a scaffolder skip the boot too. Deciding
464
+ // earlier would leave `make:*` unregistered and unrecognised, and an
465
+ // unrecognised name boots.
466
+ const requested = commandName ? this._resolveStatic(commandName) : undefined;
467
+ const skipBoot = requested !== undefined && requested.needsApp === false;
468
+
469
+ if (skipBoot) return;
470
+
471
+ await this._app.boot();
442
472
 
473
+ if (this._app._env !== "web") {
443
474
  // One `deploy:<target>` per environment this app releases to. Console-only,
444
475
  // like the other release commands — a running web server has no business
445
476
  // migrating a database.
@@ -497,6 +528,23 @@ export class CommandRunner {
497
528
  // ── Private resolution ────────────────────────────────────────────────────
498
529
 
499
530
  /** Resolve a command name to its class — handles both eager and lazy entries. */
531
+ /**
532
+ * Look a command up without resolving a lazy thunk.
533
+ *
534
+ * Used to decide whether to boot the application, which has to happen *before*
535
+ * anything is awaited that might need one. A thunk is `undefined` here rather
536
+ * than imported, so a lazily-registered command boots — the safe answer, since
537
+ * the only cost of booting unnecessarily is the cost this change avoids, while
538
+ * the cost of not booting when needed is a broken command.
539
+ *
540
+ * @internal
541
+ */
542
+ private _resolveStatic(name: string): CommandClass | undefined {
543
+ const entry = this._registry.get(name);
544
+ if (typeof entry === "function" && "commandName" in entry) return entry as CommandClass;
545
+ return undefined;
546
+ }
547
+
500
548
  private async _resolve(name: string): Promise<CommandClass | undefined> {
501
549
  const entry = this._registry.get(name);
502
550
  if (!entry) return undefined;
@@ -88,7 +88,7 @@ export async function startZerotal(
88
88
  app.useConfig(config.all());
89
89
 
90
90
  const runner = new CommandRunner(app);
91
- await runner.boot();
91
+ await runner.boot(command);
92
92
  await runner.run(process.argv.slice(2));
93
93
  }
94
94
 
@@ -41,8 +41,13 @@ const BASE_MODEL_IMPORT = /(\bimport\s*\{[^}]*?)\bBaseModel\b([^}]*?\}\s*from\s*
41
41
  const COMMAND_ALIASES: { find: RegExp; replace: string; label: string }[] = [
42
42
  { find: /\broutes:types\b/g, replace: "route:types", label: "`routes:types` → `route:types`" },
43
43
  {
44
- find: /\b(zt|zerotal)\s+serve\s+--dev\b/g,
45
- replace: "$1 dev",
44
+ // `zt.ts` as well as `zt`, because the scaffolded entry point *is* `zt.ts` and
45
+ // every generated `package.json` reads `bun zt.ts serve --dev`. Anchoring on
46
+ // `zt` followed by whitespace missed the most common occurrence there is — the
47
+ // one an app runs as `bun run dev` — so `zt upgrade` reported nothing to do on
48
+ // the file that needed it most.
49
+ find: /\b(zt|zerotal)(\.ts)?\s+serve\s+--dev\b/g,
50
+ replace: "$1$2 dev",
46
51
  label: "`serve --dev` → `dev`",
47
52
  },
48
53
  ];