@zerotal/core 1.10.0 → 1.11.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,37 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.11.0] — 2026-08-31
12
+
13
+ ### Fixed
14
+
15
+ - **A command can fail without throwing.** `CommandRunner` ran an unconditional
16
+ `process.exit(0)` the moment `run()` returned and never read `process.exitCode` —
17
+ which is the idiomatic way to fail a Bun/Node CLI without an exception, and what
18
+ most people reach for. An app's `release:check` printed six blockers, set the code,
19
+ and exited **0**; their deploy script read success and would have restarted a broken
20
+ production deployment. `zt deploy` gates on the same value through
21
+ `callInProcess`, so its own preflight had the hole too. A gate that cannot fail is
22
+ not a gate, and this one failed _open_.
23
+
24
+ `callInProcess` saves and restores the code around each command, because it runs
25
+ them in-process and the value is global. Note for anyone doing the same:
26
+ `process.exitCode = undefined` is a **no-op in Bun** — the previous value survives —
27
+ so clearing it takes `0`.
28
+
29
+ - **A Bun the project never asked for is a warning, not a refusal.**
30
+ `bun-plugin-tailwind` declares `bun` as a _required_ peer, so `bun install` fetches
31
+ the Bun npm package as a second, newer runtime — and the runtime guard refused to
32
+ boot. An app took two production outages on this: the first crash-looping behind a
33
+ 502, the second because the obvious fix (removing `node_modules/bun` after install)
34
+ cannot work — that package's postinstall runs _during_ install, so deleting it
35
+ leaves the tree incomplete.
36
+
37
+ Nothing about that is two runtimes in play; nobody executes the stray copy. The
38
+ guard now asks whether the project _declared_ `bun` in its own dependencies. If it
39
+ did, the refusal stands. If it did not, it warns and names the likely culprit, the
40
+ fix that works (`bun install --omit=peer`) and the one that does not.
41
+
11
42
  ## [1.10.0] — 2026-08-30
12
43
 
13
44
  ### Changed
package/api-surface.md CHANGED
@@ -3207,6 +3207,8 @@ function bunBinary = () => string
3207
3207
 
3208
3208
  function declaredBunFloor = (cwd: string) => { range: string; manifest: string;} | null
3209
3209
 
3210
+ function declaresBunDependency = (cwd: string) => boolean
3211
+
3210
3212
  function installedBunVersion = (cwd: string) => { version: string; manifest: string;} | null
3211
3213
 
3212
3214
  function runtimeBelowFloor = (cwd?: string) => RuntimeFloor | null
@@ -3226,6 +3228,7 @@ interface RuntimeFloor = {
3226
3228
  }
3227
3229
 
