@zerotal/core 1.7.4 → 1.8.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,49 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.8.0] — 2026-08-24
12
+
13
+ ### Added
14
+
15
+ - **`zt upgrade`** — the codemod runner. Dry by default: it rewrites source across a whole
16
+ project, so the first run is something you read and disagree with before `--write` applies
17
+ it. Nothing is written until the whole plan is known, so a run that fails halfway leaves no
18
+ half-upgraded tree. The first codemod covers the deprecated aliases — `BaseModel` → `Model`,
19
+ `routes:types` → `route:types`, `serve --dev` → `dev` — renaming the class only in a heritage
20
+ clause and fixing the import to match. What it _could not_ do is reported last and loudest,
21
+ with file, line and reason: a codemod that walks past what it does not understand implies the
22
+ job is finished when it is not.
23
+
24
+ - **`--clean` on `assets:build` and `inertia:build`.** Pruning is conservative by default —
25
+ chunk-shaped filenames plus what the last build on this machine recorded in `.zerotal/` —
26
+ which cannot recognise output some other naming produced. `--clean` needs no record: the
27
+ output directory belongs to the build. It refuses `public/` and the project root, where
28
+ deleting what was not rebuilt takes the app's images and favicon with it.
29
+
30
+ ### Fixed
31
+
32
+ - **Answering the busy-port prompt killed `serve --dev`.** The banner printed, then
33
+ `exited with code 1`, with nothing on screen saying why. Reading a prompt locks Bun's stdin
34
+ stream and the lock is held for the life of the command, so the dev deck's
35
+ `process.stdin.resume()` threw `ReadableStream is locked` — and it threw inside the alternate
36
+ screen buffer, so restoring the terminal erased the error on the way out. The prompt hands
37
+ stdin back where it took it; a deck that still cannot have it degrades to streaming instead
38
+ of dying, and a dev-mode failure stops the deck before it reports.
39
+
40
+ - **Two builds sharing an output directory deleted each other's files.** Nothing forbids
41
+ `inertia:build` and `assets:build` writing to the same place and the defaults invite it, but
42
+ the prune record was one flat list per directory — so each build read the other's files as
43
+ its own previous output and removed them. The release ended up with whichever ran last, and
44
+ neither reported a problem: the build that lost still said "Build complete", and the page it
45
+ served then 404'd its own script. The record is keyed by entry point now, so a file another
46
+ build claimed is not this one's to remove. Unclaimed chunks are still swept.
47
+
48
+ - **`zt doctor` failed a schema configuration that works.** Sync on plus migrations present
49
+ read as "the schema needs exactly one source of truth" — but sync building the schema from
50
+ the models for a fresh clone, with `synchronize` false in production where the deploy runs
51
+ `migrate`, is a documented arrangement in which the two never apply in the same environment.
52
+ It fails in production, where the deploy really does run both, and warns elsewhere.
53
+
11
54
  ## [1.7.3] — 2026-08-20
12
55
 
13
56
  ### Fixed
package/api-surface.md CHANGED
@@ -686,8 +686,6 @@ function data_get = (target: unknown, key: string, defaultValue?: unknown) => un
686
686
 
687
687
  function deepMerge = <T extends object>(base: T, override: DeepPartial<T>) => T
688
688
 
689
- function deployEnv = () => string
690
-
691
689
  function devSurfacesEnabled = () => boolean
692
690
 
693
691
  function env = { (key: string): string | undefined; (key: string, fallback: string): string; (key: string, fallback: boolean): boolean; (key: string, fallback: number): number;}
