@moku-labs/worker 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,11 +23,11 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
23
23
  let _moku_labs_common = require("@moku-labs/common");
24
24
  let _moku_labs_core = require("@moku-labs/core");
25
25
  let _moku_labs_common_cli = require("@moku-labs/common/cli");
26
- let node_child_process = require("node:child_process");
27
- let node_fs = require("node:fs");
26
+ let node_fs_promises = require("node:fs/promises");
28
27
  let node_path = require("node:path");
29
28
  node_path = __toESM(node_path, 1);
30
- let node_fs_promises = require("node:fs/promises");
29
+ let node_child_process = require("node:child_process");
30
+ let node_fs = require("node:fs");
31
31
  /**
32
32
  * stage core plugin — deployment-stage / dev-mode detection, flat-injected on
33
33
  * every regular plugin's context as `ctx.stage` (spec/02 §6). No state, no
@@ -181,115 +181,173 @@ const bindingsPlugin = createPlugin("bindings", {
181
181
  api: createBindingsApi
182
182
  });
183
183
  //#endregion
184
+ //#region src/instances.ts
185
+ /**
186
+ * Resolve the default instance key from a keyed-map config: the sole entry, or the one flagged
187
+ * `default: true`. Throws a branded error when there are no instances, or several without (or with
188
+ * more than one) `default: true`.
189
+ *
190
+ * @param instances - The keyed-map config (`Record<key, instance>`).
191
+ * @param kind - The resource kind, for the error message (e.g. "kv", "d1").
192
+ * @returns The default instance's key.
193
+ * @throws {Error} With a `[moku-worker]` prefix when no single default can be resolved.
194
+ * @example
195
+ * ```ts
196
+ * defaultInstanceKey({ main: { name: "db", binding: "DB" } }, "d1"); // "main"
197
+ * ```
198
+ */
199
+ const defaultInstanceKey = (instances, kind) => {
200
+ const keys = Object.keys(instances);
201
+ if (keys.length === 0) throw new Error(`[moku-worker] No ${kind} instance is configured.`);
202
+ if (keys.length === 1) return keys[0];
203
+ const flagged = keys.filter((key) => instances[key]?.default === true);
204
+ if (flagged.length === 1) return flagged[0];
205
+ throw new Error(`[moku-worker] ${kind} has ${String(keys.length)} instances — mark exactly one with \`default: true\`.`);
206
+ };
207
+ /**
208
+ * Look up a resource instance by key, with a branded error listing the configured keys when absent.
209
+ *
210
+ * @param instances - The keyed-map config (`Record<key, instance>`).
211
+ * @param key - The instance key to resolve (the `use(key)` selector).
212
+ * @param kind - The resource kind, for the error message.
213
+ * @returns The instance at `key`.
214
+ * @throws {Error} With a `[moku-worker]` prefix when `key` is not configured.
215
+ * @example
216
+ * ```ts
217
+ * pickInstance(cfg, "analytics", "d1");
218
+ * ```
219
+ */
220
+ const pickInstance = (instances, key, kind) => {
221
+ const instance = instances[key];
222
+ if (instance === void 0) {
223
+ const configured = Object.keys(instances).join(", ") || "(none)";
224
+ throw new Error(`[moku-worker] No ${kind} instance "${key}". Configured: ${configured}.`);
225
+ }
226
+ return instance;
227
+ };
228
+ //#endregion
184
229
  //#region src/plugins/d1/api.ts
185
230
  /**
186
- * Create the d1 api. Each method resolves the D1Database off the request
187
- * `env` via the bindings plugin, then forwards to the native D1 call. The
188
- * binding is never cached, so concurrent requests stay isolated (SB4).
231
+ * Create the d1 api over a keyed map of database instances. The default-database methods and
232
+ * `use(key)` both resolve the `D1Database` off the REQUEST-SUPPLIED env on every call — env is
233
+ * threaded, never stored (SB4), so concurrent requests stay isolated — and the instance key is
234
+ * resolved lazily by binding-getter so an unconfigured-but-present plugin only errors when actually
235
+ * called.
189
236
  *
190
237
  * The return is intentionally NOT annotated `: Api`. Annotating it would
191
238
  * collapse the per-method call-site generic `<T>` on `query`/`first` to
192
239
  * `unknown`; instead the implementation forwards `<T>` to `all<T>()` /
193
240
  * `first<T>()` and `types.ts#Api` remains the public-surface source of truth.
194
241
  *
195
- * @param {D1Ctx} ctx - Plugin context (own config + require).
196
- * @returns {object} The d1 public api (query, first, run, batch, prepare, deployManifest).
242
+ * @param {D1Ctx} ctx - Plugin context (keyed-map config + require).
243
+ * @returns {object} The d1 public api (query, first, run, batch, prepare, use, deployManifest).
197
244
  * @example
198
245
  * ```typescript
199
246
  * const api = createD1Api(ctx);
200
247
  * const { results } = await api.query<Product>(env, "SELECT * FROM products");
248
+ * await api.use("analytics").run(env, "INSERT INTO events (name) VALUES (?)", "click");
201
249
  * ```
202
250
  */