3228
3230
  interface RuntimeMismatch = {
3231
+ chosen?: boolean | undefined
3229
3232
  installed: string
3230
3233
  manifest: string
3231
3234
  running: string
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/core",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -533,8 +533,21 @@ export class CommandRunner {
533
533
  const startedAt = performance.now();
534
534
  try {
535
535
  await instance.run();
536
- FrameworkEvents.emit(new CommandRan(name, performance.now() - startedAt, 0, true));
537
- process.exit(0);
536
+
537
+ // Honour `process.exitCode` if the command set one.
538
+ //
539
+ // This used to be an unconditional `process.exit(0)`, so a command could
540
+ // only fail by throwing — and `process.exitCode = 1` is the idiomatic way
541
+ // to fail a Bun/Node CLI without an exception, which is what most people
542
+ // reach for. It was silently discarded. An app wrote a `release:check`
543
+ // gate that printed six blockers, set the code, and exited 0; the deploy
544
+ // script's `if ! …` read success and would have restarted a broken
545
+ // production deployment. A gate that cannot fail is not a gate, and that
546
+ // one failed *open*, which is the worst direction available.
547
+ const code = process.exitCode ?? 0;
548
+ const ok = Number(code) === 0;
549
+ FrameworkEvents.emit(new CommandRan(name, performance.now() - startedAt, Number(code), ok));
550
+ process.exit(Number(code));
538
551
  } catch (error) {
539
552
  FrameworkEvents.emit(
540
553
  new CommandRan(name, performance.now() - startedAt, 1, false, (error as Error).message),
@@ -594,16 +607,32 @@ export class CommandRunner {
594
607
 
595
608
  const name = (Cmd as { commandName?: string }).commandName ?? commandName;
596
609
  const startedAt = performance.now();
610
+ // `process.exitCode` is process-global, and this runs the command *in this
611
+ // process* — so it has to be cleared before and restored after, or a command
612
+ // that sets it poisons the runner and everything called after it. Reading it
613
+ // at all is the point: `zt deploy` gates on these return codes, and a
614
+ // `release:check` that printed its blockers and set the code used to come back
615
+ // as success. The gate failed open, which is the one direction a gate must not.
616
+ //
617
+ // `0`, not `undefined`, for both the clear and the restore: in Bun,
618
+ // `process.exitCode = undefined` is a no-op that leaves the previous value in
619
+ // place, so clearing with it does nothing and one blocked command marks every
620
+ // command after it as failed.
621
+ const outerExitCode = process.exitCode ?? 0;
622
+ process.exitCode = 0;
597
623
  try {
598
624
  await instance.run();
599
- FrameworkEvents.emit(new CommandRan(name, performance.now() - startedAt, 0, true));
600
- return { code: 0, output: writer.flush() };
625
+ const code = Number(process.exitCode ?? 0);
626
+ FrameworkEvents.emit(new CommandRan(name, performance.now() - startedAt, code, code === 0));
627
+ return { code, output: writer.flush() };
601
628
  } catch (error) {
602
629
  FrameworkEvents.emit(
603
630
  new CommandRan(name, performance.now() - startedAt, 1, false, (error as Error).message),
604
631
  );
605
632
  const errorLine = `\x1b[31m✖ ${(error as Error).message}\x1b[0m\n`;
606
633
  return { code: 1, output: writer.flush() + errorLine };
634
+ } finally {
635
+ process.exitCode = outerExitCode;
607
636
  }
608
637
  }
609
638
 
@@ -98,7 +98,23 @@ function assertOneRuntime(): void {
98
98
  }
99
99
 
100
100
  const mismatch = runtimeMismatch();
101
- if (mismatch) _refuse(runtimeMismatchMessage(mismatch), mismatch);
101
+ if (!mismatch) return;
102
+
103
+ // A runtime the project never asked for is a warning, not a refusal. It arrives
104
+ // as a transitive peer — `bun-plugin-tailwind` declares `bun` as a required peer
105
+ // and Bun auto-installs it — and nothing executes the stray copy, so "two
106
+ // runtimes in play" is not what is happening. Refusing over it crash-looped an
107
+ // app behind a 502 twice, and the remedies the refusal prints are the wrong ones
108
+ // for this cause. Loud, because the version skew is still real if anything ever
109
+ // *does* run it.
110
+ if (mismatch.chosen === false) {
111
+ console.warn(`
112
+ ⚠ ${runtimeMismatchMessage(mismatch)}
113
+ `);
114
+ return;
115
+ }
116
+
117
+ _refuse(runtimeMismatchMessage(mismatch), mismatch);
102
118
  }
103
119
 
104
120
  /**
@@ -35,6 +35,12 @@ export interface RuntimeMismatch {
35
35
  installed: string;
36
36
  /** Absolute path of the manifest `installed` was read from. */
37
37
  manifest: string;
38
+ /**
39
+ * Whether the project *asked* for a `bun` package — named it in its own
40
+ * dependencies — or merely acquired one as a transitive peer. A mismatch the
41
+ * project chose is a refusal; one it did not is a warning.
42
+ */
43
+ chosen?: boolean | undefined;
38
44
  }
39
45
 
40
46
  /**
@@ -83,7 +89,54 @@ export function runtimeMismatch(cwd: string = process.cwd()): RuntimeMismatch |
83
89
  const installed = installedBunVersion(cwd);
84
90
  if (!installed) return null;
85
91
  if (installed.version === running) return null;
86
- return { running, installed: installed.version, manifest: installed.manifest };
92
+ return {
93
+ running,
94
+ installed: installed.version,
95
+ manifest: installed.manifest,
96
+ chosen: declaresBunDependency(cwd),
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Whether the project asked for a `bun` package, or merely ended up with one.
102
+ *
103
+ * This is the difference between two runtimes and one runtime plus a stray npm
104
+ * package, and it decides whether the mismatch is a refusal or a warning.
105
+ *
106
+ * It matters because the common way to acquire a `node_modules/bun` is not to want
107
+ * one. `bun-plugin-tailwind@0.1.2` — which the scaffold itself installs — declares
108
+ * `"peerDependencies": { "bun": ">=1.0.0" }` with no `peerDependenciesMeta` marking
109
+ * it optional, so `bun install` auto-installs the Bun *npm package* as a second
110
+ * runtime, newer than the one executing. An app hit exactly that and took two
111
+ * production outages: the first when the refusal crash-looped behind a 502, the
112
+ * second when the obvious fix (`rm -rf node_modules/bun` after install) could not
113
+ * work, because that package's postinstall runs *during* install and its removal
114
+ * leaves the tree incomplete.
115
+ *
116
+ * Nothing about that situation is a project running two runtimes. Nobody executes
117
+ * the stray copy; it is a directory. Refusing to boot over it is the guard being
118
+ * confidently wrong, and the remedies it prints — `bun update bun`, or run
119
+ * everything through `node_modules/.bin/bun` — are both wrong answers to it.
120
+ *
121
+ * @param cwd - Project root to look under.
122
+ * @returns `true` when a manifest up the tree names `bun` in its own dependencies.
123
+ */
124
+ export function declaresBunDependency(cwd: string): boolean {
125
+ let dir = cwd;
126
+ for (;;) {
127
+ try {
128
+ const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as {
129
+ dependencies?: Record<string, string>;
130
+ devDependencies?: Record<string, string>;
131
+ };
132
+ if (manifest.dependencies?.["bun"] ?? manifest.devDependencies?.["bun"]) return true;
133
+ } catch {
134
+ // No manifest here, or unreadable — keep climbing.
135
+ }
136
+ const parent = dirname(dir);
137
+ if (parent === dir) return false;
138
+ dir = parent;
139
+ }
87
140
  }
88
141
 
89
142
  /**
@@ -93,12 +146,34 @@ export function runtimeMismatch(cwd: string = process.cwd()): RuntimeMismatch |
93
146
  * @param mismatch - The disagreement to describe.
94
147
  */
95
148
  export function runtimeMismatchMessage(mismatch: RuntimeMismatch): string {
96
- return (
149
+ const head =
97
150
  `Two Bun runtimes are in play. This process is Bun ${mismatch.running}, but the ` +
98
151
  `project installs Bun ${mismatch.installed} (${mismatch.manifest}).\n\n` +
99
152
  ` Whichever one is not running this command is still running something else — ` +
100
153
  `the server, the suite, a deploy step — and a test that passes under one is not ` +
101
- `evidence about the other.\n\n` +
154
+ `evidence about the other.\n\n`;
155
+
156
+ // Nothing named `bun`, so it arrived as a transitive peer. Naming the usual
157
+ // culprit is most of the fix, because the remedies below are the opposite of the
158
+ // ones for a runtime somebody chose — and following those instead cost an app two
159
+ // production outages.
160
+ if (mismatch.chosen === false) {
161
+ return (
162
+ head +
163
+ ` Nothing in this project depends on "bun", so it arrived as a transitive peer —\n` +
164
+ ` usually bun-plugin-tailwind, which declares "bun" as a required peer dependency\n` +
165
+ ` and has Bun auto-install it as a second runtime.\n\n` +
166
+ ` Fix it by not installing it:\n` +
167
+ ` bun install --omit=peer # skips every peer; safe when yours are direct deps\n\n` +
168
+ ` Do NOT remove node_modules/bun after installing. That package's postinstall runs\n` +
169
+ ` *during* install, so deleting it afterwards leaves the tree incomplete and the\n` +
170
+ ` next install fails — which is its own outage.\n\n` +
171
+ ` To boot anyway, set ${RUNTIME_MISMATCH_ESCAPE}=1 — it downgrades this to a warning.`
172
+ );
173
+ }
174
+
175
+ return (
176
+ head +
102
177
  ` Fix it by picking one:\n` +
103
178
  ` bun update bun # move the installed one to match your shell\n` +
104
179
  ` node_modules/.bin/bun # or run everything through the installed one\n\n` +