@moku-labs/worker 0.4.0 → 0.5.1

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.mjs CHANGED
@@ -1,9 +1,227 @@
1
1
  import { i as durableObjectsPlugin, n as queuesPlugin, o as d1Plugin, r as kvPlugin, t as storagePlugin, u as createPlugin } from "./storage-COo-F38H.mjs";
2
- import { brandedSink } from "@moku-labs/common/cli";
2
+ import { brandedSink, createBrandConsole, createBrandPrompts } from "@moku-labs/common/cli";
3
3
  import { spawn } from "node:child_process";
4
- import { readdir, stat, writeFile } from "node:fs/promises";
4
+ import { existsSync, readFileSync, watch } from "node:fs";
5
5
  import path from "node:path";
6
- import { existsSync, readFileSync } from "node:fs";
6
+ import { readdir, stat, writeFile } from "node:fs/promises";
7
+ //#region src/plugins/deploy/auth/permissions.ts
8
+ /** Permission groups every deploy needs, regardless of resources. */
9
+ const ALWAYS = [{
10
+ group: "Account · Workers Scripts",
11
+ scope: "Edit",
12
+ reason: "deploy",
13
+ inBaseTemplate: true
14
+ }, {
15
+ group: "Account · Account Settings",
16
+ scope: "Read",
17
+ reason: "account",
18
+ inBaseTemplate: true
19
+ }];
20
+ /**
21
+ * Per-resource-kind permission group. `do` needs nothing extra (Durable Objects ship with the
22
+ * Worker script, covered by Workers Scripts · Edit). `d1`/`queue` are NOT in the stock template.
23
+ */
24
+ const BY_KIND = {
25
+ kv: {
26
+ group: "Account · Workers KV Storage",
27
+ scope: "Edit",
28
+ reason: "kv",
29
+ inBaseTemplate: true
30
+ },
31
+ r2: {
32
+ group: "Account · Workers R2 Storage",
33
+ scope: "Edit",
34
+ reason: "r2",
35
+ inBaseTemplate: true
36
+ },
37
+ d1: {
38
+ group: "Account · D1",
39
+ scope: "Edit",
40
+ reason: "d1",
41
+ inBaseTemplate: false
42
+ },
43
+ queue: {
44
+ group: "Account · Queues",
45
+ scope: "Edit",
46
+ reason: "queue",
47
+ inBaseTemplate: false
48
+ },
49
+ do: void 0
50
+ };
51
+ /**
52
+ * Derive the Cloudflare API token requirement from an app manifest: the full permission set plus
53
+ * the subset that must be ADDED to the stock "Edit Cloudflare Workers" template.
54
+ *
55
+ * @param manifest - The assembled deploy manifest.
56
+ * @returns The token requirement (base template, full required set, and groups to add).
57
+ * @example
58
+ * ```ts
59
+ * const { toAdd } = requiredToken({ name: "w", compatibilityDate: "…", resources: [{ kind: "d1", binding: "DB" }] });
60
+ * // toAdd → [{ group: "Account · D1", scope: "Edit", … }]
61
+ * ```
62
+ */
63
+ const requiredToken = (manifest) => {
64
+ const required = [...ALWAYS];
65
+ const seen = new Set(required.map((permission) => permission.group));
66
+ for (const resource of manifest.resources) {
67
+ const permission = BY_KIND[resource.kind];
68
+ if (permission !== void 0 && !seen.has(permission.group)) {
69
+ required.push(permission);
70
+ seen.add(permission.group);
71
+ }
72
+ }
73
+ return {
74
+ base: "Edit Cloudflare Workers",
75
+ required,
76
+ toAdd: required.filter((permission) => !permission.inBaseTemplate)
77
+ };
78
+ };
79
+ /** Permission every CI/automation redeploy needs: ship the Worker script. */
80
+ const CI_ALWAYS = [{
81
+ group: "Account · Workers Scripts",
82
+ scope: "Edit",
83
+ reason: "deploy",
84
+ inBaseTemplate: true
85
+ }];
86
+ /**
87
+ * Per-resource-kind permission for the CI/automation token. After a first LOCAL deploy has
88
+ * provisioned everything, CI only needs to LIST existing infra (the idempotent preflight) and
89
+ * ship — so data resources drop to `Read`; R2 stays `Edit` because asset upload writes objects.
90
+ */
91
+ const CI_BY_KIND = {
92
+ kv: {
93
+ group: "Account · Workers KV Storage",
94
+ scope: "Read",
95
+ reason: "kv (preflight)",
96
+ inBaseTemplate: true
97
+ },
98
+ r2: {
99
+ group: "Account · Workers R2 Storage",
100
+ scope: "Edit",
101
+ reason: "r2 (asset upload)",
102
+ inBaseTemplate: true
103
+ },
104
+ d1: {
105
+ group: "Account · D1",
106
+ scope: "Read",
107
+ reason: "d1 (preflight)",
108
+ inBaseTemplate: false
109
+ },
110
+ queue: {
111
+ group: "Account · Queues",
112
+ scope: "Read",
113
+ reason: "queue (preflight)",
114
+ inBaseTemplate: false
115
+ },
116
+ do: void 0
117
+ };
118
+ /**
119
+ * Derive the REDUCED Cloudflare API token for CI/automation redeploys, from the same manifest.
120
+ * Assumes a prior LOCAL deploy already provisioned the infra, so CI never creates: data resources
121
+ * need only `Read` (the idempotent preflight lists them), R2 keeps `Edit` for asset upload, and no
122
+ * `Account Settings · Read` is needed because CI pins `CLOUDFLARE_ACCOUNT_ID`. Pure: no network.
123
+ *
124
+ * @param manifest - The assembled deploy manifest.
125
+ * @returns The minimum permission groups for a CI redeploy token (deduped, manifest-scoped).
126
+ * @example
127
+ * ```ts
128
+ * const groups = ciToken({ name: "w", compatibilityDate: "…", resources: [{ kind: "d1", binding: "DB" }] });
129
+ * // → [Workers Scripts·Edit, D1·Read]
130
+ * ```
131
+ */
132
+ const ciToken = (manifest) => {
133
+ const groups = [...CI_ALWAYS];
134
+ const seen = new Set(groups.map((permission) => permission.group));
135
+ for (const resource of manifest.resources) {
136
+ const permission = CI_BY_KIND[resource.kind];
137
+ if (permission !== void 0 && !seen.has(permission.group)) {
138
+ groups.push(permission);
139
+ seen.add(permission.group);
140
+ }
141
+ }
142
+ return groups;
143
+ };
144
+ //#endregion
145
+ //#region src/plugins/deploy/auth/setup.ts
146
+ /** Cloudflare's dashboard path for creating API tokens. */
147
+ const TOKENS_URL = "https://dash.cloudflare.com/profile/api-tokens";
148
+ /**
149
+ * Render the FULL local-first token section (the deploy that provisions everything): the permission
150
+ * table flagging template-missing rows, the template + "add these" steps, and the `.env.local` lines.
151
+ *
152
+ * @param requirement - The full token requirement (from requiredToken()).
153
+ * @returns The local-first section lines.
154
+ * @example
155
+ * ```ts
156
+ * const lines = localSection(requiredToken(manifest));
157
+ * ```
158
+ */
159
+ const localSection = (requirement) => {
160
+ const permissionRows = requirement.required.map((permission) => {
161
+ const flag = permission.inBaseTemplate ? "" : " <- add to template";
162
+ return ` - ${permission.group} : ${permission.scope} (${permission.reason})${flag}`;
163
+ });
164
+ 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.`];
165
+ return [
166
+ "LOCAL — first deploy (provisions infra). A Cloudflare API token with these permissions:",
167
+ "",
168
+ ...permissionRows,
169
+ "",
170
+ "Fastest path:",
171
+ ` 1. ${TOKENS_URL} -> Create Token`,
172
+ ` 2. Start from the "${requirement.base}" template.`,
173
+ ...step3,
174
+ " 4. Account Resources -> Include -> your account.",
175
+ " 5. Create the token, copy it, then add it to .env.local:",
176
+ " CLOUDFLARE_API_TOKEN=<paste your token>",
177
+ " CLOUDFLARE_ACCOUNT_ID=<your account id>",
178
+ " 6. Verify it with `auth` (app.deploy.verifyAuth())."
179
+ ];
180
+ };
181
+ /**
182
+ * Render the REDUCED CI/automation token section (redeploy-only): the scoped permission table plus
183
+ * the CI-secret + account-pin steps.
184
+ *
185
+ * @param groups - The CI permission groups (from ciToken()).
186
+ * @returns The CI section lines.
187
+ * @example
188
+ * ```ts
189
+ * const lines = ciSection(ciToken(manifest));
190
+ * ```
191
+ */
192
+ const ciSection = (groups) => {
193
+ return [
194
+ "CI — automation redeploy (infra already provisioned by a local deploy). A SCOPED token with:",
195
+ "",
196
+ ...groups.map((permission) => ` - ${permission.group} : ${permission.scope} (${permission.reason})`),
197
+ "",
198
+ ` 1. ${TOKENS_URL} -> Create Token -> Create Custom Token.`,
199
+ " 2. Add exactly the permissions above (Read, not Edit, on data resources — CI never creates).",
200
+ " 3. Account Resources -> Include -> your account.",
201
+ " 4. Store it as the CLOUDFLARE_API_TOKEN secret in CI, and PIN the account so no account",
202
+ " lookup (and no Account Settings -> Read) is needed:",
203
+ " CLOUDFLARE_ACCOUNT_ID=<your account id>",
204
+ " CI reuses the same idempotent pipeline — it lists existing infra and ships. To let CI also",
205
+ " CREATE missing infra (self-heal), give it the LOCAL token above instead."
206
+ ];
207
+ };
208
+ /**
209
+ * Render the `auth setup` instructions from the app manifest: the FULL local-first token (provisions
210
+ * everything) followed by the REDUCED CI/automation token (redeploy-only).
211
+ *
212
+ * @param manifest - The assembled deploy manifest.
213
+ * @returns A multi-line instruction string covering both tokens.
214
+ * @example
215
+ * ```ts
216
+ * const text = tokenInstructions(manifest);
217
+ * ```
218
+ */
219
+ const tokenInstructions = (manifest) => [
220
+ ...localSection(requiredToken(manifest)),
221
+ "",
222
+ ...ciSection(ciToken(manifest))
223
+ ].join("\n");
224
+ //#endregion
7
225
  //#region src/plugins/deploy/infra/cloudflare.ts
8
226
  /**
9
227
  * @file deploy plugin — Cloudflare REST discovery client (infra preflight).
@@ -59,26 +277,44 @@ const resolveAccount = async (token) => {
59
277
  };
60
278
  };
61
279
  /**
62
- * List every kv / d1 / r2 / queue resource that already exists in the account (one request per
63
- * kind, in parallel), indexed for the preflight diff.
280
+ * Verify a Cloudflare API token via `GET /user/tokens/verify`. Returns its status (`"active"` for
281
+ * a usable token); throws (via cfGet) when the token is rejected outright (401/invalid).
282
+ *
283
+ * @param token - The Cloudflare API token to verify.
284
+ * @returns The token status string reported by Cloudflare.
285
+ * @throws {Error} When the verify request fails (invalid/expired token).
286
+ * @example
287
+ * ```ts
288
+ * const { status } = await verifyToken(token); // status === "active"
289
+ * ```
290
+ */
291
+ const verifyToken = async (token) => {
292
+ return { status: (await cfGet(token, "/user/tokens/verify")).status };
293
+ };
294
+ /**
295
+ * List the resources that already exist in the account, querying ONLY the kinds the app declares
296
+ * (one request per declared kind, in parallel), indexed for the preflight diff. Scoping to the
297
+ * declared kinds keeps the API token minimal — an app with only KV never lists (and so never needs
298
+ * read permission on) D1, R2, or Queues.
64
299
  *
65
300
  * @param token - The Cloudflare API token.
66
301
  * @param accountId - The Cloudflare account id to scope the listings to.
67
- * @returns The existing resources, indexed by kind.
302
+ * @param kinds - The resource kinds present in the manifest (the only kinds queried).
303
+ * @returns The existing resources, indexed by kind (un-queried kinds resolve empty).
68
304
  * @throws {Error} When any listing request fails.
69
305
  * @example
70
306
  * ```ts
71
- * const existing = await listExisting(token, accountId);
307
+ * const existing = await listExisting(token, accountId, new Set(["kv", "d1"]));
72
308
  * if (existing.kv.has("SESSIONS")) { ... }
73
309
  * ```
74
310
  */
75
- const listExisting = async (token, accountId) => {
311
+ const listExisting = async (token, accountId, kinds) => {
76
312
  const base = `/accounts/${accountId}`;
77
313
  const [kv, d1, r2, queues] = await Promise.all([
78
- cfGet(token, `${base}/storage/kv/namespaces`),
79
- cfGet(token, `${base}/d1/database`),
80
- cfGet(token, `${base}/r2/buckets`),
81
- cfGet(token, `${base}/queues`)
314
+ kinds.has("kv") ? cfGet(token, `${base}/storage/kv/namespaces`) : Promise.resolve([]),
315
+ kinds.has("d1") ? cfGet(token, `${base}/d1/database`) : Promise.resolve([]),
316
+ kinds.has("r2") ? cfGet(token, `${base}/r2/buckets`) : Promise.resolve({}),
317
+ kinds.has("queue") ? cfGet(token, `${base}/queues`) : Promise.resolve([])
82
318
  ]);
83
319
  return {
84
320
  kv: new Map(kv.map((namespace) => [namespace.title, namespace.id])),
@@ -88,82 +324,53 @@ const listExisting = async (token, accountId) => {
88
324
  };
89
325
  };
90
326
  //#endregion
91
- //#region src/plugins/deploy/infra/plan.ts
327
+ //#region src/plugins/deploy/auth/verify.ts
92
328
  /**
93
- * Decide whether a single declared resource already exists in the account, recovering its id
94
- * (kv/d1) when it does. Durable Objects are config-only (they ship with the script), so they are
95
- * always treated as "missing" — provisioning them is a no-op that just records the binding.
329
+ * @file deploy plugin `.env` token verification + account resolution.
96
330
  *
97
- * @param resource - The declared resource descriptor.
98
- * @param existing - The indexed set of resources already in the account.
99
- * @returns Whether it exists, plus the captured id for kv/d1.
100
- * @example
101
- * ```ts
102
- * checkExisting({ kind: "kv", binding: "SESSIONS" }, existing); // { exists: true, id: "ns123" }
103
- * ```
331
+ * Reads CLOUDFLARE_API_TOKEN via ctx.env, verifies it is active against the Cloudflare API, and
332
+ * resolves the account. Emits auth:verified. Throws a branded, actionable error (pointing at
333
+ * `auth setup`) when the token is absent, invalid, or inactive never an interactive login.
334
+ * Node-only; never imported by the runtime Worker bundle.
104
335
  */
105
- const checkExisting = (resource, existing) => {
106
- switch (resource.kind) {
107
- case "kv": {
108
- const id = existing.kv.get(resource.binding);
109
- return id === void 0 ? { exists: false } : {
110
- exists: true,
111
- id
112
- };
113
- }
114
- case "d1": {
115
- const id = existing.d1.get(resource.binding);
116
- return id === void 0 ? { exists: false } : {
117
- exists: true,
118
- id
119
- };
120
- }
121
- case "r2": return { exists: existing.r2.has(resource.bucket) };
122
- case "queue": return { exists: resource.producers.every((producer) => existing.queue.has(producer)) };
123
- case "do": return { exists: false };
124
- }
125
- };
336
+ /** Branded hint appended to every auth failure so the user knows the next step. */
337
+ const SETUP_HINT = "Run `auth setup` for the exact token to create.";
126
338
  /**
127
- * Run the read-only infra preflight: resolve the account, list existing resources, diff against
128
- * the manifest, emit `provision:plan`, and return the plan. Writes nothing.
339
+ * Verify the `.env` Cloudflare API token and resolve its account.
129
340
  *
130
341
  * @param ctx - The deploy plugin context (env + emit).
131
- * @param manifest - The assembled (or caller-supplied) deploy manifest.
132
- * @returns The infra plan: existing (with ids) vs missing resources.
133
- * @throws {Error} When the token is absent/invalid or a Cloudflare listing fails.
342
+ * @returns The verified auth status (account + id).
343
+ * @throws {Error} When the token is absent, invalid/expired, or not active.
134
344
  * @example
135
345
  * ```ts
136
- * const plan = await planInfra(ctx, manifest);
346
+ * const { account, accountId } = await verifyAuth(ctx);
137
347
  * ```
138
348
  */
139
- const planInfra = async (ctx, manifest) => {
140
- const token = ctx.env.require("CLOUDFLARE_API_TOKEN");
349
+ const verifyAuth = async (ctx) => {
350
+ const token = ctx.env.get("CLOUDFLARE_API_TOKEN");
351
+ if (token === void 0 || token === "") throw new Error(`[moku-worker] CLOUDFLARE_API_TOKEN is not set. ${SETUP_HINT}`);
352
+ let status;
353
+ try {
354
+ ({status} = await verifyToken(token));
355
+ } catch (error) {
356
+ throw new Error(`[moku-worker] Cloudflare API token is invalid or expired. ${SETUP_HINT}`, { cause: error });
357
+ }
358
+ if (status !== "active") throw new Error(`[moku-worker] Cloudflare API token is "${status}", not active. ${SETUP_HINT}`);
141
359
  const pinnedAccountId = ctx.env.get("CLOUDFLARE_ACCOUNT_ID");
142
- const account = pinnedAccountId ? {
360
+ const account = pinnedAccountId === void 0 || pinnedAccountId === "" ? await resolveAccount(token) : {
143
361
  id: pinnedAccountId,
144
362
  name: pinnedAccountId
145
- } : await resolveAccount(token);
146
- const existing = await listExisting(token, account.id);
147
- const exists = [];
148
- const missing = [];
149
- for (const resource of manifest.resources) {
150
- const check = checkExisting(resource, existing);
151
- if (check.exists) exists.push(check.id === void 0 ? { resource } : {
152
- resource,
153
- id: check.id
154
- });
155
- else missing.push(resource);
156
- }
157
- ctx.emit("provision:plan", {
158
- exists: exists.length,
159
- missing: missing.length,
160
- account: account.name
363
+ };
364
+ ctx.emit("auth:verified", {
365
+ account: account.name,
366
+ accountId: account.id,
367
+ scopes: []
161
368
  });
162
369
  return {
370
+ ok: true,
163
371
  account: account.name,
164
372
  accountId: account.id,
165
- exists,
166
- missing
373
+ scopes: []
167
374
  };
168
375
  };
169
376
  //#endregion
@@ -235,6 +442,431 @@ const runWrangler = (args) => new Promise((resolve, reject) => {
235
442
  resolve(args[0] === "deploy" ? extractDeployedUrl(stdout) : stdout);
236
443
  });
237
444
  });
445
+ /**
446
+ * Spawn `wrangler` with the given args, inheriting stdio so its output streams live to the user's
447
+ * terminal (used by the generic passthrough and long-lived commands like `tail`).
448
+ *
449
+ * @param args - Wrangler CLI arguments (e.g. ["kv", "namespace", "list"]).
450
+ * @returns Resolves once wrangler exits successfully.
451
+ * @throws {Error} When wrangler cannot be spawned or exits non-zero.
452
+ * @example
453
+ * ```ts
454
+ * await runWranglerInherit(["kv", "namespace", "list"]);
455
+ * ```
456
+ */
457
+ const runWranglerInherit = (args) => {
458
+ return new Promise((resolve, reject) => {
459
+ const child = spawn("wrangler", args, { stdio: "inherit" });
460
+ child.on("error", (error) => {
461
+ reject(/* @__PURE__ */ new Error(`[moku-worker] Failed to spawn wrangler.\n ${error.message}`));
462
+ });
463
+ child.on("close", (code) => {
464
+ if (code === 0) {
465
+ resolve();
466
+ return;
467
+ }
468
+ reject(/* @__PURE__ */ new Error(`[moku-worker] wrangler exited with code ${String(code)}.`));
469
+ });
470
+ });
471
+ };
472
+ //#endregion
473
+ //#region src/plugins/deploy/dev/build.ts
474
+ /**
475
+ * @file deploy plugin — dev site-rebuild resolution.
476
+ *
477
+ * Resolves HOW to rebuild the Moku web site on change: the in-process `webBuild` hook (preferred,
478
+ * fast, typed — passed call-time from the consumer's script or set as a config default) → a
479
+ * `buildCommand` shell string → an auto-detected `scripts/build.ts`. When nothing is configured,
480
+ * dev serves the worker only and says so. Subprocesses inherit the parent env by default.
481
+ * Node-only; never imported by the runtime Worker bundle.
482
+ */
483
+ /** Convention build script auto-detected when no webBuild/buildCommand is configured. */
484
+ const AUTO_DETECT = "scripts/build.ts";
485
+ /**
486
+ * Opportunistically read a numeric `files` count off an arbitrary web build result. A real web
487
+ * build returns its own summary shape (the worker framework cannot know it), so anything without a
488
+ * numeric `files` field reports 0.
489
+ *
490
+ * @param result - The resolved value of a {@link WebBuild} hook (any shape).
491
+ * @returns The `files` count when present and numeric, else 0.
492
+ * @example
493
+ * ```ts
494
+ * fileCountOf({ files: 12 }); // 12
495
+ * fileCountOf({ outDir: "dist", pageCount: 4 }); // 0
496
+ * ```
497
+ */
498
+ const fileCountOf = (result) => {
499
+ if (typeof result === "object" && result !== null && "files" in result) {
500
+ const { files } = result;
501
+ return typeof files === "number" ? files : 0;
502
+ }
503
+ return 0;
504
+ };
505
+ /**
506
+ * Run a shell build command, resolving on a zero exit and rejecting otherwise.
507
+ *
508
+ * @param command - The shell command to run (the consumer's own configured build).
509
+ * @returns Resolves once the command exits successfully.
510
+ * @throws {Error} When the command fails to start or exits non-zero.
511
+ * @example
512
+ * ```ts
513
+ * await runShellBuild("bun run scripts/build.ts");
514
+ * ```
515
+ */
516
+ const runShellBuild = (command) => {
517
+ return new Promise((resolve, reject) => {
518
+ const child = spawn(command, {
519
+ shell: true,
520
+ stdio: "inherit"
521
+ });
522
+ child.on("error", (error) => {
523
+ reject(/* @__PURE__ */ new Error(`[moku-worker] site build failed to start.\n ${error.message}`));
524
+ });
525
+ child.on("close", (code) => {
526
+ if (code === 0) {
527
+ resolve();
528
+ return;
529
+ }
530
+ reject(/* @__PURE__ */ new Error(`[moku-worker] site build exited with code ${String(code)}.`));
531
+ });
532
+ });
533
+ };
534
+ /**
535
+ * Rebuild the Moku web site using the resolved strategy: the call-time `webBuild` hook (the
536
+ * script-driven path), else the `webBuild` config default, else the `buildCommand` shell string,
537
+ * else an auto-detected `scripts/build.ts`. A hook's result is normalized to a `{ files }` count
538
+ * (0 when the hook reports none, and for the shell path where it is unknown).
539
+ *
540
+ * @param ctx - The deploy plugin context (config + emit).
541
+ * @param webBuild - Optional call-time web build hook (takes precedence over `ctx.config.webBuild`).
542
+ * @returns The rebuilt file count (0 for the shell path / a countless hook).
543
+ * @throws {Error} When the resolved shell build fails.
544
+ * @example
545
+ * ```ts
546
+ * const { files } = await buildSite(ctx, () => web.cli.build());
547
+ * ```
548
+ */
549
+ const buildSite = async (ctx, webBuild) => {
550
+ const hook = webBuild ?? ctx.config.webBuild;
551
+ if (hook !== void 0) return { files: fileCountOf(await hook()) };
552
+ const command = ctx.config.buildCommand || (existsSync(AUTO_DETECT) ? `bun run ${AUTO_DETECT}` : "");
553
+ if (command === "") {
554
+ ctx.emit("dev:error", { message: "No site build configured (pass webBuild or set buildCommand); serving worker only." });
555
+ return { files: 0 };
556
+ }
557
+ await runShellBuild(command);
558
+ return { files: 0 };
559
+ };
560
+ //#endregion
561
+ //#region src/plugins/deploy/dev/watch.ts
562
+ /**
563
+ * @file deploy plugin — debounced filesystem watcher for dev.
564
+ *
565
+ * Watches the top-level directories implied by the config globs (recursive) and fires a debounced
566
+ * change callback with the last changed path. Uses node:fs.watch — no extra dependency.
567
+ * Node-only; never imported by the runtime Worker bundle.
568
+ */
569
+ /**
570
+ * Derive the set of top-level directories to watch from glob patterns.
571
+ *
572
+ * @param globs - Watch globs (e.g. ["src/**\/*.ts", "public/**\/*"]).
573
+ * @returns The distinct top-level directories (e.g. ["src", "public"]).
574
+ * @example
575
+ * ```ts
576
+ * watchDirectories(["src/**\/*.ts", "public/**\/*"]); // ["src", "public"]
577
+ * ```
578
+ */
579
+ const watchDirectories = (globs) => {
580
+ const directories = /* @__PURE__ */ new Set();
581
+ for (const glob of globs) {
582
+ const globStart = glob.search(/[*?[{]/u);
583
+ const top = (globStart === -1 ? path.dirname(glob) : glob.slice(0, globStart)).split(/[/\\]/u).find((segment) => segment !== "") ?? ".";
584
+ directories.add(top);
585
+ }
586
+ return [...directories];
587
+ };
588
+ /**
589
+ * Watch the directories implied by `globs` and fire `onChange` (debounced by `debounceMs`) with
590
+ * the last changed path. Missing directories are skipped silently.
591
+ *
592
+ * @param globs - Watch globs.
593
+ * @param debounceMs - Coalesce rapid changes into one callback within this window.
594
+ * @param onChange - Called with the last changed path after the debounce settles.
595
+ * @returns A handle whose close() stops all watchers and cancels any pending callback.
596
+ * @example
597
+ * ```ts
598
+ * const handle = watchPaths(["src/**\/*.ts"], 120, p => rebuild(p));
599
+ * handle.close();
600
+ * ```
601
+ */
602
+ const watchPaths = (globs, debounceMs, onChange) => {
603
+ let timer;
604
+ let lastPath = "";
605
+ const fire = (changedPath) => {
606
+ lastPath = changedPath;
607
+ if (timer !== void 0) clearTimeout(timer);
608
+ timer = setTimeout(() => {
609
+ onChange(lastPath);
610
+ }, debounceMs);
611
+ };
612
+ const watchers = [];
613
+ for (const directory of watchDirectories(globs)) {
614
+ if (!existsSync(directory)) continue;
615
+ watchers.push(watch(directory, { recursive: true }, (_event, filename) => {
616
+ if (filename !== null) fire(path.join(directory, filename.toString()));
617
+ }));
618
+ }
619
+ return { close: () => {
620
+ if (timer !== void 0) clearTimeout(timer);
621
+ for (const watcher of watchers) watcher.close();
622
+ } };
623
+ };
624
+ //#endregion
625
+ //#region src/plugins/deploy/dev/runner.ts
626
+ /**
627
+ * @file deploy plugin — dev watch/recompile orchestrator.
628
+ *
629
+ * One long-lived session: cold-build the Moku site, optionally apply local D1 migrations, spawn
630
+ * `wrangler dev --live-reload` ONCE, then watch the site sources and rebuild on change (wrangler's
631
+ * asset server live-reloads the browser). Build failures keep the session serving the last good
632
+ * build. Tears down cleanly on SIGINT. Side-effecting work is injected via DevDeps so the
633
+ * orchestration is unit-testable without real processes, watchers, or signals.
634
+ * Node-only; never imported by the runtime Worker bundle.
635
+ */
636
+ /**
637
+ * Spawn the long-lived `wrangler dev` child (inherits the parent env; non-blocking).
638
+ *
639
+ * @param args - The `wrangler dev …` arguments.
640
+ * @returns A handle exposing kill().
641
+ * @example
642
+ * ```ts
643
+ * const child = spawnWranglerDev(["dev", "--port", "8787"]);
644
+ * ```
645
+ */
646
+ const spawnWranglerDev = (args) => {
647
+ const child = spawn("wrangler", args, { stdio: "inherit" });
648
+ return { kill: () => child.kill() };
649
+ };
650
+ /**
651
+ * Resolve when the user first interrupts the dev session (SIGINT).
652
+ *
653
+ * @returns A promise that settles on the first SIGINT.
654
+ * @example
655
+ * ```ts
656
+ * await waitForSigint();
657
+ * ```
658
+ */
659
+ const waitForSigint = () => {
660
+ return new Promise((resolve) => {
661
+ process.once("SIGINT", () => {
662
+ resolve();
663
+ });
664
+ });
665
+ };
666
+ /**
667
+ * Wall-clock timestamp in ms (extracted so realDevDeps holds only named references).
668
+ *
669
+ * @returns The current time in milliseconds.
670
+ * @example
671
+ * ```ts
672
+ * const t = nowMs();
673
+ * ```
674
+ */
675
+ const nowMs = () => Date.now();
676
+ /**
677
+ * Build the real (side-effecting) dev deps used by api.dev(). Subprocesses inherit the parent env.
678
+ *
679
+ * @returns The production DevDeps (real spawn / fs.watch / SIGINT / Date.now).
680
+ * @example
681
+ * ```ts
682
+ * await runDev(ctx, opts, realDevDeps());
683
+ * ```
684
+ */
685
+ const realDevDeps = () => ({
686
+ build: buildSite,
687
+ runWrangler,
688
+ spawnDev: spawnWranglerDev,
689
+ watch: watchPaths,
690
+ untilSignal: waitForSigint,
691
+ now: nowMs
692
+ });
693
+ /**
694
+ * The d1 binding to migrate locally, when a d1 plugin is present in the app.
695
+ *
696
+ * @param ctx - The deploy plugin context.
697
+ * @returns The d1 binding name, or undefined when no d1 plugin is present.
698
+ * @example
699
+ * ```ts
700
+ * const binding = d1Binding(ctx); // "DB" | undefined
701
+ * ```
702
+ */
703
+ const d1Binding = (ctx) => ctx.has("d1") ? ctx.require(d1Plugin).deployManifest().binding : void 0;
704
+ /**
705
+ * Rebuild the site once and announce the result. A failed build keeps the session alive (it just
706
+ * emits dev:error and serves the last good build).
707
+ *
708
+ * @param ctx - The deploy plugin context.
709
+ * @param deps - The injected dev deps.
710
+ * @param changedPath - The path that triggered the rebuild.
711
+ * @param webBuild - Optional call-time web build hook threaded into the rebuild.
712
+ * @returns Resolves once the rebuild attempt completes.
713
+ * @example
714
+ * ```ts
715
+ * await rebuild(ctx, deps, "src/app.tsx", () => web.cli.build());
716
+ * ```
717
+ */
718
+ const rebuild = async (ctx, deps, changedPath, webBuild) => {
719
+ ctx.emit("dev:phase", {
720
+ phase: "rebuild",
721
+ detail: changedPath
722
+ });
723
+ const started = deps.now();
724
+ try {
725
+ const { files } = await deps.build(ctx, webBuild);
726
+ ctx.emit("dev:rebuilt", {
727
+ files,
728
+ ms: deps.now() - started
729
+ });
730
+ } catch (error) {
731
+ ctx.emit("dev:error", { message: error instanceof Error ? error.message : String(error) });
732
+ }
733
+ };
734
+ /**
735
+ * Run a long-lived dev session: cold build → (local d1 migrate) → spawn `wrangler dev` →
736
+ * watch + rebuild on change → teardown on signal.
737
+ *
738
+ * @param ctx - The deploy plugin context (config + emit + require/has).
739
+ * @param opts - Optional options.
740
+ * @param opts.port - Local dev port (default 8787).
741
+ * @param opts.webBuild - Web build hook (re)run on cold build + each change (e.g. `() => web.cli.build()`).
742
+ * @param deps - Injected side effects (real ones from realDevDeps in production).
743
+ * @returns Resolves when the session ends (SIGINT).
744
+ * @example
745
+ * ```ts
746
+ * await runDev(ctx, { port: 8787, webBuild: () => web.cli.build() }, realDevDeps());
747
+ * ```
748
+ */
749
+ const runDev = async (ctx, opts, deps) => {
750
+ const port = opts?.port ?? 8787;
751
+ const webBuild = opts?.webBuild;
752
+ ctx.emit("dev:phase", {
753
+ phase: "build",
754
+ detail: "site"
755
+ });
756
+ await deps.build(ctx, webBuild);
757
+ const binding = d1Binding(ctx);
758
+ if (ctx.config.migrateLocal && binding !== void 0) {
759
+ ctx.emit("dev:phase", {
760
+ phase: "migrate",
761
+ detail: "d1 (local)"
762
+ });
763
+ await deps.runWrangler([
764
+ "d1",
765
+ "migrations",
766
+ "apply",
767
+ binding,
768
+ "--local"
769
+ ]);
770
+ }
771
+ ctx.emit("dev:phase", {
772
+ phase: "serve",
773
+ detail: `http://localhost:${String(port)}`
774
+ });
775
+ const child = deps.spawnDev([
776
+ "dev",
777
+ "--port",
778
+ String(port),
779
+ "--config",
780
+ ctx.config.configFile,
781
+ "--live-reload"
782
+ ]);
783
+ const watcher = deps.watch(ctx.config.watch, ctx.config.debounceMs, (changedPath) => rebuild(ctx, deps, changedPath, webBuild));
784
+ await deps.untilSignal();
785
+ watcher.close();
786
+ child.kill();
787
+ ctx.emit("dev:phase", { phase: "stopped" });
788
+ };
789
+ //#endregion
790
+ //#region src/plugins/deploy/infra/plan.ts
791
+ /**
792
+ * Decide whether a single declared resource already exists in the account, recovering its id
793
+ * (kv/d1) when it does. Durable Objects are config-only (they ship with the script), so they are
794
+ * always treated as "missing" — provisioning them is a no-op that just records the binding.
795
+ *
796
+ * @param resource - The declared resource descriptor.
797
+ * @param existing - The indexed set of resources already in the account.
798
+ * @returns Whether it exists, plus the captured id for kv/d1.
799
+ * @example
800
+ * ```ts
801
+ * checkExisting({ kind: "kv", binding: "SESSIONS" }, existing); // { exists: true, id: "ns123" }
802
+ * ```
803
+ */
804
+ const checkExisting = (resource, existing) => {
805
+ switch (resource.kind) {
806
+ case "kv": {
807
+ const id = existing.kv.get(resource.binding);
808
+ return id === void 0 ? { exists: false } : {
809
+ exists: true,
810
+ id
811
+ };
812
+ }
813
+ case "d1": {
814
+ const id = existing.d1.get(resource.binding);
815
+ return id === void 0 ? { exists: false } : {
816
+ exists: true,
817
+ id
818
+ };
819
+ }
820
+ case "r2": return { exists: existing.r2.has(resource.bucket) };
821
+ case "queue": return { exists: resource.producers.every((producer) => existing.queue.has(producer)) };
822
+ case "do": return { exists: false };
823
+ }
824
+ };
825
+ /**
826
+ * Run the read-only infra preflight: resolve the account, list existing resources, diff against
827
+ * the manifest, emit `provision:plan`, and return the plan. Writes nothing.
828
+ *
829
+ * @param ctx - The deploy plugin context (env + emit).
830
+ * @param manifest - The assembled (or caller-supplied) deploy manifest.
831
+ * @returns The infra plan: existing (with ids) vs missing resources.
832
+ * @throws {Error} When the token is absent/invalid or a Cloudflare listing fails.
833
+ * @example
834
+ * ```ts
835
+ * const plan = await planInfra(ctx, manifest);
836
+ * ```
837
+ */
838
+ const planInfra = async (ctx, manifest) => {
839
+ const token = ctx.env.require("CLOUDFLARE_API_TOKEN");
840
+ const pinnedAccountId = ctx.env.get("CLOUDFLARE_ACCOUNT_ID");
841
+ const account = pinnedAccountId ? {
842
+ id: pinnedAccountId,
843
+ name: pinnedAccountId
844
+ } : await resolveAccount(token);
845
+ const kinds = /* @__PURE__ */ new Set();
846
+ for (const resource of manifest.resources) if (resource.kind !== "do") kinds.add(resource.kind);
847
+ const existing = await listExisting(token, account.id, kinds);
848
+ const exists = [];
849
+ const missing = [];
850
+ for (const resource of manifest.resources) {
851
+ const check = checkExisting(resource, existing);
852
+ if (check.exists) exists.push(check.id === void 0 ? { resource } : {
853
+ resource,
854
+ id: check.id
855
+ });
856
+ else missing.push(resource);
857
+ }
858
+ ctx.emit("provision:plan", {
859
+ exists: exists.length,
860
+ missing: missing.length,
861
+ account: account.name
862
+ });
863
+ return {
864
+ account: account.name,
865
+ accountId: account.id,
866
+ exists,
867
+ missing
868
+ };
869
+ };
238
870
  //#endregion
