@moku-labs/worker 0.6.0 → 0.7.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.
@@ -1,10 +1,10 @@
1
1
  import { envPlugin, logPlugin } from "@moku-labs/common";
2
2
  import { createCoreConfig, createCorePlugin } from "@moku-labs/core";
3
3
  import { brandedSink, createBrandConsole, createBrandPrompts } from "@moku-labs/common/cli";
4
+ import { access, readdir, stat, writeFile } from "node:fs/promises";
5
+ import path from "node:path";
4
6
  import { spawn } from "node:child_process";
5
7
  import { existsSync, readFileSync, watch } from "node:fs";
6
- import path from "node:path";
7
- import { readdir, stat, writeFile } from "node:fs/promises";
8
8
  /**
9
9
  * stage core plugin — deployment-stage / dev-mode detection, flat-injected on
10
10
  * every regular plugin's context as `ctx.stage` (spec/02 §6). No state, no
@@ -158,115 +158,173 @@ const bindingsPlugin = createPlugin("bindings", {
158
158
  api: createBindingsApi
159
159
  });
160
160
  //#endregion
161
+ //#region src/instances.ts
162
+ /**
163
+ * Resolve the default instance key from a keyed-map config: the sole entry, or the one flagged
164
+ * `default: true`. Throws a branded error when there are no instances, or several without (or with
165
+ * more than one) `default: true`.
166
+ *
167
+ * @param instances - The keyed-map config (`Record<key, instance>`).
168
+ * @param kind - The resource kind, for the error message (e.g. "kv", "d1").
169
+ * @returns The default instance's key.
170
+ * @throws {Error} With a `[moku-worker]` prefix when no single default can be resolved.
171
+ * @example
172
+ * ```ts
173
+ * defaultInstanceKey({ main: { name: "db", binding: "DB" } }, "d1"); // "main"
174
+ * ```
175
+ */
176
+ const defaultInstanceKey = (instances, kind) => {
177
+ const keys = Object.keys(instances);
178
+ if (keys.length === 0) throw new Error(`[moku-worker] No ${kind} instance is configured.`);
179
+ if (keys.length === 1) return keys[0];
180
+ const flagged = keys.filter((key) => instances[key]?.default === true);
181
+ if (flagged.length === 1) return flagged[0];
182
+ throw new Error(`[moku-worker] ${kind} has ${String(keys.length)} instances — mark exactly one with \`default: true\`.`);
183
+ };
184
+ /**
185
+ * Look up a resource instance by key, with a branded error listing the configured keys when absent.
186
+ *
187
+ * @param instances - The keyed-map config (`Record<key, instance>`).
188
+ * @param key - The instance key to resolve (the `use(key)` selector).
189
+ * @param kind - The resource kind, for the error message.
190
+ * @returns The instance at `key`.
191
+ * @throws {Error} With a `[moku-worker]` prefix when `key` is not configured.
192
+ * @example
193
+ * ```ts
194
+ * pickInstance(cfg, "analytics", "d1");
195
+ * ```
196
+ */
197
+ const pickInstance = (instances, key, kind) => {
198
+ const instance = instances[key];
199
+ if (instance === void 0) {
200
+ const configured = Object.keys(instances).join(", ") || "(none)";
201
+ throw new Error(`[moku-worker] No ${kind} instance "${key}". Configured: ${configured}.`);
202
+ }
203
+ return instance;
204
+ };
205
+ //#endregion
161
206
  //#region src/plugins/d1/api.ts
162
207
  /**
163
- * Create the d1 api. Each method resolves the D1Database off the request
164
- * `env` via the bindings plugin, then forwards to the native D1 call. The
165
- * binding is never cached, so concurrent requests stay isolated (SB4).
208
+ * Create the d1 api over a keyed map of database instances. The default-database methods and
209
+ * `use(key)` both resolve the `D1Database` off the REQUEST-SUPPLIED env on every call — env is
210
+ * threaded, never stored (SB4), so concurrent requests stay isolated — and the instance key is
211
+ * resolved lazily by binding-getter so an unconfigured-but-present plugin only errors when actually
212
+ * called.
166
213
  *
167
214
  * The return is intentionally NOT annotated `: Api`. Annotating it would
168
215
  * collapse the per-method call-site generic `<T>` on `query`/`first` to
169
216
  * `unknown`; instead the implementation forwards `<T>` to `all<T>()` /
170
217
  * `first<T>()` and `types.ts#Api` remains the public-surface source of truth.
171
218
  *
172
- * @param {D1Ctx} ctx - Plugin context (own config + require).
173
- * @returns {object} The d1 public api (query, first, run, batch, prepare, deployManifest).
219
+ * @param {D1Ctx} ctx - Plugin context (keyed-map config + require).
220
+ * @returns {object} The d1 public api (query, first, run, batch, prepare, use, deployManifest).
174
221
  * @example
175
222
  * ```typescript
176
223
  * const api = createD1Api(ctx);
177
224
  * const { results } = await api.query<Product>(env, "SELECT * FROM products");
225
+ * await api.use("analytics").run(env, "INSERT INTO events (name) VALUES (?)", "click");
178
226
  * ```
179
227
  */
