@moku-labs/worker 0.3.1 → 0.5.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/dist/cli.cjs CHANGED
@@ -24,10 +24,380 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  const require_storage = require("./storage-CgXl-dUA.cjs");
25
25
  let _moku_labs_common_cli = require("@moku-labs/common/cli");
26
26
  let node_child_process = require("node:child_process");
27
- let node_fs_promises = require("node:fs/promises");
27
+ let node_fs = require("node:fs");
28
28
  let node_path = require("node:path");
29
29
  node_path = __toESM(node_path, 1);
30
- let node_fs = require("node:fs");
30
+ let node_fs_promises = require("node:fs/promises");
31
+ //#region src/plugins/deploy/auth/permissions.ts
32
+ /** Permission groups every deploy needs, regardless of resources. */
33
+ const ALWAYS = [{
34
+ group: "Account · Workers Scripts",
35
+ scope: "Edit",
36
+ reason: "deploy",
37
+ inBaseTemplate: true
38
+ }, {
39
+ group: "Account · Account Settings",
40
+ scope: "Read",
41
+ reason: "account",
42
+ inBaseTemplate: true
43
+ }];
44
+ /**
45
+ * Per-resource-kind permission group. `do` needs nothing extra (Durable Objects ship with the
46
+ * Worker script, covered by Workers Scripts · Edit). `d1`/`queue` are NOT in the stock template.
47
+ */
48
+ const BY_KIND = {
49
+ kv: {
50
+ group: "Account · Workers KV Storage",
51
+ scope: "Edit",
52
+ reason: "kv",
53
+ inBaseTemplate: true
54
+ },
55
+ r2: {
56
+ group: "Account · Workers R2 Storage",
57
+ scope: "Edit",
58
+ reason: "r2",
59
+ inBaseTemplate: true
60
+ },
61
+ d1: {
62
+ group: "Account · D1",
63
+ scope: "Edit",
64
+ reason: "d1",
65
+ inBaseTemplate: false
66
+ },
67
+ queue: {
68
+ group: "Account · Queues",
69
+ scope: "Edit",
70
+ reason: "queue",
71
+ inBaseTemplate: false
72
+ },
73
+ do: void 0
74
+ };
75
+ /**
76
+ * Derive the Cloudflare API token requirement from an app manifest: the full permission set plus
77
+ * the subset that must be ADDED to the stock "Edit Cloudflare Workers" template.
78
+ *
79
+ * @param manifest - The assembled deploy manifest.
80
+ * @returns The token requirement (base template, full required set, and groups to add).
81
+ * @example
82
+ * ```ts
83
+ * const { toAdd } = requiredToken({ name: "w", compatibilityDate: "…", resources: [{ kind: "d1", binding: "DB" }] });
84
+ * // toAdd → [{ group: "Account · D1", scope: "Edit", … }]
85
+ * ```
86
+ */
87
+ const requiredToken = (manifest) => {
88
+ const required = [...ALWAYS];
89
+ const seen = new Set(required.map((permission) => permission.group));
90
+ for (const resource of manifest.resources) {
91
+ const permission = BY_KIND[resource.kind];
92
+ if (permission !== void 0 && !seen.has(permission.group)) {
93
+ required.push(permission);
94
+ seen.add(permission.group);
95
+ }
96
+ }
97
+ return {
98
+ base: "Edit Cloudflare Workers",
99
+ required,
100
+ toAdd: required.filter((permission) => !permission.inBaseTemplate)
101
+ };
102
+ };
103
+ /** Permission every CI/automation redeploy needs: ship the Worker script. */
104
+ const CI_ALWAYS = [{
105
+ group: "Account · Workers Scripts",
106
+ scope: "Edit",
107
+ reason: "deploy",
108
+ inBaseTemplate: true
109
+ }];
110
+ /**
111
+ * Per-resource-kind permission for the CI/automation token. After a first LOCAL deploy has
112
+ * provisioned everything, CI only needs to LIST existing infra (the idempotent preflight) and
113
+ * ship — so data resources drop to `Read`; R2 stays `Edit` because asset upload writes objects.
114
+ */
115
+ const CI_BY_KIND = {
116
+ kv: {
117
+ group: "Account · Workers KV Storage",
118
+ scope: "Read",
119
+ reason: "kv (preflight)",
120
+ inBaseTemplate: true
121
+ },
122
+ r2: {
123
+ group: "Account · Workers R2 Storage",
124
+ scope: "Edit",
125
+ reason: "r2 (asset upload)",
126
+ inBaseTemplate: true
127
+ },
128
+ d1: {
129
+ group: "Account · D1",
130
+ scope: "Read",
131
+ reason: "d1 (preflight)",
132
+ inBaseTemplate: false
133
+ },
134
+ queue: {
135
+ group: "Account · Queues",
136
+ scope: "Read",
137
+ reason: "queue (preflight)",
138
+ inBaseTemplate: false
139
+ },
140
+ do: void 0
141
+ };
142
+ /**
143
+ * Derive the REDUCED Cloudflare API token for CI/automation redeploys, from the same manifest.
144
+ * Assumes a prior LOCAL deploy already provisioned the infra, so CI never creates: data resources
145
+ * need only `Read` (the idempotent preflight lists them), R2 keeps `Edit` for asset upload, and no
146
+ * `Account Settings · Read` is needed because CI pins `CLOUDFLARE_ACCOUNT_ID`. Pure: no network.
147
+ *
148
+ * @param manifest - The assembled deploy manifest.
149
+ * @returns The minimum permission groups for a CI redeploy token (deduped, manifest-scoped).
150
+ * @example
151
+ * ```ts
152
+ * const groups = ciToken({ name: "w", compatibilityDate: "…", resources: [{ kind: "d1", binding: "DB" }] });
153
+ * // → [Workers Scripts·Edit, D1·Read]
154
+ * ```
155
+ */
156
+ const ciToken = (manifest) => {
157
+ const groups = [...CI_ALWAYS];
158
+ const seen = new Set(groups.map((permission) => permission.group));
159
+ for (const resource of manifest.resources) {
160
+ const permission = CI_BY_KIND[resource.kind];
161
+ if (permission !== void 0 && !seen.has(permission.group)) {
162
+ groups.push(permission);
163
+ seen.add(permission.group);
164
+ }
165
+ }
166
+ return groups;
167
+ };
168
+ //#endregion
169
+ //#region src/plugins/deploy/auth/setup.ts
170
+ /** Cloudflare's dashboard path for creating API tokens. */
171
+ const TOKENS_URL = "https://dash.cloudflare.com/profile/api-tokens";
172
+ /**
173
+ * Render the FULL local-first token section (the deploy that provisions everything): the permission
174
+ * table flagging template-missing rows, the template + "add these" steps, and the `.env.local` lines.
175
+ *
176
+ * @param requirement - The full token requirement (from requiredToken()).
177
+ * @returns The local-first section lines.
178
+ * @example
179
+ * ```ts
180
+ * const lines = localSection(requiredToken(manifest));
181
+ * ```
182
+ */
183
+ const localSection = (requirement) => {
184
+ const permissionRows = requirement.required.map((permission) => {
185
+ const flag = permission.inBaseTemplate ? "" : " <- add to template";
186
+ return ` - ${permission.group} : ${permission.scope} (${permission.reason})${flag}`;
187
+ });
188
+ const step3 = requirement.toAdd.length > 0 ? [` 3. Under Permissions, ADD: ${requirement.toAdd.map((permission) => `${permission.group.replace("Account · ", "")} -> ${permission.scope}`).join(", ")}`, " (the template omits these; everything else is already included)"] : [` 3. The "${requirement.base}" template covers everything — no changes needed.`];
189
+ return [
190
+ "LOCAL — first deploy (provisions infra). A Cloudflare API token with these permissions:",
191
+ "",
192
+ ...permissionRows,
193
+ "",
194
+ "Fastest path:",
195
+ ` 1. ${TOKENS_URL} -> Create Token`,
196
+ ` 2. Start from the "${requirement.base}" template.`,
197
+ ...step3,
198
+ " 4. Account Resources -> Include -> your account.",
199
+ " 5. Create the token, copy it, then add it to .env.local:",
200
+ " CLOUDFLARE_API_TOKEN=<paste your token>",
201
+ " CLOUDFLARE_ACCOUNT_ID=<your account id>",
202
+ " 6. Verify it with `auth` (app.deploy.verifyAuth())."
203
+ ];
204
+ };
205
+ /**
206
+ * Render the REDUCED CI/automation token section (redeploy-only): the scoped permission table plus
207
+ * the CI-secret + account-pin steps.
208
+ *
209
+ * @param groups - The CI permission groups (from ciToken()).
210
+ * @returns The CI section lines.
211
+ * @example
212
+ * ```ts
213
+ * const lines = ciSection(ciToken(manifest));
214
+ * ```
215
+ */
216
+ const ciSection = (groups) => {
217
+ return [
218
+ "CI — automation redeploy (infra already provisioned by a local deploy). A SCOPED token with:",
219
+ "",
220
+ ...groups.map((permission) => ` - ${permission.group} : ${permission.scope} (${permission.reason})`),
221
+ "",
222
+ ` 1. ${TOKENS_URL} -> Create Token -> Create Custom Token.`,
223
+ " 2. Add exactly the permissions above (Read, not Edit, on data resources — CI never creates).",
224
+ " 3. Account Resources -> Include -> your account.",
225
+ " 4. Store it as the CLOUDFLARE_API_TOKEN secret in CI, and PIN the account so no account",
226
+ " lookup (and no Account Settings -> Read) is needed:",
227
+ " CLOUDFLARE_ACCOUNT_ID=<your account id>",
228
+ " CI reuses the same idempotent pipeline — it lists existing infra and ships. To let CI also",
229
+ " CREATE missing infra (self-heal), give it the LOCAL token above instead."
230
+ ];
231
+ };
232
+ /**
233
+ * Render the `auth setup` instructions from the app manifest: the FULL local-first token (provisions
234
+ * everything) followed by the REDUCED CI/automation token (redeploy-only).
235
+ *
236
+ * @param manifest - The assembled deploy manifest.
237
+ * @returns A multi-line instruction string covering both tokens.
238
+ * @example
239
+ * ```ts
240
+ * const text = tokenInstructions(manifest);
241
+ * ```
242
+ */
243
+ const tokenInstructions = (manifest) => [
244
+ ...localSection(requiredToken(manifest)),
245
+ "",
246
+ ...ciSection(ciToken(manifest))
247
+ ].join("\n");
248
+ //#endregion
249
+ //#region src/plugins/deploy/infra/cloudflare.ts
250
+ /**
251
+ * @file deploy plugin — Cloudflare REST discovery client (infra preflight).
252
+ *
253
+ * Lists what already exists in a Cloudflare account so the deploy pipeline can create only the
254
+ * missing resources (idempotent provisioning) and recover real ids for existing kv/d1 bindings.
255
+ * Authenticated with the `.env` API token (CLOUDFLARE_API_TOKEN) — never an interactive login.
256
+ * Uses the global `fetch`; node-only, never imported by the runtime Worker bundle.
257
+ */
258
+ const API_BASE = "https://api.cloudflare.com/client/v4";
259
+ /**
260
+ * GET a Cloudflare API path with the bearer token and unwrap the `result`.
261
+ *
262
+ * @param token - The Cloudflare API token (CLOUDFLARE_API_TOKEN).
263
+ * @param path - API path beneath the v4 base (e.g. "/accounts").
264
+ * @returns The unwrapped `result` payload, typed by the caller.
265
+ * @throws {Error} When the HTTP request fails or the API reports `success: false`.
266
+ * @example
267
+ * ```ts
268
+ * const accounts = await cfGet<Array<{ id: string }>>(token, "/accounts");
269
+ * ```
270
+ */
271
+ const cfGet = async (token, path) => {
272
+ const response = await fetch(`${API_BASE}${path}`, { headers: {
273
+ Authorization: `Bearer ${token}`,
274
+ "Content-Type": "application/json"
275
+ } });
276
+ const body = await response.json();
277
+ if (!response.ok || !body.success) {
278
+ const detail = body.errors?.map((error) => error.message).join("; ") || `HTTP ${response.status}`;
279
+ throw new Error(`[moku-worker] Cloudflare API request failed (${path}): ${detail}`);
280
+ }
281
+ return body.result;
282
+ };
283
+ /**
284
+ * Resolve the Cloudflare account (id + display name) accessible to the token. Used when the
285
+ * consumer did not pin CLOUDFLARE_ACCOUNT_ID; the first accessible account is chosen.
286
+ *
287
+ * @param token - The Cloudflare API token.
288
+ * @returns The resolved account id and name.
289
+ * @throws {Error} When the token can access no account.
290
+ * @example
291
+ * ```ts
292
+ * const { id, name } = await resolveAccount(token);
293
+ * ```
294
+ */
295
+ const resolveAccount = async (token) => {
296
+ const first = (await cfGet(token, "/accounts"))[0];
297
+ if (!first) throw new Error("[moku-worker] No Cloudflare account is accessible with this API token.");
298
+ return {
299
+ id: first.id,
300
+ name: first.name
301
+ };
302
+ };
303
+ /**
304
+ * Verify a Cloudflare API token via `GET /user/tokens/verify`. Returns its status (`"active"` for
305
+ * a usable token); throws (via cfGet) when the token is rejected outright (401/invalid).
306
+ *
307
+ * @param token - The Cloudflare API token to verify.
308
+ * @returns The token status string reported by Cloudflare.
309
+ * @throws {Error} When the verify request fails (invalid/expired token).
310
+ * @example
311
+ * ```ts
312
+ * const { status } = await verifyToken(token); // status === "active"
313
+ * ```
314
+ */
315
+ const verifyToken = async (token) => {
316
+ return { status: (await cfGet(token, "/user/tokens/verify")).status };
317
+ };
318
+ /**
319
+ * List the resources that already exist in the account, querying ONLY the kinds the app declares
320
+ * (one request per declared kind, in parallel), indexed for the preflight diff. Scoping to the
321
+ * declared kinds keeps the API token minimal — an app with only KV never lists (and so never needs
322
+ * read permission on) D1, R2, or Queues.
323
+ *
324
+ * @param token - The Cloudflare API token.
325
+ * @param accountId - The Cloudflare account id to scope the listings to.
326
+ * @param kinds - The resource kinds present in the manifest (the only kinds queried).
327
+ * @returns The existing resources, indexed by kind (un-queried kinds resolve empty).
328
+ * @throws {Error} When any listing request fails.
329
+ * @example
330
+ * ```ts
331
+ * const existing = await listExisting(token, accountId, new Set(["kv", "d1"]));
332
+ * if (existing.kv.has("SESSIONS")) { ... }
333
+ * ```
334
+ */
335
+ const listExisting = async (token, accountId, kinds) => {
336
+ const base = `/accounts/${accountId}`;
337
+ const [kv, d1, r2, queues] = await Promise.all([
338
+ kinds.has("kv") ? cfGet(token, `${base}/storage/kv/namespaces`) : Promise.resolve([]),
339
+ kinds.has("d1") ? cfGet(token, `${base}/d1/database`) : Promise.resolve([]),
340
+ kinds.has("r2") ? cfGet(token, `${base}/r2/buckets`) : Promise.resolve({}),
341
+ kinds.has("queue") ? cfGet(token, `${base}/queues`) : Promise.resolve([])
342
+ ]);
343
+ return {
344
+ kv: new Map(kv.map((namespace) => [namespace.title, namespace.id])),
345
+ d1: new Map(d1.map((database) => [database.name, database.uuid])),
346
+ r2: new Set((r2.buckets ?? []).map((bucket) => bucket.name)),
347
+ queue: new Set(queues.map((queue) => queue.queue_name))
348
+ };
349
+ };
350
+ //#endregion
351
+ //#region src/plugins/deploy/auth/verify.ts
352
+ /**
353
+ * @file deploy plugin — `.env` token verification + account resolution.
354
+ *
355
+ * Reads CLOUDFLARE_API_TOKEN via ctx.env, verifies it is active against the Cloudflare API, and
356
+ * resolves the account. Emits auth:verified. Throws a branded, actionable error (pointing at
357
+ * `auth setup`) when the token is absent, invalid, or inactive — never an interactive login.
358
+ * Node-only; never imported by the runtime Worker bundle.
359
+ */
360
+ /** Branded hint appended to every auth failure so the user knows the next step. */
361
+ const SETUP_HINT = "Run `auth setup` for the exact token to create.";
362
+ /**
363
+ * Verify the `.env` Cloudflare API token and resolve its account.
364
+ *
365
+ * @param ctx - The deploy plugin context (env + emit).
366
+ * @returns The verified auth status (account + id).
367
+ * @throws {Error} When the token is absent, invalid/expired, or not active.
368
+ * @example
369
+ * ```ts
370
+ * const { account, accountId } = await verifyAuth(ctx);
371
+ * ```
372
+ */
373
+ const verifyAuth = async (ctx) => {
374
+ const token = ctx.env.get("CLOUDFLARE_API_TOKEN");
375
+ if (token === void 0 || token === "") throw new Error(`[moku-worker] CLOUDFLARE_API_TOKEN is not set. ${SETUP_HINT}`);
376
+ let status;
377
+ try {
378
+ ({status} = await verifyToken(token));
379
+ } catch (error) {
380
+ throw new Error(`[moku-worker] Cloudflare API token is invalid or expired. ${SETUP_HINT}`, { cause: error });
381
+ }
382
+ if (status !== "active") throw new Error(`[moku-worker] Cloudflare API token is "${status}", not active. ${SETUP_HINT}`);
383
+ const pinnedAccountId = ctx.env.get("CLOUDFLARE_ACCOUNT_ID");
384
+ const account = pinnedAccountId === void 0 || pinnedAccountId === "" ? await resolveAccount(token) : {
385
+ id: pinnedAccountId,
386
+ name: pinnedAccountId
387
+ };
388
+ ctx.emit("auth:verified", {
389
+ account: account.name,
390
+ accountId: account.id,
391
+ scopes: []
392
+ });
393
+ return {
394
+ ok: true,
395
+ account: account.name,
396
+ accountId: account.id,
397
+ scopes: []
398
+ };
399
+ };
400
+ //#endregion
31
401
  //#region src/plugins/deploy/runner.ts