239
871
  //#region src/plugins/deploy/providers/d1.ts
240
872
  /**
@@ -481,6 +1113,25 @@ const provisionResource = async (resource, ci) => {
481
1113
  }
482
1114
  };
483
1115
  //#endregion
1116
+ //#region src/plugins/deploy/tty.ts
1117
+ /**
1118
+ * @file deploy plugin — TTY detection (isolated so the guided flow is testable).
1119
+ *
1120
+ * The guided deploy only prompts on an interactive terminal; in a pipe or CI it must never block
1121
+ * on stdin. Kept in its own module so tests can mock it without stubbing `process.stdout`.
1122
+ * Node-only; never imported by the runtime Worker bundle.
1123
+ */
1124
+ /**
1125
+ * Whether stdout is an interactive TTY (so prompts are safe to show).
1126
+ *
1127
+ * @returns True when stdout is a terminal.
1128
+ * @example
1129
+ * ```ts
1130
+ * if (stdoutIsTty()) await prompts.confirm("Deploy?");
1131
+ * ```
1132
+ */
1133
+ const stdoutIsTty = () => process.stdout.isTTY === true;
1134
+ //#endregion
484
1135
  //#region src/plugins/deploy/wrangler-config.ts
485
1136
  /**
486
1137
  * @file deploy plugin — wrangler config generation + scaffold.
@@ -767,21 +1418,38 @@ const createDeployApi = (ctx) => ({
767
1418
  * it is used verbatim (universal path).
768
1419
  *
769
1420
  * @param opts - Optional run options.
770
- * @param opts.guided - Enable interactive confirmation steps (wired in a later phase).
771
- * @param opts.yes - Auto-confirm all prompts (wired in a later phase).
1421
+ * @param opts.guided - Enable interactive confirmation steps (only on a TTY, non-CI).
1422
+ * @param opts.yes - Auto-confirm all prompts (non-interactive / CI).
1423
+ * @param opts.webBuild - Build the web site first (e.g. `() => webApp.cli.build()`), before deploy.
772
1424
  * @param opts.manifest - Caller-supplied manifest (bypasses deployManifest() assembly).
773
1425
  * @returns Resolves once the deploy completes.
774
1426
  * @example
775
1427
  * ```ts
776
- * await api.run({ guided: true });
1428
+ * await api.run({ guided: true, webBuild: () => web.cli.build() });
777
1429
  * await api.run({ manifest: { name: "w", compatibilityDate: "2026-06-17", resources: [] } });
778
1430
  * ```
779
1431
  */