@@ -704,8 +702,6 @@ function inject = (...tokens: BindingToken[]) => (target: new (...args: any[]) =
704
702
 
705
703
  function isDevSurfaceAllowed = (env: string) => boolean
706
704
 
707
- function isProdLike = (env: string) => boolean
708
-
709
705
  function json = (data: unknown, status?: number) => void
710
706
 
711
707
  function make = <T>(token: BindingToken<T>) => Promise<T>
@@ -728,8 +724,6 @@ function redirect = { (url: string, status?: 301 | 302 | 303 | 307 | 308): Re
728
724
 
729
725
  function redirectTo = <N extends RouteTarget>(name: N, params?: RouteParamsArg<N>, status?: 301 | 302 | 303 | 307 | 308) => ResponseBuilder
730
726
 
731
- function registerAppScope = (installer: AppScopeInstaller) => void
732
-
733
727
  function registerErrorDiagnoser = (diagnoser: ErrorDiagnoser) => void
734
728
 
735
729
  function registerFileRouteResolver = (resolver: FileRouteResolver) => void
@@ -754,8 +748,6 @@ function setAppEnv = (command?: string) => void
754
748
 
755
749
  function setCurrentPageResolver = (resolver: CurrentPageResolver) => void
756
750
 
757
- function setImplicitModelResolver = (fn: ((paramName: string) => ModelBindingResolver | undefined) | null) => void
758
-
759
751
  function sha256Hex = (input: string | Uint8Array) => string
760
752
 
761
753
  function singularize = (value: string) => string
@@ -1114,8 +1106,6 @@ type ArgDef = { name: string; required?: boolean; default?: string;}
1114
1106
 
1115
1107
  type BindingToken = ClassToken<T> | AbstractToken<T> | keyof ContainerBindings
1116
1108
 
1117
- type ClassRef = abstract new (...args: never[]) => unknown
1118
-
1119
1109
  type ContextKey = never
1120
1110
 
1121
1111
  type ContextValue = ContextRegistry[K]
@@ -1526,35 +1516,6 @@ class CssBuildCommand = {
1526
1516
  write: (msg: string) => void
1527
1517
  }
1528
1518
 
1529
- class DeployCommand = {
1530
- new (): DeployCommand
1531
- static args: ArgDef[]
1532
- static commandName: string
1533
- static description: string
1534
- static flags: ({ name: string; type: 'boolean'; description: string; default: boolean;} | { name: string; type: 'string'; description: string; default?: never;})[]
1535
- static needsApp: boolean
1536
- static target: string
1537
- _readLine: () => Promise<string>
1538
- _writer: OutputWriter
1539
- app: unknown
1540
- args: Record<string, string>
1541
- ask: (question: string, defaultValue?: string) => Promise<string>
1542
- choice: (question: string, options: string[]) => Promise<string>
1543
- confirm: (question: string, defaultValue?: boolean) => Promise<boolean>
1544
- dim: (msg: string) => void
1545
- error: (msg: string) => void
1546
- flags: Record<string, string | number | boolean>
1547
- info: (msg: string) => void
1548
- line: (msg: string) => void
1549
- newLine: () => void
1550
- run: () => Promise<void>
1551
- secret: (question: string) => Promise<string>
1552
- section: (title: string) => void
1553
- table: (rows: [string, string][], indent?: number) => void
1554
- warn: (msg: string) => void
1555
- write: (msg: string) => void
1556
- }
1557
-
1558
1519
  class DevCommand = {
1559
1520
  new (): DevCommand
1560
1521
  static aliases: string[]
@@ -2287,6 +2248,34 @@ class TestCommand = {
2287
2248
  write: (msg: string) => void
2288
2249
  }
2289
2250
 
2251
+ class UpgradeCommand = {
2252
+ new (): UpgradeCommand
2253
+ static args: ArgDef[]
2254
+ static commandName: string
2255
+ static description: string
2256
+ static flags: ({ name: string; type: 'string'; description: string; default: string; short?: never;} | { name: string; short: string; type: 'boolean'; description: string; default: boolean;} | { name: string; short: string; type: 'string'; description: string; default: string;})[]
2257
+ static needsApp: boolean
2258
+ _readLine: () => Promise<string>
2259
+ _writer: OutputWriter
2260
+ app: unknown
2261
+ args: Record<string, string>
2262
+ ask: (question: string, defaultValue?: string) => Promise<string>
2263
+ choice: (question: string, options: string[]) => Promise<string>
2264
+ confirm: (question: string, defaultValue?: boolean) => Promise<boolean>
2265
+ dim: (msg: string) => void
2266
+ error: (msg: string) => void
2267
+ flags: Record<string, string | number | boolean>
2268
+ info: (msg: string) => void
2269
+ line: (msg: string) => void
2270
+ newLine: () => void
2271
+ run: () => Promise<void>
2272
+ secret: (question: string) => Promise<string>
2273
+ section: (title: string) => void
2274
+ table: (rows: [string, string][], indent?: number) => void
2275
+ warn: (msg: string) => void
2276
+ write: (msg: string) => void
2277
+ }
2278
+
2290
2279
  class WorkerCommand = {
2291
2280
  new (): WorkerCommand
2292
2281
  static aliases: string[]
@@ -2316,8 +2305,6 @@ class WorkerCommand = {
2316
2305
  write: (msg: string) => void
2317
2306
  }
2318
2307
 
2319
- function makeDeployCommand = (target: string) => DeployCommandClass
2320
-
2321
2308
  ## ./config `(./src/config/index.ts)`
2322
2309
 
2323
2310
  class ConfigLoader = {
@@ -2511,8 +2498,6 @@ const SERVER_PROCESS_NAME = 'server'
2511
2498
 
2512
2499
  function bootBuildDecision = (outDirs: string[], env: string | undefined) => Promise<BootBuildDecision>
2513
2500
 
2514
- function browserEnvDefines = (isProduction: boolean) => Record<string, string>
2515
-
2516
2501
  function buildCssBundle = (input: string, outdir: string, minify?: boolean, loader?: Record<string, string>) => Promise<BundleResult>
2517
2502
 
2518
2503
  function buildJsBundle = (input: string, outdir: string, minify?: boolean) => Promise<BundleResult>
@@ -2523,19 +2508,14 @@ function createDeck = (options: DeckOptions) => Deck
2523
2508
 
2524
2509
  function detectCssPlugins = (cwd: string) => Promise<BunPlugin[]>
2525
2510
 
2526
- function isDevOrchestrated = (env?: Record<string, string | undefined>, argv?: readonly string[]) => boolean
2527
-
2528
2511
  function isWritableDir = (dir: string) => Promise<boolean>
2529
2512
 
2530
- function pruneBuildOutput = (outdir: string, outputs: readonly { path: string;}[]) => Promise<string[]>
2531
-
2532
- function registerDevBuildHook = (name: string, fn: BuildHookFn) => void
2533
-
2534
2513
  function registerDevHtmlSnippet = (name: string, fn: DevHtmlSnippet) => void
2535
2514
 
2536
2515
  function startDevMode = (options: StartDevModeOptions) => Promise<void>
2537
2516
 
2538
2517
  interface AssetBuildConfig = {
2518
+ clean?: boolean
2539
2519
  entrypoint: string | string[]
2540
2520
  loader?: Record<string, string>
2541
2521
  minify: boolean
@@ -3409,8 +3389,6 @@ class StackChannel = {
3409
3389
 
3410
3390
  const Log = LogManager
3411
3391
 
3412
- function frameworkLog = (scope: string) => BoundLogger
3413
-
3414
3392
  function LoggingConfig = (options?: Partial<LoggingConfigShape>) => LoggingConfigShape
3415
3393
 
3416
3394
  function renderTable = (data: TableData, indent?: number) => string[]
@@ -3506,17 +3484,11 @@ function hasRoute = (name: string) => boolean
3506
3484
 
3507
3485
  function resetRoutes = () => void
3508
3486
 
3509
- function routeMethod = (name: string) => string | undefined
3510
-
3511
3487
  interface RouteAction = {
3512
3488
  method: string
3513
3489
  url: string
3514
3490
  }
3515
3491
 
3516
- interface RouteMethodRegistry = {}
3517
-
3518
- type MethodedRouteName = never
3519
-
3520
3492
  type RouteArgs = [params?: RouteParamValues, query?: RouteQuery]
3521
3493
 
3522
3494
  type RouteParamValues = { [x: string]: RouteParamValue | readonly RouteParamValue[];}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/core",
3
- "version": "1.7.4",
3
+ "version": "1.8.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -34,7 +34,12 @@ import {
34
34
  } from "../middleware/SecureHeadersMiddleware.ts";
35
35
  import { isAllowedOrigin, allowedOriginsFrom } from "../http/originGuard.ts";
36
36
  import { rescueSync } from "../helpers/index.ts";
37
- import { configureAssets, setAssetVersion, assetVersion } from "../assets/assets.ts";
37
+ import {
38
+ configureAssets,
39
+ setAssetVersion,
40
+ assetVersion,
41
+ deriveAssetVersion,
42
+ } from "../assets/assets.ts";
38
43
  import { ConfigLoader, type ConfigMap } from "../config/ConfigLoader.ts";
39
44
  import { Emitter } from "../events/Emitter.ts";
40
45
  import { FrameworkEvents, AppBooted } from "../events/FrameworkEvents.ts";
@@ -1358,7 +1363,18 @@ export class Application {
1358
1363
  // each rebuild ships a fresh token over the reload channel (see ServeCommand).
1359
1364
  const isDevWorker = process.argv.includes("--dev-worker");
1360
1365
  configureAssets({ prefix: assets?.prefix ?? "/", dev: isDevWorker });
1361
- if (isDevWorker) setAssetVersion(Bun.env["ZT_ASSET_VERSION"] ?? "");
1366
+
1367
+ if (isDevWorker) {
1368
+ setAssetVersion(Bun.env["ZT_ASSET_VERSION"] ?? "");
1369
+ } else {
1370
+ // Production gets a token too, derived from the built files. `assets:build`
1371
+ // writes `app.js` under that name every deploy and the static handler sends
1372
+ // no `Cache-Control`, so without this a returning visitor keeps the bundle
1373
+ // their browser cached — through any number of deploys, while the server
1374
+ // reports success. Derived rather than random so a restart that changed no
1375
+ // assets does not invalidate every client's cache for nothing.
1376
+ setAssetVersion(deriveAssetVersion(`${dir}/${assets?.outDir ?? "public"}`));
1377
+ }
1362
1378
  }
1363
1379
 
1364
1380
  // Convention phase — discovers app/schedules (class-based), models, observers, jobs, etc.
@@ -14,8 +14,25 @@
14
14
  * {@link bumpAssetVersion}) and propagated from the dev orchestrator to the
15
15
  * server worker over its reload channel. This mirrors Inertia's asset-version
16
16
  * model — one token per build, busting every asset at once.
17
+ *
18
+ * ## Production needs this more than dev does
19
+ *
20
+ * It used to be dev-only, on the reasoning that production assets would be
21
+ * content-hashed by something downstream. Nothing does: `assets:build` writes
22
+ * `public/js/app.js` under that exact name every time, the static handler sends
23
+ * no `Cache-Control`, and so a returning visitor keeps whatever bundle their
24
+ * browser heuristically cached — for the life of that heuristic, across any
25
+ * number of deploys. A shipped fix that reaches nobody who has visited before is
26
+ * the worst kind, because everything on the server says it worked.
27
+ *
28
+ * So the version applies whenever it is set, in any environment. In production
29
+ * it is derived from the built files themselves ({@link deriveAssetVersion}), so
30
+ * it changes when the assets change and a plain restart does not bust a thing.
17
31
  */
18
32
 
33
+ import { readdirSync, statSync } from "node:fs";
34
+ import { join } from "node:path";
35
+
19
36
  let _prefix = "/";
20
37
  let _dev = false;
21
38
  let _version = "";
@@ -36,6 +53,41 @@ export function setAssetVersion(version: string): void {
36
53
  _version = version;
37
54
  }
38
55
 
56
+ /**
57
+ * A token derived from the built assets in `dir`, or `""` when there are none.
58
+ *
59
+ * Name, size and mtime of every file, hashed. Content would be stricter and costs
60
+ * a full read of every bundle at boot for a token that only has to *change* when
61
+ * the files do — a rebuild that produces identical bytes keeping its old token is
62
+ * the correct outcome either way.
63
+ *
64
+ * `""` when the directory is missing or empty, which leaves `asset()` emitting
65
+ * clean URLs rather than a token that means nothing.
66
+ */
67
+ export function deriveAssetVersion(dir: string): string {
68
+ let entries: string[];
69
+ try {
70
+ entries = readdirSync(dir, { recursive: true }) as string[];
71
+ } catch {
72
+ return "";
73
+ }
74
+
75
+ const parts: string[] = [];
76
+ for (const entry of entries.sort()) {
77
+ try {
78
+ const info = statSync(join(dir, entry));
79
+ if (!info.isFile()) continue;
80
+ parts.push(`${entry}:${info.size}:${Math.floor(info.mtimeMs)}`);
81
+ } catch {
82
+ // Raced with a deploy swapping the directory. Skipping the file is fine:
83
+ // whatever lands next boot produces a different token anyway.
84
+ }
85
+ }
86
+
87
+ if (parts.length === 0) return "";
88
+ return Bun.hash(parts.join("|")).toString(36).slice(0, 10);
89
+ }
90
+
39
91
  /** The current asset version, or `""` when unset. */
40
92
  export function assetVersion(): string {
41
93
  return _version;
@@ -56,15 +108,15 @@ export function bumpAssetVersion(): string {
56
108
  * Build the public URL for a front-end asset.
57
109
  *
58
110
  * @param path Asset path relative to the asset prefix (leading slash optional).
59
- * @returns `<prefix>/<path>`, with `?v=<version>` appended in dev.
111
+ * @returns `<prefix>/<path>`, with `?v=<version>` appended when a version is set.
60
112
  *
61
113
  * @example
62
- * asset("/app.css") // prod → "/app.css"
63
- * asset("app.css") // dev → "/app.css?v=lq3k7m"
114
+ * asset("/app.css") // → "/app.css?v=lq3k7m"
115
+ * asset("app.css") // → "/app.css?v=lq3k7m"
64
116
  */
65
117
  export function asset(path: string): string {
66
118
  const url = _joinUrl(_prefix, path);
67
- if (_dev && _version) {
119
+ if (_version) {
68
120
  const separator = url.includes("?") ? "&" : "?";
69
121
  return `${url}${separator}v=${_version}`;
70
122
  }
@@ -1,14 +1,19 @@
1
1
  /**
2
2
  * Front-end asset URL helper and asset versioning (the `@zerotal/core/assets`
3
- * subpath). {@link asset} builds the public URL for a built asset, appending a
4
- * `?v=<version>` cache-buster in dev; the versioning helpers let the dev
5
- * orchestrator bust every asset at once after a rebuild.
3
+ * subpath). {@link asset} builds the public URL for a built asset and appends a
4
+ * `?v=<version>` cache-buster in every environment, not only in dev.
5
+ *
6
+ * Production needs it more: `assets:build` writes `app.js` under that name on
7
+ * every deploy and the static handler sends no `Cache-Control`, so an unbusted
8
+ * URL leaves a returning visitor on whatever bundle their browser cached. The
9
+ * production token is derived from the built files, so it moves when they do and
10
+ * a plain restart busts nothing.
6
11
  *
7
12
  * @example
8
13
  * ```ts
9
14
  * import { asset } from "@zerotal/core/assets";
10
15
  *
11
- * asset("/app.css"); // prod → "/app.css"; dev → "/app.css?v=lq3k7m"
16
+ * asset("/app.css"); // → "/app.css?v=lq3k7m"
12
17
  * ```
13
18
  *
14
19
  * @packageDocumentation
@@ -113,6 +113,61 @@ function _checkErrorDiscipline(files: PackageFiles, packageName: string): Violat
113
113
  }
114
114
 
115
115
  /** Lint every package under `packagesDir` and return a report per package. */
116
+ /**
117
+ * A package below `stable` names the release by which its label is reviewed, and
118
+ * the review actually falls due.
119
+ *
120
+ * The support policy's own argument is that "an honest 'experimental' is useful
121
+ * once and corrosive indefinitely" — a package that has worn the label for a year
122
+ * is not being cautious, it is unowned. It then set a review release for `ai` and
123
+ * `arch` in a prose table, where nothing could enforce it: the version could sail
124
+ * past and the only consequence would be a sentence quietly becoming untrue.
125
+ *
126
+ * So the date lives in `package.json` beside the label it governs, and when the
127
+ * package's version reaches it, this fails until somebody performs the review —
128
+ * promote, keep with a new date and the reason, or withdraw.
129
+ */
130
+ function _checkMaturityReview(files: PackageFiles): Violation[] {
131
+ const violations: Violation[] = [];
132
+ const pkg = files.packageJson;
133
+ if (!pkg) return violations;
134
+
135
+ const maturity = pkg["maturity"] as string | undefined;
136
+ if (!maturity || maturity === "stable") return violations;
137
+
138
+ const review = pkg["maturityReview"] as string | undefined;
139
+ if (!review) {
140
+ violations.push({
141
+ rule: "maturity-review",
142
+ severity: "medium",
143
+ message: `maturity '${maturity}' with no 'maturityReview' — name the release by which the label is reviewed`,
144
+ });
145
+ return violations;
146
+ }
147
+
148
+ const version = pkg["version"] as string | undefined;
149
+ if (version && _atOrPast(version, review)) {
150
+ violations.push({
151
+ rule: "maturity-review-due",
152
+ severity: "high",
153
+ message: `maturity '${maturity}' was to be reviewed by ${review} and this package is ${version} — promote it, withdraw it, or set a new 'maturityReview' with the reason in docs/support-policy.md`,
154
+ });
155
+ }
156
+ return violations;
157
+ }
158
+
159
+ /** Whether `version` has reached `target`, comparing numerically per segment. */
160
+ function _atOrPast(version: string, target: string): boolean {
161
+ const a = version.split(".").map(Number);
162
+ const b = target.split(".").map(Number);
163
+ for (let i = 0; i < 3; i++) {
164
+ const left = a[i] ?? 0;
165
+ const right = b[i] ?? 0;
166
+ if (left !== right) return left > right;
167
+ }
168
+ return true;
169
+ }
170
+
116
171
  export async function lintPackages(packagesDir: string): Promise<PackageReport[]> {
117
172
  const reports: PackageReport[] = [];
118
173
  let entries: string[];
@@ -134,6 +189,7 @@ export async function lintPackages(packagesDir: string): Promise<PackageReport[]
134
189
  ..._checkTests(files),
135
190
  ..._checkPackaging(files),
136
191
  ..._checkErrorDiscipline(files, packageName),
192
+ ..._checkMaturityReview(files),
137
193
  ];
138
194
  reports.push({ package: packageName, violations });
139
195
  }
@@ -188,10 +188,11 @@ export abstract class Command {
188
188
  try {
189
189
  tty.setRawMode!(true);
190
190
  const characters: string[] = [];
191
- const stdinIterator = this._stdinIter();
191
+ const reader = this._reader();
192
+ if (!reader) return "";
192
193
  const decoder = new TextDecoder();
193
194
  outer: while (true) {
194
- const { value, done } = await stdinIterator.next();
195
+ const { value, done } = await reader.read();
195
196
  if (done) break;
196
197
  for (const character of decoder.decode(value)) {
197
198
  if (character === "\r" || character === "\n") break outer;
@@ -219,11 +220,18 @@ export abstract class Command {
219
220
  return line.replace(/\r$/, "");
220
221
  }
221
222
 
222
- const stdinIterator = this._stdinIter();
223
+ const reader = this._reader();
224
+ if (!reader) {
225
+ // stdin belongs to something else now; answer from the buffer or not at all.
226
+ const rest = this._lineBuf;
227
+ this._lineBuf = "";
228
+ return rest.replace(/\r$/, "");
229
+ }
230
+
223
231
  const decoder = new TextDecoder();
224
232
 
225
233
  while (true) {
226
- const { value, done } = await stdinIterator.next();
234
+ const { value, done } = await reader.read();
227
235
  if (done) break;
228
236
  this._lineBuf += decoder.decode(value);
229
237
  const newlineIndex = this._lineBuf.indexOf("\n");
@@ -239,16 +247,47 @@ export abstract class Command {
239
247
  return remaining.replace(/\r$/, "");
240
248
  }
241
249
 
242
- // Shared stdin async iterator one per Command instance so consecutive prompts
243
- // don't each open a competing stream on the same stdin fd.
250
+ // One reader for the whole command: a second `Bun.stdin.stream()` over the same
251
+ // fd never yields, so consecutive prompts have to share the first one.
244
252
  private _lineBuf = "";
245
- private _stdinIterator: AsyncIterator<Uint8Array> | undefined;
246
- private _stdinIter(): AsyncIterator<Uint8Array> {
247
- if (!this._stdinIterator) {
248
- this._stdinIterator = (Bun.stdin.stream() as unknown as AsyncIterable<Uint8Array>)[
249
- Symbol.asyncIterator
250
- ]();
253
+ private _stdinReader: ReadableStreamDefaultReader<Uint8Array> | undefined;
254
+ private _stdinHandedOver = false;
255
+
256
+ private _reader(): ReadableStreamDefaultReader<Uint8Array> | undefined {
257
+ if (this._stdinHandedOver) return undefined;
258
+ this._stdinReader ??= (Bun.stdin.stream() as ReadableStream<Uint8Array>).getReader();
259
+ return this._stdinReader;
260
+ }
261
+
262
+ /**
263
+ * Give stdin back, for a command that prompts and then hands the terminal to
264
+ * something else.
265
+ *
266
+ * Reading locks the stdin stream, and the lock is held for the life of the
267
+ * command so a second prompt can still read. Anything that takes stdin over
268
+ * afterwards — `process.stdin.resume()`, a raw-mode key listener, the dev deck
269
+ * — then throws `ReadableStream is locked`. That is how answering the busy-port
270
+ * menu used to kill `zt dev` on the spot: the deck died taking the terminal
271
+ * over, and because it dies inside the alternate screen buffer, the restore
272
+ * erased the reason on its way out.
273
+ *
274
+ * Released rather than cancelled. Cancelling closes the underlying stdin, and
275
+ * the next owner would take over a terminal that never delivers a keystroke —
276
+ * a dev deck whose tab keys and `q` silently do nothing.
277
+ *
278
+ * One-way: a prompt after this returns whatever is still buffered, because a
279
+ * fresh reader on Bun's stdin hangs rather than fails, and a hang is the worse
280
+ * of the two.
281
+ */
282
+ protected releaseStdin(): void {
283
+ this._stdinHandedOver = true;
284
+ const reader = this._stdinReader;
285
+ this._stdinReader = undefined;
286
+ try {
287
+ reader?.releaseLock();
288
+ } catch {
289
+ // Only throws with a read still in flight — nobody is mid-keystroke here,
290
+ // and the caller is taking the terminal over either way.
251
291
  }
252
- return this._stdinIterator;
253
292
  }
254
293
  }
@@ -359,6 +359,7 @@ export class CommandRunner {
359
359
  RouteListCommand,
360
360
  RouteTypesCommand,
361
361
  DoctorCommand,
362
+ UpgradeCommand,
362
363
  MakeProviderCommand,
363
364
  CssBuildCommand,
364
365
  AssetsBuildCommand,
@@ -390,6 +391,9 @@ export class CommandRunner {
390
391
  // Non-web commands (console, worker, test).
391
392
  if (this._app._env !== "web") {
392
393
  this.register(ReplCommand);
394
+ // Rewrites source across the project, so it belongs nowhere near a running
395
+ // web process — same reasoning as the release commands below.
396
+ this.register(UpgradeCommand);
393
397
  this.register(CompileCommand, ["build"]);
394
398
  this.registerAll([
395
399
  WorkerCommand,
@@ -34,11 +34,23 @@ export class AssetsBuildCommand extends Command {
34
34
  description: "Minify the output",
35
35
  default: true,
36
36
  },
37
+ {
38
+ name: "clean",
39
+ short: "c",
40
+ type: "boolean" as const,
41
+ description:
42
+ "Delete everything in the output directory this build did not write. " +
43
+ "Refused when that directory holds more than build output",
44
+ default: false,
45
+ },
37
46
  ];
38
47
 
39
48
  async run(): Promise<void> {
40
49
  const cwd = process.cwd();
41
50
  const minify = this.flags["minify"] as boolean;
51
+ // Off by default: the prune below is safe anywhere, and this is not — it is
52
+ // for a directory the build owns, which is a claim only the app can make.
53
+ const clean = this.flags["clean"] as boolean;
42
54
  let built = 0;
43
55
  let failed = 0;
44
56
 
@@ -48,7 +60,7 @@ export class AssetsBuildCommand extends Command {
48
60
  ? assets.entrypoint.join(", ")
49
61
  : assets.entrypoint;
50
62
  this.info(`Building assets: ${entries} → ${assets.outDir}/`);
51
- const result = await buildConfiguredAssets({ ...assets, minify }, cwd);
63
+ const result = await buildConfiguredAssets({ ...assets, minify, clean }, cwd);
52
64
  if (result.success) built++;
53
65
  else {
54
66
  failed++;
@@ -372,7 +372,14 @@ export class ServeCommand extends Command {
372
372
  );
373
373
  }
374
374
 
375
- return this._askAboutPort(requested, owner, held);
375
+ try {
376
+ return await this._askAboutPort(requested, owner, held);
377
+ } finally {
378
+ // Handed back here, where it was taken. What runs next on this path — the
379
+ // dev deck, or a `bun --watch` child with inherited stdio — takes the
380
+ // terminal over, and cannot while the menu still holds the read lock.
381
+ this.releaseStdin();
382
+ }
376
383
  }
377
384
 
378
385
  /**