@zerotal/core 1.11.2 → 1.13.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,32 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.13.0] — 2026-08-31
12
+
13
+ ### Changed — **BREAKING**
14
+
15
+ - **`LockDriver.extend()` is required.** Only affects a custom driver; all three built-in
16
+ ones implement it.
17
+
18
+ It shipped optional in 1.5.0 with `acquire(key, owner, ttl)` as the fallback, and the
19
+ fallback was correct only by coincidence — `acquire` happens to be an owner-guarded refresh
20
+ on every built-in driver, and nothing in the interface said it had to be. A third-party
21
+ driver whose `acquire` takes a _free_ lock, which is the ordinary reading of the word, would
22
+ have had `refresh()` silently take a lock another holder owned. That is the one thing a lock
23
+ exists to prevent, and the contract was quietly assuming its way past it.
24
+
25
+ - **`routes:types` and `serve --dev` are retired**, in favour of `route:types` and `dev`.
26
+ `zt upgrade` rewrites both.
27
+
28
+ `serve --dev` **fails with a message** rather than being ignored. The flag is still declared
29
+ for that reason alone: flag parsing runs non-strict, so deleting it would have left
30
+ `serve --dev` starting a plain server with no watcher and no explanation — a retired flag
31
+ that silently changes what a command does is worse than one that is still there.
32
+
33
+ ### Added
34
+
35
+ - **The `client-tagged-template` codemod**, for the Flow removal above.
36
+
11
37
  ## [1.11.1] — 2026-08-31
12
38
 
13
39
  ### Added