780
1432
  async run(opts) {
1433
+ const confirm = (opts?.guided ?? false) && !ctx.config.ci && !(opts?.yes ?? false) && stdoutIsTty() ? createBrandPrompts().confirm : async (_question) => true;
1434
+ ctx.emit("deploy:phase", { phase: "auth" });
1435
+ await verifyAuth(ctx);
1436
+ const webBuild = opts?.webBuild ?? ctx.config.webBuild;
1437
+ if (webBuild !== void 0) {
1438
+ ctx.emit("deploy:phase", {
1439
+ phase: "build",
1440
+ detail: "web"
1441
+ });
1442
+ await webBuild();
1443
+ }
781
1444
  ctx.emit("deploy:phase", { phase: "detect" });
782
1445
  const manifest = opts?.manifest ?? assembleManifest(ctx);
783
1446
  ctx.emit("deploy:phase", { phase: "provision" });
784
- const { ids } = await applyPlan(ctx, await planInfra(ctx, manifest));
1447
+ const plan = await planInfra(ctx, manifest);
1448
+ if (plan.missing.length > 0 && !await confirm(`Create ${plan.missing.length} missing resource(s) in "${plan.account}"?`)) {
1449
+ ctx.emit("deploy:phase", { phase: "aborted" });
1450
+ return;
1451
+ }
1452
+ const { ids } = await applyPlan(ctx, plan);
785
1453
  ctx.emit("deploy:phase", { phase: "wrangler-config" });
786
1454
  await writeWranglerConfig(ctx.config.configFile, manifest, ids);
787
1455
  const r2Resource = manifest.resources.find((resource) => resource.kind === "r2");
@@ -792,6 +1460,10 @@ const createDeployApi = (ctx) => ({
792
1460
  detail: `${String(count)} files`
793
1461
  });
794
1462
  }