32
402
  /**
33
403
  * @file deploy plugin — wrangler subprocess wrapper (node:child_process).
@@ -96,31 +466,453 @@ const runWrangler = (args) => new Promise((resolve, reject) => {
96
466
  resolve(args[0] === "deploy" ? extractDeployedUrl(stdout) : stdout);
97
467
  });
98
468
  });
469
+ /**
470
+ * Spawn `wrangler` with the given args, inheriting stdio so its output streams live to the user's
471
+ * terminal (used by the generic passthrough and long-lived commands like `tail`).
472
+ *
473
+ * @param args - Wrangler CLI arguments (e.g. ["kv", "namespace", "list"]).
474
+ * @returns Resolves once wrangler exits successfully.
475
+ * @throws {Error} When wrangler cannot be spawned or exits non-zero.
476
+ * @example
477
+ * ```ts
478
+ * await runWranglerInherit(["kv", "namespace", "list"]);
479
+ * ```
480
+ */
481
+ const runWranglerInherit = (args) => {
482
+ return new Promise((resolve, reject) => {
483
+ const child = (0, node_child_process.spawn)("wrangler", args, { stdio: "inherit" });
484
+ child.on("error", (error) => {
485
+ reject(/* @__PURE__ */ new Error(`[moku-worker] Failed to spawn wrangler.\n ${error.message}`));
486
+ });
487
+ child.on("close", (code) => {
488
+ if (code === 0) {
489
+ resolve();
490
+ return;
491
+ }
492
+ reject(/* @__PURE__ */ new Error(`[moku-worker] wrangler exited with code ${String(code)}.`));
493
+ });
494
+ });
495
+ };
496
+ //#endregion
497
+ //#region src/plugins/deploy/dev/build.ts
498
+ /**
499
+ * @file deploy plugin — dev site-rebuild resolution.
500
+ *
501
+ * Resolves HOW to rebuild the Moku web site on change: the in-process `webBuild` hook (preferred,
502
+ * fast, typed — passed call-time from the consumer's script or set as a config default) → a
503
+ * `buildCommand` shell string → an auto-detected `scripts/build.ts`. When nothing is configured,
504
+ * dev serves the worker only and says so. Subprocesses inherit the parent env by default.
505
+ * Node-only; never imported by the runtime Worker bundle.
506
+ */
507
+ /** Convention build script auto-detected when no webBuild/buildCommand is configured. */
508
+ const AUTO_DETECT = "scripts/build.ts";
509
+ /**
510
+ * Run a shell build command, resolving on a zero exit and rejecting otherwise.
511
+ *
512
+ * @param command - The shell command to run (the consumer's own configured build).
513
+ * @returns Resolves once the command exits successfully.
514
+ * @throws {Error} When the command fails to start or exits non-zero.
515
+ * @example
516
+ * ```ts
517
+ * await runShellBuild("bun run scripts/build.ts");
518
+ * ```
519
+ */
520
+ const runShellBuild = (command) => {
521
+ return new Promise((resolve, reject) => {
522
+ const child = (0, node_child_process.spawn)(command, {
523
+ shell: true,
524
+ stdio: "inherit"
525
+ });
526
+ child.on("error", (error) => {
527
+ reject(/* @__PURE__ */ new Error(`[moku-worker] site build failed to start.\n ${error.message}`));
528
+ });
529
+ child.on("close", (code) => {
530
+ if (code === 0) {
531
+ resolve();
532
+ return;
533
+ }
534
+ reject(/* @__PURE__ */ new Error(`[moku-worker] site build exited with code ${String(code)}.`));
535
+ });
536
+ });
537
+ };
538
+ /**
539
+ * Rebuild the Moku web site using the resolved strategy: the call-time `webBuild` hook (the
540
+ * script-driven path), else the `webBuild` config default, else the `buildCommand` shell string,
541
+ * else an auto-detected `scripts/build.ts`. A hook's result is normalized to a `{ files }` count
542
+ * (0 when the hook reports none, and for the shell path where it is unknown).
543
+ *
544
+ * @param ctx - The deploy plugin context (config + emit).
545
+ * @param webBuild - Optional call-time web build hook (takes precedence over `ctx.config.webBuild`).
546
+ * @returns The rebuilt file count (0 for the shell path / a countless hook).
547
+ * @throws {Error} When the resolved shell build fails.
548
+ * @example
549
+ * ```ts
550
+ * const { files } = await buildSite(ctx, () => web.cli.build());
551
+ * ```
552
+ */
553
+ const buildSite = async (ctx, webBuild) => {
554
+ const hook = webBuild ?? ctx.config.webBuild;
555
+ if (hook !== void 0) return { files: (await hook())?.files ?? 0 };
556
+ const command = ctx.config.buildCommand || ((0, node_fs.existsSync)(AUTO_DETECT) ? `bun run ${AUTO_DETECT}` : "");
557
+ if (command === "") {
558
+ ctx.emit("dev:error", { message: "No site build configured (pass webBuild or set buildCommand); serving worker only." });
559
+ return { files: 0 };
560
+ }
561
+ await runShellBuild(command);
562
+ return { files: 0 };
563
+ };
564
+ //#endregion
565
+ //#region src/plugins/deploy/dev/watch.ts
566
+ /**
567
+ * @file deploy plugin — debounced filesystem watcher for dev.
568
+ *
569
+ * Watches the top-level directories implied by the config globs (recursive) and fires a debounced
570
+ * change callback with the last changed path. Uses node:fs.watch — no extra dependency.
571
+ * Node-only; never imported by the runtime Worker bundle.
572
+ */
573
+ /**
574
+ * Derive the set of top-level directories to watch from glob patterns.
575
+ *
576
+ * @param globs - Watch globs (e.g. ["src/**\/*.ts", "public/**\/*"]).
577
+ * @returns The distinct top-level directories (e.g. ["src", "public"]).
578
+ * @example
579
+ * ```ts
580
+ * watchDirectories(["src/**\/*.ts", "public/**\/*"]); // ["src", "public"]
581
+ * ```
582
+ */
583
+ const watchDirectories = (globs) => {
584
+ const directories = /* @__PURE__ */ new Set();
585
+ for (const glob of globs) {
586
+ const globStart = glob.search(/[*?[{]/u);
587
+ const top = (globStart === -1 ? node_path.default.dirname(glob) : glob.slice(0, globStart)).split(/[/\\]/u).find((segment) => segment !== "") ?? ".";
588
+ directories.add(top);
589
+ }
590
+ return [...directories];
591
+ };
592
+ /**
593
+ * Watch the directories implied by `globs` and fire `onChange` (debounced by `debounceMs`) with
594
+ * the last changed path. Missing directories are skipped silently.
595
+ *
596
+ * @param globs - Watch globs.
597
+ * @param debounceMs - Coalesce rapid changes into one callback within this window.
598
+ * @param onChange - Called with the last changed path after the debounce settles.
599
+ * @returns A handle whose close() stops all watchers and cancels any pending callback.
600
+ * @example
601
+ * ```ts
602
+ * const handle = watchPaths(["src/**\/*.ts"], 120, p => rebuild(p));
603
+ * handle.close();
604
+ * ```
605
+ */
606
+ const watchPaths = (globs, debounceMs, onChange) => {
607
+ let timer;
608
+ let lastPath = "";
609
+ const fire = (changedPath) => {
610
+ lastPath = changedPath;
611
+ if (timer !== void 0) clearTimeout(timer);
612
+ timer = setTimeout(() => {
613
+ onChange(lastPath);
614
+ }, debounceMs);
615
+ };
616
+ const watchers = [];
617
+ for (const directory of watchDirectories(globs)) {
618
+ if (!(0, node_fs.existsSync)(directory)) continue;
619
+ watchers.push((0, node_fs.watch)(directory, { recursive: true }, (_event, filename) => {
620
+ if (filename !== null) fire(node_path.default.join(directory, filename.toString()));
621
+ }));
622
+ }
623
+ return { close: () => {
624
+ if (timer !== void 0) clearTimeout(timer);
625
+ for (const watcher of watchers) watcher.close();
626
+ } };
627
+ };
628
+ //#endregion
629
+ //#region src/plugins/deploy/dev/runner.ts
630
+ /**
631
+ * @file deploy plugin — dev watch/recompile orchestrator.
632
+ *
633
+ * One long-lived session: cold-build the Moku site, optionally apply local D1 migrations, spawn
634
+ * `wrangler dev --live-reload` ONCE, then watch the site sources and rebuild on change (wrangler's
635
+ * asset server live-reloads the browser). Build failures keep the session serving the last good
636
+ * build. Tears down cleanly on SIGINT. Side-effecting work is injected via DevDeps so the
637
+ * orchestration is unit-testable without real processes, watchers, or signals.
638
+ * Node-only; never imported by the runtime Worker bundle.
639
+ */
640
+ /**
641
+ * Spawn the long-lived `wrangler dev` child (inherits the parent env; non-blocking).
642
+ *
643
+ * @param args - The `wrangler dev …` arguments.
644
+ * @returns A handle exposing kill().
645
+ * @example
646
+ * ```ts
647
+ * const child = spawnWranglerDev(["dev", "--port", "8787"]);
648
+ * ```
649
+ */
650
+ const spawnWranglerDev = (args) => {
651
+ const child = (0, node_child_process.spawn)("wrangler", args, { stdio: "inherit" });
652
+ return { kill: () => child.kill() };
653
+ };
654
+ /**
655
+ * Resolve when the user first interrupts the dev session (SIGINT).
656
+ *
657
+ * @returns A promise that settles on the first SIGINT.
658
+ * @example
659
+ * ```ts
660
+ * await waitForSigint();
661
+ * ```
662
+ */
663
+ const waitForSigint = () => {
664
+ return new Promise((resolve) => {
665
+ process.once("SIGINT", () => {
666
+ resolve();
667
+ });
668
+ });
669
+ };
670
+ /**
671
+ * Wall-clock timestamp in ms (extracted so realDevDeps holds only named references).
672
+ *
673
+ * @returns The current time in milliseconds.
674
+ * @example
675
+ * ```ts
676
+ * const t = nowMs();
677
+ * ```
678
+ */
679
+ const nowMs = () => Date.now();
680
+ /**
681
+ * Build the real (side-effecting) dev deps used by api.dev(). Subprocesses inherit the parent env.
682
+ *
683
+ * @returns The production DevDeps (real spawn / fs.watch / SIGINT / Date.now).
684
+ * @example
685
+ * ```ts
686
+ * await runDev(ctx, opts, realDevDeps());
687
+ * ```
688
+ */
689
+ const realDevDeps = () => ({
690
+ build: buildSite,
691
+ runWrangler,
692
+ spawnDev: spawnWranglerDev,
693
+ watch: watchPaths,
694
+ untilSignal: waitForSigint,
695
+ now: nowMs
696
+ });
697
+ /**
698
+ * The d1 binding to migrate locally, when a d1 plugin is present in the app.
699
+ *
700
+ * @param ctx - The deploy plugin context.
701
+ * @returns The d1 binding name, or undefined when no d1 plugin is present.
702
+ * @example
703
+ * ```ts
704
+ * const binding = d1Binding(ctx); // "DB" | undefined
705
+ * ```
706
+ */
707
+ const d1Binding = (ctx) => ctx.has("d1") ? ctx.require(require_storage.d1Plugin).deployManifest().binding : void 0;
708
+ /**
709
+ * Rebuild the site once and announce the result. A failed build keeps the session alive (it just
710
+ * emits dev:error and serves the last good build).
711
+ *
712
+ * @param ctx - The deploy plugin context.
713
+ * @param deps - The injected dev deps.
714
+ * @param changedPath - The path that triggered the rebuild.
715
+ * @param webBuild - Optional call-time web build hook threaded into the rebuild.
716
+ * @returns Resolves once the rebuild attempt completes.
717
+ * @example
718
+ * ```ts
719
+ * await rebuild(ctx, deps, "src/app.tsx", () => web.cli.build());
720
+ * ```
721
+ */
722
+ const rebuild = async (ctx, deps, changedPath, webBuild) => {
723
+ ctx.emit("dev:phase", {
724
+ phase: "rebuild",
725
+ detail: changedPath
726
+ });
727
+ const started = deps.now();
728
+ try {
729
+ const { files } = await deps.build(ctx, webBuild);
730
+ ctx.emit("dev:rebuilt", {
731
+ files,
732
+ ms: deps.now() - started
733
+ });
734
+ } catch (error) {
735
+ ctx.emit("dev:error", { message: error instanceof Error ? error.message : String(error) });
736
+ }
737
+ };
738
+ /**
739
+ * Run a long-lived dev session: cold build → (local d1 migrate) → spawn `wrangler dev` →
740
+ * watch + rebuild on change → teardown on signal.
741
+ *
742
+ * @param ctx - The deploy plugin context (config + emit + require/has).
743
+ * @param opts - Optional options.
744
+ * @param opts.port - Local dev port (default 8787).
745
+ * @param opts.webBuild - Web build hook (re)run on cold build + each change (e.g. `() => web.cli.build()`).
746
+ * @param deps - Injected side effects (real ones from realDevDeps in production).
747
+ * @returns Resolves when the session ends (SIGINT).
748
+ * @example
749
+ * ```ts
750
+ * await runDev(ctx, { port: 8787, webBuild: () => web.cli.build() }, realDevDeps());
751
+ * ```
752
+ */
753
+ const runDev = async (ctx, opts, deps) => {
754
+ const port = opts?.port ?? 8787;
755
+ const webBuild = opts?.webBuild;
756
+ ctx.emit("dev:phase", {
757
+ phase: "build",
758
+ detail: "site"
759
+ });
760
+ await deps.build(ctx, webBuild);
761
+ const binding = d1Binding(ctx);
762
+ if (ctx.config.migrateLocal && binding !== void 0) {
763
+ ctx.emit("dev:phase", {
764
+ phase: "migrate",
765
+ detail: "d1 (local)"
766
+ });
767
+ await deps.runWrangler([
768
+ "d1",
769
+ "migrations",
770
+ "apply",
771
+ binding,
772
+ "--local"
773
+ ]);
774
+ }
775
+ ctx.emit("dev:phase", {
776
+ phase: "serve",
777
+ detail: `http://localhost:${String(port)}`
778
+ });
779
+ const child = deps.spawnDev([
780
+ "dev",
781
+ "--port",
782
+ String(port),
783
+ "--config",
784
+ ctx.config.configFile,
785
+ "--live-reload"
786
+ ]);
787
+ const watcher = deps.watch(ctx.config.watch, ctx.config.debounceMs, (changedPath) => rebuild(ctx, deps, changedPath, webBuild));
788
+ await deps.untilSignal();
789
+ watcher.close();
790
+ child.kill();
791
+ ctx.emit("dev:phase", { phase: "stopped" });
792
+ };
793
+ //#endregion
794
+ //#region src/plugins/deploy/infra/plan.ts
795
+ /**
796
+ * Decide whether a single declared resource already exists in the account, recovering its id
797
+ * (kv/d1) when it does. Durable Objects are config-only (they ship with the script), so they are
798
+ * always treated as "missing" — provisioning them is a no-op that just records the binding.
799
+ *
800
+ * @param resource - The declared resource descriptor.
801
+ * @param existing - The indexed set of resources already in the account.
802
+ * @returns Whether it exists, plus the captured id for kv/d1.
803
+ * @example
804
+ * ```ts
805
+ * checkExisting({ kind: "kv", binding: "SESSIONS" }, existing); // { exists: true, id: "ns123" }
806
+ * ```
807
+ */
808
+ const checkExisting = (resource, existing) => {
809
+ switch (resource.kind) {
810
+ case "kv": {
811
+ const id = existing.kv.get(resource.binding);
812
+ return id === void 0 ? { exists: false } : {
813
+ exists: true,
814
+ id
815
+ };
816
+ }
817
+ case "d1": {
818
+ const id = existing.d1.get(resource.binding);
819
+ return id === void 0 ? { exists: false } : {
820
+ exists: true,
821
+ id
822
+ };
823
+ }
824
+ case "r2": return { exists: existing.r2.has(resource.bucket) };
825
+ case "queue": return { exists: resource.producers.every((producer) => existing.queue.has(producer)) };
826
+ case "do": return { exists: false };
827
+ }
828
+ };
829
+ /**
830
+ * Run the read-only infra preflight: resolve the account, list existing resources, diff against
831
+ * the manifest, emit `provision:plan`, and return the plan. Writes nothing.
832
+ *
833
+ * @param ctx - The deploy plugin context (env + emit).
834
+ * @param manifest - The assembled (or caller-supplied) deploy manifest.
835
+ * @returns The infra plan: existing (with ids) vs missing resources.
836
+ * @throws {Error} When the token is absent/invalid or a Cloudflare listing fails.
837
+ * @example
838
+ * ```ts
839
+ * const plan = await planInfra(ctx, manifest);
840
+ * ```
841
+ */
842
+ const planInfra = async (ctx, manifest) => {
843
+ const token = ctx.env.require("CLOUDFLARE_API_TOKEN");
844
+ const pinnedAccountId = ctx.env.get("CLOUDFLARE_ACCOUNT_ID");
845
+ const account = pinnedAccountId ? {
846
+ id: pinnedAccountId,
847
+ name: pinnedAccountId
848
+ } : await resolveAccount(token);
849
+ const kinds = /* @__PURE__ */ new Set();
850
+ for (const resource of manifest.resources) if (resource.kind !== "do") kinds.add(resource.kind);
851
+ const existing = await listExisting(token, account.id, kinds);
852
+ const exists = [];
853
+ const missing = [];
854
+ for (const resource of manifest.resources) {
855
+ const check = checkExisting(resource, existing);
856
+ if (check.exists) exists.push(check.id === void 0 ? { resource } : {
857
+ resource,
858
+ id: check.id
859
+ });
860
+ else missing.push(resource);
861
+ }
862
+ ctx.emit("provision:plan", {
863
+ exists: exists.length,
864
+ missing: missing.length,
865
+ account: account.name
866
+ });
867
+ return {
868
+ account: account.name,
869
+ accountId: account.id,
870
+ exists,
871
+ missing
872
+ };
873
+ };
99
874
  //#endregion
100
875
  //#region src/plugins/deploy/providers/d1.ts
101
876
  /**
102
877
  * @file deploy plugin — D1 provisioning adapter.
103
878
  *
104
- * Creates a Cloudflare D1 database via `wrangler d1 create <binding>`.
879
+ * Creates a Cloudflare D1 database via `wrangler d1 create <binding>`, captures the created
880
+ * database id from wrangler's output (so writeWranglerConfig can write a real `database_id`
881
+ * instead of an empty placeholder), and applies migrations when declared.
105
882
  * Node-only; never imported by the runtime Worker bundle.
106
883
  */
107
884
  /**
108
- * Provision a D1 database via `wrangler d1 create` and apply migrations.
885
+ * Parse the created D1 database id from `wrangler d1 create` output.
886
+ * Wrangler prints the new binding as JSON (`"database_id": "..."`) or TOML
887
+ * (`database_id = "..."`); the leading boundary keeps the match anchored to the field name.
888
+ *
889
+ * @param output - Raw stdout from the wrangler create command.
890
+ * @returns The database id, or undefined when none is found.
891
+ * @example
892
+ * ```ts
893
+ * parseD1DatabaseId('{ "database_id": "uuid-1234" }'); // "uuid-1234"
894
+ * ```
895
+ */
896
+ const parseD1DatabaseId = (output) => {
897
+ return /(?:^|[\s,{])"?database_id"?\s*[:=]\s*"([^"]+)"/m.exec(output)?.[1];
898
+ };
899
+ /**
900
+ * Provision a D1 database via `wrangler d1 create`, capture its id, and apply migrations.
109
901
  *
110
902
  * @param manifest - The D1 resource descriptor.
111
903
  * @param _ci - Whether running non-interactively.
112
- * @returns Resolves once the database is created (and migrations applied when specified).
904
+ * @returns The captured database id when wrangler reported one, else an empty outcome.
113
905
  * @example
114
906
  * ```ts
115
- * await provisionD1({ kind: "d1", binding: "DB", migrations: "./migrations" }, false);
907
+ * const { id } = await provisionD1({ kind: "d1", binding: "DB", migrations: "./migrations" }, false);
116
908
  * ```
117
909
  */
118
910
  const provisionD1 = async (manifest, _ci) => {
119
- await runWrangler([
911
+ const id = parseD1DatabaseId(await runWrangler([
120
912
  "d1",
121
913
  "create",
122
914
  manifest.binding
123
- ]);
915
+ ]));
124
916
  if (manifest.migrations) await runWrangler([
125
917
  "d1",
126
918
  "migrations",
@@ -128,6 +920,7 @@ const provisionD1 = async (manifest, _ci) => {
128
920
  manifest.binding,
129
921
  "--local"
130
922
  ]);
923
+ return id ? { id } : {};
131
924
  };
132
925
  //#endregion
133
926
  //#region src/plugins/deploy/providers/do.ts
@@ -150,27 +943,46 @@ const provisionDurableObject = async (_manifest, _ci) => {};
150
943
  /**
151
944
  * @file deploy plugin — KV provisioning adapter.
152
945
  *
153
- * Creates a Cloudflare KV namespace via `wrangler kv namespace create <binding>`.
154
- * Node-only; never imported by the runtime Worker bundle.
946
+ * Creates a Cloudflare KV namespace via `wrangler kv namespace create <binding>` and captures
947
+ * the created namespace id from wrangler's output, so writeWranglerConfig can write a real `id`
948
+ * (not an empty placeholder) into the generated wrangler config — otherwise the binding resolves
949
+ * to nothing at runtime. Node-only; never imported by the runtime Worker bundle.
950
+ */
951
+ /**
952
+ * Parse the created KV namespace id from `wrangler kv namespace create` output.
953
+ * Wrangler prints the new binding as JSON (`"id": "..."`) or TOML (`id = "..."`); the leading
954
+ * boundary (start / whitespace / `{` / `,`) keeps the match off a longer identifier such as
955
+ * `kv_namespace_id`.
956
+ *
957
+ * @param output - Raw stdout from the wrangler create command.
958
+ * @returns The namespace id, or undefined when none is found.
959
+ * @example
960
+ * ```ts
961
+ * parseKvNamespaceId('{ "id": "abc123" }'); // "abc123"
962
+ * ```
155
963
  */
964
+ const parseKvNamespaceId = (output) => {
965
+ return /(?:^|[\s,{])"?id"?\s*[:=]\s*"([^"]+)"/m.exec(output)?.[1];
966
+ };
156
967
  /**
157
- * Provision a KV namespace via `wrangler kv namespace create`.
968
+ * Provision a KV namespace via `wrangler kv namespace create` and capture its id.
158
969
  *
159
970
  * @param manifest - The KV resource descriptor.
160
971
  * @param _ci - Whether running non-interactively (passed through; wrangler respects env vars).
161
- * @returns Resolves once the namespace is created.
972
+ * @returns The captured namespace id when wrangler reported one, else an empty outcome.
162
973
  * @example
163
974
  * ```ts
164
- * await provisionKv({ kind: "kv", binding: "CACHE" }, false);
975
+ * const { id } = await provisionKv({ kind: "kv", binding: "CACHE" }, false);
165
976
  * ```
166
977
  */
167
978
  const provisionKv = async (manifest, _ci) => {
168
- await runWrangler([
979
+ const id = parseKvNamespaceId(await runWrangler([
169
980
  "kv",
170
981
  "namespace",
171
982
  "create",
172
983
  manifest.binding
173
- ]);
984
+ ]));
985
+ return id ? { id } : {};
174
986
  };
175
987
  //#endregion
176
988
  //#region src/plugins/deploy/providers/queues.ts
@@ -282,33 +1094,48 @@ const uploadDirToR2 = async (bucket, directory) => {
282
1094
  *
283
1095
  * @param resource - The resource descriptor to provision.
284
1096
  * @param ci - Whether running non-interactively.
285
- * @returns Resolves once the resource is provisioned.
1097
+ * @returns The provisioning outcome `{ id }` for kv/d1, `{}` for r2/queue/do.
286
1098
  * @example
287
1099
  * ```ts
288
- * await provisionResource({ kind: "kv", binding: "CACHE" }, false);
289
- * await provisionResource({ kind: "r2", bucket: "ASSETS" }, false);
1100
+ * const { id } = await provisionResource({ kind: "kv", binding: "CACHE" }, false);
1101
+ * await provisionResource({ kind: "r2", bucket: "ASSETS" }, false); // {}
290
1102
  * ```
291
1103
  */
292
1104
  const provisionResource = async (resource, ci) => {
293
1105
  switch (resource.kind) {
294
- case "kv":
295
- await provisionKv(resource, ci);
296
- break;
1106
+ case "kv": return provisionKv(resource, ci);
1107
+ case "d1": return provisionD1(resource, ci);
297
1108
  case "r2":
298
1109
  await provisionR2(resource, ci);
299
- break;
300
- case "d1":
301
- await provisionD1(resource, ci);
302
- break;
1110
+ return {};
303
1111
  case "queue":
304
1112
  await provisionQueue(resource, ci);
305
- break;
1113
+ return {};
306
1114
  case "do":
307
1115
  await provisionDurableObject(resource, ci);
308
- break;
1116
+ return {};
309
1117
  }
310
1118
  };
311
1119
  //#endregion
1120
+ //#region src/plugins/deploy/tty.ts
1121
+ /**
1122
+ * @file deploy plugin — TTY detection (isolated so the guided flow is testable).
1123
+ *
1124
+ * The guided deploy only prompts on an interactive terminal; in a pipe or CI it must never block
1125
+ * on stdin. Kept in its own module so tests can mock it without stubbing `process.stdout`.
1126
+ * Node-only; never imported by the runtime Worker bundle.
1127
+ */
1128
+ /**
1129
+ * Whether stdout is an interactive TTY (so prompts are safe to show).
1130
+ *
1131
+ * @returns True when stdout is a terminal.
1132
+ * @example
1133
+ * ```ts
1134
+ * if (stdoutIsTty()) await prompts.confirm("Deploy?");
1135
+ * ```
1136
+ */
1137
+ const stdoutIsTty = () => process.stdout.isTTY === true;
1138
+ //#endregion
312
1139
  //#region src/plugins/deploy/wrangler-config.ts
313
1140
  /**
314
1141
  * @file deploy plugin — wrangler config generation + scaffold.
@@ -339,15 +1166,16 @@ const parseJsonc = (source) => {
339
1166
  * Build the wrangler `kv_namespaces` array from the manifest's kv resources.
340
1167
  *
341
1168
  * @param resources - All resource descriptors from the manifest.
342
- * @returns One wrangler KV namespace entry per kv resource.
1169
+ * @param ids - Captured Cloudflare ids keyed by binding; the entry's `id` is filled from here.
1170
+ * @returns One wrangler KV namespace entry per kv resource (real `id` when known, else "").
343
1171
  * @example
344
1172
  * ```ts
345
- * const kv = buildKvNamespaces([{ kind: "kv", binding: "CACHE" }]);
1173
+ * const kv = buildKvNamespaces([{ kind: "kv", binding: "CACHE" }], { CACHE: "ns123" });
346
1174
  * ```
347
1175
  */
348
- const buildKvNamespaces = (resources) => resources.filter((resource) => resource.kind === "kv").map((resource) => ({
1176
+ const buildKvNamespaces = (resources, ids) => resources.filter((resource) => resource.kind === "kv").map((resource) => ({
349
1177
  binding: resource.binding,
350
- id: ""
1178
+ id: ids[resource.binding] ?? ""
351
1179
  }));
352
1180
  /**
353
1181
  * Build the wrangler `r2_buckets` array from the manifest's r2 resources.
@@ -367,17 +1195,18 @@ const buildR2Buckets = (resources) => resources.filter((resource) => resource.ki
367
1195
  * Build the wrangler `d1_databases` array from the manifest's d1 resources.
368
1196
  *
369
1197
  * @param resources - All resource descriptors from the manifest.
1198
+ * @param ids - Captured Cloudflare ids keyed by binding; the entry's `database_id` is filled from here.
370
1199
  * @returns One wrangler D1 database entry per d1 resource (migrations_dir set when present).
371
1200
  * @example
372
1201
  * ```ts
373
- * const d1 = buildD1Databases([{ kind: "d1", binding: "DB" }]);
1202
+ * const d1 = buildD1Databases([{ kind: "d1", binding: "DB" }], { DB: "uuid-1234" });
374
1203
  * ```
375
1204
  */
376
- const buildD1Databases = (resources) => resources.filter((resource) => resource.kind === "d1").map((resource) => {
1205
+ const buildD1Databases = (resources, ids) => resources.filter((resource) => resource.kind === "d1").map((resource) => {
377
1206
  const entry = {
378
1207
  binding: resource.binding,
379
1208
  database_name: resource.binding.toLowerCase(),
380
- database_id: ""
1209
+ database_id: ids[resource.binding] ?? ""
381
1210
  };
382
1211
  if (resource.migrations) entry.migrations_dir = resource.migrations;
383
1212
  return entry;
@@ -426,22 +1255,24 @@ const buildDurableObjects = (resources) => {
426
1255
  *
427
1256
  * @param configFile - Path to the wrangler config file.
428
1257
  * @param manifest - The assembled deploy manifest.
1258
+ * @param ids - Captured Cloudflare ids keyed by binding (kv namespace id, d1 database id). Defaults
1259
+ * to an empty map, in which case `id`/`database_id` are written as "" (e.g. the universal path).
429
1260
  * @returns Resolves once the file is written.
430
1261
  * @example
431
1262
  * ```ts
432
- * await writeWranglerConfig("wrangler.jsonc", manifest);
1263
+ * await writeWranglerConfig("wrangler.jsonc", manifest, { CACHE: "ns123", DB: "uuid-1234" });
433
1264
  * ```
434
1265
  */
435
- const writeWranglerConfig = async (configFile, manifest) => {
1266
+ const writeWranglerConfig = async (configFile, manifest, ids = {}) => {
436
1267
  let existing = {};
437
1268
  if ((0, node_fs.existsSync)(configFile)) try {
438
1269
  existing = parseJsonc((0, node_fs.readFileSync)(configFile, "utf8"));
439
1270
  } catch {
440
1271
  existing = {};
441
1272
  }
442
- const kvNamespaces = buildKvNamespaces(manifest.resources);
1273
+ const kvNamespaces = buildKvNamespaces(manifest.resources, ids);
443
1274
  const r2Buckets = buildR2Buckets(manifest.resources);
444
- const d1Databases = buildD1Databases(manifest.resources);
1275
+ const d1Databases = buildD1Databases(manifest.resources, ids);
445
1276
  const queues = buildQueues(manifest.resources);
446
1277
  const durableObjects = buildDurableObjects(manifest.resources);
447
1278
  const updated = {
@@ -480,21 +1311,22 @@ const scaffoldWranglerAndCi = async (configFile, _ci) => {
480
1311
  //#endregion
481
1312
  //#region src/plugins/deploy/api.ts
482
1313
  /**
483
- * @file deploy plugin — API factory (run, dev, init).
1314
+ * @file deploy plugin — API factory (run, dev, init, checkInfra, provisionInfra).
484
1315
  *
485
1316
  * Pure ctx-taking factory. Assembles the deploy manifest from each resource plugin's own
486
- * deployManifest() api (never sibling pluginConfigs — design F6), provisions resources,
487
- * generates/updates the wrangler config, uploads the R2 upload dir, and runs wrangler deploy.
488
- * Emits only global events: deploy:phase, deploy:complete, provision:resource.
1317
+ * deployManifest() api (never sibling pluginConfigs — design F6), runs an infra preflight
1318
+ * (check-before-create + capture real ids), generates/updates the wrangler config, uploads the
1319
+ * R2 upload dir, and runs wrangler deploy. Emits only global events: deploy:phase,
1320
+ * deploy:complete, provision:resource, provision:plan, provision:skip.
489
1321
  *
490
- * Node-only: uses node:child_process (via runner.ts) and node:fs (via wrangler-config.ts).
491
- * Never called in the deployed Worker runtime.
1322
+ * Node-only: uses node:child_process (via runner.ts), node:fs (via wrangler-config.ts), and the
1323
+ * Cloudflare REST API (via infra/). Never called in the deployed Worker runtime.
492
1324
  */
493
1325
  /**
494
- * Derive a human-readable name string from a resource descriptor (used in provision:resource).
1326
+ * Derive a human-readable name string from a resource descriptor (used in provision events).
495
1327
  *
496
1328
  * @param resource - The resource descriptor.
497
- * @returns A name suitable for the provision:resource event payload.
1329
+ * @returns A name suitable for the provision:resource / provision:skip event payload.
498
1330
  * @example
499
1331
  * ```ts
500
1332
  * resourceName({ kind: "kv", binding: "CACHE" }); // "CACHE"
@@ -509,12 +1341,74 @@ const resourceName = (resource) => {
509
1341
  }
510
1342
  };
511
1343
  /**
512
- * Create the deploy api. Assembles the manifest from each resource plugin's own
513
- * deployManifest() (never sibling config), provisions, generates config, uploads,
514
- * and runs `wrangler deploy`, emitting global deploy events along the way.
1344
+ * Assemble the deploy manifest from each present resource plugin's OWN deployManifest() api,
1345
+ * gated by ctx.has(name) so absent plugins are skipped never sibling pluginConfigs (F6).
1346
+ *
1347
+ * @param ctx - The deploy plugin context.
1348
+ * @returns The assembled manifest (name, compatibilityDate, resources).
1349
+ * @example
1350
+ * ```ts
1351
+ * const manifest = assembleManifest(ctx);
1352
+ * ```
1353
+ */
1354
+ const assembleManifest = (ctx) => ({
1355
+ name: ctx.global.name,
1356
+ compatibilityDate: ctx.global.compatibilityDate,
1357
+ resources: [
1358
+ ctx.has("storage") ? ctx.require(require_storage.storagePlugin).deployManifest() : void 0,
1359
+ ctx.has("kv") ? ctx.require(require_storage.kvPlugin).deployManifest() : void 0,
1360
+ ctx.has("d1") ? ctx.require(require_storage.d1Plugin).deployManifest() : void 0,
1361
+ ctx.has("queues") ? ctx.require(require_storage.queuesPlugin).deployManifest() : void 0,
1362
+ ctx.has("durableObjects") ? ctx.require(require_storage.durableObjectsPlugin).deployManifest() : void 0
1363
+ ].filter((resource) => resource !== void 0)
1364
+ });
1365
+ /**
1366
+ * Act on an infra plan: skip the resources that already exist (reusing their ids), create only
1367
+ * the missing ones (capturing each new id), and announce each via provision:skip / :resource.
1368
+ *
1369
+ * @param ctx - The deploy plugin context.
1370
+ * @param plan - The infra plan from planInfra (existing vs missing).
1371
+ * @returns The provisioning result: created, skipped, and the merged binding → id map.
1372
+ * @example
1373
+ * ```ts
1374
+ * const { ids } = await applyPlan(ctx, plan);
1375
+ * ```
1376
+ */
1377
+ const applyPlan = async (ctx, plan) => {
1378
+ const ids = {};
1379
+ for (const ref of plan.exists) {
1380
+ if (ref.id !== void 0 && (ref.resource.kind === "kv" || ref.resource.kind === "d1")) ids[ref.resource.binding] = ref.id;
1381
+ ctx.emit("provision:skip", {
1382
+ kind: ref.resource.kind,
1383
+ name: resourceName(ref.resource)
1384
+ });
1385
+ }
1386
+ const created = [];
1387
+ for (const resource of plan.missing) {
1388
+ const { id } = await provisionResource(resource, ctx.config.ci);
1389
+ if (id !== void 0 && (resource.kind === "kv" || resource.kind === "d1")) ids[resource.binding] = id;
1390
+ created.push(id === void 0 ? { resource } : {
1391
+ resource,
1392
+ id
1393
+ });
1394
+ ctx.emit("provision:resource", {
1395
+ kind: resource.kind,
1396
+ name: resourceName(resource)
1397
+ });
1398
+ }
1399
+ return {
1400
+ created,
1401
+ skipped: plan.exists,
1402
+ ids
1403
+ };
1404
+ };
1405
+ /**
1406
+ * Create the deploy api. Assembles the manifest from each resource plugin's own deployManifest(),
1407
+ * runs an infra preflight (check-before-create + id capture), generates config, uploads, and runs
1408
+ * `wrangler deploy`, emitting global deploy events along the way.
515
1409
  *
516
- * @param ctx - Plugin context (own config + require + has + emit + global).
517
- * @returns The app.deploy api: run / dev / init.
1410
+ * @param ctx - Plugin context (own config + require + has + emit + global + env).
1411
+ * @returns The app.deploy api: run / dev / init / checkInfra / provisionInfra.
518
1412
  * @example
519
1413
  * ```ts
520
1414
  * const api = createDeployApi(ctx);
@@ -523,43 +1417,45 @@ const resourceName = (resource) => {
523
1417
  */
524
1418
  const createDeployApi = (ctx) => ({
525
1419
  /**
526
- * Run the full deploy pipeline: detect → provision → wrangler-configupload deploy.
527
- * When opts.manifest is supplied, it is used verbatim (universal path).
1420
+ * Run the full deploy pipeline: detect → preflight (check-before-create)provision (only the
1421
+ * missing) wrangler-config (with real ids) upload deploy. When opts.manifest is supplied
1422
+ * it is used verbatim (universal path).
528
1423
  *
529
1424
  * @param opts - Optional run options.
530
- * @param opts.guided - Enable interactive confirmation steps (skipped when ci=true).
531
- * @param opts.yes - Auto-confirm all prompts.
1425
+ * @param opts.guided - Enable interactive confirmation steps (only on a TTY, non-CI).
1426
+ * @param opts.yes - Auto-confirm all prompts (non-interactive / CI).
1427
+ * @param opts.webBuild - Build the web site first (e.g. `() => webApp.cli.build()`), before deploy.
532
1428
  * @param opts.manifest - Caller-supplied manifest (bypasses deployManifest() assembly).
533
1429
  * @returns Resolves once the deploy completes.
534
1430
  * @example
535
1431
  * ```ts
536
- * await api.run({ guided: true });
1432
+ * await api.run({ guided: true, webBuild: () => web.cli.build() });
537
1433
  * await api.run({ manifest: { name: "w", compatibilityDate: "2026-06-17", resources: [] } });
538
1434
  * ```
539
1435
  */
540
1436
  async run(opts) {
1437
+ const confirm = (opts?.guided ?? false) && !ctx.config.ci && !(opts?.yes ?? false) && stdoutIsTty() ? (0, _moku_labs_common_cli.createBrandPrompts)().confirm : async (_question) => true;
1438
+ ctx.emit("deploy:phase", { phase: "auth" });
1439
+ await verifyAuth(ctx);
1440
+ const webBuild = opts?.webBuild ?? ctx.config.webBuild;
1441
+ if (webBuild !== void 0) {
1442
+ ctx.emit("deploy:phase", {
1443
+ phase: "build",
1444
+ detail: "web"
1445
+ });
1446
+ await webBuild();
1447
+ }
541
1448
  ctx.emit("deploy:phase", { phase: "detect" });
542
- const manifest = opts?.manifest ?? {
543
- name: ctx.global.name,
544
- compatibilityDate: ctx.global.compatibilityDate,
545
- resources: [
546
- ctx.has("storage") ? ctx.require(require_storage.storagePlugin).deployManifest() : void 0,
547
- ctx.has("kv") ? ctx.require(require_storage.kvPlugin).deployManifest() : void 0,
548
- ctx.has("d1") ? ctx.require(require_storage.d1Plugin).deployManifest() : void 0,
549
- ctx.has("queues") ? ctx.require(require_storage.queuesPlugin).deployManifest() : void 0,
550
- ctx.has("durableObjects") ? ctx.require(require_storage.durableObjectsPlugin).deployManifest() : void 0
551
- ].filter((resource) => resource !== void 0)
552
- };
1449
+ const manifest = opts?.manifest ?? assembleManifest(ctx);
553
1450
  ctx.emit("deploy:phase", { phase: "provision" });
554
- for (const resource of manifest.resources) {
555
- await provisionResource(resource, ctx.config.ci);
556
- ctx.emit("provision:resource", {
557
- kind: resource.kind,
558
- name: resourceName(resource)
559
- });
1451
+ const plan = await planInfra(ctx, manifest);
1452
+ if (plan.missing.length > 0 && !await confirm(`Create ${plan.missing.length} missing resource(s) in "${plan.account}"?`)) {
1453
+ ctx.emit("deploy:phase", { phase: "aborted" });
1454
+ return;
560
1455
  }
1456
+ const { ids } = await applyPlan(ctx, plan);
561
1457
  ctx.emit("deploy:phase", { phase: "wrangler-config" });
562
- await writeWranglerConfig(ctx.config.configFile, manifest);
1458
+ await writeWranglerConfig(ctx.config.configFile, manifest, ids);
563
1459
  const r2Resource = manifest.resources.find((resource) => resource.kind === "r2");
564
1460
  if (r2Resource?.upload) {
565
1461
  const count = await uploadDirToR2(r2Resource.bucket, r2Resource.upload);
@@ -568,6 +1464,10 @@ const createDeployApi = (ctx) => ({
568
1464
  detail: `${String(count)} files`
569
1465
  });
570
1466
  }
1467
+ if (!await confirm(`Deploy "${manifest.name}" to ${ctx.global.stage}?`)) {
1468
+ ctx.emit("deploy:phase", { phase: "aborted" });
1469
+ return;
1470
+ }
571
1471
  ctx.emit("deploy:phase", { phase: "deploy" });
572
1472
  const url = await runWrangler([
573
1473
  "deploy",
@@ -577,25 +1477,20 @@ const createDeployApi = (ctx) => ({
577
1477
  ctx.emit("deploy:complete", { url });
578
1478
  },
579
1479
  /**
580
- * Start a local Cloudflare dev session via `wrangler dev`.
1480
+ * Start a long-lived local dev session: cold-build the Moku site, spawn `wrangler dev
1481
+ * --live-reload`, and watch the site sources — rebuilding on change (wrangler live-reloads the
1482
+ * browser). Resolves on SIGINT.
581
1483
  *
582
1484
  * @param opts - Optional options.
583
1485
  * @param opts.port - Local dev port (default 8787).
1486
+ * @param opts.webBuild - Rebuild the web site on change (e.g. `() => webApp.cli.build()`).
584
1487
  * @returns Resolves when the dev session ends.
585
1488
  * @example
586
1489
  * ```ts
587
- * await api.dev({ port: 8787 });
1490
+ * await api.dev({ port: 8787, webBuild: () => web.cli.build() });
588
1491
  * ```
589
1492
  */
590
- dev: async (opts) => {
591
- await runWrangler([
592
- "dev",
593
- "--port",
594
- String(opts?.port ?? 8787),
595
- "--config",
596
- ctx.config.configFile
597
- ]);
598
- },
1493
+ dev: (opts) => runDev(ctx, opts, realDevDeps()),
599
1494
  /**
600
1495
  * Scaffold a starting wrangler config (and CI files when ci is set).
601
1496
  * Idempotent: an existing config file is left untouched.
@@ -610,7 +1505,71 @@ const createDeployApi = (ctx) => ({
610
1505
  */
611
1506
  init: async (opts) => {
612
1507
  await scaffoldWranglerAndCi(ctx.config.configFile, opts?.ci ?? ctx.config.ci);
613
- }
1508
+ },
1509
+ /**
1510
+ * Read-only infra preflight: assemble the manifest, resolve the account, list what exists in
1511
+ * Cloudflare, diff, emit provision:plan, and return the plan. Writes nothing.
1512
+ *
1513
+ * @returns The infra plan (existing vs missing resources, with captured ids).
1514
+ * @example
1515
+ * ```ts
1516
+ * const plan = await api.checkInfra();
1517
+ * ```
1518
+ */
1519
+ checkInfra: () => planInfra(ctx, assembleManifest(ctx)),
1520
+ /**
1521
+ * Create only the resources missing from the plan (skipping existing), capturing each id.
1522
+ *
1523
+ * @param plan - A plan produced by checkInfra().
1524
+ * @returns The provisioning result: created, skipped, and the merged id map.
1525
+ * @example
1526
+ * ```ts
1527
+ * const { created } = await api.provisionInfra(await api.checkInfra());
1528
+ * ```
1529
+ */
1530
+ provisionInfra: (plan) => applyPlan(ctx, plan),
1531
+ /**
1532
+ * Verify the `.env` Cloudflare API token (must be active) and resolve its account; emits
1533
+ * auth:verified. Throws a branded error pointing at `auth setup` when absent/invalid/inactive.
1534
+ *
1535
+ * @returns The verified auth status (account + id).
1536
+ * @example
1537
+ * ```ts
1538
+ * const { account } = await api.verifyAuth();
1539
+ * ```
1540
+ */
1541
+ verifyAuth: () => verifyAuth(ctx),
1542
+ /**
1543
+ * Derive the minimum Cloudflare API token this app needs from its manifest (pure, no network).
1544
+ *
1545
+ * @returns The token requirement (full set + groups to add to the stock template).
1546
+ * @example
1547
+ * ```ts
1548
+ * const { toAdd } = api.requiredToken();
1549
+ * ```
1550
+ */
1551
+ requiredToken: () => requiredToken(assembleManifest(ctx)),
1552
+ /**
1553
+ * Render the `auth setup` guidance from the derived token requirement (pure, no network).
1554
+ *
1555
+ * @returns The rendered instruction text.
1556
+ * @example
1557
+ * ```ts
1558
+ * const text = api.tokenInstructions();
1559
+ * ```
1560
+ */
1561
+ tokenInstructions: () => tokenInstructions(assembleManifest(ctx)),
1562
+ /**
1563
+ * Run an arbitrary wrangler command, streaming its output (the branded CLI escape hatch).
1564
+ *
1565
+ * @param args - The wrangler arguments.
1566
+ * @returns Resolves once wrangler exits.
1567
+ * @example
1568
+ * ```ts
1569
+ * await api.wrangler(["kv", "namespace", "list"]);
1570
+ * ```
1571
+ */
1572
+ wrangler: (args) => runWranglerInherit(args)
614
1573
  });
615
1574
  /**
616
1575
  * Complex tier (node-only) — build-time deploy orchestrator over the five resource plugins.
@@ -627,7 +1586,11 @@ const createDeployApi = (ctx) => ({
627
1586
  const deployPlugin = require_storage.createPlugin("deploy", {
628
1587
  config: {
629
1588
  configFile: "wrangler.jsonc",
630
- ci: false
1589
+ ci: false,
1590
+ watch: ["src/**/*.{ts,tsx,css}", "public/**/*"],
1591
+ buildCommand: "",
1592
+ migrateLocal: true,
1593
+ debounceMs: 120
631
1594
  },
632
1595
  depends: [
633
1596
  require_storage.storagePlugin,
@@ -641,6 +1604,9 @@ const deployPlugin = require_storage.createPlugin("deploy", {
641
1604
  //#endregion
642
1605
  //#region src/plugins/cli/api.ts
643
1606
  /**
1607
+ * @file cli plugin — API factory (dev, deploy, auth, doctor).
1608
+ */
1609
+ /**
644
1610
  * Builds app.cli.* — thin passthroughs to the deploy plugin via ctx.require(deployPlugin).
645
1611
  * Both verbs forward their opts verbatim; `dev` defaults port to ctx.config.port when no
646
1612
  * opts are supplied.
@@ -656,37 +1622,137 @@ const deployPlugin = require_storage.createPlugin("deploy", {
656
1622
  */
657
1623
  const createCliApi = (ctx) => ({
658
1624
  /**
659
- * Run the Worker locally; defaults port to ctx.config.port (8787) when no opts supplied.
1625
+ * Run the Worker locally; defaults port to ctx.config.port (8787) when no opts supplied. A
1626
+ * `webBuild` hook (e.g. `() => webApp.cli.build()`) wires the web build into the dev loop so the
1627
+ * site recompiles on change — this is how an app-side script composes web + worker.
660
1628
  *
661
1629
  * @param opts - Optional local dev options.
662
1630
  * @param opts.port - Local dev port to bind. Defaults to ctx.config.port (8787).
1631
+ * @param opts.webBuild - Rebuild the web site on change (e.g. `() => webApp.cli.build()`).
663
1632
  * @returns Resolves when the dev session ends.
664
1633
  * @example
665
1634
  * ```ts
666
- * await api.dev(); // port 8787
667
- * await api.dev({ port: 3000 }); // port 3000
1635
+ * await api.dev(); // port 8787, worker only
1636
+ * await api.dev({ webBuild: () => web.cli.build() }); // wire the web build in
668
1637
  * ```
669
1638
  */
670
1639
  dev(opts) {
671
- return ctx.require(deployPlugin).dev(opts ?? { port: ctx.config.port });
1640
+ const port = opts?.port ?? ctx.config.port;
1641
+ return ctx.require(deployPlugin).dev(opts?.webBuild ? {
1642
+ port,
1643
+ webBuild: opts.webBuild
1644
+ } : { port });
672
1645
  },
673
1646
  /**
674
1647
  * One-command guided Cloudflare deploy; forwards flags verbatim to deploy.run.
675
- * Passes `undefined` when called with no opts (not a default empty object).
1648
+ * Passes `undefined` when called with no opts (not a default empty object). A `webBuild` hook
1649
+ * builds the web site first (before `wrangler deploy`) — how an app-side script ships web + worker.
676
1650
  *
677
1651
  * @param opts - Optional deploy options.
678
1652
  * @param opts.guided - Walk through each step interactively.
679
1653
  * @param opts.yes - Skip confirmation prompts (non-interactive / CI).
1654
+ * @param opts.webBuild - Build the web site first (e.g. `() => webApp.cli.build()`), before deploy.
680
1655
  * @returns Resolves once the deploy completes.
681
1656
  * @example
682
1657
  * ```ts
683
- * await api.deploy({ guided: true });
1658
+ * await api.deploy({ guided: true, webBuild: () => web.cli.build() });
684
1659
  * await api.deploy({ yes: true }); // CI
685
1660
  * await api.deploy(); // opts === undefined
686
1661
  * ```
687
1662
  */
688
1663
  deploy(opts) {
689
1664
  return ctx.require(deployPlugin).run(opts);
1665
+ },
1666
+ /**
1667
+ * Verify the `.env` token (no sub) or print the config-derived token guidance (`"setup"`),
1668
+ * rendered in Moku style. `setup` works without a token; verify reports the resolved account.
1669
+ *
1670
+ * @param sub - Pass "setup" to print guidance; omit to verify the current token.
1671
+ * @returns Resolves once the check or guidance render completes.
1672
+ * @example
1673
+ * ```ts
1674
+ * await api.auth("setup"); // print what token to create
1675
+ * await api.auth(); // verify the current token
1676
+ * ```
1677
+ */
1678
+ async auth(sub) {
1679
+ const deploy = ctx.require(deployPlugin);
1680
+ const ui = (0, _moku_labs_common_cli.createBrandConsole)();
1681
+ if (sub === "setup") {
1682
+ for (const line of deploy.tokenInstructions().split("\n")) ui.line(line);
1683
+ return;
1684
+ }
1685
+ try {
1686
+ const status = await deploy.verifyAuth();
1687
+ ui.check(true, "token valid", `account "${status.account}" (${status.accountId})`);
1688
+ } catch (error) {
1689
+ ui.error(error instanceof Error ? error.message : String(error));
1690
+ }
1691
+ },
1692
+ /**
1693
+ * One-shot preflight report: token + account (verifyAuth) then infra drift (checkInfra),
1694
+ * each as a branded check line. Stops after the token check when auth fails.
1695
+ *
1696
+ * @returns Resolves once the report is printed.
1697
+ * @example
1698
+ * ```ts
1699
+ * await api.doctor();
1700
+ * ```
1701
+ */
1702
+ async doctor() {
1703
+ const deploy = ctx.require(deployPlugin);
1704
+ const ui = (0, _moku_labs_common_cli.createBrandConsole)();
1705
+ ui.heading("doctor");
1706
+ let tokenOk = false;
1707
+ try {
1708
+ const status = await deploy.verifyAuth();
1709
+ tokenOk = true;
1710
+ ui.check(true, "token", `valid · account "${status.account}" (${status.accountId})`);
1711
+ } catch (error) {
1712
+ ui.check(false, "token", error instanceof Error ? error.message : String(error));
1713
+ }
1714
+ if (!tokenOk) {
1715
+ ui.line("Run `auth setup` for the exact token to create.");
1716
+ return;
1717
+ }
1718
+ try {
1719
+ const plan = await deploy.checkInfra();
1720
+ ui.check(true, "infra", `${plan.exists.length} exist, ${plan.missing.length} to create in "${plan.account}"`);
1721
+ } catch (error) {
1722
+ ui.check(false, "infra", error instanceof Error ? error.message : String(error));
1723
+ }
1724
+ },
1725
+ /**
1726
+ * Print the resolved Cloudflare account for the current `.env` token.
1727
+ *
1728
+ * @returns Resolves once the account summary is printed.
1729
+ * @example
1730
+ * ```ts
1731
+ * await api.whoami();
1732
+ * ```
1733
+ */
1734
+ async whoami() {
1735
+ const ui = (0, _moku_labs_common_cli.createBrandConsole)();
1736
+ try {
1737
+ const status = await ctx.require(deployPlugin).verifyAuth();
1738
+ ui.check(true, "account", `${status.account} (${status.accountId})`);
1739
+ } catch (error) {
1740
+ ui.error(error instanceof Error ? error.message : String(error));
1741
+ }
1742
+ },
1743
+ /**
1744
+ * Run an arbitrary wrangler command through the branded CLI (escape hatch). Streams its output.
1745
+ *
1746
+ * @param args - The wrangler arguments.
1747
+ * @returns Resolves once wrangler exits.
1748
+ * @example
1749
+ * ```ts
1750
+ * await api.wrangler(["kv", "namespace", "list"]);
1751
+ * ```
1752
+ */
1753
+ async wrangler(args) {
1754
+ (0, _moku_labs_common_cli.createBrandConsole)().heading(`wrangler ${args.join(" ")}`);
1755
+ await ctx.require(deployPlugin).wrangler(args);
690
1756
  }
691
1757
  });
692
1758
  //#endregion
@@ -723,6 +1789,18 @@ const createCliHooks = (ctx) => ({
723
1789
  ctx.log.info(p.detail ? `${p.phase} · ${p.detail}` : p.phase);
724
1790
  },
725
1791
  /**
1792
+ * Log the infra preflight summary: "infra · N exist, M to create · account".
1793
+ *
1794
+ * @param p - The provision:plan event payload.
1795
+ * @example
1796
+ * ```ts
1797
+ * handler({ exists: 2, missing: 1, account: "Play Co" }); // "infra · 2 exist, 1 to create · Play Co"
1798
+ * ```
1799
+ */
1800
+ "provision:plan"(p) {
1801
+ ctx.log.info(`infra · ${p.exists} exist, ${p.missing} to create · ${p.account}`);
1802
+ },
1803
+ /**
726
1804
  * Log one clean line per provisioned resource: "kind name".
727
1805
  *
728
1806
  * @param p - The provision:resource event payload.
@@ -735,6 +1813,55 @@ const createCliHooks = (ctx) => ({
735
1813
  ctx.log.info(`${p.kind} ${p.name}`);
736
1814
  },
737
1815
  /**
1816
+ * Log one clean line per already-existing resource (skipped): "kind name (exists)".
1817
+ *
1818
+ * @param p - The provision:skip event payload.
1819
+ * @example
1820
+ * ```ts
1821
+ * handler({ kind: "kv", name: "KV" }); // "kv KV (exists)"
1822
+ * ```
1823
+ */
1824
+ "provision:skip"(p) {
1825
+ ctx.log.info(`${p.kind} ${p.name} (exists)`);
1826
+ },
1827
+ /**
1828
+ * Log one dev-session phase: "phase" or "phase · detail".
1829
+ *
1830
+ * @param p - The dev:phase event payload.
1831
+ * @example
1832
+ * ```ts
1833
+ * handler({ phase: "serve", detail: "http://localhost:8787" }); // "serve · http://localhost:8787"
1834
+ * ```
1835
+ */
1836
+ "dev:phase"(p) {
1837
+ ctx.log.info(p.detail ? `${p.phase} · ${p.detail}` : p.phase);
1838
+ },
1839
+ /**
1840
+ * Log the site rebuild result: "site <n> files · <ms>ms" (omits the count when unknown).
1841
+ *
1842
+ * @param p - The dev:rebuilt event payload.
1843
+ * @example
1844
+ * ```ts
1845
+ * handler({ files: 12, ms: 240 }); // "site 12 files · 240ms"
1846
+ * handler({ files: 0, ms: 240 }); // "site · 240ms"
1847
+ * ```
1848
+ */
1849
+ "dev:rebuilt"(p) {
1850
+ ctx.log.info(p.files > 0 ? `site ${String(p.files)} files · ${String(p.ms)}ms` : `site · ${String(p.ms)}ms`);
1851
+ },
1852
+ /**
1853
+ * Log a non-fatal dev build failure via warn (the session keeps serving the last good build).
1854
+ *
1855
+ * @param p - The dev:error event payload.
1856
+ * @example
1857
+ * ```ts
1858
+ * handler({ message: "build failed" }); // warn "build failed"
1859
+ * ```
1860
+ */
1861
+ "dev:error"(p) {
1862
+ ctx.log.warn(p.message);
1863
+ },
1864
+ /**
738
1865
  * Log the terminal success line with the deployed URL.
739
1866
  *
740
1867
  * @param p - The deploy:complete event payload.