203
251
  const createD1Api = (ctx) => {
204
- const db = (env) => ctx.require(bindingsPlugin).require(env, ctx.config.binding);
252
+ const bindings = ctx.require(bindingsPlugin);
253
+ const surface = (binding) => {
254
+ const db = (env) => bindings.require(env, binding());
255
+ return {
256
+ /**
257
+ * Run a statement against this database and return all rows.
258
+ *
259
+ * @param env - The per-request Cloudflare env.
260
+ * @param sql - SQL with `?` placeholders.
261
+ * @param params - Bind parameters for the placeholders.
262
+ * @returns All rows in a D1 result.
263
+ * @example
264
+ * ```typescript
265
+ * const { results } = await api.query<Product>(env, "SELECT * FROM products");
266
+ * ```
267
+ */
268
+ query: (env, sql, ...params) => db(env).prepare(sql).bind(...params).all(),
269
+ /**
270
+ * Run a statement against this database and return the first row, or null when none.
271
+ *
272
+ * @param env - The per-request Cloudflare env.
273
+ * @param sql - SQL with `?` placeholders.
274
+ * @param params - Bind parameters for the placeholders.
275
+ * @returns The first row, or null if none.
276
+ * @example
277
+ * ```typescript
278
+ * const product = await api.first<Product>(env, "SELECT * FROM products WHERE id = ?", 1);
279
+ * ```
280
+ */
281
+ first: (env, sql, ...params) => db(env).prepare(sql).bind(...params).first(),
282
+ /**
283
+ * Run a write/DDL statement against this database and return its result meta.
284
+ *
285
+ * @param env - The per-request Cloudflare env.
286
+ * @param sql - SQL with `?` placeholders.
287
+ * @param params - Bind parameters for the placeholders.
288
+ * @returns Result carrying `.meta`.
289
+ * @example
290
+ * ```typescript
291
+ * await api.run(env, "INSERT INTO events (name) VALUES (?)", "click");
292
+ * ```
293
+ */
294
+ run: (env, sql, ...params) => db(env).prepare(sql).bind(...params).run(),
295
+ /**
296
+ * Execute caller-built prepared statements atomically in one round-trip.
297
+ *
298
+ * @param env - The per-request Cloudflare env.
299
+ * @param stmts - Caller-built prepared statements.
300
+ * @returns One result per statement, order preserved.
301
+ * @example
302
+ * ```typescript
303
+ * await api.batch(env, [api.prepare(env).prepare("INSERT INTO t (id) VALUES (1)")]);
304
+ * ```
305
+ */
306
+ batch: (env, stmts) => db(env).batch(stmts),
307
+ /**
308
+ * Resolve the request `D1Database` so callers can build statements for `batch()`.
309
+ *
310
+ * @param env - The per-request Cloudflare env.
311
+ * @returns The request-resolved database handle.
312
+ * @example
313
+ * ```typescript
314
+ * const stmt = api.prepare(env).prepare("SELECT * FROM products");
315
+ * ```
316
+ */
317
+ prepare: (env) => db(env)
318
+ };
319
+ };
320
+ const defaultBinding = () => pickInstance(ctx.config, defaultInstanceKey(ctx.config, "d1"), "d1").binding;
205
321
  return {
322
+ ...surface(defaultBinding),
206
323
  /**
207
- * Run a statement and return all rows. Forwards the call-site generic to
208
- * `all<T>()` so the result type is not widened to `unknown`.
209
- *
210
- * @param {WorkerEnv} env - Per-request Cloudflare bindings object.
211
- * @param {string} sql - SQL text with `?` placeholders.
212
- * @param {unknown[]} params - Bind parameters, in placeholder order.
213
- * @returns {Promise<D1Result<T>>} All rows (`.results` is `T[]`).
214
- * @example
215
- * ```typescript
216
- * const { results } = await api.query<Product>(env, "SELECT * FROM products WHERE active = ?", 1);
217
- * ```
218
- */
219
- query: (env, sql, ...params) => db(env).prepare(sql).bind(...params).all(),
220
- /**
221
- * Run a statement and return the first row, or `null` if there are none.
222
- * Forwards the call-site generic to `first<T>()`.
324
+ * Select a specific D1 database instance by its config key.
223
325
  *
224
- * @param {WorkerEnv} env - Per-request Cloudflare bindings object.
225
- * @param {string} sql - SQL text with `?` placeholders.
226
- * @param {unknown[]} params - Bind parameters, in placeholder order.
227
- * @returns {Promise<T | null>} The first row, or `null` if none matched.
326
+ * @param key - The instance key (as configured under `pluginConfigs.d1`).
327
+ * @returns The SQL surface bound to that database.
228
328
  * @example
229
329
  * ```typescript
230
- * const row = await api.first<Product>(env, "SELECT * FROM products WHERE id = ?", id);
330
+ * await api.use("analytics").run(env, "INSERT INTO events (name) VALUES (?)", "click");
231
331
  * ```
232
332
  */
233
- first: (env, sql, ...params) => db(env).prepare(sql).bind(...params).first(),
333
+ use: (key) => surface(() => pickInstance(ctx.config, key, "d1").binding),
234
334
  /**
235
- * Run a write/DDL statement (INSERT/UPDATE/DELETE/DDL) and return the
236
- * D1 result carrying `.meta` (e.g. `rows_written`, `last_row_id`).
335
+ * Return this plugin's deploy metadata one descriptor per configured database.
237
336
  *
238
- * @param {WorkerEnv} env - Per-request Cloudflare bindings object.
239
- * @param {string} sql - SQL text with `?` placeholders.
240
- * @param {unknown[]} params - Bind parameters, in placeholder order.
241
- * @returns {Promise<D1Result>} Result carrying `.meta`.
337
+ * @returns One d1 deploy descriptor per instance.
242
338
  * @example
243
339
  * ```typescript
244
- * const res = await api.run(env, "INSERT INTO products (name) VALUES (?)", name);
245
- * const id = res.meta.last_row_id;
340
+ * const manifest = api.deployManifest(); // [{ kind: "d1", name: "tracker-db", binding: "DB" }]
246
341
  * ```
247
342
  */
248
- run: (env, sql, ...params) => db(env).prepare(sql).bind(...params).run(),
249
- /**
250
- * Execute caller-built prepared statements atomically in one round-trip,
251
- * returning one result per statement in order.
252
- *
253
- * @param {WorkerEnv} env - Per-request Cloudflare bindings object.
254
- * @param {D1PreparedStatement[]} stmts - Statements built from prepare(env).
255
- * @returns {Promise<D1Result[]>} One result per statement, order preserved.
256
- * @example
257
- * ```typescript
258
- * const handle = api.prepare(env);
259
- * await api.batch(env, [handle.prepare("INSERT INTO a VALUES (1)").bind()]);
260
- * ```
261
- */
262
- batch: (env, stmts) => db(env).batch(stmts),
263
- /**
264
- * Resolve the request-scoped D1Database so callers can build prepared
265
- * statements for batch(). Issues no query itself.
266
- *
267
- * @param {WorkerEnv} env - Per-request Cloudflare bindings object.
268
- * @returns {D1Database} The request-resolved database handle.
269
- * @example
270
- * ```typescript
271
- * const handle = api.prepare(env);
272
- * const stmt = handle.prepare("SELECT * FROM t").bind();
273
- * ```
274
- */
275
- prepare: (env) => db(env),
276
- /**
277
- * Return this plugin's deploy metadata for the deploy plugin to read.
278
- * Build-time only — takes no `env`. The return is typed `DeployManifest`
279
- * (from types.ts), which pins `kind` to the literal `"d1"` without an
280
- * inline `as` assertion.
281
- *
282
- * @returns {DeployManifest} Deploy manifest entry `{ kind: "d1", binding, migrations }`.
283
- * @example
284
- * ```typescript
285
- * const m = api.deployManifest();
286
- * // => { kind: "d1", binding: "DB", migrations: "./migrations" }
287
- * ```
288
- */
289
- deployManifest: () => ({
290
- kind: "d1",
291
- binding: ctx.config.binding,
292
- migrations: ctx.config.migrations
343
+ deployManifest: () => Object.values(ctx.config).map((instance) => {
344
+ const entry = {
345
+ kind: "d1",
346
+ name: instance.name,
347
+ binding: instance.binding
348
+ };
349
+ if (instance.migrations !== void 0) entry.migrations = instance.migrations;
350
+ return entry;
293
351
  })
294
352
  };
295
353
  };
@@ -304,10 +362,7 @@ const createD1Api = (ctx) => {
304
362
  */
305
363
  const d1Plugin = createPlugin("d1", {
306
364
  depends: [bindingsPlugin],
307
- config: {
308
- binding: "DB",
309
- migrations: ""
310
- },
365
+ config: {},
311
366
  api: (ctx) => createD1Api(ctx)
312
367
  });
313
368
  //#endregion
@@ -316,59 +371,62 @@ const d1Plugin = createPlugin("d1", {
316
371
  * Builds the `app.durableObjects` API surface — `get` and `deployManifest`.
317
372
  *
318
373
  * All namespace resolution uses the per-call `env` argument: `env` is threaded, never
319
- * stored (SB4 / design §1a). The config bindings map is frozen and read-only. No state
374
+ * stored (SB4 / design §1a). The keyed-map config is frozen and read-only. No state
320
375
  * is held on the plugin between calls (stateless — `Record<string, never>`).
321
376
  *
322
- * @param ctx - Plugin context with `config.bindings`, `require(bindingsPlugin)`, and core APIs.
377
+ * @param ctx - Plugin context with the keyed-map `config`, `require(bindingsPlugin)`, and core APIs.
323
378
  * @returns The durableObjects API: `{ get, deployManifest }`.
324
379
  * @example
325
380
  * ```typescript
326
381
  * const api = createDoApi(ctx);
327
- * const stub = api.get(env, "counter", "room-42");
328
- * const manifest = api.deployManifest(); // { kind: "do", bindings: { counter: "COUNTER" } }
382
+ * const stub = api.get(env, "board", "room-42");
383
+ * const manifest = api.deployManifest(); // [{ kind: "do", binding: "BOARD", className: "BoardChannel" }]
329
384
  * ```
330
385
  */
331
386
  const createDoApi = (ctx) => ({
332
387
  /**
333
388
  * Resolves a `DurableObjectStub` off the per-request env.
334
389
  *
335
- * Maps `logicalName` `config.bindings[logicalName]` (falling back to `logicalName`
336
- * itself when unmapped), derives a deterministic id via `namespace.idFromName(idName)`,
337
- * and returns the addressed stub. Synchronous — returns a stub, not a Promise.
338
- * Throws (via the bindings resolver) when the binding is not present on `env`.
390
+ * Selects the configured instance by `logicalName` (the config key) via `pickInstance`, resolves
391
+ * its `binding` off `env`, derives a deterministic id via `namespace.idFromName(idName)`, and
392
+ * returns the addressed stub. Synchronous — returns a stub, not a Promise. Throws (branded) when
393
+ * `logicalName` is not configured, or (via the bindings resolver) when the binding is not present
394
+ * on `env`.
339
395
  *
340
396
  * @param env - Per-request Cloudflare bindings object (Worker fetch/queue/scheduled env).
341
- * @param logicalName - Logical DO name used in code (e.g. `"counter"`).
397
+ * @param logicalName - Logical DO key (selects the configured instance, e.g. `"board"`).
342
398
  * @param idName - Stable id name passed to `idFromName` (e.g. `"room-42"`).
343
399
  * @returns The addressed `DurableObjectStub`.
344
- * @throws {Error} With `[moku-worker]` prefix when the binding is not bound on `env`.
400
+ * @throws {Error} With `[moku-worker]` prefix when `logicalName` is not configured, or when the
401
+ * binding is not bound on `env`.
345
402
  * @example
346
403
  * ```typescript
347
- * const stub = app.durableObjects.get(env, "counter", "room-42");
404
+ * const stub = app.durableObjects.get(env, "board", "room-42");
348
405
  * const res = await stub.fetch("https://do/increment");
349
406
  * ```
350
407
  */
351
408
  get: (env, logicalName, idName) => {
352
- const binding = ctx.config.bindings[logicalName] ?? logicalName;
409
+ const binding = pickInstance(ctx.config, logicalName, "durableObjects").binding;
353
410
  const ns = ctx.require(bindingsPlugin).require(env, binding);
354
411
  return ns.get(ns.idFromName(idName));
355
412
  },
356
413
  /**
357
- * Returns this plugin's deploy metadata — read by the `deploy` plugin via
358
- * `ctx.require(durableObjectsPlugin)`. Never reads sibling `pluginConfigs` (F6;
359
- * spec/08 §5, §7). Pure synchronous read of `ctx.config.bindings`.
414
+ * Returns this plugin's deploy metadata — one entry per configured instance, read by the `deploy`
415
+ * plugin via `ctx.require(durableObjectsPlugin)`. Never reads sibling `pluginConfigs` (F6;
416
+ * spec/08 §5, §7). Pure synchronous read of `ctx.config`.
360
417
  *
361
- * @returns `{ kind: "do", bindings }` reflecting the frozen plugin config.
418
+ * @returns One `{ kind: "do", binding, className }` per configured instance.
362
419
  * @example
363
420
  * ```typescript
364
421
  * const manifest = app.durableObjects.deployManifest();
365
- * // → { kind: "do", bindings: { counter: "COUNTER" } }
422
+ * // → [{ kind: "do", binding: "BOARD", className: "BoardChannel" }]
366
423
  * ```
367
424
  */
368
- deployManifest: () => ({
425
+ deployManifest: () => Object.values(ctx.config).map((instance) => ({
369
426
  kind: "do",
370
- bindings: ctx.config.bindings
371
- })
427
+ binding: instance.binding,
428
+ className: instance.className
429
+ }))
372
430
  });
373
431
  //#endregion
374
432
  //#region src/plugins/durable-objects/helpers.ts
@@ -441,17 +499,17 @@ const defineDurableObject = (name) => {
441
499
  * Cloudflare Durable Objects plugin — Standard tier.
442
500
  *
443
501
  * Exposes `get(env, logicalName, idName)` (synchronous stub accessor, threaded env) and
444
- * `deployManifest()` (build-time metadata). Depends on `bindingsPlugin` for namespace
445
- * resolution. The `defineDurableObject` helper is mounted under `helpers` and re-exported
446
- * at the top level for consumer use.
502
+ * `deployManifest()` (build-time metadata, one entry per configured instance). Depends on
503
+ * `bindingsPlugin` for namespace resolution. The `defineDurableObject` helper is mounted under
504
+ * `helpers` and re-exported at the top level for consumer use.
447
505
  *
448
506
  * @example
449
507
  * ```typescript
450
508
  * // Consumer endpoint handler:
451
- * const stub = app.durableObjects.get(env, "counter", params.room!);
509
+ * const stub = app.durableObjects.get(env, "board", params.room!);
452
510
  * const res = await stub.fetch("https://do/increment");
453
- * // Consumer DO class:
454
- * export class Counter extends defineDurableObject("Counter") {
511
+ * // Consumer DO class (the EXPORTED className referenced by the "board" instance):
512
+ * export class BoardChannel extends defineDurableObject("BoardChannel") {
455
513
  * async fetch(): Promise<Response> { return new Response("ok"); }
456
514
  * }
457
515
  * ```
@@ -459,91 +517,112 @@ const defineDurableObject = (name) => {
459
517
  */
460
518
  const durableObjectsPlugin = createPlugin("durableObjects", {
461
519
  depends: [bindingsPlugin],
462
- config: { bindings: {} },
520
+ config: {},
463
521
  api: createDoApi,
464
522
  helpers: { defineDurableObject }
465
523
  });
466
524
  //#endregion
467
525
  //#region src/plugins/kv/api.ts
468
526
  /**
469
- * Builds the app.kv.* api. Resolves the KV namespace off the REQUEST-SUPPLIED env
470
- * on every call — env is threaded, never stored (design §1a / SB4).
527
+ * Builds the app.kv.* api over a keyed map of namespace instances. The default-namespace methods and
528
+ * `use(key)` both resolve the namespace off the REQUEST-SUPPLIED env on every call — env is threaded,
529
+ * never stored (design §1a / SB4) — and the instance key is resolved lazily so an unconfigured-but-
530
+ * present plugin only errors when actually called.
471
531
  *
472
- * @param ctx - The kv plugin context (own config + merged events).
473
- * @returns The app.kv api: get / put / delete / list / deployManifest.
532
+ * @param ctx - The kv plugin context (keyed-map config + merged events).
533
+ * @returns The app.kv api: get / put / delete / list / use / deployManifest.
474
534
  * @example
475
535
  * ```typescript
476
536
  * const api = createKvApi(ctx);
477
537
  * const value = await api.get(env, "key");
538
+ * await api.use("sessions").put(env, "s:1", "data");
478
539
  * ```
479
540
  */
480
541
  const createKvApi = (ctx) => {
481
- const ns = (env) => ctx.require(bindingsPlugin).require(env, ctx.config.binding);
542
+ const bindings = ctx.require(bindingsPlugin);
543
+ const surface = (binding) => {
544
+ const ns = (env) => bindings.require(env, binding());
545
+ return {
546
+ /**
547
+ * Read a value by key from this namespace. Returns null when absent.
548
+ *
549
+ * @param env - The per-request Cloudflare env.
550
+ * @param key - The key to read.
551
+ * @returns The stored value, or null when absent.
552
+ * @example
553
+ * ```typescript
554
+ * const value = await api.get(env, "feature-flags");
555
+ * ```
556
+ */
557
+ get: async (env, key) => ns(env).get(key),
558
+ /**
559
+ * Write a string value under a key, optionally with KV put options.
560
+ *
561
+ * @param env - The per-request Cloudflare env.
562
+ * @param key - The key to write.
563
+ * @param value - The string value to store.
564
+ * @param opts - Optional expiration / metadata.
565
+ * @returns Resolves once the write is acknowledged.
566
+ * @example
567
+ * ```typescript
568
+ * await api.put(env, "session:1", "data", { expirationTtl: 3600 });
569
+ * ```
570
+ */
571
+ put: async (env, key, value, opts) => ns(env).put(key, value, opts),
572
+ /**
573
+ * Remove a key from this namespace (no-op if absent).
574
+ *
575
+ * @param env - The per-request Cloudflare env.
576
+ * @param key - The key to delete.
577
+ * @returns Resolves once the delete is acknowledged.
578
+ * @example
579
+ * ```typescript
580
+ * await api.delete(env, "session:expired");
581
+ * ```
582
+ */
583
+ delete: async (env, key) => ns(env).delete(key),
584
+ /**
585
+ * List keys in this namespace, optionally filtered/paginated.
586
+ *
587
+ * @param env - The per-request Cloudflare env.
588
+ * @param opts - Optional prefix / cursor / limit.
589
+ * @returns The list result.
590
+ * @example
591
+ * ```typescript
592
+ * const { keys } = await api.list(env, { prefix: "session:" });
593
+ * ```
594
+ */
595
+ list: async (env, opts) => ns(env).list(opts)
596
+ };
597
+ };
598
+ const defaultBinding = () => pickInstance(ctx.config, defaultInstanceKey(ctx.config, "kv"), "kv").binding;
482
599
  return {
600
+ ...surface(defaultBinding),
483
601
  /**
484
- * Reads a value by key from the KV namespace. Returns null when absent.
485
- *
486
- * @param env - The per-request Cloudflare env (threaded, never stored).
487
- * @param key - The key to read.
488
- * @returns The stored value, or null when absent.
489
- * @example
490
- * ```typescript
491
- * const value = await api.get(env, "feature-flags");
492
- * ```
493
- */
494
- get: async (env, key) => ns(env).get(key),
495
- /**
496
- * Writes a string value under a key, optionally with KV put options.
602
+ * Select a specific KV namespace instance by its config key.
497
603
  *
498
- * @param env - The per-request Cloudflare env.
499
- * @param key - The key to write.
500
- * @param value - The string value to store.
501
- * @param opts - Optional expiration / metadata.
502
- * @returns Resolves once the write is acknowledged.
604
+ * @param key - The instance key (as configured under `pluginConfigs.kv`).
605
+ * @returns The key/value surface bound to that namespace.
503
606
  * @example
504
607
  * ```typescript
505
- * await api.put(env, "session:1", "data", { expirationTtl: 3600 });
608
+ * await api.use("sessions").get(env, "s:1");
506
609
  * ```
507
610
  */
508
- put: async (env, key, value, opts) => ns(env).put(key, value, opts),
611
+ use: (key) => surface(() => pickInstance(ctx.config, key, "kv").binding),
509
612
  /**
510
- * Removes a key from the namespace (no-op if absent).
613
+ * Return this plugin's deploy metadata one descriptor per configured namespace.
511
614
  *
512
- * @param env - The per-request Cloudflare env.
513
- * @param key - The key to delete.
514
- * @returns Resolves once the delete is acknowledged.
615
+ * @returns One kv deploy descriptor per instance.
515
616
  * @example
516
617
  * ```typescript
517
- * await api.delete(env, "session:expired");
618
+ * const manifest = api.deployManifest(); // [{ kind: "kv", name: "tracker-cache", binding: "CACHE" }]
518
619
  * ```
519
620
  */
520
- delete: async (env, key) => ns(env).delete(key),
521
- /**
522
- * Lists keys in the namespace, optionally filtered/paginated via opts.
523
- *
524
- * @param env - The per-request Cloudflare env.
525
- * @param opts - Optional prefix / cursor / limit.
526
- * @returns The list result from the KV namespace.
527
- * @example
528
- * ```typescript
529
- * const { keys } = await api.list(env, { prefix: "session:" });
530
- * ```
531
- */
532
- list: async (env, opts) => ns(env).list(opts),
533
- /**
534
- * Returns this plugin's own deploy metadata, read by the deploy plugin via
535
- * require (design §6 / F6). Build-time only — takes no env.
536
- *
537
- * @returns The kv deploy descriptor with kind literal and binding name.
538
- * @example
539
- * ```typescript
540
- * const manifest = api.deployManifest(); // { kind: "kv", binding: "KV" }
541
- * ```
542
- */
543
- deployManifest: () => ({
621
+ deployManifest: () => Object.values(ctx.config).map((instance) => ({
544
622
  kind: "kv",
545
- binding: ctx.config.binding
546
- })
623
+ name: instance.name,
624
+ binding: instance.binding
625
+ }))
547
626
  };
548
627
  };
549
628
  /**
@@ -557,109 +636,113 @@ const createKvApi = (ctx) => {
557
636
  */
558
637
  const kvPlugin = createPlugin("kv", {
559
638
  depends: [bindingsPlugin],
560
- config: { binding: "KV" },
639
+ config: {},
561
640
  api: createKvApi
562
641
  });
563
642
  //#endregion
564
643
  //#region src/plugins/queues/api.ts
565
644
  /**
566
- * @file queues plugin API factory (send, sendBatch, consume, deployManifest).
645
+ * Resolve the instance a consumed batch belongs to. With a single instance, that instance always
646
+ * matches. With several, match the instance whose `name` equals `batch.queue` OR whose stage-suffixed
647
+ * form (`${name}-`) prefixes it (tolerant of the deploy stage suffix, e.g. `tracker-activity-dev`);
648
+ * fall back to the default instance when nothing matches.
567
649
  *
568
- * All binding-resolving methods take the per-request `env` as the first argument
569
- * and resolve the `Queue` via `ctx.require(bindingsPlugin).require<Queue>(env, name)`.
570
- * The `env` is never stored (SB4 / design §1a) — resolved fresh on every call.
650
+ * @param config - The keyed-map queues config.
651
+ * @param queueName - The CF queue name from `batch.queue`.
652
+ * @returns The matched `QueueInstance` (its `onMessage` is what `consume` awaits).
653
+ * @example
654
+ * ```ts
655
+ * routeInstance(cfg, "tracker-activity-dev"); // → the `activity` instance
656
+ * ```
571
657
  */
658
+ const routeInstance = (config, queueName) => {
659
+ const keys = Object.keys(config);
660
+ if (keys.length === 1) return pickInstance(config, keys[0], "queues");
661
+ return Object.values(config).find((instance) => instance.name === queueName || queueName.startsWith(`${instance.name}-`)) ?? pickInstance(config, defaultInstanceKey(config, "queues"), "queues");
662
+ };
572
663
  /**
573
- * Builds app.queues.* — read by worker.ts queue() delegation (design §1d; spec/02 §7).
574
- *
575
- * Resolves Queue bindings off the request env per call (never stored — SB4).
576
- * Emits `queue:message` for observability after each consumed message (F8).
664
+ * Builds app.queues.* over a keyed map of Queue instances — read by worker.ts queue() delegation
665
+ * (design §1d; spec/02 §7). The default-instance producer methods and `use(key)` both resolve the
666
+ * Queue off the REQUEST-SUPPLIED env on every call (env is threaded, never stored — SB4); the
667
+ * instance key is resolved lazily. Emits `queue:message` for observability after each consumed
668
+ * message (F8).
577
669
  *
578
- * @param ctx - Plugin context (own config + require + emit).
579
- * @returns The queues API surface: send, sendBatch, consume, deployManifest.
670
+ * @param ctx - Plugin context (keyed-map config + require + emit).
671
+ * @returns The queues API surface: send, sendBatch, use, consume, deployManifest.
580
672
  * @example
581
673
  * ```ts
582
- * // Worker entry (design §1d)
583
- * export default {
584
- * queue: (b, e, c) => app.queues.consume(b, e, c),
585
- * };
674
+ * const api = createQueuesApi(ctx);
675
+ * await api.send(env, { orderId: "1" }); // default instance
676
+ * await api.use("activity").send(env, { id: 2 }); // a named instance
677
+ * // Worker entry (design §1d): queue: (b, e, c) => app.queues.consume(b, e, c)
586
678
  * ```
587
679
  */
588
680
  const createQueuesApi = (ctx) => {
589
- /**
590
- * Resolves a named Queue binding from the per-request env.
591
- * Throws a [moku-worker]-prefixed error when the binding is absent.
592
- *
593
- * @param env - Per-request Cloudflare bindings.
594
- * @param name - Queue binding name.
595
- * @returns The resolved Queue instance.
596
- * @example
597
- * ```ts
598
- * const q = queue(env, "ORDERS");
599
- * ```
600
- */
601
- const queue = (env, name) => ctx.require(bindingsPlugin).require(env, name);
681
+ const bindings = ctx.require(bindingsPlugin);
682
+ const surface = (binding) => {
683
+ const queue = (env) => bindings.require(env, binding());
684
+ return {
685
+ /**
686
+ * Enqueue a single message onto this instance's queue.
687
+ *
688
+ * @param env - The per-request Cloudflare env.
689
+ * @param body - The message body to enqueue.
690
+ * @returns Resolves once the message is enqueued.
691
+ * @example
692
+ * ```typescript
693
+ * await api.send(env, { userId: "u1" });
694
+ * ```
695
+ */
696
+ send: async (env, body) => {
697
+ await queue(env).send(body);
698
+ },
699
+ /**
700
+ * Enqueue many messages onto this instance's queue; each element becomes one message.
701
+ *
702
+ * @param env - The per-request Cloudflare env.
703
+ * @param bodies - Array of message bodies; each becomes one message.
704
+ * @returns Resolves once all messages are enqueued.
705
+ * @example
706
+ * ```typescript
707
+ * await api.sendBatch(env, [{ id: 1 }, { id: 2 }]);
708
+ * ```
709
+ */
710
+ sendBatch: async (env, bodies) => {
711
+ await queue(env).sendBatch(bodies.map((body) => ({ body })));
712
+ }
713
+ };
714
+ };
715
+ const defaultBinding = () => pickInstance(ctx.config, defaultInstanceKey(ctx.config, "queues"), "queues").binding;
602
716
  return {
717
+ ...surface(defaultBinding),
603
718
  /**
604
- * Enqueue a single message onto the named queue.
605
- *
606
- * Resolves the Queue binding fresh from `env` on every call (SB4).
607
- * Request/response work → api method, never emit (F8).
719
+ * Select a specific Queue instance by its config key.
608
720
  *
609
- * @param env - Per-request Cloudflare bindings object.
610
- * @param q - Target queue binding name in `env`.
611
- * @param body - Message body to enqueue.
612
- * @returns Resolves once the message is enqueued.
613
- * @throws {Error} With a `[moku-worker]` prefix if the binding is missing.
721
+ * @param key - The instance key (as configured under `pluginConfigs.queues`).
722
+ * @returns The producer surface bound to that instance.
614
723
  * @example
615
- * ```ts
616
- * await app.queues.send(env, "ORDERS", { orderId: "123" });
617
- * ```
618
- */
619
- send: async (env, q, body) => {
620
- await queue(env, q).send(body);
621
- },
622
- /**
623
- * Enqueue many messages in one call; each element becomes one message.
624
- *
625
- * Maps each body to `{ body }` before calling `Queue.sendBatch` (design §4.3).
626
- *
627
- * @param env - Per-request Cloudflare bindings object.
628
- * @param q - Target queue binding name in `env`.
629
- * @param bodies - Array of message bodies; each becomes one message.
630
- * @returns Resolves once all messages are enqueued.
631
- * @throws {Error} With a `[moku-worker]` prefix if the binding is missing.
632
- * @example
633
- * ```ts
634
- * await app.queues.sendBatch(env, "ORDERS", orders);
724
+ * ```typescript
725
+ * await api.use("activity").send(env, { id: 2 });
635
726
  * ```
636
727
  */
637
- sendBatch: async (env, q, bodies) => {
638
- await queue(env, q).sendBatch(bodies.map((body) => ({ body })));
639
- },
728
+ use: (key) => surface(() => pickInstance(ctx.config, key, "queues").binding),
640
729
  /**
641
- * Consumer dispatch — the Worker's `queue()` export delegates here.
642
- *
643
- * Iterates `batch.messages`, **awaits** `config.onMessage(message, env)` per message
644
- * (so Cloudflare gets a settled promise and the handler controls ack/retry; F8,
645
- * spec/07 §3 — never emit for awaited work), then fire-and-forget emits `queue:message`
646
- * for observability. Returns a promise the Worker **must** await so the isolate is not
647
- * killed mid-batch.
730
+ * Consumer dispatch — the Worker's `queue()` export delegates here. Routes the batch to the
731
+ * matching instance's `onMessage` and emits `queue:message` per message.
648
732
  *
649
- * @param batch - The incoming message batch from Cloudflare.
650
- * @param env - Per-request Cloudflare bindings object.
651
- * @param _exec - waitUntil / passThroughOnException (reserved for future use).
652
- * @returns Resolves after all messages in the batch are processed.
653
- * @throws {Error} Re-throws any error from `config.onMessage` so Cloudflare can retry.
733
+ * @param batch - The incoming message batch.
734
+ * @param env - The per-request Cloudflare env.
735
+ * @param _ctx - The execution context (waitUntil / passThroughOnException); unused.
736
+ * @returns Resolves after all messages settle.
654
737
  * @example
655
- * ```ts
656
- * // Worker entry
657
- * queue: (b, e, c) => app.queues.consume(b, e, c),
738
+ * ```typescript
739
+ * // Worker entry (design §1d): queue: (b, e, c) => app.queues.consume(b, e, c)
658
740
  * ```
659
741
  */
660
- consume: async (batch, env, _exec) => {
742
+ consume: async (batch, env, _ctx) => {
743
+ const instance = routeInstance(ctx.config, batch.queue);
661
744
  for (const m of batch.messages) {
662
- await ctx.config.onMessage(m, env);
745
+ if (instance.onMessage) await instance.onMessage(m, env);
663
746
  ctx.emit("queue:message", {
664
747
  queue: batch.queue,
665
748
  messageId: m.id
@@ -667,24 +750,24 @@ const createQueuesApi = (ctx) => {
667
750
  }
668
751
  },
669
752
  /**
670
- * Returns this plugin's deploy metadata, read by the deploy plugin via
671
- * `ctx.require(queuesPlugin).deployManifest()` (F6 — never reads sibling config).
753
+ * Return this plugin's deploy metadata one descriptor per configured instance.
672
754
  *
673
- * @returns Deploy manifest entry `{ kind: "queue", producers }`.
755
+ * @returns One queue deploy descriptor per instance.
674
756
  * @example
675
- * ```ts
676
- * const manifest = ctx.require(queuesPlugin).deployManifest();
677
- * // → { kind: "queue", producers: ["orders"] }
757
+ * ```typescript
758
+ * const manifest = api.deployManifest(); // [{ kind: "queue", name: "tracker-activity", binding: "ACTIVITY" }]
678
759
  * ```
679
760
  */
680
- deployManifest: () => ({
761
+ deployManifest: () => Object.values(ctx.config).map((instance) => ({
681
762
  kind: "queue",
682
- producers: ctx.config.producers
683
- })
763
+ name: instance.name,
764
+ binding: instance.binding
765
+ }))
684
766
  };
685
767
  };
686
768
  /**
687
- * Standard tier — Cloudflare Queues producer + consumer dispatch.
769
+ * Standard tier — Cloudflare Queues producer + per-instance consumer dispatch over a keyed map of
770
+ * instances.
688
771
  *
689
772
  * `events` is declared first and via `register.map<QueueEvents>` so the plugin's own events infer
690
773
  * into the factory context; the api wiring is therefore arrow-wrapped (contextually typed).
@@ -696,10 +779,7 @@ const createQueuesApi = (ctx) => {
696
779
  const queuesPlugin = createPlugin("queues", {
697
780
  events: (register) => register.map({ "queue:message": "A queue message was processed" }),
698
781
  depends: [bindingsPlugin],
699
- config: {
700
- producers: [],
701
- onMessage: async () => {}
702
- },
782
+ config: {},
703
783
  api: (ctx) => createQueuesApi(ctx)
704
784
  });
705
785
  //#endregion
@@ -794,101 +874,108 @@ const resolveR2Provider = (bindings, env, bucket) => {
794
874
  //#endregion
795
875
  //#region src/plugins/storage/api.ts
796
876
  /**
797
- * Build the env-first storage API. Each runtime method resolves the bucket
798
- * provider fresh from the per-request `env`nothing is stored, so concurrent
799
- * requests stay isolated (worker-api-design SB4; spec/08 §6,§7).
877
+ * Build the app.storage.* api over a keyed map of R2 bucket instances. The default-bucket methods and
878
+ * `use(key)` both resolve the bucket off the REQUEST-SUPPLIED env on every call env is threaded,
879
+ * never stored (worker-api-design SB4; spec/08 §6,§7) — and the instance key is resolved lazily so an
880
+ * unconfigured-but-present plugin only errors when actually called.
800
881
  *
801
882
  * The `deployManifest()` method is build-time only: it reads from `ctx.config`
802
883
  * and never touches `env` or R2.
803
884
  *
804
- * @param ctx - Plugin context (config + require for bindings resolution).
805
- * @returns {StorageApi} The env-first storage API surface.
885
+ * @param ctx - Plugin context (keyed-map config + require for bindings resolution).
886
+ * @returns {StorageApi} The app.storage api: get / put / delete / list / use / deployManifest.
806
887
  * @example
807
888
  * ```typescript
808
889
  * const api = createStorageApi(ctx);
809
890
  * const body = await api.get(env, "my-object");
891
+ * await api.use("uploads").put(env, "avatar.png", buffer);
810
892
  * ```
811
893
  */
812
894
  const createStorageApi = (ctx) => {
813
- /**
814
- * Resolve the StorageProvider for the given per-request env. Called on every
815
- * method invocation the bucket binding is never cached across calls.
816
- *
817
- * @param env - The per-request Cloudflare bindings object.
818
- * @returns {StorageProvider} A StorageProvider delegating to the resolved R2Bucket.
819
- * @example
820
- * ```typescript
821
- * const p = provider(env);
822
- * const body = await p.get("key");
823
- * ```
824
- */
825
- const provider = (env) => resolveR2Provider(ctx.require(bindingsPlugin), env, ctx.config.bucket);
895
+ const bindings = ctx.require(bindingsPlugin);
896
+ const surface = (binding) => {
897
+ const provider = (env) => resolveR2Provider(bindings, env, binding());
898
+ return {
899
+ /**
900
+ * Read an object from this bucket; resolves null when the key is absent.
901
+ *
902
+ * @param env - The per-request Cloudflare env.
903
+ * @param key - The object key to read.
904
+ * @returns The object body, or null.
905
+ * @example
906
+ * ```typescript
907
+ * const body = await api.get(env, "assets/logo.png");
908
+ * ```
909
+ */
910
+ get: (env, key) => provider(env).get(key),
911
+ /**
912
+ * Write an object to this bucket.
913
+ *
914
+ * @param env - The per-request Cloudflare env.
915
+ * @param key - The object key to write.
916
+ * @param value - The object contents.
917
+ * @returns The written object metadata.
918
+ * @example
919
+ * ```typescript
920
+ * await api.put(env, "avatar.png", buffer);
921
+ * ```
922
+ */
923
+ put: (env, key, value) => provider(env).put(key, value),
924
+ /**
925
+ * Remove an object (or keys) from this bucket. No-op when absent.
926
+ *
927
+ * @param env - The per-request Cloudflare env.
928
+ * @param key - The object key or keys to delete.
929
+ * @returns Resolves once removed.
930
+ * @example
931
+ * ```typescript
932
+ * await api.delete(env, "assets/old-logo.png");
933
+ * ```
934
+ */
935
+ delete: (env, key) => provider(env).delete(key),
936
+ /**
937
+ * List objects in this bucket, optionally filtered by R2ListOptions.
938
+ *
939
+ * @param env - The per-request Cloudflare env.
940
+ * @param opts - Optional prefix / limit / cursor / delimiter.
941
+ * @returns The list result.
942
+ * @example
943
+ * ```typescript
944
+ * const { objects } = await api.list(env, { prefix: "assets/" });
945
+ * ```
946
+ */
947
+ list: (env, opts) => provider(env).list(opts)
948
+ };
949
+ };
950
+ const defaultBinding = () => pickInstance(ctx.config, defaultInstanceKey(ctx.config, "r2"), "r2").binding;
826
951
  return {
952
+ ...surface(defaultBinding),
827
953
  /**
828
- * Read an object from the bucket; resolves null when the key is absent.
954
+ * Select a specific R2 bucket instance by its config key.
829
955
  *
830
- * @param env - Per-request Cloudflare bindings.
831
- * @param key - Object key.
832
- * @returns {Promise<R2ObjectBody | null>} The R2ObjectBody, or null.
956
+ * @param key - The instance key (as configured under `pluginConfigs.storage`).
957
+ * @returns The object surface bound to that bucket.
833
958
  * @example
834
959
  * ```typescript
835
- * const body = await api.get(env, "assets/logo.png");
960
+ * await api.use("uploads").put(env, "avatar.png", buffer);
836
961
  * ```
837
962
  */
838
- get: (env, key) => provider(env).get(key),
963
+ use: (key) => surface(() => pickInstance(ctx.config, key, "r2").binding),
839
964
  /**
840
- * Write an object to the bucket.
965
+ * Return this plugin's deploy metadata — one descriptor per configured bucket.
841
966
  *
842
- * @param env - Per-request Cloudflare bindings.
843
- * @param key - Object key.
844
- * @param value - Object contents (ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob | null).
845
- * @returns {Promise<R2Object>} The R2Object metadata for the written object.
967
+ * @returns One r2 deploy descriptor per instance.
846
968
  * @example
847
969
  * ```typescript
848
- * const obj = await api.put(env, "assets/logo.png", buffer);
970
+ * const manifest = api.deployManifest(); // [{ kind: "r2", name: "tracker-files", binding: "FILES" }]
849
971
  * ```
850
972
  */
851
- put: (env, key, value) => provider(env).put(key, value),
852
- /**
853
- * Remove an object (or array of keys) from the bucket. No-op when absent.
854
- *
855
- * @param env - Per-request Cloudflare bindings.
856
- * @param key - Object key or array of keys.
857
- * @returns {Promise<void>} Resolves once removed.
858
- * @example
859
- * ```typescript
860
- * await api.delete(env, "assets/old.png");
861
- * ```
862
- */
863
- delete: (env, key) => provider(env).delete(key),
864
- /**
865
- * List objects, optionally filtered by R2ListOptions.
866
- *
867
- * @param env - Per-request Cloudflare bindings.
868
- * @param opts - Optional R2ListOptions (prefix, limit, cursor, delimiter).
869
- * @returns {Promise<R2Objects>} The R2Objects list result.
870
- * @example
871
- * ```typescript
872
- * const { objects } = await api.list(env, { prefix: "images/" });
873
- * ```
874
- */
875
- list: (env, opts) => provider(env).list(opts),
876
- /**
877
- * Return this plugin's deploy metadata. Build-time only — does not touch
878
- * `env` or R2. The deploy plugin reads this via `ctx.require(storagePlugin).deployManifest()`.
879
- *
880
- * @returns {StorageManifest} Deploy manifest entry `{ kind: "r2", bucket, upload }`.
881
- * @example
882
- * ```typescript
883
- * const manifest = api.deployManifest();
884
- * // { kind: "r2", bucket: "ASSETS", upload: "./public" }
885
- * ```
886
- */
887
- deployManifest: () => ({
973
+ deployManifest: () => Object.values(ctx.config).map((instance) => ({
888
974
  kind: "r2",
889
- bucket: ctx.config.bucket,
890
- upload: ctx.config.upload
891
- })
975
+ name: instance.name,
976
+ binding: instance.binding,
977
+ ...instance.upload === void 0 ? {} : { upload: instance.upload }
978
+ }))
892
979
  };
893
980
  };
894
981
  /**
@@ -902,13 +989,47 @@ const createStorageApi = (ctx) => {
902
989
  */
903
990
  const storagePlugin = createPlugin("storage", {
904
991
  depends: [bindingsPlugin],
905
- config: {
906
- upload: "",
907
- bucket: "ASSETS"
908
- },
992
+ config: {},
909
993
  api: createStorageApi
910
994
  });
911
995
  //#endregion
996
+ //#region src/plugins/deploy/auth/env-file.ts
997
+ /**
998
+ * @file deploy plugin — `.env.local` scaffolder (node:fs).
999
+ *
1000
+ * Writes a ready-to-fill `.env.local` so the guided deploy can hand the user a real file to paste
1001
+ * their Cloudflare token into — NEVER clobbering an existing one (it may already hold real secrets).
1002
+ * Node-only; never imported by the runtime Worker bundle.
1003
+ */
1004
+ /**
1005
+ * Create `<dir>/.env.local` with the given contents, unless it already exists. Existing files are
1006
+ * left untouched (they may hold real secrets) — the caller tells the user to fill that one in.
1007
+ *
1008
+ * @param dir - Directory to create the file in (usually `process.cwd()`).
1009
+ * @param content - The file contents to write when absent (e.g. `envLocalScaffold(manifest)`).
1010
+ * @returns Whether the file was created (false when it already existed) and its path.
1011
+ * @example
1012
+ * ```ts
1013
+ * const { created, path } = await ensureEnvLocal(process.cwd(), envLocalScaffold(manifest));
1014
+ * ```
1015
+ */
1016
+ const ensureEnvLocal = async (dir, content) => {
1017
+ const filePath = node_path.default.join(dir, ".env.local");
1018
+ try {
1019
+ await (0, node_fs_promises.access)(filePath);
1020
+ return {
1021
+ created: false,
1022
+ path: filePath
1023
+ };
1024
+ } catch {
1025
+ await (0, node_fs_promises.writeFile)(filePath, content, "utf8");
1026
+ return {
1027
+ created: true,
1028
+ path: filePath
1029
+ };
1030
+ }
1031
+ };
1032
+ //#endregion
912
1033
  //#region src/plugins/deploy/auth/permissions.ts
913
1034
  /** Permission groups every deploy needs, regardless of resources. */
914
1035
  const ALWAYS = [{
@@ -1047,6 +1168,104 @@ const ciToken = (manifest) => {
1047
1168
  return groups;
1048
1169
  };
1049
1170
  //#endregion
1171
+ //#region src/plugins/deploy/auth/render.ts
1172
+ /** Cloudflare's dashboard path for creating API tokens. */
1173
+ const TOKENS_URL$1 = "https://dash.cloudflare.com/profile/api-tokens";
1174
+ /**
1175
+ * Render one permission as a framed row. With the template flag on (the LOCAL panel) a green `✓`
1176
+ * marks a permission the stock template already includes and a pink `+ ← add to template` marks one
1177
+ * the user must add; with it off (the CI panel) every row is a neutral bullet. Scope bold, reason dim.
1178
+ *
1179
+ * @param ui - The branded console (for its palette).
1180
+ * @param permission - The permission group to render.
1181
+ * @param showTemplateFlag - Whether to mark template-vs-add (LOCAL) or use a neutral bullet (CI).
1182
+ * @returns The rendered (colorized) row, ready to drop into a box.
1183
+ * @example
1184
+ * ```ts
1185
+ * permissionRow(ui, { group: "Account · D1", scope: "Edit", reason: "d1", inBaseTemplate: false }, true);
1186
+ * ```
1187
+ */
1188
+ const permissionRow = (ui, permission, showTemplateFlag) => {
1189
+ const { palette } = ui;
1190
+ const templateMark = permission.inBaseTemplate ? palette.green("✓") : palette.pink("+");
1191
+ const mark = showTemplateFlag ? templateMark : palette.dim("•");
1192
+ const flag = showTemplateFlag && !permission.inBaseTemplate ? palette.pink(" ← add to template") : "";
1193
+ const reason = palette.dim(`(${permission.reason})`);
1194
+ return `${mark} ${permission.group} : ${palette.bold(permission.scope)} ${reason}${flag}`;
1195
+ };
1196
+ /**
1197
+ * Render the LOCAL (first deploy) token panel: the full permission set with template/add markers,
1198
+ * then the numbered create-token steps (URL cyan, template + `.env.local` bold).
1199
+ *
1200
+ * @param ui - The branded console to render through.
1201
+ * @param requirement - The LOCAL token requirement (from requiredToken()).
1202
+ * @example
1203
+ * ```ts
1204
+ * localPanel(ui, requiredToken(manifest));
1205
+ * ```
1206
+ */
1207
+ const localPanel = (ui, requirement) => {
1208
+ const { palette } = ui;
1209
+ const adds = requirement.toAdd.map((permission) => `${permission.group.replace("Account · ", "")} → ${permission.scope}`).join(", ");
1210
+ const coversAll = palette.dim(`The "${requirement.base}" template covers everything.`);
1211
+ const addStep = requirement.toAdd.length > 0 ? ` 3. ADD ${palette.pink(adds)}` : ` 3. ${coversAll}`;
1212
+ const template = palette.bold(`"${requirement.base}"`);
1213
+ ui.box([
1214
+ palette.bold("LOCAL — first deploy (creates your infra)"),
1215
+ "",
1216
+ ...requirement.required.map((permission) => permissionRow(ui, permission, true)),
1217
+ "",
1218
+ ` 1. ${palette.cyan(TOKENS_URL$1)}`,
1219
+ ` 2. Create Token → start from the ${template} template.`,
1220
+ addStep,
1221
+ " 4. Account Resources → Include → your account.",
1222
+ ` 5. Create it, copy it, then paste into ${palette.bold(".env.local")} (below).`
1223
+ ]);
1224
+ };
1225
+ /**
1226
+ * Render the compact CI (automation redeploy) token panel: the reduced, read-mostly permission set
1227
+ * for a later Custom Token. No template markers — CI builds a token from scratch, not the template.
1228
+ *
1229
+ * @param ui - The branded console to render through.
1230
+ * @param groups - The CI token permission groups (from ciToken()).
1231
+ * @example
1232
+ * ```ts
1233
+ * ciPanel(ui, ciToken(manifest));
1234
+ * ```
1235
+ */
1236
+ const ciPanel = (ui, groups) => {
1237
+ const { palette } = ui;
1238
+ ui.box([
1239
+ palette.bold("CI — automation redeploy (optional, later)"),
1240
+ "",
1241
+ ...groups.map((permission) => permissionRow(ui, permission, false)),
1242
+ "",
1243
+ palette.dim("Create a Custom Token with exactly these (Read, not Edit, on data)."),
1244
+ palette.dim("Store as the CLOUDFLARE_API_TOKEN secret; pin CLOUDFLARE_ACCOUNT_ID.")
1245
+ ]);
1246
+ };
1247
+ /**
1248
+ * Render the full branded `auth setup` guidance: a heading, the LOCAL token panel (what to create
1249
+ * now), and — when `opts.ci` is supplied — the compact CI panel; otherwise a one-line pointer to
1250
+ * `auth setup` for the CI token (so the guided deploy stays focused on the immediate next step).
1251
+ *
1252
+ * @param ui - The branded console to render through.
1253
+ * @param requirement - The LOCAL token requirement (from requiredToken()).
1254
+ * @param opts - Optional rendering options.
1255
+ * @param opts.ci - The CI token permission groups (from ciToken()); omit to show a pointer instead.
1256
+ * @example
1257
+ * ```ts
1258
+ * renderAuthSetup(ui, requiredToken(manifest)); // guided deploy (LOCAL only)
1259
+ * renderAuthSetup(ui, requiredToken(manifest), { ci: ciToken(m) }); // `auth setup` (LOCAL + CI)
1260
+ * ```
1261
+ */
1262
+ const renderAuthSetup = (ui, requirement, opts) => {
1263
+ ui.heading("Cloudflare API token");
1264
+ localPanel(ui, requirement);
1265
+ if (opts?.ci) ciPanel(ui, opts.ci);
1266
+ else ui.line(ui.palette.dim(" Need a CI token later? Run `auth setup` for the reduced set."));
1267
+ };
1268
+ //#endregion
1050
1269
  //#region src/plugins/deploy/auth/setup.ts
1051
1270
  /** Cloudflare's dashboard path for creating API tokens. */
1052
1271
  const TOKENS_URL = "https://dash.cloudflare.com/profile/api-tokens";
@@ -1126,6 +1345,41 @@ const tokenInstructions = (manifest) => [
1126
1345
  "",
1127
1346
  ...ciSection(ciToken(manifest))
1128
1347
  ].join("\n");
1348
+ /**
1349
+ * Render a ready-to-fill `.env.local` for the guided deploy: the two Cloudflare credential keys
1350
+ * (left blank to paste into) preceded by a comment block derived from the manifest — where to
1351
+ * create the token, which template to start from, exactly which permissions to add, and how to find
1352
+ * the account id. The same guidance {@link tokenInstructions} prints, but PERSISTED in the file the
1353
+ * user edits (so it survives the terminal scrolling away). Pure: no fs, no network.
1354
+ *
1355
+ * @param manifest - The assembled deploy manifest.
1356
+ * @returns The `.env.local` file contents (trailing newline included).
1357
+ * @example
1358
+ * ```ts
1359
+ * await writeFile(".env.local", envLocalScaffold(manifest));
1360
+ * ```
1361
+ */
1362
+ const envLocalScaffold = (manifest) => {
1363
+ const requirement = requiredToken(manifest);
1364
+ 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.`;
1365
+ return `${[
1366
+ "# Cloudflare credentials for the moku deploy — fill in the two values below, then re-run deploy.",
1367
+ "# Local-only: keep this file out of git (.env.local is gitignored by convention).",
1368
+ "#",
1369
+ "# Create the API token:",
1370
+ `# 1. ${TOKENS_URL} -> Create Token`,
1371
+ `# 2. Start from the "${requirement.base}" template.`,
1372
+ addStep,
1373
+ "# 4. Account Resources -> Include -> your account.",
1374
+ "# 5. Create the token, copy it, and paste it after CLOUDFLARE_API_TOKEN= below.",
1375
+ "#",
1376
+ "# Account id: open https://dash.cloudflare.com — it is the id in the URL",
1377
+ "# (dash.cloudflare.com/<account-id>) or in the right sidebar of any domain's overview.",
1378
+ "",
1379
+ "CLOUDFLARE_API_TOKEN=",
1380
+ "CLOUDFLARE_ACCOUNT_ID="
1381
+ ].join("\n")}\n`;
1382
+ };
1129
1383
  //#endregion
1130
1384
  //#region src/plugins/deploy/infra/cloudflare.ts
1131
1385
  /**
@@ -1629,16 +1883,17 @@ const realDevDeps = () => ({
1629
1883
  now: nowMs
1630
1884
  });
1631
1885
  /**
1632
- * The d1 binding to migrate locally, when a d1 plugin is present in the app.
1886
+ * The d1 bindings to migrate locally one per configured d1 instance that declares a migrations
1887
+ * directory (empty when no d1 plugin is present, or none declares migrations).
1633
1888
  *
1634
1889
  * @param ctx - The deploy plugin context.
1635
- * @returns The d1 binding name, or undefined when no d1 plugin is present.
1890
+ * @returns The d1 binding names with migrations (e.g. `["DB"]`).
1636
1891
  * @example
1637
1892
  * ```ts
1638
- * const binding = d1Binding(ctx); // "DB" | undefined
1893
+ * const bindings = d1MigrationBindings(ctx); // ["DB"]
1639
1894
  * ```
1640
1895
  */
1641
- const d1Binding = (ctx) => ctx.has("d1") ? ctx.require(d1Plugin).deployManifest().binding : void 0;
1896
+ const d1MigrationBindings = (ctx) => ctx.has("d1") ? ctx.require(d1Plugin).deployManifest().filter((manifest) => manifest.migrations !== void 0).map((manifest) => manifest.binding) : [];
1642
1897
  /**
1643
1898
  * Rebuild the site once and announce the result. A failed build keeps the session alive (it just
1644
1899
  * emits dev:error and serves the last good build).
@@ -1692,13 +1947,13 @@ const runDev = async (ctx, opts, deps) => {
1692
1947
  detail: "site"
1693
1948
  });
1694
1949
  await deps.build(ctx, webBuild);
1695
- const binding = d1Binding(ctx);
1696
- if (ctx.config.migrateLocal && binding !== void 0) {
1950
+ const migrationBindings = ctx.config.migrateLocal ? d1MigrationBindings(ctx) : [];
1951
+ if (migrationBindings.length > 0) {
1697
1952
  ctx.emit("dev:phase", {
1698
1953
  phase: "migrate",
1699
1954
  detail: "d1 (local)"
1700
1955
  });
1701
- await deps.runWrangler([
1956
+ for (const binding of migrationBindings) await deps.runWrangler([
1702
1957
  "d1",
1703
1958
  "migrations",
1704
1959
  "apply",
@@ -1742,21 +1997,21 @@ const runDev = async (ctx, opts, deps) => {
1742
1997
  const checkExisting = (resource, existing) => {
1743
1998
  switch (resource.kind) {
1744
1999
  case "kv": {
1745
- const id = existing.kv.get(resource.binding);
2000
+ const id = existing.kv.get(resource.name);
1746
2001
  return id === void 0 ? { exists: false } : {
1747
2002
  exists: true,
1748
2003
  id
1749
2004
  };
1750
2005
  }
1751
2006
  case "d1": {
1752
- const id = existing.d1.get(resource.binding);
2007
+ const id = existing.d1.get(resource.name);
1753
2008
  return id === void 0 ? { exists: false } : {
1754
2009
  exists: true,
1755
2010
  id
1756
2011
  };
1757
2012
  }
1758
- case "r2": return { exists: existing.r2.has(resource.bucket) };
1759
- case "queue": return { exists: resource.producers.every((producer) => existing.queue.has(producer)) };
2013
+ case "r2": return { exists: existing.r2.has(resource.name) };
2014
+ case "queue": return { exists: existing.queue.has(resource.name) };
1760
2015
  case "do": return { exists: false };
1761
2016
  }
1762
2017
  };
@@ -1806,6 +2061,177 @@ const planInfra = async (ctx, manifest) => {
1806
2061
  };
1807
2062
  };
1808
2063
  //#endregion
2064
+ //#region src/plugins/deploy/infra/render.ts
2065
+ /**
2066
+ * Derive a human-readable name from a resource descriptor: the Cloudflare resource `name` for the
2067
+ * provisioned kinds (kv/r2/d1/queue), or the exported `className` for a Durable Object (which has no
2068
+ * provisioned name). Used in both the provision events and the branded panels so the two agree.
2069
+ *
2070
+ * @param resource - The resource descriptor.
2071
+ * @returns A short name identifying the resource.
2072
+ * @example
2073
+ * ```ts
2074
+ * resourceName({ kind: "kv", name: "tracker-cache", binding: "CACHE" }); // "tracker-cache"
2075
+ * ```
2076
+ */
2077
+ const resourceName = (resource) => resource.kind === "do" ? resource.className : resource.name;
2078
+ /**
2079
+ * Format a `kind name` cell, padding the kind so the names line up in a column.
2080
+ *
2081
+ * @param kind - The resource kind (kv / r2 / d1 / queue / do).
2082
+ * @param name - The resource name.
2083
+ * @returns The aligned `kind name` cell.
2084
+ * @example
2085
+ * ```ts
2086
+ * cell("kv", "CACHE"); // "kv CACHE"
2087
+ * ```
2088
+ */
2089
+ const cell = (kind, name) => `${kind.padEnd(6)}${name}`;
2090
+ /**
2091
+ * ANSI SGR matcher — built from `String.fromCharCode(27)` (the ESC byte) so no control character
2092
+ * appears in a regex literal (which both linters reject).
2093
+ */
2094
+ const ANSI_SGR = new RegExp(String.raw`${String.fromCodePoint(27)}\[[0-9;]*m`, "gu");
2095
+ /**
2096
+ * Strip ANSI SGR escape sequences so a captured (colorized) error renders as plain, readable text.
2097
+ *
2098
+ * @param text - The (possibly colorized) text.
2099
+ * @returns The text with ANSI color codes removed.
2100
+ * @example
2101
+ * ```ts
2102
+ * stripAnsi(`${String.fromCharCode(27)}[31mX${String.fromCharCode(27)}[0m`); // "X"
2103
+ * ```
2104
+ */
2105
+ const stripAnsi = (text) => text.replaceAll(ANSI_SGR, "");
2106
+ /**
2107
+ * Clean a captured (colorized, multi-line, wrapper-wrapped) provision error down to its meaningful
2108
+ * text: strip ANSI, drop the wrapper lines (the branded prefix, wrangler's log-file pointer), strip
2109
+ * each `✘ [ERROR]` marker, and join what's left. Returns the FULL message (the caller word-wraps it)
2110
+ * so the user reads the actual reason — never a truncated `…`.
2111
+ *
2112
+ * @param message - The captured error message.
2113
+ * @returns The full, plain failure reason.
2114
+ * @example
2115
+ * ```ts
2116
+ * cleanError("[moku-worker] wrangler exited…\n ✘ [ERROR] The bucket name is invalid.");
2117
+ * // "The bucket name is invalid."
2118
+ * ```
2119
+ */
2120
+ const cleanError = (message) => {
2121
+ 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(" ");
2122
+ return cleaned.length > 0 ? cleaned : stripAnsi(message).trim();
2123
+ };
2124
+ /**
2125
+ * Word-wrap text to `width` columns (never splitting inside a word), so a long failure reason reads
2126
+ * as a tidy indented block instead of forcing the box wide or scrolling off the edge.
2127
+ *
2128
+ * @param text - The text to wrap.
2129
+ * @param width - The maximum column width per line.
2130
+ * @returns The wrapped lines.
2131
+ * @example
2132
+ * ```ts
2133
+ * wrapText("a long sentence to wrap", 10); // ["a long", "sentence", "to wrap"]
2134
+ * ```
2135
+ */
2136
+ const wrapText = (text, width) => {
2137
+ const lines = [];
2138
+ let line = "";
2139
+ for (const word of text.split(/\s+/u).filter(Boolean)) if (line.length === 0) line = word;
2140
+ else if (line.length + 1 + word.length <= width) line += ` ${word}`;
2141
+ else {
2142
+ lines.push(line);
2143
+ line = word;
2144
+ }
2145
+ if (line.length > 0) lines.push(line);
2146
+ return lines;
2147
+ };
2148
+ /**
2149
+ * Render the infra preflight plan as a branded panel: a dim summary line (counts + account) then one
2150
+ * row per declared resource — a pink `+` for those to create, a dim `~ (exists)` for those already
2151
+ * present. When nothing needs creating it still renders, so the user sees the full picture.
2152
+ *
2153
+ * @param ui - The branded console to render through.
2154
+ * @param plan - The infra plan (existing vs missing) from checkInfra()/planInfra().
2155
+ * @example
2156
+ * ```ts
2157
+ * renderPlan(ui, await planInfra(ctx, manifest));
2158
+ * ```
2159
+ */
2160
+ const renderPlan = (ui, plan) => {
2161
+ const { palette } = ui;
2162
+ const summary = palette.dim(`${String(plan.missing.length)} to create · ${String(plan.exists.length)} exist · ${plan.account}`);
2163
+ const existsRows = plan.exists.map((ref) => `${palette.dim("~")} ${cell(ref.resource.kind, resourceName(ref.resource))} ${palette.dim("(exists)")}`);
2164
+ const createRows = plan.missing.map((resource) => `${palette.pink("+")} ${cell(resource.kind, resourceName(resource))}`);
2165
+ ui.heading("Infra plan");
2166
+ ui.box([
2167
+ summary,
2168
+ "",
2169
+ ...createRows,
2170
+ ...existsRows
2171
+ ]);
2172
+ };
2173
+ /**
2174
+ * Render the provision result as a branded panel — a green `✓` per created resource, a dim `~` per
2175
+ * skipped, a red `✗` per failure, then a summary line (failed count red when non-zero) — followed,
2176
+ * when anything failed, by a detail block printing each failure's FULL reason (ANSI-stripped and
2177
+ * word-wrapped) so it is actually readable instead of truncated inside the box.
2178
+ *
2179
+ * @param ui - The branded console to render through.
2180
+ * @param result - The provision result from provisionInfra()/the deploy pipeline.
2181
+ * @example
2182
+ * ```ts
2183
+ * renderProvisionResult(ui, await provisionInfra(plan));
2184
+ * ```
2185
+ */
2186
+ const renderProvisionResult = (ui, result) => {
2187
+ const { palette } = ui;
2188
+ const createdRows = result.created.map((ref) => `${palette.green("✓")} ${cell(ref.resource.kind, resourceName(ref.resource))}`);
2189
+ const skippedRows = result.skipped.map((ref) => `${palette.dim("~")} ${cell(ref.resource.kind, resourceName(ref.resource))} ${palette.dim("(exists)")}`);
2190
+ const failedRows = result.failed.map((failure) => `${palette.red("✗")} ${cell(failure.resource.kind, resourceName(failure.resource))}`);
2191
+ const failedCount = result.failed.length > 0 ? palette.red(`${String(result.failed.length)} failed`) : "0 failed";
2192
+ const summary = `${String(result.created.length)} created · ${String(result.skipped.length)} exist · ${failedCount}`;
2193
+ ui.heading("Provisioned");
2194
+ ui.box([
2195
+ ...createdRows,
2196
+ ...skippedRows,
2197
+ ...failedRows,
2198
+ "",
2199
+ summary
2200
+ ]);
2201
+ if (result.failed.length > 0) {
2202
+ ui.line();
2203
+ for (const failure of result.failed) {
2204
+ ui.line(` ${palette.red("✗")} ${cell(failure.resource.kind, resourceName(failure.resource))}`);
2205
+ for (const wrapped of wrapText(cleanError(failure.error), ui.width - 4)) ui.line(palette.dim(` ${wrapped}`));
2206
+ }
2207
+ }
2208
+ };
2209
+ //#endregion
2210
+ //#region src/plugins/deploy/naming.ts
2211
+ /**
2212
+ * @file deploy plugin — stage-aware resource naming.
2213
+ *
2214
+ * One source of truth for turning a base Cloudflare resource name into its stage variant, so the
2215
+ * worker name, the provisioners, the infra existence diff, and the generated wrangler config all
2216
+ * agree. Production keeps the base name; every other stage gets a `-${stage}` suffix. Node-only;
2217
+ * never imported by the runtime Worker bundle.
2218
+ */
2219
+ /**
2220
+ * Apply the deploy stage to a base Cloudflare resource name: the base name in `production`, else
2221
+ * `${base}-${stage}` (e.g. dev → `tracker-db-dev`). Env bindings + DO class names never get the
2222
+ * suffix — only provisioned resource names (and the worker name) are stage-qualified.
2223
+ *
2224
+ * @param base - The base resource name (e.g. "tracker-db").
2225
+ * @param stage - The deploy stage (e.g. "production", "development", "dev").
2226
+ * @returns The stage-qualified name.
2227
+ * @example
2228
+ * ```ts
2229
+ * stageName("tracker-db", "production"); // "tracker-db"
2230
+ * stageName("tracker-db", "dev"); // "tracker-db-dev"
2231
+ * ```
2232
+ */
2233
+ const stageName = (base, stage) => stage === "production" ? base : `${base}-${stage}`;
2234
+ //#endregion
1809
2235
  //#region src/plugins/deploy/providers/d1.ts
1810
2236
  /**
1811
2237
  * @file deploy plugin — D1 provisioning adapter.
@@ -1845,13 +2271,13 @@ const provisionD1 = async (manifest, _ci) => {
1845
2271
  const id = parseD1DatabaseId(await runWrangler([
1846
2272
  "d1",
1847
2273
  "create",
1848
- manifest.binding
2274
+ manifest.name
1849
2275
  ]));
1850
2276
  if (manifest.migrations) await runWrangler([
1851
2277
  "d1",
1852
2278
  "migrations",
1853
2279
  "apply",
1854
- manifest.binding,
2280
+ manifest.name,
1855
2281
  "--local"
1856
2282
  ]);
1857
2283
  return id ? { id } : {};
@@ -1914,7 +2340,7 @@ const provisionKv = async (manifest, _ci) => {
1914
2340
  "kv",
1915
2341
  "namespace",
1916
2342
  "create",
1917
- manifest.binding
2343
+ manifest.name
1918
2344
  ]));
1919
2345
  return id ? { id } : {};
1920
2346
  };
@@ -1923,25 +2349,25 @@ const provisionKv = async (manifest, _ci) => {
1923
2349
  /**
1924
2350
  * @file deploy plugin — Queues provisioning adapter.
1925
2351
  *
1926
- * Creates Cloudflare Queues via `wrangler queues create <name>` for each producer.
2352
+ * Creates one Cloudflare Queue via `wrangler queues create <name>` per queue instance.
1927
2353
  * Node-only; never imported by the runtime Worker bundle.
1928
2354
  */
1929
2355
  /**
1930
- * Provision queues via `wrangler queues create` for each declared producer.
2356
+ * Provision the queue via `wrangler queues create <name>`.
1931
2357
  *
1932
2358
  * @param manifest - The queue resource descriptor.
1933
2359
  * @param _ci - Whether running non-interactively.
1934
- * @returns Resolves once all queues are created.
2360
+ * @returns Resolves once the queue is created.
1935
2361
  * @example
1936
2362
  * ```ts
1937
- * await provisionQueue({ kind: "queue", producers: ["orders"] }, false);
2363
+ * await provisionQueue({ kind: "queue", name: "tracker-activity", binding: "ACTIVITY" }, false);
1938
2364
  * ```
1939
2365
  */
1940
2366
  const provisionQueue = async (manifest, _ci) => {
1941
- for (const producer of manifest.producers) await runWrangler([
2367
+ await runWrangler([
1942
2368
  "queues",
1943
2369
  "create",
1944
- producer
2370
+ manifest.name
1945
2371
  ]);
1946
2372
  };
1947
2373
  //#endregion
@@ -1964,7 +2390,7 @@ const provisionQueue = async (manifest, _ci) => {
1964
2390
  * @returns Resolves once the bucket is created.
1965
2391
  * @example
1966
2392
  * ```ts
1967
- * await provisionR2({ kind: "r2", bucket: "ASSETS" }, false);
2393
+ * await provisionR2({ kind: "r2", name: "tracker-files", binding: "FILES" }, false);
1968
2394
  * ```
1969
2395
  */
1970
2396
  const provisionR2 = async (manifest, _ci) => {
@@ -1972,7 +2398,7 @@ const provisionR2 = async (manifest, _ci) => {
1972
2398
  "r2",
1973
2399
  "bucket",
1974
2400
  "create",
1975
- manifest.bucket
2401
+ manifest.name
1976
2402
  ]);
1977
2403
  };
1978
2404
  /**
@@ -2118,12 +2544,12 @@ const buildKvNamespaces = (resources, ids) => resources.filter((resource) => res
2118
2544
  * @returns One wrangler R2 bucket entry per r2 resource.
2119
2545
  * @example
2120
2546
  * ```ts
2121
- * const r2 = buildR2Buckets([{ kind: "r2", bucket: "ASSETS" }]);
2547
+ * const r2 = buildR2Buckets([{ kind: "r2", name: "tracker-files", binding: "FILES" }]);
2122
2548
  * ```
2123
2549
  */
2124
2550
  const buildR2Buckets = (resources) => resources.filter((resource) => resource.kind === "r2").map((resource) => ({
2125
- binding: resource.bucket,
2126
- bucket_name: resource.bucket.toLowerCase()
2551
+ binding: resource.binding,
2552
+ bucket_name: resource.name
2127
2553
  }));
2128
2554
  /**
2129
2555
  * Build the wrangler `d1_databases` array from the manifest's d1 resources.
@@ -2133,13 +2559,13 @@ const buildR2Buckets = (resources) => resources.filter((resource) => resource.ki
2133
2559
  * @returns One wrangler D1 database entry per d1 resource (migrations_dir set when present).
2134
2560
  * @example
2135
2561
  * ```ts
2136
- * const d1 = buildD1Databases([{ kind: "d1", binding: "DB" }], { DB: "uuid-1234" });
2562
+ * const d1 = buildD1Databases([{ kind: "d1", name: "tracker-db", binding: "DB" }], { DB: "uuid-1234" });
2137
2563
  * ```
2138
2564
  */
2139
2565
  const buildD1Databases = (resources, ids) => resources.filter((resource) => resource.kind === "d1").map((resource) => {
2140
2566
  const entry = {
2141
2567
  binding: resource.binding,
2142
- database_name: resource.binding.toLowerCase(),
2568
+ database_name: resource.name,
2143
2569
  database_id: ids[resource.binding] ?? ""
2144
2570
  };
2145
2571
  if (resource.migrations) entry.migrations_dir = resource.migrations;
@@ -2152,16 +2578,16 @@ const buildD1Databases = (resources, ids) => resources.filter((resource) => reso
2152
2578
  * @returns The queues section, or undefined when there are no queue resources.
2153
2579
  * @example
2154
2580
  * ```ts
2155
- * const q = buildQueues([{ kind: "queue", producers: ["jobs"] }]);
2581
+ * const q = buildQueues([{ kind: "queue", name: "tracker-activity", binding: "ACTIVITY" }]);
2156
2582
  * ```
2157
2583
  */
2158
2584
  const buildQueues = (resources) => {
2159
2585
  const queueResources = resources.filter((resource) => resource.kind === "queue");
2160
2586
  if (queueResources.length === 0) return void 0;
2161
- return { producers: queueResources.flatMap((resource) => resource.producers.map((producer) => ({
2162
- queue: producer,
2163
- binding: producer.toUpperCase()
2164
- }))) };
2587
+ return { producers: queueResources.map((resource) => ({
2588
+ queue: resource.name,
2589
+ binding: resource.binding
2590
+ })) };
2165
2591
  };
2166
2592
  /**
2167
2593
  * Build the wrangler `durable_objects` bindings section from the manifest's do resources.
@@ -2170,47 +2596,136 @@ const buildQueues = (resources) => {
2170
2596
  * @returns The durable_objects section, or undefined when there are no do resources.
2171
2597
  * @example
2172
2598
  * ```ts
2173
- * const dobj = buildDurableObjects([{ kind: "do", bindings: { Counter: "COUNTER" } }]);
2599
+ * const dobj = buildDurableObjects([{ kind: "do", binding: "COUNTER", className: "Counter" }]);
2174
2600
  * ```
2175
2601
  */
2176
2602
  const buildDurableObjects = (resources) => {
2177
2603
  const doResources = resources.filter((resource) => resource.kind === "do");
2178
2604
  if (doResources.length === 0) return void 0;
2179
- return { bindings: doResources.flatMap((resource) => Object.entries(resource.bindings).map(([className, bindingName]) => ({
2180
- name: bindingName,
2181
- class_name: className
2182
- }))) };
2605
+ return { bindings: doResources.map((resource) => ({
2606
+ name: resource.binding,
2607
+ class_name: resource.className
2608
+ })) };
2609
+ };
2610
+ /**
2611
+ * Build the auto Durable Object `migrations` from the manifest's do classes. wrangler REQUIRES a
2612
+ * migration for every DO class, so this derives a single `v1` migration registering each class as
2613
+ * SQLite-backed (the modern default) — the exact section wrangler prompts for when it is missing.
2614
+ *
2615
+ * @param resources - All resource descriptors from the manifest.
2616
+ * @returns A single-entry migrations array, or undefined when there are no do resources.
2617
+ * @example
2618
+ * ```ts
2619
+ * buildMigrations([{ kind: "do", binding: "BOARD", className: "BoardChannel" }]);
2620
+ * // [{ tag: "v1", new_sqlite_classes: ["BoardChannel"] }]
2621
+ * ```
2622
+ */
2623
+ const buildMigrations = (resources) => {
2624
+ const classes = resources.filter((resource) => resource.kind === "do").map((resource) => resource.className);
2625
+ return classes.length > 0 ? [{
2626
+ tag: "v1",
2627
+ new_sqlite_classes: classes
2628
+ }] : void 0;
2629
+ };
2630
+ /**
2631
+ * Extract the already-captured Cloudflare ids (kv namespace `id`, d1 `database_id`) from an existing
2632
+ * parsed wrangler config, keyed by binding — so a regeneration (e.g. on `dev`) can preserve ids it
2633
+ * isn't handed. Tolerant of a malformed/hand-edited file (skips non-object / non-string entries).
2634
+ *
2635
+ * @param existing - The parsed existing wrangler config (or `{}`).
2636
+ * @returns A binding → id map (empty when the file has none).
2637
+ * @example
2638
+ * ```ts
2639
+ * extractExistingIds({ kv_namespaces: [{ binding: "CACHE", id: "ns1" }] }); // { CACHE: "ns1" }
2640
+ * ```
2641
+ */
2642
+ const extractExistingIds = (existing) => {
2643
+ const ids = {};
2644
+ const collect = (list, idKey) => {
2645
+ if (!Array.isArray(list)) return;
2646
+ for (const raw of list) {
2647
+ if (raw === null || typeof raw !== "object") continue;
2648
+ const entry = raw;
2649
+ const binding = entry.binding;
2650
+ const id = entry[idKey];
2651
+ if (typeof binding === "string" && typeof id === "string" && id.length > 0) ids[binding] = id;
2652
+ }
2653
+ };
2654
+ collect(existing.kv_namespaces, "id");
2655
+ collect(existing.d1_databases, "database_id");
2656
+ return ids;
2657
+ };
2658
+ /**
2659
+ * Build the extra top-level wrangler keys from the typed deploy config: `entry` → `main`,
2660
+ * `nodeCompat` → `compatibility_flags: ["nodejs_compat"]`, `assets` → the wrangler `assets` block
2661
+ * (SPA fallback when `spa`), then the raw `wrangler` passthrough last (the escape hatch wins / adds
2662
+ * anything else). Pass the result as the `extra` argument to {@link writeWranglerConfig}.
2663
+ *
2664
+ * @param config - The deploy plugin config.
2665
+ * @returns The merged extra wrangler keys.
2666
+ * @example
2667
+ * ```ts
2668
+ * await writeWranglerConfig(file, manifest, ids, wranglerExtra(ctx.config));
2669
+ * ```
2670
+ */
2671
+ const wranglerExtra = (config) => {
2672
+ const extra = {};
2673
+ if (config.entry !== void 0) extra.main = config.entry;
2674
+ if (config.nodeCompat === true) extra.compatibility_flags = ["nodejs_compat"];
2675
+ if (config.assets !== void 0) extra.assets = {
2676
+ directory: config.assets.directory,
2677
+ binding: config.assets.binding,
2678
+ ...config.assets.spa === true ? { not_found_handling: "single-page-application" } : {}
2679
+ };
2680
+ return {
2681
+ ...extra,
2682
+ ...config.wrangler
2683
+ };
2183
2684
  };
2184
2685
  /**
2185
2686
  * Generate/update the wrangler config file from a manifest (non-destructive merge).
2186
- * If the file exists, its top-level keys are preserved and only deploy-managed keys
2187
- * (name, compatibility_date, kv_namespaces, r2_buckets, d1_databases, queues,
2188
- * durable_objects) are updated.
2687
+ *
2688
+ * Layering (last wins): existing file keys → the `extra` passthrough (the app's `wrangler` config:
2689
+ * `main`, `compatibility_flags`, `assets`, `vars`, …) the deploy-managed keys (name,
2690
+ * compatibility_date, kv_namespaces, r2_buckets, d1_databases, queues, durable_objects). So the
2691
+ * framework always owns the resource sections, the app supplies what the manifest can't derive, and
2692
+ * any other hand-written keys survive. Durable Object `migrations` are auto-derived for every DO
2693
+ * class (the section wrangler requires) UNLESS the file/passthrough already defines `migrations`.
2189
2694
  *
2190
2695
  * @param configFile - Path to the wrangler config file.
2191
2696
  * @param manifest - The assembled deploy manifest.
2192
2697
  * @param ids - Captured Cloudflare ids keyed by binding (kv namespace id, d1 database id). Defaults
2193
2698
  * to an empty map, in which case `id`/`database_id` are written as "" (e.g. the universal path).
2699
+ * @param extra - Extra top-level wrangler keys to merge in (the app's `deploy.wrangler` config).
2194
2700
  * @returns Resolves once the file is written.
2195
2701
  * @example
2196
2702
  * ```ts
2197
- * await writeWranglerConfig("wrangler.jsonc", manifest, { CACHE: "ns123", DB: "uuid-1234" });
2703
+ * await writeWranglerConfig("wrangler.jsonc", manifest, { CACHE: "ns123" }, {
2704
+ * main: "src/cloudflare/worker.ts",
2705
+ * compatibility_flags: ["nodejs_compat"],
2706
+ * assets: { directory: "dist/client", binding: "ASSETS" }
2707
+ * });
2198
2708
  * ```
2199
2709
  */
2200
- const writeWranglerConfig = async (configFile, manifest, ids = {}) => {
2710
+ const writeWranglerConfig = async (configFile, manifest, ids = {}, extra = {}) => {
2201
2711
  let existing = {};
2202
2712
  if ((0, node_fs.existsSync)(configFile)) try {
2203
2713
  existing = parseJsonc((0, node_fs.readFileSync)(configFile, "utf8"));
2204
2714
  } catch {
2205
2715
  existing = {};
2206
2716
  }
2207
- const kvNamespaces = buildKvNamespaces(manifest.resources, ids);
2717
+ const effectiveIds = {
2718
+ ...extractExistingIds(existing),
2719
+ ...ids
2720
+ };
2721
+ const kvNamespaces = buildKvNamespaces(manifest.resources, effectiveIds);
2208
2722
  const r2Buckets = buildR2Buckets(manifest.resources);
2209
- const d1Databases = buildD1Databases(manifest.resources, ids);
2723
+ const d1Databases = buildD1Databases(manifest.resources, effectiveIds);
2210
2724
  const queues = buildQueues(manifest.resources);
2211
2725
  const durableObjects = buildDurableObjects(manifest.resources);
2212
2726
  const updated = {
2213
2727
  ...existing,
2728
+ ...extra,
2214
2729
  name: manifest.name,
2215
2730
  compatibility_date: manifest.compatibilityDate
2216
2731
  };
@@ -2219,6 +2734,8 @@ const writeWranglerConfig = async (configFile, manifest, ids = {}) => {
2219
2734
  if (d1Databases.length > 0) updated.d1_databases = d1Databases;
2220
2735
  if (queues !== void 0) updated.queues = queues;
2221
2736
  if (durableObjects !== void 0) updated.durable_objects = durableObjects;
2737
+ const migrations = buildMigrations(manifest.resources);
2738
+ if (migrations !== void 0 && updated.migrations === void 0) updated.migrations = migrations;
2222
2739
  await (0, node_fs_promises.writeFile)(configFile, JSON.stringify(updated, void 0, 2));
2223
2740
  };
2224
2741
  /**
@@ -2257,56 +2774,50 @@ const scaffoldWranglerAndCi = async (configFile, _ci) => {
2257
2774
  * Cloudflare REST API (via infra/). Never called in the deployed Worker runtime.
2258
2775
  */
2259
2776
  /**
2260
- * Derive a human-readable name string from a resource descriptor (used in provision events).
2261
- *
2262
- * @param resource - The resource descriptor.
2263
- * @returns A name suitable for the provision:resource / provision:skip event payload.
2264
- * @example
2265
- * ```ts
2266
- * resourceName({ kind: "kv", binding: "CACHE" }); // "CACHE"
2267
- * ```
2268
- */
2269
- const resourceName = (resource) => {
2270
- switch (resource.kind) {
2271
- case "r2": return resource.bucket;
2272
- case "do": return Object.values(resource.bindings).join(",");
2273
- case "queue": return resource.producers.join(",");
2274
- default: return resource.binding;
2275
- }
2276
- };
2277
- /**
2278
- * Assemble the deploy manifest from each present resource plugin's OWN deployManifest() api,
2279
- * gated by ctx.has(name) so absent plugins are skipped — never sibling pluginConfigs (F6).
2777
+ * Assemble the deploy manifest from each present resource plugin's OWN deployManifest() api (each
2778
+ * returns one entry PER configured instance), gated by ctx.has(name) so absent plugins are skipped —
2779
+ * never sibling pluginConfigs (F6). The single place the deploy stage is baked into names: the worker
2780
+ * name and every provisioned resource `name` are run through {@link stageName} (bindings/DO class
2781
+ * names are never suffixed), so provisioning, the existence diff, and the generated config all agree.
2280
2782
  *
2281
2783
  * @param ctx - The deploy plugin context.
2282
- * @returns The assembled manifest (name, compatibilityDate, resources).
2784
+ * @param stage - The deploy stage (e.g. "production", "dev") applied to every resource name.
2785
+ * @returns The assembled manifest (stage-qualified name, compatibilityDate, per-instance resources).
2283
2786
  * @example
2284
2787
  * ```ts
2285
- * const manifest = assembleManifest(ctx);
2788
+ * const manifest = assembleManifest(ctx, "production");
2286
2789
  * ```
2287
2790
  */
2288
- const assembleManifest = (ctx) => ({
2289
- name: ctx.global.name,
2290
- compatibilityDate: ctx.global.compatibilityDate,
2291
- resources: [
2292
- ctx.has("storage") ? ctx.require(storagePlugin).deployManifest() : void 0,
2293
- ctx.has("kv") ? ctx.require(kvPlugin).deployManifest() : void 0,
2294
- ctx.has("d1") ? ctx.require(d1Plugin).deployManifest() : void 0,
2295
- ctx.has("queues") ? ctx.require(queuesPlugin).deployManifest() : void 0,
2296
- ctx.has("durableObjects") ? ctx.require(durableObjectsPlugin).deployManifest() : void 0
2297
- ].filter((resource) => resource !== void 0)
2298
- });
2791
+ const assembleManifest = (ctx, stage) => {
2792
+ const resources = [
2793
+ ctx.has("storage") ? ctx.require(storagePlugin).deployManifest() : [],
2794
+ ctx.has("kv") ? ctx.require(kvPlugin).deployManifest() : [],
2795
+ ctx.has("d1") ? ctx.require(d1Plugin).deployManifest() : [],
2796
+ ctx.has("queues") ? ctx.require(queuesPlugin).deployManifest() : [],
2797
+ ctx.has("durableObjects") ? ctx.require(durableObjectsPlugin).deployManifest() : []
2798
+ ].flat();
2799
+ return {
2800
+ name: stageName(ctx.global.name, stage),
2801
+ compatibilityDate: ctx.global.compatibilityDate,
2802
+ resources: resources.map((resource) => "name" in resource ? {
2803
+ ...resource,
2804
+ name: stageName(resource.name, stage)
2805
+ } : resource)
2806
+ };
2807
+ };
2299
2808
  /**
2300
- * Act on an infra plan: skip the resources that already exist (reusing their ids), create only
2301
- * the missing ones (capturing each new id), and announce each via provision:skip / :resource.
2809
+ * Act on an infra plan: skip the resources that already exist (reusing their ids), create the
2810
+ * missing ones (capturing each new id), and announce each via provision:skip / :resource. Resilient
2811
+ * — a single resource that fails to create is CAPTURED in `failed` (not thrown), so one bad resource
2812
+ * (e.g. an invalid bucket name) never aborts the whole run and the caller can report a clear result.
2302
2813
  *
2303
2814
  * @param ctx - The deploy plugin context.
2304
2815
  * @param plan - The infra plan from planInfra (existing vs missing).
2305
2816
  * @param ci - Whether provisioning runs non-interactively (forwarded to each provider).
2306
- * @returns The provisioning result: created, skipped, and the merged binding → id map.
2817
+ * @returns The provisioning result: created, skipped, failed, and the merged binding → id map.
2307
2818
  * @example
2308
2819
  * ```ts
2309
- * const { ids } = await applyPlan(ctx, plan, false);
2820
+ * const { created, failed } = await applyPlan(ctx, plan, false);
2310
2821
  * ```
2311
2822
  */
2312
2823
  const applyPlan = async (ctx, plan, ci) => {
@@ -2319,7 +2830,8 @@ const applyPlan = async (ctx, plan, ci) => {
2319
2830
  });
2320
2831
  }
2321
2832
  const created = [];
2322
- for (const resource of plan.missing) {
2833
+ const failed = [];
2834
+ for (const resource of plan.missing) try {
2323
2835
  const { id } = await provisionResource(resource, ci);
2324
2836
  if (id !== void 0 && (resource.kind === "kv" || resource.kind === "d1")) ids[resource.binding] = id;
2325
2837
  created.push(id === void 0 ? { resource } : {
@@ -2330,14 +2842,234 @@ const applyPlan = async (ctx, plan, ci) => {
2330
2842
  kind: resource.kind,
2331
2843
  name: resourceName(resource)
2332
2844
  });
2845
+ } catch (error) {
2846
+ failed.push({
2847
+ resource,
2848
+ error: error instanceof Error ? error.message : String(error)
2849
+ });
2333
2850
  }
2334
2851
  return {
2335
2852
  created,
2336
2853
  skipped: plan.exists,
2854
+ failed,
2337
2855
  ids
2338
2856
  };
2339
2857
  };
2340
2858
  /**
2859
+ * Sentinel a guided helper resolves to when the user declined recovery — a clean abort the caller
2860
+ * turns into a `deploy:phase aborted` + early return, never a thrown (and re-rendered) error.
2861
+ */
2862
+ const ABORTED = Symbol("deploy:aborted");
2863
+ /** Retry guidance shown beneath each step's failure, before the "Retry?" prompt. */
2864
+ const HINTS = {
2865
+ build: "Web build failed — fix the error above, then retry.",
2866
+ provision: "Verify your token's account scopes and Cloudflare's status, then retry.",
2867
+ upload: "R2 upload failed — check the bucket and your token's R2 scope, then retry.",
2868
+ deploy: "wrangler deploy failed — review the output above, then retry."
2869
+ };
2870
+ /**
2871
+ * Emit the terminal `aborted` phase — the single exit every guided gate/retry funnels through when
2872
+ * the user stops the deploy. Factored out so each abort path renders one consistent line.
2873
+ *
2874
+ * @param ctx - The deploy plugin context.
2875
+ * @returns Nothing.
2876
+ * @example
2877
+ * ```ts
2878
+ * if (declined) return emitAborted(ctx);
2879
+ * ```
2880
+ */
2881
+ const emitAborted = (ctx) => ctx.emit("deploy:phase", { phase: "aborted" });
2882
+ /**
2883
+ * The full guided token setup shown after an auth failure on a TTY. Offers to walk the user through
2884
+ * it, and when accepted: prints WHERE to create the Cloudflare token (dashboard URL, which template,
2885
+ * the exact permissions to add) AND scaffolds a ready-to-fill `.env.local` — the same guidance baked
2886
+ * in as comments — for the user to paste the token + account id into (never clobbering an existing
2887
+ * file). Always ends pointing at the re-run.
2888
+ *
2889
+ * @param ctx - The deploy plugin context.
2890
+ * @param ui - The branded console to render the guidance through.
2891
+ * @param confirm - The yes/no prompt.
2892
+ * @returns Resolves once the guidance (and optional `.env.local` scaffold) has been rendered.
2893
+ * @example
2894
+ * ```ts
2895
+ * await guidedTokenSetup(ctx, createBrandConsole(), confirm);
2896
+ * ```
2897
+ */
2898
+ const guidedTokenSetup = async (ctx, ui, confirm) => {
2899
+ if (!await confirm("Set up Cloudflare credentials now? (guided)")) {
2900
+ ui.info("Set CLOUDFLARE_API_TOKEN in .env.local, then run `deploy` again.");
2901
+ return;
2902
+ }
2903
+ const manifest = assembleManifest(ctx, ctx.global.stage);
2904
+ renderAuthSetup(ui, requiredToken(manifest));
2905
+ const { created, path } = await ensureEnvLocal(process.cwd(), envLocalScaffold(manifest));
2906
+ 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.`);
2907
+ };
2908
+ /**
2909
+ * Verify the `.env` token, turning a missing/invalid token into a guided recovery on a TTY: surface
2910
+ * WHY auth failed, then walk the user through {@link guidedTokenSetup} (where to create the token +
2911
+ * scaffold a `.env.local`). The env is snapshotted at app start, so a freshly-pasted token only
2912
+ * takes effect on a NEW run. In CI/pipes the branded error re-throws (fail-fast).
2913
+ *
2914
+ * @param ctx - The deploy plugin context.
2915
+ * @param deps - Interactivity + the confirm prompt.
2916
+ * @returns True when the token verified; false when the user must set it up and re-run.
2917
+ * @throws {Error} Re-throws the branded auth error in CI / non-interactive runs.
2918
+ * @example
2919
+ * ```ts
2920
+ * if (!(await guidedAuth(ctx, { interactive, confirm }))) return;
2921
+ * ```
2922
+ */
2923
+ const guidedAuth = async (ctx, deps) => {
2924
+ try {
2925
+ await verifyAuth(ctx);
2926
+ return true;
2927
+ } catch (error) {
2928
+ if (!deps.interactive) throw error;
2929
+ const ui = (0, _moku_labs_common_cli.createBrandConsole)();
2930
+ ui.error(error instanceof Error ? error.message : String(error));
2931
+ await guidedTokenSetup(ctx, ui, deps.confirm);
2932
+ return false;
2933
+ }
2934
+ };
2935
+ /**
2936
+ * Run one external pipeline step with interactive recovery: on failure, render the branded error +
2937
+ * an actionable hint, then offer to retry — looping until the step succeeds or the user declines.
2938
+ * A decline resolves to {@link ABORTED} (a clean abort the caller surfaces), so the error is shown
2939
+ * once, not re-rendered downstream. In CI/pipes the first failure re-throws (fail-fast). The step
2940
+ * MUST be safe to re-run (idempotent).
2941
+ *
2942
+ * @param step - The async step to run (e.g. the web build, the R2 upload, `wrangler deploy`).
2943
+ * @param hint - One-line guidance shown beneath the error before the retry prompt.
2944
+ * @param deps - Interactivity + the confirm prompt.
2945
+ * @returns The step's resolved value once it succeeds, or {@link ABORTED} when a retry is declined.
2946
+ * @throws {Error} Re-throws the step's error in CI / non-interactive runs.
2947
+ * @example
2948
+ * ```ts
2949
+ * const url = await guidedStep(() => runWrangler(args), "wrangler deploy failed …", deps);
2950
+ * if (url === ABORTED) return;
2951
+ * ```
2952
+ */
2953
+ const guidedStep = async (step, hint, deps) => {
2954
+ for (;;) try {
2955
+ return await step();
2956
+ } catch (error) {
2957
+ if (!deps.interactive) throw error;
2958
+ const ui = (0, _moku_labs_common_cli.createBrandConsole)();
2959
+ ui.error(error instanceof Error ? error.message : String(error));
2960
+ ui.info(hint);
2961
+ if (!await deps.confirm("Retry?")) return ABORTED;
2962
+ }
2963
+ };
2964
+ /**
2965
+ * Run the read-only infra preflight with interactive recovery: a network/scope failure fails fast in
2966
+ * CI, or (on a TTY) renders the error + hint and offers a retry. Resolves the plan, or {@link ABORTED}
2967
+ * when the user declines the retry.
2968
+ *
2969
+ * @param ctx - The deploy plugin context.
2970
+ * @param manifest - The assembled (or caller-supplied) deploy manifest.
2971
+ * @param deps - Interactivity + the confirm prompt.
2972
+ * @returns The infra plan, or {@link ABORTED} when a preflight retry is declined.
2973
+ * @throws {Error} Re-throws the preflight error in CI / non-interactive runs.
2974
+ * @example
2975
+ * ```ts
2976
+ * const plan = await guidedPlan(ctx, manifest, deps);
2977
+ * if (plan === ABORTED) return;
2978
+ * ```
2979
+ */
2980
+ const guidedPlan = async (ctx, manifest, deps) => {
2981
+ for (;;) try {
2982
+ return await planInfra(ctx, manifest);
2983
+ } catch (error) {
2984
+ if (!deps.interactive) throw error;
2985
+ const ui = (0, _moku_labs_common_cli.createBrandConsole)();
2986
+ ui.error(error instanceof Error ? error.message : String(error));
2987
+ ui.info(HINTS.provision);
2988
+ if (!await deps.confirm("Retry?")) return ABORTED;
2989
+ }
2990
+ };
2991
+ /**
2992
+ * Plan + provision the infra with branded panels and interactive recovery. Each attempt RE-PLANS
2993
+ * (a resource created by a prior attempt is seen as existing and skipped — retries stay idempotent),
2994
+ * renders the plan panel (what will be created vs already exists), confirms the create gate, creates
2995
+ * the resources, then renders the result panel (created / skipped / failed). When some resources
2996
+ * FAIL it offers to retry just those (interactive) or fails fast (CI). Resolves to {@link ABORTED}
2997
+ * when the user declines the gate or a retry.
2998
+ *
2999
+ * @param ctx - The deploy plugin context.
3000
+ * @param manifest - The assembled (or caller-supplied) deploy manifest.
3001
+ * @param ci - Whether provisioning runs non-interactively (forwarded to each provider).
3002
+ * @param deps - Interactivity + the confirm prompt.
3003
+ * @returns The provisioning result (all created/skipped), or {@link ABORTED} when the user declined.
3004
+ * @throws {Error} Re-throws a plan error, or throws on a provision failure, in CI / non-interactive runs.
3005
+ * @example
3006
+ * ```ts
3007
+ * const provisioned = await guidedProvision(ctx, manifest, ci, deps);
3008
+ * if (provisioned === ABORTED) return;
3009
+ * ```
3010
+ */
3011
+ const guidedProvision = async (ctx, manifest, ci, deps) => {
3012
+ for (;;) {
3013
+ const plan = await guidedPlan(ctx, manifest, deps);
3014
+ if (plan === ABORTED) return ABORTED;
3015
+ const ui = (0, _moku_labs_common_cli.createBrandConsole)();
3016
+ renderPlan(ui, plan);
3017
+ if (plan.missing.length > 0 && !await deps.confirm(`Create ${String(plan.missing.length)} missing resource(s) in "${plan.account}"?`)) return ABORTED;
3018
+ const result = await applyPlan(ctx, plan, ci);
3019
+ renderProvisionResult(ui, result);
3020
+ if (result.failed.length === 0) return result;
3021
+ if (!deps.interactive) throw new Error(`[moku-worker] ${String(result.failed.length)} resource(s) failed to provision.`);
3022
+ if (!await deps.confirm("Retry the failed resource(s)?")) return ABORTED;
3023
+ }
3024
+ };
3025
+ /**
3026
+ * Build the web site first (when a hook is wired in), so its assets exist before the R2 upload and
3027
+ * `wrangler deploy`. Emits the `build · web` phase, then runs the build with interactive retry.
3028
+ *
3029
+ * @param ctx - The deploy plugin context.
3030
+ * @param webBuild - The web build hook, or undefined when none is wired (then this is a no-op).
3031
+ * @param deps - Interactivity + the confirm prompt.
3032
+ * @returns True to continue the pipeline; false when the user declined a build retry (abort).
3033
+ * @example
3034
+ * ```ts
3035
+ * if (!(await guidedWebBuild(ctx, webBuild, deps))) return emitAborted(ctx);
3036
+ * ```
3037
+ */
3038
+ const guidedWebBuild = async (ctx, webBuild, deps) => {
3039
+ if (webBuild === void 0) return true;
3040
+ ctx.emit("deploy:phase", {
3041
+ phase: "build",
3042
+ detail: "web"
3043
+ });
3044
+ return await guidedStep(() => webBuild(), HINTS.build, deps) !== ABORTED;
3045
+ };
3046
+ /**
3047
+ * Upload the R2 directory when a bucket declares an upload source, with interactive retry. Emits the
3048
+ * `upload · N files` phase on success; a no-op (and emits nothing) when no bucket declares an upload.
3049
+ *
3050
+ * @param ctx - The deploy plugin context.
3051
+ * @param manifest - The assembled (or caller-supplied) deploy manifest.
3052
+ * @param deps - Interactivity + the confirm prompt.
3053
+ * @returns True to continue the pipeline; false when the user declined an upload retry (abort).
3054
+ * @example
3055
+ * ```ts
3056
+ * if (!(await guidedUpload(ctx, manifest, deps))) return emitAborted(ctx);
3057
+ * ```
3058
+ */
3059
+ const guidedUpload = async (ctx, manifest, deps) => {
3060
+ const r2 = manifest.resources.find((resource) => resource.kind === "r2");
3061
+ if (!r2?.upload) return true;
3062
+ const bucket = r2.name;
3063
+ const uploadDir = r2.upload;
3064
+ const count = await guidedStep(() => uploadDirToR2(bucket, uploadDir), HINTS.upload, deps);
3065
+ if (count === ABORTED) return false;
3066
+ ctx.emit("deploy:phase", {
3067
+ phase: "upload",
3068
+ detail: `${String(count)} files`
3069
+ });
3070
+ return true;
3071
+ };
3072
+ /**
2341
3073
  * Create the deploy api. Assembles the manifest from each resource plugin's own deployManifest(),
2342
3074
  * runs an infra preflight (check-before-create + id capture), generates config, uploads, and runs
2343
3075
  * `wrangler deploy`, emitting global deploy events along the way.
@@ -2356,10 +3088,16 @@ const createDeployApi = (ctx) => ({
2356
3088
  * missing) → wrangler-config (with real ids) → upload → deploy. When opts.manifest is supplied
2357
3089
  * it is used verbatim (universal path).
2358
3090
  *
3091
+ * On a TTY the run is GUIDED end to end: each gate is confirmed, and every failure is recovered
3092
+ * interactively rather than thrown — a missing/invalid token offers `auth setup`, and the build,
3093
+ * infra, upload, and `wrangler deploy` steps offer a retry. In CI/pipes it fails fast (no prompt,
3094
+ * the first error propagates to the branded CLI handler).
3095
+ *
2359
3096
  * @param opts - Optional run options.
2360
- * @param opts.ci - CI/automated mode: never prompts, auto-confirms every gate. When false (the
2361
- * default) and stdout is a TTY, the deploy is guided — each gate is confirmed interactively.
2362
- * Falls back to ctx.config.ci when omitted.
3097
+ * @param opts.ci - CI/automated mode: never prompts, auto-confirms every gate, fails fast. When
3098
+ * false (the default) and stdout is a TTY, the deploy is guided — each gate is confirmed and
3099
+ * failures are recovered interactively. Falls back to ctx.config.ci when omitted.
3100
+ * @param opts.stage - Target stage; suffixes resource names (`production` = bare). Falls back to the app stage.
2363
3101
  * @param opts.webBuild - Build the web site first (e.g. `() => webApp.cli.build()`), before deploy.
2364
3102
  * @param opts.manifest - Caller-supplied manifest (bypasses deployManifest() assembly).
2365
3103
  * @returns Resolves once the deploy completes.
@@ -2371,46 +3109,32 @@ const createDeployApi = (ctx) => ({
2371
3109
  */
2372
3110
  async run(opts) {
2373
3111
  const ci = opts?.ci ?? ctx.config.ci;
2374
- const confirm = !ci && stdoutIsTty() ? (0, _moku_labs_common_cli.createBrandPrompts)().confirm : async (_question) => true;
3112
+ const stage = opts?.stage ?? ctx.global.stage;
3113
+ const interactive = !ci && stdoutIsTty();
3114
+ const confirm = interactive ? (0, _moku_labs_common_cli.createBrandPrompts)().confirm : async (_question) => true;
3115
+ const deps = {
3116
+ interactive,
3117
+ confirm
3118
+ };
2375
3119
  ctx.emit("deploy:phase", { phase: "auth" });
2376
- await verifyAuth(ctx);
2377
- const webBuild = opts?.webBuild ?? ctx.config.webBuild;
2378
- if (webBuild !== void 0) {
2379
- ctx.emit("deploy:phase", {
2380
- phase: "build",
2381
- detail: "web"
2382
- });
2383
- await webBuild();
2384
- }
3120
+ if (!await guidedAuth(ctx, deps)) return emitAborted(ctx);
3121
+ if (!await guidedWebBuild(ctx, opts?.webBuild ?? ctx.config.webBuild, deps)) return emitAborted(ctx);
2385
3122
  ctx.emit("deploy:phase", { phase: "detect" });
2386
- const manifest = opts?.manifest ?? assembleManifest(ctx);
3123
+ const manifest = opts?.manifest ?? assembleManifest(ctx, stage);
2387
3124
  ctx.emit("deploy:phase", { phase: "provision" });
2388
- const plan = await planInfra(ctx, manifest);
2389
- if (plan.missing.length > 0 && !await confirm(`Create ${plan.missing.length} missing resource(s) in "${plan.account}"?`)) {
2390
- ctx.emit("deploy:phase", { phase: "aborted" });
2391
- return;
2392
- }
2393
- const { ids } = await applyPlan(ctx, plan, ci);
3125
+ const provisioned = await guidedProvision(ctx, manifest, ci, deps);
3126
+ if (provisioned === ABORTED) return emitAborted(ctx);
2394
3127
  ctx.emit("deploy:phase", { phase: "wrangler-config" });
2395
- await writeWranglerConfig(ctx.config.configFile, manifest, ids);
2396
- const r2Resource = manifest.resources.find((resource) => resource.kind === "r2");
2397
- if (r2Resource?.upload) {
2398
- const count = await uploadDirToR2(r2Resource.bucket, r2Resource.upload);
2399
- ctx.emit("deploy:phase", {
2400
- phase: "upload",
2401
- detail: `${String(count)} files`
2402
- });
2403
- }
2404
- if (!await confirm(`Deploy "${manifest.name}" to ${ctx.global.stage}?`)) {
2405
- ctx.emit("deploy:phase", { phase: "aborted" });
2406
- return;
2407
- }
3128
+ await writeWranglerConfig(ctx.config.configFile, manifest, provisioned.ids, wranglerExtra(ctx.config));
3129
+ if (!await guidedUpload(ctx, manifest, deps)) return emitAborted(ctx);
3130
+ if (!await confirm(`Deploy "${manifest.name}" to ${stage}?`)) return emitAborted(ctx);
2408
3131
  ctx.emit("deploy:phase", { phase: "deploy" });
2409
- const url = await runWrangler([
3132
+ const url = await guidedStep(() => runWrangler([
2410
3133
  "deploy",
2411
3134
  "--config",
2412
3135
  ctx.config.configFile
2413
- ]);
3136
+ ]), HINTS.deploy, deps);
3137
+ if (url === ABORTED) return emitAborted(ctx);
2414
3138
  ctx.emit("deploy:complete", { url });
2415
3139
  },
2416
3140
  /**
@@ -2420,6 +3144,7 @@ const createDeployApi = (ctx) => ({
2420
3144
  *
2421
3145
  * @param opts - Optional options.
2422
3146
  * @param opts.port - Local dev port (default 8787).
3147
+ * @param opts.stage - Stage for the generated config's resource names (defaults to the app stage).
2423
3148
  * @param opts.webBuild - Rebuild the web site on change (e.g. `() => webApp.cli.build()`).
2424
3149
  * @returns Resolves when the dev session ends.
2425
3150
  * @example
@@ -2427,7 +3152,11 @@ const createDeployApi = (ctx) => ({
2427
3152
  * await api.dev({ port: 8787, webBuild: () => web.cli.build() });
2428
3153
  * ```
2429
3154
  */
2430
- dev: (opts) => runDev(ctx, opts, realDevDeps()),
3155
+ async dev(opts) {
3156
+ const manifest = assembleManifest(ctx, opts?.stage ?? ctx.global.stage);
3157
+ await writeWranglerConfig(ctx.config.configFile, manifest, {}, wranglerExtra(ctx.config));
3158
+ await runDev(ctx, opts, realDevDeps());
3159
+ },
2431
3160
  /**
2432
3161
  * Scaffold a starting wrangler config (and CI files when ci is set).
2433
3162
  * Idempotent: an existing config file is left untouched.
@@ -2453,7 +3182,7 @@ const createDeployApi = (ctx) => ({
2453
3182
  * const plan = await api.checkInfra();
2454
3183
  * ```
2455
3184
  */
2456
- checkInfra: () => planInfra(ctx, assembleManifest(ctx)),
3185
+ checkInfra: () => planInfra(ctx, assembleManifest(ctx, ctx.global.stage)),
2457
3186
  /**
2458
3187
  * Create only the resources missing from the plan (skipping existing), capturing each id.
2459
3188
  *
@@ -2485,7 +3214,19 @@ const createDeployApi = (ctx) => ({
2485
3214
  * const { toAdd } = api.requiredToken();
2486
3215
  * ```
2487
3216
  */
2488
- requiredToken: () => requiredToken(assembleManifest(ctx)),
3217
+ requiredToken: () => requiredToken(assembleManifest(ctx, ctx.global.stage)),
3218
+ /**
3219
+ * Derive the REDUCED CI/automation redeploy token permission groups from the manifest (pure, no
3220
+ * network). Used by the branded `auth setup` renderer to show the scoped CI token alongside the
3221
+ * full LOCAL one.
3222
+ *
3223
+ * @returns The CI token permission groups (read-mostly, manifest-scoped).
3224
+ * @example
3225
+ * ```ts
3226
+ * const groups = api.ciToken();
3227
+ * ```
3228
+ */
3229
+ ciToken: () => ciToken(assembleManifest(ctx, ctx.global.stage)),
2489
3230
  /**
2490
3231
  * Render the `auth setup` guidance from the derived token requirement (pure, no network).
2491
3232
  *
@@ -2495,7 +3236,7 @@ const createDeployApi = (ctx) => ({
2495
3236
  * const text = api.tokenInstructions();
2496
3237
  * ```
2497
3238
  */
2498
- tokenInstructions: () => tokenInstructions(assembleManifest(ctx)),
3239
+ tokenInstructions: () => tokenInstructions(assembleManifest(ctx, ctx.global.stage)),
2499
3240
  /**
2500
3241
  * Run an arbitrary wrangler command, streaming its output (the branded CLI escape hatch).
2501
3242
  *
@@ -2591,6 +3332,45 @@ const parsePortArg = (argv) => {
2591
3332
  if (Number.isInteger(port) && port > 0 && port <= MAX_PORT) return port;
2592
3333
  }
2593
3334
  };
3335
+ /**
3336
+ * Extract a `--stage` value from a single token (and the token after it, for the spaced form).
3337
+ *
3338
+ * @param token - The current argv token.
3339
+ * @param next - The following argv token (the value, for the `--stage dev` spaced form).
3340
+ * @returns The raw string value when this token is a stage flag, else undefined.
3341
+ * @example
3342
+ * ```ts
3343
+ * stageValueFrom("--stage=dev", undefined); // "dev"
3344
+ * stageValueFrom("--stage", "dev"); // "dev"
3345
+ * stageValueFrom("--port", "3000"); // undefined
3346
+ * ```
3347
+ */
3348
+ const stageValueFrom = (token, next) => {
3349
+ const inline = /^--stage=(.+)$/u.exec(token);
3350
+ if (inline) return inline[1];
3351
+ if (token === "--stage") return next;
3352
+ };
3353
+ /**
3354
+ * Parse a `--stage <name>` / `--stage=<name>` flag out of an argv array — the deploy/dev stage that
3355
+ * drives the resource-name suffix (e.g. `tracker-db-dev`). Returns the first non-empty value, or
3356
+ * undefined so the caller can fall back to the app's configured stage.
3357
+ *
3358
+ * @param argv - The argv array to scan (the caller passes the process argv).
3359
+ * @returns The parsed stage string, or undefined when no `--stage` flag is present.
3360
+ * @example
3361
+ * ```ts
3362
+ * parseStageArg(["bun", "scripts/deploy.ts", "--stage", "dev"]); // "dev"
3363
+ * parseStageArg(["bun", "scripts/deploy.ts"]); // undefined
3364
+ * ```
3365
+ */
3366
+ const parseStageArg = (argv) => {
3367
+ for (let index = 0; index < argv.length; index++) {
3368
+ const token = argv[index];
3369
+ if (token === void 0) continue;
3370
+ const raw = stageValueFrom(token, argv[index + 1]);
3371
+ if (raw !== void 0 && raw.length > 0) return raw;
3372
+ }
3373
+ };
2594
3374
  //#endregion
2595
3375
  //#region src/plugins/cli/api.ts
2596
3376
  /**
@@ -2619,6 +3399,7 @@ const createCliApi = (ctx) => ({
2619
3399
  *
2620
3400
  * @param opts - Optional local dev options.
2621
3401
  * @param opts.port - Local dev port to bind. Overrides the `--port` flag and the default.
3402
+ * @param opts.stage - Stage for the generated wrangler config; falls back to `--stage` then the app stage.
2622
3403
  * @param opts.webBuild - Rebuild the web site on change (e.g. `() => webApp.cli.build()`).
2623
3404
  * @returns Resolves when the dev session ends.
2624
3405
  * @example
@@ -2633,11 +3414,13 @@ const createCliApi = (ctx) => ({
2633
3414
  label: "dev session"
2634
3415
  });
2635
3416
  const port = opts?.port ?? parsePortArg(process.argv) ?? ctx.config.port;
3417
+ const stage = opts?.stage ?? parseStageArg(process.argv);
2636
3418
  try {
2637
- await ctx.require(deployPlugin).dev(opts?.webBuild ? {
3419
+ await ctx.require(deployPlugin).dev({
2638
3420
  port,
2639
- webBuild: opts.webBuild
2640
- } : { port });
3421
+ ...stage === void 0 ? {} : { stage },
3422
+ ...opts?.webBuild ? { webBuild: opts.webBuild } : {}
3423
+ });
2641
3424
  ui.check(true, "dev session stopped cleanly");
2642
3425
  } catch (error) {
2643
3426
  ui.error(error instanceof Error ? error.message : String(error));
@@ -2652,17 +3435,22 @@ const createCliApi = (ctx) => ({
2652
3435
  *
2653
3436
  * @param opts - Optional deploy options.
2654
3437
  * @param opts.ci - Automated mode: never prompts, auto-confirms. Omit/false → guided on a TTY.
3438
+ * @param opts.stage - Target stage (resource-name suffix); falls back to `--stage` then the app stage.
2655
3439
  * @param opts.webBuild - Build the web site first (e.g. `() => webApp.cli.build()`), before deploy.
2656
3440
  * @returns Resolves once the deploy completes (or after a failure is rendered).
2657
3441
  * @example
2658
3442
  * ```ts
2659
- * await api.deploy({ webBuild: () => web.cli.build() }); // guided
2660
- * await api.deploy({ ci: true, webBuild: () => web.cli.build() }); // CI
3443
+ * await api.deploy({ webBuild: () => web.cli.build() }); // guided, app stage
3444
+ * await api.deploy({ ci: true, webBuild: () => web.cli.build() }); // CI; `--stage dev` honored
2661
3445
  * ```
2662
3446
  */
2663
3447
  async deploy(opts) {
3448
+ const stage = opts?.stage ?? parseStageArg(process.argv);
2664
3449
  try {
2665
- await ctx.require(deployPlugin).run(opts);
3450
+ await ctx.require(deployPlugin).run({
3451
+ ...opts,
3452
+ ...stage === void 0 ? {} : { stage }
3453
+ });
2666
3454
  } catch (error) {
2667
3455
  (0, _moku_labs_common_cli.createBrandConsole)().error(error instanceof Error ? error.message : String(error));
2668
3456
  process.exitCode = 1;
@@ -2684,7 +3472,7 @@ const createCliApi = (ctx) => ({
2684
3472
  const deploy = ctx.require(deployPlugin);
2685
3473
  const ui = (0, _moku_labs_common_cli.createBrandConsole)();
2686
3474
  if (sub === "setup") {
2687
- for (const line of deploy.tokenInstructions().split("\n")) ui.line(line);
3475
+ renderAuthSetup(ui, deploy.requiredToken(), { ci: deploy.ciToken() });
2688
3476
  return;
2689
3477
  }
2690
3478
  try {
@@ -2772,12 +3560,12 @@ const WRANGLER_DIVIDER = ` ── wrangler ${"─".repeat(48)}`;
2772
3560
  * never block the deploy pipeline (fire-and-forget, spec/07 §3,§4).
2773
3561
  *
2774
3562
  * @param ctx - CLI plugin context with injected log core API.
2775
- * @returns Hook map for the three global deploy events.
3563
+ * @returns Hook map for the deploy/dev phase + completion events (provision detail is panel-rendered).
2776
3564
  * @example
2777
3565
  * ```ts
2778
3566
  * const hooks = createCliHooks(ctx);
2779
3567
  * hooks["deploy:phase"]({ phase: "detect" }); // logs "detect" → renders " › detect"
2780
- * hooks["provision:resource"]({ kind: "kv", name: "KV" }); // logs "kv KV" " › kv KV"
3568
+ * hooks["dev:phase"]({ phase: "serve", detail: "http://localhost:8787" }); // "serve · "
2781
3569
  * hooks["deploy:complete"]({ url: "https://x.workers.dev" }); // "deployed → https://x.workers.dev"
2782
3570
  * ```
2783
3571
  */
@@ -2798,42 +3586,6 @@ const createCliHooks = (ctx) => {
2798
3586
  ctx.log.info(p.detail ? `${p.phase} · ${p.detail}` : p.phase);
2799
3587
  },
2800
3588
  /**
2801
- * Log the infra preflight summary: "infra · N exist, M to create · account".
2802
- *
2803
- * @param p - The provision:plan event payload.
2804
- * @example
2805
- * ```ts
2806
- * handler({ exists: 2, missing: 1, account: "Play Co" }); // "infra · 2 exist, 1 to create · Play Co"
2807
- * ```
2808
- */
2809
- "provision:plan"(p) {
2810
- ctx.log.info(`infra · ${p.exists} exist, ${p.missing} to create · ${p.account}`);
2811
- },
2812
- /**
2813
- * Log one clean line per provisioned resource: "kind name".
2814
- *
2815
- * @param p - The provision:resource event payload.
2816
- * @example
2817
- * ```ts
2818
- * handler({ kind: "kv", name: "KV" }); // "kv KV"
2819
- * ```
2820
- */
2821
- "provision:resource"(p) {
2822
- ctx.log.info(`${p.kind} ${p.name}`);
2823
- },
2824
- /**
2825
- * Log one clean line per already-existing resource (skipped): "kind name (exists)".
2826
- *
2827
- * @param p - The provision:skip event payload.
2828
- * @example
2829
- * ```ts
2830
- * handler({ kind: "kv", name: "KV" }); // "kv KV (exists)"
2831
- * ```
2832
- */
2833
- "provision:skip"(p) {
2834
- ctx.log.info(`${p.kind} ${p.name} (exists)`);
2835
- },
2836
- /**
2837
3589
  * Log one dev-session phase: "phase" or "phase · detail".
2838
3590
  *
2839
3591
  * @param p - The dev:phase event payload.