@zerotal/core 1.10.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 +52 -0
- package/api-surface.md +31 -0
- package/package.json +1 -1
- package/src/command/CommandRunner.ts +38 -4
- package/src/command/builtin/VersionCommand.ts +53 -0
- package/src/command/builtin/index.ts +1 -0
- package/src/command/startZerotal.ts +41 -1
- package/src/command/versionInfo.ts +89 -0
- package/src/support/runtime.ts +78 -3
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,58 @@ 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
|
+
|
|
32
|
+
## [1.11.0] — 2026-08-31
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- **A command can fail without throwing.** `CommandRunner` ran an unconditional
|
|
37
|
+
`process.exit(0)` the moment `run()` returned and never read `process.exitCode` —
|
|
38
|
+
which is the idiomatic way to fail a Bun/Node CLI without an exception, and what
|
|
39
|
+
most people reach for. An app's `release:check` printed six blockers, set the code,
|
|
40
|
+
and exited **0**; their deploy script read success and would have restarted a broken
|
|
41
|
+
production deployment. `zt deploy` gates on the same value through
|
|
42
|
+
`callInProcess`, so its own preflight had the hole too. A gate that cannot fail is
|
|
43
|
+
not a gate, and this one failed _open_.
|
|
44
|
+
|
|
45
|
+
`callInProcess` saves and restores the code around each command, because it runs
|
|
46
|
+
them in-process and the value is global. Note for anyone doing the same:
|
|
47
|
+
`process.exitCode = undefined` is a **no-op in Bun** — the previous value survives —
|
|
48
|
+
so clearing it takes `0`.
|
|
49
|
+
|
|
50
|
+
- **A Bun the project never asked for is a warning, not a refusal.**
|
|
51
|
+
`bun-plugin-tailwind` declares `bun` as a _required_ peer, so `bun install` fetches
|
|
52
|
+
the Bun npm package as a second, newer runtime — and the runtime guard refused to
|
|
53
|
+
boot. An app took two production outages on this: the first crash-looping behind a
|
|
54
|
+
502, the second because the obvious fix (removing `node_modules/bun` after install)
|
|
55
|
+
cannot work — that package's postinstall runs _during_ install, so deleting it
|
|
56
|
+
leaves the tree incomplete.
|
|
57
|
+
|
|
58
|
+
Nothing about that is two runtimes in play; nobody executes the stray copy. The
|
|
59
|
+
guard now asks whether the project _declared_ `bun` in its own dependencies. If it
|
|
60
|
+
did, the refusal stands. If it did not, it warns and names the likely culprit, the
|
|
61
|
+
fix that works (`bun install --omit=peer`) and the one that does not.
|
|
62
|
+
|
|
11
63
|
## [1.10.0] — 2026-08-30
|
|
12
64
|
|
|
13
65
|
### Changed
|
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[]
|
|
@@ -3207,6 +3235,8 @@ function bunBinary = () => string
|
|
|
3207
3235
|
|
|
3208
3236
|
function declaredBunFloor = (cwd: string) => { range: string; manifest: string;} | null
|
|
3209
3237
|
|
|
3238
|
+
function declaresBunDependency = (cwd: string) => boolean
|
|
3239
|
+
|
|
3210
3240
|
function installedBunVersion = (cwd: string) => { version: string; manifest: string;} | null
|
|
3211
3241
|
|
|
3212
3242
|
function runtimeBelowFloor = (cwd?: string) => RuntimeFloor | null
|
|
@@ -3226,6 +3256,7 @@ interface RuntimeFloor = {
|
|
|
3226
3256
|
}
|
|
3227
3257
|
|
|
3228
3258
|
interface RuntimeMismatch = {
|
|
3259
|
+
chosen?: boolean | undefined
|
|
3229
3260
|
installed: string
|
|
3230
3261
|
manifest: string
|
|
3231
3262
|
running: string
|
package/package.json
CHANGED
|
@@ -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);
|
|
@@ -533,8 +538,21 @@ export class CommandRunner {
|
|
|
533
538
|
const startedAt = performance.now();
|
|
534
539
|
try {
|
|
535
540
|
await instance.run();
|
|
536
|
-
|
|
537
|
-
process.
|
|
541
|
+
|
|
542
|
+
// Honour `process.exitCode` if the command set one.
|
|
543
|
+
//
|
|
544
|
+
// This used to be an unconditional `process.exit(0)`, so a command could
|
|
545
|
+
// only fail by throwing — and `process.exitCode = 1` is the idiomatic way
|
|
546
|
+
// to fail a Bun/Node CLI without an exception, which is what most people
|
|
547
|
+
// reach for. It was silently discarded. An app wrote a `release:check`
|
|
548
|
+
// gate that printed six blockers, set the code, and exited 0; the deploy
|
|
549
|
+
// script's `if ! …` read success and would have restarted a broken
|
|
550
|
+
// production deployment. A gate that cannot fail is not a gate, and that
|
|
551
|
+
// one failed *open*, which is the worst direction available.
|
|
552
|
+
const code = process.exitCode ?? 0;
|
|
553
|
+
const ok = Number(code) === 0;
|
|
554
|
+
FrameworkEvents.emit(new CommandRan(name, performance.now() - startedAt, Number(code), ok));
|
|
555
|
+
process.exit(Number(code));
|
|
538
556
|
} catch (error) {
|
|
539
557
|
FrameworkEvents.emit(
|
|
540
558
|
new CommandRan(name, performance.now() - startedAt, 1, false, (error as Error).message),
|
|
@@ -594,16 +612,32 @@ export class CommandRunner {
|
|
|
594
612
|
|
|
595
613
|
const name = (Cmd as { commandName?: string }).commandName ?? commandName;
|
|
596
614
|
const startedAt = performance.now();
|
|
615
|
+
// `process.exitCode` is process-global, and this runs the command *in this
|
|
616
|
+
// process* — so it has to be cleared before and restored after, or a command
|
|
617
|
+
// that sets it poisons the runner and everything called after it. Reading it
|
|
618
|
+
// at all is the point: `zt deploy` gates on these return codes, and a
|
|
619
|
+
// `release:check` that printed its blockers and set the code used to come back
|
|
620
|
+
// as success. The gate failed open, which is the one direction a gate must not.
|
|
621
|
+
//
|
|
622
|
+
// `0`, not `undefined`, for both the clear and the restore: in Bun,
|
|
623
|
+
// `process.exitCode = undefined` is a no-op that leaves the previous value in
|
|
624
|
+
// place, so clearing with it does nothing and one blocked command marks every
|
|
625
|
+
// command after it as failed.
|
|
626
|
+
const outerExitCode = process.exitCode ?? 0;
|
|
627
|
+
process.exitCode = 0;
|
|
597
628
|
try {
|
|
598
629
|
await instance.run();
|
|
599
|
-
|
|
600
|
-
|
|
630
|
+
const code = Number(process.exitCode ?? 0);
|
|
631
|
+
FrameworkEvents.emit(new CommandRan(name, performance.now() - startedAt, code, code === 0));
|
|
632
|
+
return { code, output: writer.flush() };
|
|
601
633
|
} catch (error) {
|
|
602
634
|
FrameworkEvents.emit(
|
|
603
635
|
new CommandRan(name, performance.now() - startedAt, 1, false, (error as Error).message),
|
|
604
636
|
);
|
|
605
637
|
const errorLine = `\x1b[31m✖ ${(error as Error).message}\x1b[0m\n`;
|
|
606
638
|
return { code: 1, output: writer.flush() + errorLine };
|
|
639
|
+
} finally {
|
|
640
|
+
process.exitCode = outerExitCode;
|
|
607
641
|
}
|
|
608
642
|
}
|
|
609
643
|
|
|
@@ -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.
|
|
@@ -98,7 +122,23 @@ function assertOneRuntime(): void {
|
|
|
98
122
|
}
|
|
99
123
|
|
|
100
124
|
const mismatch = runtimeMismatch();
|
|
101
|
-
if (mismatch)
|
|
125
|
+
if (!mismatch) return;
|
|
126
|
+
|
|
127
|
+
// A runtime the project never asked for is a warning, not a refusal. It arrives
|
|
128
|
+
// as a transitive peer — `bun-plugin-tailwind` declares `bun` as a required peer
|
|
129
|
+
// and Bun auto-installs it — and nothing executes the stray copy, so "two
|
|
130
|
+
// runtimes in play" is not what is happening. Refusing over it crash-looped an
|
|
131
|
+
// app behind a 502 twice, and the remedies the refusal prints are the wrong ones
|
|
132
|
+
// for this cause. Loud, because the version skew is still real if anything ever
|
|
133
|
+
// *does* run it.
|
|
134
|
+
if (mismatch.chosen === false) {
|
|
135
|
+
console.warn(`
|
|
136
|
+
⚠ ${runtimeMismatchMessage(mismatch)}
|
|
137
|
+
`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
_refuse(runtimeMismatchMessage(mismatch), mismatch);
|
|
102
142
|
}
|
|
103
143
|
|
|
104
144
|
/**
|
|
@@ -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
|
+
}
|
package/src/support/runtime.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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` +
|