@zerotal/core 1.14.2 → 1.15.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 +79 -0
- package/api-surface.md +3 -0
- package/package.json +1 -1
- package/src/command/TypeGenerators.ts +87 -0
- package/src/command/builtin/RouteTypesCommand.ts +52 -15
- package/src/command/builtin/TestCommand.ts +22 -0
- package/src/command/builtin/index.ts +7 -0
- package/src/command/testEnv.ts +140 -0
- package/src/doctor/AppDoctor.ts +71 -5
- package/src/index.ts +3 -0
- package/src/router/FileRouter.ts +61 -13
- package/src/upgrade/codemods/deprecated-aliases.ts +56 -25
- package/src/upgrade/codemods/index.ts +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,85 @@ follows the Zerotal monorepo's unified versioning.
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [1.15.0] — 2026-09-04
|
|
12
|
+
|
|
13
|
+
### Changed — **BREAKING**
|
|
14
|
+
|
|
15
|
+
- **BREAKING: a `_middleware.ts` that cannot apply now stops the boot.** Two ways one could
|
|
16
|
+
fail were silent, and both left the routes it was written to guard live and unguarded.
|
|
17
|
+
An app where either was happening will now refuse to boot until the file is fixed —
|
|
18
|
+
which is the point: it was not guarding anything before, and nothing said so. A directory
|
|
19
|
+
with no `_middleware` file is unchanged and stays silent. See the
|
|
20
|
+
[Upgrade Guide](/docs/upgrade#114-to-115).
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **A `_middleware.ts` that default-exported its list left the subtree unguarded.** The
|
|
25
|
+
loader read only `export const middleware`, so `export default [EmployerMiddleware]` was
|
|
26
|
+
ignored — no warning, no boot error, nothing in `route:list`. The app started, the routes
|
|
27
|
+
answered, and the directory the file was written to protect served to anyone. `export
|
|
28
|
+
default` is the natural guess: every route file in the same directory default-exports its
|
|
29
|
+
handler.
|
|
30
|
+
|
|
31
|
+
The file now has to export a `middleware` array, and boot fails naming the file and the
|
|
32
|
+
fix when it does not. Nothing rejected here has ever applied, so no working app changes.
|
|
33
|
+
|
|
34
|
+
- **An import error in a `_middleware.ts` was swallowed with the missing-file case.** Both
|
|
35
|
+
shared one `catch`, so a typo, a bad import path, a circular import or a `SyntaxError` all
|
|
36
|
+
read as "this directory has no middleware" and the routes beneath went on serving — on a
|
|
37
|
+
hot-reload, a guard that had been there a second earlier.
|
|
38
|
+
|
|
39
|
+
Absence and failure are now separate: no file is silent, as the convention intends, and a
|
|
40
|
+
file that threw stops the boot with the original error and its path. Under `zt dev` the
|
|
41
|
+
reload aborts and the previously compiled routes keep serving, so the guard never drops.
|
|
42
|
+
|
|
43
|
+
- **`zt doctor`'s secure-headers check failed on any app behind a reverse proxy.** It read
|
|
44
|
+
`app.secureHeaders.secure` and inferred the response from it, so a deployment where Caddy
|
|
45
|
+
or nginx terminates TLS and sets HSTS — the most common production topology for a Bun app
|
|
46
|
+
— was reported as downgradeable while `curl -I` showed the header present. A check that is
|
|
47
|
+
wrong on the ordinary case is one people learn to skip.
|
|
48
|
+
|
|
49
|
+
It now makes one request to `app.url` on a deployment and reports what came back. When it
|
|
50
|
+
cannot reach the site, it warns with what it actually knows — the app does not send HSTS,
|
|
51
|
+
confirm the proxy does — instead of asserting a header is missing. Unreachable is no
|
|
52
|
+
longer reported as absent.
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
|
|
56
|
+
- **`zt route:types` regenerates every type file the app generates from its file tree**, not
|
|
57
|
+
only `types/routes.generated.ts`. `@zerotal/inertia` registers its page registry through
|
|
58
|
+
the new `registerTypeGenerator`, so adding a page and running the command whose name says
|
|
59
|
+
it generates types now does. Previously it regenerated the routes half, `Inertia.render`
|
|
60
|
+
still failed with `TS2345: … not assignable to 'PageName'`, and the error reads as a typo
|
|
61
|
+
in the page name rather than a registry that has not been rebuilt. `--check` gates on all
|
|
62
|
+
of them.
|
|
63
|
+
|
|
64
|
+
- **`zt test` no longer inherits `.env`'s mail, queue, session and cache drivers.** Bun loads
|
|
65
|
+
`.env` into every process, so a developer with `MAIL_DRIVER=smtp` pointed at a local
|
|
66
|
+
Postfix got a suite where every path that sends mail opened a real SMTP connection — one
|
|
67
|
+
test going from milliseconds to a five-second timeout, failing only when run with its
|
|
68
|
+
siblings, and invisible to anyone without a mail server configured.
|
|
69
|
+
|
|
70
|
+
These four now get their in-process defaults (`log`, `sync`, `cookie`, `memory`), which is
|
|
71
|
+
what the app's own config already defaults to. A value set in the shell still wins, so
|
|
72
|
+
`MAIL_DRIVER=smtp bun zt test` works; `.env.test` is read and merged last; `--keep-env`
|
|
73
|
+
turns the whole thing off. The command prints what it changed.
|
|
74
|
+
|
|
75
|
+
## [1.14.3] — 2026-09-01
|
|
76
|
+
|
|
77
|
+
### Fixed
|
|
78
|
+
|
|
79
|
+
- **`zt upgrade` never offered the 1.13.0 migrations.** `deprecated-aliases` bundled two
|
|
80
|
+
retirements that shipped in 1.13.0 — `serve --dev` and `routes:types` — with the
|
|
81
|
+
`BaseModel` rename that has not shipped and is scheduled for 2.0. A codemod carries one
|
|
82
|
+
version, so it took the later one, and `zt upgrade --to 1.13.0` selected nothing at all.
|
|
83
|
+
The migration for the release that broke `serve --dev` was unreachable by every app
|
|
84
|
+
crossing it.
|
|
85
|
+
|
|
86
|
+
Split into `deprecated-aliases` at 1.13.0 and `base-model-rename` at 2.0.0. An app moving
|
|
87
|
+
to any 1.13+ release now gets the command rewrites, and one crossing to 2.0 no longer has
|
|
88
|
+
`BaseModel` renamed as a side effect of a command alias.
|
|
89
|
+
|
|
11
90
|
## [1.14.2] — 2026-09-01
|
|
12
91
|
|
|
13
92
|
### Fixed
|
package/api-surface.md
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
<!-- AUTO-GENERATED by scripts/api-surface.ts. Do not edit by hand.
|
|
4
4
|
Run `bun run api:surface` to regenerate after an intentional API change. -->
|
|
5
5
|
|
|
6
|
+
> Not a guide — this is a flat, alphabetical snapshot used to detect unintended
|
|
7
|
+
> API changes. For how to use these, read the [documentation](https://zerotal.dev/docs).
|
|
8
|
+
|
|
6
9
|
## . `(./src/index.ts)`
|
|
7
10
|
|
|
8
11
|
class AppBooted = {
|
package/package.json
CHANGED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The registry of generated-type files that `bun zt route:types` refreshes.
|
|
3
|
+
*
|
|
4
|
+
* Route names are not the only thing a Zerotal app generates from its own
|
|
5
|
+
* filesystem — `@zerotal/inertia` emits a page registry that gives
|
|
6
|
+
* `Inertia.render(name, props)` its typed page names. Both answer the same
|
|
7
|
+
* question ("what does the tree contain, as types?"), both go stale the moment
|
|
8
|
+
* a file is added, and `route:types` is the command whose name says it fixes
|
|
9
|
+
* that. It used to fix only half of it: adding a page and running `route:types`
|
|
10
|
+
* left the page registry stale, and the resulting error —
|
|
11
|
+
*
|
|
12
|
+
* TS2345: Argument of type '"ops/orders"' is not assignable to 'PageName'
|
|
13
|
+
*
|
|
14
|
+
* — reads as "you typed the name wrong" rather than "nothing has regenerated
|
|
15
|
+
* the registry since you added the file", which sends you looking at the name.
|
|
16
|
+
*
|
|
17
|
+
* A view package registers its routine here from provider boot, so
|
|
18
|
+
* `@zerotal/core` never imports `@zerotal/inertia` (which would be circular).
|
|
19
|
+
* This mirrors `registerDevBuildHook` in `../dev/DevBuildHook.ts`, for the same
|
|
20
|
+
* reason and with the same shape.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What one generator wrote, for the command's summary output.
|
|
25
|
+
*
|
|
26
|
+
* @internal Part of the {@link registerTypeGenerator} contract, like
|
|
27
|
+
* {@link TypeGeneratorFn} — framework-package plumbing, not app-facing API.
|
|
28
|
+
*/
|
|
29
|
+
export interface TypeGeneratorResult {
|
|
30
|
+
/** The file written, relative to the project root. */
|
|
31
|
+
file: string;
|
|
32
|
+
/** What it contains, phrased for a summary line (e.g. `12 pages`). */
|
|
33
|
+
summary: string;
|
|
34
|
+
/**
|
|
35
|
+
* Whether the file on disk differed from what the routine produced. Drives
|
|
36
|
+
* `route:types --check`, and lets the command say "Unchanged" rather than
|
|
37
|
+
* claiming a write it did not need to make.
|
|
38
|
+
*/
|
|
39
|
+
changed: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A generated-types routine. `check` asks it to compare against disk and report,
|
|
44
|
+
* without writing — the CI gate that keeps a committed generated file honest.
|
|
45
|
+
*
|
|
46
|
+
* @internal
|
|
47
|
+
*/
|
|
48
|
+
export type TypeGeneratorFn = (options: { check: boolean }) => Promise<TypeGeneratorResult>;
|
|
49
|
+
|
|
50
|
+
const _generators = new Map<string, TypeGeneratorFn>();
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @internal Exported for view packages to wire their codegen into `route:types`.
|
|
54
|
+
*
|
|
55
|
+
* Register a generated-types routine under a package name. Registering the same
|
|
56
|
+
* name twice replaces the earlier routine, so a provider may safely register on
|
|
57
|
+
* every boot.
|
|
58
|
+
*/
|
|
59
|
+
export function registerTypeGenerator(name: string, fn: TypeGeneratorFn): void {
|
|
60
|
+
_generators.set(name, fn);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** @internal Forget every registered routine (tests only). */
|
|
64
|
+
export function _resetTypeGenerators(): void {
|
|
65
|
+
_generators.clear();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @internal Run every registered routine, in registration order.
|
|
70
|
+
*
|
|
71
|
+
* One routine throwing does not stop the others — a broken page registry should
|
|
72
|
+
* not cost you your route types — but the error is returned rather than
|
|
73
|
+
* swallowed, so the command can report it and exit non-zero.
|
|
74
|
+
*/
|
|
75
|
+
export async function _runTypeGenerators(options: {
|
|
76
|
+
check: boolean;
|
|
77
|
+
}): Promise<{ name: string; result?: TypeGeneratorResult; error?: unknown }[]> {
|
|
78
|
+
const outcomes: { name: string; result?: TypeGeneratorResult; error?: unknown }[] = [];
|
|
79
|
+
for (const [name, fn] of _generators) {
|
|
80
|
+
try {
|
|
81
|
+
outcomes.push({ name, result: await fn(options) });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
outcomes.push({ name, error });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return outcomes;
|
|
87
|
+
}
|
|
@@ -2,25 +2,34 @@ import { Command } from "../Command.ts";
|
|
|
2
2
|
import type { FlagDef } from "../Command.ts";
|
|
3
3
|
import { Router } from "../../router/Router.ts";
|
|
4
4
|
import { writeRouteTypes, ROUTE_TYPES_FILE } from "../../router/routeTypes.ts";
|
|
5
|
+
import { _runTypeGenerators } from "../TypeGenerators.ts";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
|
-
* `bun zt route:types` —
|
|
8
|
-
* map that makes
|
|
8
|
+
* `bun zt route:types` — refresh every type file the app generates from its own
|
|
9
|
+
* filesystem: `types/routes.generated.ts`, the name → pattern map that makes
|
|
10
|
+
* `route()` type-checked, plus whatever the installed view packages register
|
|
11
|
+
* (`@zerotal/inertia` adds its page registry, which types `Inertia.render`).
|
|
12
|
+
*
|
|
13
|
+
* It generates more than routes because a developer who adds a page and gets a
|
|
14
|
+
* type error about its name reaches for the command whose name says it
|
|
15
|
+
* generates types — and used to get the same error back, having regenerated the
|
|
16
|
+
* wrong half. Both files answer "what does the tree contain, as types?", and
|
|
17
|
+
* both go stale on the same edits.
|
|
9
18
|
*
|
|
10
19
|
* Boots the application (`needsApp`) and reads `Router.namedRoutes`, so routes
|
|
11
20
|
* a provider registers programmatically and names set via a route file's
|
|
12
21
|
* `export const meta` are included — not just what the file-router's naming
|
|
13
22
|
* convention would produce.
|
|
14
23
|
*
|
|
15
|
-
* Commit the generated
|
|
16
|
-
* `zt dev` refreshes
|
|
17
|
-
* disk no longer matches
|
|
24
|
+
* Commit the generated files: editors and CI need them without booting the app.
|
|
25
|
+
* `zt dev` refreshes them on every restart, and `--check` fails when a file on
|
|
26
|
+
* disk no longer matches, which is the CI gate that keeps them honest.
|
|
18
27
|
*
|
|
19
28
|
* @category Diagnostics
|
|
20
29
|
*/
|
|
21
30
|
export class RouteTypesCommand extends Command {
|
|
22
31
|
static commandName = "route:types";
|
|
23
|
-
static description = "Generate
|
|
32
|
+
static description = "Generate the app's type files (routes, and any view package's registry)";
|
|
24
33
|
static needsApp = true;
|
|
25
34
|
static args = [];
|
|
26
35
|
static flags: FlagDef[] = [
|
|
@@ -35,23 +44,51 @@ export class RouteTypesCommand extends Command {
|
|
|
35
44
|
async run(): Promise<void> {
|
|
36
45
|
const check = this.flags["check"] as boolean;
|
|
37
46
|
|
|
38
|
-
const
|
|
47
|
+
const routes = await writeRouteTypes(Router.namedRoutes, { check });
|
|
48
|
+
|
|
49
|
+
// The routes file, then whatever the installed view packages register — the
|
|
50
|
+
// Inertia page registry, today. Collected into one list so the command reports
|
|
51
|
+
// every generated file the same way, and so a failure in one does not cost
|
|
52
|
+
// you the other: half-regenerated types are how this got confusing to begin with.
|
|
53
|
+
const files: { file: string; summary: string; changed: boolean }[] = [
|
|
54
|
+
{
|
|
55
|
+
file: ROUTE_TYPES_FILE,
|
|
56
|
+
summary: `${routes.count} named routes`,
|
|
57
|
+
changed: routes.changed,
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
let failed = false;
|
|
61
|
+
|
|
62
|
+
for (const { name, result, error } of await _runTypeGenerators({ check })) {
|
|
63
|
+
if (error) {
|
|
64
|
+
failed = true;
|
|
65
|
+
this.error(`${name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (result) files.push(result);
|
|
69
|
+
}
|
|
39
70
|
|
|
40
71
|
if (check) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
72
|
+
const stale = files.filter((f) => f.changed);
|
|
73
|
+
for (const f of files) {
|
|
74
|
+
if (f.changed) this.error(`${f.file} is out of date. Run: bun zt route:types`);
|
|
75
|
+
else this.info(`${f.file} is up to date (${f.summary}).`);
|
|
76
|
+
}
|
|
77
|
+
if (stale.length > 0) {
|
|
78
|
+
throw new Error(`Generated types are out of date: ${stale.map((f) => f.file).join(", ")}`);
|
|
44
79
|
}
|
|
45
|
-
|
|
80
|
+
if (failed) throw new Error("A type generator failed.");
|
|
46
81
|
return;
|
|
47
82
|
}
|
|
48
83
|
|
|
49
|
-
if (
|
|
84
|
+
if (routes.count === 0) {
|
|
50
85
|
this.warn("No named routes found — the generated map is empty.");
|
|
51
86
|
}
|
|
52
87
|
|
|
53
|
-
|
|
54
|
-
`${
|
|
55
|
-
|
|
88
|
+
for (const f of files) {
|
|
89
|
+
this.info(`${f.changed ? "Wrote" : "Unchanged"}: ${f.file} (${f.summary})`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (failed) throw new Error("A type generator failed.");
|
|
56
93
|
}
|
|
57
94
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Command } from "../Command.ts";
|
|
2
2
|
import { bunBinary } from "../../support/runtime.ts";
|
|
3
|
+
import { testEnvOverrides } from "../testEnv.ts";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* `bun zt test [pattern] [flags]` — runs the test suite in the test environment.
|
|
@@ -11,6 +12,9 @@ import { bunBinary } from "../../support/runtime.ts";
|
|
|
11
12
|
* withDatabase() and DB.table() work without manual beforeAll boilerplate.
|
|
12
13
|
* - If @zerotal/testing is not installed the preload is silently skipped —
|
|
13
14
|
* tests that use createTestApp() still work without it.
|
|
15
|
+
* - Resets the driver keys `.env` would otherwise lend the suite (mail, queue,
|
|
16
|
+
* session, cache) to their in-process defaults, and loads `.env.test` when
|
|
17
|
+
* the app has one. See `../testEnv.ts` for why, and `--keep-env` to opt out.
|
|
14
18
|
*
|
|
15
19
|
* All positional args and recognised flags are forwarded to bun test.
|
|
16
20
|
*
|
|
@@ -72,6 +76,12 @@ export class TestCommand extends Command {
|
|
|
72
76
|
description: "Run database/migrations against the test database before the suite",
|
|
73
77
|
default: false,
|
|
74
78
|
},
|
|
79
|
+
{
|
|
80
|
+
name: "keep-env",
|
|
81
|
+
type: "boolean" as const,
|
|
82
|
+
description: "Inherit .env's mail/queue/session/cache drivers instead of resetting them",
|
|
83
|
+
default: false,
|
|
84
|
+
},
|
|
75
85
|
];
|
|
76
86
|
|
|
77
87
|
async run(): Promise<void> {
|
|
@@ -122,7 +132,18 @@ export class TestCommand extends Command {
|
|
|
122
132
|
const timeout = (this.flags["timeout"] as number | undefined) || TestCommand.DEFAULT_TIMEOUT_MS;
|
|
123
133
|
bunArguments.push(`--timeout=${timeout}`);
|
|
124
134
|
|
|
135
|
+
// `.env` is written for `zt dev`; a test run should decide for itself which
|
|
136
|
+
// outbound services it reaches. Reported rather than applied silently — a
|
|
137
|
+
// suite that behaves differently from the same code under `zt dev` has to
|
|
138
|
+
// say so, or the next person debugs the difference instead of reading it.
|
|
139
|
+
const { overrides, neutralised } = this.flags["keep-env"]
|
|
140
|
+
? { overrides: {}, neutralised: [] as string[] }
|
|
141
|
+
: await testEnvOverrides(process.cwd(), Bun.env as Record<string, string | undefined>);
|
|
142
|
+
|
|
125
143
|
this.dim(`APP_ENV=test ZT_DB_URL=${dbUrl} bun ${Bun.version}`);
|
|
144
|
+
if (neutralised.length > 0) {
|
|
145
|
+
this.dim(`Reset for tests: ${neutralised.join(" ")} (keep .env's with --keep-env)`);
|
|
146
|
+
}
|
|
126
147
|
this.dim(`bun ${bunArguments.join(" ")}\n`);
|
|
127
148
|
|
|
128
149
|
// `bunBinary()`, not `"bun"`. The child of a command whose entire job is to run
|
|
@@ -136,6 +157,7 @@ export class TestCommand extends Command {
|
|
|
136
157
|
stderr: "inherit",
|
|
137
158
|
env: {
|
|
138
159
|
...Bun.env,
|
|
160
|
+
...overrides,
|
|
139
161
|
APP_ENV: "test",
|
|
140
162
|
ZT_DB_URL: dbUrl,
|
|
141
163
|
} as Record<string, string | undefined>,
|
|
@@ -60,3 +60,10 @@ export { AssetsBuildCommand } from "./AssetsBuildCommand.ts";
|
|
|
60
60
|
export { AssetsPruneCommand } from "./AssetsPruneCommand.ts";
|
|
61
61
|
export { LintPackagesCommand } from "./LintPackagesCommand.ts";
|
|
62
62
|
export { MakePackageCommand } from "./MakePackageCommand.ts";
|
|
63
|
+
|
|
64
|
+
// Extension point rather than a command: lets a view package add its own
|
|
65
|
+
// generated-types file to `zt route:types`, so one command refreshes every type
|
|
66
|
+
// file an app generates from its file tree. `@zerotal/inertia` registers its page
|
|
67
|
+
// registry here. See ../TypeGenerators.ts.
|
|
68
|
+
export { registerTypeGenerator } from "../TypeGenerators.ts";
|
|
69
|
+
export type { TypeGeneratorFn, TypeGeneratorResult } from "../TypeGenerators.ts";
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The environment `bun zt test` hands its child, and the one thing it takes away.
|
|
3
|
+
*
|
|
4
|
+
* Bun loads `.env` into every process it starts, so a test run inherits the
|
|
5
|
+
* developer's whole local setup — including the driver keys that decide whether
|
|
6
|
+
* a code path talks to something real. A developer with `MAIL_DRIVER=smtp`
|
|
7
|
+
* pointed at a local Postfix gets a suite where every path that sends mail opens
|
|
8
|
+
* an SMTP connection: one confirmed payment that issues an invitation goes from
|
|
9
|
+
* milliseconds to a five-second timeout, and fails only when run with its
|
|
10
|
+
* siblings. A developer with no mail server configured never sees it, which
|
|
11
|
+
* makes it precisely the kind of failure that lands on one person and looks
|
|
12
|
+
* like flakiness to everyone else.
|
|
13
|
+
*
|
|
14
|
+
* So the drivers below are reset for the test child. Not because `.env` is
|
|
15
|
+
* wrong — it is right for `zt dev`, which is what it was written for — but
|
|
16
|
+
* because "which outbound service does this reach" is a question a test run
|
|
17
|
+
* should answer for itself. The app's own config already defaults to the same
|
|
18
|
+
* safe values; it is only `.env` that overrides them.
|
|
19
|
+
*
|
|
20
|
+
* Two things still win over this:
|
|
21
|
+
* - a value set in the shell (`MAIL_DRIVER=smtp bun zt test`), which is
|
|
22
|
+
* someone deliberately testing that path;
|
|
23
|
+
* - a value in `.env.test`, which is the same intent written down. Bun only
|
|
24
|
+
* loads that file when `NODE_ENV=test`, so it is read here explicitly rather
|
|
25
|
+
* than left as a file the docs mention and nothing loads.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Driver keys reset for a test run, and what they are reset to. Each default is
|
|
30
|
+
* the in-process option: no socket, no external service, no shared state between
|
|
31
|
+
* runs — and each is already the app config's own default.
|
|
32
|
+
*/
|
|
33
|
+
export const TEST_SAFE_DRIVERS: Readonly<Record<string, string>> = {
|
|
34
|
+
/** Writes the message to the log instead of opening an SMTP connection. */
|
|
35
|
+
MAIL_DRIVER: "log",
|
|
36
|
+
/** Runs a dispatched job inline, so a test sees its effects without a worker. */
|
|
37
|
+
QUEUE_DRIVER: "sync",
|
|
38
|
+
/** Keeps the session in the cookie rather than reaching for Redis. */
|
|
39
|
+
SESSION_DRIVER: "cookie",
|
|
40
|
+
/** In-process cache, so one test file cannot see another's entries. */
|
|
41
|
+
CACHE_DRIVER: "memory",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parse a dotenv file into a map. Deliberately small: this is used to find out
|
|
46
|
+
* *which keys a file mentions*, not to load anything — Bun has already loaded
|
|
47
|
+
* `.env` by the time this runs, and re-implementing its full semantics here
|
|
48
|
+
* would only produce a second, disagreeing answer.
|
|
49
|
+
*
|
|
50
|
+
* @param text - Raw file contents.
|
|
51
|
+
* @returns Key → value, with surrounding quotes stripped.
|
|
52
|
+
*/
|
|
53
|
+
export function parseDotenv(text: string): Map<string, string> {
|
|
54
|
+
const entries = new Map<string, string>();
|
|
55
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
56
|
+
const line = raw.trim();
|
|
57
|
+
if (line === "" || line.startsWith("#")) continue;
|
|
58
|
+
|
|
59
|
+
const eq = line.indexOf("=");
|
|
60
|
+
if (eq <= 0) continue;
|
|
61
|
+
|
|
62
|
+
const key = line
|
|
63
|
+
.slice(0, eq)
|
|
64
|
+
.trim()
|
|
65
|
+
.replace(/^export\s+/, "");
|
|
66
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
|
67
|
+
|
|
68
|
+
let value = line.slice(eq + 1).trim();
|
|
69
|
+
const quote = value[0];
|
|
70
|
+
if ((quote === '"' || quote === "'") && value.endsWith(quote) && value.length > 1) {
|
|
71
|
+
value = value.slice(1, -1);
|
|
72
|
+
} else {
|
|
73
|
+
// Unquoted values run to a trailing comment, matching dotenv.
|
|
74
|
+
value = value.replace(/\s+#.*$/, "");
|
|
75
|
+
}
|
|
76
|
+
entries.set(key, value);
|
|
77
|
+
}
|
|
78
|
+
return entries;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Read and parse a dotenv file, or an empty map when it does not exist. */
|
|
82
|
+
async function _read(path: string): Promise<Map<string, string>> {
|
|
83
|
+
const text = await Bun.file(path)
|
|
84
|
+
.text()
|
|
85
|
+
.catch(() => null);
|
|
86
|
+
return text === null ? new Map() : parseDotenv(text);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Work out what to change about the child process's environment.
|
|
91
|
+
*
|
|
92
|
+
* @param cwd - Project root holding the dotenv files.
|
|
93
|
+
* @param env - The parent's environment, as the child would otherwise inherit it.
|
|
94
|
+
* @returns Keys to override, and the subset of those that were neutralised drivers (for reporting).
|
|
95
|
+
*/
|
|
96
|
+
export async function testEnvOverrides(
|
|
97
|
+
cwd: string,
|
|
98
|
+
env: Record<string, string | undefined>,
|
|
99
|
+
): Promise<{ overrides: Record<string, string>; neutralised: string[] }> {
|
|
100
|
+
const [dotenv, dotenvLocal, dotenvTest] = await Promise.all([
|
|
101
|
+
_read(`${cwd}/.env`),
|
|
102
|
+
_read(`${cwd}/.env.local`),
|
|
103
|
+
_read(`${cwd}/.env.test`),
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
const overrides: Record<string, string> = {};
|
|
107
|
+
const neutralised: string[] = [];
|
|
108
|
+
|
|
109
|
+
for (const [key, safe] of Object.entries(TEST_SAFE_DRIVERS)) {
|
|
110
|
+
const current = env[key];
|
|
111
|
+
|
|
112
|
+
// Unset means the app config's default applies, which is already the safe
|
|
113
|
+
// one — setting it anyway would only paper over a config the app changed
|
|
114
|
+
// on purpose.
|
|
115
|
+
if (current === undefined) continue;
|
|
116
|
+
|
|
117
|
+
// Already safe: nothing to say, and nothing to report.
|
|
118
|
+
if (current === safe) continue;
|
|
119
|
+
|
|
120
|
+
// Set in the shell rather than in a file — someone testing this path on
|
|
121
|
+
// purpose. Inferred by comparing against what the files declare, which is
|
|
122
|
+
// the only handle available: Bun merges dotenv into the environment before
|
|
123
|
+
// any of this runs, and does not record where a value came from.
|
|
124
|
+
const fromFile = dotenv.get(key) === current || dotenvLocal.get(key) === current;
|
|
125
|
+
if (!fromFile) continue;
|
|
126
|
+
|
|
127
|
+
overrides[key] = safe;
|
|
128
|
+
neutralised.push(`${key}=${safe}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// `.env.test` last: it is the app saying what a test run should use, which
|
|
132
|
+
// outranks both the inherited value and the default chosen here.
|
|
133
|
+
for (const [key, value] of dotenvTest) {
|
|
134
|
+
overrides[key] = value;
|
|
135
|
+
const index = neutralised.findIndex((entry) => entry.startsWith(`${key}=`));
|
|
136
|
+
if (index !== -1) neutralised.splice(index, 1);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return { overrides, neutralised };
|
|
140
|
+
}
|
package/src/doctor/AppDoctor.ts
CHANGED
|
@@ -351,22 +351,88 @@ const corsWildcardCheck: DoctorCheck = {
|
|
|
351
351
|
* false and has never had production auto-detection. A deployment that never set it
|
|
352
352
|
* serves no `Strict-Transport-Security` at all, so a visitor's first request each
|
|
353
353
|
* time stays downgradeable to plain HTTP.
|
|
354
|
+
*
|
|
355
|
+
* The header is the only thing that settles this, and the header is not always the
|
|
356
|
+
* app's to send. Reading `app.secureHeaders.secure` and inferring the response from
|
|
357
|
+
* it failed on the most ordinary production topology there is: a reverse proxy
|
|
358
|
+
* terminating TLS and setting HSTS itself. The app's config is then correctly
|
|
359
|
+
* false, the header is correctly present, and the check said the site could be
|
|
360
|
+
* downgraded — a wrong answer on a check that is otherwise reliable enough to be
|
|
361
|
+
* read, which is how a check becomes the line people learn to skip.
|
|
362
|
+
*
|
|
363
|
+
* So it probes when it can (`--url`, or `app.url` on a deployment: the response is
|
|
364
|
+
* the only thing that answers the question) and, when it cannot, says what it
|
|
365
|
+
* actually knows — the app does not send HSTS — rather than what it was guessing.
|
|
354
366
|
*/
|
|
355
367
|
const secureHeadersCheck: DoctorCheck = {
|
|
356
368
|
id: "secure-headers",
|
|
357
369
|
label: "Secure headers",
|
|
358
|
-
run(app) {
|
|
370
|
+
async run(app) {
|
|
359
371
|
const secure = _config(app, "app.secureHeaders.secure") === true;
|
|
360
372
|
if (secure) return ok("HSTS enabled.");
|
|
361
373
|
if (!_isProductionEnv(app)) return ok("HSTS off — expected outside a deployment.");
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
374
|
+
|
|
375
|
+
// One request to the public URL, which is the only view that includes the
|
|
376
|
+
// proxy. A loopback `app.url` on a deployment is its own finding, already
|
|
377
|
+
// reported by the transport-origins check.
|
|
378
|
+
const url = _config(app, "app.url");
|
|
379
|
+
if (typeof url === "string" && url !== "" && !_isLoopback(url)) {
|
|
380
|
+
const observed = await _observedHsts(url);
|
|
381
|
+
if (observed === true) {
|
|
382
|
+
return ok(`HSTS served by the proxy (${url}) — the app does not set it.`);
|
|
383
|
+
}
|
|
384
|
+
if (observed === false) {
|
|
385
|
+
return fail(
|
|
386
|
+
`No Strict-Transport-Security on ${url}: neither the app nor anything in ` +
|
|
387
|
+
`front of it sends it, so the first request of each visit can be downgraded ` +
|
|
388
|
+
`to HTTP.`,
|
|
389
|
+
"Set app.secureHeaders.secure: true in config/app.ts, or add the header at the proxy.",
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
// Unreachable: the deployment may not be up yet, or `doctor` may be running
|
|
393
|
+
// somewhere that cannot see the public URL. That is not evidence either way.
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return warn(
|
|
397
|
+
"app.secureHeaders.secure is not true, so the app does not send " +
|
|
398
|
+
"Strict-Transport-Security. If a reverse proxy terminates TLS it may be sending " +
|
|
399
|
+
"it — confirm there; if nothing does, the first request of each visit can be " +
|
|
400
|
+
"downgraded to HTTP.",
|
|
401
|
+
`Check with: curl -sI ${typeof url === "string" && url !== "" ? url : "https://your-site"} | grep -i strict-transport-security\n` +
|
|
402
|
+
" Then either set app.secureHeaders.secure: true in config/app.ts, or leave it " +
|
|
403
|
+
"to the proxy — but not both, which sends the header twice.",
|
|
366
404
|
);
|
|
367
405
|
},
|
|
368
406
|
};
|
|
369
407
|
|
|
408
|
+
/**
|
|
409
|
+
* Whether the deployed site answers with `Strict-Transport-Security`.
|
|
410
|
+
*
|
|
411
|
+
* `null` when the request could not be made at all, which is different from "no
|
|
412
|
+
* header" and must not be reported as one.
|
|
413
|
+
*/
|
|
414
|
+
async function _observedHsts(url: string): Promise<boolean | null> {
|
|
415
|
+
try {
|
|
416
|
+
const response = await fetch(url, {
|
|
417
|
+
method: "HEAD",
|
|
418
|
+
// `redirect: "manual"`: the redirect's own response is the one the proxy wrote,
|
|
419
|
+
// and following it would report the destination's headers instead.
|
|
420
|
+
redirect: "manual",
|
|
421
|
+
// `doctor` is what people run immediately after a deploy, so it may not hang on
|
|
422
|
+
// an unreachable host — a diagnostic that stops responding is worse than one
|
|
423
|
+
// that says it could not tell. A timeout lands in the `catch` as `null`, which
|
|
424
|
+
// is already "no evidence either way" rather than "no header".
|
|
425
|
+
signal: AbortSignal.timeout(_PROBE_TIMEOUT_MS),
|
|
426
|
+
});
|
|
427
|
+
return response.headers.get("strict-transport-security") !== null;
|
|
428
|
+
} catch {
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** How long the HSTS probe waits before reporting that it could not tell. */
|
|
434
|
+
const _PROBE_TIMEOUT_MS = 5_000;
|
|
435
|
+
|
|
370
436
|
/**
|
|
371
437
|
* A rate limiter behind a proxy that was never told about the proxy keys every
|
|
372
438
|
* visitor to the same bucket, because the socket address is the proxy's. It then
|
package/src/index.ts
CHANGED
|
@@ -245,6 +245,9 @@ export type {
|
|
|
245
245
|
} from "./router/Route.ts";
|
|
246
246
|
export { scanFileRoutes, registerFileRouteResolver } from "./router/FileRouter.ts";
|
|
247
247
|
export type { FileRouteResolver, FileRouteContext } from "./router/FileRouter.ts";
|
|
248
|
+
// `registerTypeGenerator` — the hook that lets a view package add its own
|
|
249
|
+
// generated-types file to `zt route:types` — lives on `@zerotal/core/commands`,
|
|
250
|
+
// not here: it is command plumbing, and this barrel is the frozen lean kernel.
|
|
248
251
|
export type {
|
|
249
252
|
FileHandler,
|
|
250
253
|
RouteModule,
|
package/src/router/FileRouter.ts
CHANGED
|
@@ -312,25 +312,73 @@ async function _collectMiddleware(
|
|
|
312
312
|
// sibling routes stayed live and unguarded, with no error and no log. A convention
|
|
313
313
|
// that fails open is worse than one that does not exist.
|
|
314
314
|
const middlewarePath = await _findMiddlewareFile(baseDir, dir);
|
|
315
|
+
// No file is the convention working — most directories have none. Everything past
|
|
316
|
+
// this point is a file somebody wrote in order to guard a subtree, so every way it
|
|
317
|
+
// can fail to apply is loud. These two cases used to share one `catch`, which made
|
|
318
|
+
// "there is no middleware here" and "the middleware is broken" indistinguishable.
|
|
319
|
+
if (!middlewarePath) continue;
|
|
320
|
+
|
|
321
|
+
// Convert to a file:// URL so dynamic import works on Windows (raw
|
|
322
|
+
// absolute paths with backslashes are not valid import specifiers).
|
|
323
|
+
// Append ?t=<reloadId> to bust the module cache on hot-reload.
|
|
324
|
+
const href = pathToFileURL(middlewarePath).href;
|
|
325
|
+
const importUrl = reloadId ? `${href}?t=${reloadId}` : href;
|
|
326
|
+
|
|
327
|
+
let loadedModule: MiddlewareModule & { default?: unknown };
|
|
315
328
|
try {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
+
loadedModule = (await import(importUrl)) as MiddlewareModule & { default?: unknown };
|
|
330
|
+
} catch (error) {
|
|
331
|
+
// A typo, a bad import path, a circular import, a SyntaxError — swallowing any of
|
|
332
|
+
// these left the subtree beneath serving normally with its guard silently gone,
|
|
333
|
+
// and on a hot-reload, gone a second after it was there. Boot stops instead.
|
|
334
|
+
throw new Error(
|
|
335
|
+
`[Zerotal] Failed to import ${middlewarePath}: ` +
|
|
336
|
+
`${error instanceof Error ? error.message : String(error)}\n` +
|
|
337
|
+
`Routes under this directory would serve unguarded, so boot stops here. ` +
|
|
338
|
+
`Fix the file, or delete it if the subtree needs no middleware.`,
|
|
339
|
+
{ cause: error },
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (Array.isArray(loadedModule.middleware)) {
|
|
344
|
+
result.push(...loadedModule.middleware);
|
|
345
|
+
continue;
|
|
329
346
|
}
|
|
347
|
+
|
|
348
|
+
// The file loaded and exports nothing the loader can apply — the same fail-open in
|
|
349
|
+
// a different disguise. `export default` is the common one: every route file in the
|
|
350
|
+
// same directory default-exports its handler, so it is the natural guess, and it
|
|
351
|
+
// used to leave the subtree open while looking exactly like a guarded one. Throwing
|
|
352
|
+
// cannot break a working app, because no shape rejected here has ever applied.
|
|
353
|
+
throw new Error(_middlewareExportError(middlewarePath, loadedModule));
|
|
330
354
|
}
|
|
331
355
|
return result;
|
|
332
356
|
}
|
|
333
357
|
|
|
358
|
+
/**
|
|
359
|
+
* The boot error for a `_middleware` file that exports nothing the loader can apply,
|
|
360
|
+
* naming the specific mistake so the message carries its own fix.
|
|
361
|
+
*/
|
|
362
|
+
function _middlewareExportError(
|
|
363
|
+
path: string,
|
|
364
|
+
loadedModule: MiddlewareModule & { default?: unknown },
|
|
365
|
+
): string {
|
|
366
|
+
const fix = " export const middleware = [YourMiddleware];";
|
|
367
|
+
const head = `[Zerotal] ${path} does not export a \`middleware\` array, so it guards nothing.`;
|
|
368
|
+
|
|
369
|
+
if (loadedModule.default !== undefined) {
|
|
370
|
+
return (
|
|
371
|
+
`${head}\n` +
|
|
372
|
+
`It has a \`default\` export, which this file does not use — route files ` +
|
|
373
|
+
`default-export their handler, but a _middleware file is read by name:\n${fix}`
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
if (loadedModule.middleware !== undefined) {
|
|
377
|
+
return `${head}\n\`middleware\` must be an array, even for a single entry:\n${fix}`;
|
|
378
|
+
}
|
|
379
|
+
return `${head}\nAdd the export, or delete the file if the subtree needs no middleware:\n${fix}`;
|
|
380
|
+
}
|
|
381
|
+
|
|
334
382
|
/**
|
|
335
383
|
* Combine the stacked `_middleware.ts` chain (`base`) with a route file's own
|
|
336
384
|
* `export const middleware` for a specific verb. Array form applies to every method;
|
|
@@ -1,24 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Ledger #4 — retire the deprecated aliases.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Two codemods, not one, because they retire at different times and a codemod
|
|
5
|
+
* carries a single version:
|
|
6
6
|
*
|
|
7
|
-
* -
|
|
8
|
-
*
|
|
9
|
-
* -
|
|
7
|
+
* - {@link deprecatedAliases} at **1.13.0** — `routes:types` → `route:types` and
|
|
8
|
+
* `serve --dev` → `dev`. Both shipped; both fail loudly now.
|
|
9
|
+
* - {@link baseModelRename} at **2.0.0** — `BaseModel` → `Model`. Not shipped;
|
|
10
|
+
* sized on 1 Sep 2026 at 617 api-surface references and moved to 2.0.
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* for a reader even though it resolves identically, and in framework source it
|
|
17
|
-
* genuinely is. Those are reported for a person instead.
|
|
18
|
-
*
|
|
19
|
-
* That asymmetry is the whole reason this is a codemod rather than a
|
|
20
|
-
* find-and-replace: the mixin-composition script learned it the hard way in
|
|
21
|
-
* 1.3.0, where a blind rename broke import specifiers it had not considered.
|
|
12
|
+
* They were one codemod, which forced one version, which took the later of the
|
|
13
|
+
* two — so `zt upgrade --to 1.13.0` selected nothing and offered no help
|
|
14
|
+
* migrating the very release that retired `serve --dev`, while an app crossing to
|
|
15
|
+
* 2.0 would have had its `BaseModel` renamed as a side effect of a command alias.
|
|
16
|
+
* A codemod nobody's upgrade reaches is a codemod that does not exist.
|
|
22
17
|
*/
|
|
23
18
|
import type { Change, Codemod, CodemodResult, Manual, SourceFile } from "../types.ts";
|
|
24
19
|
|
|
@@ -52,10 +47,53 @@ const COMMAND_ALIASES: { find: RegExp; replace: string; label: string }[] = [
|
|
|
52
47
|
},
|
|
53
48
|
];
|
|
54
49
|
|
|
50
|
+
/** The command aliases retired in 1.13.0. Both fail loudly rather than drifting. */
|
|
55
51
|
export const deprecatedAliases: Codemod = {
|
|
56
|
-
version: "
|
|
52
|
+
version: "1.13.0",
|
|
57
53
|
name: "deprecated-aliases",
|
|
58
|
-
description: "Retire
|
|
54
|
+
description: "Retire routes:types and serve --dev in favour of their real names",
|
|
55
|
+
ledger: 4,
|
|
56
|
+
|
|
57
|
+
run(files: SourceFile[]): CodemodResult {
|
|
58
|
+
const changes: Change[] = [];
|
|
59
|
+
|
|
60
|
+
for (const { file, contents } of files) {
|
|
61
|
+
let next = contents;
|
|
62
|
+
const notes: string[] = [];
|
|
63
|
+
|
|
64
|
+
for (const { find, replace, label } of COMMAND_ALIASES) {
|
|
65
|
+
const hits = next.match(find)?.length ?? 0;
|
|
66
|
+
if (hits === 0) continue;
|
|
67
|
+
next = next.replace(find, replace);
|
|
68
|
+
notes.push(`${hits} × ${label}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (next !== contents) changes.push({ file, summary: notes.join(", "), contents: next });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { changes, manual: [] };
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `BaseModel` → `Model`, which has **not shipped** and is scheduled for 2.0.
|
|
80
|
+
*
|
|
81
|
+
* ## What this deliberately does not do
|
|
82
|
+
*
|
|
83
|
+
* **It does not touch `BaseModel` in a type position.** `Model` and `BaseModel`
|
|
84
|
+
* are the same runtime class, so a value-position rename is cosmetic and safe.
|
|
85
|
+
* In a generic bound — `<T extends BaseModel>` — the name may be load-bearing
|
|
86
|
+
* for a reader even though it resolves identically, and in framework source it
|
|
87
|
+
* genuinely is. Those are reported for a person instead.
|
|
88
|
+
*
|
|
89
|
+
* That asymmetry is the whole reason this is a codemod rather than a
|
|
90
|
+
* find-and-replace: the mixin-composition script learned it the hard way in
|
|
91
|
+
* 1.3.0, where a blind rename broke import specifiers it had not considered.
|
|
92
|
+
*/
|
|
93
|
+
export const baseModelRename: Codemod = {
|
|
94
|
+
version: "2.0.0",
|
|
95
|
+
name: "base-model-rename",
|
|
96
|
+
description: "Rename BaseModel to Model at every declaration site",
|
|
59
97
|
ledger: 4,
|
|
60
98
|
|
|
61
99
|
run(files: SourceFile[]): CodemodResult {
|
|
@@ -88,13 +126,6 @@ export const deprecatedAliases: Codemod = {
|
|
|
88
126
|
notes.push("import specifier updated");
|
|
89
127
|
}
|
|
90
128
|
|
|
91
|
-
for (const { find, replace, label } of COMMAND_ALIASES) {
|
|
92
|
-
const hits = next.match(find)?.length ?? 0;
|
|
93
|
-
if (hits === 0) continue;
|
|
94
|
-
next = next.replace(find, replace);
|
|
95
|
-
notes.push(`${hits} × ${label}`);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
129
|
// Type positions are a handover, not a rewrite.
|
|
99
130
|
contents.split("\n").forEach((line, i) => {
|
|
100
131
|
if (!BASE_MODEL_TYPE.test(line)) return;
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
* that rule fails is by everyone assuming there is time.
|
|
9
9
|
*/
|
|
10
10
|
import type { Codemod } from "../types.ts";
|
|
11
|
-
import { deprecatedAliases } from "./deprecated-aliases.ts";
|
|
11
|
+
import { deprecatedAliases, baseModelRename } from "./deprecated-aliases.ts";
|
|
12
12
|
import { clientTaggedTemplate } from "./client-tagged-template.ts";
|
|
13
13
|
|
|
14
|
-
export const CODEMODS: Codemod[] = [clientTaggedTemplate, deprecatedAliases];
|
|
14
|
+
export const CODEMODS: Codemod[] = [clientTaggedTemplate, deprecatedAliases, baseModelRename];
|
|
15
15
|
|
|
16
|
-
export { deprecatedAliases, clientTaggedTemplate };
|
|
16
|
+
export { deprecatedAliases, baseModelRename, clientTaggedTemplate };
|