1463
+ if (!await confirm(`Deploy "${manifest.name}" to ${ctx.global.stage}?`)) {
1464
+ ctx.emit("deploy:phase", { phase: "aborted" });
1465
+ return;
1466
+ }
795
1467
  ctx.emit("deploy:phase", { phase: "deploy" });
796
1468
  const url = await runWrangler([
797
1469
  "deploy",
@@ -801,25 +1473,20 @@ const createDeployApi = (ctx) => ({
801
1473
  ctx.emit("deploy:complete", { url });
802
1474
  },
803
1475
  /**
804
- * Start a local Cloudflare dev session via `wrangler dev`.
1476
+ * Start a long-lived local dev session: cold-build the Moku site, spawn `wrangler dev
1477
+ * --live-reload`, and watch the site sources — rebuilding on change (wrangler live-reloads the
1478
+ * browser). Resolves on SIGINT.
805
1479
  *
806
1480
  * @param opts - Optional options.
807
1481
  * @param opts.port - Local dev port (default 8787).
1482
+ * @param opts.webBuild - Rebuild the web site on change (e.g. `() => webApp.cli.build()`).
808
1483
  * @returns Resolves when the dev session ends.
809
1484
  * @example
810
1485
  * ```ts
811
- * await api.dev({ port: 8787 });
1486
+ * await api.dev({ port: 8787, webBuild: () => web.cli.build() });
812
1487
  * ```
813
1488
  */
814
- dev: async (opts) => {
815
- await runWrangler([
816
- "dev",
817
- "--port",
818
- String(opts?.port ?? 8787),
819
- "--config",
820
- ctx.config.configFile
821
- ]);
822
- },
1489
+ dev: (opts) => runDev(ctx, opts, realDevDeps()),
823
1490
  /**
824
1491
  * Scaffold a starting wrangler config (and CI files when ci is set).
825
1492
  * Idempotent: an existing config file is left untouched.
@@ -856,7 +1523,49 @@ const createDeployApi = (ctx) => ({
856
1523
  * const { created } = await api.provisionInfra(await api.checkInfra());
857
1524
  * ```
858
1525
  */
859
- provisionInfra: (plan) => applyPlan(ctx, plan)
1526
+ provisionInfra: (plan) => applyPlan(ctx, plan),
1527
+ /**
1528
+ * Verify the `.env` Cloudflare API token (must be active) and resolve its account; emits
1529
+ * auth:verified. Throws a branded error pointing at `auth setup` when absent/invalid/inactive.
1530
+ *
1531
+ * @returns The verified auth status (account + id).
1532
+ * @example
1533
+ * ```ts
1534
+ * const { account } = await api.verifyAuth();
1535
+ * ```
1536
+ */
1537
+ verifyAuth: () => verifyAuth(ctx),
1538
+ /**
1539
+ * Derive the minimum Cloudflare API token this app needs from its manifest (pure, no network).
1540
+ *
1541
+ * @returns The token requirement (full set + groups to add to the stock template).
1542
+ * @example
1543
+ * ```ts
1544
+ * const { toAdd } = api.requiredToken();
1545
+ * ```
1546
+ */
1547
+ requiredToken: () => requiredToken(assembleManifest(ctx)),
1548
+ /**
1549
+ * Render the `auth setup` guidance from the derived token requirement (pure, no network).
1550
+ *
1551
+ * @returns The rendered instruction text.
1552
+ * @example
1553
+ * ```ts
1554
+ * const text = api.tokenInstructions();
1555
+ * ```
1556
+ */
1557
+ tokenInstructions: () => tokenInstructions(assembleManifest(ctx)),
1558
+ /**
1559
+ * Run an arbitrary wrangler command, streaming its output (the branded CLI escape hatch).
1560
+ *
1561
+ * @param args - The wrangler arguments.
1562
+ * @returns Resolves once wrangler exits.
1563
+ * @example
1564
+ * ```ts
1565
+ * await api.wrangler(["kv", "namespace", "list"]);
1566
+ * ```
1567
+ */
1568
+ wrangler: (args) => runWranglerInherit(args)
860
1569
  });
861
1570
  /**
862
1571
  * Complex tier (node-only) — build-time deploy orchestrator over the five resource plugins.
@@ -873,7 +1582,11 @@ const createDeployApi = (ctx) => ({
873
1582
  const deployPlugin = createPlugin("deploy", {
874
1583
  config: {
875
1584
  configFile: "wrangler.jsonc",
876
- ci: false
1585
+ ci: false,
1586
+ watch: ["src/**/*.{ts,tsx,css}", "public/**/*"],
1587
+ buildCommand: "",
1588
+ migrateLocal: true,
1589
+ debounceMs: 120
877
1590
  },