180
228
  const createD1Api = (ctx) => {
181
- const db = (env) => ctx.require(bindingsPlugin).require(env, ctx.config.binding);
229
+ const bindings = ctx.require(bindingsPlugin);
230
+ const surface = (binding) => {
231
+ const db = (env) => bindings.require(env, binding());
232
+ return {
233
+ /**
234
+ * Run a statement against this database and return all rows.
235
+ *
236
+ * @param env - The per-request Cloudflare env.
237
+ * @param sql - SQL with `?` placeholders.
238
+ * @param params - Bind parameters for the placeholders.
239
+ * @returns All rows in a D1 result.
240
+ * @example
241
+ * ```typescript
242
+ * const { results } = await api.query<Product>(env, "SELECT * FROM products");
243
+ * ```
244
+ */
245
+ query: (env, sql, ...params) => db(env).prepare(sql).bind(...params).all(),
246
+ /**
247
+ * Run a statement against this database and return the first row, or null when none.
248
+ *
249
+ * @param env - The per-request Cloudflare env.
250
+ * @param sql - SQL with `?` placeholders.
251
+ * @param params - Bind parameters for the placeholders.
252
+ * @returns The first row, or null if none.
253
+ * @example
254
+ * ```typescript
255
+ * const product = await api.first<Product>(env, "SELECT * FROM products WHERE id = ?", 1);
256
+ * ```
257
+ */
258
+ first: (env, sql, ...params) => db(env).prepare(sql).bind(...params).first(),
259
+ /**
260
+ * Run a write/DDL statement against this database and return its result meta.
261
+ *
262
+ * @param env - The per-request Cloudflare env.
263
+ * @param sql - SQL with `?` placeholders.
264
+ * @param params - Bind parameters for the placeholders.
265
+ * @returns Result carrying `.meta`.
266
+ * @example
267
+ * ```typescript
268
+ * await api.run(env, "INSERT INTO events (name) VALUES (?)", "click");
269
+ * ```
270
+ */
271
+ run: (env, sql, ...params) => db(env).prepare(sql).bind(...params).run(),
272
+ /**
273
+ * Execute caller-built prepared statements atomically in one round-trip.
274
+ *
275
+ * @param env - The per-request Cloudflare env.
276
+ * @param stmts - Caller-built prepared statements.
277
+ * @returns One result per statement, order preserved.
278
+ * @example
279
+ * ```typescript
280
+ * await api.batch(env, [api.prepare(env).prepare("INSERT INTO t (id) VALUES (1)")]);
281
+ * ```
282
+ */
283
+ batch: (env, stmts) => db(env).batch(stmts),
284
+ /**
285
+ * Resolve the request `D1Database` so callers can build statements for `batch()`.
286
+ *
287
+ * @param env - The per-request Cloudflare env.
288
+ * @returns The request-resolved database handle.
289
+ * @example
290
+ * ```typescript
291
+ * const stmt = api.prepare(env).prepare("SELECT * FROM products");
292
+ * ```
293
+ */
294
+ prepare: (env) => db(env)
295
+ };
296
+ };
297
+ const defaultBinding = () => pickInstance(ctx.config, defaultInstanceKey(ctx.config, "d1"), "d1").binding;
182
298
  return {
299
+ ...surface(defaultBinding),
183
300
  /**
184
- * Run a statement and return all rows. Forwards the call-site generic to
185
- * `all<T>()` so the result type is not widened to `unknown`.
186
- *
187
- * @param {WorkerEnv} env - Per-request Cloudflare bindings object.
188
- * @param {string} sql - SQL text with `?` placeholders.
189
- * @param {unknown[]} params - Bind parameters, in placeholder order.
190
- * @returns {Promise<D1Result<T>>} All rows (`.results` is `T[]`).
191
- * @example
192
- * ```typescript
193
- * const { results } = await api.query<Product>(env, "SELECT * FROM products WHERE active = ?", 1);
194
- * ```
195
- */
196
- query: (env, sql, ...params) => db(env).prepare(sql).bind(...params).all(),
197
- /**
198
- * Run a statement and return the first row, or `null` if there are none.
199
- * Forwards the call-site generic to `first<T>()`.
200
- *
201
- * @param {WorkerEnv} env - Per-request Cloudflare bindings object.
202
- * @param {string} sql - SQL text with `?` placeholders.
203
- * @param {unknown[]} params - Bind parameters, in placeholder order.
204
- * @returns {Promise<T | null>} The first row, or `null` if none matched.
205
- * @example
206
- * ```typescript
207
- * const row = await api.first<Product>(env, "SELECT * FROM products WHERE id = ?", id);
208
- * ```
209
- */
210
- first: (env, sql, ...params) => db(env).prepare(sql).bind(...params).first(),
211
- /**
212
- * Run a write/DDL statement (INSERT/UPDATE/DELETE/DDL) and return the
213
- * D1 result carrying `.meta` (e.g. `rows_written`, `last_row_id`).
214
- *
215
- * @param {WorkerEnv} env - Per-request Cloudflare bindings object.
216
- * @param {string} sql - SQL text with `?` placeholders.
217
- * @param {unknown[]} params - Bind parameters, in placeholder order.
218
- * @returns {Promise<D1Result>} Result carrying `.meta`.
219
- * @example
220
- * ```typescript
221
- * const res = await api.run(env, "INSERT INTO products (name) VALUES (?)", name);
222
- * const id = res.meta.last_row_id;
223
- * ```
224
- */
225
- run: (env, sql, ...params) => db(env).prepare(sql).bind(...params).run(),
226
- /**
227
- * Execute caller-built prepared statements atomically in one round-trip,
228
- * returning one result per statement in order.
301
+ * Select a specific D1 database instance by its config key.
229
302
  *
230
- * @param {WorkerEnv} env - Per-request Cloudflare bindings object.
231
- * @param {D1PreparedStatement[]} stmts - Statements built from prepare(env).
232
- * @returns {Promise<D1Result[]>} One result per statement, order preserved.
303
+ * @param key - The instance key (as configured under `pluginConfigs.d1`).
304
+ * @returns The SQL surface bound to that database.
233
305
  * @example
234
306
  * ```typescript
235
- * const handle = api.prepare(env);
236
- * await api.batch(env, [handle.prepare("INSERT INTO a VALUES (1)").bind()]);
307
+ * await api.use("analytics").run(env, "INSERT INTO events (name) VALUES (?)", "click");
237
308
  * ```
238
309
  */
239
- batch: (env, stmts) => db(env).batch(stmts),
310
+ use: (key) => surface(() => pickInstance(ctx.config, key, "d1").binding),
240
311
  /**
241
- * Resolve the request-scoped D1Database so callers can build prepared
242
- * statements for batch(). Issues no query itself.
312
+ * Return this plugin's deploy metadata one descriptor per configured database.
243
313
  *
244
- * @param {WorkerEnv} env - Per-request Cloudflare bindings object.
245
- * @returns {D1Database} The request-resolved database handle.
314
+ * @returns One d1 deploy descriptor per instance.
246
315
  * @example
247
316
  * ```typescript
248
- * const handle = api.prepare(env);
249
- * const stmt = handle.prepare("SELECT * FROM t").bind();
317
+ * const manifest = api.deployManifest(); // [{ kind: "d1", name: "tracker-db", binding: "DB" }]
250
318
  * ```
251
319
  */
252
- prepare: (env) => db(env),
253
- /**
254
- * Return this plugin's deploy metadata for the deploy plugin to read.
255
- * Build-time only — takes no `env`. The return is typed `DeployManifest`
256
- * (from types.ts), which pins `kind` to the literal `"d1"` without an
257
- * inline `as` assertion.
258
- *
259
- * @returns {DeployManifest} Deploy manifest entry `{ kind: "d1", binding, migrations }`.
260
- * @example
261
- * ```typescript
262
- * const m = api.deployManifest();
263
- * // => { kind: "d1", binding: "DB", migrations: "./migrations" }
264
- * ```
265
- */
266
- deployManifest: () => ({
267
- kind: "d1",
268
- binding: ctx.config.binding,
269
- migrations: ctx.config.migrations
320
+ deployManifest: () => Object.values(ctx.config).map((instance) => {
321
+ const entry = {
322
+ kind: "d1",
323
+ name: instance.name,
324
+ binding: instance.binding
325
+ };
326
+ if (instance.migrations !== void 0) entry.migrations = instance.migrations;
327
+ return entry;
270
328
  })
271
329
  };
272
330
  };
@@ -281,10 +339,7 @@ const createD1Api = (ctx) => {
281
339
  */
282
340
  const d1Plugin = createPlugin("d1", {
283
341
  depends: [bindingsPlugin],
284
- config: {
285
- binding: "DB",
286
- migrations: ""
287
- },
342
+ config: {},
288
343
  api: (ctx) => createD1Api(ctx)
289
344
  });
290
345
  //#endregion
@@ -293,59 +348,62 @@ const d1Plugin = createPlugin("d1", {
293
348
  * Builds the `app.durableObjects` API surface — `get` and `deployManifest`.
294
349
  *
295
350
  * All namespace resolution uses the per-call `env` argument: `env` is threaded, never
296
- * stored (SB4 / design §1a). The config bindings map is frozen and read-only. No state
351
+ * stored (SB4 / design §1a). The keyed-map config is frozen and read-only. No state
297
352
  * is held on the plugin between calls (stateless — `Record<string, never>`).
298
353
  *
299
- * @param ctx - Plugin context with `config.bindings`, `require(bindingsPlugin)`, and core APIs.
354
+ * @param ctx - Plugin context with the keyed-map `config`, `require(bindingsPlugin)`, and core APIs.
300
355
  * @returns The durableObjects API: `{ get, deployManifest }`.
301
356
  * @example
302
357
  * ```typescript
303
358
  * const api = createDoApi(ctx);
304
- * const stub = api.get(env, "counter", "room-42");
305
- * const manifest = api.deployManifest(); // { kind: "do", bindings: { counter: "COUNTER" } }
359
+ * const stub = api.get(env, "board", "room-42");
360
+ * const manifest = api.deployManifest(); // [{ kind: "do", binding: "BOARD", className: "BoardChannel" }]
306
361
  * ```
307
362
  */
308
363
  const createDoApi = (ctx) => ({
309
364
  /**
310
365
  * Resolves a `DurableObjectStub` off the per-request env.
311
366
  *
312
- * Maps `logicalName` `config.bindings[logicalName]` (falling back to `logicalName`
313
- * itself when unmapped), derives a deterministic id via `namespace.idFromName(idName)`,
314
- * and returns the addressed stub. Synchronous — returns a stub, not a Promise.
315
- * Throws (via the bindings resolver) when the binding is not present on `env`.
367
+ * Selects the configured instance by `logicalName` (the config key) via `pickInstance`, resolves
368
+ * its `binding` off `env`, derives a deterministic id via `namespace.idFromName(idName)`, and
369
+ * returns the addressed stub. Synchronous — returns a stub, not a Promise. Throws (branded) when
370
+ * `logicalName` is not configured, or (via the bindings resolver) when the binding is not present
371
+ * on `env`.
316
372
  *
317
373
  * @param env - Per-request Cloudflare bindings object (Worker fetch/queue/scheduled env).
318
- * @param logicalName - Logical DO name used in code (e.g. `"counter"`).
374
+ * @param logicalName - Logical DO key (selects the configured instance, e.g. `"board"`).
319
375
  * @param idName - Stable id name passed to `idFromName` (e.g. `"room-42"`).
320
376
  * @returns The addressed `DurableObjectStub`.
321
- * @throws {Error} With `[moku-worker]` prefix when the binding is not bound on `env`.
377
+ * @throws {Error} With `[moku-worker]` prefix when `logicalName` is not configured, or when the
378
+ * binding is not bound on `env`.
322
379
  * @example
323
380
  * ```typescript
324
- * const stub = app.durableObjects.get(env, "counter", "room-42");
381
+ * const stub = app.durableObjects.get(env, "board", "room-42");
325
382
  * const res = await stub.fetch("https://do/increment");
326
383
  * ```
327
384
  */
328
385
  get: (env, logicalName, idName) => {
329
- const binding = ctx.config.bindings[logicalName] ?? logicalName;
386
+ const binding = pickInstance(ctx.config, logicalName, "durableObjects").binding;
330
387
  const ns = ctx.require(bindingsPlugin).require(env, binding);
331
388
  return ns.get(ns.idFromName(idName));
332
389
  },
333
390
  /**
334
- * Returns this plugin's deploy metadata — read by the `deploy` plugin via
335
- * `ctx.require(durableObjectsPlugin)`. Never reads sibling `pluginConfigs` (F6;
336
- * spec/08 §5, §7). Pure synchronous read of `ctx.config.bindings`.
391
+ * Returns this plugin's deploy metadata — one entry per configured instance, read by the `deploy`
392
+ * plugin via `ctx.require(durableObjectsPlugin)`. Never reads sibling `pluginConfigs` (F6;
393
+ * spec/08 §5, §7). Pure synchronous read of `ctx.config`.
337
394
  *
338
- * @returns `{ kind: "do", bindings }` reflecting the frozen plugin config.
395
+ * @returns One `{ kind: "do", binding, className }` per configured instance.
339
396
  * @example
340
397
  * ```typescript
341
398
  * const manifest = app.durableObjects.deployManifest();
342
- * // → { kind: "do", bindings: { counter: "COUNTER" } }
399
+ * // → [{ kind: "do", binding: "BOARD", className: "BoardChannel" }]
343
400
  * ```
344
401
  */
345
- deployManifest: () => ({
402
+ deployManifest: () => Object.values(ctx.config).map((instance) => ({
346
403
  kind: "do",
347
- bindings: ctx.config.bindings
348
- })
404
+ binding: instance.binding,
405
+ className: instance.className
406
+ }))
349
407
  });
350
408
  //#endregion
351
409
  //#region src/plugins/durable-objects/helpers.ts
@@ -418,17 +476,17 @@ const defineDurableObject = (name) => {
418
476
  * Cloudflare Durable Objects plugin — Standard tier.
419
477
  *
420
478
  * Exposes `get(env, logicalName, idName)` (synchronous stub accessor, threaded env) and
421
- * `deployManifest()` (build-time metadata). Depends on `bindingsPlugin` for namespace
422
- * resolution. The `defineDurableObject` helper is mounted under `helpers` and re-exported
423
- * at the top level for consumer use.
479
+ * `deployManifest()` (build-time metadata, one entry per configured instance). Depends on
480
+ * `bindingsPlugin` for namespace resolution. The `defineDurableObject` helper is mounted under
481
+ * `helpers` and re-exported at the top level for consumer use.
424
482
  *
425
483
  * @example
426
484
  * ```typescript
427
485
  * // Consumer endpoint handler:
428
- * const stub = app.durableObjects.get(env, "counter", params.room!);
486
+ * const stub = app.durableObjects.get(env, "board", params.room!);
429
487
  * const res = await stub.fetch("https://do/increment");
430
- * // Consumer DO class:
431
- * export class Counter extends defineDurableObject("Counter") {
488
+ * // Consumer DO class (the EXPORTED className referenced by the "board" instance):
489
+ * export class BoardChannel extends defineDurableObject("BoardChannel") {
432
490
  * async fetch(): Promise<Response> { return new Response("ok"); }
433
491
  * }
434
492
  * ```
@@ -436,91 +494,112 @@ const defineDurableObject = (name) => {
436
494
  */
437
495
  const durableObjectsPlugin = createPlugin("durableObjects", {
438
496
  depends: [bindingsPlugin],
439
- config: { bindings: {} },
497
+ config: {},
440
498
  api: createDoApi,
441
499
  helpers: { defineDurableObject }
442
500
  });
443
501
  //#endregion
444
502
  //#region src/plugins/kv/api.ts
445
503
  /**
446
- * Builds the app.kv.* api. Resolves the KV namespace off the REQUEST-SUPPLIED env
447
- * on every call — env is threaded, never stored (design §1a / SB4).
504
+ * Builds the app.kv.* api over a keyed map of namespace instances. The default-namespace methods and
505
+ * `use(key)` both resolve the namespace off the REQUEST-SUPPLIED env on every call — env is threaded,
506
+ * never stored (design §1a / SB4) — and the instance key is resolved lazily so an unconfigured-but-
507
+ * present plugin only errors when actually called.
448
508
  *
449
- * @param ctx - The kv plugin context (own config + merged events).
450
- * @returns The app.kv api: get / put / delete / list / deployManifest.
509
+ * @param ctx - The kv plugin context (keyed-map config + merged events).
510
+ * @returns The app.kv api: get / put / delete / list / use / deployManifest.
451
511
  * @example
452
512
  * ```typescript
453
513
  * const api = createKvApi(ctx);
454
514
  * const value = await api.get(env, "key");
515
+ * await api.use("sessions").put(env, "s:1", "data");
455
516
  * ```
456
517
  */
457
518
  const createKvApi = (ctx) => {
458
- const ns = (env) => ctx.require(bindingsPlugin).require(env, ctx.config.binding);
519
+ const bindings = ctx.require(bindingsPlugin);
520
+ const surface = (binding) => {
521
+ const ns = (env) => bindings.require(env, binding());
522
+ return {
523
+ /**
524
+ * Read a value by key from this namespace. Returns null when absent.
525
+ *
526
+ * @param env - The per-request Cloudflare env.
527
+ * @param key - The key to read.
528
+ * @returns The stored value, or null when absent.
529
+ * @example
530
+ * ```typescript
531
+ * const value = await api.get(env, "feature-flags");
532
+ * ```
533
+ */
534
+ get: async (env, key) => ns(env).get(key),
535
+ /**
536
+ * Write a string value under a key, optionally with KV put options.
537
+ *
538
+ * @param env - The per-request Cloudflare env.
539
+ * @param key - The key to write.
540
+ * @param value - The string value to store.
541
+ * @param opts - Optional expiration / metadata.
542
+ * @returns Resolves once the write is acknowledged.
543
+ * @example
544
+ * ```typescript
545
+ * await api.put(env, "session:1", "data", { expirationTtl: 3600 });
546
+ * ```
547
+ */
548
+ put: async (env, key, value, opts) => ns(env).put(key, value, opts),
549
+ /**
550
+ * Remove a key from this namespace (no-op if absent).
551
+ *
552
+ * @param env - The per-request Cloudflare env.
553
+ * @param key - The key to delete.
554
+ * @returns Resolves once the delete is acknowledged.
555
+ * @example
556
+ * ```typescript
557
+ * await api.delete(env, "session:expired");
558
+ * ```
559
+ */
560
+ delete: async (env, key) => ns(env).delete(key),
561
+ /**
562
+ * List keys in this namespace, optionally filtered/paginated.
563
+ *
564
+ * @param env - The per-request Cloudflare env.
565
+ * @param opts - Optional prefix / cursor / limit.
566
+ * @returns The list result.
567
+ * @example
568
+ * ```typescript
569
+ * const { keys } = await api.list(env, { prefix: "session:" });
570
+ * ```
571
+ */
572
+ list: async (env, opts) => ns(env).list(opts)
573
+ };
574
+ };
575
+ const defaultBinding = () => pickInstance(ctx.config, defaultInstanceKey(ctx.config, "kv"), "kv").binding;
459
576
  return {
577
+ ...surface(defaultBinding),
460
578
  /**
461
- * Reads a value by key from the KV namespace. Returns null when absent.
579
+ * Select a specific KV namespace instance by its config key.
462
580
  *
463
- * @param env - The per-request Cloudflare env (threaded, never stored).
464
- * @param key - The key to read.
465
- * @returns The stored value, or null when absent.
581
+ * @param key - The instance key (as configured under `pluginConfigs.kv`).
582
+ * @returns The key/value surface bound to that namespace.
466
583
  * @example
467
584
  * ```typescript
468
- * const value = await api.get(env, "feature-flags");
585
+ * await api.use("sessions").get(env, "s:1");
469
586
  * ```
470
587
  */
471
- get: async (env, key) => ns(env).get(key),
588
+ use: (key) => surface(() => pickInstance(ctx.config, key, "kv").binding),
472
589
  /**
473
- * Writes a string value under a key, optionally with KV put options.
590
+ * Return this plugin's deploy metadata one descriptor per configured namespace.
474
591
  *
475
- * @param env - The per-request Cloudflare env.
476
- * @param key - The key to write.
477
- * @param value - The string value to store.
478
- * @param opts - Optional expiration / metadata.
479
- * @returns Resolves once the write is acknowledged.
592
+ * @returns One kv deploy descriptor per instance.
480
593
  * @example
481
594
  * ```typescript
482
- * await api.put(env, "session:1", "data", { expirationTtl: 3600 });
595
+ * const manifest = api.deployManifest(); // [{ kind: "kv", name: "tracker-cache", binding: "CACHE" }]
483
596
  * ```
484
597
  */
485
- put: async (env, key, value, opts) => ns(env).put(key, value, opts),
486
- /**
487
- * Removes a key from the namespace (no-op if absent).
488
- *
489
- * @param env - The per-request Cloudflare env.
490
- * @param key - The key to delete.
491
- * @returns Resolves once the delete is acknowledged.
492
- * @example
493
- * ```typescript
494
- * await api.delete(env, "session:expired");
495
- * ```
496
- */
497
- delete: async (env, key) => ns(env).delete(key),
498
- /**
499
- * Lists keys in the namespace, optionally filtered/paginated via opts.
500
- *
501
- * @param env - The per-request Cloudflare env.
502
- * @param opts - Optional prefix / cursor / limit.
503
- * @returns The list result from the KV namespace.
504
- * @example
505
- * ```typescript
506
- * const { keys } = await api.list(env, { prefix: "session:" });
507
- * ```
508
- */
509
- list: async (env, opts) => ns(env).list(opts),
510
- /**
511
- * Returns this plugin's own deploy metadata, read by the deploy plugin via
512
- * require (design §6 / F6). Build-time only — takes no env.
513
- *
514
- * @returns The kv deploy descriptor with kind literal and binding name.
515
- * @example
516
- * ```typescript
517
- * const manifest = api.deployManifest(); // { kind: "kv", binding: "KV" }
518
- * ```
519
- */
520
- deployManifest: () => ({
598
+ deployManifest: () => Object.values(ctx.config).map((instance) => ({
521
599
  kind: "kv",
522
- binding: ctx.config.binding
523
- })
600
+ name: instance.name,
601
+ binding: instance.binding
602
+ }))
524
603
  };
525
604
  };
526
605
  /**
@@ -534,109 +613,113 @@ const createKvApi = (ctx) => {
534
613
  */
535
614
  const kvPlugin = createPlugin("kv", {
536
615
  depends: [bindingsPlugin],
537
- config: { binding: "KV" },
616
+ config: {},
538
617
  api: createKvApi
539
618
  });
540
619
  //#endregion
541
620
  //#region src/plugins/queues/api.ts
542
621
  /**
543
- * @file queues plugin API factory (send, sendBatch, consume, deployManifest).
622
+ * Resolve the instance a consumed batch belongs to. With a single instance, that instance always
623
+ * matches. With several, match the instance whose `name` equals `batch.queue` OR whose stage-suffixed
624
+ * form (`${name}-`) prefixes it (tolerant of the deploy stage suffix, e.g. `tracker-activity-dev`);
625
+ * fall back to the default instance when nothing matches.
544
626
  *
545
- * All binding-resolving methods take the per-request `env` as the first argument
546
- * and resolve the `Queue` via `ctx.require(bindingsPlugin).require<Queue>(env, name)`.
547
- * The `env` is never stored (SB4 / design §1a) — resolved fresh on every call.
627
+ * @param config - The keyed-map queues config.
628
+ * @param queueName - The CF queue name from `batch.queue`.
629
+ * @returns The matched `QueueInstance` (its `onMessage` is what `consume` awaits).
630
+ * @example
631
+ * ```ts
632
+ * routeInstance(cfg, "tracker-activity-dev"); // → the `activity` instance
633
+ * ```
548
634
  */
635
+ const routeInstance = (config, queueName) => {
636
+ const keys = Object.keys(config);
637
+ if (keys.length === 1) return pickInstance(config, keys[0], "queues");
638
+ return Object.values(config).find((instance) => instance.name === queueName || queueName.startsWith(`${instance.name}-`)) ?? pickInstance(config, defaultInstanceKey(config, "queues"), "queues");
639
+ };
549
640
  /**
550
- * Builds app.queues.* — read by worker.ts queue() delegation (design §1d; spec/02 §7).
641
+ * Builds app.queues.* over a keyed map of Queue instances — read by worker.ts queue() delegation
642
+ * (design §1d; spec/02 §7). The default-instance producer methods and `use(key)` both resolve the
643
+ * Queue off the REQUEST-SUPPLIED env on every call (env is threaded, never stored — SB4); the
644
+ * instance key is resolved lazily. Emits `queue:message` for observability after each consumed
645
+ * message (F8).
551
646
  *
552
- * Resolves Queue bindings off the request env per call (never stored — SB4).
553
- * Emits `queue:message` for observability after each consumed message (F8).
554
- *
555
- * @param ctx - Plugin context (own config + require + emit).
556
- * @returns The queues API surface: send, sendBatch, consume, deployManifest.
647
+ * @param ctx - Plugin context (keyed-map config + require + emit).
648
+ * @returns The queues API surface: send, sendBatch, use, consume, deployManifest.
557
649
  * @example
558
650
  * ```ts
559
- * // Worker entry (design §1d)
560
- * export default {
561
- * queue: (b, e, c) => app.queues.consume(b, e, c),
562
- * };
651
+ * const api = createQueuesApi(ctx);
652
+ * await api.send(env, { orderId: "1" }); // default instance
653
+ * await api.use("activity").send(env, { id: 2 }); // a named instance
654
+ * // Worker entry (design §1d): queue: (b, e, c) => app.queues.consume(b, e, c)
563
655
  * ```
564
656
  */
565
657
  const createQueuesApi = (ctx) => {
566
- /**
567
- * Resolves a named Queue binding from the per-request env.
568
- * Throws a [moku-worker]-prefixed error when the binding is absent.
569
- *
570
- * @param env - Per-request Cloudflare bindings.
571
- * @param name - Queue binding name.
572
- * @returns The resolved Queue instance.
573
- * @example
574
- * ```ts
575
- * const q = queue(env, "ORDERS");
576
- * ```
577
- */
578
- const queue = (env, name) => ctx.require(bindingsPlugin).require(env, name);
658
+ const bindings = ctx.require(bindingsPlugin);
659
+ const surface = (binding) => {
660
+ const queue = (env) => bindings.require(env, binding());
661
+ return {
662
+ /**
663
+ * Enqueue a single message onto this instance's queue.
664
+ *
665
+ * @param env - The per-request Cloudflare env.
666
+ * @param body - The message body to enqueue.
667
+ * @returns Resolves once the message is enqueued.
668
+ * @example
669
+ * ```typescript
670
+ * await api.send(env, { userId: "u1" });
671
+ * ```
672
+ */
673
+ send: async (env, body) => {
674
+ await queue(env).send(body);
675
+ },
676
+ /**
677
+ * Enqueue many messages onto this instance's queue; each element becomes one message.
678
+ *
679
+ * @param env - The per-request Cloudflare env.
680
+ * @param bodies - Array of message bodies; each becomes one message.
681
+ * @returns Resolves once all messages are enqueued.
682
+ * @example
683
+ * ```typescript
684
+ * await api.sendBatch(env, [{ id: 1 }, { id: 2 }]);
685
+ * ```
686
+ */
687
+ sendBatch: async (env, bodies) => {
688
+ await queue(env).sendBatch(bodies.map((body) => ({ body })));
689
+ }
690
+ };
691
+ };
692
+ const defaultBinding = () => pickInstance(ctx.config, defaultInstanceKey(ctx.config, "queues"), "queues").binding;
579
693
  return {
694
+ ...surface(defaultBinding),
580
695
  /**
581
- * Enqueue a single message onto the named queue.
582
- *
583
- * Resolves the Queue binding fresh from `env` on every call (SB4).
584
- * Request/response work → api method, never emit (F8).
696
+ * Select a specific Queue instance by its config key.
585
697
  *
586
- * @param env - Per-request Cloudflare bindings object.
587
- * @param q - Target queue binding name in `env`.
588
- * @param body - Message body to enqueue.
589
- * @returns Resolves once the message is enqueued.
590
- * @throws {Error} With a `[moku-worker]` prefix if the binding is missing.
698
+ * @param key - The instance key (as configured under `pluginConfigs.queues`).
699
+ * @returns The producer surface bound to that instance.
591
700
  * @example
592
- * ```ts
593
- * await app.queues.send(env, "ORDERS", { orderId: "123" });
594
- * ```
595
- */
596
- send: async (env, q, body) => {
597
- await queue(env, q).send(body);
598
- },
599
- /**
600
- * Enqueue many messages in one call; each element becomes one message.
601
- *
602
- * Maps each body to `{ body }` before calling `Queue.sendBatch` (design §4.3).
603
- *
604
- * @param env - Per-request Cloudflare bindings object.
605
- * @param q - Target queue binding name in `env`.
606
- * @param bodies - Array of message bodies; each becomes one message.
607
- * @returns Resolves once all messages are enqueued.
608
- * @throws {Error} With a `[moku-worker]` prefix if the binding is missing.
609
- * @example
610
- * ```ts
611
- * await app.queues.sendBatch(env, "ORDERS", orders);
701
+ * ```typescript
702
+ * await api.use("activity").send(env, { id: 2 });
612
703
  * ```
613
704
  */
614
- sendBatch: async (env, q, bodies) => {
615
- await queue(env, q).sendBatch(bodies.map((body) => ({ body })));
616
- },
705
+ use: (key) => surface(() => pickInstance(ctx.config, key, "queues").binding),
617
706
  /**
618
- * Consumer dispatch — the Worker's `queue()` export delegates here.
707
+ * Consumer dispatch — the Worker's `queue()` export delegates here. Routes the batch to the
708
+ * matching instance's `onMessage` and emits `queue:message` per message.
619
709
  *
620
- * Iterates `batch.messages`, **awaits** `config.onMessage(message, env)` per message
621
- * (so Cloudflare gets a settled promise and the handler controls ack/retry; F8,
622
- * spec/07 §3 never emit for awaited work), then fire-and-forget emits `queue:message`
623
- * for observability. Returns a promise the Worker **must** await so the isolate is not
624
- * killed mid-batch.
625
- *
626
- * @param batch - The incoming message batch from Cloudflare.
627
- * @param env - Per-request Cloudflare bindings object.
628
- * @param _exec - waitUntil / passThroughOnException (reserved for future use).
629
- * @returns Resolves after all messages in the batch are processed.
630
- * @throws {Error} Re-throws any error from `config.onMessage` so Cloudflare can retry.
710
+ * @param batch - The incoming message batch.
711
+ * @param env - The per-request Cloudflare env.
712
+ * @param _ctx - The execution context (waitUntil / passThroughOnException); unused.
713
+ * @returns Resolves after all messages settle.
631
714
  * @example
632
- * ```ts
633
- * // Worker entry
634
- * queue: (b, e, c) => app.queues.consume(b, e, c),
715
+ * ```typescript
716
+ * // Worker entry (design §1d): queue: (b, e, c) => app.queues.consume(b, e, c)
635
717
  * ```
636
718
  */
637
- consume: async (batch, env, _exec) => {
719
+ consume: async (batch, env, _ctx) => {
720
+ const instance = routeInstance(ctx.config, batch.queue);
638
721
  for (const m of batch.messages) {
639
- await ctx.config.onMessage(m, env);
722
+ if (instance.onMessage) await instance.onMessage(m, env);
640
723
  ctx.emit("queue:message", {
641
724
  queue: batch.queue,
642
725
  messageId: m.id
@@ -644,24 +727,25 @@ const createQueuesApi = (ctx) => {
644
727
  }
645
728
  },
646
729
  /**
647
- * Returns this plugin's deploy metadata, read by the deploy plugin via
648
- * `ctx.require(queuesPlugin).deployManifest()` (F6 — never reads sibling config).
730
+ * Return this plugin's deploy metadata one descriptor per configured instance.
649
731
  *
650
- * @returns Deploy manifest entry `{ kind: "queue", producers }`.
732
+ * @returns One queue deploy descriptor per instance.
651
733
  * @example
652
- * ```ts
653
- * const manifest = ctx.require(queuesPlugin).deployManifest();
654
- * // → { kind: "queue", producers: ["orders"] }
734
+ * ```typescript
735
+ * const manifest = api.deployManifest(); // [{ kind: "queue", name: "tracker-activity", binding: "ACTIVITY" }]
655
736
  * ```
656
737
  */
657
- deployManifest: () => ({
738
+ deployManifest: () => Object.values(ctx.config).map((instance) => ({
658
739
  kind: "queue",
659
- producers: ctx.config.producers
660
- })
740
+ name: instance.name,
741
+ binding: instance.binding,
742
+ ...instance.onMessage ? { consumer: true } : {}
743
+ }))
661
744
  };
662
745
  };
663
746
  /**
664
- * Standard tier — Cloudflare Queues producer + consumer dispatch.
747
+ * Standard tier — Cloudflare Queues producer + per-instance consumer dispatch over a keyed map of
748
+ * instances.
665
749
  *
666
750
  * `events` is declared first and via `register.map<QueueEvents>` so the plugin's own events infer
667
751
  * into the factory context; the api wiring is therefore arrow-wrapped (contextually typed).
@@ -673,10 +757,7 @@ const createQueuesApi = (ctx) => {
673
757
  const queuesPlugin = createPlugin("queues", {
674
758
  events: (register) => register.map({ "queue:message": "A queue message was processed" }),
675
759
  depends: [bindingsPlugin],
676
- config: {
677
- producers: [],
678
- onMessage: async () => {}
679
- },
760
+ config: {},
680
761
  api: (ctx) => createQueuesApi(ctx)
681
762
  });
682
763
  //#endregion
@@ -771,101 +852,108 @@ const resolveR2Provider = (bindings, env, bucket) => {
771
852
  //#endregion
772
853
  //#region src/plugins/storage/api.ts
773
854
  /**
774
- * Build the env-first storage API. Each runtime method resolves the bucket
775
- * provider fresh from the per-request `env`nothing is stored, so concurrent
776
- * requests stay isolated (worker-api-design SB4; spec/08 §6,§7).
855
+ * Build the app.storage.* api over a keyed map of R2 bucket instances. The default-bucket methods and
856
+ * `use(key)` both resolve the bucket off the REQUEST-SUPPLIED env on every call env is threaded,
857
+ * never stored (worker-api-design SB4; spec/08 §6,§7) — and the instance key is resolved lazily so an
858
+ * unconfigured-but-present plugin only errors when actually called.
777
859
  *
778
860
  * The `deployManifest()` method is build-time only: it reads from `ctx.config`
779
861
  * and never touches `env` or R2.
780
862
  *
781
- * @param ctx - Plugin context (config + require for bindings resolution).
782
- * @returns {StorageApi} The env-first storage API surface.
863
+ * @param ctx - Plugin context (keyed-map config + require for bindings resolution).
864
+ * @returns {StorageApi} The app.storage api: get / put / delete / list / use / deployManifest.
783
865
  * @example
784
866
  * ```typescript
785
867
  * const api = createStorageApi(ctx);
786
868
  * const body = await api.get(env, "my-object");
869
+ * await api.use("uploads").put(env, "avatar.png", buffer);
787
870
  * ```
788
871
  */
789
872
  const createStorageApi = (ctx) => {
790
- /**
791
- * Resolve the StorageProvider for the given per-request env. Called on every
792
- * method invocation the bucket binding is never cached across calls.
793
- *
794
- * @param env - The per-request Cloudflare bindings object.
795
- * @returns {StorageProvider} A StorageProvider delegating to the resolved R2Bucket.
796
- * @example
797
- * ```typescript
798
- * const p = provider(env);
799
- * const body = await p.get("key");
800
- * ```
801
- */
802
- const provider = (env) => resolveR2Provider(ctx.require(bindingsPlugin), env, ctx.config.bucket);
873
+ const bindings = ctx.require(bindingsPlugin);
874
+ const surface = (binding) => {
875
+ const provider = (env) => resolveR2Provider(bindings, env, binding());
876
+ return {
877
+ /**
878
+ * Read an object from this bucket; resolves null when the key is absent.
879
+ *
880
+ * @param env - The per-request Cloudflare env.
881
+ * @param key - The object key to read.
882
+ * @returns The object body, or null.
883
+ * @example
884
+ * ```typescript
885
+ * const body = await api.get(env, "assets/logo.png");
886
+ * ```
887
+ */
888
+ get: (env, key) => provider(env).get(key),
889
+ /**
890
+ * Write an object to this bucket.
891
+ *
892
+ * @param env - The per-request Cloudflare env.
893
+ * @param key - The object key to write.
894
+ * @param value - The object contents.
895
+ * @returns The written object metadata.
896
+ * @example
897
+ * ```typescript
898
+ * await api.put(env, "avatar.png", buffer);
899
+ * ```
900
+ */
901
+ put: (env, key, value) => provider(env).put(key, value),
902
+ /**
903
+ * Remove an object (or keys) from this bucket. No-op when absent.
904
+ *
905
+ * @param env - The per-request Cloudflare env.
906
+ * @param key - The object key or keys to delete.
907
+ * @returns Resolves once removed.
908
+ * @example
909
+ * ```typescript
910
+ * await api.delete(env, "assets/old-logo.png");
911
+ * ```
912
+ */
913
+ delete: (env, key) => provider(env).delete(key),
914
+ /**
915
+ * List objects in this bucket, optionally filtered by R2ListOptions.
916
+ *
917
+ * @param env - The per-request Cloudflare env.
918
+ * @param opts - Optional prefix / limit / cursor / delimiter.
919
+ * @returns The list result.
920
+ * @example
921
+ * ```typescript
922
+ * const { objects } = await api.list(env, { prefix: "assets/" });
923
+ * ```
924
+ */
925
+ list: (env, opts) => provider(env).list(opts)
926
+ };
927
+ };
928
+ const defaultBinding = () => pickInstance(ctx.config, defaultInstanceKey(ctx.config, "r2"), "r2").binding;
803
929
  return {
930
+ ...surface(defaultBinding),
804
931
  /**
805
- * Read an object from the bucket; resolves null when the key is absent.
932
+ * Select a specific R2 bucket instance by its config key.
806
933
  *
807
- * @param env - Per-request Cloudflare bindings.
808
- * @param key - Object key.
809
- * @returns {Promise<R2ObjectBody | null>} The R2ObjectBody, or null.
934
+ * @param key - The instance key (as configured under `pluginConfigs.storage`).
935
+ * @returns The object surface bound to that bucket.
810
936
  * @example
811
937
  * ```typescript
812
- * const body = await api.get(env, "assets/logo.png");
938
+ * await api.use("uploads").put(env, "avatar.png", buffer);
813
939
  * ```
814
940
  */
815
- get: (env, key) => provider(env).get(key),
941
+ use: (key) => surface(() => pickInstance(ctx.config, key, "r2").binding),
816
942
  /**
817
- * Write an object to the bucket.
943
+ * Return this plugin's deploy metadata — one descriptor per configured bucket.
818
944
  *
819
- * @param env - Per-request Cloudflare bindings.
820
- * @param key - Object key.
821
- * @param value - Object contents (ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null).
822
- * @returns {Promise<R2Object>} The R2Object metadata for the written object.
945
+ * @returns One r2 deploy descriptor per instance.
823
946
  * @example
824
947
  * ```typescript
825
- * const obj = await api.put(env, "assets/logo.png", buffer);
948
+ * const manifest = api.deployManifest(); // [{ kind: "r2", name: "tracker-files", binding: "FILES" }]
826
949
  * ```
827
950
  */
828
- put: (env, key, value) => provider(env).put(key, value),
829
- /**
830
- * Remove an object (or array of keys) from the bucket. No-op when absent.
831
- *
832
- * @param env - Per-request Cloudflare bindings.
833
- * @param key - Object key or array of keys.
834
- * @returns {Promise<void>} Resolves once removed.
835
- * @example
836
- * ```typescript
837
- * await api.delete(env, "assets/old.png");
838
- * ```
839
- */
840
- delete: (env, key) => provider(env).delete(key),
841
- /**
842
- * List objects, optionally filtered by R2ListOptions.
843
- *
844
- * @param env - Per-request Cloudflare bindings.
845
- * @param opts - Optional R2ListOptions (prefix, limit, cursor, delimiter).
846
- * @returns {Promise<R2Objects>} The R2Objects list result.
847
- * @example
848
- * ```typescript
849
- * const { objects } = await api.list(env, { prefix: "images/" });
850
- * ```
851
- */
852
- list: (env, opts) => provider(env).list(opts),
853
- /**
854
- * Return this plugin's deploy metadata. Build-time only — does not touch
855
- * `env` or R2. The deploy plugin reads this via `ctx.require(storagePlugin).deployManifest()`.
856
- *
857
- * @returns {StorageManifest} Deploy manifest entry `{ kind: "r2", bucket, upload }`.
858
- * @example
859
- * ```typescript
860
- * const manifest = api.deployManifest();
861
- * // { kind: "r2", bucket: "ASSETS", upload: "./public" }
862
- * ```
863
- */
864
- deployManifest: () => ({
951
+ deployManifest: () => Object.values(ctx.config).map((instance) => ({
865
952
  kind: "r2",
866
- bucket: ctx.config.bucket,
867
- upload: ctx.config.upload
868
- })
953
+ name: instance.name,
954
+ binding: instance.binding,
955
+ ...instance.upload === void 0 ? {} : { upload: instance.upload }
956
+ }))
869
957
  };
870
958
  };
871
959
  /**
@@ -879,13 +967,47 @@ const createStorageApi = (ctx) => {
879
967
  */
880
968
  const storagePlugin = createPlugin("storage", {
881
969
  depends: [bindingsPlugin],
882
- config: {
883
- upload: "",
884
- bucket: "ASSETS"
885
- },
970
+ config: {},
886
971
  api: createStorageApi
887
972
  });
888
973
  //#endregion
974
+ //#region src/plugins/deploy/auth/env-file.ts
975
+ /**
976
+ * @file deploy plugin — `.env.local` scaffolder (node:fs).
977
+ *
978
+ * Writes a ready-to-fill `.env.local` so the guided deploy can hand the user a real file to paste
979
+ * their Cloudflare token into — NEVER clobbering an existing one (it may already hold real secrets).
980
+ * Node-only; never imported by the runtime Worker bundle.
981
+ */
982
+ /**
983
+ * Create `<dir>/.env.local` with the given contents, unless it already exists. Existing files are
984
+ * left untouched (they may hold real secrets) — the caller tells the user to fill that one in.
985
+ *
986
+ * @param dir - Directory to create the file in (usually `process.cwd()`).
987
+ * @param content - The file contents to write when absent (e.g. `envLocalScaffold(manifest)`).
988
+ * @returns Whether the file was created (false when it already existed) and its path.
989
+ * @example
990
+ * ```ts
991
+ * const { created, path } = await ensureEnvLocal(process.cwd(), envLocalScaffold(manifest));
992
+ * ```
993
+ */
994
+ const ensureEnvLocal = async (dir, content) => {
995
+ const filePath = path.join(dir, ".env.local");
996
+ try {
997
+ await access(filePath);
998
+ return {
999
+ created: false,
1000
+ path: filePath
1001
+ };
1002
+ } catch {
1003
+ await writeFile(filePath, content, "utf8");
1004
+ return {
1005
+ created: true,
1006
+ path: filePath
1007
+ };
1008
+ }
1009
+ };
1010
+ //#endregion
889
1011
  //#region src/plugins/deploy/auth/permissions.ts
890
1012
  /** Permission groups every deploy needs, regardless of resources. */
891
1013
  const ALWAYS = [{
@@ -1024,6 +1146,104 @@ const ciToken = (manifest) => {
1024
1146
  return groups;
1025
1147
  };
1026
1148
  //#endregion
1149
+ //#region src/plugins/deploy/auth/render.ts
1150
+ /** Cloudflare's dashboard path for creating API tokens. */
1151
+ const TOKENS_URL$1 = "https://dash.cloudflare.com/profile/api-tokens";
1152
+ /**
1153
+ * Render one permission as a framed row. With the template flag on (the LOCAL panel) a green `✓`
1154
+ * marks a permission the stock template already includes and a pink `+ ← add to template` marks one
1155
+ * the user must add; with it off (the CI panel) every row is a neutral bullet. Scope bold, reason dim.
1156
+ *
1157
+ * @param ui - The branded console (for its palette).
1158
+ * @param permission - The permission group to render.
1159
+ * @param showTemplateFlag - Whether to mark template-vs-add (LOCAL) or use a neutral bullet (CI).
1160
+ * @returns The rendered (colorized) row, ready to drop into a box.
1161
+ * @example
1162
+ * ```ts
1163
+ * permissionRow(ui, { group: "Account · D1", scope: "Edit", reason: "d1", inBaseTemplate: false }, true);
1164
+ * ```
1165
+ */
1166
+ const permissionRow = (ui, permission, showTemplateFlag) => {
1167
+ const { palette } = ui;
1168
+ const templateMark = permission.inBaseTemplate ? palette.green("✓") : palette.pink("+");
1169
+ const mark = showTemplateFlag ? templateMark : palette.dim("•");
1170
+ const flag = showTemplateFlag && !permission.inBaseTemplate ? palette.pink(" ← add to template") : "";
1171
+ const reason = palette.dim(`(${permission.reason})`);
1172
+ return `${mark} ${permission.group} : ${palette.bold(permission.scope)} ${reason}${flag}`;
1173
+ };
1174
+ /**
1175
+ * Render the LOCAL (first deploy) token panel: the full permission set with template/add markers,
1176
+ * then the numbered create-token steps (URL cyan, template + `.env.local` bold).
1177
+ *
1178
+ * @param ui - The branded console to render through.
1179
+ * @param requirement - The LOCAL token requirement (from requiredToken()).
1180
+ * @example
1181
+ * ```ts
1182
+ * localPanel(ui, requiredToken(manifest));
1183
+ * ```
1184
+ */
1185
+ const localPanel = (ui, requirement) => {
1186
+ const { palette } = ui;
1187
+ const adds = requirement.toAdd.map((permission) => `${permission.group.replace("Account · ", "")} → ${permission.scope}`).join(", ");
1188
+ const coversAll = palette.dim(`The "${requirement.base}" template covers everything.`);
1189
+ const addStep = requirement.toAdd.length > 0 ? ` 3. ADD ${palette.pink(adds)}` : ` 3. ${coversAll}`;
1190
+ const template = palette.bold(`"${requirement.base}"`);
1191
+ ui.box([
1192
+ palette.bold("LOCAL — first deploy (creates your infra)"),
1193
+ "",
1194
+ ...requirement.required.map((permission) => permissionRow(ui, permission, true)),
1195
+ "",
1196
+ ` 1. ${palette.cyan(TOKENS_URL$1)}`,
1197
+ ` 2. Create Token → start from the ${template} template.`,
1198
+ addStep,
1199
+ " 4. Account Resources → Include → your account.",
1200
+ ` 5. Create it, copy it, then paste into ${palette.bold(".env.local")} (below).`
1201
+ ]);
1202
+ };
1203
+ /**
1204
+ * Render the compact CI (automation redeploy) token panel: the reduced, read-mostly permission set
1205
+ * for a later Custom Token. No template markers — CI builds a token from scratch, not the template.
1206
+ *
1207
+ * @param ui - The branded console to render through.
1208
+ * @param groups - The CI token permission groups (from ciToken()).
1209
+ * @example
1210
+ * ```ts
1211
+ * ciPanel(ui, ciToken(manifest));
1212
+ * ```
1213
+ */
1214
+ const ciPanel = (ui, groups) => {
1215
+ const { palette } = ui;
1216
+ ui.box([
1217
+ palette.bold("CI — automation redeploy (optional, later)"),
1218
+ "",
1219
+ ...groups.map((permission) => permissionRow(ui, permission, false)),
1220
+ "",
1221
+ palette.dim("Create a Custom Token with exactly these (Read, not Edit, on data)."),
1222
+ palette.dim("Store as the CLOUDFLARE_API_TOKEN secret; pin CLOUDFLARE_ACCOUNT_ID.")
1223
+ ]);
1224
+ };
1225
+ /**
1226
+ * Render the full branded `auth setup` guidance: a heading, the LOCAL token panel (what to create
1227
+ * now), and — when `opts.ci` is supplied — the compact CI panel; otherwise a one-line pointer to
1228
+ * `auth setup` for the CI token (so the guided deploy stays focused on the immediate next step).
1229
+ *
1230
+ * @param ui - The branded console to render through.
1231
+ * @param requirement - The LOCAL token requirement (from requiredToken()).
1232
+ * @param opts - Optional rendering options.
1233
+ * @param opts.ci - The CI token permission groups (from ciToken()); omit to show a pointer instead.
1234
+ * @example
1235
+ * ```ts
1236
+ * renderAuthSetup(ui, requiredToken(manifest)); // guided deploy (LOCAL only)
1237
+ * renderAuthSetup(ui, requiredToken(manifest), { ci: ciToken(m) }); // `auth setup` (LOCAL + CI)
1238
+ * ```
1239
+ */
1240
+ const renderAuthSetup = (ui, requirement, opts) => {
1241
+ ui.heading("Cloudflare API token");
1242
+ localPanel(ui, requirement);
1243
+ if (opts?.ci) ciPanel(ui, opts.ci);
1244
+ else ui.line(ui.palette.dim(" Need a CI token later? Run `auth setup` for the reduced set."));
1245
+ };
1246
+ //#endregion
1027
1247
  //#region src/plugins/deploy/auth/setup.ts
1028
1248
  /** Cloudflare's dashboard path for creating API tokens. */
1029
1249
  const TOKENS_URL = "https://dash.cloudflare.com/profile/api-tokens";
@@ -1103,6 +1323,41 @@ const tokenInstructions = (manifest) => [
1103
1323
  "",
1104
1324
  ...ciSection(ciToken(manifest))
1105
1325
  ].join("\n");
1326
+ /**
1327
+ * Render a ready-to-fill `.env.local` for the guided deploy: the two Cloudflare credential keys
1328
+ * (left blank to paste into) preceded by a comment block derived from the manifest — where to
1329
+ * create the token, which template to start from, exactly which permissions to add, and how to find
1330
+ * the account id. The same guidance {@link tokenInstructions} prints, but PERSISTED in the file the
1331
+ * user edits (so it survives the terminal scrolling away). Pure: no fs, no network.
1332
+ *
1333
+ * @param manifest - The assembled deploy manifest.
1334
+ * @returns The `.env.local` file contents (trailing newline included).
1335
+ * @example
1336
+ * ```ts
1337
+ * await writeFile(".env.local", envLocalScaffold(manifest));
1338
+ * ```
1339
+ */
1340
+ const envLocalScaffold = (manifest) => {
1341
+ const requirement = requiredToken(manifest);
1342
+ const addStep = requirement.toAdd.length > 0 ? `# 3. Under Permissions, ADD: ${requirement.toAdd.map((permission) => `${permission.group.replace("Account · ", "")} -> ${permission.scope}`).join(", ")}` : `# 3. The "${requirement.base}" template covers everything — no changes needed.`;
1343
+ return `${[
1344
+ "# Cloudflare credentials for the moku deploy — fill in the two values below, then re-run deploy.",
1345
+ "# Local-only: keep this file out of git (.env.local is gitignored by convention).",
1346
+ "#",
1347
+ "# Create the API token:",
1348
+ `# 1. ${TOKENS_URL} -> Create Token`,
1349
+ `# 2. Start from the "${requirement.base}" template.`,
1350
+ addStep,
1351
+ "# 4. Account Resources -> Include -> your account.",
1352
+ "# 5. Create the token, copy it, and paste it after CLOUDFLARE_API_TOKEN= below.",
1353
+ "#",
1354
+ "# Account id: open https://dash.cloudflare.com — it is the id in the URL",
1355
+ "# (dash.cloudflare.com/<account-id>) or in the right sidebar of any domain's overview.",
1356
+ "",
1357
+ "CLOUDFLARE_API_TOKEN=",
1358
+ "CLOUDFLARE_ACCOUNT_ID="
1359
+ ].join("\n")}\n`;
1360
+ };
1106
1361
  //#endregion
1107
1362
  //#region src/plugins/deploy/infra/cloudflare.ts
1108
1363
  /**
@@ -1606,16 +1861,17 @@ const realDevDeps = () => ({
1606
1861
  now: nowMs
1607
1862
  });
1608
1863
  /**
1609
- * The d1 binding to migrate locally, when a d1 plugin is present in the app.
1864
+ * The d1 bindings to migrate locally one per configured d1 instance that declares a migrations
1865
+ * directory (empty when no d1 plugin is present, or none declares migrations).
1610
1866
  *
1611
1867
  * @param ctx - The deploy plugin context.
1612
- * @returns The d1 binding name, or undefined when no d1 plugin is present.
1868
+ * @returns The d1 binding names with migrations (e.g. `["DB"]`).
1613
1869
  * @example
1614
1870
  * ```ts
1615
- * const binding = d1Binding(ctx); // "DB" | undefined
1871
+ * const bindings = d1MigrationBindings(ctx); // ["DB"]
1616
1872
  * ```
1617
1873
  */
1618
- const d1Binding = (ctx) => ctx.has("d1") ? ctx.require(d1Plugin).deployManifest().binding : void 0;
1874
+ const d1MigrationBindings = (ctx) => ctx.has("d1") ? ctx.require(d1Plugin).deployManifest().filter((manifest) => manifest.migrations !== void 0).map((manifest) => manifest.binding) : [];
1619
1875
  /**
1620
1876
  * Rebuild the site once and announce the result. A failed build keeps the session alive (it just
1621
1877
  * emits dev:error and serves the last good build).
@@ -1669,13 +1925,13 @@ const runDev = async (ctx, opts, deps) => {
1669
1925
  detail: "site"
1670
1926
  });
1671
1927
  await deps.build(ctx, webBuild);
1672
- const binding = d1Binding(ctx);
1673
- if (ctx.config.migrateLocal && binding !== void 0) {
1928
+ const migrationBindings = ctx.config.migrateLocal ? d1MigrationBindings(ctx) : [];
1929
+ if (migrationBindings.length > 0) {
1674
1930
  ctx.emit("dev:phase", {
1675
1931
  phase: "migrate",
1676
1932
  detail: "d1 (local)"
1677
1933
  });
1678
- await deps.runWrangler([
1934
+ for (const binding of migrationBindings) await deps.runWrangler([
1679
1935
  "d1",
1680
1936
  "migrations",
1681
1937
  "apply",
@@ -1719,21 +1975,21 @@ const runDev = async (ctx, opts, deps) => {
1719
1975
  const checkExisting = (resource, existing) => {
1720
1976
  switch (resource.kind) {
1721
1977
  case "kv": {
1722
- const id = existing.kv.get(resource.binding);
1978
+ const id = existing.kv.get(resource.name);
1723
1979
  return id === void 0 ? { exists: false } : {
1724
1980
  exists: true,
1725
1981
  id
1726
1982
  };
1727
1983
  }
1728
1984
  case "d1": {
1729
- const id = existing.d1.get(resource.binding);
1985
+ const id = existing.d1.get(resource.name);
1730
1986
  return id === void 0 ? { exists: false } : {
1731
1987
  exists: true,
1732
1988
  id
1733
1989
  };
1734
1990
  }
1735
- case "r2": return { exists: existing.r2.has(resource.bucket) };
1736
- case "queue": return { exists: resource.producers.every((producer) => existing.queue.has(producer)) };
1991
+ case "r2": return { exists: existing.r2.has(resource.name) };
1992
+ case "queue": return { exists: existing.queue.has(resource.name) };
1737
1993
  case "do": return { exists: false };
1738
1994
  }
1739
1995
  };
@@ -1783,6 +2039,177 @@ const planInfra = async (ctx, manifest) => {
1783
2039
  };
1784
2040
  };
1785
2041
  //#endregion
2042
+ //#region src/plugins/deploy/infra/render.ts
2043
+ /**
2044
+ * Derive a human-readable name from a resource descriptor: the Cloudflare resource `name` for the
2045
+ * provisioned kinds (kv/r2/d1/queue), or the exported `className` for a Durable Object (which has no
2046
+ * provisioned name). Used in both the provision events and the branded panels so the two agree.
2047
+ *
2048
+ * @param resource - The resource descriptor.
2049
+ * @returns A short name identifying the resource.
2050
+ * @example
2051
+ * ```ts
2052
+ * resourceName({ kind: "kv", name: "tracker-cache", binding: "CACHE" }); // "tracker-cache"
2053
+ * ```
2054
+ */
2055
+ const resourceName = (resource) => resource.kind === "do" ? resource.className : resource.name;
2056
+ /**
2057
+ * Format a `kind name` cell, padding the kind so the names line up in a column.
2058
+ *
2059
+ * @param kind - The resource kind (kv / r2 / d1 / queue / do).
2060
+ * @param name - The resource name.
2061
+ * @returns The aligned `kind name` cell.
2062
+ * @example
2063
+ * ```ts
2064
+ * cell("kv", "CACHE"); // "kv CACHE"
2065
+ * ```
2066
+ */
2067
+ const cell = (kind, name) => `${kind.padEnd(6)}${name}`;
2068
+ /**
2069
+ * ANSI SGR matcher — built from `String.fromCharCode(27)` (the ESC byte) so no control character
2070
+ * appears in a regex literal (which both linters reject).
2071
+ */
2072
+ const ANSI_SGR = new RegExp(String.raw`${String.fromCodePoint(27)}\[[0-9;]*m`, "gu");
2073
+ /**
2074
+ * Strip ANSI SGR escape sequences so a captured (colorized) error renders as plain, readable text.
2075
+ *
2076
+ * @param text - The (possibly colorized) text.
2077
+ * @returns The text with ANSI color codes removed.
2078
+ * @example
2079
+ * ```ts
2080
+ * stripAnsi(`${String.fromCharCode(27)}[31mX${String.fromCharCode(27)}[0m`); // "X"
2081
+ * ```
2082
+ */
2083
+ const stripAnsi = (text) => text.replaceAll(ANSI_SGR, "");
2084
+ /**
2085
+ * Clean a captured (colorized, multi-line, wrapper-wrapped) provision error down to its meaningful
2086
+ * text: strip ANSI, drop the wrapper lines (the branded prefix, wrangler's log-file pointer), strip
2087
+ * each `✘ [ERROR]` marker, and join what's left. Returns the FULL message (the caller word-wraps it)
2088
+ * so the user reads the actual reason — never a truncated `…`.
2089
+ *
2090
+ * @param message - The captured error message.
2091
+ * @returns The full, plain failure reason.
2092
+ * @example
2093
+ * ```ts
2094
+ * cleanError("[moku-worker] wrangler exited…\n ✘ [ERROR] The bucket name is invalid.");
2095
+ * // "The bucket name is invalid."
2096
+ * ```
2097
+ */
2098
+ const cleanError = (message) => {
2099
+ const cleaned = stripAnsi(message).split("\n").map((line) => line.trim()).filter((line) => line.length > 0).filter((line) => !/^\[moku-worker\]/u.test(line)).filter((line) => !/logs were written to/iu.test(line)).map((line) => line.replace(/^✘\s*/u, "").replace(/^\[error\]\s*/iu, "")).join(" ");
2100
+ return cleaned.length > 0 ? cleaned : stripAnsi(message).trim();
2101
+ };
2102
+ /**
2103
+ * Word-wrap text to `width` columns (never splitting inside a word), so a long failure reason reads
2104
+ * as a tidy indented block instead of forcing the box wide or scrolling off the edge.
2105
+ *
2106
+ * @param text - The text to wrap.
2107
+ * @param width - The maximum column width per line.
2108
+ * @returns The wrapped lines.
2109
+ * @example
2110
+ * ```ts
2111
+ * wrapText("a long sentence to wrap", 10); // ["a long", "sentence", "to wrap"]
2112
+ * ```
2113
+ */
2114
+ const wrapText = (text, width) => {
2115
+ const lines = [];
2116
+ let line = "";
2117
+ for (const word of text.split(/\s+/u).filter(Boolean)) if (line.length === 0) line = word;
2118
+ else if (line.length + 1 + word.length <= width) line += ` ${word}`;
2119
+ else {
2120
+ lines.push(line);
2121
+ line = word;
2122
+ }
2123
+ if (line.length > 0) lines.push(line);
2124
+ return lines;
2125
+ };
2126
+ /**
2127
+ * Render the infra preflight plan as a branded panel: a dim summary line (counts + account) then one
2128
+ * row per declared resource — a pink `+` for those to create, a dim `~ (exists)` for those already
2129
+ * present. When nothing needs creating it still renders, so the user sees the full picture.
2130
+ *
2131
+ * @param ui - The branded console to render through.
2132
+ * @param plan - The infra plan (existing vs missing) from checkInfra()/planInfra().
2133
+ * @example
2134
+ * ```ts
2135
+ * renderPlan(ui, await planInfra(ctx, manifest));
2136
+ * ```
2137
+ */
2138
+ const renderPlan = (ui, plan) => {
2139
+ const { palette } = ui;
2140
+ const summary = palette.dim(`${String(plan.missing.length)} to create · ${String(plan.exists.length)} exist · ${plan.account}`);
2141
+ const existsRows = plan.exists.map((ref) => `${palette.dim("~")} ${cell(ref.resource.kind, resourceName(ref.resource))} ${palette.dim("(exists)")}`);
2142
+ const createRows = plan.missing.map((resource) => `${palette.pink("+")} ${cell(resource.kind, resourceName(resource))}`);
2143
+ ui.heading("Infra plan");
2144
+ ui.box([
2145
+ summary,
2146
+ "",
2147
+ ...createRows,
2148
+ ...existsRows
2149
+ ]);
2150
+ };
2151
+ /**
2152
+ * Render the provision result as a branded panel — a green `✓` per created resource, a dim `~` per
2153
+ * skipped, a red `✗` per failure, then a summary line (failed count red when non-zero) — followed,
2154
+ * when anything failed, by a detail block printing each failure's FULL reason (ANSI-stripped and
2155
+ * word-wrapped) so it is actually readable instead of truncated inside the box.
2156
+ *
2157
+ * @param ui - The branded console to render through.
2158
+ * @param result - The provision result from provisionInfra()/the deploy pipeline.
2159
+ * @example
2160
+ * ```ts
2161
+ * renderProvisionResult(ui, await provisionInfra(plan));
2162
+ * ```
2163
+ */
2164
+ const renderProvisionResult = (ui, result) => {
2165
+ const { palette } = ui;
2166
+ const createdRows = result.created.map((ref) => `${palette.green("✓")} ${cell(ref.resource.kind, resourceName(ref.resource))}`);
2167
+ const skippedRows = result.skipped.map((ref) => `${palette.dim("~")} ${cell(ref.resource.kind, resourceName(ref.resource))} ${palette.dim("(exists)")}`);
2168
+ const failedRows = result.failed.map((failure) => `${palette.red("✗")} ${cell(failure.resource.kind, resourceName(failure.resource))}`);
2169
+ const failedCount = result.failed.length > 0 ? palette.red(`${String(result.failed.length)} failed`) : "0 failed";
2170
+ const summary = `${String(result.created.length)} created · ${String(result.skipped.length)} exist · ${failedCount}`;
2171
+ ui.heading("Provisioned");
2172
+ ui.box([
2173
+ ...createdRows,
2174
+ ...skippedRows,
2175
+ ...failedRows,
2176
+ "",
2177
+ summary
2178
+ ]);
2179
+ if (result.failed.length > 0) {
2180
+ ui.line();
2181
+ for (const failure of result.failed) {
2182
+ ui.line(` ${palette.red("✗")} ${cell(failure.resource.kind, resourceName(failure.resource))}`);
2183
+ for (const wrapped of wrapText(cleanError(failure.error), ui.width - 4)) ui.line(palette.dim(` ${wrapped}`));
2184
+ }
2185
+ }
2186
+ };
2187
+ //#endregion
2188
+ //#region src/plugins/deploy/naming.ts
2189
+ /**
2190
+ * @file deploy plugin — stage-aware resource naming.
2191
+ *
2192
+ * One source of truth for turning a base Cloudflare resource name into its stage variant, so the
2193
+ * worker name, the provisioners, the infra existence diff, and the generated wrangler config all
2194
+ * agree. Production keeps the base name; every other stage gets a `-${stage}` suffix. Node-only;
2195
+ * never imported by the runtime Worker bundle.
2196
+ */
2197
+ /**
2198
+ * Apply the deploy stage to a base Cloudflare resource name: the base name in `production`, else
2199
+ * `${base}-${stage}` (e.g. dev → `tracker-db-dev`). Env bindings + DO class names never get the
2200
+ * suffix — only provisioned resource names (and the worker name) are stage-qualified.
2201
+ *
2202
+ * @param base - The base resource name (e.g. "tracker-db").
2203
+ * @param stage - The deploy stage (e.g. "production", "development", "dev").
2204
+ * @returns The stage-qualified name.
2205
+ * @example
2206
+ * ```ts
2207
+ * stageName("tracker-db", "production"); // "tracker-db"
2208
+ * stageName("tracker-db", "dev"); // "tracker-db-dev"
2209
+ * ```
2210
+ */
2211
+ const stageName = (base, stage) => stage === "production" ? base : `${base}-${stage}`;
2212
+ //#endregion
1786
2213
  //#region src/plugins/deploy/providers/d1.ts
1787
2214
  /**
1788
2215
  * @file deploy plugin — D1 provisioning adapter.
@@ -1822,13 +2249,13 @@ const provisionD1 = async (manifest, _ci) => {
1822
2249
  const id = parseD1DatabaseId(await runWrangler([
1823
2250
  "d1",
1824
2251
  "create",
1825
- manifest.binding
2252
+ manifest.name
1826
2253
  ]));
1827
2254
  if (manifest.migrations) await runWrangler([
1828
2255
  "d1",
1829
2256
  "migrations",
1830
2257
  "apply",
1831
- manifest.binding,
2258
+ manifest.name,
1832
2259
  "--local"
1833
2260
  ]);
1834
2261
  return id ? { id } : {};
@@ -1891,7 +2318,7 @@ const provisionKv = async (manifest, _ci) => {
1891
2318
  "kv",
1892
2319
  "namespace",
1893
2320
  "create",
1894
- manifest.binding
2321
+ manifest.name
1895
2322
  ]));
1896
2323
  return id ? { id } : {};
1897
2324
  };
@@ -1900,25 +2327,25 @@ const provisionKv = async (manifest, _ci) => {
1900
2327
  /**
1901
2328
  * @file deploy plugin — Queues provisioning adapter.
1902
2329
  *
1903
- * Creates Cloudflare Queues via `wrangler queues create <name>` for each producer.
2330
+ * Creates one Cloudflare Queue via `wrangler queues create <name>` per queue instance.
1904
2331
  * Node-only; never imported by the runtime Worker bundle.
1905
2332
  */
1906
2333
  /**
1907
- * Provision queues via `wrangler queues create` for each declared producer.
2334
+ * Provision the queue via `wrangler queues create <name>`.
1908
2335
  *
1909
2336
  * @param manifest - The queue resource descriptor.
1910
2337
  * @param _ci - Whether running non-interactively.
1911
- * @returns Resolves once all queues are created.
2338
+ * @returns Resolves once the queue is created.
1912
2339
  * @example
1913
2340
  * ```ts
1914
- * await provisionQueue({ kind: "queue", producers: ["orders"] }, false);
2341
+ * await provisionQueue({ kind: "queue", name: "tracker-activity", binding: "ACTIVITY" }, false);
1915
2342
  * ```
1916
2343
  */
1917
2344
  const provisionQueue = async (manifest, _ci) => {
1918
- for (const producer of manifest.producers) await runWrangler([
2345
+ await runWrangler([
1919
2346
  "queues",
1920
2347
  "create",
1921
- producer
2348
+ manifest.name
1922
2349
  ]);
1923
2350
  };
1924
2351
  //#endregion
@@ -1941,7 +2368,7 @@ const provisionQueue = async (manifest, _ci) => {
1941
2368
  * @returns Resolves once the bucket is created.
1942
2369
  * @example
1943
2370
  * ```ts
1944
- * await provisionR2({ kind: "r2", bucket: "ASSETS" }, false);
2371
+ * await provisionR2({ kind: "r2", name: "tracker-files", binding: "FILES" }, false);
1945
2372
  * ```
1946
2373
  */
1947
2374
  const provisionR2 = async (manifest, _ci) => {
@@ -1949,7 +2376,7 @@ const provisionR2 = async (manifest, _ci) => {
1949
2376
  "r2",
1950
2377
  "bucket",
1951
2378
  "create",
1952
- manifest.bucket
2379
+ manifest.name
1953
2380
  ]);
1954
2381
  };
1955
2382
  /**
@@ -2078,16 +2505,20 @@ const parseJsonc = (source) => {
2078
2505
  *
2079
2506
  * @param resources - All resource descriptors from the manifest.
2080
2507
  * @param ids - Captured Cloudflare ids keyed by binding; the entry's `id` is filled from here.
2081
- * @returns One wrangler KV namespace entry per kv resource (real `id` when known, else "").
2508
+ * @returns One wrangler KV namespace entry per kv resource real `id` when known, omitted otherwise
2509
+ * (wrangler rejects an empty `id`, but a local-dev / freshly-generated config validates without one).
2082
2510
  * @example
2083
2511
  * ```ts
2084
2512
  * const kv = buildKvNamespaces([{ kind: "kv", binding: "CACHE" }], { CACHE: "ns123" });
2085
2513
  * ```
2086
2514
  */
2087
- const buildKvNamespaces = (resources, ids) => resources.filter((resource) => resource.kind === "kv").map((resource) => ({
2088
- binding: resource.binding,
2089
- id: ids[resource.binding] ?? ""
2090
- }));
2515
+ const buildKvNamespaces = (resources, ids) => resources.filter((resource) => resource.kind === "kv").map((resource) => {
2516
+ const id = ids[resource.binding];
2517
+ return id ? {
2518
+ binding: resource.binding,
2519
+ id
2520
+ } : { binding: resource.binding };
2521
+ });
2091
2522
  /**
2092
2523
  * Build the wrangler `r2_buckets` array from the manifest's r2 resources.
2093
2524
  *
@@ -2095,12 +2526,12 @@ const buildKvNamespaces = (resources, ids) => resources.filter((resource) => res
2095
2526
  * @returns One wrangler R2 bucket entry per r2 resource.
2096
2527
  * @example
2097
2528
  * ```ts
2098
- * const r2 = buildR2Buckets([{ kind: "r2", bucket: "ASSETS" }]);
2529
+ * const r2 = buildR2Buckets([{ kind: "r2", name: "tracker-files", binding: "FILES" }]);
2099
2530
  * ```
2100
2531
  */
2101
2532
  const buildR2Buckets = (resources) => resources.filter((resource) => resource.kind === "r2").map((resource) => ({
2102
- binding: resource.bucket,
2103
- bucket_name: resource.bucket.toLowerCase()
2533
+ binding: resource.binding,
2534
+ bucket_name: resource.name
2104
2535
  }));
2105
2536
  /**
2106
2537
  * Build the wrangler `d1_databases` array from the manifest's d1 resources.
@@ -2110,35 +2541,45 @@ const buildR2Buckets = (resources) => resources.filter((resource) => resource.ki
2110
2541
  * @returns One wrangler D1 database entry per d1 resource (migrations_dir set when present).
2111
2542
  * @example
2112
2543
  * ```ts
2113
- * const d1 = buildD1Databases([{ kind: "d1", binding: "DB" }], { DB: "uuid-1234" });
2544
+ * const d1 = buildD1Databases([{ kind: "d1", name: "tracker-db", binding: "DB" }], { DB: "uuid-1234" });
2114
2545
  * ```
2115
2546
  */
2116
2547
  const buildD1Databases = (resources, ids) => resources.filter((resource) => resource.kind === "d1").map((resource) => {
2548
+ const databaseId = ids[resource.binding];
2117
2549
  const entry = {
2118
2550
  binding: resource.binding,
2119
- database_name: resource.binding.toLowerCase(),
2120
- database_id: ids[resource.binding] ?? ""
2551
+ database_name: resource.name
2121
2552
  };
2553
+ if (databaseId) entry.database_id = databaseId;
2122
2554
  if (resource.migrations) entry.migrations_dir = resource.migrations;
2123
2555
  return entry;
2124
2556
  });
2125
2557
  /**
2126
- * Build the wrangler `queues` producers section from the manifest's queue resources.
2558
+ * Build the wrangler `queues` section (producers + consumers) from the manifest's queue resources.
2559
+ * Every queue is a `producer`; a queue flagged `consumer: true` (it declares an `onMessage` handler)
2560
+ * is ALSO registered as a `consumer` so wrangler delivers its messages to this Worker's queue()
2561
+ * handler — both locally under `wrangler dev` and in production. Without the consumer entry the
2562
+ * handler never runs (the bug that silently drops a queue-driven activity feed).
2127
2563
  *
2128
2564
  * @param resources - All resource descriptors from the manifest.
2129
- * @returns The queues section, or undefined when there are no queue resources.
2565
+ * @returns The queues section (producers, plus consumers when any), or undefined when there are none.
2130
2566
  * @example
2131
2567
  * ```ts
2132
- * const q = buildQueues([{ kind: "queue", producers: ["jobs"] }]);
2568
+ * const q = buildQueues([{ kind: "queue", name: "tracker-activity", binding: "ACTIVITY", consumer: true }]);
2133
2569
  * ```
2134
2570
  */
2135
2571
  const buildQueues = (resources) => {
2136
2572
  const queueResources = resources.filter((resource) => resource.kind === "queue");
2137
2573
  if (queueResources.length === 0) return void 0;
2138
- return { producers: queueResources.flatMap((resource) => resource.producers.map((producer) => ({
2139
- queue: producer,
2140
- binding: producer.toUpperCase()
2141
- }))) };
2574
+ const producers = queueResources.map((resource) => ({
2575
+ queue: resource.name,
2576
+ binding: resource.binding
2577
+ }));
2578
+ const consumers = queueResources.filter((resource) => resource.consumer === true).map((resource) => ({ queue: resource.name }));
2579
+ return consumers.length > 0 ? {
2580
+ producers,
2581
+ consumers
2582
+ } : { producers };
2142
2583
  };
2143
2584
  /**
2144
2585
  * Build the wrangler `durable_objects` bindings section from the manifest's do resources.
@@ -2147,47 +2588,137 @@ const buildQueues = (resources) => {
2147
2588
  * @returns The durable_objects section, or undefined when there are no do resources.
2148
2589
  * @example
2149
2590
  * ```ts
2150
- * const dobj = buildDurableObjects([{ kind: "do", bindings: { Counter: "COUNTER" } }]);
2591
+ * const dobj = buildDurableObjects([{ kind: "do", binding: "COUNTER", className: "Counter" }]);
2151
2592
  * ```
2152
2593
  */
2153
2594
  const buildDurableObjects = (resources) => {
2154
2595
  const doResources = resources.filter((resource) => resource.kind === "do");
2155
2596
  if (doResources.length === 0) return void 0;
2156
- return { bindings: doResources.flatMap((resource) => Object.entries(resource.bindings).map(([className, bindingName]) => ({
2157
- name: bindingName,
2158
- class_name: className
2159
- }))) };
2597
+ return { bindings: doResources.map((resource) => ({
2598
+ name: resource.binding,
2599
+ class_name: resource.className
2600
+ })) };
2601
+ };
2602
+ /**
2603
+ * Build the auto Durable Object `migrations` from the manifest's do classes. wrangler REQUIRES a
2604
+ * migration for every DO class, so this derives a single `v1` migration registering each class as
2605
+ * SQLite-backed (the modern default) — the exact section wrangler prompts for when it is missing.
2606
+ *
2607
+ * @param resources - All resource descriptors from the manifest.
2608
+ * @returns A single-entry migrations array, or undefined when there are no do resources.
2609
+ * @example
2610
+ * ```ts
2611
+ * buildMigrations([{ kind: "do", binding: "BOARD", className: "BoardChannel" }]);
2612
+ * // [{ tag: "v1", new_sqlite_classes: ["BoardChannel"] }]
2613
+ * ```
2614
+ */
2615
+ const buildMigrations = (resources) => {
2616
+ const classes = resources.filter((resource) => resource.kind === "do").map((resource) => resource.className);
2617
+ return classes.length > 0 ? [{
2618
+ tag: "v1",
2619
+ new_sqlite_classes: classes
2620
+ }] : void 0;
2621
+ };
2622
+ /**
2623
+ * Extract the already-captured Cloudflare ids (kv namespace `id`, d1 `database_id`) from an existing
2624
+ * parsed wrangler config, keyed by binding — so a regeneration (e.g. on `dev`) can preserve ids it
2625
+ * isn't handed. Tolerant of a malformed/hand-edited file (skips non-object / non-string entries).
2626
+ *
2627
+ * @param existing - The parsed existing wrangler config (or `{}`).
2628
+ * @returns A binding → id map (empty when the file has none).
2629
+ * @example
2630
+ * ```ts
2631
+ * extractExistingIds({ kv_namespaces: [{ binding: "CACHE", id: "ns1" }] }); // { CACHE: "ns1" }
2632
+ * ```
2633
+ */
2634
+ const extractExistingIds = (existing) => {
2635
+ const ids = {};
2636
+ const collect = (list, idKey) => {
2637
+ if (!Array.isArray(list)) return;
2638
+ for (const raw of list) {
2639
+ if (raw === null || typeof raw !== "object") continue;
2640
+ const entry = raw;
2641
+ const binding = entry.binding;
2642
+ const id = entry[idKey];
2643
+ if (typeof binding === "string" && typeof id === "string" && id.length > 0) ids[binding] = id;
2644
+ }
2645
+ };
2646
+ collect(existing.kv_namespaces, "id");
2647
+ collect(existing.d1_databases, "database_id");
2648
+ return ids;
2649
+ };
2650
+ /**
2651
+ * Build the extra top-level wrangler keys from the typed deploy config: `entry` → `main`,
2652
+ * `nodeCompat` → `compatibility_flags: ["nodejs_compat"]`, `assets` → the wrangler `assets` block
2653
+ * (SPA fallback when `spa`), then the raw `wrangler` passthrough last (the escape hatch wins / adds
2654
+ * anything else). Pass the result as the `extra` argument to {@link writeWranglerConfig}.
2655
+ *
2656
+ * @param config - The deploy plugin config.
2657
+ * @returns The merged extra wrangler keys.
2658
+ * @example
2659
+ * ```ts
2660
+ * await writeWranglerConfig(file, manifest, ids, wranglerExtra(ctx.config));
2661
+ * ```
2662
+ */
2663
+ const wranglerExtra = (config) => {
2664
+ const extra = {};
2665
+ if (config.entry !== void 0) extra.main = config.entry;
2666
+ if (config.nodeCompat === true) extra.compatibility_flags = ["nodejs_compat"];
2667
+ if (config.assets !== void 0) extra.assets = {
2668
+ directory: config.assets.directory,
2669
+ binding: config.assets.binding,
2670
+ ...config.assets.spa === true ? { not_found_handling: "single-page-application" } : {}
2671
+ };
2672
+ return {
2673
+ ...extra,
2674
+ ...config.wrangler
2675
+ };
2160
2676
  };
2161
2677
  /**
2162
2678
  * Generate/update the wrangler config file from a manifest (non-destructive merge).
2163
- * If the file exists, its top-level keys are preserved and only deploy-managed keys
2164
- * (name, compatibility_date, kv_namespaces, r2_buckets, d1_databases, queues,
2165
- * durable_objects) are updated.
2679
+ *
2680
+ * Layering (last wins): existing file keys → the `extra` passthrough (the app's `wrangler` config:
2681
+ * `main`, `compatibility_flags`, `assets`, `vars`, …) the deploy-managed keys (name,
2682
+ * compatibility_date, kv_namespaces, r2_buckets, d1_databases, queues, durable_objects). So the
2683
+ * framework always owns the resource sections, the app supplies what the manifest can't derive, and
2684
+ * any other hand-written keys survive. Durable Object `migrations` are auto-derived for every DO
2685
+ * class (the section wrangler requires) UNLESS the file/passthrough already defines `migrations`.
2166
2686
  *
2167
2687
  * @param configFile - Path to the wrangler config file.
2168
2688
  * @param manifest - The assembled deploy manifest.
2169
2689
  * @param ids - Captured Cloudflare ids keyed by binding (kv namespace id, d1 database id). Defaults
2170
- * to an empty map, in which case `id`/`database_id` are written as "" (e.g. the universal path).
2690
+ * to an empty map, in which case `id`/`database_id` are OMITTED (not "") so the generated config
2691
+ * still validates for local `dev` (wrangler rejects an empty id); a deploy fills the real ids.
2692
+ * @param extra - Extra top-level wrangler keys to merge in (the app's `deploy.wrangler` config).
2171
2693
  * @returns Resolves once the file is written.
2172
2694
  * @example
2173
2695
  * ```ts
2174
- * await writeWranglerConfig("wrangler.jsonc", manifest, { CACHE: "ns123", DB: "uuid-1234" });
2696
+ * await writeWranglerConfig("wrangler.jsonc", manifest, { CACHE: "ns123" }, {
2697
+ * main: "src/cloudflare/worker.ts",
2698
+ * compatibility_flags: ["nodejs_compat"],
2699
+ * assets: { directory: "dist/client", binding: "ASSETS" }
2700
+ * });
2175
2701
  * ```
2176
2702
  */
2177
- const writeWranglerConfig = async (configFile, manifest, ids = {}) => {
2703
+ const writeWranglerConfig = async (configFile, manifest, ids = {}, extra = {}) => {
2178
2704
  let existing = {};
2179
2705
  if (existsSync(configFile)) try {
2180
2706
  existing = parseJsonc(readFileSync(configFile, "utf8"));
2181
2707
  } catch {
2182
2708
  existing = {};
2183
2709
  }
2184
- const kvNamespaces = buildKvNamespaces(manifest.resources, ids);
2710
+ const effectiveIds = {
2711
+ ...extractExistingIds(existing),
2712
+ ...ids
2713
+ };
2714
+ const kvNamespaces = buildKvNamespaces(manifest.resources, effectiveIds);
2185
2715
  const r2Buckets = buildR2Buckets(manifest.resources);
2186
- const d1Databases = buildD1Databases(manifest.resources, ids);
2716
+ const d1Databases = buildD1Databases(manifest.resources, effectiveIds);
2187
2717
  const queues = buildQueues(manifest.resources);
2188
2718
  const durableObjects = buildDurableObjects(manifest.resources);
2189
2719
  const updated = {
2190
2720
  ...existing,
2721
+ ...extra,
2191
2722
  name: manifest.name,
2192
2723
  compatibility_date: manifest.compatibilityDate
2193
2724
  };
@@ -2196,6 +2727,8 @@ const writeWranglerConfig = async (configFile, manifest, ids = {}) => {
2196
2727
  if (d1Databases.length > 0) updated.d1_databases = d1Databases;
2197
2728
  if (queues !== void 0) updated.queues = queues;
2198
2729
  if (durableObjects !== void 0) updated.durable_objects = durableObjects;
2730
+ const migrations = buildMigrations(manifest.resources);
2731
+ if (migrations !== void 0 && updated.migrations === void 0) updated.migrations = migrations;
2199
2732
  await writeFile(configFile, JSON.stringify(updated, void 0, 2));
2200
2733
  };
2201
2734
  /**
@@ -2234,56 +2767,50 @@ const scaffoldWranglerAndCi = async (configFile, _ci) => {
2234
2767
  * Cloudflare REST API (via infra/). Never called in the deployed Worker runtime.
2235
2768
  */
2236
2769
  /**
2237
- * Derive a human-readable name string from a resource descriptor (used in provision events).
2238
- *
2239
- * @param resource - The resource descriptor.
2240
- * @returns A name suitable for the provision:resource / provision:skip event payload.
2241
- * @example
2242
- * ```ts
2243
- * resourceName({ kind: "kv", binding: "CACHE" }); // "CACHE"
2244
- * ```
2245
- */
2246
- const resourceName = (resource) => {
2247
- switch (resource.kind) {
2248
- case "r2": return resource.bucket;
2249
- case "do": return Object.values(resource.bindings).join(",");
2250
- case "queue": return resource.producers.join(",");
2251
- default: return resource.binding;
2252
- }
2253
- };
2254
- /**
2255
- * Assemble the deploy manifest from each present resource plugin's OWN deployManifest() api,
2256
- * gated by ctx.has(name) so absent plugins are skipped — never sibling pluginConfigs (F6).
2770
+ * Assemble the deploy manifest from each present resource plugin's OWN deployManifest() api (each
2771
+ * returns one entry PER configured instance), gated by ctx.has(name) so absent plugins are skipped —
2772
+ * never sibling pluginConfigs (F6). The single place the deploy stage is baked into names: the worker
2773
+ * name and every provisioned resource `name` are run through {@link stageName} (bindings/DO class
2774
+ * names are never suffixed), so provisioning, the existence diff, and the generated config all agree.
2257
2775
  *
2258
2776
  * @param ctx - The deploy plugin context.
2259
- * @returns The assembled manifest (name, compatibilityDate, resources).
2777
+ * @param stage - The deploy stage (e.g. "production", "dev") applied to every resource name.
2778
+ * @returns The assembled manifest (stage-qualified name, compatibilityDate, per-instance resources).
2260
2779
  * @example
2261
2780
  * ```ts
2262
- * const manifest = assembleManifest(ctx);
2781
+ * const manifest = assembleManifest(ctx, "production");
2263
2782
  * ```
2264
2783
  */
2265
- const assembleManifest = (ctx) => ({
2266
- name: ctx.global.name,
2267
- compatibilityDate: ctx.global.compatibilityDate,
2268
- resources: [
2269
- ctx.has("storage") ? ctx.require(storagePlugin).deployManifest() : void 0,
2270
- ctx.has("kv") ? ctx.require(kvPlugin).deployManifest() : void 0,
2271
- ctx.has("d1") ? ctx.require(d1Plugin).deployManifest() : void 0,
2272
- ctx.has("queues") ? ctx.require(queuesPlugin).deployManifest() : void 0,
2273
- ctx.has("durableObjects") ? ctx.require(durableObjectsPlugin).deployManifest() : void 0
2274
- ].filter((resource) => resource !== void 0)
2275
- });
2784
+ const assembleManifest = (ctx, stage) => {
2785
+ const resources = [
2786
+ ctx.has("storage") ? ctx.require(storagePlugin).deployManifest() : [],
2787
+ ctx.has("kv") ? ctx.require(kvPlugin).deployManifest() : [],
2788
+ ctx.has("d1") ? ctx.require(d1Plugin).deployManifest() : [],
2789
+ ctx.has("queues") ? ctx.require(queuesPlugin).deployManifest() : [],
2790
+ ctx.has("durableObjects") ? ctx.require(durableObjectsPlugin).deployManifest() : []
2791
+ ].flat();
2792
+ return {
2793
+ name: stageName(ctx.global.name, stage),
2794
+ compatibilityDate: ctx.global.compatibilityDate,
2795
+ resources: resources.map((resource) => "name" in resource ? {
2796
+ ...resource,
2797
+ name: stageName(resource.name, stage)
2798
+ } : resource)
2799
+ };
2800
+ };
2276
2801
  /**
2277
- * Act on an infra plan: skip the resources that already exist (reusing their ids), create only
2278
- * the missing ones (capturing each new id), and announce each via provision:skip / :resource.
2802
+ * Act on an infra plan: skip the resources that already exist (reusing their ids), create the
2803
+ * missing ones (capturing each new id), and announce each via provision:skip / :resource. Resilient
2804
+ * — a single resource that fails to create is CAPTURED in `failed` (not thrown), so one bad resource
2805
+ * (e.g. an invalid bucket name) never aborts the whole run and the caller can report a clear result.
2279
2806
  *
2280
2807
  * @param ctx - The deploy plugin context.
2281
2808
  * @param plan - The infra plan from planInfra (existing vs missing).
2282
2809
  * @param ci - Whether provisioning runs non-interactively (forwarded to each provider).
2283
- * @returns The provisioning result: created, skipped, and the merged binding → id map.
2810
+ * @returns The provisioning result: created, skipped, failed, and the merged binding → id map.
2284
2811
  * @example
2285
2812
  * ```ts
2286
- * const { ids } = await applyPlan(ctx, plan, false);
2813
+ * const { created, failed } = await applyPlan(ctx, plan, false);
2287
2814
  * ```
2288
2815
  */
2289
2816
  const applyPlan = async (ctx, plan, ci) => {
@@ -2296,7 +2823,8 @@ const applyPlan = async (ctx, plan, ci) => {
2296
2823
  });
2297
2824
  }
2298
2825
  const created = [];
2299
- for (const resource of plan.missing) {
2826
+ const failed = [];
2827
+ for (const resource of plan.missing) try {
2300
2828
  const { id } = await provisionResource(resource, ci);
2301
2829
  if (id !== void 0 && (resource.kind === "kv" || resource.kind === "d1")) ids[resource.binding] = id;
2302
2830
  created.push(id === void 0 ? { resource } : {
@@ -2307,14 +2835,234 @@ const applyPlan = async (ctx, plan, ci) => {
2307
2835
  kind: resource.kind,
2308
2836
  name: resourceName(resource)
2309
2837
  });
2838
+ } catch (error) {
2839
+ failed.push({
2840
+ resource,
2841
+ error: error instanceof Error ? error.message : String(error)
2842
+ });
2310
2843
  }
2311
2844
  return {
2312
2845
  created,
2313
2846
  skipped: plan.exists,
2847
+ failed,
2314
2848
  ids
2315
2849
  };
2316
2850
  };
2317
2851
  /**
2852
+ * Sentinel a guided helper resolves to when the user declined recovery — a clean abort the caller
2853
+ * turns into a `deploy:phase aborted` + early return, never a thrown (and re-rendered) error.
2854
+ */
2855
+ const ABORTED = Symbol("deploy:aborted");
2856
+ /** Retry guidance shown beneath each step's failure, before the "Retry?" prompt. */
2857
+ const HINTS = {
2858
+ build: "Web build failed — fix the error above, then retry.",
2859
+ provision: "Verify your token's account scopes and Cloudflare's status, then retry.",
2860
+ upload: "R2 upload failed — check the bucket and your token's R2 scope, then retry.",
2861
+ deploy: "wrangler deploy failed — review the output above, then retry."
2862
+ };
2863
+ /**
2864
+ * Emit the terminal `aborted` phase — the single exit every guided gate/retry funnels through when
2865
+ * the user stops the deploy. Factored out so each abort path renders one consistent line.
2866
+ *
2867
+ * @param ctx - The deploy plugin context.
2868
+ * @returns Nothing.
2869
+ * @example
2870
+ * ```ts
2871
+ * if (declined) return emitAborted(ctx);
2872
+ * ```
2873
+ */
2874
+ const emitAborted = (ctx) => ctx.emit("deploy:phase", { phase: "aborted" });
2875
+ /**
2876
+ * The full guided token setup shown after an auth failure on a TTY. Offers to walk the user through
2877
+ * it, and when accepted: prints WHERE to create the Cloudflare token (dashboard URL, which template,
2878
+ * the exact permissions to add) AND scaffolds a ready-to-fill `.env.local` — the same guidance baked
2879
+ * in as comments — for the user to paste the token + account id into (never clobbering an existing
2880
+ * file). Always ends pointing at the re-run.
2881
+ *
2882
+ * @param ctx - The deploy plugin context.
2883
+ * @param ui - The branded console to render the guidance through.
2884
+ * @param confirm - The yes/no prompt.
2885
+ * @returns Resolves once the guidance (and optional `.env.local` scaffold) has been rendered.
2886
+ * @example
2887
+ * ```ts
2888
+ * await guidedTokenSetup(ctx, createBrandConsole(), confirm);
2889
+ * ```
2890
+ */
2891
+ const guidedTokenSetup = async (ctx, ui, confirm) => {
2892
+ if (!await confirm("Set up Cloudflare credentials now? (guided)")) {
2893
+ ui.info("Set CLOUDFLARE_API_TOKEN in .env.local, then run `deploy` again.");
2894
+ return;
2895
+ }
2896
+ const manifest = assembleManifest(ctx, ctx.global.stage);
2897
+ renderAuthSetup(ui, requiredToken(manifest));
2898
+ const { created, path } = await ensureEnvLocal(process.cwd(), envLocalScaffold(manifest));
2899
+ ui.info(created ? `Created ${path} — paste your token + account id there, then run \`deploy\` again.` : `${path} already exists — fill in CLOUDFLARE_API_TOKEN there, then run \`deploy\` again.`);
2900
+ };
2901
+ /**
2902
+ * Verify the `.env` token, turning a missing/invalid token into a guided recovery on a TTY: surface
2903
+ * WHY auth failed, then walk the user through {@link guidedTokenSetup} (where to create the token +
2904
+ * scaffold a `.env.local`). The env is snapshotted at app start, so a freshly-pasted token only
2905
+ * takes effect on a NEW run. In CI/pipes the branded error re-throws (fail-fast).
2906
+ *
2907
+ * @param ctx - The deploy plugin context.
2908
+ * @param deps - Interactivity + the confirm prompt.
2909
+ * @returns True when the token verified; false when the user must set it up and re-run.
2910
+ * @throws {Error} Re-throws the branded auth error in CI / non-interactive runs.
2911
+ * @example
2912
+ * ```ts
2913
+ * if (!(await guidedAuth(ctx, { interactive, confirm }))) return;
2914
+ * ```
2915
+ */
2916
+ const guidedAuth = async (ctx, deps) => {
2917
+ try {
2918
+ await verifyAuth(ctx);
2919
+ return true;
2920
+ } catch (error) {
2921
+ if (!deps.interactive) throw error;
2922
+ const ui = createBrandConsole();
2923
+ ui.error(error instanceof Error ? error.message : String(error));
2924
+ await guidedTokenSetup(ctx, ui, deps.confirm);
2925
+ return false;
2926
+ }
2927
+ };
2928
+ /**
2929
+ * Run one external pipeline step with interactive recovery: on failure, render the branded error +
2930
+ * an actionable hint, then offer to retry — looping until the step succeeds or the user declines.
2931
+ * A decline resolves to {@link ABORTED} (a clean abort the caller surfaces), so the error is shown
2932
+ * once, not re-rendered downstream. In CI/pipes the first failure re-throws (fail-fast). The step
2933
+ * MUST be safe to re-run (idempotent).
2934
+ *
2935
+ * @param step - The async step to run (e.g. the web build, the R2 upload, `wrangler deploy`).
2936
+ * @param hint - One-line guidance shown beneath the error before the retry prompt.
2937
+ * @param deps - Interactivity + the confirm prompt.
2938
+ * @returns The step's resolved value once it succeeds, or {@link ABORTED} when a retry is declined.
2939
+ * @throws {Error} Re-throws the step's error in CI / non-interactive runs.
2940
+ * @example
2941
+ * ```ts
2942
+ * const url = await guidedStep(() => runWrangler(args), "wrangler deploy failed …", deps);
2943
+ * if (url === ABORTED) return;
2944
+ * ```
2945
+ */
2946
+ const guidedStep = async (step, hint, deps) => {
2947
+ for (;;) try {
2948
+ return await step();
2949
+ } catch (error) {
2950
+ if (!deps.interactive) throw error;
2951
+ const ui = createBrandConsole();
2952
+ ui.error(error instanceof Error ? error.message : String(error));
2953
+ ui.info(hint);
2954
+ if (!await deps.confirm("Retry?")) return ABORTED;
2955
+ }
2956
+ };
2957
+ /**
2958
+ * Run the read-only infra preflight with interactive recovery: a network/scope failure fails fast in
2959
+ * CI, or (on a TTY) renders the error + hint and offers a retry. Resolves the plan, or {@link ABORTED}
2960
+ * when the user declines the retry.
2961
+ *
2962
+ * @param ctx - The deploy plugin context.
2963
+ * @param manifest - The assembled (or caller-supplied) deploy manifest.
2964
+ * @param deps - Interactivity + the confirm prompt.
2965
+ * @returns The infra plan, or {@link ABORTED} when a preflight retry is declined.
2966
+ * @throws {Error} Re-throws the preflight error in CI / non-interactive runs.
2967
+ * @example
2968
+ * ```ts
2969
+ * const plan = await guidedPlan(ctx, manifest, deps);
2970
+ * if (plan === ABORTED) return;
2971
+ * ```
2972
+ */
2973
+ const guidedPlan = async (ctx, manifest, deps) => {
2974
+ for (;;) try {
2975
+ return await planInfra(ctx, manifest);
2976
+ } catch (error) {
2977
+ if (!deps.interactive) throw error;
2978
+ const ui = createBrandConsole();
2979
+ ui.error(error instanceof Error ? error.message : String(error));
2980
+ ui.info(HINTS.provision);
2981
+ if (!await deps.confirm("Retry?")) return ABORTED;
2982
+ }
2983
+ };
2984
+ /**
2985
+ * Plan + provision the infra with branded panels and interactive recovery. Each attempt RE-PLANS
2986
+ * (a resource created by a prior attempt is seen as existing and skipped — retries stay idempotent),
2987
+ * renders the plan panel (what will be created vs already exists), confirms the create gate, creates
2988
+ * the resources, then renders the result panel (created / skipped / failed). When some resources
2989
+ * FAIL it offers to retry just those (interactive) or fails fast (CI). Resolves to {@link ABORTED}
2990
+ * when the user declines the gate or a retry.
2991
+ *
2992
+ * @param ctx - The deploy plugin context.
2993
+ * @param manifest - The assembled (or caller-supplied) deploy manifest.
2994
+ * @param ci - Whether provisioning runs non-interactively (forwarded to each provider).
2995
+ * @param deps - Interactivity + the confirm prompt.
2996
+ * @returns The provisioning result (all created/skipped), or {@link ABORTED} when the user declined.
2997
+ * @throws {Error} Re-throws a plan error, or throws on a provision failure, in CI / non-interactive runs.
2998
+ * @example
2999
+ * ```ts
3000
+ * const provisioned = await guidedProvision(ctx, manifest, ci, deps);
3001
+ * if (provisioned === ABORTED) return;
3002
+ * ```
3003
+ */
3004
+ const guidedProvision = async (ctx, manifest, ci, deps) => {
3005
+ for (;;) {
3006
+ const plan = await guidedPlan(ctx, manifest, deps);
3007
+ if (plan === ABORTED) return ABORTED;
3008
+ const ui = createBrandConsole();
3009
+ renderPlan(ui, plan);
3010
+ if (plan.missing.length > 0 && !await deps.confirm(`Create ${String(plan.missing.length)} missing resource(s) in "${plan.account}"?`)) return ABORTED;
3011
+ const result = await applyPlan(ctx, plan, ci);
3012
+ renderProvisionResult(ui, result);
3013
+ if (result.failed.length === 0) return result;
3014
+ if (!deps.interactive) throw new Error(`[moku-worker] ${String(result.failed.length)} resource(s) failed to provision.`);
3015
+ if (!await deps.confirm("Retry the failed resource(s)?")) return ABORTED;
3016
+ }
3017
+ };
3018
+ /**
3019
+ * Build the web site first (when a hook is wired in), so its assets exist before the R2 upload and
3020
+ * `wrangler deploy`. Emits the `build · web` phase, then runs the build with interactive retry.
3021
+ *
3022
+ * @param ctx - The deploy plugin context.
3023
+ * @param webBuild - The web build hook, or undefined when none is wired (then this is a no-op).
3024
+ * @param deps - Interactivity + the confirm prompt.
3025
+ * @returns True to continue the pipeline; false when the user declined a build retry (abort).
3026
+ * @example
3027
+ * ```ts
3028
+ * if (!(await guidedWebBuild(ctx, webBuild, deps))) return emitAborted(ctx);
3029
+ * ```
3030
+ */
3031
+ const guidedWebBuild = async (ctx, webBuild, deps) => {
3032
+ if (webBuild === void 0) return true;
3033
+ ctx.emit("deploy:phase", {
3034
+ phase: "build",
3035
+ detail: "web"
3036
+ });
3037
+ return await guidedStep(() => webBuild(), HINTS.build, deps) !== ABORTED;
3038
+ };
3039
+ /**
3040
+ * Upload the R2 directory when a bucket declares an upload source, with interactive retry. Emits the
3041
+ * `upload · N files` phase on success; a no-op (and emits nothing) when no bucket declares an upload.
3042
+ *
3043
+ * @param ctx - The deploy plugin context.
3044
+ * @param manifest - The assembled (or caller-supplied) deploy manifest.
3045
+ * @param deps - Interactivity + the confirm prompt.
3046
+ * @returns True to continue the pipeline; false when the user declined an upload retry (abort).
3047
+ * @example
3048
+ * ```ts
3049
+ * if (!(await guidedUpload(ctx, manifest, deps))) return emitAborted(ctx);
3050
+ * ```
3051
+ */
3052
+ const guidedUpload = async (ctx, manifest, deps) => {
3053
+ const r2 = manifest.resources.find((resource) => resource.kind === "r2");
3054
+ if (!r2?.upload) return true;
3055
+ const bucket = r2.name;
3056
+ const uploadDir = r2.upload;
3057
+ const count = await guidedStep(() => uploadDirToR2(bucket, uploadDir), HINTS.upload, deps);
3058
+ if (count === ABORTED) return false;
3059
+ ctx.emit("deploy:phase", {
3060
+ phase: "upload",
3061
+ detail: `${String(count)} files`
3062
+ });
3063
+ return true;
3064
+ };
3065
+ /**
2318
3066
  * Create the deploy api. Assembles the manifest from each resource plugin's own deployManifest(),
2319
3067
  * runs an infra preflight (check-before-create + id capture), generates config, uploads, and runs
2320
3068
  * `wrangler deploy`, emitting global deploy events along the way.
@@ -2333,10 +3081,16 @@ const createDeployApi = (ctx) => ({
2333
3081
  * missing) → wrangler-config (with real ids) → upload → deploy. When opts.manifest is supplied
2334
3082
  * it is used verbatim (universal path).
2335
3083
  *
3084
+ * On a TTY the run is GUIDED end to end: each gate is confirmed, and every failure is recovered
3085
+ * interactively rather than thrown — a missing/invalid token offers `auth setup`, and the build,
3086
+ * infra, upload, and `wrangler deploy` steps offer a retry. In CI/pipes it fails fast (no prompt,
3087
+ * the first error propagates to the branded CLI handler).
3088
+ *
2336
3089
  * @param opts - Optional run options.
2337
- * @param opts.ci - CI/automated mode: never prompts, auto-confirms every gate. When false (the
2338
- * default) and stdout is a TTY, the deploy is guided — each gate is confirmed interactively.
2339
- * Falls back to ctx.config.ci when omitted.
3090
+ * @param opts.ci - CI/automated mode: never prompts, auto-confirms every gate, fails fast. When
3091
+ * false (the default) and stdout is a TTY, the deploy is guided — each gate is confirmed and
3092
+ * failures are recovered interactively. Falls back to ctx.config.ci when omitted.
3093
+ * @param opts.stage - Target stage; suffixes resource names (`production` = bare). Falls back to the app stage.
2340
3094
  * @param opts.webBuild - Build the web site first (e.g. `() => webApp.cli.build()`), before deploy.
2341
3095
  * @param opts.manifest - Caller-supplied manifest (bypasses deployManifest() assembly).
2342
3096
  * @returns Resolves once the deploy completes.
@@ -2348,46 +3102,32 @@ const createDeployApi = (ctx) => ({
2348
3102
  */
2349
3103
  async run(opts) {
2350
3104
  const ci = opts?.ci ?? ctx.config.ci;
2351
- const confirm = !ci && stdoutIsTty() ? createBrandPrompts().confirm : async (_question) => true;
3105
+ const stage = opts?.stage ?? ctx.global.stage;
3106
+ const interactive = !ci && stdoutIsTty();
3107
+ const confirm = interactive ? createBrandPrompts().confirm : async (_question) => true;
3108
+ const deps = {
3109
+ interactive,
3110
+ confirm
3111
+ };
2352
3112
  ctx.emit("deploy:phase", { phase: "auth" });
2353
- await verifyAuth(ctx);
2354
- const webBuild = opts?.webBuild ?? ctx.config.webBuild;
2355
- if (webBuild !== void 0) {
2356
- ctx.emit("deploy:phase", {
2357
- phase: "build",
2358
- detail: "web"
2359
- });
2360
- await webBuild();
2361
- }
3113
+ if (!await guidedAuth(ctx, deps)) return emitAborted(ctx);
3114
+ if (!await guidedWebBuild(ctx, opts?.webBuild ?? ctx.config.webBuild, deps)) return emitAborted(ctx);
2362
3115
  ctx.emit("deploy:phase", { phase: "detect" });
2363
- const manifest = opts?.manifest ?? assembleManifest(ctx);
3116
+ const manifest = opts?.manifest ?? assembleManifest(ctx, stage);
2364
3117
  ctx.emit("deploy:phase", { phase: "provision" });
2365
- const plan = await planInfra(ctx, manifest);
2366
- if (plan.missing.length > 0 && !await confirm(`Create ${plan.missing.length} missing resource(s) in "${plan.account}"?`)) {
2367
- ctx.emit("deploy:phase", { phase: "aborted" });
2368
- return;
2369
- }
2370
- const { ids } = await applyPlan(ctx, plan, ci);
3118
+ const provisioned = await guidedProvision(ctx, manifest, ci, deps);
3119
+ if (provisioned === ABORTED) return emitAborted(ctx);
2371
3120
  ctx.emit("deploy:phase", { phase: "wrangler-config" });
2372
- await writeWranglerConfig(ctx.config.configFile, manifest, ids);
2373
- const r2Resource = manifest.resources.find((resource) => resource.kind === "r2");
2374
- if (r2Resource?.upload) {
2375
- const count = await uploadDirToR2(r2Resource.bucket, r2Resource.upload);
2376
- ctx.emit("deploy:phase", {
2377
- phase: "upload",
2378
- detail: `${String(count)} files`
2379
- });
2380
- }
2381
- if (!await confirm(`Deploy "${manifest.name}" to ${ctx.global.stage}?`)) {
2382
- ctx.emit("deploy:phase", { phase: "aborted" });
2383
- return;
2384
- }
3121
+ await writeWranglerConfig(ctx.config.configFile, manifest, provisioned.ids, wranglerExtra(ctx.config));
3122
+ if (!await guidedUpload(ctx, manifest, deps)) return emitAborted(ctx);
3123
+ if (!await confirm(`Deploy "${manifest.name}" to ${stage}?`)) return emitAborted(ctx);
2385
3124
  ctx.emit("deploy:phase", { phase: "deploy" });
2386
- const url = await runWrangler([
3125
+ const url = await guidedStep(() => runWrangler([
2387
3126
  "deploy",
2388
3127
  "--config",
2389
3128
  ctx.config.configFile
2390
- ]);
3129
+ ]), HINTS.deploy, deps);
3130
+ if (url === ABORTED) return emitAborted(ctx);
2391
3131
  ctx.emit("deploy:complete", { url });
2392
3132
  },
2393
3133
  /**
@@ -2397,6 +3137,7 @@ const createDeployApi = (ctx) => ({
2397
3137
  *
2398
3138
  * @param opts - Optional options.
2399
3139
  * @param opts.port - Local dev port (default 8787).
3140
+ * @param opts.stage - Stage for the generated config's resource names (defaults to the app stage).
2400
3141
  * @param opts.webBuild - Rebuild the web site on change (e.g. `() => webApp.cli.build()`).
2401
3142
  * @returns Resolves when the dev session ends.
2402
3143
  * @example
@@ -2404,7 +3145,55 @@ const createDeployApi = (ctx) => ({
2404
3145
  * await api.dev({ port: 8787, webBuild: () => web.cli.build() });
2405
3146
  * ```
2406
3147
  */
2407
- dev: (opts) => runDev(ctx, opts, realDevDeps()),
3148
+ async dev(opts) {
3149
+ const manifest = assembleManifest(ctx, opts?.stage ?? ctx.global.stage);
3150
+ await writeWranglerConfig(ctx.config.configFile, manifest, {}, wranglerExtra(ctx.config));
3151
+ await runDev(ctx, opts, realDevDeps());
3152
+ },
3153
+ /**
3154
+ * Execute a SQL file against a configured D1 database via `wrangler d1 execute` — for seeding dev
3155
+ * data. Local by default (applies that database's migrations first so the file's tables exist);
3156
+ * `opts.remote` seeds Cloudflare (schema is applied by `deploy`). Generates the wrangler config up
3157
+ * front so the binding resolves even on a first run. Streams wrangler's output.
3158
+ *
3159
+ * @param sqlFile - Path to the SQL file to execute (e.g. "db/seed.sql").
3160
+ * @param opts - Optional options.
3161
+ * @param opts.stage - Stage for the generated config's resource names (defaults to the app stage).
3162
+ * @param opts.binding - The d1 binding to target when more than one is configured (e.g. "DB").
3163
+ * @param opts.remote - Seed the remote (Cloudflare) D1 instead of the local one.
3164
+ * @returns Resolves once wrangler finishes executing the file.
3165
+ * @example
3166
+ * ```ts
3167
+ * await api.seed("db/seed.sql"); // local default d1 (migrate, then execute)
3168
+ * await api.seed("db/seed.sql", { remote: true }); // remote d1
3169
+ * ```
3170
+ */
3171
+ async seed(sqlFile, opts) {
3172
+ if (!ctx.has("d1")) throw new Error("[moku-worker] seed: no d1 database is configured.");
3173
+ const stage = opts?.stage ?? ctx.global.stage;
3174
+ await writeWranglerConfig(ctx.config.configFile, assembleManifest(ctx, stage), {}, wranglerExtra(ctx.config));
3175
+ const databases = ctx.require(d1Plugin).deployManifest();
3176
+ const wanted = opts?.binding;
3177
+ const matched = wanted === void 0 ? databases : databases.filter((database) => database.binding === wanted);
3178
+ const target = matched.length === 1 ? matched[0] : void 0;
3179
+ if (target === void 0) throw new Error(wanted === void 0 ? `[moku-worker] seed: ${String(databases.length)} d1 databases configured — pass { binding } to choose one.` : `[moku-worker] seed: no d1 database is bound to "${wanted}".`);
3180
+ const scope = opts?.remote === true ? "--remote" : "--local";
3181
+ if (scope === "--local" && target.migrations !== void 0) await runWranglerInherit([
3182
+ "d1",
3183
+ "migrations",
3184
+ "apply",
3185
+ target.binding,
3186
+ "--local"
3187
+ ]);
3188
+ await runWranglerInherit([
3189
+ "d1",
3190
+ "execute",
3191
+ target.binding,
3192
+ scope,
3193
+ "--file",
3194
+ sqlFile
3195
+ ]);
3196
+ },
2408
3197
  /**
2409
3198
  * Scaffold a starting wrangler config (and CI files when ci is set).
2410
3199
  * Idempotent: an existing config file is left untouched.
@@ -2430,7 +3219,7 @@ const createDeployApi = (ctx) => ({
2430
3219
  * const plan = await api.checkInfra();
2431
3220
  * ```
2432
3221
  */
2433
- checkInfra: () => planInfra(ctx, assembleManifest(ctx)),
3222
+ checkInfra: () => planInfra(ctx, assembleManifest(ctx, ctx.global.stage)),
2434
3223
  /**
2435
3224
  * Create only the resources missing from the plan (skipping existing), capturing each id.
2436
3225
  *
@@ -2462,7 +3251,19 @@ const createDeployApi = (ctx) => ({
2462
3251
  * const { toAdd } = api.requiredToken();
2463
3252
  * ```
2464
3253
  */
2465
- requiredToken: () => requiredToken(assembleManifest(ctx)),
3254
+ requiredToken: () => requiredToken(assembleManifest(ctx, ctx.global.stage)),
3255
+ /**
3256
+ * Derive the REDUCED CI/automation redeploy token permission groups from the manifest (pure, no
3257
+ * network). Used by the branded `auth setup` renderer to show the scoped CI token alongside the
3258
+ * full LOCAL one.
3259
+ *
3260
+ * @returns The CI token permission groups (read-mostly, manifest-scoped).
3261
+ * @example
3262
+ * ```ts
3263
+ * const groups = api.ciToken();
3264
+ * ```
3265
+ */
3266
+ ciToken: () => ciToken(assembleManifest(ctx, ctx.global.stage)),
2466
3267
  /**
2467
3268
  * Render the `auth setup` guidance from the derived token requirement (pure, no network).
2468
3269
  *
@@ -2472,7 +3273,7 @@ const createDeployApi = (ctx) => ({
2472
3273
  * const text = api.tokenInstructions();
2473
3274
  * ```
2474
3275
  */
2475
- tokenInstructions: () => tokenInstructions(assembleManifest(ctx)),
3276
+ tokenInstructions: () => tokenInstructions(assembleManifest(ctx, ctx.global.stage)),
2476
3277
  /**
2477
3278
  * Run an arbitrary wrangler command, streaming its output (the branded CLI escape hatch).
2478
3279
  *
@@ -2520,52 +3321,47 @@ const deployPlugin = createPlugin("deploy", {
2520
3321
  /**
2521
3322
  * @file cli plugin — argv parsing helpers (isolated so they unit-test without a real process).
2522
3323
  *
2523
- * `dev` resolves its port from the command line (`bun scripts/dev.ts --port 3000`) so a consumer
2524
- * never hardcodes it in the app. Pure: takes an argv array, reads no globals. Node-only tooling.
3324
+ * `deploy`/`dev` resolve the target stage from the command line (`--stage dev`) so a consumer never
3325
+ * hardcodes it. The dev PORT is not parsed here it comes only from the `dev()` argument (no hidden
3326
+ * argv/config resolution). Pure: takes an argv array, reads no globals. Node-only tooling.
2525
3327
  */
2526
- /** The valid TCP port range a `--port` value must fall within to be accepted. */
2527
- const MAX_PORT = 65535;
2528
3328
  /**
2529
- * Extract a `--port`/`-p` value from a single token (and the token after it, for the spaced form).
3329
+ * Extract a `--stage` value from a single token (and the token after it, for the spaced form).
2530
3330
  *
2531
3331
  * @param token - The current argv token.
2532
- * @param next - The following argv token (the value, for the `--port 3000` spaced form).
2533
- * @returns The raw string value when this token is a port flag, else undefined.
3332
+ * @param next - The following argv token (the value, for the `--stage dev` spaced form).
3333
+ * @returns The raw string value when this token is a stage flag, else undefined.
2534
3334
  * @example
2535
3335
  * ```ts
2536
- * portValueFrom("--port=3000", undefined); // "3000"
2537
- * portValueFrom("--port", "3000"); // "3000"
2538
- * portValueFrom("--config", "x"); // undefined
3336
+ * stageValueFrom("--stage=dev", undefined); // "dev"
3337
+ * stageValueFrom("--stage", "dev"); // "dev"
3338
+ * stageValueFrom("--other", "x"); // undefined
2539
3339
  * ```
2540
3340
  */
2541
- const portValueFrom = (token, next) => {
2542
- const inline = /^(?:--port|-p)=(.+)$/u.exec(token);
3341
+ const stageValueFrom = (token, next) => {
3342
+ const inline = /^--stage=(.+)$/u.exec(token);
2543
3343
  if (inline) return inline[1];
2544
- if (token === "--port" || token === "-p") return next;
3344
+ if (token === "--stage") return next;
2545
3345
  };
2546
3346
  /**
2547
- * Parse a `--port <n>` / `--port=<n>` / `-p <n>` flag out of an argv array.
2548
- *
2549
- * Returns the first valid port (a positive integer 65535) found, or undefined when the flag is
2550
- * absent or its value is not a usable port — letting the caller fall back to a default.
3347
+ * Parse a `--stage <name>` / `--stage=<name>` flag out of an argv array — the deploy/dev stage that
3348
+ * drives the resource-name suffix (e.g. `tracker-db-dev`). Returns the first non-empty value, or
3349
+ * undefined so the caller can fall back to the app's configured stage.
2551
3350
  *
2552
3351
  * @param argv - The argv array to scan (the caller passes the process argv).
2553
- * @returns The parsed port number, or undefined when no valid `--port`/`-p` flag is present.
3352
+ * @returns The parsed stage string, or undefined when no `--stage` flag is present.
2554
3353
  * @example
2555
3354
  * ```ts
2556
- * parsePortArg(["bun", "scripts/dev.ts", "--port", "3000"]); // 3000
2557
- * parsePortArg(["bun", "scripts/dev.ts", "--port=3000"]); // 3000
2558
- * parsePortArg(["bun", "scripts/dev.ts"]); // undefined
3355
+ * parseStageArg(["bun", "scripts/deploy.ts", "--stage", "dev"]); // "dev"
3356
+ * parseStageArg(["bun", "scripts/deploy.ts"]); // undefined
2559
3357
  * ```
2560
3358
  */
2561
- const parsePortArg = (argv) => {
3359
+ const parseStageArg = (argv) => {
2562
3360
  for (let index = 0; index < argv.length; index++) {
2563
3361
  const token = argv[index];
2564
3362
  if (token === void 0) continue;
2565
- const raw = portValueFrom(token, argv[index + 1]);
2566
- if (raw === void 0) continue;
2567
- const port = Number(raw);
2568
- if (Number.isInteger(port) && port > 0 && port <= MAX_PORT) return port;
3363
+ const raw = stageValueFrom(token, argv[index + 1]);
3364
+ if (raw !== void 0 && raw.length > 0) return raw;
2569
3365
  }
2570
3366
  };
2571
3367
  //#endregion
@@ -2589,18 +3385,21 @@ const parsePortArg = (argv) => {
2589
3385
  */
2590
3386
  const createCliApi = (ctx) => ({
2591
3387
  /**
2592
- * Run the Worker locally. Resolves the port from `opts.port`, else a `--port <n>` CLI flag, else
2593
- * `ctx.config.port` (8787). Prints a branded dev-session banner, then delegates to deploy.dev; a
2594
- * `webBuild` hook (e.g. `() => webApp.cli.build()`) wires the web build into the dev loop so the
2595
- * site recompiles on change. A failure renders a branded `✗` line + non-zero exit, not a stack.
3388
+ * Run the Worker locally. The dev port comes ONLY from `opts.port` the consumer passes it (e.g.
3389
+ * parsed from its own CLI flags in scripts/dev.ts); when omitted it defaults to wrangler's 8787.
3390
+ * There is no hidden argv/config port resolution. Prints a branded dev-session banner, then
3391
+ * delegates to deploy.dev; a `webBuild` hook (e.g. `() => webApp.cli.build()`) wires the web build
3392
+ * into the dev loop so the site recompiles on change. A failure renders a branded `✗` line +
3393
+ * non-zero exit, not a stack.
2596
3394
  *
2597
3395
  * @param opts - Optional local dev options.
2598
- * @param opts.port - Local dev port to bind. Overrides the `--port` flag and the default.
3396
+ * @param opts.port - Local dev port to bind. Defaults to 8787 when omitted.
3397
+ * @param opts.stage - Stage for the generated wrangler config; falls back to `--stage` then the app stage.
2599
3398
  * @param opts.webBuild - Rebuild the web site on change (e.g. `() => webApp.cli.build()`).
2600
3399
  * @returns Resolves when the dev session ends.
2601
3400
  * @example
2602
3401
  * ```ts
2603
- * await api.dev({ webBuild: () => web.cli.build() }); // port from --port or 8787
3402
+ * await api.dev({ port: 7878, webBuild: () => web.cli.build() });
2604
3403
  * ```
2605
3404
  */
2606
3405
  async dev(opts) {
@@ -2609,12 +3408,13 @@ const createCliApi = (ctx) => ({
2609
3408
  wordmark: "moku worker",
2610
3409
  label: "dev session"
2611
3410
  });
2612
- const port = opts?.port ?? parsePortArg(process.argv) ?? ctx.config.port;
3411
+ const stage = opts?.stage ?? parseStageArg(process.argv);
2613
3412
  try {
2614
- await ctx.require(deployPlugin).dev(opts?.webBuild ? {
2615
- port,
2616
- webBuild: opts.webBuild
2617
- } : { port });
3413
+ await ctx.require(deployPlugin).dev({
3414
+ ...opts?.port === void 0 ? {} : { port: opts.port },
3415
+ ...stage === void 0 ? {} : { stage },
3416
+ ...opts?.webBuild ? { webBuild: opts.webBuild } : {}
3417
+ });
2618
3418
  ui.check(true, "dev session stopped cleanly");
2619
3419
  } catch (error) {
2620
3420
  ui.error(error instanceof Error ? error.message : String(error));
@@ -2629,23 +3429,62 @@ const createCliApi = (ctx) => ({
2629
3429
  *
2630
3430
  * @param opts - Optional deploy options.
2631
3431
  * @param opts.ci - Automated mode: never prompts, auto-confirms. Omit/false → guided on a TTY.
3432
+ * @param opts.stage - Target stage (resource-name suffix); falls back to `--stage` then the app stage.
2632
3433
  * @param opts.webBuild - Build the web site first (e.g. `() => webApp.cli.build()`), before deploy.
2633
3434
  * @returns Resolves once the deploy completes (or after a failure is rendered).
2634
3435
  * @example
2635
3436
  * ```ts
2636
- * await api.deploy({ webBuild: () => web.cli.build() }); // guided
2637
- * await api.deploy({ ci: true, webBuild: () => web.cli.build() }); // CI
3437
+ * await api.deploy({ webBuild: () => web.cli.build() }); // guided, app stage
3438
+ * await api.deploy({ ci: true, webBuild: () => web.cli.build() }); // CI; `--stage dev` honored
2638
3439
  * ```
2639
3440
  */
2640
3441
  async deploy(opts) {
3442
+ const stage = opts?.stage ?? parseStageArg(process.argv);
2641
3443
  try {
2642
- await ctx.require(deployPlugin).run(opts);
3444
+ await ctx.require(deployPlugin).run({
3445
+ ...opts,
3446
+ ...stage === void 0 ? {} : { stage }
3447
+ });
2643
3448
  } catch (error) {
2644
3449
  createBrandConsole().error(error instanceof Error ? error.message : String(error));
2645
3450
  process.exitCode = 1;
2646
3451
  }
2647
3452
  },
2648
3453
  /**
3454
+ * Seed a configured D1 database from a SQL file (delegates to deploy.seed). Local by default;
3455
+ * `opts.remote` seeds Cloudflare. The stage is resolved from a `--stage <name>` CLI flag (so
3456
+ * `bun run dev --seed --stage dev` seeds the dev database). A failure renders a branded `✗` line
3457
+ * and sets a non-zero exit code rather than throwing.
3458
+ *
3459
+ * @param sqlFile - Path to the SQL file to execute (e.g. "db/seed.sql").
3460
+ * @param opts - Optional options.
3461
+ * @param opts.binding - The d1 binding to target when more than one is configured (e.g. "DB").
3462
+ * @param opts.remote - Seed the remote (Cloudflare) D1 instead of the local one.
3463
+ * @returns Resolves once the seed completes (or after a failure is rendered).
3464
+ * @example
3465
+ * ```ts
3466
+ * await app.cli.seed("db/seed.sql"); // before app.cli.dev(...)
3467
+ * ```
3468
+ */
3469
+ async seed(sqlFile, opts) {
3470
+ const ui = createBrandConsole();
3471
+ ui.lockup({
3472
+ wordmark: "moku worker",
3473
+ label: "seed"
3474
+ });
3475
+ const stage = parseStageArg(process.argv);
3476
+ try {
3477
+ await ctx.require(deployPlugin).seed(sqlFile, {
3478
+ ...opts,
3479
+ ...stage === void 0 ? {} : { stage }
3480
+ });
3481
+ ui.check(true, "seeded", sqlFile);
3482
+ } catch (error) {
3483
+ ui.error(error instanceof Error ? error.message : String(error));
3484
+ process.exitCode = 1;
3485
+ }
3486
+ },
3487
+ /**
2649
3488
  * Verify the `.env` token (no sub) or print the config-derived token guidance (`"setup"`),
2650
3489
  * rendered in Moku style. `setup` works without a token; verify reports the resolved account.
2651
3490
  *
@@ -2661,7 +3500,7 @@ const createCliApi = (ctx) => ({
2661
3500
  const deploy = ctx.require(deployPlugin);
2662
3501
  const ui = createBrandConsole();
2663
3502
  if (sub === "setup") {
2664
- for (const line of deploy.tokenInstructions().split("\n")) ui.line(line);
3503
+ renderAuthSetup(ui, deploy.requiredToken(), { ci: deploy.ciToken() });
2665
3504
  return;
2666
3505
  }
2667
3506
  try {
@@ -2749,12 +3588,12 @@ const WRANGLER_DIVIDER = ` ── wrangler ${"─".repeat(48)}`;
2749
3588
  * never block the deploy pipeline (fire-and-forget, spec/07 §3,§4).
2750
3589
  *
2751
3590
  * @param ctx - CLI plugin context with injected log core API.
2752
- * @returns Hook map for the three global deploy events.
3591
+ * @returns Hook map for the deploy/dev phase + completion events (provision detail is panel-rendered).
2753
3592
  * @example
2754
3593
  * ```ts
2755
3594
  * const hooks = createCliHooks(ctx);
2756
3595
  * hooks["deploy:phase"]({ phase: "detect" }); // logs "detect" → renders " › detect"
2757
- * hooks["provision:resource"]({ kind: "kv", name: "KV" }); // logs "kv KV" " › kv KV"
3596
+ * hooks["dev:phase"]({ phase: "serve", detail: "http://localhost:8787" }); // "serve · "
2758
3597
  * hooks["deploy:complete"]({ url: "https://x.workers.dev" }); // "deployed → https://x.workers.dev"
2759
3598
  * ```
2760
3599
  */
@@ -2775,42 +3614,6 @@ const createCliHooks = (ctx) => {
2775
3614
  ctx.log.info(p.detail ? `${p.phase} · ${p.detail}` : p.phase);
2776
3615
  },
2777
3616
  /**
2778
- * Log the infra preflight summary: "infra · N exist, M to create · account".
2779
- *
2780
- * @param p - The provision:plan event payload.
2781
- * @example
2782
- * ```ts
2783
- * handler({ exists: 2, missing: 1, account: "Play Co" }); // "infra · 2 exist, 1 to create · Play Co"
2784
- * ```
2785
- */
2786
- "provision:plan"(p) {
2787
- ctx.log.info(`infra · ${p.exists} exist, ${p.missing} to create · ${p.account}`);
2788
- },
2789
- /**
2790
- * Log one clean line per provisioned resource: "kind name".
2791
- *
2792
- * @param p - The provision:resource event payload.
2793
- * @example
2794
- * ```ts
2795
- * handler({ kind: "kv", name: "KV" }); // "kv KV"
2796
- * ```
2797
- */
2798
- "provision:resource"(p) {
2799
- ctx.log.info(`${p.kind} ${p.name}`);
2800
- },
2801
- /**
2802
- * Log one clean line per already-existing resource (skipped): "kind name (exists)".
2803
- *
2804
- * @param p - The provision:skip event payload.
2805
- * @example
2806
- * ```ts
2807
- * handler({ kind: "kv", name: "KV" }); // "kv KV (exists)"
2808
- * ```
2809
- */
2810
- "provision:skip"(p) {
2811
- ctx.log.info(`${p.kind} ${p.name} (exists)`);
2812
- },
2813
- /**
2814
3617
  * Log one dev-session phase: "phase" or "phase · detail".
2815
3618
  *
2816
3619
  * @param p - The dev:phase event payload.
@@ -2876,7 +3679,7 @@ const createCliHooks = (ctx) => {
2876
3679
  */
2877
3680
  const cliPlugin = createPlugin("cli", {
2878
3681
  depends: [deployPlugin],
2879
- config: { port: 8787 },
3682
+ config: {},
2880
3683
  onInit: (ctx) => {
2881
3684
  ctx.log.clearSinks();
2882
3685
  ctx.log.addSink(brandedSink("info"));