@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 +43 -0
- package/api-surface.md +29 -57
- package/package.json +1 -1
- package/src/application/Application.ts +18 -2
- package/src/assets/assets.ts +56 -4
- package/src/assets/index.ts +9 -4
- package/src/build/PackageLinter.ts +56 -0
- package/src/command/Command.ts +52 -13
- package/src/command/CommandRunner.ts +4 -0
- package/src/command/builtin/AssetsBuildCommand.ts +13 -1
- package/src/command/builtin/ServeCommand.ts +8 -1
- package/src/command/builtin/UpgradeCommand.ts +148 -0
- package/src/command/builtin/index.ts +1 -0
- package/src/dev/BuildOutput.ts +149 -27
- package/src/dev/CssPlugins.ts +9 -2
- package/src/dev/DevDeck.ts +33 -7
- package/src/dev/index.ts +1 -1
- package/src/dev/startDevMode.ts +31 -14
- package/src/doctor/AppDoctor.ts +23 -5
- package/src/events/FrameworkEvents.ts +2 -1
- package/src/helpers/response.ts +8 -0
- package/src/upgrade/codemods/deprecated-aliases.ts +111 -0
- package/src/upgrade/codemods/index.ts +15 -0
- package/src/upgrade/runner.ts +148 -0
- package/src/upgrade/types.ts +78 -0
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
|
@@ -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 {
|
|
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
|
-
|
|
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.
|
package/src/assets/assets.ts
CHANGED
|
@@ -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
|
|
111
|
+
* @returns `<prefix>/<path>`, with `?v=<version>` appended when a version is set.
|
|
60
112
|
*
|
|
61
113
|
* @example
|
|
62
|
-
* asset("/app.css") //
|
|
63
|
-
* asset("app.css") //
|
|
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 (
|
|
119
|
+
if (_version) {
|
|
68
120
|
const separator = url.includes("?") ? "&" : "?";
|
|
69
121
|
return `${url}${separator}v=${_version}`;
|
|
70
122
|
}
|
package/src/assets/index.ts
CHANGED
|
@@ -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
|
|
4
|
-
* `?v=<version>` cache-buster in
|
|
5
|
-
*
|
|
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"); //
|
|
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
|
}
|
package/src/command/Command.ts
CHANGED
|
@@ -188,10 +188,11 @@ export abstract class Command {
|
|
|
188
188
|
try {
|
|
189
189
|
tty.setRawMode!(true);
|
|
190
190
|
const characters: string[] = [];
|
|
191
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
243
|
-
//
|
|
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
|
|
246
|
-
private
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
/**
|