878
1591
  depends: [
879
1592
  storagePlugin,
@@ -887,6 +1600,9 @@ const deployPlugin = createPlugin("deploy", {
887
1600
  //#endregion
888
1601
  //#region src/plugins/cli/api.ts
889
1602
  /**
1603
+ * @file cli plugin — API factory (dev, deploy, auth, doctor).
1604
+ */
1605
+ /**
890
1606
  * Builds app.cli.* — thin passthroughs to the deploy plugin via ctx.require(deployPlugin).
891
1607
  * Both verbs forward their opts verbatim; `dev` defaults port to ctx.config.port when no
892
1608
  * opts are supplied.
@@ -902,37 +1618,137 @@ const deployPlugin = createPlugin("deploy", {
902
1618
  */
903
1619
  const createCliApi = (ctx) => ({
904
1620
  /**
905
- * Run the Worker locally; defaults port to ctx.config.port (8787) when no opts supplied.
1621
+ * Run the Worker locally; defaults port to ctx.config.port (8787) when no opts supplied. A
1622
+ * `webBuild` hook (e.g. `() => webApp.cli.build()`) wires the web build into the dev loop so the
1623
+ * site recompiles on change — this is how an app-side script composes web + worker.
906
1624
  *
907
1625
  * @param opts - Optional local dev options.
908
1626
  * @param opts.port - Local dev port to bind. Defaults to ctx.config.port (8787).
1627
+ * @param opts.webBuild - Rebuild the web site on change (e.g. `() => webApp.cli.build()`).
909
1628
  * @returns Resolves when the dev session ends.
910
1629
  * @example
911
1630
  * ```ts
912
- * await api.dev(); // port 8787
913
- * await api.dev({ port: 3000 }); // port 3000
1631
+ * await api.dev(); // port 8787, worker only
1632
+ * await api.dev({ webBuild: () => web.cli.build() }); // wire the web build in
914
1633
  * ```
915
1634
  */
916
1635
  dev(opts) {
917
- return ctx.require(deployPlugin).dev(opts ?? { port: ctx.config.port });
1636
+ const port = opts?.port ?? ctx.config.port;
1637
+ return ctx.require(deployPlugin).dev(opts?.webBuild ? {
1638
+ port,
1639
+ webBuild: opts.webBuild
1640
+ } : { port });
918
1641
  },
919
1642
  /**
920
1643
  * One-command guided Cloudflare deploy; forwards flags verbatim to deploy.run.
921
- * Passes `undefined` when called with no opts (not a default empty object).
1644
+ * Passes `undefined` when called with no opts (not a default empty object). A `webBuild` hook
1645
+ * builds the web site first (before `wrangler deploy`) — how an app-side script ships web + worker.
922
1646
  *
923
1647
  * @param opts - Optional deploy options.
924
1648
  * @param opts.guided - Walk through each step interactively.
925
1649
  * @param opts.yes - Skip confirmation prompts (non-interactive / CI).
1650
+ * @param opts.webBuild - Build the web site first (e.g. `() => webApp.cli.build()`), before deploy.
926
1651
  * @returns Resolves once the deploy completes.
927
1652
  * @example
928
1653
  * ```ts
929
- * await api.deploy({ guided: true });
1654
+ * await api.deploy({ guided: true, webBuild: () => web.cli.build() });
930
1655
  * await api.deploy({ yes: true }); // CI
931
1656
  * await api.deploy(); // opts === undefined
932
1657
  * ```
933
1658
  */
934
1659
  deploy(opts) {
935
1660
  return ctx.require(deployPlugin).run(opts);
1661
+ },
1662
+ /**
1663
+ * Verify the `.env` token (no sub) or print the config-derived token guidance (`"setup"`),
1664
+ * rendered in Moku style. `setup` works without a token; verify reports the resolved account.
1665
+ *
1666
+ * @param sub - Pass "setup" to print guidance; omit to verify the current token.
1667
+ * @returns Resolves once the check or guidance render completes.
1668
+ * @example
1669
+ * ```ts
1670
+ * await api.auth("setup"); // print what token to create
1671
+ * await api.auth(); // verify the current token
1672
+ * ```
1673
+ */
1674
+ async auth(sub) {
1675
+ const deploy = ctx.require(deployPlugin);
1676
+ const ui = createBrandConsole();
1677
+ if (sub === "setup") {
1678
+ for (const line of deploy.tokenInstructions().split("\n")) ui.line(line);
1679
+ return;
1680
+ }
1681
+ try {
1682
+ const status = await deploy.verifyAuth();
1683
+ ui.check(true, "token valid", `account "${status.account}" (${status.accountId})`);
1684
+ } catch (error) {
1685
+ ui.error(error instanceof Error ? error.message : String(error));
1686
+ }
1687
+ },
1688
+ /**
1689
+ * One-shot preflight report: token + account (verifyAuth) then infra drift (checkInfra),
1690
+ * each as a branded check line. Stops after the token check when auth fails.
1691
+ *
1692
+ * @returns Resolves once the report is printed.
1693
+ * @example
1694
+ * ```ts
1695
+ * await api.doctor();
1696
+ * ```
1697
+ */
1698
+ async doctor() {
1699
+ const deploy = ctx.require(deployPlugin);
1700
+ const ui = createBrandConsole();
1701
+ ui.heading("doctor");
1702
+ let tokenOk = false;
1703
+ try {
1704
+ const status = await deploy.verifyAuth();
1705
+ tokenOk = true;
1706
+ ui.check(true, "token", `valid · account "${status.account}" (${status.accountId})`);
1707
+ } catch (error) {
1708
+ ui.check(false, "token", error instanceof Error ? error.message : String(error));
1709
+ }
1710
+ if (!tokenOk) {
1711
+ ui.line("Run `auth setup` for the exact token to create.");
1712
+ return;
1713
+ }
1714
+ try {
1715
+ const plan = await deploy.checkInfra();
1716
+ ui.check(true, "infra", `${plan.exists.length} exist, ${plan.missing.length} to create in "${plan.account}"`);
1717
+ } catch (error) {
1718
+ ui.check(false, "infra", error instanceof Error ? error.message : String(error));
1719
+ }
1720
+ },
1721
+ /**
1722
+ * Print the resolved Cloudflare account for the current `.env` token.
1723
+ *
1724
+ * @returns Resolves once the account summary is printed.
1725
+ * @example
1726
+ * ```ts
1727
+ * await api.whoami();
1728
+ * ```
1729
+ */
1730
+ async whoami() {
1731
+ const ui = createBrandConsole();
1732
+ try {
1733
+ const status = await ctx.require(deployPlugin).verifyAuth();
1734
+ ui.check(true, "account", `${status.account} (${status.accountId})`);
1735
+ } catch (error) {
1736
+ ui.error(error instanceof Error ? error.message : String(error));
1737
+ }
1738
+ },
1739
+ /**
1740
+ * Run an arbitrary wrangler command through the branded CLI (escape hatch). Streams its output.
1741
+ *
1742
+ * @param args - The wrangler arguments.
1743
+ * @returns Resolves once wrangler exits.
1744
+ * @example
1745
+ * ```ts
1746
+ * await api.wrangler(["kv", "namespace", "list"]);
1747
+ * ```
1748
+ */
1749
+ async wrangler(args) {
1750
+ createBrandConsole().heading(`wrangler ${args.join(" ")}`);
1751
+ await ctx.require(deployPlugin).wrangler(args);
936
1752
  }
937
1753
  });
938
1754
  //#endregion
@@ -1005,6 +1821,43 @@ const createCliHooks = (ctx) => ({
1005
1821
  ctx.log.info(`${p.kind} ${p.name} (exists)`);
1006
1822
  },
1007
1823
  /**
1824
+ * Log one dev-session phase: "phase" or "phase · detail".
1825
+ *
1826
+ * @param p - The dev:phase event payload.
1827
+ * @example
1828
+ * ```ts
1829
+ * handler({ phase: "serve", detail: "http://localhost:8787" }); // "serve · http://localhost:8787"
1830
+ * ```
1831
+ */
1832
+ "dev:phase"(p) {
1833
+ ctx.log.info(p.detail ? `${p.phase} · ${p.detail}` : p.phase);
1834
+ },
1835
+ /**
1836
+ * Log the site rebuild result: "site <n> files · <ms>ms" (omits the count when unknown).
1837
+ *
1838
+ * @param p - The dev:rebuilt event payload.
1839
+ * @example
1840
+ * ```ts
1841
+ * handler({ files: 12, ms: 240 }); // "site 12 files · 240ms"
1842
+ * handler({ files: 0, ms: 240 }); // "site · 240ms"
1843
+ * ```
1844
+ */
1845
+ "dev:rebuilt"(p) {
1846
+ ctx.log.info(p.files > 0 ? `site ${String(p.files)} files · ${String(p.ms)}ms` : `site · ${String(p.ms)}ms`);
1847
+ },
1848
+ /**
1849
+ * Log a non-fatal dev build failure via warn (the session keeps serving the last good build).
1850
+ *
1851
+ * @param p - The dev:error event payload.
1852
+ * @example
1853
+ * ```ts
1854
+ * handler({ message: "build failed" }); // warn "build failed"
1855
+ * ```
1856
+ */
1857
+ "dev:error"(p) {
1858
+ ctx.log.warn(p.message);
1859
+ },
1860
+ /**
1008
1861
  * Log the terminal success line with the deployed URL.
1009
1862
  *
1010
1863
  * @param p - The deploy:complete event payload.