package/api-surface.md CHANGED
@@ -3034,7 +3034,7 @@ interface LockDriver = {
3034
3034
  acquire: (key: string, owner: string, ttlSeconds: number) => Promise<boolean>
3035
3035
  dispose?: () => void
3036
3036
  exists: (key: string) => Promise<boolean>
3037
- extend?: (key: string, owner: string, ttlSeconds: number) => Promise<boolean>
3037
+ extend: (key: string, owner: string, ttlSeconds: number) => Promise<boolean>
3038
3038
  forceRelease: (key: string) => Promise<void>
3039
3039
  release: (key: string, owner: string) => Promise<boolean>
3040
3040
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/core",
3
- "version": "1.11.2",
3
+ "version": "1.13.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -392,7 +392,9 @@ export class CommandRunner {
392
392
 
393
393
  // route:types reads the same booted router, so it is available wherever
394
394
  // route:list is — including in CI containers that only ever run `--check`.
395
- this.register(RouteTypesCommand, ["routes:types"]);
395
+ //
396
+ // The `routes:types` alias was retired in 1.13.0; `zt upgrade` rewrites it.
397
+ this.register(RouteTypesCommand);
396
398
 
397
399
  // Non-web commands (console, worker, test).
398
400
  if (this._app._env !== "web") {
@@ -80,9 +80,10 @@ export class DevCommand extends ServeCommand {
80
80
  ];
81
81
 
82
82
  override async run(): Promise<void> {
83
- // `dev` is `serve --dev` — set the flag the parent branches on rather than
84
- // duplicating its port resolution, asset config and banner.
85
- this.flags["dev"] = true;
83
+ // Set the parent's own switch rather than the `dev` flag, which is now
84
+ // reserved for refusing `serve --dev` by name. Same supervisor, same port
85
+ // resolution, same banner — this subclasses so the two cannot drift.
86
+ this._dev = true;
86
87
  await super.run();
87
88
  }
88
89
  }
@@ -48,9 +48,15 @@ export class ServeCommand extends Command {
48
48
  default: 3000,
49
49
  },
50
50
  {
51
+ // Declared only so it can be refused by name. Removing it outright would be
52
+ // worse than keeping it: `parseFlagsAndArgs` runs with `strict: false`, so an
53
+ // undeclared `--dev` is parsed and dropped, and `serve --dev` would have gone
54
+ // on starting a plain server with no watcher and no message. A retired flag
55
+ // that silently changes what a command does is the failure this release is
56
+ // otherwise about.
51
57
  name: "dev",
52
58
  type: "boolean",
53
- description: "Start in dev mode with file watching, auto-rebuild, and browser reload",
59
+ description: "Retired in 1.13.0 — use `zt dev`",
54
60
  default: false,
55
61
  },
56
62
  {
@@ -74,8 +80,26 @@ export class ServeCommand extends Command {
74
80
  },
75
81
  ];
76
82
 
83
+ /**
84
+ * Set by {@link DevCommand} rather than by a flag, so `serve --dev` can be
85
+ * refused while `dev` keeps the supervisor it subclasses.
86
+ *
87
+ * @internal
88
+ */
89
+ protected _dev = false;
90
+
77
91
  async run(): Promise<void> {
78
- const isDev = this.flags["dev"] as boolean;
92
+ if (this.flags["dev"] === true && !this._dev) {
93
+ this.error(
94
+ "`serve --dev` was retired in 1.13.0. Use `bun zt dev` — it is the same supervisor, " +
95
+ "the same processes and the same restart behaviour, plus the flags that only make " +
96
+ "sense with a deck on screen (--only, --without, --list, --stream).",
97
+ );
98
+ process.exitCode = 1;
99
+ return;
100
+ }
101
+
102
+ const isDev = this._dev;
79
103
  const isWorker = this.flags["dev-worker"] as boolean;
80
104
 
81
105
  // ── DEV ORCHESTRATOR (Process 1) ────────────────────────────────────────
@@ -135,13 +135,12 @@ export class ManagedLock {
135
135
  if (!this._acquired) return false;
136
136
  const ttl = ttlSeconds ?? this._ttl;
137
137
 
138
- // `extend` is optional on the contract so a driver written against 1.x still
139
- // satisfies it. `acquire` is the fallback because on every built-in driver it
140
- // is an owner-guarded refresh — which is exactly what this needs, and is the
141
- // behaviour the memory driver had to be fixed to honour.
142
- const extended = this._driver.extend
143
- ? await this._driver.extend(this._key, this._owner, ttl)
144
- : await this._driver.acquire(this._key, this._owner, ttl);
138
+ // `extend` is required on the contract since 1.13.0, so there is no fallback
139
+ // here any more. The one it replaced — `acquire(key, owner, ttl)` — worked only
140
+ // because every built-in driver happens to make `acquire` an owner-guarded
141
+ // refresh, which the interface never required and a third-party driver had no
142
+ // reason to know.
143
+ const extended = await this._driver.extend(this._key, this._owner, ttl);
145
144
 
146
145
  if (!extended) {
147
146
  this._acquired = false;
@@ -32,11 +32,16 @@ export interface LockDriver {
32
32
  * returns `false` when the key is free or held by someone else, so a holder
33
33
  * that lost the lock learns about it rather than extending a stranger's.
34
34
  *
35
- * **Optional** so a driver written against 1.x still satisfies this interface.
36
- * {@link ManagedLock.refresh} falls back to `acquire(key, owner, ttl)`, which
37
- * is an owner-guarded refresh on all three built-in drivers.
35
+ * **Required since 1.13.0.** It shipped optional in 1.5.0 with `acquire()` as a
36
+ * fallback, and the fallback was only ever correct by coincidence: `acquire` is
37
+ * an owner-guarded refresh on all three built-in drivers, and nothing in this
38
+ * interface said it had to be. A third-party driver whose `acquire` takes a free
39
+ * lock — the ordinary reading of the word — would have had `refresh()` silently
40
+ * steal a lock another holder owned, which is the one thing a lock exists to
41
+ * prevent. Requiring the method turns an assumption the contract never stated
42
+ * into something a driver has to answer.
38
43
  */
39
- extend?(key: string, owner: string, ttlSeconds: number): Promise<boolean>;
44
+ extend(key: string, owner: string, ttlSeconds: number): Promise<boolean>;
40
45
 
41
46
  /** Release background resources (timers, DB connections). */
42
47
  dispose?(): void;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Ledger #5 — `Component.client(…)` gives way to the `$` tagged template.
3
+ *
4
+ * This one is a security fix wearing an ergonomics change's clothes, and that is
5
+ * why it shipped in a minor rather than waiting for 2.0. `client()` takes a
6
+ * **string** and queues it to be evaluated in the browser, so the caller owns the
7
+ * escaping — and its own docblock had to say *never interpolate unescaped user
8
+ * input*, which is a warning about a footgun rather than a design. `$` is a tagged
9
+ * template, so every `${…}` is encoded as a JS literal before it reaches the page
10
+ * and the caller cannot get it wrong by forgetting.
11
+ *
12
+ * ## What this rewrites, and what it hands back
13
+ *
14
+ * A call whose argument is **one template literal or one plain string** is
15
+ * mechanical: the quotes become backticks and `client(` becomes `` $` ``. Nothing
16
+ * about the expression changes.
17
+ *
18
+ * A call whose argument is **anything else** is a handover. `client(expr)` where
19
+ * `expr` is a variable, a concatenation, or a function call is exactly the shape
20
+ * the security note was about — the string was built somewhere else, possibly out
21
+ * of user input, and turning it into `` $`${expr}` `` would change its meaning:
22
+ * the tag would encode the whole expression as a *string literal* rather than
23
+ * splicing it as code. That is a silent behaviour change, so a person decides.
24
+ *
25
+ * Getting this backwards is the failure worth avoiding. A codemod that
26
+ * mechanically wrapped every argument would leave an app that compiles, runs, and
27
+ * quietly does nothing where it used to run a script.
28
+ */
29
+ import type { Change, Codemod, CodemodResult, Manual, SourceFile } from "../types.ts";
30
+
31
+ /**
32
+ * `this.client(` followed by a single template literal with no substitution of its
33
+ * own, to the closing `)`.
34
+ *
35
+ * Backtick-delimited, containing no backtick and no `${`, so the match cannot run
36
+ * past the end of the literal.
37
+ *
38
+ * A **bare `$` is allowed, and has to be.** `$refs`, `$el` and the `$flow` magics
39
+ * are the whole reason anyone calls this — the method's own docblock example is
40
+ * `` this.client(`$refs.titleInput.focus()`) ``. An earlier draft excluded `$`
41
+ * outright to keep `${` out, which skipped every realistic call site while passing
42
+ * a test suite written around `alert(1)`.
43
+ */
44
+ const PLAIN_TEMPLATE = /\bthis\.client\(\s*`((?:[^`\\$]|\$(?!\{))*)`\s*\)/g;
45
+
46
+ /**
47
+ * `this.client('…')` / `this.client("…")` with no escape, no backtick and no `${`.
48
+ *
49
+ * `${` matters more here than in the template case: promoting a quoted string to a
50
+ * template literal turns a literal `${…}` into an interpolation, which is a change
51
+ * in meaning rather than in syntax.
52
+ */
53
+ const PLAIN_STRING = /\bthis\.client\(\s*(['"])((?:(?!\1)(?:[^\\`$]|\$(?!\{)))*)\1\s*\)/g;
54
+
55
+ /** Any remaining `this.client(`, for the handover pass. */
56
+ const ANY_CALL = /\bthis\.client\(/;
57
+
58
+ export const clientTaggedTemplate: Codemod = {
59
+ version: "1.13.0",
60
+ name: "client-tagged-template",
61
+ description: "Rewrite this.client(…) to the $ tagged template, which encodes interpolations",
62
+ ledger: 5,
63
+
64
+ run(files: SourceFile[]): CodemodResult {
65
+ const changes: Change[] = [];
66
+ const manual: Manual[] = [];
67
+
68
+ for (const { file, contents } of files) {
69
+ let next = contents;
70
+ const notes: string[] = [];
71
+
72
+ const templateHits = next.match(PLAIN_TEMPLATE)?.length ?? 0;
73
+ if (templateHits > 0) {
74
+ next = next.replace(PLAIN_TEMPLATE, (_m, body: string) => `this.$\`${body}\``);
75
+ notes.push(`${templateHits} × \`client(\\\`…\\\`)\` → \`$\\\`…\\\`\``);
76
+ }
77
+
78
+ const stringHits = next.match(PLAIN_STRING)?.length ?? 0;
79
+ if (stringHits > 0) {
80
+ // The body carried no backtick or `${`, so promoting the quotes cannot
81
+ // change what the expression means.
82
+ next = next.replace(PLAIN_STRING, (_m, _q: string, body: string) => `this.$\`${body}\``);
83
+ notes.push(`${stringHits} × \`client("…")\` → \`$\\\`…\\\`\``);
84
+ }
85
+
86
+ // Whatever is left took an argument this cannot read.
87
+ next.split("\n").forEach((line, i) => {
88
+ if (!ANY_CALL.test(line)) return;
89
+ manual.push({
90
+ file,
91
+ line: i + 1,
92
+ text: line.trim(),
93
+ reason:
94
+ "`client()` with an argument that is not a single literal — a variable, a " +
95
+ "concatenation, a call. This is the shape the security note was about, so it is " +
96
+ "worth reading rather than rewriting: if the string is built from user input, the " +
97
+ "fix is to interpolate through `$` so the value is encoded, not to wrap the " +
98
+ "finished string. Wrapping it as `` $`${expr}` `` would encode the whole thing as a " +
99
+ "string literal and stop running it as code.",
100
+ });
101
+ });
102
+
103
+ if (next !== contents) changes.push({ file, summary: notes.join(", "), contents: next });
104
+ }
105
+
106
+ return { changes, manual };
107
+ },
108
+ };
@@ -9,7 +9,8 @@
9
9
  */
10
10
  import type { Codemod } from "../types.ts";
11
11
  import { deprecatedAliases } from "./deprecated-aliases.ts";
12
+ import { clientTaggedTemplate } from "./client-tagged-template.ts";
12
13
 
13
- export const CODEMODS: Codemod[] = [deprecatedAliases];
14
+ export const CODEMODS: Codemod[] = [clientTaggedTemplate, deprecatedAliases];
14
15
 
15
- export { deprecatedAliases };
16
+ export { deprecatedAliases, clientTaggedTemplate };