@moku-labs/worker 0.10.0 → 0.11.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.
@@ -1,4446 +0,0 @@
1
- //#region \0rolldown/runtime.js
2
- var __create = Object.create;
3
- var __defProp = Object.defineProperty;
4
- var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
- var __getOwnPropNames = Object.getOwnPropertyNames;
6
- var __getProtoOf = Object.getPrototypeOf;
7
- var __hasOwnProp = Object.prototype.hasOwnProperty;
8
- var __copyProps = (to, from, except, desc) => {
9
- if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
10
- key = keys[i];
11
- if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
12
- get: ((k) => from[k]).bind(null, key),
13
- enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
14
- });
15
- }
16
- return to;
17
- };
18
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
19
- value: mod,
20
- enumerable: true
21
- }) : target, mod));
22
- //#endregion
23
- let _moku_labs_common = require("@moku-labs/common");
24
- let _moku_labs_core = require("@moku-labs/core");
25
- let _moku_labs_common_cli = require("@moku-labs/common/cli");
26
- let node_fs_promises = require("node:fs/promises");
27
- let node_path = require("node:path");
28
- node_path = __toESM(node_path, 1);
29
- let node_child_process = require("node:child_process");
30
- let node_fs = require("node:fs");
31
- /**
32
- * stage core plugin — deployment-stage / dev-mode detection, flat-injected on
33
- * every regular plugin's context as `ctx.stage` (spec/02 §6). No state, no
34
- * events, no depends, no lifecycle hooks.
35
- *
36
- * @see README.md
37
- * @example
38
- * ```typescript
39
- * // Inside any regular plugin's api factory:
40
- * api: (ctx) => ({
41
- * errorBody: (e: Error) =>
42
- * ctx.stage.isDev() ? e.stack ?? e.message : "Internal Error",
43
- * })
44
- * ```
45
- */
46
- const stagePlugin = (0, _moku_labs_core.createCorePlugin)("stage", {
47
- config: { stage: "production" },
48
- /**
49
- * Builds the stage accessor surface from the resolved stage.
50
- *
51
- * @param ctx - Core plugin context (spec/02 §6 — `{ config, state }` only;
52
- * no `global`, `emit`, or `require`). `state` is unused by this plugin.
53
- * @param ctx.config - The resolved plugin config containing the deployment stage.
54
- * @returns The `ctx.stage` API: `isDev`, `isProduction`, `current`.
55
- * @example
56
- * ```typescript
57
- * const api = stagePlugin.spec.api({ config: { stage: "development" }, state: {} });
58
- * api.isDev(); // true
59
- * ```
60
- */
61
- api: ({ config }) => ({
62
- /**
63
- * Whether this Worker runs in the development stage.
64
- *
65
- * @returns True iff `stage === "development"`.
66
- * @example
67
- * ```typescript
68
- * if (ctx.stage.isDev()) return Response.json({ stack: err.stack });
69
- * ```
70
- */
71
- isDev: () => config.stage === "development",
72
- /**
73
- * Whether this Worker runs in the production stage. Note: false in "test".
74
- *
75
- * @returns True iff `stage === "production"`.
76
- * @example
77
- * ```typescript
78
- * const cc = ctx.stage.isProduction() ? "public, max-age=31536000" : "no-store";
79
- * ```
80
- */
81
- isProduction: () => config.stage === "production",
82
- /**
83
- * The raw deployment stage, as the literal union (not `string`).
84
- *
85
- * @returns The resolved stage.
86
- * @example
87
- * ```typescript
88
- * ctx.log.info("startup", { stage: ctx.stage.current() });
89
- * ```
90
- */
91
- current: () => config.stage
92
- })
93
- });
94
- const coreConfig = (0, _moku_labs_core.createCoreConfig)("moku-worker", {
95
- config: {
96
- stage: "production",
97
- name: "moku-worker",
98
- compatibilityDate: ""
99
- },
100
- plugins: [
101
- _moku_labs_common.logPlugin,
102
- _moku_labs_common.envPlugin,
103
- stagePlugin
104
- ]
105
- });
106
- const { createPlugin, createCore } = coreConfig;
107
- //#endregion
108
- //#region src/plugins/bindings/api.ts
109
- /**
110
- * Checks whether a value read from an env object is nullish (null or undefined).
111
- * Cloudflare supplies either form when a binding is absent, so both must be caught.
112
- *
113
- * @param value - The value read from the env object.
114
- * @returns True when the value is null or undefined.
115
- * @example
116
- * ```typescript
117
- * isNullish(undefined); // true
118
- * isNullish(0); // false — falsy but bound
119
- * ```
120
- */
121
- const isNullish = (value) => value === void 0 || value === null;
122
- /**
123
- * Resolves binding `name` off a request-supplied env object, narrowed to T.
124
- * Throws a `[moku-worker]`-prefixed error when the binding is nullish.
125
- * The env argument is read but never retained.
126
- *
127
- * @param env - The Cloudflare request env object passed to fetch/scheduled/queue.
128
- * @param name - The binding name to resolve.
129
- * @returns The binding value narrowed to T.
130
- * @throws {Error} With a `[moku-worker]` prefix when the binding is null or undefined.
131
- * @example
132
- * ```typescript
133
- * const kv = requireBinding<KVNamespace>(env, "MY_KV");
134
- * ```
135
- */
136
- const requireBinding = (env, name) => {
137
- const value = env[name];
138
- if (isNullish(value)) throw new Error(`[moku-worker] binding "${name}" is not bound.\n Declare it in wrangler config and pass it in via the request env.`);
139
- return value;
140
- };
141
- /**
142
- * Returns true when `name` resolves to a non-nullish value on the request env.
143
- * Never throws. Use for optional-binding branching without forcing an error.
144
- *
145
- * @param env - The Cloudflare request env object passed to fetch/scheduled/queue.
146
- * @param name - The binding name to check.
147
- * @returns Whether the binding is present and non-nullish.
148
- * @example
149
- * ```typescript
150
- * const ok = hasBinding(env, "DB"); // false if DB is not bound
151
- * ```
152
- */
153
- const hasBinding = (env, name) => !isNullish(env[name]);
154
- /**
155
- * Builds the app.bindings API surface. The factory receives a context but does
156
- * not use it — bindings holds no state (F4) and all resolution is argument-local.
157
- *
158
- * @param _ctx - Plugin context (unused; bindings is stateless — F4).
159
- * @returns BindingsApi with `require` and `has` methods.
160
- * @example
161
- * ```typescript
162
- * const api = createBindingsApi(ctx);
163
- * const kv = api.require<KVNamespace>(env, "MY_KV");
164
- * ```
165
- */
166
- const createBindingsApi = (_ctx) => ({
167
- require: requireBinding,
168
- has: hasBinding
169
- });
170
- /**
171
- * Standard-tier stateless resolver — the binding-family dependency root.
172
- *
173
- * Exposes `require<T>(env, name)` and `has(env, name)` off a per-request env
174
- * object. Regular plugin so downstream binding plugins can declare
175
- * `depends: [bindingsPlugin]` and reach it via `ctx.require(bindingsPlugin)`.
176
- *
177
- * @see README.md
178
- */
179
- const bindingsPlugin = createPlugin("bindings", {
180
- config: { required: [] },
181
- api: createBindingsApi
182
- });
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
229
- //#region src/plugins/d1/api.ts
230
- /**
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.
236
- *
237
- * The return is intentionally NOT annotated `: Api`. Annotating it would
238
- * collapse the per-method call-site generic `<T>` on `query`/`first` to
239
- * `unknown`; instead the implementation forwards `<T>` to `all<T>()` /
240
- * `first<T>()` and `types.ts#Api` remains the public-surface source of truth.
241
- *
242
- * @param {D1Ctx} ctx - Plugin context (keyed-map config + require).
243
- * @returns {object} The d1 public api (query, first, run, batch, prepare, use, deployManifest).
244
- * @example
245
- * ```typescript
246
- * const api = createD1Api(ctx);
247
- * const { results } = await api.query<Product>(env, "SELECT * FROM products");
248
- * await api.use("analytics").run(env, "INSERT INTO events (name) VALUES (?)", "click");
249
- * ```
250
- */
251
- const createD1Api = (ctx) => {
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;
321
- return {
322
- ...surface(defaultBinding),
323
- /**
324
- * Select a specific D1 database instance by its config key.
325
- *
326
- * @param key - The instance key (as configured under `pluginConfigs.d1`).
327
- * @returns The SQL surface bound to that database.
328
- * @example
329
- * ```typescript
330
- * await api.use("analytics").run(env, "INSERT INTO events (name) VALUES (?)", "click");
331
- * ```
332
- */
333
- use: (key) => surface(() => pickInstance(ctx.config, key, "d1").binding),
334
- /**
335
- * Return this plugin's deploy metadata — one descriptor per configured database.
336
- *
337
- * @returns One d1 deploy descriptor per instance.
338
- * @example
339
- * ```typescript
340
- * const manifest = api.deployManifest(); // [{ kind: "d1", name: "tracker-db", binding: "DB" }]
341
- * ```
342
- */
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;
351
- })
352
- };
353
- };
354
- /**
355
- * Standard tier — Cloudflare D1 SQL access (thin typed wrappers, not an ORM).
356
- *
357
- * Exposes `query`, `first`, `run`, `batch`, `prepare`, and `deployManifest`.
358
- * Resolves the D1 binding off the per-request `env` via the bindings plugin.
359
- * No state, no events, no lifecycle hooks (request-scoped, spec/06 §3).
360
- *
361
- * @see README.md
362
- */
363
- const d1Plugin = createPlugin("d1", {
364
- depends: [bindingsPlugin],
365
- config: {},
366
- api: (ctx) => createD1Api(ctx)
367
- });
368
- //#endregion
369
- //#region src/plugins/durable-objects/api.ts
370
- /**
371
- * Builds the `app.durableObjects` API surface — `get` and `deployManifest`.
372
- *
373
- * All namespace resolution uses the per-call `env` argument: `env` is threaded, never
374
- * stored (SB4 / design §1a). The keyed-map config is frozen and read-only. No state
375
- * is held on the plugin between calls (stateless — `Record<string, never>`).
376
- *
377
- * @param ctx - Plugin context with the keyed-map `config`, `require(bindingsPlugin)`, and core APIs.
378
- * @returns The durableObjects API: `{ get, deployManifest }`.
379
- * @example
380
- * ```typescript
381
- * const api = createDoApi(ctx);
382
- * const stub = api.get(env, "board", "room-42");
383
- * const manifest = api.deployManifest(); // [{ kind: "do", binding: "BOARD", className: "BoardChannel" }]
384
- * ```
385
- */
386
- const createDoApi = (ctx) => ({
387
- /**
388
- * Resolves a `DurableObjectStub` off the per-request env.
389
- *
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`.
395
- *
396
- * @param env - Per-request Cloudflare bindings object (Worker fetch/queue/scheduled env).
397
- * @param logicalName - Logical DO key (selects the configured instance, e.g. `"board"`).
398
- * @param idName - Stable id name passed to `idFromName` (e.g. `"room-42"`).
399
- * @returns The addressed `DurableObjectStub`.
400
- * @throws {Error} With `[moku-worker]` prefix when `logicalName` is not configured, or when the
401
- * binding is not bound on `env`.
402
- * @example
403
- * ```typescript
404
- * const stub = app.durableObjects.get(env, "board", "room-42");
405
- * const res = await stub.fetch("https://do/increment");
406
- * ```
407
- */
408
- get: (env, logicalName, idName) => {
409
- const binding = pickInstance(ctx.config, logicalName, "durableObjects").binding;
410
- const ns = ctx.require(bindingsPlugin).require(env, binding);
411
- return ns.get(ns.idFromName(idName));
412
- },
413
- /**
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`.
417
- *
418
- * @returns One `{ kind: "do", binding, className }` per configured instance.
419
- * @example
420
- * ```typescript
421
- * const manifest = app.durableObjects.deployManifest();
422
- * // → [{ kind: "do", binding: "BOARD", className: "BoardChannel" }]
423
- * ```
424
- */
425
- deployManifest: () => Object.values(ctx.config).map((instance) => ({
426
- kind: "do",
427
- binding: instance.binding,
428
- className: instance.className
429
- }))
430
- });
431
- //#endregion
432
- //#region src/plugins/durable-objects/helpers.ts
433
- /**
434
- * Returns a base class the consumer extends and exports from `worker.ts`.
435
- *
436
- * PURE (spec/03 §1): takes no `ctx`, has no side effects, and may be called before
437
- * `createApp`. The static `doName` property captures `name` for diagnostics and
438
- * binding correlation. The constructor stores `(state, env)` as `this.ctx` / `this.env`,
439
- * satisfying the Cloudflare Durable Object constructor contract. The plugin NEVER
440
- * generates the final exported class — the consumer owns that class.
441
- *
442
- * @param name - Logical DO name; captured as `static doName` for diagnostics.
443
- * @returns A base class (constructor) the consumer extends.
444
- * @example
445
- * ```typescript
446
- * // src/counter.ts
447
- * import { defineDurableObject } from "@moku-labs/worker";
448
- *
449
- * export class Counter extends defineDurableObject("Counter") {
450
- * async fetch(): Promise<Response> {
451
- * const n = ((await this.ctx.storage.get<number>("n")) ?? 0) + 1;
452
- * await this.ctx.storage.put("n", n);
453
- * return Response.json({ n });
454
- * }
455
- * }
456
- * ```
457
- */
458
- const defineDurableObject = (name) => {
459
- /**
460
- * Base implementation of the Cloudflare Durable Object constructor contract.
461
- * Stores `(ctx, env)` as readonly properties for consumer subclasses to use.
462
- */
463
- class DurableObjectBaseImpl {
464
- /**
465
- * Cloudflare per-object storage/alarm context (DurableObjectState).
466
- * Use `this.ctx.storage` to read/write durable storage and `this.ctx.id` to inspect the DO id.
467
- */
468
- ctx;
469
- /**
470
- * Per-object Cloudflare bindings (per-request WorkerEnv).
471
- * Mirrors the env passed at construction time; never cached across requests.
472
- */
473
- env;
474
- /**
475
- * Logical DO name captured from `defineDurableObject(name)`.
476
- * Used for diagnostics and binding correlation.
477
- */
478
- static doName = name;
479
- /**
480
- * Constructs the base Durable Object with Cloudflare's required signature.
481
- *
482
- * @param ctx - Cloudflare DurableObjectState (storage, id, blockConcurrencyWhile, …).
483
- * @param env - Per-request Cloudflare bindings object (WorkerEnv).
484
- * @example
485
- * ```typescript
486
- * class Counter extends Base {
487
- * constructor(ctx: DurableObjectState, env: WorkerEnv) { super(ctx, env); }
488
- * }
489
- * ```
490
- */
491
- constructor(ctx, env) {
492
- this.ctx = ctx;
493
- this.env = env;
494
- }
495
- }
496
- return DurableObjectBaseImpl;
497
- };
498
- /**
499
- * Cloudflare Durable Objects plugin — Standard tier.
500
- *
501
- * Exposes `get(env, logicalName, idName)` (synchronous stub accessor, threaded env) and
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.
505
- *
506
- * @example
507
- * ```typescript
508
- * // Consumer endpoint handler:
509
- * const stub = app.durableObjects.get(env, "board", params.room!);
510
- * const res = await stub.fetch("https://do/increment");
511
- * // Consumer DO class (the EXPORTED className referenced by the "board" instance):
512
- * export class BoardChannel extends defineDurableObject("BoardChannel") {
513
- * async fetch(): Promise<Response> { return new Response("ok"); }
514
- * }
515
- * ```
516
- * @see README.md
517
- */
518
- const durableObjectsPlugin = createPlugin("durableObjects", {
519
- depends: [bindingsPlugin],
520
- config: {},
521
- api: createDoApi,
522
- helpers: { defineDurableObject }
523
- });
524
- //#endregion
525
- //#region src/plugins/kv/api.ts
526
- /**
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.
531
- *
532
- * @param ctx - The kv plugin context (keyed-map config + merged events).
533
- * @returns The app.kv api: get / put / delete / list / use / deployManifest.
534
- * @example
535
- * ```typescript
536
- * const api = createKvApi(ctx);
537
- * const value = await api.get(env, "key");
538
- * await api.use("sessions").put(env, "s:1", "data");
539
- * ```
540
- */
541
- const createKvApi = (ctx) => {
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;
599
- return {
600
- ...surface(defaultBinding),
601
- /**
602
- * Select a specific KV namespace instance by its config key.
603
- *
604
- * @param key - The instance key (as configured under `pluginConfigs.kv`).
605
- * @returns The key/value surface bound to that namespace.
606
- * @example
607
- * ```typescript
608
- * await api.use("sessions").get(env, "s:1");
609
- * ```
610
- */
611
- use: (key) => surface(() => pickInstance(ctx.config, key, "kv").binding),
612
- /**
613
- * Return this plugin's deploy metadata — one descriptor per configured namespace.
614
- *
615
- * @returns One kv deploy descriptor per instance.
616
- * @example
617
- * ```typescript
618
- * const manifest = api.deployManifest(); // [{ kind: "kv", name: "tracker-cache", binding: "CACHE" }]
619
- * ```
620
- */
621
- deployManifest: () => Object.values(ctx.config).map((instance) => ({
622
- kind: "kv",
623
- name: instance.name,
624
- binding: instance.binding
625
- }))
626
- };
627
- };
628
- /**
629
- * Micro tier — thin env-first wrapper over a Cloudflare KV namespace.
630
- *
631
- * Resolves the KV namespace per request via `ctx.require(bindingsPlugin)`;
632
- * never stores env in state (design §1a / SB4). No lifecycle hooks —
633
- * request-scoped; nothing to open or close.
634
- *
635
- * @see README.md
636
- */
637
- const kvPlugin = createPlugin("kv", {
638
- depends: [bindingsPlugin],
639
- config: {},
640
- api: createKvApi
641
- });
642
- //#endregion
643
- //#region src/plugins/queues/api.ts
644
- /**
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.
649
- *
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
- * ```
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
- };
663
- /**
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).
669
- *
670
- * @param ctx - Plugin context (keyed-map config + require + emit).
671
- * @returns The queues API surface: send, sendBatch, use, consume, deployManifest.
672
- * @example
673
- * ```ts
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)
678
- * ```
679
- */
680
- const createQueuesApi = (ctx) => {
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;
716
- return {
717
- ...surface(defaultBinding),
718
- /**
719
- * Select a specific Queue instance by its config key.
720
- *
721
- * @param key - The instance key (as configured under `pluginConfigs.queues`).
722
- * @returns The producer surface bound to that instance.
723
- * @example
724
- * ```typescript
725
- * await api.use("activity").send(env, { id: 2 });
726
- * ```
727
- */
728
- use: (key) => surface(() => pickInstance(ctx.config, key, "queues").binding),
729
- /**
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.
732
- *
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.
737
- * @example
738
- * ```typescript
739
- * // Worker entry (design §1d): queue: (b, e, c) => app.queues.consume(b, e, c)
740
- * ```
741
- */
742
- consume: async (batch, env, _ctx) => {
743
- const instance = routeInstance(ctx.config, batch.queue);
744
- for (const m of batch.messages) {
745
- if (instance.onMessage) await instance.onMessage(m, env);
746
- ctx.emit("queue:message", {
747
- queue: batch.queue,
748
- messageId: m.id
749
- });
750
- }
751
- },
752
- /**
753
- * Return this plugin's deploy metadata — one descriptor per configured instance.
754
- *
755
- * @returns One queue deploy descriptor per instance.
756
- * @example
757
- * ```typescript
758
- * const manifest = api.deployManifest(); // [{ kind: "queue", name: "tracker-activity", binding: "ACTIVITY" }]
759
- * ```
760
- */
761
- deployManifest: () => Object.values(ctx.config).map((instance) => ({
762
- kind: "queue",
763
- name: instance.name,
764
- binding: instance.binding,
765
- ...instance.onMessage ? { consumer: true } : {},
766
- ...instance.maxBatchTimeout === void 0 ? {} : { maxBatchTimeout: instance.maxBatchTimeout }
767
- }))
768
- };
769
- };
770
- /**
771
- * Standard tier — Cloudflare Queues producer + per-instance consumer dispatch over a keyed map of
772
- * instances.
773
- *
774
- * `events` is declared first and via `register.map<QueueEvents>` so the plugin's own events infer
775
- * into the factory context; the api wiring is therefore arrow-wrapped (contextually typed).
776
- *
777
- * Emits the plugin-local `queue:message` event after each consumed message.
778
- *
779
- * @see README.md
780
- */
781
- const queuesPlugin = createPlugin("queues", {
782
- events: (register) => register.map({ "queue:message": "A queue message was processed" }),
783
- depends: [bindingsPlugin],
784
- config: {},
785
- api: (ctx) => createQueuesApi(ctx)
786
- });
787
- //#endregion
788
- //#region src/plugins/storage/providers/r2.ts
789
- /**
790
- * Build a StorageProvider backed by the real R2Bucket resolved off the
791
- * per-request env via the bindings plugin. The bucket is resolved fresh on
792
- * EVERY method call — never cached, so concurrent requests stay isolated
793
- * (worker-api-design SB4; spec/08 §6).
794
- *
795
- * Each method is `async` so that synchronous throws from `bindings.require`
796
- * (e.g. missing binding) are automatically wrapped in rejected Promises —
797
- * callers can always use `await` / `.catch` instead of `try/catch`.
798
- *
799
- * @param bindings - The bindings plugin API (provides `require<T>`).
800
- * @param env - The per-request Cloudflare bindings object.
801
- * @param bucket - The R2 bucket binding name (e.g. "ASSETS").
802
- * @returns {StorageProvider} A provider that delegates to the resolved R2Bucket.
803
- * @example
804
- * ```typescript
805
- * const provider = resolveR2Provider(ctx.require(bindingsPlugin), env, ctx.config.bucket);
806
- * const body = await provider.get("my-object");
807
- * ```
808
- */
809
- const resolveR2Provider = (bindings, env, bucket) => {
810
- /**
811
- * Resolve the R2Bucket for this request's env. Throws on missing binding.
812
- *
813
- * @returns {R2Bucket} The resolved R2Bucket binding.
814
- * @example
815
- * ```typescript
816
- * const bucket = b();
817
- * ```
818
- */
819
- const b = () => bindings.require(env, bucket);
820
- return {
821
- /**
822
- * Read an object from the bucket.
823
- *
824
- * @param key - The object key.
825
- * @returns {Promise<R2ObjectBody | null>} The R2ObjectBody, or null if the key is absent.
826
- * @example
827
- * ```typescript
828
- * const body = await provider.get("assets/logo.png");
829
- * ```
830
- */
831
- async get(key) {
832
- return b().get(key);
833
- },
834
- /**
835
- * Write an object to the bucket.
836
- *
837
- * @param key - The object key.
838
- * @param value - The object contents (any R2-accepted type).
839
- * @returns {Promise<R2Object>} The R2Object metadata for the written object.
840
- * @example
841
- * ```typescript
842
- * const obj = await provider.put("assets/logo.png", buffer);
843
- * ```
844
- */
845
- async put(key, value) {
846
- return b().put(key, value);
847
- },
848
- /**
849
- * Remove one or more objects from the bucket. No-op when a key is absent.
850
- *
851
- * @param key - A single key or array of keys to remove.
852
- * @returns {Promise<void>} Resolves once removed.
853
- * @example
854
- * ```typescript
855
- * await provider.delete("assets/old.png");
856
- * ```
857
- */
858
- async delete(key) {
859
- return b().delete(key);
860
- },
861
- /**
862
- * List objects, optionally filtered by R2ListOptions.
863
- *
864
- * @param opts - Optional list options (prefix, limit, cursor, delimiter).
865
- * @returns {Promise<R2Objects>} The R2Objects list result.
866
- * @example
867
- * ```typescript
868
- * const { objects } = await provider.list({ prefix: "images/" });
869
- * ```
870
- */
871
- async list(opts) {
872
- return b().list(opts);
873
- }
874
- };
875
- };
876
- //#endregion
877
- //#region src/plugins/storage/api.ts
878
- /**
879
- * Build the app.storage.* api over a keyed map of R2 bucket instances. The default-bucket methods and
880
- * `use(key)` both resolve the bucket off the REQUEST-SUPPLIED env on every call — env is threaded,
881
- * never stored (worker-api-design SB4; spec/08 §6,§7) — and the instance key is resolved lazily so an
882
- * unconfigured-but-present plugin only errors when actually called.
883
- *
884
- * The `deployManifest()` method is build-time only: it reads from `ctx.config`
885
- * and never touches `env` or R2.
886
- *
887
- * @param ctx - Plugin context (keyed-map config + require for bindings resolution).
888
- * @returns {StorageApi} The app.storage api: get / put / delete / list / use / deployManifest.
889
- * @example
890
- * ```typescript
891
- * const api = createStorageApi(ctx);
892
- * const body = await api.get(env, "my-object");
893
- * await api.use("uploads").put(env, "avatar.png", buffer);
894
- * ```
895
- */
896
- const createStorageApi = (ctx) => {
897
- const bindings = ctx.require(bindingsPlugin);
898
- const surface = (binding) => {
899
- const provider = (env) => resolveR2Provider(bindings, env, binding());
900
- return {
901
- /**
902
- * Read an object from this bucket; resolves null when the key is absent.
903
- *
904
- * @param env - The per-request Cloudflare env.
905
- * @param key - The object key to read.
906
- * @returns The object body, or null.
907
- * @example
908
- * ```typescript
909
- * const body = await api.get(env, "assets/logo.png");
910
- * ```
911
- */
912
- get: (env, key) => provider(env).get(key),
913
- /**
914
- * Write an object to this bucket.
915
- *
916
- * @param env - The per-request Cloudflare env.
917
- * @param key - The object key to write.
918
- * @param value - The object contents.
919
- * @returns The written object metadata.
920
- * @example
921
- * ```typescript
922
- * await api.put(env, "avatar.png", buffer);
923
- * ```
924
- */
925
- put: (env, key, value) => provider(env).put(key, value),
926
- /**
927
- * Remove an object (or keys) from this bucket. No-op when absent.
928
- *
929
- * @param env - The per-request Cloudflare env.
930
- * @param key - The object key or keys to delete.
931
- * @returns Resolves once removed.
932
- * @example
933
- * ```typescript
934
- * await api.delete(env, "assets/old-logo.png");
935
- * ```
936
- */
937
- delete: (env, key) => provider(env).delete(key),
938
- /**
939
- * List objects in this bucket, optionally filtered by R2ListOptions.
940
- *
941
- * @param env - The per-request Cloudflare env.
942
- * @param opts - Optional prefix / limit / cursor / delimiter.
943
- * @returns The list result.
944
- * @example
945
- * ```typescript
946
- * const { objects } = await api.list(env, { prefix: "assets/" });
947
- * ```
948
- */
949
- list: (env, opts) => provider(env).list(opts)
950
- };
951
- };
952
- const defaultBinding = () => pickInstance(ctx.config, defaultInstanceKey(ctx.config, "r2"), "r2").binding;
953
- return {
954
- ...surface(defaultBinding),
955
- /**
956
- * Select a specific R2 bucket instance by its config key.
957
- *
958
- * @param key - The instance key (as configured under `pluginConfigs.storage`).
959
- * @returns The object surface bound to that bucket.
960
- * @example
961
- * ```typescript
962
- * await api.use("uploads").put(env, "avatar.png", buffer);
963
- * ```
964
- */
965
- use: (key) => surface(() => pickInstance(ctx.config, key, "r2").binding),
966
- /**
967
- * Return this plugin's deploy metadata — one descriptor per configured bucket.
968
- *
969
- * @returns One r2 deploy descriptor per instance.
970
- * @example
971
- * ```typescript
972
- * const manifest = api.deployManifest(); // [{ kind: "r2", name: "tracker-files", binding: "FILES" }]
973
- * ```
974
- */
975
- deployManifest: () => Object.values(ctx.config).map((instance) => ({
976
- kind: "r2",
977
- name: instance.name,
978
- binding: instance.binding,
979
- ...instance.upload === void 0 ? {} : { upload: instance.upload }
980
- }))
981
- };
982
- };
983
- /**
984
- * Complex tier — Cloudflare R2 object storage behind a provider adapter seam.
985
- *
986
- * Exposes `get`, `put`, `delete`, `list` (all env-first) and `deployManifest()`
987
- * (build-time). Depends on `bindingsPlugin` to resolve the `R2Bucket` binding
988
- * per request. No state, no events, no lifecycle hooks.
989
- *
990
- * @see README.md
991
- */
992
- const storagePlugin = createPlugin("storage", {
993
- depends: [bindingsPlugin],
994
- config: {},
995
- api: createStorageApi
996
- });
997
- //#endregion
998
- //#region src/plugins/deploy/auth/env-file.ts
999
- /**
1000
- * @file deploy plugin — `.env.local` scaffolder (node:fs).
1001
- *
1002
- * Writes a ready-to-fill `.env.local` so the guided deploy can hand the user a real file to paste
1003
- * their Cloudflare token into — NEVER clobbering an existing one (it may already hold real secrets).
1004
- * Node-only; never imported by the runtime Worker bundle.
1005
- */
1006
- /**
1007
- * Create `<dir>/.env.local` with the given contents, unless it already exists. Existing files are
1008
- * left untouched (they may hold real secrets) — the caller tells the user to fill that one in.
1009
- *
1010
- * @param dir - Directory to create the file in (usually `process.cwd()`).
1011
- * @param content - The file contents to write when absent (e.g. `envLocalScaffold(manifest)`).
1012
- * @returns Whether the file was created (false when it already existed) and its path.
1013
- * @example
1014
- * ```ts
1015
- * const { created, path } = await ensureEnvLocal(process.cwd(), envLocalScaffold(manifest));
1016
- * ```
1017
- */
1018
- const ensureEnvLocal = async (dir, content) => {
1019
- const filePath = node_path.default.join(dir, ".env.local");
1020
- try {
1021
- await (0, node_fs_promises.access)(filePath);
1022
- return {
1023
- created: false,
1024
- path: filePath
1025
- };
1026
- } catch {
1027
- await (0, node_fs_promises.writeFile)(filePath, content, "utf8");
1028
- return {
1029
- created: true,
1030
- path: filePath
1031
- };
1032
- }
1033
- };
1034
- //#endregion
1035
- //#region src/plugins/deploy/auth/permissions.ts
1036
- /** Permission groups every deploy needs, regardless of resources. */
1037
- const ALWAYS = [{
1038
- group: "Account · Workers Scripts",
1039
- scope: "Edit",
1040
- reason: "deploy",
1041
- inBaseTemplate: true
1042
- }, {
1043
- group: "Account · Account Settings",
1044
- scope: "Read",
1045
- reason: "account",
1046
- inBaseTemplate: true
1047
- }];
1048
- /**
1049
- * Per-resource-kind permission group. `do` needs nothing extra (Durable Objects ship with the
1050
- * Worker script, covered by Workers Scripts · Edit). `d1`/`queue` are NOT in the stock template.
1051
- */
1052
- const BY_KIND = {
1053
- kv: {
1054
- group: "Account · Workers KV Storage",
1055
- scope: "Edit",
1056
- reason: "kv",
1057
- inBaseTemplate: true
1058
- },
1059
- r2: {
1060
- group: "Account · Workers R2 Storage",
1061
- scope: "Edit",
1062
- reason: "r2",
1063
- inBaseTemplate: true
1064
- },
1065
- d1: {
1066
- group: "Account · D1",
1067
- scope: "Edit",
1068
- reason: "d1",
1069
- inBaseTemplate: false
1070
- },
1071
- queue: {
1072
- group: "Account · Queues",
1073
- scope: "Edit",
1074
- reason: "queue",
1075
- inBaseTemplate: false
1076
- },
1077
- do: void 0
1078
- };
1079
- /**
1080
- * Derive the Cloudflare API token requirement from an app manifest: the full permission set plus
1081
- * the subset that must be ADDED to the stock "Edit Cloudflare Workers" template.
1082
- *
1083
- * @param manifest - The assembled deploy manifest.
1084
- * @returns The token requirement (base template, full required set, and groups to add).
1085
- * @example
1086
- * ```ts
1087
- * const { toAdd } = requiredToken({ name: "w", compatibilityDate: "…", resources: [{ kind: "d1", binding: "DB" }] });
1088
- * // toAdd → [{ group: "Account · D1", scope: "Edit", … }]
1089
- * ```
1090
- */
1091
- const requiredToken = (manifest) => {
1092
- const required = [...ALWAYS];
1093
- const seen = new Set(required.map((permission) => permission.group));
1094
- for (const resource of manifest.resources) {
1095
- const permission = BY_KIND[resource.kind];
1096
- if (permission !== void 0 && !seen.has(permission.group)) {
1097
- required.push(permission);
1098
- seen.add(permission.group);
1099
- }
1100
- }
1101
- return {
1102
- base: "Edit Cloudflare Workers",
1103
- required,
1104
- toAdd: required.filter((permission) => !permission.inBaseTemplate)
1105
- };
1106
- };
1107
- /** Permission every CI/automation redeploy needs: ship the Worker script. */
1108
- const CI_ALWAYS = [{
1109
- group: "Account · Workers Scripts",
1110
- scope: "Edit",
1111
- reason: "deploy",
1112
- inBaseTemplate: true
1113
- }];
1114
- /**
1115
- * Per-resource-kind permission for the CI/automation token. After a first LOCAL deploy has
1116
- * provisioned everything, CI only needs to LIST existing infra (the idempotent preflight) and
1117
- * ship — so data resources drop to `Read`; R2 stays `Edit` because asset upload writes objects.
1118
- */
1119
- const CI_BY_KIND = {
1120
- kv: {
1121
- group: "Account · Workers KV Storage",
1122
- scope: "Read",
1123
- reason: "kv (preflight)",
1124
- inBaseTemplate: true
1125
- },
1126
- r2: {
1127
- group: "Account · Workers R2 Storage",
1128
- scope: "Edit",
1129
- reason: "r2 (asset upload)",
1130
- inBaseTemplate: true
1131
- },
1132
- d1: {
1133
- group: "Account · D1",
1134
- scope: "Read",
1135
- reason: "d1 (preflight)",
1136
- inBaseTemplate: false
1137
- },
1138
- queue: {
1139
- group: "Account · Queues",
1140
- scope: "Read",
1141
- reason: "queue (preflight)",
1142
- inBaseTemplate: false
1143
- },
1144
- do: void 0
1145
- };
1146
- /**
1147
- * Derive the REDUCED Cloudflare API token for CI/automation redeploys, from the same manifest.
1148
- * Assumes a prior LOCAL deploy already provisioned the infra, so CI never creates: data resources
1149
- * need only `Read` (the idempotent preflight lists them), R2 keeps `Edit` for asset upload, and no
1150
- * `Account Settings · Read` is needed because CI pins `CLOUDFLARE_ACCOUNT_ID`. Pure: no network.
1151
- *
1152
- * @param manifest - The assembled deploy manifest.
1153
- * @returns The minimum permission groups for a CI redeploy token (deduped, manifest-scoped).
1154
- * @example
1155
- * ```ts
1156
- * const groups = ciToken({ name: "w", compatibilityDate: "…", resources: [{ kind: "d1", binding: "DB" }] });
1157
- * // → [Workers Scripts·Edit, D1·Read]
1158
- * ```
1159
- */
1160
- const ciToken = (manifest) => {
1161
- const groups = [...CI_ALWAYS];
1162
- const seen = new Set(groups.map((permission) => permission.group));
1163
- for (const resource of manifest.resources) {
1164
- const permission = CI_BY_KIND[resource.kind];
1165
- if (permission !== void 0 && !seen.has(permission.group)) {
1166
- groups.push(permission);
1167
- seen.add(permission.group);
1168
- }
1169
- }
1170
- return groups;
1171
- };
1172
- //#endregion
1173
- //#region src/plugins/deploy/auth/render.ts
1174
- /** Cloudflare's dashboard path for creating API tokens. */
1175
- const TOKENS_URL$1 = "https://dash.cloudflare.com/profile/api-tokens";
1176
- /**
1177
- * Render one permission as a framed row. With the template flag on (the LOCAL panel) a green `✓`
1178
- * marks a permission the stock template already includes and a pink `+ ← add to template` marks one
1179
- * the user must add; with it off (the CI panel) every row is a neutral bullet. Scope bold, reason dim.
1180
- *
1181
- * @param ui - The branded console (for its palette).
1182
- * @param permission - The permission group to render.
1183
- * @param showTemplateFlag - Whether to mark template-vs-add (LOCAL) or use a neutral bullet (CI).
1184
- * @returns The rendered (colorized) row, ready to drop into a box.
1185
- * @example
1186
- * ```ts
1187
- * permissionRow(ui, { group: "Account · D1", scope: "Edit", reason: "d1", inBaseTemplate: false }, true);
1188
- * ```
1189
- */
1190
- const permissionRow = (ui, permission, showTemplateFlag) => {
1191
- const { palette } = ui;
1192
- const templateMark = permission.inBaseTemplate ? palette.green("✓") : palette.pink("+");
1193
- const mark = showTemplateFlag ? templateMark : palette.dim("•");
1194
- const flag = showTemplateFlag && !permission.inBaseTemplate ? palette.pink(" ← add to template") : "";
1195
- const reason = palette.dim(`(${permission.reason})`);
1196
- return `${mark} ${permission.group} : ${palette.bold(permission.scope)} ${reason}${flag}`;
1197
- };
1198
- /**
1199
- * Render the LOCAL (first deploy) token panel: the full permission set with template/add markers,
1200
- * then the numbered create-token steps (URL cyan, template + `.env.local` bold).
1201
- *
1202
- * @param ui - The branded console to render through.
1203
- * @param requirement - The LOCAL token requirement (from requiredToken()).
1204
- * @example
1205
- * ```ts
1206
- * localPanel(ui, requiredToken(manifest));
1207
- * ```
1208
- */
1209
- const localPanel = (ui, requirement) => {
1210
- const { palette } = ui;
1211
- const adds = requirement.toAdd.map((permission) => `${permission.group.replace("Account · ", "")} → ${permission.scope}`).join(", ");
1212
- const coversAll = palette.dim(`The "${requirement.base}" template covers everything.`);
1213
- const addStep = requirement.toAdd.length > 0 ? ` 3. ADD ${palette.pink(adds)}` : ` 3. ${coversAll}`;
1214
- const template = palette.bold(`"${requirement.base}"`);
1215
- ui.box([
1216
- palette.bold("LOCAL — first deploy (creates your infra)"),
1217
- "",
1218
- ...requirement.required.map((permission) => permissionRow(ui, permission, true)),
1219
- "",
1220
- ` 1. ${palette.cyan(TOKENS_URL$1)}`,
1221
- ` 2. Create Token → start from the ${template} template.`,
1222
- addStep,
1223
- " 4. Account Resources → Include → your account.",
1224
- ` 5. Create it, copy it, then paste into ${palette.bold(".env.local")} (below).`
1225
- ]);
1226
- };
1227
- /**
1228
- * Render the compact CI (automation redeploy) token panel: the reduced, read-mostly permission set
1229
- * for a later Custom Token. No template markers — CI builds a token from scratch, not the template.
1230
- *
1231
- * @param ui - The branded console to render through.
1232
- * @param groups - The CI token permission groups (from ciToken()).
1233
- * @example
1234
- * ```ts
1235
- * ciPanel(ui, ciToken(manifest));
1236
- * ```
1237
- */
1238
- const ciPanel = (ui, groups) => {
1239
- const { palette } = ui;
1240
- ui.box([
1241
- palette.bold("CI — automation redeploy (optional, later)"),
1242
- "",
1243
- ...groups.map((permission) => permissionRow(ui, permission, false)),
1244
- "",
1245
- palette.dim("Create a Custom Token with exactly these (Read, not Edit, on data)."),
1246
- palette.dim("Store as the CLOUDFLARE_API_TOKEN secret; pin CLOUDFLARE_ACCOUNT_ID.")
1247
- ]);
1248
- };
1249
- /**
1250
- * Render the full branded `auth setup` guidance: a heading, the LOCAL token panel (what to create
1251
- * now), and — when `opts.ci` is supplied — the compact CI panel; otherwise a one-line pointer to
1252
- * `auth setup` for the CI token (so the guided deploy stays focused on the immediate next step).
1253
- *
1254
- * @param ui - The branded console to render through.
1255
- * @param requirement - The LOCAL token requirement (from requiredToken()).
1256
- * @param opts - Optional rendering options.
1257
- * @param opts.ci - The CI token permission groups (from ciToken()); omit to show a pointer instead.
1258
- * @example
1259
- * ```ts
1260
- * renderAuthSetup(ui, requiredToken(manifest)); // guided deploy (LOCAL only)
1261
- * renderAuthSetup(ui, requiredToken(manifest), { ci: ciToken(m) }); // `auth setup` (LOCAL + CI)
1262
- * ```
1263
- */
1264
- const renderAuthSetup = (ui, requirement, opts) => {
1265
- ui.heading("Cloudflare API token");
1266
- localPanel(ui, requirement);
1267
- if (opts?.ci) ciPanel(ui, opts.ci);
1268
- else ui.line(ui.palette.dim(" Need a CI token later? Run `auth setup` for the reduced set."));
1269
- };
1270
- //#endregion
1271
- //#region src/plugins/deploy/auth/setup.ts
1272
- /** Cloudflare's dashboard path for creating API tokens. */
1273
- const TOKENS_URL = "https://dash.cloudflare.com/profile/api-tokens";
1274
- /**
1275
- * Render the FULL local-first token section (the deploy that provisions everything): the permission
1276
- * table flagging template-missing rows, the template + "add these" steps, and the `.env.local` lines.
1277
- *
1278
- * @param requirement - The full token requirement (from requiredToken()).
1279
- * @returns The local-first section lines.
1280
- * @example
1281
- * ```ts
1282
- * const lines = localSection(requiredToken(manifest));
1283
- * ```
1284
- */
1285
- const localSection = (requirement) => {
1286
- const permissionRows = requirement.required.map((permission) => {
1287
- const flag = permission.inBaseTemplate ? "" : " <- add to template";
1288
- return ` - ${permission.group} : ${permission.scope} (${permission.reason})${flag}`;
1289
- });
1290
- const step3 = requirement.toAdd.length > 0 ? [` 3. Under Permissions, ADD: ${requirement.toAdd.map((permission) => `${permission.group.replace("Account · ", "")} -> ${permission.scope}`).join(", ")}`, " (the template omits these; everything else is already included)"] : [` 3. The "${requirement.base}" template covers everything — no changes needed.`];
1291
- return [
1292
- "LOCAL — first deploy (provisions infra). A Cloudflare API token with these permissions:",
1293
- "",
1294
- ...permissionRows,
1295
- "",
1296
- "Fastest path:",
1297
- ` 1. ${TOKENS_URL} -> Create Token`,
1298
- ` 2. Start from the "${requirement.base}" template.`,
1299
- ...step3,
1300
- " 4. Account Resources -> Include -> your account.",
1301
- " 5. Create the token, copy it, then add it to .env.local:",
1302
- " CLOUDFLARE_API_TOKEN=<paste your token>",
1303
- " CLOUDFLARE_ACCOUNT_ID=<your account id>",
1304
- " 6. Verify it with `auth` (app.deploy.verifyAuth())."
1305
- ];
1306
- };
1307
- /**
1308
- * Render the REDUCED CI/automation token section (redeploy-only): the scoped permission table plus
1309
- * the CI-secret + account-pin steps.
1310
- *
1311
- * @param groups - The CI permission groups (from ciToken()).
1312
- * @returns The CI section lines.
1313
- * @example
1314
- * ```ts
1315
- * const lines = ciSection(ciToken(manifest));
1316
- * ```
1317
- */
1318
- const ciSection = (groups) => {
1319
- return [
1320
- "CI — automation redeploy (infra already provisioned by a local deploy). A SCOPED token with:",
1321
- "",
1322
- ...groups.map((permission) => ` - ${permission.group} : ${permission.scope} (${permission.reason})`),
1323
- "",
1324
- ` 1. ${TOKENS_URL} -> Create Token -> Create Custom Token.`,
1325
- " 2. Add exactly the permissions above (Read, not Edit, on data resources — CI never creates).",
1326
- " 3. Account Resources -> Include -> your account.",
1327
- " 4. Store it as the CLOUDFLARE_API_TOKEN secret in CI, and PIN the account so no account",
1328
- " lookup (and no Account Settings -> Read) is needed:",
1329
- " CLOUDFLARE_ACCOUNT_ID=<your account id>",
1330
- " CI reuses the same idempotent pipeline — it lists existing infra and ships. To let CI also",
1331
- " CREATE missing infra (self-heal), give it the LOCAL token above instead."
1332
- ];
1333
- };
1334
- /**
1335
- * Render the `auth setup` instructions from the app manifest: the FULL local-first token (provisions
1336
- * everything) followed by the REDUCED CI/automation token (redeploy-only).
1337
- *
1338
- * @param manifest - The assembled deploy manifest.
1339
- * @returns A multi-line instruction string covering both tokens.
1340
- * @example
1341
- * ```ts
1342
- * const text = tokenInstructions(manifest);
1343
- * ```
1344
- */
1345
- const tokenInstructions = (manifest) => [
1346
- ...localSection(requiredToken(manifest)),
1347
- "",
1348
- ...ciSection(ciToken(manifest))
1349
- ].join("\n");
1350
- /**
1351
- * Render a ready-to-fill `.env.local` for the guided deploy: the two Cloudflare credential keys
1352
- * (left blank to paste into) preceded by a comment block derived from the manifest — where to
1353
- * create the token, which template to start from, exactly which permissions to add, and how to find
1354
- * the account id. The same guidance {@link tokenInstructions} prints, but PERSISTED in the file the
1355
- * user edits (so it survives the terminal scrolling away). Pure: no fs, no network.
1356
- *
1357
- * @param manifest - The assembled deploy manifest.
1358
- * @returns The `.env.local` file contents (trailing newline included).
1359
- * @example
1360
- * ```ts
1361
- * await writeFile(".env.local", envLocalScaffold(manifest));
1362
- * ```
1363
- */
1364
- const envLocalScaffold = (manifest) => {
1365
- const requirement = requiredToken(manifest);
1366
- 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.`;
1367
- return `${[
1368
- "# Cloudflare credentials for the moku deploy — fill in the two values below, then re-run deploy.",
1369
- "# Local-only: keep this file out of git (.env.local is gitignored by convention).",
1370
- "#",
1371
- "# Create the API token:",
1372
- `# 1. ${TOKENS_URL} -> Create Token`,
1373
- `# 2. Start from the "${requirement.base}" template.`,
1374
- addStep,
1375
- "# 4. Account Resources -> Include -> your account.",
1376
- "# 5. Create the token, copy it, and paste it after CLOUDFLARE_API_TOKEN= below.",
1377
- "#",
1378
- "# Account id: open https://dash.cloudflare.com — it is the id in the URL",
1379
- "# (dash.cloudflare.com/<account-id>) or in the right sidebar of any domain's overview.",
1380
- "",
1381
- "CLOUDFLARE_API_TOKEN=",
1382
- "CLOUDFLARE_ACCOUNT_ID="
1383
- ].join("\n")}\n`;
1384
- };
1385
- //#endregion
1386
- //#region src/plugins/deploy/infra/cloudflare.ts
1387
- /**
1388
- * @file deploy plugin — Cloudflare REST discovery client (infra preflight).
1389
- *
1390
- * Lists what already exists in a Cloudflare account so the deploy pipeline can create only the
1391
- * missing resources (idempotent provisioning) and recover real ids for existing kv/d1 bindings.
1392
- * Authenticated with the `.env` API token (CLOUDFLARE_API_TOKEN) — never an interactive login.
1393
- * Uses the global `fetch`; node-only, never imported by the runtime Worker bundle.
1394
- */
1395
- const API_BASE = "https://api.cloudflare.com/client/v4";
1396
- /**
1397
- * GET a Cloudflare API path with the bearer token and unwrap the `result`.
1398
- *
1399
- * @param token - The Cloudflare API token (CLOUDFLARE_API_TOKEN).
1400
- * @param path - API path beneath the v4 base (e.g. "/accounts").
1401
- * @returns The unwrapped `result` payload, typed by the caller.
1402
- * @throws {Error} When the HTTP request fails or the API reports `success: false`.
1403
- * @example
1404
- * ```ts
1405
- * const accounts = await cfGet<Array<{ id: string }>>(token, "/accounts");
1406
- * ```
1407
- */
1408
- const cfGet = async (token, path) => {
1409
- const response = await fetch(`${API_BASE}${path}`, { headers: {
1410
- Authorization: `Bearer ${token}`,
1411
- "Content-Type": "application/json"
1412
- } });
1413
- const body = await response.json();
1414
- if (!response.ok || !body.success) {
1415
- const detail = body.errors?.map((error) => error.message).join("; ") || `HTTP ${response.status}`;
1416
- throw new Error(`[moku-worker] Cloudflare API request failed (${path}): ${detail}`);
1417
- }
1418
- return body.result;
1419
- };
1420
- /**
1421
- * Resolve the Cloudflare account (id + display name) accessible to the token. Used when the
1422
- * consumer did not pin CLOUDFLARE_ACCOUNT_ID; the first accessible account is chosen.
1423
- *
1424
- * @param token - The Cloudflare API token.
1425
- * @returns The resolved account id and name.
1426
- * @throws {Error} When the token can access no account.
1427
- * @example
1428
- * ```ts
1429
- * const { id, name } = await resolveAccount(token);
1430
- * ```
1431
- */
1432
- const resolveAccount = async (token) => {
1433
- const first = (await cfGet(token, "/accounts"))[0];
1434
- if (!first) throw new Error("[moku-worker] No Cloudflare account is accessible with this API token.");
1435
- return {
1436
- id: first.id,
1437
- name: first.name
1438
- };
1439
- };
1440
- /**
1441
- * Verify a Cloudflare API token via `GET /user/tokens/verify`. Returns its status (`"active"` for
1442
- * a usable token); throws (via cfGet) when the token is rejected outright (401/invalid).
1443
- *
1444
- * @param token - The Cloudflare API token to verify.
1445
- * @returns The token status string reported by Cloudflare.
1446
- * @throws {Error} When the verify request fails (invalid/expired token).
1447
- * @example
1448
- * ```ts
1449
- * const { status } = await verifyToken(token); // status === "active"
1450
- * ```
1451
- */
1452
- const verifyToken = async (token) => {
1453
- return { status: (await cfGet(token, "/user/tokens/verify")).status };
1454
- };
1455
- /**
1456
- * List the resources that already exist in the account, querying ONLY the kinds the app declares
1457
- * (one request per declared kind, in parallel), indexed for the preflight diff. Scoping to the
1458
- * declared kinds keeps the API token minimal — an app with only KV never lists (and so never needs
1459
- * read permission on) D1, R2, or Queues.
1460
- *
1461
- * @param token - The Cloudflare API token.
1462
- * @param accountId - The Cloudflare account id to scope the listings to.
1463
- * @param kinds - The resource kinds present in the manifest (the only kinds queried).
1464
- * @returns The existing resources, indexed by kind (un-queried kinds resolve empty).
1465
- * @throws {Error} When any listing request fails.
1466
- * @example
1467
- * ```ts
1468
- * const existing = await listExisting(token, accountId, new Set(["kv", "d1"]));
1469
- * if (existing.kv.has("SESSIONS")) { ... }
1470
- * ```
1471
- */
1472
- const listExisting = async (token, accountId, kinds) => {
1473
- const base = `/accounts/${accountId}`;
1474
- const [kv, d1, r2, queues] = await Promise.all([
1475
- kinds.has("kv") ? cfGet(token, `${base}/storage/kv/namespaces`) : Promise.resolve([]),
1476
- kinds.has("d1") ? cfGet(token, `${base}/d1/database`) : Promise.resolve([]),
1477
- kinds.has("r2") ? cfGet(token, `${base}/r2/buckets`) : Promise.resolve({}),
1478
- kinds.has("queue") ? cfGet(token, `${base}/queues`) : Promise.resolve([])
1479
- ]);
1480
- return {
1481
- kv: new Map(kv.map((namespace) => [namespace.title, namespace.id])),
1482
- d1: new Map(d1.map((database) => [database.name, database.uuid])),
1483
- r2: new Set((r2.buckets ?? []).map((bucket) => bucket.name)),
1484
- queue: new Set(queues.map((queue) => queue.queue_name))
1485
- };
1486
- };
1487
- //#endregion
1488
- //#region src/plugins/deploy/auth/verify.ts
1489
- /**
1490
- * @file deploy plugin — `.env` token verification + account resolution.
1491
- *
1492
- * Reads CLOUDFLARE_API_TOKEN via ctx.env, verifies it is active against the Cloudflare API, and
1493
- * resolves the account. Emits auth:verified. Throws a branded, actionable error (pointing at
1494
- * `auth setup`) when the token is absent, invalid, or inactive — never an interactive login.
1495
- * Node-only; never imported by the runtime Worker bundle.
1496
- */
1497
- /** Branded hint appended to every auth failure so the user knows the next step. */
1498
- const SETUP_HINT = "Run `auth setup` for the exact token to create.";
1499
- /**
1500
- * Verify the `.env` Cloudflare API token and resolve its account.
1501
- *
1502
- * @param ctx - The deploy plugin context (env + emit).
1503
- * @returns The verified auth status (account + id).
1504
- * @throws {Error} When the token is absent, invalid/expired, or not active.
1505
- * @example
1506
- * ```ts
1507
- * const { account, accountId } = await verifyAuth(ctx);
1508
- * ```
1509
- */
1510
- const verifyAuth = async (ctx) => {
1511
- const token = ctx.env.get("CLOUDFLARE_API_TOKEN");
1512
- if (token === void 0 || token === "") throw new Error(`[moku-worker] CLOUDFLARE_API_TOKEN is not set. ${SETUP_HINT}`);
1513
- let status;
1514
- try {
1515
- ({status} = await verifyToken(token));
1516
- } catch (error) {
1517
- throw new Error(`[moku-worker] Cloudflare API token is invalid or expired. ${SETUP_HINT}`, { cause: error });
1518
- }
1519
- if (status !== "active") throw new Error(`[moku-worker] Cloudflare API token is "${status}", not active. ${SETUP_HINT}`);
1520
- const pinnedAccountId = ctx.env.get("CLOUDFLARE_ACCOUNT_ID");
1521
- const account = pinnedAccountId === void 0 || pinnedAccountId === "" ? await resolveAccount(token) : {
1522
- id: pinnedAccountId,
1523
- name: pinnedAccountId
1524
- };
1525
- ctx.emit("auth:verified", {
1526
- account: account.name,
1527
- accountId: account.id,
1528
- scopes: []
1529
- });
1530
- return {
1531
- ok: true,
1532
- account: account.name,
1533
- accountId: account.id,
1534
- scopes: []
1535
- };
1536
- };
1537
- //#endregion
1538
- //#region src/plugins/deploy/infra/render.ts
1539
- /**
1540
- * Derive a human-readable name from a resource descriptor: the Cloudflare resource `name` for the
1541
- * provisioned kinds (kv/r2/d1/queue), or the exported `className` for a Durable Object (which has no
1542
- * provisioned name). Used in both the provision events and the branded panels so the two agree.
1543
- *
1544
- * @param resource - The resource descriptor.
1545
- * @returns A short name identifying the resource.
1546
- * @example
1547
- * ```ts
1548
- * resourceName({ kind: "kv", name: "tracker-cache", binding: "CACHE" }); // "tracker-cache"
1549
- * ```
1550
- */
1551
- const resourceName = (resource) => resource.kind === "do" ? resource.className : resource.name;
1552
- /**
1553
- * Format a `kind name` cell, padding the kind so the names line up in a column.
1554
- *
1555
- * @param kind - The resource kind (kv / r2 / d1 / queue / do).
1556
- * @param name - The resource name.
1557
- * @returns The aligned `kind name` cell.
1558
- * @example
1559
- * ```ts
1560
- * cell("kv", "CACHE"); // "kv CACHE"
1561
- * ```
1562
- */
1563
- const cell = (kind, name) => `${kind.padEnd(6)}${name}`;
1564
- /**
1565
- * Row tag for a Durable Object — it ships with the Worker (`wrangler deploy` creates the namespace),
1566
- * so it is NEVER labelled `(exists)` (the planner never queried the account for it). Shared by the
1567
- * plan and provision-result panels so the two always read the same.
1568
- */
1569
- const SHIPS_WITH_WORKER = "(ships with worker)";
1570
- /**
1571
- * ANSI SGR matcher — built from `String.fromCharCode(27)` (the ESC byte) so no control character
1572
- * appears in a regex literal (which both linters reject).
1573
- */
1574
- const ANSI_SGR = new RegExp(String.raw`${String.fromCodePoint(27)}\[[0-9;]*m`, "gu");
1575
- /**
1576
- * Strip ANSI SGR escape sequences so a captured (colorized) error renders as plain, readable text.
1577
- *
1578
- * @param text - The (possibly colorized) text.
1579
- * @returns The text with ANSI color codes removed.
1580
- * @example
1581
- * ```ts
1582
- * stripAnsi(`${String.fromCharCode(27)}[31mX${String.fromCharCode(27)}[0m`); // "X"
1583
- * ```
1584
- */
1585
- const stripAnsi = (text) => text.replaceAll(ANSI_SGR, "");
1586
- /**
1587
- * Clean a captured (colorized, multi-line, wrapper-wrapped) provision error down to its meaningful
1588
- * text: strip ANSI, drop the wrapper lines (the branded prefix, wrangler's log-file pointer), strip
1589
- * each `✘ [ERROR]` marker, and join what's left. Returns the FULL message (the caller word-wraps it)
1590
- * so the user reads the actual reason — never a truncated `…`.
1591
- *
1592
- * @param message - The captured error message.
1593
- * @returns The full, plain failure reason.
1594
- * @example
1595
- * ```ts
1596
- * cleanError("[moku-worker] wrangler exited…\n ✘ [ERROR] The bucket name is invalid.");
1597
- * // "The bucket name is invalid."
1598
- * ```
1599
- */
1600
- const cleanError = (message) => {
1601
- 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(" ");
1602
- return cleaned.length > 0 ? cleaned : stripAnsi(message).trim();
1603
- };
1604
- /**
1605
- * Word-wrap text to `width` columns (never splitting inside a word), so a long failure reason reads
1606
- * as a tidy indented block instead of forcing the box wide or scrolling off the edge.
1607
- *
1608
- * @param text - The text to wrap.
1609
- * @param width - The maximum column width per line.
1610
- * @returns The wrapped lines.
1611
- * @example
1612
- * ```ts
1613
- * wrapText("a long sentence to wrap", 10); // ["a long", "sentence", "to wrap"]
1614
- * ```
1615
- */
1616
- const wrapText = (text, width) => {
1617
- const lines = [];
1618
- let line = "";
1619
- for (const word of text.split(/\s+/u).filter(Boolean)) if (line.length === 0) line = word;
1620
- else if (line.length + 1 + word.length <= width) line += ` ${word}`;
1621
- else {
1622
- lines.push(line);
1623
- line = word;
1624
- }
1625
- if (line.length > 0) lines.push(line);
1626
- return lines;
1627
- };
1628
- /**
1629
- * Render the infra preflight plan as a branded panel: a dim summary line (counts + account) then one
1630
- * row per declared resource — a pink `+` for those to create, a dim `~ (exists)` for those already
1631
- * present, and a dim `~ (ships with worker)` for Durable Objects (created by `wrangler deploy`, never
1632
- * pre-provisioned). When nothing needs creating it still renders, so the user sees the full picture.
1633
- *
1634
- * @param ui - The branded console to render through.
1635
- * @param plan - The infra plan (existing vs missing vs ships-with-Worker) from checkInfra()/planInfra().
1636
- * @example
1637
- * ```ts
1638
- * renderPlan(ui, await planInfra(ctx, manifest));
1639
- * ```
1640
- */
1641
- const renderPlan = (ui, plan) => {
1642
- const { palette } = ui;
1643
- const counts = [`${String(plan.missing.length)} to create`, `${String(plan.exists.length)} exist`];
1644
- if (plan.ships.length > 0) counts.push(`${String(plan.ships.length)} with worker`);
1645
- const summary = palette.dim(`${counts.join(" · ")} · ${plan.account}`);
1646
- const createRows = plan.missing.map((resource) => `${palette.pink("+")} ${cell(resource.kind, resourceName(resource))}`);
1647
- const existsRows = plan.exists.map((ref) => `${palette.dim("~")} ${cell(ref.resource.kind, resourceName(ref.resource))} ${palette.dim("(exists)")}`);
1648
- const shipsRows = plan.ships.map((resource) => `${palette.dim("~")} ${cell(resource.kind, resourceName(resource))} ${palette.dim(SHIPS_WITH_WORKER)}`);
1649
- ui.heading("Infra plan");
1650
- ui.box([
1651
- summary,
1652
- "",
1653
- ...createRows,
1654
- ...existsRows,
1655
- ...shipsRows
1656
- ]);
1657
- };
1658
- /**
1659
- * Render the provision result as a branded panel — a green `✓` per created resource, a dim `~` per
1660
- * skipped, a dim `~ (ships with worker)` per Durable Object, a red `✗` per failure, then a summary
1661
- * line (failed count red when non-zero) — followed, when anything failed, by a detail block printing
1662
- * each failure's FULL reason (ANSI-stripped and word-wrapped) so it is actually readable instead of
1663
- * truncated inside the box.
1664
- *
1665
- * @param ui - The branded console to render through.
1666
- * @param result - The provision result from provisionInfra()/the deploy pipeline.
1667
- * @example
1668
- * ```ts
1669
- * renderProvisionResult(ui, await provisionInfra(plan));
1670
- * ```
1671
- */
1672
- const renderProvisionResult = (ui, result) => {
1673
- const { palette } = ui;
1674
- const createdRows = result.created.map((ref) => `${palette.green("✓")} ${cell(ref.resource.kind, resourceName(ref.resource))}`);
1675
- const skippedRows = result.skipped.map((ref) => `${palette.dim("~")} ${cell(ref.resource.kind, resourceName(ref.resource))} ${palette.dim("(exists)")}`);
1676
- const bundledRows = result.bundled.map((resource) => `${palette.dim("~")} ${cell(resource.kind, resourceName(resource))} ${palette.dim(SHIPS_WITH_WORKER)}`);
1677
- const failedRows = result.failed.map((failure) => `${palette.red("✗")} ${cell(failure.resource.kind, resourceName(failure.resource))}`);
1678
- const failedCount = result.failed.length > 0 ? palette.red(`${String(result.failed.length)} failed`) : "0 failed";
1679
- const counts = [`${String(result.created.length)} created`, `${String(result.skipped.length)} exist`];
1680
- if (result.bundled.length > 0) counts.push(`${String(result.bundled.length)} with worker`);
1681
- const summary = `${counts.join(" · ")} · ${failedCount}`;
1682
- ui.heading("Provisioned");
1683
- ui.box([
1684
- ...createdRows,
1685
- ...skippedRows,
1686
- ...bundledRows,
1687
- ...failedRows,
1688
- "",
1689
- summary
1690
- ]);
1691
- if (result.failed.length > 0) {
1692
- ui.line();
1693
- for (const failure of result.failed) {
1694
- ui.line(` ${palette.red("✗")} ${cell(failure.resource.kind, resourceName(failure.resource))}`);
1695
- for (const wrapped of wrapText(cleanError(failure.error), ui.width - 4)) ui.line(palette.dim(` ${wrapped}`));
1696
- }
1697
- }
1698
- };
1699
- /**
1700
- * Format an elapsed duration compactly: sub-second as `820ms`, otherwise one-decimal seconds (`4.2s`),
1701
- * and minutes once it crosses 60s (`1m04s`) so a long deploy stays readable.
1702
- *
1703
- * @param ms - The elapsed milliseconds.
1704
- * @returns The compact duration string.
1705
- * @example
1706
- * ```ts
1707
- * formatDuration(4234); // "4.2s"
1708
- * ```
1709
- */
1710
- const formatDuration = (ms) => {
1711
- if (ms < 1e3) return `${String(ms)}ms`;
1712
- const seconds = ms / 1e3;
1713
- if (seconds < 60) return `${seconds.toFixed(1)}s`;
1714
- const whole = Math.floor(seconds);
1715
- return `${String(Math.floor(whole / 60))}m${String(whole % 60).padStart(2, "0")}s`;
1716
- };
1717
- /**
1718
- * Render the terminal deploy summary as a branded panel — the headline the user actually wants. The
1719
- * live URL leads on its own line (pink, so it is the first thing the eye lands on), then a dim
1720
- * key/value block: the target stage, the resource tally (with a red `failed` count when non-zero),
1721
- * and the wall-clock time the whole deploy took. Replaces the prior single `deployed → url` line.
1722
- *
1723
- * @param ui - The branded console to render through.
1724
- * @param summary - The deploy summary fields.
1725
- * @param summary.url - The live deployed URL (the panel headline).
1726
- * @param summary.stage - The target stage the worker deployed to.
1727
- * @param summary.created - How many resources were created this run.
1728
- * @param summary.exists - How many resources already existed (skipped).
1729
- * @param summary.bundled - How many Durable Objects shipped with the Worker.
1730
- * @param summary.failed - How many resources failed to provision.
1731
- * @param summary.elapsedMs - The wall-clock deploy duration in milliseconds.
1732
- * @example
1733
- * ```ts
1734
- * renderDeploySummary(ui, { url, stage: "production", created: 0, exists: 5, bundled: 1, failed: 0, elapsedMs: 4234 });
1735
- * ```
1736
- */
1737
- const renderDeploySummary = (ui, summary) => {
1738
- const { palette } = ui;
1739
- const parts = [`${String(summary.exists)} exist`, `${String(summary.created)} created`];
1740
- if (summary.bundled > 0) parts.push(`${String(summary.bundled)} with worker`);
1741
- const tally = parts.join(" · ");
1742
- const failedLabel = palette.red(`${String(summary.failed)} failed`);
1743
- const resources = summary.failed > 0 ? `${tally} · ${failedLabel}` : tally;
1744
- ui.heading("Deployed");
1745
- ui.box([
1746
- palette.pink(summary.url),
1747
- "",
1748
- `${palette.dim("stage".padEnd(10))}${summary.stage}`,
1749
- `${palette.dim("resources".padEnd(10))}${resources}`,
1750
- `${palette.dim("took".padEnd(10))}${formatDuration(summary.elapsedMs)}`
1751
- ]);
1752
- };
1753
- /**
1754
- * Render the D1 migration outcome as a branded panel — the readable replacement for wrangler's raw
1755
- * `d1 migrations apply` TUI. One row per database: the `d1 <binding>` cell plus a pink `N applied`
1756
- * count (or a dim `up to date` when nothing was pending), with each applied migration filename listed
1757
- * beneath as a green `✓`. A dim scope footer (`remote` / `local`) names which database was touched.
1758
- * The caller renders this only when at least one database actually ran migrations.
1759
- *
1760
- * @param ui - The branded console to render through.
1761
- * @param outcomes - The per-database migration outcomes (one per d1 instance that declares migrations).
1762
- * @param scope - Which database the migrations ran against: `remote` (Cloudflare) or `local` (dev).
1763
- * @example
1764
- * ```ts
1765
- * renderMigrateSummary(ui, [{ binding: "DB", applied: ["0003_x.sql"], upToDate: false }], "remote");
1766
- * ```
1767
- */
1768
- const renderMigrateSummary = (ui, outcomes, scope) => {
1769
- const { palette } = ui;
1770
- const rows = [];
1771
- for (const outcome of outcomes) {
1772
- const count = outcome.applied.length;
1773
- const appliedLabel = palette.pink(count > 0 ? `${String(count)} applied` : "applied");
1774
- const status = outcome.upToDate ? palette.dim("up to date") : appliedLabel;
1775
- rows.push(`${cell("d1", outcome.binding)} ${status}`);
1776
- for (const name of outcome.applied) rows.push(` ${palette.green("✓")} ${palette.dim(name)}`);
1777
- }
1778
- ui.heading("Migrated");
1779
- ui.box([
1780
- ...rows,
1781
- "",
1782
- palette.dim(scope)
1783
- ]);
1784
- };
1785
- /**
1786
- * Render the seed outcome as a branded panel — the readable replacement for wrangler's raw
1787
- * `d1 execute` / `kv key delete` TUI. Leads with the loaded `file → binding` (pink file), an optional
1788
- * dim stats line (rows written / statements, only the parts wrangler reported), then — when the seed
1789
- * cleared cached KV keys — a `KV reset` block listing each `~ binding key` so the user sees exactly
1790
- * what was dropped. A dim scope footer (`remote` / `local`) names which database was seeded.
1791
- *
1792
- * @param ui - The branded console to render through.
1793
- * @param outcome - The seed outcome (file, target binding, best-effort counts, the KV keys reset).
1794
- * @param scope - Which database the seed ran against: `remote` (Cloudflare) or `local` (dev).
1795
- * @example
1796
- * ```ts
1797
- * renderSeedSummary(ui, { file: "db/seed.sql", binding: "DB", rowsWritten: 18, resetKv: [] }, "remote");
1798
- * ```
1799
- */
1800
- const renderSeedSummary = (ui, outcome, scope) => {
1801
- const { palette } = ui;
1802
- const lines = [`${palette.pink(outcome.file)} ${palette.dim("→")} ${outcome.binding}`];
1803
- const stats = [];
1804
- if (outcome.rowsWritten !== void 0) stats.push(`${String(outcome.rowsWritten)} rows written`);
1805
- if (outcome.statements !== void 0) stats.push(`${String(outcome.statements)} statements`);
1806
- if (stats.length > 0) lines.push(palette.dim(stats.join(" · ")));
1807
- if (outcome.resetKv.length > 0) {
1808
- lines.push("", palette.dim("KV reset"));
1809
- for (const entry of outcome.resetKv) lines.push(`${palette.dim("~")} ${entry.binding} ${palette.dim(entry.key)}`);
1810
- }
1811
- ui.heading("Seeded");
1812
- ui.box([
1813
- ...lines,
1814
- "",
1815
- palette.dim(scope)
1816
- ]);
1817
- };
1818
- //#endregion
1819
- //#region src/plugins/deploy/runner.ts
1820
- /**
1821
- * @file deploy plugin — wrangler subprocess wrapper (node:child_process).
1822
- *
1823
- * Spawns `wrangler` with the given args and resolves the deployed URL
1824
- * (extracted from stdout for `wrangler deploy`), or the full stdout for other verbs.
1825
- * This module is node-only; never imported by the runtime Worker bundle.
1826
- */
1827
- /**
1828
- * Extract the deployed URL from `wrangler deploy` stdout.
1829
- * Wrangler prints a line like: "Published my-worker (1.23 sec) https://..."
1830
- * or "Deployed my-worker (1.23 sec) https://...".
1831
- *
1832
- * @param output - The combined stdout from wrangler deploy.
1833
- * @returns The deployed URL, or empty string when not found.
1834
- * @example
1835
- * ```ts
1836
- * extractDeployedUrl("Deployed my-worker (0.5 sec) https://my-worker.workers.dev");
1837
- * // "https://my-worker.workers.dev"
1838
- * ```
1839
- */
1840
- const extractDeployedUrl = (output) => {
1841
- return /https:\/\/[^\s]+\.workers\.dev[^\s]*/u.exec(output)?.[0] ?? "";
1842
- };
1843
- /**
1844
- * Spawn `wrangler` with the given args and resolve the output string.
1845
- * For `wrangler deploy`, the resolved value is the deployed URL parsed from stdout.
1846
- * For all other verbs (dev, kv namespace create, etc.), the resolved value is stdout.
1847
- *
1848
- * @param args - Wrangler CLI arguments (e.g. ["deploy", "--config", "wrangler.jsonc"]).
1849
- * @returns Resolves with the deployed URL (deploy verb) or full stdout (other verbs).
1850
- * @throws {Error} When wrangler exits with a non-zero code.
1851
- * @example
1852
- * ```ts
1853
- * const url = await runWrangler(["deploy", "--config", "wrangler.jsonc"]);
1854
- * await runWrangler(["kv", "namespace", "create", "CACHE"]);
1855
- * ```
1856
- */
1857
- const runWrangler = (args) => new Promise((resolve, reject) => {
1858
- const chunks = [];
1859
- const errChunks = [];
1860
- const child = (0, node_child_process.spawn)("wrangler", args, {
1861
- env: { ...process.env },
1862
- stdio: [
1863
- "ignore",
1864
- "pipe",
1865
- "pipe"
1866
- ]
1867
- });
1868
- child.stdout.on("data", (chunk) => {
1869
- chunks.push(chunk);
1870
- });
1871
- child.stderr.on("data", (chunk) => {
1872
- errChunks.push(chunk);
1873
- });
1874
- child.on("error", (err) => {
1875
- reject(/* @__PURE__ */ new Error(`[moku-worker] Failed to spawn wrangler.\n ${err.message}`));
1876
- });
1877
- child.on("close", (code) => {
1878
- const stdout = Buffer.concat(chunks).toString("utf8");
1879
- const stderr = Buffer.concat(errChunks).toString("utf8");
1880
- if (code !== 0) {
1881
- reject(/* @__PURE__ */ new Error(`[moku-worker] wrangler exited with code ${String(code)}.\n ${stderr || stdout}`));
1882
- return;
1883
- }
1884
- resolve(args[0] === "deploy" ? extractDeployedUrl(stdout) : stdout);
1885
- });
1886
- });
1887
- /**
1888
- * Spawn `wrangler` with the given args, inheriting stdio so its output streams live to the user's
1889
- * terminal (used by the generic passthrough and long-lived commands like `tail`).
1890
- *
1891
- * @param args - Wrangler CLI arguments (e.g. ["kv", "namespace", "list"]).
1892
- * @returns Resolves once wrangler exits successfully.
1893
- * @throws {Error} When wrangler cannot be spawned or exits non-zero.
1894
- * @example
1895
- * ```ts
1896
- * await runWranglerInherit(["kv", "namespace", "list"]);
1897
- * ```
1898
- */
1899
- const runWranglerInherit = (args) => {
1900
- return new Promise((resolve, reject) => {
1901
- const child = (0, node_child_process.spawn)("wrangler", args, { stdio: "inherit" });
1902
- child.on("error", (error) => {
1903
- reject(/* @__PURE__ */ new Error(`[moku-worker] Failed to spawn wrangler.\n ${error.message}`));
1904
- });
1905
- child.on("close", (code) => {
1906
- if (code === 0) {
1907
- resolve();
1908
- return;
1909
- }
1910
- reject(/* @__PURE__ */ new Error(`[moku-worker] wrangler exited with code ${String(code)}.`));
1911
- });
1912
- });
1913
- };
1914
- //#endregion
1915
- //#region src/plugins/deploy/seed.ts
1916
- /**
1917
- * @file deploy plugin — shared D1 seed helpers (resolve the target db, run a configured seed).
1918
- *
1919
- * Pure orchestration over an INJECTED wrangler runner, so the post-deploy REMOTE seed (api.ts) and
1920
- * the dev-session LOCAL seed (dev/runner.ts) stay in lockstep — same file, same KV-reset semantics,
1921
- * differing only in the `--remote` / `--local` scope. Migrations are NOT applied here: each caller
1922
- * applies the schema first (the deploy's migration step / dev's local-migrate step), then seeds.
1923
- * Node-only; never imported by the runtime Worker bundle.
1924
- */
1925
- /**
1926
- * Parse the best-effort row/statement counts from wrangler's `d1 execute` output so the branded seed
1927
- * panel can report them — degrading gracefully (each field simply omitted) when wrangler's format
1928
- * differs or the runner streamed instead of captured. Wrangler prints lines like "🚣 18 commands
1929
- * executed" and a rows-written total; both are matched loosely (case-insensitive).
1930
- *
1931
- * @param output - The captured stdout from `wrangler d1 execute` (empty when the runner streamed).
1932
- * @returns The parsed counts — each field present only when found.
1933
- * @example
1934
- * ```ts
1935
- * parseSeedStats("🚣 18 commands executed (30 rows written)"); // { statements: 18, rowsWritten: 30 }
1936
- * ```
1937
- */
1938
- const parseSeedStats = (output) => {
1939
- const rows = /(\d{1,12}) rows? written/iu.exec(output);
1940
- const commands = /(\d{1,12}) commands? executed/iu.exec(output) ?? /executed (\d{1,12}) commands?/iu.exec(output);
1941
- const result = {};
1942
- if (commands?.[1] !== void 0) result.statements = Number(commands[1]);
1943
- if (rows?.[1] !== void 0) result.rowsWritten = Number(rows[1]);
1944
- return result;
1945
- };
1946
- /**
1947
- * Parse which migrations wrangler applied from its captured `d1 migrations apply` output, so the
1948
- * branded migrate panel can name them instead of dumping wrangler's raw migration TUI. `upToDate` is
1949
- * true when wrangler reported nothing pending ("No migrations to apply"); otherwise every
1950
- * `NNNN_name.sql` filename token in the output is collected in order (de-duplicated). Degrades
1951
- * safely — an unrecognized format yields no names, and the panel falls back to a generic "applied".
1952
- * Lives here (not in api.ts) so both the deploy path and the dev path parse it without a cycle.
1953
- *
1954
- * @param output - The captured stdout from `wrangler d1 migrations apply`.
1955
- * @returns The applied migration filenames and whether the database was already up to date.
1956
- * @example
1957
- * ```ts
1958
- * parseMigrationsApplied("Applied 0003_x.sql\n0004_y.sql"); // { applied: ["0003_x.sql", "0004_y.sql"], upToDate: false }
1959
- * ```
1960
- */
1961
- const parseMigrationsApplied = (output) => {
1962
- if (/no migrations to apply/iu.test(output)) return {
1963
- applied: [],
1964
- upToDate: true
1965
- };
1966
- const applied = [];
1967
- const seen = /* @__PURE__ */ new Set();
1968
- for (const match of output.matchAll(/\b\d{3,}_[A-Za-z0-9_-]+\.sql\b/gu)) {
1969
- const name = match[0];
1970
- if (!seen.has(name)) {
1971
- seen.add(name);
1972
- applied.push(name);
1973
- }
1974
- }
1975
- return {
1976
- applied,
1977
- upToDate: false
1978
- };
1979
- };
1980
- /**
1981
- * Resolve the single configured d1 database (or the one bound to `binding` when several exist) from
1982
- * the d1 plugin's manifest. The shared resolver behind `seed()`, the post-deploy seed, and the dev
1983
- * seed; throws a branded error when the choice is ambiguous (none/several, no binding) or unknown.
1984
- *
1985
- * @param ctx - The deploy plugin context.
1986
- * @param binding - The d1 binding to target when more than one is configured; the sole one otherwise.
1987
- * @returns The resolved d1 resource descriptor (its binding + optional migrations dir).
1988
- * @throws {Error} When no single database resolves (none/several without a binding, or unknown binding).
1989
- * @example
1990
- * ```ts
1991
- * const db = resolveD1(ctx, "DB");
1992
- * ```
1993
- */
1994
- const resolveD1 = (ctx, binding) => {
1995
- const databases = ctx.require(d1Plugin).deployManifest();
1996
- const matched = binding === void 0 ? databases : databases.filter((db) => db.binding === binding);
1997
- const target = matched.length === 1 ? matched[0] : void 0;
1998
- if (target === void 0) throw new Error(binding === void 0 ? `[moku-worker] seed: ${String(databases.length)} d1 databases configured — pass { binding } to choose one.` : `[moku-worker] seed: no d1 database is bound to "${binding}".`);
1999
- return target;
2000
- };
2001
- /**
2002
- * Run a configured seed against one scope: execute the seed SQL against the d1 database, then delete
2003
- * each configured cached KV key so the next read rebuilds it from the freshly-seeded rows. The
2004
- * schema is assumed to exist (the caller applies migrations first), so this never migrates. The
2005
- * wrangler runner is injected so the same orchestration serves the streamed deploy path and the
2006
- * injectable dev path.
2007
- *
2008
- * @param ctx - The deploy plugin context.
2009
- * @param run - The wrangler runner to execute each command through (a CAPTURING runner lets the
2010
- * returned outcome report row/statement counts; a streaming one still works, just without them).
2011
- * @param seed - The resolved seed config (SQL file, optional binding, KV keys to reset).
2012
- * @param scope - The wrangler scope: `--remote` (deploy) or `--local` (dev).
2013
- * @returns The seed outcome (file, target binding, best-effort counts, and the KV keys that were reset).
2014
- * @throws {Error} When no d1 database is configured, or the seed's binding cannot be resolved.
2015
- * @example
2016
- * ```ts
2017
- * const outcome = await runConfiguredSeed(ctx, runWrangler, ctx.config.seed, "--remote");
2018
- * ```
2019
- */
2020
- const runConfiguredSeed = async (ctx, run, seed, scope) => {
2021
- if (!ctx.has("d1")) throw new Error("[moku-worker] seed: no d1 database is configured.");
2022
- const database = resolveD1(ctx, seed.binding);
2023
- const executed = await run([
2024
- "d1",
2025
- "execute",
2026
- database.binding,
2027
- scope,
2028
- "--file",
2029
- seed.file
2030
- ]);
2031
- const resetKv = seed.resetKv ?? [];
2032
- for (const entry of resetKv) await run([
2033
- "kv",
2034
- "key",
2035
- "delete",
2036
- entry.key,
2037
- "--binding",
2038
- entry.binding,
2039
- scope
2040
- ]);
2041
- return {
2042
- file: seed.file,
2043
- binding: database.binding,
2044
- resetKv,
2045
- ...parseSeedStats(typeof executed === "string" ? executed : "")
2046
- };
2047
- };
2048
- //#endregion
2049
- //#region src/plugins/deploy/dev/build.ts
2050
- /**
2051
- * @file deploy plugin — dev site-rebuild resolution.
2052
- *
2053
- * Resolves HOW to rebuild the Moku web site on change: the in-process `webBuild` hook (preferred,
2054
- * fast, typed — passed call-time from the consumer's script or set as a config default) → a
2055
- * `buildCommand` shell string → an auto-detected `scripts/build.ts`. When nothing is configured,
2056
- * dev serves the worker only and says so. Subprocesses inherit the parent env by default.
2057
- * Node-only; never imported by the runtime Worker bundle.
2058
- */
2059
- /** Convention build script auto-detected when no webBuild/buildCommand is configured. */
2060
- const AUTO_DETECT = "scripts/build.ts";
2061
- /**
2062
- * Opportunistically read a numeric `files` count off an arbitrary web build result. A real web
2063
- * build returns its own summary shape (the worker framework cannot know it), so anything without a
2064
- * numeric `files` field reports 0.
2065
- *
2066
- * @param result - The resolved value of a {@link WebBuild} hook (any shape).
2067
- * @returns The `files` count when present and numeric, else 0.
2068
- * @example
2069
- * ```ts
2070
- * fileCountOf({ files: 12 }); // 12
2071
- * fileCountOf({ outDir: "dist", pageCount: 4 }); // 0
2072
- * ```
2073
- */
2074
- const fileCountOf = (result) => {
2075
- if (typeof result === "object" && result !== null && "files" in result) {
2076
- const { files } = result;
2077
- return typeof files === "number" ? files : 0;
2078
- }
2079
- return 0;
2080
- };
2081
- /**
2082
- * Run a shell build command, resolving on a zero exit and rejecting otherwise.
2083
- *
2084
- * @param command - The shell command to run (the consumer's own configured build).
2085
- * @returns Resolves once the command exits successfully.
2086
- * @throws {Error} When the command fails to start or exits non-zero.
2087
- * @example
2088
- * ```ts
2089
- * await runShellBuild("bun run scripts/build.ts");
2090
- * ```
2091
- */
2092
- const runShellBuild = (command) => {
2093
- return new Promise((resolve, reject) => {
2094
- const child = (0, node_child_process.spawn)(command, {
2095
- shell: true,
2096
- stdio: "inherit"
2097
- });
2098
- child.on("error", (error) => {
2099
- reject(/* @__PURE__ */ new Error(`[moku-worker] site build failed to start.\n ${error.message}`));
2100
- });
2101
- child.on("close", (code) => {
2102
- if (code === 0) {
2103
- resolve();
2104
- return;
2105
- }
2106
- reject(/* @__PURE__ */ new Error(`[moku-worker] site build exited with code ${String(code)}.`));
2107
- });
2108
- });
2109
- };
2110
- /**
2111
- * Rebuild the Moku web site using the resolved strategy: the call-time `webBuild` hook (the
2112
- * script-driven path), else the `webBuild` config default, else the `buildCommand` shell string,
2113
- * else an auto-detected `scripts/build.ts`. A hook's result is normalized to a `{ files }` count
2114
- * (0 when the hook reports none, and for the shell path where it is unknown).
2115
- *
2116
- * @param ctx - The deploy plugin context (config + emit).
2117
- * @param webBuild - Optional call-time web build hook (takes precedence over `ctx.config.webBuild`).
2118
- * @returns The rebuilt file count (0 for the shell path / a countless hook).
2119
- * @throws {Error} When the resolved shell build fails.
2120
- * @example
2121
- * ```ts
2122
- * const { files } = await buildSite(ctx, () => web.cli.build());
2123
- * ```
2124
- */
2125
- const buildSite = async (ctx, webBuild) => {
2126
- const hook = webBuild ?? ctx.config.webBuild;
2127
- if (hook !== void 0) return { files: fileCountOf(await hook()) };
2128
- const command = ctx.config.buildCommand || ((0, node_fs.existsSync)(AUTO_DETECT) ? `bun run ${AUTO_DETECT}` : "");
2129
- if (command === "") {
2130
- ctx.emit("dev:error", { message: "No site build configured (pass webBuild or set buildCommand); serving worker only." });
2131
- return { files: 0 };
2132
- }
2133
- await runShellBuild(command);
2134
- return { files: 0 };
2135
- };
2136
- //#endregion
2137
- //#region src/plugins/deploy/dev/watch.ts
2138
- /**
2139
- * @file deploy plugin — debounced filesystem watcher for dev.
2140
- *
2141
- * Watches the top-level directories implied by the config globs (recursive) and fires a debounced
2142
- * change callback with the SET of paths changed in the window (so a burst of edits coalesces into
2143
- * one rebuild that knows every changed file). Uses node:fs.watch — no extra dependency.
2144
- * Node-only; never imported by the runtime Worker bundle.
2145
- */
2146
- /**
2147
- * Derive the set of top-level directories to watch from glob patterns.
2148
- *
2149
- * @param globs - Watch globs (e.g. ["src/**\/*.ts", "public/**\/*"]).
2150
- * @returns The distinct top-level directories (e.g. ["src", "public"]).
2151
- * @example
2152
- * ```ts
2153
- * watchDirectories(["src/**\/*.ts", "public/**\/*"]); // ["src", "public"]
2154
- * ```
2155
- */
2156
- const watchDirectories = (globs) => {
2157
- const directories = /* @__PURE__ */ new Set();
2158
- for (const glob of globs) {
2159
- const globStart = glob.search(/[*?[{]/u);
2160
- const top = (globStart === -1 ? node_path.default.dirname(glob) : glob.slice(0, globStart)).split(/[/\\]/u).find((segment) => segment !== "") ?? ".";
2161
- directories.add(top);
2162
- }
2163
- return [...directories];
2164
- };
2165
- /**
2166
- * Watch the directories implied by `globs` and fire `onChange` (debounced by `debounceMs`) with the
2167
- * distinct set of paths changed within the window. Missing directories are skipped silently.
2168
- *
2169
- * @param globs - Watch globs.
2170
- * @param debounceMs - Coalesce rapid changes into one callback within this window.
2171
- * @param onChange - Called with the changed paths (snapshot of the window) after the debounce settles.
2172
- * @returns A handle whose close() stops all watchers and cancels any pending callback.
2173
- * @example
2174
- * ```ts
2175
- * const handle = watchPaths(["src/**\/*.ts"], 120, paths => rebuild(paths));
2176
- * handle.close();
2177
- * ```
2178
- */
2179
- const watchPaths = (globs, debounceMs, onChange) => {
2180
- let timer;
2181
- const changed = /* @__PURE__ */ new Set();
2182
- const fire = (changedPath) => {
2183
- changed.add(changedPath);
2184
- if (timer !== void 0) clearTimeout(timer);
2185
- timer = setTimeout(() => {
2186
- const batch = [...changed];
2187
- changed.clear();
2188
- onChange(batch);
2189
- }, debounceMs);
2190
- };
2191
- const watchers = [];
2192
- for (const directory of watchDirectories(globs)) {
2193
- if (!(0, node_fs.existsSync)(directory)) continue;
2194
- watchers.push((0, node_fs.watch)(directory, { recursive: true }, (_event, filename) => {
2195
- if (filename !== null) fire(node_path.default.join(directory, filename.toString()));
2196
- }));
2197
- }
2198
- return { close: () => {
2199
- if (timer !== void 0) clearTimeout(timer);
2200
- for (const watcher of watchers) watcher.close();
2201
- } };
2202
- };
2203
- //#endregion
2204
- //#region src/plugins/deploy/dev/runner.ts
2205
- /**
2206
- * @file deploy plugin — dev watch/recompile orchestrator.
2207
- *
2208
- * One long-lived session: cold-build the Moku site, optionally apply local D1 migrations, spawn
2209
- * `wrangler dev --live-reload` ONCE, then watch the site sources and rebuild on change (wrangler's
2210
- * asset server live-reloads the browser). Build failures keep the session serving the last good
2211
- * build. Tears down cleanly on SIGINT. Side-effecting work is injected via DevDeps so the
2212
- * orchestration is unit-testable without real processes, watchers, or signals.
2213
- * Node-only; never imported by the runtime Worker bundle.
2214
- */
2215
- /** Grace period (ms) before escalating a hung `wrangler dev` shutdown from SIGINT to SIGKILL. */
2216
- const STOP_GRACE_MS = 4e3;
2217
- /**
2218
- * Spawn the long-lived `wrangler dev` child (inherits the parent env; non-blocking).
2219
- *
2220
- * `whenExited` settles when the child exits OR fails to spawn — the `error` listener is essential:
2221
- * a missing/unexecutable wrangler emits `error` (not `exit`), which is otherwise unhandled (crashes
2222
- * the process) and would leave `whenExited` pending forever, hanging `stop()`. `stop()` shuts
2223
- * wrangler down the way its own Ctrl+C does — a graceful SIGINT, then a SIGKILL escalation if it has
2224
- * not exited within {@link STOP_GRACE_MS} — resolving only once it is gone; a spawn failure is
2225
- * surfaced as a thrown branded error so the caller can render it. Without the wait, the
2226
- * inherited-stdio child can keep the parent alive after the watcher closes ("stuck on stopping").
2227
- *
2228
- * @param args - The `wrangler dev …` arguments.
2229
- * @returns A handle: `whenExited` (settles on exit/spawn-failure) and `stop()` (resolves once gone).
2230
- * @example
2231
- * ```ts
2232
- * const child = spawnWranglerDev(["dev", "--port", "8787"]);
2233
- * await Promise.race([untilSignal(), child.whenExited]);
2234
- * await child.stop();
2235
- * ```
2236
- */
2237
- const spawnWranglerDev = (args) => {
2238
- const child = (0, node_child_process.spawn)("wrangler", args, { stdio: "inherit" });
2239
- let spawnError;
2240
- const whenExited = new Promise((resolve) => {
2241
- child.once("exit", () => {
2242
- resolve();
2243
- });
2244
- child.once("error", (error) => {
2245
- spawnError = /* @__PURE__ */ new Error(`[moku-worker] Failed to spawn wrangler.\n ${error.message}`);
2246
- resolve();
2247
- });
2248
- });
2249
- const stop = async () => {
2250
- if (spawnError !== void 0) throw spawnError;
2251
- if (child.exitCode !== null || child.signalCode !== null || child.pid === void 0) return;
2252
- child.kill("SIGINT");
2253
- const forceKill = setTimeout(() => child.kill("SIGKILL"), STOP_GRACE_MS);
2254
- await whenExited;
2255
- clearTimeout(forceKill);
2256
- };
2257
- return {
2258
- stop,
2259
- whenExited
2260
- };
2261
- };
2262
- /**
2263
- * Resolve when the user first interrupts the dev session (SIGINT).
2264
- *
2265
- * @returns A promise that settles on the first SIGINT.
2266
- * @example
2267
- * ```ts
2268
- * await waitForSigint();
2269
- * ```
2270
- */
2271
- const waitForSigint = () => {
2272
- return new Promise((resolve) => {
2273
- process.once("SIGINT", () => {
2274
- resolve();
2275
- });
2276
- });
2277
- };
2278
- /**
2279
- * Wall-clock timestamp in ms (extracted so realDevDeps holds only named references).
2280
- *
2281
- * @returns The current time in milliseconds.
2282
- * @example
2283
- * ```ts
2284
- * const t = nowMs();
2285
- * ```
2286
- */
2287
- const nowMs = () => Date.now();
2288
- /**
2289
- * Build the real (side-effecting) dev deps used by api.dev(). Subprocesses inherit the parent env.
2290
- *
2291
- * @returns The production DevDeps (real spawn / fs.watch / SIGINT / Date.now).
2292
- * @example
2293
- * ```ts
2294
- * await runDev(ctx, opts, realDevDeps());
2295
- * ```
2296
- */
2297
- const realDevDeps = () => ({
2298
- build: buildSite,
2299
- runWrangler,
2300
- spawnDev: spawnWranglerDev,
2301
- watch: watchPaths,
2302
- untilSignal: waitForSigint,
2303
- now: nowMs
2304
- });
2305
- /**
2306
- * The d1 bindings to migrate locally — one per configured d1 instance that declares a migrations
2307
- * directory (empty when no d1 plugin is present, or none declares migrations).
2308
- *
2309
- * @param ctx - The deploy plugin context.
2310
- * @returns The d1 binding names with migrations (e.g. `["DB"]`).
2311
- * @example
2312
- * ```ts
2313
- * const bindings = d1MigrationBindings(ctx); // ["DB"]
2314
- * ```
2315
- */
2316
- const d1MigrationBindings = (ctx) => ctx.has("d1") ? ctx.require(d1Plugin).deployManifest().filter((manifest) => manifest.migrations !== void 0).map((manifest) => manifest.binding) : [];
2317
- /**
2318
- * One-line description of a changed-path batch for the `dev:phase rebuild` detail: the single path,
2319
- * or the first path plus a `(+N more)` tail. Empty batches (defensive) read as "site".
2320
- *
2321
- * @param paths - The changed paths the watcher coalesced for this rebuild.
2322
- * @returns The detail string for the rebuild phase event.
2323
- * @example
2324
- * ```ts
2325
- * describeChanges(["src/a.ts", "src/b.css"]); // "src/a.ts (+1 more)"
2326
- * ```
2327
- */
2328
- const describeChanges = (paths) => {
2329
- const [first, ...rest] = paths;
2330
- if (first === void 0) return "site";
2331
- return rest.length === 0 ? first : `${first} (+${String(rest.length)} more)`;
2332
- };
2333
- /**
2334
- * Rebuild the site once for a changed-path batch and announce the result. The FAST path is the
2335
- * incremental `onChange(changedPaths)` hook (e.g. `web.cli.update`) when wired; otherwise it falls
2336
- * back to a full `webBuild()` rebuild (via deps.build) — the prior behavior. A failed rebuild keeps
2337
- * the session alive (it just emits dev:error and serves the last good build). Both paths share one
2338
- * `dev:phase rebuild` → `dev:rebuilt`/`dev:error` envelope so the branded dev TUI is identical.
2339
- *
2340
- * @param ctx - The deploy plugin context.
2341
- * @param deps - The injected dev deps.
2342
- * @param changedPaths - The paths that triggered the rebuild (the watcher's debounced set).
2343
- * @param hooks - The consumer rebuild hooks.
2344
- * @param hooks.webBuild - Full rebuild (used when `onChange` is absent — the prior behavior).
2345
- * @param hooks.onChange - Incremental rebuild for the changed set (the fast path when wired).
2346
- * @returns Resolves once the rebuild attempt completes.
2347
- * @example
2348
- * ```ts
2349
- * await rebuild(ctx, deps, ["src/app.tsx"], { onChange: c => web.cli.update(c) });
2350
- * ```
2351
- */
2352
- const rebuild = async (ctx, deps, changedPaths, hooks) => {
2353
- ctx.emit("dev:phase", {
2354
- phase: "rebuild",
2355
- detail: describeChanges(changedPaths)
2356
- });
2357
- const started = deps.now();
2358
- try {
2359
- let files;
2360
- if (hooks.onChange) files = fileCountOf(await hooks.onChange(changedPaths));
2361
- else files = (await deps.build(ctx, hooks.webBuild)).files;
2362
- ctx.emit("dev:rebuilt", {
2363
- files,
2364
- ms: deps.now() - started
2365
- });
2366
- } catch (error) {
2367
- ctx.emit("dev:error", { message: error instanceof Error ? error.message : String(error) });
2368
- }
2369
- };
2370
- /**
2371
- * Load the configured seed into the LOCAL D1 for a `dev --seed` session: execute the SQL file, then
2372
- * clear the configured cached KV keys so the app rebuilds them from the freshly-seeded rows. The
2373
- * schema already exists (the migrate step above runs first), so this never migrates — the local
2374
- * analogue of the deploy's remote seed, over the same `pluginConfigs.deploy.seed` config.
2375
- *
2376
- * @param ctx - The deploy plugin context.
2377
- * @param deps - The injected dev deps (for the wrangler runner).
2378
- * @returns Resolves once the seed file has executed and every cached KV key is cleared.
2379
- * @throws {Error} When `--seed` is set but no seed is configured under `pluginConfigs.deploy.seed`.
2380
- * @example
2381
- * ```ts
2382
- * await seedLocal(ctx, realDevDeps());
2383
- * ```
2384
- */
2385
- const seedLocal = async (ctx, deps) => {
2386
- const config = ctx.config.seed;
2387
- if (config === void 0) throw new Error("[moku-worker] dev({ seed: true }) but no seed is configured — set pluginConfigs.deploy.seed.");
2388
- ctx.emit("dev:phase", {
2389
- phase: "seed",
2390
- detail: config.file
2391
- });
2392
- const outcome = await runConfiguredSeed(ctx, deps.runWrangler, config, "--local");
2393
- renderSeedSummary((0, _moku_labs_common_cli.createBrandConsole)(), outcome, "local");
2394
- };
2395
- /**
2396
- * Run a long-lived dev session: cold build → (local d1 migrate) → (local seed) → spawn `wrangler
2397
- * dev` → watch + rebuild on change → teardown on signal.
2398
- *
2399
- * @param ctx - The deploy plugin context (config + emit + require/has).
2400
- * @param opts - Optional options.
2401
- * @param opts.port - Local dev port (default 8787).
2402
- * @param opts.webBuild - Cold-build hook (also the per-change rebuild when `onChange` is omitted).
2403
- * @param opts.onChange - Incremental per-change rebuild hook (e.g. `c => web.cli.update(c)`); when
2404
- * set, each debounced change rebuilds only the changed paths instead of a full `webBuild()`.
2405
- * @param opts.seed - Load the configured seed into the LOCAL D1 (+ reset its KV keys) before serving.
2406
- * @param deps - Injected side effects (real ones from realDevDeps in production).
2407
- * @returns Resolves when the session ends (SIGINT).
2408
- * @example
2409
- * ```ts
2410
- * await runDev(ctx, { port: 8787, seed: true, webBuild: () => web.cli.build() }, realDevDeps());
2411
- * ```
2412
- */
2413
- const runDev = async (ctx, opts, deps) => {
2414
- const port = opts?.port ?? 8787;
2415
- const webBuild = opts?.webBuild;
2416
- const onChange = opts?.onChange;
2417
- const seed = opts?.seed === true;
2418
- ctx.emit("dev:phase", {
2419
- phase: "build",
2420
- detail: "site"
2421
- });
2422
- await deps.build(ctx, webBuild);
2423
- const migrationBindings = ctx.config.migrateLocal || seed ? d1MigrationBindings(ctx) : [];
2424
- if (migrationBindings.length > 0) {
2425
- ctx.emit("dev:phase", {
2426
- phase: "migrate",
2427
- detail: "d1 (local)"
2428
- });
2429
- const outcomes = [];
2430
- for (const binding of migrationBindings) {
2431
- const output = await deps.runWrangler([
2432
- "d1",
2433
- "migrations",
2434
- "apply",
2435
- binding,
2436
- "--local"
2437
- ]);
2438
- outcomes.push({
2439
- binding,
2440
- ...parseMigrationsApplied(output)
2441
- });
2442
- }
2443
- renderMigrateSummary((0, _moku_labs_common_cli.createBrandConsole)(), outcomes, "local");
2444
- }
2445
- if (seed) await seedLocal(ctx, deps);
2446
- ctx.emit("dev:phase", {
2447
- phase: "serve",
2448
- detail: `http://localhost:${String(port)}`
2449
- });
2450
- const child = deps.spawnDev([
2451
- "dev",
2452
- "--port",
2453
- String(port),
2454
- "--config",
2455
- ctx.config.configFile,
2456
- "--live-reload"
2457
- ]);
2458
- const watcher = deps.watch(ctx.config.watch, ctx.config.debounceMs, (changedPaths) => rebuild(ctx, deps, changedPaths, {
2459
- webBuild,
2460
- onChange
2461
- }));
2462
- await Promise.race([deps.untilSignal(), child.whenExited]);
2463
- ctx.emit("dev:phase", { phase: "stopping" });
2464
- watcher.close();
2465
- await child.stop();
2466
- };
2467
- //#endregion
2468
- //#region src/plugins/deploy/infra/plan.ts
2469
- /**
2470
- * Decide whether a single API-provisioned resource already exists in the account, recovering its id
2471
- * (kv/d1) when it does. Durable Objects are NOT handled here — they ship with the Worker (`wrangler
2472
- * deploy` + the auto-derived DO migration create the namespace), are never provisioned via the API,
2473
- * and are partitioned into the plan's `ships` bucket by {@link planInfra} before this is ever called.
2474
- *
2475
- * @param resource - The declared (provisionable) resource descriptor.
2476
- * @param existing - The indexed set of resources already in the account.
2477
- * @returns Whether it exists, plus the captured id for kv/d1.
2478
- * @example
2479
- * ```ts
2480
- * checkExisting({ kind: "kv", binding: "SESSIONS" }, existing); // { exists: true, id: "ns123" }
2481
- * ```
2482
- */
2483
- const checkExisting = (resource, existing) => {
2484
- switch (resource.kind) {
2485
- case "kv": {
2486
- const id = existing.kv.get(resource.name);
2487
- return id === void 0 ? { exists: false } : {
2488
- exists: true,
2489
- id
2490
- };
2491
- }
2492
- case "d1": {
2493
- const id = existing.d1.get(resource.name);
2494
- return id === void 0 ? { exists: false } : {
2495
- exists: true,
2496
- id
2497
- };
2498
- }
2499
- case "r2": return { exists: existing.r2.has(resource.name) };
2500
- case "queue": return { exists: existing.queue.has(resource.name) };
2501
- }
2502
- };
2503
- /**
2504
- * Run the read-only infra preflight: resolve the account, list existing resources, diff against
2505
- * the manifest, emit `provision:plan`, and return the plan. Writes nothing.
2506
- *
2507
- * @param ctx - The deploy plugin context (env + emit).
2508
- * @param manifest - The assembled (or caller-supplied) deploy manifest.
2509
- * @returns The infra plan: existing (with ids) vs missing vs ships-with-Worker (Durable Objects).
2510
- * @throws {Error} When the token is absent/invalid or a Cloudflare listing fails.
2511
- * @example
2512
- * ```ts
2513
- * const plan = await planInfra(ctx, manifest);
2514
- * ```
2515
- */
2516
- const planInfra = async (ctx, manifest) => {
2517
- const token = ctx.env.require("CLOUDFLARE_API_TOKEN");
2518
- const pinnedAccountId = ctx.env.get("CLOUDFLARE_ACCOUNT_ID");
2519
- const account = pinnedAccountId ? {
2520
- id: pinnedAccountId,
2521
- name: pinnedAccountId
2522
- } : await resolveAccount(token);
2523
- const kinds = /* @__PURE__ */ new Set();
2524
- for (const resource of manifest.resources) if (resource.kind !== "do") kinds.add(resource.kind);
2525
- const existing = await listExisting(token, account.id, kinds);
2526
- const exists = [];
2527
- const missing = [];
2528
- const ships = [];
2529
- for (const resource of manifest.resources) {
2530
- if (resource.kind === "do") {
2531
- ships.push(resource);
2532
- continue;
2533
- }
2534
- const check = checkExisting(resource, existing);
2535
- if (check.exists) exists.push(check.id === void 0 ? { resource } : {
2536
- resource,
2537
- id: check.id
2538
- });
2539
- else missing.push(resource);
2540
- }
2541
- ctx.emit("provision:plan", {
2542
- exists: exists.length,
2543
- missing: missing.length,
2544
- ships: ships.length,
2545
- account: account.name
2546
- });
2547
- return {
2548
- account: account.name,
2549
- accountId: account.id,
2550
- exists,
2551
- missing,
2552
- ships
2553
- };
2554
- };
2555
- //#endregion
2556
- //#region src/plugins/deploy/naming.ts
2557
- /**
2558
- * @file deploy plugin — stage-aware resource naming.
2559
- *
2560
- * One source of truth for turning a base Cloudflare resource name into its stage variant, so the
2561
- * worker name, the provisioners, the infra existence diff, and the generated wrangler config all
2562
- * agree. Production keeps the base name; every other stage gets a `-${stage}` suffix. Node-only;
2563
- * never imported by the runtime Worker bundle.
2564
- */
2565
- /**
2566
- * Apply the deploy stage to a base Cloudflare resource name: the base name in `production`, else
2567
- * `${base}-${stage}` (e.g. dev → `tracker-db-dev`). Env bindings + DO class names never get the
2568
- * suffix — only provisioned resource names (and the worker name) are stage-qualified.
2569
- *
2570
- * @param base - The base resource name (e.g. "tracker-db").
2571
- * @param stage - The deploy stage (e.g. "production", "development", "dev").
2572
- * @returns The stage-qualified name.
2573
- * @example
2574
- * ```ts
2575
- * stageName("tracker-db", "production"); // "tracker-db"
2576
- * stageName("tracker-db", "dev"); // "tracker-db-dev"
2577
- * ```
2578
- */
2579
- const stageName = (base, stage) => stage === "production" ? base : `${base}-${stage}`;
2580
- //#endregion
2581
- //#region src/plugins/deploy/providers/d1.ts
2582
- /**
2583
- * @file deploy plugin — D1 provisioning adapter.
2584
- *
2585
- * Creates a Cloudflare D1 database via `wrangler d1 create <binding>`, captures the created
2586
- * database id from wrangler's output (so writeWranglerConfig can write a real `database_id`
2587
- * instead of an empty placeholder), and applies migrations when declared.
2588
- * Node-only; never imported by the runtime Worker bundle.
2589
- */
2590
- /**
2591
- * Parse the created D1 database id from `wrangler d1 create` output.
2592
- * Wrangler prints the new binding as JSON (`"database_id": "..."`) or TOML
2593
- * (`database_id = "..."`); the leading boundary keeps the match anchored to the field name.
2594
- *
2595
- * @param output - Raw stdout from the wrangler create command.
2596
- * @returns The database id, or undefined when none is found.
2597
- * @example
2598
- * ```ts
2599
- * parseD1DatabaseId('{ "database_id": "uuid-1234" }'); // "uuid-1234"
2600
- * ```
2601
- */
2602
- const parseD1DatabaseId = (output) => {
2603
- return /(?:^|[\s,{])"?database_id"?\s*[:=]\s*"([^"]+)"/m.exec(output)?.[1];
2604
- };
2605
- /**
2606
- * Provision a D1 database via `wrangler d1 create`, capture its id, and apply migrations.
2607
- *
2608
- * @param manifest - The D1 resource descriptor.
2609
- * @param _ci - Whether running non-interactively.
2610
- * @returns The captured database id when wrangler reported one, else an empty outcome.
2611
- * @example
2612
- * ```ts
2613
- * const { id } = await provisionD1({ kind: "d1", binding: "DB", migrations: "./migrations" }, false);
2614
- * ```
2615
- */
2616
- const provisionD1 = async (manifest, _ci) => {
2617
- const id = parseD1DatabaseId(await runWrangler([
2618
- "d1",
2619
- "create",
2620
- manifest.name
2621
- ]));
2622
- if (manifest.migrations) await runWrangler([
2623
- "d1",
2624
- "migrations",
2625
- "apply",
2626
- manifest.name,
2627
- "--local"
2628
- ]);
2629
- return id ? { id } : {};
2630
- };
2631
- //#endregion
2632
- //#region src/plugins/deploy/providers/do.ts
2633
- /**
2634
- * Provision Durable Object bindings. DOs are config-driven (no `wrangler do create` command
2635
- * exists) — the actual binding entries are written by writeWranglerConfig. This function is
2636
- * a resolved no-op for the dispatch step.
2637
- *
2638
- * @param _manifest - The Durable Objects resource descriptor.
2639
- * @param _ci - Whether running non-interactively.
2640
- * @returns Resolves immediately (DOs are config-only provisioning).
2641
- * @example
2642
- * ```ts
2643
- * await provisionDurableObject({ kind: "do", bindings: { counter: "COUNTER" } }, false);
2644
- * ```
2645
- */
2646
- const provisionDurableObject = async (_manifest, _ci) => {};
2647
- //#endregion
2648
- //#region src/plugins/deploy/providers/kv.ts
2649
- /**
2650
- * @file deploy plugin — KV provisioning adapter.
2651
- *
2652
- * Creates a Cloudflare KV namespace via `wrangler kv namespace create <binding>` and captures
2653
- * the created namespace id from wrangler's output, so writeWranglerConfig can write a real `id`
2654
- * (not an empty placeholder) into the generated wrangler config — otherwise the binding resolves
2655
- * to nothing at runtime. Node-only; never imported by the runtime Worker bundle.
2656
- */
2657
- /**
2658
- * Parse the created KV namespace id from `wrangler kv namespace create` output.
2659
- * Wrangler prints the new binding as JSON (`"id": "..."`) or TOML (`id = "..."`); the leading
2660
- * boundary (start / whitespace / `{` / `,`) keeps the match off a longer identifier such as
2661
- * `kv_namespace_id`.
2662
- *
2663
- * @param output - Raw stdout from the wrangler create command.
2664
- * @returns The namespace id, or undefined when none is found.
2665
- * @example
2666
- * ```ts
2667
- * parseKvNamespaceId('{ "id": "abc123" }'); // "abc123"
2668
- * ```
2669
- */
2670
- const parseKvNamespaceId = (output) => {
2671
- return /(?:^|[\s,{])"?id"?\s*[:=]\s*"([^"]+)"/m.exec(output)?.[1];
2672
- };
2673
- /**
2674
- * Provision a KV namespace via `wrangler kv namespace create` and capture its id.
2675
- *
2676
- * @param manifest - The KV resource descriptor.
2677
- * @param _ci - Whether running non-interactively (passed through; wrangler respects env vars).
2678
- * @returns The captured namespace id when wrangler reported one, else an empty outcome.
2679
- * @example
2680
- * ```ts
2681
- * const { id } = await provisionKv({ kind: "kv", binding: "CACHE" }, false);
2682
- * ```
2683
- */
2684
- const provisionKv = async (manifest, _ci) => {
2685
- const id = parseKvNamespaceId(await runWrangler([
2686
- "kv",
2687
- "namespace",
2688
- "create",
2689
- manifest.name
2690
- ]));
2691
- return id ? { id } : {};
2692
- };
2693
- //#endregion
2694
- //#region src/plugins/deploy/providers/queues.ts
2695
- /**
2696
- * @file deploy plugin — Queues provisioning adapter.
2697
- *
2698
- * Creates one Cloudflare Queue via `wrangler queues create <name>` per queue instance.
2699
- * Node-only; never imported by the runtime Worker bundle.
2700
- */
2701
- /**
2702
- * Provision the queue via `wrangler queues create <name>`.
2703
- *
2704
- * @param manifest - The queue resource descriptor.
2705
- * @param _ci - Whether running non-interactively.
2706
- * @returns Resolves once the queue is created.
2707
- * @example
2708
- * ```ts
2709
- * await provisionQueue({ kind: "queue", name: "tracker-activity", binding: "ACTIVITY" }, false);
2710
- * ```
2711
- */
2712
- const provisionQueue = async (manifest, _ci) => {
2713
- await runWrangler([
2714
- "queues",
2715
- "create",
2716
- manifest.name
2717
- ]);
2718
- };
2719
- //#endregion
2720
- //#region src/plugins/deploy/providers/r2.ts
2721
- /**
2722
- * @file deploy plugin — R2 provisioning + asset upload adapter.
2723
- *
2724
- * Provides two exports:
2725
- * - `provisionR2`: creates an R2 bucket via `wrangler r2 bucket create`.
2726
- * - `uploadDirToR2`: walks a directory recursively and uploads each file via
2727
- * `wrangler r2 object put`, returning the uploaded file count.
2728
- *
2729
- * Node-only; never imported by the runtime Worker bundle.
2730
- */
2731
- /**
2732
- * Provision an R2 bucket via `wrangler r2 bucket create`.
2733
- *
2734
- * @param manifest - The R2 resource descriptor.
2735
- * @param _ci - Whether running non-interactively.
2736
- * @returns Resolves once the bucket is created.
2737
- * @example
2738
- * ```ts
2739
- * await provisionR2({ kind: "r2", name: "tracker-files", binding: "FILES" }, false);
2740
- * ```
2741
- */
2742
- const provisionR2 = async (manifest, _ci) => {
2743
- await runWrangler([
2744
- "r2",
2745
- "bucket",
2746
- "create",
2747
- manifest.name
2748
- ]);
2749
- };
2750
- /**
2751
- * Walk a directory recursively and return all file paths (absolute).
2752
- *
2753
- * @param directory - Directory path to walk.
2754
- * @returns All file paths found under the directory.
2755
- * @example
2756
- * ```ts
2757
- * const files = await walkDir("./public");
2758
- * ```
2759
- */
2760
- const walkDir = async (directory) => {
2761
- const entries = await (0, node_fs_promises.readdir)(directory);
2762
- const results = [];
2763
- for (const entry of entries) {
2764
- const fullPath = node_path.default.join(directory, entry);
2765
- if ((await (0, node_fs_promises.stat)(fullPath)).isDirectory()) {
2766
- const nested = await walkDir(fullPath);
2767
- results.push(...nested);
2768
- } else results.push(fullPath);
2769
- }
2770
- return results;
2771
- };
2772
- /**
2773
- * Upload a directory to an R2 bucket and return the uploaded file count.
2774
- * Each file is uploaded via `wrangler r2 object put <bucket>/<key> --file <path>`.
2775
- *
2776
- * @param bucket - The R2 bucket binding name.
2777
- * @param directory - The directory to upload.
2778
- * @returns The number of files uploaded.
2779
- * @example
2780
- * ```ts
2781
- * const count = await uploadDirToR2("ASSETS", "./public");
2782
- * ```
2783
- */
2784
- const uploadDirToR2 = async (bucket, directory) => {
2785
- const files = await walkDir(directory);
2786
- for (const filePath of files) await runWrangler([
2787
- "r2",
2788
- "object",
2789
- "put",
2790
- `${bucket}/${node_path.default.relative(directory, filePath)}`,
2791
- "--file",
2792
- filePath
2793
- ]);
2794
- return files.length;
2795
- };
2796
- //#endregion
2797
- //#region src/plugins/deploy/providers/index.ts
2798
- /**
2799
- * Dispatch a resource descriptor to the matching provider's provisioning routine.
2800
- *
2801
- * @param resource - The resource descriptor to provision.
2802
- * @param ci - Whether running non-interactively.
2803
- * @returns The provisioning outcome — `{ id }` for kv/d1, `{}` for r2/queue/do.
2804
- * @example
2805
- * ```ts
2806
- * const { id } = await provisionResource({ kind: "kv", binding: "CACHE" }, false);
2807
- * await provisionResource({ kind: "r2", bucket: "ASSETS" }, false); // {}
2808
- * ```
2809
- */
2810
- const provisionResource = async (resource, ci) => {
2811
- switch (resource.kind) {
2812
- case "kv": return provisionKv(resource, ci);
2813
- case "d1": return provisionD1(resource, ci);
2814
- case "r2":
2815
- await provisionR2(resource, ci);
2816
- return {};
2817
- case "queue":
2818
- await provisionQueue(resource, ci);
2819
- return {};
2820
- case "do":
2821
- await provisionDurableObject(resource, ci);
2822
- return {};
2823
- }
2824
- };
2825
- //#endregion
2826
- //#region src/plugins/deploy/tty.ts
2827
- /**
2828
- * @file deploy plugin — TTY detection (isolated so the guided flow is testable).
2829
- *
2830
- * The guided deploy only prompts on an interactive terminal; in a pipe or CI it must never block
2831
- * on stdin. Kept in its own module so tests can mock it without stubbing `process.stdout`.
2832
- * Node-only; never imported by the runtime Worker bundle.
2833
- */
2834
- /**
2835
- * Whether stdout is an interactive TTY (so prompts are safe to show).
2836
- *
2837
- * @returns True when stdout is a terminal.
2838
- * @example
2839
- * ```ts
2840
- * if (stdoutIsTty()) await prompts.confirm("Deploy?");
2841
- * ```
2842
- */
2843
- const stdoutIsTty = () => process.stdout.isTTY === true;
2844
- //#endregion
2845
- //#region src/plugins/deploy/wrangler-config.ts
2846
- /**
2847
- * @file deploy plugin — wrangler config generation + scaffold.
2848
- *
2849
- * Provides two exports:
2850
- * - `writeWranglerConfig`: generates/updates a wrangler.jsonc file from an ExternalManifest.
2851
- * Non-destructive: preserves existing top-level keys not managed by deploy.
2852
- * - `scaffoldWranglerAndCi`: creates a minimal starter wrangler config when the file does not
2853
- * exist yet; idempotent (leaves existing files untouched).
2854
- *
2855
- * Node-only; never imported by the runtime Worker bundle.
2856
- */
2857
- /**
2858
- * Strip JSONC line- and block-comments, then JSON.parse the result.
2859
- *
2860
- * @param source - Raw JSONC file contents.
2861
- * @returns The parsed object.
2862
- * @example
2863
- * ```ts
2864
- * const cfg = parseJsonc('{ "name": "w" } // trailing comment');
2865
- * ```
2866
- */
2867
- const parseJsonc = (source) => {
2868
- const stripped = source.replaceAll(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/gu, "");
2869
- return JSON.parse(stripped);
2870
- };
2871
- /**
2872
- * Build the wrangler `kv_namespaces` array from the manifest's kv resources.
2873
- *
2874
- * @param resources - All resource descriptors from the manifest.
2875
- * @param ids - Captured Cloudflare ids keyed by binding; the entry's `id` is filled from here.
2876
- * @returns One wrangler KV namespace entry per kv resource — real `id` when known, omitted otherwise
2877
- * (wrangler rejects an empty `id`, but a local-dev / freshly-generated config validates without one).
2878
- * @example
2879
- * ```ts
2880
- * const kv = buildKvNamespaces([{ kind: "kv", binding: "CACHE" }], { CACHE: "ns123" });
2881
- * ```
2882
- */
2883
- const buildKvNamespaces = (resources, ids) => resources.filter((resource) => resource.kind === "kv").map((resource) => {
2884
- const id = ids[resource.binding];
2885
- return id ? {
2886
- binding: resource.binding,
2887
- id
2888
- } : { binding: resource.binding };
2889
- });
2890
- /**
2891
- * Build the wrangler `r2_buckets` array from the manifest's r2 resources.
2892
- *
2893
- * @param resources - All resource descriptors from the manifest.
2894
- * @returns One wrangler R2 bucket entry per r2 resource.
2895
- * @example
2896
- * ```ts
2897
- * const r2 = buildR2Buckets([{ kind: "r2", name: "tracker-files", binding: "FILES" }]);
2898
- * ```
2899
- */
2900
- const buildR2Buckets = (resources) => resources.filter((resource) => resource.kind === "r2").map((resource) => ({
2901
- binding: resource.binding,
2902
- bucket_name: resource.name
2903
- }));
2904
- /**
2905
- * Build the wrangler `d1_databases` array from the manifest's d1 resources.
2906
- *
2907
- * @param resources - All resource descriptors from the manifest.
2908
- * @param ids - Captured Cloudflare ids keyed by binding; the entry's `database_id` is filled from here.
2909
- * @returns One wrangler D1 database entry per d1 resource (migrations_dir set when present).
2910
- * @example
2911
- * ```ts
2912
- * const d1 = buildD1Databases([{ kind: "d1", name: "tracker-db", binding: "DB" }], { DB: "uuid-1234" });
2913
- * ```
2914
- */
2915
- const buildD1Databases = (resources, ids) => resources.filter((resource) => resource.kind === "d1").map((resource) => {
2916
- const databaseId = ids[resource.binding];
2917
- const entry = {
2918
- binding: resource.binding,
2919
- database_name: resource.name
2920
- };
2921
- if (databaseId) entry.database_id = databaseId;
2922
- if (resource.migrations) entry.migrations_dir = resource.migrations;
2923
- return entry;
2924
- });
2925
- /**
2926
- * Build the wrangler `queues` section (producers + consumers) from the manifest's queue resources.
2927
- * Every queue is a `producer`; a queue flagged `consumer: true` (it declares an `onMessage` handler)
2928
- * is ALSO registered as a `consumer` so wrangler delivers its messages to this Worker's queue()
2929
- * handler — both locally under `wrangler dev` and in production. Without the consumer entry the
2930
- * handler never runs (the bug that silently drops a queue-driven activity feed). A consumer that
2931
- * sets `maxBatchTimeout` carries it through as wrangler's `max_batch_timeout` (lower delivery latency).
2932
- *
2933
- * @param resources - All resource descriptors from the manifest.
2934
- * @returns The queues section (producers, plus consumers when any), or undefined when there are none.
2935
- * @example
2936
- * ```ts
2937
- * const q = buildQueues([{ kind: "queue", name: "tracker-activity", binding: "ACTIVITY", consumer: true, maxBatchTimeout: 1 }]);
2938
- * ```
2939
- */
2940
- const buildQueues = (resources) => {
2941
- const queueResources = resources.filter((resource) => resource.kind === "queue");
2942
- if (queueResources.length === 0) return void 0;
2943
- const producers = queueResources.map((resource) => ({
2944
- queue: resource.name,
2945
- binding: resource.binding
2946
- }));
2947
- const consumers = queueResources.filter((resource) => resource.consumer === true).map((resource) => {
2948
- const entry = { queue: resource.name };
2949
- if (resource.maxBatchTimeout !== void 0) entry.max_batch_timeout = resource.maxBatchTimeout;
2950
- return entry;
2951
- });
2952
- return consumers.length > 0 ? {
2953
- producers,
2954
- consumers
2955
- } : { producers };
2956
- };
2957
- /**
2958
- * Build the wrangler `durable_objects` bindings section from the manifest's do resources.
2959
- *
2960
- * @param resources - All resource descriptors from the manifest.
2961
- * @returns The durable_objects section, or undefined when there are no do resources.
2962
- * @example
2963
- * ```ts
2964
- * const dobj = buildDurableObjects([{ kind: "do", binding: "COUNTER", className: "Counter" }]);
2965
- * ```
2966
- */
2967
- const buildDurableObjects = (resources) => {
2968
- const doResources = resources.filter((resource) => resource.kind === "do");
2969
- if (doResources.length === 0) return void 0;
2970
- return { bindings: doResources.map((resource) => ({
2971
- name: resource.binding,
2972
- class_name: resource.className
2973
- })) };
2974
- };
2975
- /**
2976
- * Build the auto Durable Object `migrations` from the manifest's do classes. wrangler REQUIRES a
2977
- * migration for every DO class, so this derives a single `v1` migration registering each class as
2978
- * SQLite-backed (the modern default) — the exact section wrangler prompts for when it is missing.
2979
- *
2980
- * @param resources - All resource descriptors from the manifest.
2981
- * @returns A single-entry migrations array, or undefined when there are no do resources.
2982
- * @example
2983
- * ```ts
2984
- * buildMigrations([{ kind: "do", binding: "BOARD", className: "BoardChannel" }]);
2985
- * // [{ tag: "v1", new_sqlite_classes: ["BoardChannel"] }]
2986
- * ```
2987
- */
2988
- const buildMigrations = (resources) => {
2989
- const classes = resources.filter((resource) => resource.kind === "do").map((resource) => resource.className);
2990
- return classes.length > 0 ? [{
2991
- tag: "v1",
2992
- new_sqlite_classes: classes
2993
- }] : void 0;
2994
- };
2995
- /**
2996
- * Extract the already-captured Cloudflare ids (kv namespace `id`, d1 `database_id`) from an existing
2997
- * parsed wrangler config, keyed by binding — so a regeneration (e.g. on `dev`) can preserve ids it
2998
- * isn't handed. Tolerant of a malformed/hand-edited file (skips non-object / non-string entries).
2999
- *
3000
- * @param existing - The parsed existing wrangler config (or `{}`).
3001
- * @returns A binding → id map (empty when the file has none).
3002
- * @example
3003
- * ```ts
3004
- * extractExistingIds({ kv_namespaces: [{ binding: "CACHE", id: "ns1" }] }); // { CACHE: "ns1" }
3005
- * ```
3006
- */
3007
- const extractExistingIds = (existing) => {
3008
- const ids = {};
3009
- const collect = (list, idKey) => {
3010
- if (!Array.isArray(list)) return;
3011
- for (const raw of list) {
3012
- if (raw === null || typeof raw !== "object") continue;
3013
- const entry = raw;
3014
- const binding = entry.binding;
3015
- const id = entry[idKey];
3016
- if (typeof binding === "string" && typeof id === "string" && id.length > 0) ids[binding] = id;
3017
- }
3018
- };
3019
- collect(existing.kv_namespaces, "id");
3020
- collect(existing.d1_databases, "database_id");
3021
- return ids;
3022
- };
3023
- /**
3024
- * Build the extra top-level wrangler keys from the typed deploy config: `entry` → `main`,
3025
- * `nodeCompat` → `compatibility_flags: ["nodejs_compat"]`, `assets` → the wrangler `assets` block
3026
- * (SPA fallback when `spa`), then the raw `wrangler` passthrough last (the escape hatch wins / adds
3027
- * anything else). Pass the result as the `extra` argument to {@link writeWranglerConfig}.
3028
- *
3029
- * @param config - The deploy plugin config.
3030
- * @returns The merged extra wrangler keys.
3031
- * @example
3032
- * ```ts
3033
- * await writeWranglerConfig(file, manifest, ids, wranglerExtra(ctx.config));
3034
- * ```
3035
- */
3036
- const wranglerExtra = (config) => {
3037
- const extra = {};
3038
- if (config.entry !== void 0) extra.main = config.entry;
3039
- if (config.nodeCompat === true) extra.compatibility_flags = ["nodejs_compat"];
3040
- if (config.assets !== void 0) extra.assets = {
3041
- directory: config.assets.directory,
3042
- binding: config.assets.binding,
3043
- ...config.assets.spa === true ? { not_found_handling: "single-page-application" } : {}
3044
- };
3045
- return {
3046
- ...extra,
3047
- ...config.wrangler
3048
- };
3049
- };
3050
- /**
3051
- * Generate/update the wrangler config file from a manifest (non-destructive merge).
3052
- *
3053
- * Layering (last wins): existing file keys → the `extra` passthrough (the app's `wrangler` config:
3054
- * `main`, `compatibility_flags`, `assets`, `vars`, …) → the deploy-managed keys (name,
3055
- * compatibility_date, kv_namespaces, r2_buckets, d1_databases, queues, durable_objects). So the
3056
- * framework always owns the resource sections, the app supplies what the manifest can't derive, and
3057
- * any other hand-written keys survive. Durable Object `migrations` are auto-derived for every DO
3058
- * class (the section wrangler requires) UNLESS the file/passthrough already defines `migrations`.
3059
- *
3060
- * @param configFile - Path to the wrangler config file.
3061
- * @param manifest - The assembled deploy manifest.
3062
- * @param ids - Captured Cloudflare ids keyed by binding (kv namespace id, d1 database id). Defaults
3063
- * to an empty map, in which case `id`/`database_id` are OMITTED (not "") so the generated config
3064
- * still validates for local `dev` (wrangler rejects an empty id); a deploy fills the real ids.
3065
- * @param extra - Extra top-level wrangler keys to merge in (the app's `deploy.wrangler` config).
3066
- * @returns Resolves once the file is written.
3067
- * @example
3068
- * ```ts
3069
- * await writeWranglerConfig("wrangler.jsonc", manifest, { CACHE: "ns123" }, {
3070
- * main: "src/cloudflare/worker.ts",
3071
- * compatibility_flags: ["nodejs_compat"],
3072
- * assets: { directory: "dist/client", binding: "ASSETS" }
3073
- * });
3074
- * ```
3075
- */
3076
- const writeWranglerConfig = async (configFile, manifest, ids = {}, extra = {}) => {
3077
- let existing = {};
3078
- if ((0, node_fs.existsSync)(configFile)) try {
3079
- existing = parseJsonc((0, node_fs.readFileSync)(configFile, "utf8"));
3080
- } catch {
3081
- existing = {};
3082
- }
3083
- const effectiveIds = {
3084
- ...extractExistingIds(existing),
3085
- ...ids
3086
- };
3087
- const kvNamespaces = buildKvNamespaces(manifest.resources, effectiveIds);
3088
- const r2Buckets = buildR2Buckets(manifest.resources);
3089
- const d1Databases = buildD1Databases(manifest.resources, effectiveIds);
3090
- const queues = buildQueues(manifest.resources);
3091
- const durableObjects = buildDurableObjects(manifest.resources);
3092
- const updated = {
3093
- ...existing,
3094
- ...extra,
3095
- name: manifest.name,
3096
- compatibility_date: manifest.compatibilityDate
3097
- };
3098
- if (kvNamespaces.length > 0) updated.kv_namespaces = kvNamespaces;
3099
- if (r2Buckets.length > 0) updated.r2_buckets = r2Buckets;
3100
- if (d1Databases.length > 0) updated.d1_databases = d1Databases;
3101
- if (queues !== void 0) updated.queues = queues;
3102
- if (durableObjects !== void 0) updated.durable_objects = durableObjects;
3103
- const migrations = buildMigrations(manifest.resources);
3104
- if (migrations !== void 0 && updated.migrations === void 0) updated.migrations = migrations;
3105
- await (0, node_fs_promises.writeFile)(configFile, JSON.stringify(updated, void 0, 2));
3106
- };
3107
- /**
3108
- * Scaffold a starting wrangler config and, when ci is set, CI workflow files.
3109
- * Idempotent: an existing config file is left completely untouched.
3110
- *
3111
- * @param configFile - Path to the wrangler config file.
3112
- * @param _ci - Whether to also scaffold CI workflow files.
3113
- * @returns Resolves once scaffolding is written.
3114
- * @example
3115
- * ```ts
3116
- * await scaffoldWranglerAndCi("wrangler.jsonc", true);
3117
- * ```
3118
- */
3119
- const scaffoldWranglerAndCi = async (configFile, _ci) => {
3120
- if ((0, node_fs.existsSync)(configFile)) return;
3121
- const starter = {
3122
- name: "my-worker",
3123
- main: "src/worker.ts",
3124
- compatibility_date: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
3125
- };
3126
- await (0, node_fs_promises.writeFile)(configFile, JSON.stringify(starter, void 0, 2));
3127
- };
3128
- //#endregion
3129
- //#region src/plugins/deploy/api.ts
3130
- /**
3131
- * @file deploy plugin — API factory (run, dev, init, checkInfra, provisionInfra).
3132
- *
3133
- * Pure ctx-taking factory. Assembles the deploy manifest from each resource plugin's own
3134
- * deployManifest() api (never sibling pluginConfigs — design F6), runs an infra preflight
3135
- * (check-before-create + capture real ids), generates/updates the wrangler config, uploads the
3136
- * R2 upload dir, and runs wrangler deploy. Emits only global events: deploy:phase,
3137
- * deploy:complete, provision:resource, provision:plan, provision:skip.
3138
- *
3139
- * Node-only: uses node:child_process (via runner.ts), node:fs (via wrangler-config.ts), and the
3140
- * Cloudflare REST API (via infra/). Never called in the deployed Worker runtime.
3141
- */
3142
- /**
3143
- * Assemble the deploy manifest from each present resource plugin's OWN deployManifest() api (each
3144
- * returns one entry PER configured instance), gated by ctx.has(name) so absent plugins are skipped —
3145
- * never sibling pluginConfigs (F6). The single place the deploy stage is baked into names: the worker
3146
- * name and every provisioned resource `name` are run through {@link stageName} (bindings/DO class
3147
- * names are never suffixed), so provisioning, the existence diff, and the generated config all agree.
3148
- *
3149
- * @param ctx - The deploy plugin context.
3150
- * @param stage - The deploy stage (e.g. "production", "dev") applied to every resource name.
3151
- * @returns The assembled manifest (stage-qualified name, compatibilityDate, per-instance resources).
3152
- * @example
3153
- * ```ts
3154
- * const manifest = assembleManifest(ctx, "production");
3155
- * ```
3156
- */
3157
- const assembleManifest = (ctx, stage) => {
3158
- const resources = [
3159
- ctx.has("storage") ? ctx.require(storagePlugin).deployManifest() : [],
3160
- ctx.has("kv") ? ctx.require(kvPlugin).deployManifest() : [],
3161
- ctx.has("d1") ? ctx.require(d1Plugin).deployManifest() : [],
3162
- ctx.has("queues") ? ctx.require(queuesPlugin).deployManifest() : [],
3163
- ctx.has("durableObjects") ? ctx.require(durableObjectsPlugin).deployManifest() : []
3164
- ].flat();
3165
- return {
3166
- name: stageName(ctx.global.name, stage),
3167
- compatibilityDate: ctx.global.compatibilityDate,
3168
- resources: resources.map((resource) => "name" in resource ? {
3169
- ...resource,
3170
- name: stageName(resource.name, stage)
3171
- } : resource)
3172
- };
3173
- };
3174
- /**
3175
- * Create the still-missing resources one at a time: provision each, fold its captured id (kv/d1) into
3176
- * the shared `ids` map, and announce it via provision:resource. Resilient — a single failure is
3177
- * CAPTURED (not thrown), so one bad resource never aborts the rest. Extracted from {@link applyPlan}
3178
- * so that orchestrator stays flat (skip existing, skip DOs, create missing).
3179
- *
3180
- * @param ctx - The deploy plugin context.
3181
- * @param missing - The resources the plan flagged as not-yet-existing.
3182
- * @param ci - Whether provisioning runs non-interactively (forwarded to each provider).
3183
- * @param ids - The binding → Cloudflare id map, mutated in place with each created kv/d1 id.
3184
- * @returns The created refs and any captured per-resource failures.
3185
- * @example
3186
- * ```ts
3187
- * const { created, failed } = await provisionMissing(ctx, plan.missing, false, ids);
3188
- * ```
3189
- */
3190
- const provisionMissing = async (ctx, missing, ci, ids) => {
3191
- const created = [];
3192
- const failed = [];
3193
- for (const resource of missing) try {
3194
- const { id } = await provisionResource(resource, ci);
3195
- if (id !== void 0 && (resource.kind === "kv" || resource.kind === "d1")) ids[resource.binding] = id;
3196
- created.push(id === void 0 ? { resource } : {
3197
- resource,
3198
- id
3199
- });
3200
- ctx.emit("provision:resource", {
3201
- kind: resource.kind,
3202
- name: resourceName(resource)
3203
- });
3204
- } catch (error) {
3205
- failed.push({
3206
- resource,
3207
- error: error instanceof Error ? error.message : String(error)
3208
- });
3209
- }
3210
- return {
3211
- created,
3212
- failed
3213
- };
3214
- };
3215
- /**
3216
- * Act on an infra plan: skip the resources that already exist (reusing their ids), skip the Durable
3217
- * Objects that ship with the Worker, create the missing ones (capturing each new id), and announce
3218
- * each via provision:skip / :resource. Resilient — a single resource that fails to create is CAPTURED
3219
- * in `failed` (not thrown), so one bad resource (e.g. an invalid bucket name) never aborts the whole
3220
- * run and the caller can report a clear result.
3221
- *
3222
- * @param ctx - The deploy plugin context.
3223
- * @param plan - The infra plan from planInfra (existing vs missing vs ships-with-Worker).
3224
- * @param ci - Whether provisioning runs non-interactively (forwarded to each provider).
3225
- * @returns The provisioning result: created, skipped, bundled, failed, and the merged binding → id map.
3226
- * @example
3227
- * ```ts
3228
- * const { created, failed } = await applyPlan(ctx, plan, false);
3229
- * ```
3230
- */
3231
- const applyPlan = async (ctx, plan, ci) => {
3232
- const ids = {};
3233
- for (const ref of plan.exists) {
3234
- if (ref.id !== void 0 && (ref.resource.kind === "kv" || ref.resource.kind === "d1")) ids[ref.resource.binding] = ref.id;
3235
- ctx.emit("provision:skip", {
3236
- kind: ref.resource.kind,
3237
- name: resourceName(ref.resource)
3238
- });
3239
- }
3240
- for (const resource of plan.ships) ctx.emit("provision:skip", {
3241
- kind: resource.kind,
3242
- name: resourceName(resource)
3243
- });
3244
- const { created, failed } = await provisionMissing(ctx, plan.missing, ci, ids);
3245
- return {
3246
- created,
3247
- skipped: plan.exists,
3248
- bundled: plan.ships,
3249
- failed,
3250
- ids
3251
- };
3252
- };
3253
- /**
3254
- * Sentinel a guided helper resolves to when the user declined recovery — a clean abort the caller
3255
- * turns into a `deploy:phase aborted` + early return, never a thrown (and re-rendered) error.
3256
- */
3257
- const ABORTED = Symbol("deploy:aborted");
3258
- /** Retry guidance shown beneath each step's failure, before the "Retry?" prompt. */
3259
- const HINTS = {
3260
- build: "Web build failed — fix the error above, then retry.",
3261
- provision: "Verify your token's account scopes and Cloudflare's status, then retry.",
3262
- upload: "R2 upload failed — check the bucket and your token's R2 scope, then retry.",
3263
- deploy: "wrangler deploy failed — review the output above, then retry."
3264
- };
3265
- /**
3266
- * Emit the terminal `aborted` phase AND build the matching {@link DeployReport} — the single exit
3267
- * every guided gate/retry funnels through when the user stops the deploy (or auth was never set up).
3268
- * Centralizing it keeps every abort path emitting one consistent line and returning the same shaped
3269
- * report: `status: "aborted"`, both post-steps `"skipped"`, no errors — so a calling script sees a
3270
- * clean stop, never a half-filled success, and the remote-DB migration/seed are guaranteed unrun.
3271
- *
3272
- * @param ctx - The deploy plugin context.
3273
- * @param stage - The resolved deploy stage (echoed into the report).
3274
- * @param startedAt - The run's start timestamp, for the elapsed field.
3275
- * @returns The aborted deploy report.
3276
- * @example
3277
- * ```ts
3278
- * if (declined) return aborted(ctx, stage, startedAt);
3279
- * ```
3280
- */
3281
- const aborted = (ctx, stage, startedAt) => {
3282
- ctx.emit("deploy:phase", { phase: "aborted" });
3283
- return {
3284
- ok: false,
3285
- status: "aborted",
3286
- stage,
3287
- migration: "skipped",
3288
- seed: "skipped",
3289
- elapsedMs: Date.now() - startedAt,
3290
- errors: []
3291
- };
3292
- };
3293
- /**
3294
- * The full guided token setup shown after an auth failure on a TTY. Offers to walk the user through
3295
- * it, and when accepted: prints WHERE to create the Cloudflare token (dashboard URL, which template,
3296
- * the exact permissions to add) AND scaffolds a ready-to-fill `.env.local` — the same guidance baked
3297
- * in as comments — for the user to paste the token + account id into (never clobbering an existing
3298
- * file). Always ends pointing at the re-run.
3299
- *
3300
- * @param ctx - The deploy plugin context.
3301
- * @param ui - The branded console to render the guidance through.
3302
- * @param confirm - The yes/no prompt.
3303
- * @returns Resolves once the guidance (and optional `.env.local` scaffold) has been rendered.
3304
- * @example
3305
- * ```ts
3306
- * await guidedTokenSetup(ctx, createBrandConsole(), confirm);
3307
- * ```
3308
- */
3309
- const guidedTokenSetup = async (ctx, ui, confirm) => {
3310
- if (!await confirm("Set up Cloudflare credentials now? (guided)")) {
3311
- ui.info("Set CLOUDFLARE_API_TOKEN in .env.local, then run `deploy` again.");
3312
- return;
3313
- }
3314
- const manifest = assembleManifest(ctx, ctx.global.stage);
3315
- renderAuthSetup(ui, requiredToken(manifest));
3316
- const { created, path } = await ensureEnvLocal(process.cwd(), envLocalScaffold(manifest));
3317
- 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.`);
3318
- };
3319
- /**
3320
- * Verify the `.env` token, turning a missing/invalid token into a guided recovery on a TTY: surface
3321
- * WHY auth failed, then walk the user through {@link guidedTokenSetup} (where to create the token +
3322
- * scaffold a `.env.local`). The env is snapshotted at app start, so a freshly-pasted token only
3323
- * takes effect on a NEW run. In CI/pipes the branded error re-throws (fail-fast).
3324
- *
3325
- * @param ctx - The deploy plugin context.
3326
- * @param deps - Interactivity + the confirm prompt.
3327
- * @returns True when the token verified; false when the user must set it up and re-run.
3328
- * @throws {Error} Re-throws the branded auth error in CI / non-interactive runs.
3329
- * @example
3330
- * ```ts
3331
- * if (!(await guidedAuth(ctx, { interactive, confirm }))) return;
3332
- * ```
3333
- */
3334
- const guidedAuth = async (ctx, deps) => {
3335
- try {
3336
- await verifyAuth(ctx);
3337
- return true;
3338
- } catch (error) {
3339
- if (!deps.interactive) throw error;
3340
- const ui = (0, _moku_labs_common_cli.createBrandConsole)();
3341
- ui.error(error instanceof Error ? error.message : String(error));
3342
- await guidedTokenSetup(ctx, ui, deps.confirm);
3343
- return false;
3344
- }
3345
- };
3346
- /**
3347
- * Run one external pipeline step with interactive recovery: on failure, render the branded error +
3348
- * an actionable hint, then offer to retry — looping until the step succeeds or the user declines.
3349
- * A decline resolves to {@link ABORTED} (a clean abort the caller surfaces), so the error is shown
3350
- * once, not re-rendered downstream. In CI/pipes the first failure re-throws (fail-fast). The step
3351
- * MUST be safe to re-run (idempotent).
3352
- *
3353
- * @param step - The async step to run (e.g. the web build, the R2 upload, `wrangler deploy`).
3354
- * @param hint - One-line guidance shown beneath the error before the retry prompt.
3355
- * @param deps - Interactivity + the confirm prompt.
3356
- * @returns The step's resolved value once it succeeds, or {@link ABORTED} when a retry is declined.
3357
- * @throws {Error} Re-throws the step's error in CI / non-interactive runs.
3358
- * @example
3359
- * ```ts
3360
- * const url = await guidedStep(() => runWrangler(args), "wrangler deploy failed …", deps);
3361
- * if (url === ABORTED) return;
3362
- * ```
3363
- */
3364
- const guidedStep = async (step, hint, deps) => {
3365
- for (;;) try {
3366
- return await step();
3367
- } catch (error) {
3368
- if (!deps.interactive) throw error;
3369
- const ui = (0, _moku_labs_common_cli.createBrandConsole)();
3370
- ui.error(error instanceof Error ? error.message : String(error));
3371
- ui.info(hint);
3372
- if (!await deps.confirm("Retry?")) return ABORTED;
3373
- }
3374
- };
3375
- /**
3376
- * Run the read-only infra preflight with interactive recovery: a network/scope failure fails fast in
3377
- * CI, or (on a TTY) renders the error + hint and offers a retry. Resolves the plan, or {@link ABORTED}
3378
- * when the user declines the retry.
3379
- *
3380
- * @param ctx - The deploy plugin context.
3381
- * @param manifest - The assembled (or caller-supplied) deploy manifest.
3382
- * @param deps - Interactivity + the confirm prompt.
3383
- * @returns The infra plan, or {@link ABORTED} when a preflight retry is declined.
3384
- * @throws {Error} Re-throws the preflight error in CI / non-interactive runs.
3385
- * @example
3386
- * ```ts
3387
- * const plan = await guidedPlan(ctx, manifest, deps);
3388
- * if (plan === ABORTED) return;
3389
- * ```
3390
- */
3391
- const guidedPlan = async (ctx, manifest, deps) => {
3392
- for (;;) try {
3393
- return await planInfra(ctx, manifest);
3394
- } catch (error) {
3395
- if (!deps.interactive) throw error;
3396
- const ui = (0, _moku_labs_common_cli.createBrandConsole)();
3397
- ui.error(error instanceof Error ? error.message : String(error));
3398
- ui.info(HINTS.provision);
3399
- if (!await deps.confirm("Retry?")) return ABORTED;
3400
- }
3401
- };
3402
- /**
3403
- * Plan + provision the infra with branded panels and interactive recovery. Each attempt RE-PLANS
3404
- * (a resource created by a prior attempt is seen as existing and skipped — retries stay idempotent),
3405
- * renders the plan panel (what will be created vs already exists), confirms the create gate, creates
3406
- * the resources, then renders the result panel (created / skipped / failed). When some resources
3407
- * FAIL it offers to retry just those (interactive) or fails fast (CI). Resolves to {@link ABORTED}
3408
- * when the user declines the gate or a retry.
3409
- *
3410
- * @param ctx - The deploy plugin context.
3411
- * @param manifest - The assembled (or caller-supplied) deploy manifest.
3412
- * @param ci - Whether provisioning runs non-interactively (forwarded to each provider).
3413
- * @param deps - Interactivity + the confirm prompt.
3414
- * @returns The provisioning result (all created/skipped), or {@link ABORTED} when the user declined.
3415
- * @throws {Error} Re-throws a plan error, or throws on a provision failure, in CI / non-interactive runs.
3416
- * @example
3417
- * ```ts
3418
- * const provisioned = await guidedProvision(ctx, manifest, ci, deps);
3419
- * if (provisioned === ABORTED) return;
3420
- * ```
3421
- */
3422
- const guidedProvision = async (ctx, manifest, ci, deps) => {
3423
- for (;;) {
3424
- const plan = await guidedPlan(ctx, manifest, deps);
3425
- if (plan === ABORTED) return ABORTED;
3426
- const ui = (0, _moku_labs_common_cli.createBrandConsole)();
3427
- renderPlan(ui, plan);
3428
- if (plan.missing.length > 0 && !await deps.confirm(`Create ${String(plan.missing.length)} missing resource(s) in "${plan.account}"?`)) return ABORTED;
3429
- const result = await applyPlan(ctx, plan, ci);
3430
- renderProvisionResult(ui, result);
3431
- if (result.failed.length === 0) return result;
3432
- if (!deps.interactive) throw new Error(`[moku-worker] ${String(result.failed.length)} resource(s) failed to provision.`);
3433
- if (!await deps.confirm("Retry the failed resource(s)?")) return ABORTED;
3434
- }
3435
- };
3436
- /**
3437
- * Build the web site first (when a hook is wired in), so its assets exist before the R2 upload and
3438
- * `wrangler deploy`. Emits the `build · web` phase, then runs the build with interactive retry.
3439
- *
3440
- * @param ctx - The deploy plugin context.
3441
- * @param webBuild - The web build hook, or undefined when none is wired (then this is a no-op).
3442
- * @param deps - Interactivity + the confirm prompt.
3443
- * @returns True to continue the pipeline; false when the user declined a build retry (abort).
3444
- * @example
3445
- * ```ts
3446
- * if (!(await guidedWebBuild(ctx, webBuild, deps))) return emitAborted(ctx);
3447
- * ```
3448
- */
3449
- const guidedWebBuild = async (ctx, webBuild, deps) => {
3450
- if (webBuild === void 0) return true;
3451
- ctx.emit("deploy:phase", {
3452
- phase: "build",
3453
- detail: "web"
3454
- });
3455
- return await guidedStep(() => webBuild(), HINTS.build, deps) !== ABORTED;
3456
- };
3457
- /**
3458
- * Upload the R2 directory when a bucket declares an upload source, with interactive retry. Emits the
3459
- * `upload · N files` phase on success; a no-op (and emits nothing) when no bucket declares an upload.
3460
- *
3461
- * @param ctx - The deploy plugin context.
3462
- * @param manifest - The assembled (or caller-supplied) deploy manifest.
3463
- * @param deps - Interactivity + the confirm prompt.
3464
- * @returns True to continue the pipeline; false when the user declined an upload retry (abort).
3465
- * @example
3466
- * ```ts
3467
- * if (!(await guidedUpload(ctx, manifest, deps))) return emitAborted(ctx);
3468
- * ```
3469
- */
3470
- const guidedUpload = async (ctx, manifest, deps) => {
3471
- const r2 = manifest.resources.find((resource) => resource.kind === "r2");
3472
- if (!r2?.upload) return true;
3473
- const bucket = r2.name;
3474
- const uploadDir = r2.upload;
3475
- const count = await guidedStep(() => uploadDirToR2(bucket, uploadDir), HINTS.upload, deps);
3476
- if (count === ABORTED) return false;
3477
- ctx.emit("deploy:phase", {
3478
- phase: "upload",
3479
- detail: `${String(count)} files`
3480
- });
3481
- return true;
3482
- };
3483
- /**
3484
- * The final deploy step: confirm the target (guided only), run `wrangler deploy` with interactive
3485
- * retry, then emit deploy:complete. Returns the deployed URL, or undefined when the target gate or a
3486
- * deploy retry is declined (so the caller renders the summary panel only on a real success).
3487
- *
3488
- * @param ctx - The deploy plugin context.
3489
- * @param manifest - The assembled (or caller-supplied) deploy manifest.
3490
- * @param stage - The resolved deploy stage (for the confirm prompt).
3491
- * @param deps - Interactivity + the confirm prompt.
3492
- * @returns The deployed URL once live; undefined when the user declined the gate or a retry (abort).
3493
- * @example
3494
- * ```ts
3495
- * const url = await guidedDeployStep(ctx, manifest, stage, deps);
3496
- * if (url === undefined) return emitAborted(ctx);
3497
- * ```
3498
- */
3499
- const guidedDeployStep = async (ctx, manifest, stage, deps) => {
3500
- if (!await deps.confirm(`Deploy "${manifest.name}" to ${stage}?`)) return void 0;
3501
- ctx.emit("deploy:phase", { phase: "deploy" });
3502
- const url = await guidedStep(() => runWrangler([
3503
- "deploy",
3504
- "--config",
3505
- ctx.config.configFile
3506
- ]), HINTS.deploy, deps);
3507
- if (url === ABORTED) return void 0;
3508
- ctx.emit("deploy:complete", { url });
3509
- return url;
3510
- };
3511
- /**
3512
- * Apply pending D1 migrations to the REMOTE database for every configured d1 instance that ships a
3513
- * migrations dir — the generic, deploy-owned analogue of `wrangler d1 migrations apply <binding>
3514
- * --remote`. The wrangler config was written earlier in the pipeline, so each binding resolves. The
3515
- * caller runs this only AFTER a successful deploy, so a deploy that never happened never migrates a
3516
- * remote DB. CAPTURES wrangler's output (the raw TUI is hidden; {@link parseMigrationsApplied} turns
3517
- * it into the branded panel's facts); throws on the first non-zero exit (the caller folds it into the
3518
- * report, where the captured error is still surfaced).
3519
- *
3520
- * @param ctx - The deploy plugin context.
3521
- * @returns The per-database migration outcomes (one per d1 instance that declares migrations).
3522
- * @example
3523
- * ```ts
3524
- * const outcomes = await applyRemoteMigrations(ctx);
3525
- * ```
3526
- */
3527
- const applyRemoteMigrations = async (ctx) => {
3528
- if (!ctx.has("d1")) return [];
3529
- const outcomes = [];
3530
- for (const database of ctx.require(d1Plugin).deployManifest()) if (database.migrations !== void 0) {
3531
- const output = await runWrangler([
3532
- "d1",
3533
- "migrations",
3534
- "apply",
3535
- database.binding,
3536
- "--remote"
3537
- ]);
3538
- outcomes.push({
3539
- binding: database.binding,
3540
- ...parseMigrationsApplied(output)
3541
- });
3542
- }
3543
- return outcomes;
3544
- };
3545
- /**
3546
- * Render a post-deploy step's failure as a branded line and capture its message into `errors` —
3547
- * folding the failure into the report instead of throwing, so a deploy that already went live still
3548
- * yields a complete, honest report when a later remote step (migration/seed) fails.
3549
- *
3550
- * @param ui - The branded console to render the error through.
3551
- * @param errors - The accumulator the captured message is pushed into.
3552
- * @param error - The thrown error (or value) to brand and capture.
3553
- * @returns The captured (branded) message.
3554
- * @example
3555
- * ```ts
3556
- * captureFailure(ui, errors, new Error("[moku-worker] seed failed"));
3557
- * ```
3558
- */
3559
- const captureFailure = (ui, errors, error) => {
3560
- const message = error instanceof Error ? error.message : String(error);
3561
- ui.error(message);
3562
- errors.push(message);
3563
- return message;
3564
- };
3565
- /**
3566
- * Run the post-deploy remote steps — REACHED ONLY ON A SUCCESSFUL DEPLOY (every gate in `run` returns
3567
- * early before here), so a deploy that never happened never touches a remote DB. Applies remote D1
3568
- * migrations (when requested), then loads the configured seed (when requested) — but skips the seed
3569
- * if the migration it depends on failed. Each step's failure is RENDERED inline and CAPTURED in
3570
- * `errors` (never thrown), so one failed step still yields a complete, honest report.
3571
- *
3572
- * @param ctx - The deploy plugin context.
3573
- * @param want - Which post-steps the caller requested.
3574
- * @param want.migration - Whether to apply pending remote D1 migrations.
3575
- * @param want.seed - Whether to load the configured remote seed (and reset its KV keys).
3576
- * @returns The migration + seed outcomes and any captured branded errors.
3577
- * @example
3578
- * ```ts
3579
- * const post = await runPostDeploy(ctx, { migration: true, seed: true });
3580
- * ```
3581
- */
3582
- const runPostDeploy = async (ctx, want) => {
3583
- const ui = (0, _moku_labs_common_cli.createBrandConsole)();
3584
- const errors = [];
3585
- let migration = "skipped";
3586
- if (want.migration) try {
3587
- ctx.emit("deploy:phase", {
3588
- phase: "migrate",
3589
- detail: "remote D1"
3590
- });
3591
- const outcomes = await applyRemoteMigrations(ctx);
3592
- migration = "applied";
3593
- if (outcomes.length > 0) renderMigrateSummary(ui, outcomes, "remote");
3594
- } catch (error) {
3595
- migration = "failed";
3596
- captureFailure(ui, errors, error);
3597
- }
3598
- let seed = "skipped";
3599
- if (want.seed && migration === "failed") {
3600
- seed = "failed";
3601
- captureFailure(ui, errors, /* @__PURE__ */ new Error("[moku-worker] seed skipped — the remote migration it depends on failed."));
3602
- } else if (want.seed) {
3603
- const config = ctx.config.seed;
3604
- if (config === void 0) {
3605
- seed = "failed";
3606
- captureFailure(ui, errors, /* @__PURE__ */ new Error("[moku-worker] deploy({ seed: true }) but no seed is configured — set pluginConfigs.deploy.seed."));
3607
- } else try {
3608
- ctx.emit("deploy:phase", {
3609
- phase: "seed",
3610
- detail: config.file
3611
- });
3612
- const outcome = await runConfiguredSeed(ctx, runWrangler, config, "--remote");
3613
- seed = "applied";
3614
- renderSeedSummary(ui, outcome, "remote");
3615
- } catch (error) {
3616
- seed = "failed";
3617
- captureFailure(ui, errors, error);
3618
- }
3619
- }
3620
- return {
3621
- migration,
3622
- seed,
3623
- errors
3624
- };
3625
- };
3626
- /**
3627
- * Create the deploy api. Assembles the manifest from each resource plugin's own deployManifest(),
3628
- * runs an infra preflight (check-before-create + id capture), generates config, uploads, and runs
3629
- * `wrangler deploy`, emitting global deploy events along the way.
3630
- *
3631
- * @param ctx - Plugin context (own config + require + has + emit + global + env).
3632
- * @returns The app.deploy api: run / dev / init / checkInfra / provisionInfra.
3633
- * @example
3634
- * ```ts
3635
- * const api = createDeployApi(ctx);
3636
- * await api.run();
3637
- * ```
3638
- */
3639
- const createDeployApi = (ctx) => ({
3640
- /**
3641
- * Run the full deploy pipeline: detect → preflight (check-before-create) → provision (only the
3642
- * missing) → wrangler-config (with real ids) → upload → deploy, then — ONLY on a successful
3643
- * deploy — the requested post-deploy remote steps (migration, seed). When opts.manifest is
3644
- * supplied it is used verbatim (universal path).
3645
- *
3646
- * On a TTY the run is GUIDED end to end: each gate is confirmed, and every failure is recovered
3647
- * interactively rather than thrown — a missing/invalid token offers a `.env.local` scaffold, and
3648
- * the build, infra, upload, and `wrangler deploy` steps offer a retry. In CI/pipes it fails fast
3649
- * (no prompt, the first error propagates to the branded CLI handler).
3650
- *
3651
- * Resolves to a {@link DeployReport}. Every abort path (a declined gate, or auth never set up)
3652
- * returns `status: "aborted"` BEFORE the post-deploy steps, so `migration`/`seed` run only when
3653
- * the worker actually went live — a first `deploy --seed` with no token aborts cleanly instead of
3654
- * falling through to a raw `wrangler … --remote` auth error.
3655
- *
3656
- * @param opts - Optional run options.
3657
- * @param opts.ci - CI/automated mode: never prompts, auto-confirms every gate, fails fast. When
3658
- * false (the default) and stdout is a TTY, the deploy is guided — each gate is confirmed and
3659
- * failures are recovered interactively. Falls back to ctx.config.ci when omitted.
3660
- * @param opts.stage - Target stage; suffixes resource names (`production` = bare). Falls back to the app stage.
3661
- * @param opts.webBuild - Build the web site first (e.g. `() => webApp.cli.build()`), before deploy.
3662
- * @param opts.manifest - Caller-supplied manifest (bypasses deployManifest() assembly).
3663
- * @param opts.migration - After a successful deploy, apply pending remote D1 migrations for every
3664
- * configured d1 instance that ships migrations. Skipped (not attempted) on an aborted deploy.
3665
- * @param opts.seed - After a successful deploy (+ migration), load the seed configured under
3666
- * `pluginConfigs.deploy.seed` into the remote D1 and reset its cached KV keys. Skipped on abort.
3667
- * @returns The deploy report (status, url, resource tally, migration/seed outcome, errors).
3668
- * @example
3669
- * ```ts
3670
- * const report = await api.run({ webBuild: () => web.cli.build(), migration: true, seed: true });
3671
- * if (!report.ok) process.exitCode = 1; // aborted or a post-step failed
3672
- * ```
3673
- */
3674
- async run(opts) {
3675
- const ci = opts?.ci ?? ctx.config.ci;
3676
- const stage = opts?.stage ?? ctx.global.stage;
3677
- const interactive = !ci && stdoutIsTty();
3678
- const deps = {
3679
- interactive,
3680
- confirm: interactive ? (0, _moku_labs_common_cli.createBrandPrompts)().confirm : async (_question) => true
3681
- };
3682
- const startedAt = Date.now();
3683
- ctx.emit("deploy:phase", { phase: "auth" });
3684
- if (!await guidedAuth(ctx, deps)) return aborted(ctx, stage, startedAt);
3685
- if (!await guidedWebBuild(ctx, opts?.webBuild ?? ctx.config.webBuild, deps)) return aborted(ctx, stage, startedAt);
3686
- ctx.emit("deploy:phase", { phase: "detect" });
3687
- const manifest = opts?.manifest ?? assembleManifest(ctx, stage);
3688
- ctx.emit("deploy:phase", { phase: "provision" });
3689
- const provisioned = await guidedProvision(ctx, manifest, ci, deps);
3690
- if (provisioned === ABORTED) return aborted(ctx, stage, startedAt);
3691
- ctx.emit("deploy:phase", { phase: "wrangler-config" });
3692
- await writeWranglerConfig(ctx.config.configFile, manifest, provisioned.ids, wranglerExtra(ctx.config));
3693
- if (!await guidedUpload(ctx, manifest, deps)) return aborted(ctx, stage, startedAt);
3694
- const url = await guidedDeployStep(ctx, manifest, stage, deps);
3695
- if (url === void 0) return aborted(ctx, stage, startedAt);
3696
- const resources = {
3697
- created: provisioned.created.length,
3698
- exists: provisioned.skipped.length,
3699
- bundled: provisioned.bundled.length,
3700
- failed: provisioned.failed.length
3701
- };
3702
- renderDeploySummary((0, _moku_labs_common_cli.createBrandConsole)(), {
3703
- url,
3704
- stage,
3705
- ...resources,
3706
- elapsedMs: Date.now() - startedAt
3707
- });
3708
- const post = await runPostDeploy(ctx, {
3709
- migration: opts?.migration === true,
3710
- seed: opts?.seed === true
3711
- });
3712
- return {
3713
- ok: post.errors.length === 0,
3714
- status: post.errors.length === 0 ? "deployed" : "failed",
3715
- stage,
3716
- url,
3717
- resources,
3718
- migration: post.migration,
3719
- seed: post.seed,
3720
- elapsedMs: Date.now() - startedAt,
3721
- errors: post.errors
3722
- };
3723
- },
3724
- /**
3725
- * Start a long-lived local dev session: cold-build the Moku site, spawn `wrangler dev
3726
- * --live-reload`, and watch the site sources — rebuilding on change (wrangler live-reloads the
3727
- * browser). Resolves on SIGINT.
3728
- *
3729
- * @param opts - Optional options.
3730
- * @param opts.port - Local dev port (default 8787).
3731
- * @param opts.stage - Stage for the generated config's resource names (defaults to the app stage).
3732
- * @param opts.webBuild - Cold-build the web site (e.g. `() => webApp.cli.build()`); also the
3733
- * per-change rebuild when `onChange` is omitted.
3734
- * @param opts.onChange - Incremental per-change rebuild (e.g. `changes => webApp.cli.update(changes)`).
3735
- * @param opts.seed - Load the configured seed (`pluginConfigs.deploy.seed`) into the LOCAL D1 and
3736
- * reset its cached KV keys before serving — the local analogue of `deploy({ seed: true })`.
3737
- * @returns Resolves when the dev session ends.
3738
- * @example
3739
- * ```ts
3740
- * await api.dev({ port: 8787, seed: true, webBuild: () => web.cli.build(), onChange: c => web.cli.update(c) });
3741
- * ```
3742
- */
3743
- async dev(opts) {
3744
- const manifest = assembleManifest(ctx, opts?.stage ?? ctx.global.stage);
3745
- await writeWranglerConfig(ctx.config.configFile, manifest, {}, wranglerExtra(ctx.config));
3746
- await runDev(ctx, opts, realDevDeps());
3747
- },
3748
- /**
3749
- * Execute a SQL file against a configured D1 database via `wrangler d1 execute` — for seeding dev
3750
- * data. Local by default (applies that database's migrations first so the file's tables exist);
3751
- * `opts.remote` seeds Cloudflare (schema is applied by `deploy`). Generates the wrangler config up
3752
- * front so the binding resolves even on a first run. CAPTURES wrangler's output and renders a
3753
- * branded "Migrated" / "Seeded" summary (the raw migration/execute TUI is hidden) so the command
3754
- * reads the same as the rest of the deploy UX; a failure still surfaces the real wrangler error.
3755
- *
3756
- * @param sqlFile - Path to the SQL file to execute (e.g. "db/seed.sql").
3757
- * @param opts - Optional options.
3758
- * @param opts.stage - Stage for the generated config's resource names (defaults to the app stage).
3759
- * @param opts.binding - The d1 binding to target when more than one is configured (e.g. "DB").
3760
- * @param opts.remote - Seed the remote (Cloudflare) D1 instead of the local one.
3761
- * @returns Resolves once wrangler finishes executing the file and the summary is rendered.
3762
- * @example
3763
- * ```ts
3764
- * await api.seed("db/seed.sql"); // local default d1 (migrate, then execute)
3765
- * await api.seed("db/seed.sql", { remote: true }); // remote d1
3766
- * ```
3767
- */
3768
- async seed(sqlFile, opts) {
3769
- if (!ctx.has("d1")) throw new Error("[moku-worker] seed: no d1 database is configured.");
3770
- const stage = opts?.stage ?? ctx.global.stage;
3771
- await writeWranglerConfig(ctx.config.configFile, assembleManifest(ctx, stage), {}, wranglerExtra(ctx.config));
3772
- const target = resolveD1(ctx, opts?.binding);
3773
- const scope = opts?.remote === true ? "--remote" : "--local";
3774
- const where = opts?.remote === true ? "remote" : "local";
3775
- const ui = (0, _moku_labs_common_cli.createBrandConsole)();
3776
- if (scope === "--local" && target.migrations !== void 0) {
3777
- const migrated = await runWrangler([
3778
- "d1",
3779
- "migrations",
3780
- "apply",
3781
- target.binding,
3782
- "--local"
3783
- ]);
3784
- renderMigrateSummary(ui, [{
3785
- binding: target.binding,
3786
- ...parseMigrationsApplied(migrated)
3787
- }], where);
3788
- }
3789
- const executed = await runWrangler([
3790
- "d1",
3791
- "execute",
3792
- target.binding,
3793
- scope,
3794
- "--file",
3795
- sqlFile
3796
- ]);
3797
- renderSeedSummary(ui, {
3798
- file: sqlFile,
3799
- binding: target.binding,
3800
- resetKv: [],
3801
- ...parseSeedStats(executed)
3802
- }, where);
3803
- },
3804
- /**
3805
- * Scaffold a starting wrangler config (and CI files when ci is set).
3806
- * Idempotent: an existing config file is left untouched.
3807
- *
3808
- * @param opts - Optional options.
3809
- * @param opts.ci - Also scaffold CI workflow files.
3810
- * @returns Resolves once scaffolding is written.
3811
- * @example
3812
- * ```ts
3813
- * await api.init({ ci: true });
3814
- * ```
3815
- */
3816
- init: async (opts) => {
3817
- await scaffoldWranglerAndCi(ctx.config.configFile, opts?.ci ?? ctx.config.ci);
3818
- },
3819
- /**
3820
- * Read-only infra preflight: assemble the manifest, resolve the account, list what exists in
3821
- * Cloudflare, diff, emit provision:plan, and return the plan. Writes nothing.
3822
- *
3823
- * @returns The infra plan (existing vs missing resources, with captured ids).
3824
- * @example
3825
- * ```ts
3826
- * const plan = await api.checkInfra();
3827
- * ```
3828
- */
3829
- checkInfra: () => planInfra(ctx, assembleManifest(ctx, ctx.global.stage)),
3830
- /**
3831
- * Create only the resources missing from the plan (skipping existing), capturing each id.
3832
- *
3833
- * @param plan - A plan produced by checkInfra().
3834
- * @returns The provisioning result: created, skipped, and the merged id map.
3835
- * @example
3836
- * ```ts
3837
- * const { created } = await api.provisionInfra(await api.checkInfra());
3838
- * ```
3839
- */
3840
- provisionInfra: (plan) => applyPlan(ctx, plan, ctx.config.ci),
3841
- /**
3842
- * Verify the `.env` Cloudflare API token (must be active) and resolve its account; emits
3843
- * auth:verified. Throws a branded error pointing at `auth setup` when absent/invalid/inactive.
3844
- *
3845
- * @returns The verified auth status (account + id).
3846
- * @example
3847
- * ```ts
3848
- * const { account } = await api.verifyAuth();
3849
- * ```
3850
- */
3851
- verifyAuth: () => verifyAuth(ctx),
3852
- /**
3853
- * Derive the minimum Cloudflare API token this app needs from its manifest (pure, no network).
3854
- *
3855
- * @returns The token requirement (full set + groups to add to the stock template).
3856
- * @example
3857
- * ```ts
3858
- * const { toAdd } = api.requiredToken();
3859
- * ```
3860
- */
3861
- requiredToken: () => requiredToken(assembleManifest(ctx, ctx.global.stage)),
3862
- /**
3863
- * Derive the REDUCED CI/automation redeploy token permission groups from the manifest (pure, no
3864
- * network). Used by the branded `auth setup` renderer to show the scoped CI token alongside the
3865
- * full LOCAL one.
3866
- *
3867
- * @returns The CI token permission groups (read-mostly, manifest-scoped).
3868
- * @example
3869
- * ```ts
3870
- * const groups = api.ciToken();
3871
- * ```
3872
- */
3873
- ciToken: () => ciToken(assembleManifest(ctx, ctx.global.stage)),
3874
- /**
3875
- * Render the `auth setup` guidance from the derived token requirement (pure, no network).
3876
- *
3877
- * @returns The rendered instruction text.
3878
- * @example
3879
- * ```ts
3880
- * const text = api.tokenInstructions();
3881
- * ```
3882
- */
3883
- tokenInstructions: () => tokenInstructions(assembleManifest(ctx, ctx.global.stage)),
3884
- /**
3885
- * Run an arbitrary wrangler command, streaming its output (the branded CLI escape hatch).
3886
- *
3887
- * @param args - The wrangler arguments.
3888
- * @returns Resolves once wrangler exits.
3889
- * @example
3890
- * ```ts
3891
- * await api.wrangler(["kv", "namespace", "list"]);
3892
- * ```
3893
- */
3894
- wrangler: (args) => runWranglerInherit(args)
3895
- });
3896
- /**
3897
- * Complex tier (node-only) — build-time deploy orchestrator over the five resource plugins.
3898
- *
3899
- * Assembles each resource plugin's deployManifest() via ctx.require, provisions resources,
3900
- * generates/updates wrangler config, uploads the R2 upload dir, and runs wrangler deploy.
3901
- * Also supports a universal path: run({ manifest }) uses a caller-supplied manifest verbatim.
3902
- *
3903
- * Emits only the global events `deploy:phase`, `deploy:complete`, and `provision:resource`
3904
- * (declared in WorkerEvents — no per-plugin events block).
3905
- *
3906
- * @see README.md
3907
- */
3908
- const deployPlugin = createPlugin("deploy", {
3909
- config: {
3910
- configFile: "wrangler.jsonc",
3911
- ci: false,
3912
- watch: ["src/**/*.{ts,tsx,css}", "public/**/*"],
3913
- buildCommand: "",
3914
- migrateLocal: true,
3915
- debounceMs: 120
3916
- },
3917
- depends: [
3918
- storagePlugin,
3919
- kvPlugin,
3920
- d1Plugin,
3921
- queuesPlugin,
3922
- durableObjectsPlugin
3923
- ],
3924
- api: (ctx) => createDeployApi(ctx)
3925
- });
3926
- //#endregion
3927
- //#region src/plugins/cli/args.ts
3928
- /**
3929
- * @file cli plugin — argv parsing helpers (isolated so they unit-test without a real process).
3930
- *
3931
- * `deploy`/`dev` resolve the target stage from the command line (`--stage dev`) so a consumer never
3932
- * hardcodes it. The dev PORT is not parsed here — it comes only from the `dev()` argument (no hidden
3933
- * argv/config resolution). Pure: takes an argv array, reads no globals. Node-only tooling.
3934
- */
3935
- /**
3936
- * Extract a `--stage` value from a single token (and the token after it, for the spaced form).
3937
- *
3938
- * @param token - The current argv token.
3939
- * @param next - The following argv token (the value, for the `--stage dev` spaced form).
3940
- * @returns The raw string value when this token is a stage flag, else undefined.
3941
- * @example
3942
- * ```ts
3943
- * stageValueFrom("--stage=dev", undefined); // "dev"
3944
- * stageValueFrom("--stage", "dev"); // "dev"
3945
- * stageValueFrom("--other", "x"); // undefined
3946
- * ```
3947
- */
3948
- const stageValueFrom = (token, next) => {
3949
- const inline = /^--stage=(.+)$/u.exec(token);
3950
- if (inline) return inline[1];
3951
- if (token === "--stage") return next;
3952
- };
3953
- /**
3954
- * Parse a `--stage <name>` / `--stage=<name>` flag out of an argv array — the deploy/dev stage that
3955
- * drives the resource-name suffix (e.g. `tracker-db-dev`). Returns the first non-empty value, or
3956
- * undefined so the caller can fall back to the app's configured stage.
3957
- *
3958
- * @param argv - The argv array to scan (the caller passes the process argv).
3959
- * @returns The parsed stage string, or undefined when no `--stage` flag is present.
3960
- * @example
3961
- * ```ts
3962
- * parseStageArg(["bun", "scripts/deploy.ts", "--stage", "dev"]); // "dev"
3963
- * parseStageArg(["bun", "scripts/deploy.ts"]); // undefined
3964
- * ```
3965
- */
3966
- const parseStageArg = (argv) => {
3967
- for (let index = 0; index < argv.length; index++) {
3968
- const token = argv[index];
3969
- if (token === void 0) continue;
3970
- const raw = stageValueFrom(token, argv[index + 1]);
3971
- if (raw !== void 0 && raw.length > 0) return raw;
3972
- }
3973
- };
3974
- //#endregion
3975
- //#region src/plugins/cli/api.ts
3976
- /**
3977
- * @file cli plugin — API factory (dev, deploy, auth, doctor).
3978
- */
3979
- /**
3980
- * Builds app.cli.* over the deploy plugin (via ctx.require(deployPlugin)). `dev`/`deploy` resolve
3981
- * their args (port from `--port`; guided unless `ci`) then delegate, catching any failure into a
3982
- * branded `✗` line + non-zero exit; the read-only verbs (auth/doctor/whoami) render in Moku style.
3983
- *
3984
- * @param ctx - CLI plugin context (own config + typed require to deployPlugin).
3985
- * @returns The cli API object (dev, deploy, auth, doctor, whoami, wrangler).
3986
- * @example
3987
- * ```ts
3988
- * const api = createCliApi(ctx);
3989
- * await api.dev({ webBuild: () => web.cli.build() }); // → deploy.dev({ port })
3990
- * await api.deploy({ ci: true }); // → deploy.run({ ci: true })
3991
- * ```
3992
- */
3993
- const createCliApi = (ctx) => ({
3994
- /**
3995
- * Run the Worker locally. The dev port comes ONLY from `opts.port` — the consumer passes it (e.g.
3996
- * parsed from its own CLI flags in scripts/dev.ts); when omitted it defaults to wrangler's 8787.
3997
- * There is no hidden argv/config port resolution. Prints a branded dev-session banner, then
3998
- * delegates to deploy.dev; a `webBuild` hook (e.g. `() => webApp.cli.build()`) wires the web build
3999
- * into the dev loop so the site recompiles on change. A failure renders a branded `✗` line +
4000
- * non-zero exit, not a stack.
4001
- *
4002
- * @param opts - Optional local dev options.
4003
- * @param opts.port - Local dev port to bind. Defaults to 8787 when omitted.
4004
- * @param opts.stage - Stage for the generated wrangler config; falls back to `--stage` then the app stage.
4005
- * @param opts.webBuild - Cold-build the web site (e.g. `() => webApp.cli.build()`); also the
4006
- * per-change rebuild when `onChange` is omitted.
4007
- * @param opts.onChange - Incremental per-change rebuild (e.g. `changes => webApp.cli.update(changes)`),
4008
- * so each change rebuilds only the changed paths instead of a full `webBuild()`.
4009
- * @param opts.seed - Load the configured seed (`pluginConfigs.deploy.seed`) into the LOCAL D1 and
4010
- * reset its cached KV keys before serving — the local analogue of `deploy({ seed: true })`.
4011
- * @returns Resolves when the dev session ends.
4012
- * @example
4013
- * ```ts
4014
- * await api.dev({ port: 7878, seed: true, webBuild: () => web.cli.build(), onChange: c => web.cli.update(c) });
4015
- * ```
4016
- */
4017
- async dev(opts) {
4018
- const ui = (0, _moku_labs_common_cli.createBrandConsole)();
4019
- ui.lockup({
4020
- wordmark: "moku worker",
4021
- label: "dev session"
4022
- });
4023
- const stage = opts?.stage ?? parseStageArg(process.argv);
4024
- try {
4025
- await ctx.require(deployPlugin).dev({
4026
- ...opts?.port === void 0 ? {} : { port: opts.port },
4027
- ...stage === void 0 ? {} : { stage },
4028
- ...opts?.webBuild ? { webBuild: opts.webBuild } : {},
4029
- ...opts?.onChange ? { onChange: opts.onChange } : {},
4030
- ...opts?.seed ? { seed: opts.seed } : {}
4031
- });
4032
- ui.check(true, "dev session stopped cleanly");
4033
- } catch (error) {
4034
- ui.error(error instanceof Error ? error.message : String(error));
4035
- process.exitCode = 1;
4036
- }
4037
- },
4038
- /**
4039
- * One-command Cloudflare deploy; forwards opts verbatim to deploy.run, then — only on a successful
4040
- * deploy — the requested post-deploy migration/seed. Guided/interactive by default; `{ ci: true }`
4041
- * runs the automated path (CI). A `webBuild` hook builds the web site first (before `wrangler
4042
- * deploy`). RETURNS the structured {@link DeployReport}; on a failure it also renders a branded `✗`
4043
- * line + sets a non-zero exit code (matching cli.auth/doctor), never a raw stack trace.
4044
- *
4045
- * @param opts - Optional deploy options.
4046
- * @param opts.ci - Automated mode: never prompts, auto-confirms. Omit/false → guided on a TTY.
4047
- * @param opts.stage - Target stage (resource-name suffix); falls back to `--stage` then the app stage.
4048
- * @param opts.webBuild - Build the web site first (e.g. `() => webApp.cli.build()`), before deploy.
4049
- * @param opts.migration - Apply pending remote D1 migrations after a successful deploy (skipped on abort).
4050
- * @param opts.seed - Load the configured remote seed (`pluginConfigs.deploy.seed`) after a
4051
- * successful deploy (+ migration); skipped on an aborted deploy.
4052
- * @returns The deploy report (status, url, resource tally, migration/seed outcome, errors).
4053
- * @example
4054
- * ```ts
4055
- * const report = await api.deploy({ webBuild: () => web.cli.build(), migration: true, seed: true });
4056
- * if (report.status === "aborted") return; // creds not set up yet — nothing shipped
4057
- * ```
4058
- */
4059
- async deploy(opts) {
4060
- const stage = opts?.stage ?? parseStageArg(process.argv);
4061
- try {
4062
- const report = await ctx.require(deployPlugin).run({
4063
- ...opts,
4064
- ...stage === void 0 ? {} : { stage }
4065
- });
4066
- if (report.status === "failed") process.exitCode = 1;
4067
- return report;
4068
- } catch (error) {
4069
- const message = error instanceof Error ? error.message : String(error);
4070
- (0, _moku_labs_common_cli.createBrandConsole)().error(message);
4071
- process.exitCode = 1;
4072
- return {
4073
- ok: false,
4074
- status: "failed",
4075
- stage: stage ?? "production",
4076
- migration: "skipped",
4077
- seed: "skipped",
4078
- elapsedMs: 0,
4079
- errors: [message]
4080
- };
4081
- }
4082
- },
4083
- /**
4084
- * Seed a configured D1 database from a SQL file (delegates to deploy.seed). Local by default;
4085
- * `opts.remote` seeds Cloudflare. The stage is resolved from a `--stage <name>` CLI flag (so
4086
- * `bun run dev --seed --stage dev` seeds the dev database). A failure renders a branded `✗` line
4087
- * and sets a non-zero exit code rather than throwing.
4088
- *
4089
- * @param sqlFile - Path to the SQL file to execute (e.g. "db/seed.sql").
4090
- * @param opts - Optional options.
4091
- * @param opts.binding - The d1 binding to target when more than one is configured (e.g. "DB").
4092
- * @param opts.remote - Seed the remote (Cloudflare) D1 instead of the local one.
4093
- * @returns Resolves once the seed completes (or after a failure is rendered).
4094
- * @example
4095
- * ```ts
4096
- * await app.cli.seed("db/seed.sql"); // before app.cli.dev(...)
4097
- * ```
4098
- */
4099
- async seed(sqlFile, opts) {
4100
- const ui = (0, _moku_labs_common_cli.createBrandConsole)();
4101
- ui.lockup({
4102
- wordmark: "moku worker",
4103
- label: "seed"
4104
- });
4105
- const stage = parseStageArg(process.argv);
4106
- try {
4107
- await ctx.require(deployPlugin).seed(sqlFile, {
4108
- ...opts,
4109
- ...stage === void 0 ? {} : { stage }
4110
- });
4111
- ui.check(true, "seeded", sqlFile);
4112
- } catch (error) {
4113
- ui.error(error instanceof Error ? error.message : String(error));
4114
- process.exitCode = 1;
4115
- }
4116
- },
4117
- /**
4118
- * Verify the `.env` token (no sub) or print the config-derived token guidance (`"setup"`),
4119
- * rendered in Moku style. `setup` works without a token; verify reports the resolved account.
4120
- *
4121
- * @param sub - Pass "setup" to print guidance; omit to verify the current token.
4122
- * @returns Resolves once the check or guidance render completes.
4123
- * @example
4124
- * ```ts
4125
- * await api.auth("setup"); // print what token to create
4126
- * await api.auth(); // verify the current token
4127
- * ```
4128
- */
4129
- async auth(sub) {
4130
- const deploy = ctx.require(deployPlugin);
4131
- const ui = (0, _moku_labs_common_cli.createBrandConsole)();
4132
- if (sub === "setup") {
4133
- renderAuthSetup(ui, deploy.requiredToken(), { ci: deploy.ciToken() });
4134
- return;
4135
- }
4136
- try {
4137
- const status = await deploy.verifyAuth();
4138
- ui.check(true, "token valid", `account "${status.account}" (${status.accountId})`);
4139
- } catch (error) {
4140
- ui.error(error instanceof Error ? error.message : String(error));
4141
- }
4142
- },
4143
- /**
4144
- * One-shot preflight report: token + account (verifyAuth) then infra drift (checkInfra),
4145
- * each as a branded check line. Stops after the token check when auth fails.
4146
- *
4147
- * @returns Resolves once the report is printed.
4148
- * @example
4149
- * ```ts
4150
- * await api.doctor();
4151
- * ```
4152
- */
4153
- async doctor() {
4154
- const deploy = ctx.require(deployPlugin);
4155
- const ui = (0, _moku_labs_common_cli.createBrandConsole)();
4156
- ui.heading("doctor");
4157
- let tokenOk = false;
4158
- try {
4159
- const status = await deploy.verifyAuth();
4160
- tokenOk = true;
4161
- ui.check(true, "token", `valid · account "${status.account}" (${status.accountId})`);
4162
- } catch (error) {
4163
- ui.check(false, "token", error instanceof Error ? error.message : String(error));
4164
- }
4165
- if (!tokenOk) {
4166
- ui.line("Run `auth setup` for the exact token to create.");
4167
- return;
4168
- }
4169
- try {
4170
- const plan = await deploy.checkInfra();
4171
- ui.check(true, "infra", `${plan.exists.length} exist, ${plan.missing.length} to create in "${plan.account}"`);
4172
- } catch (error) {
4173
- ui.check(false, "infra", error instanceof Error ? error.message : String(error));
4174
- }
4175
- },
4176
- /**
4177
- * Print the resolved Cloudflare account for the current `.env` token.
4178
- *
4179
- * @returns Resolves once the account summary is printed.
4180
- * @example
4181
- * ```ts
4182
- * await api.whoami();
4183
- * ```
4184
- */
4185
- async whoami() {
4186
- const ui = (0, _moku_labs_common_cli.createBrandConsole)();
4187
- try {
4188
- const status = await ctx.require(deployPlugin).verifyAuth();
4189
- ui.check(true, "account", `${status.account} (${status.accountId})`);
4190
- } catch (error) {
4191
- ui.error(error instanceof Error ? error.message : String(error));
4192
- }
4193
- },
4194
- /**
4195
- * Run an arbitrary wrangler command through the branded CLI (escape hatch). Streams its output.
4196
- *
4197
- * @param args - The wrangler arguments.
4198
- * @returns Resolves once wrangler exits.
4199
- * @example
4200
- * ```ts
4201
- * await api.wrangler(["kv", "namespace", "list"]);
4202
- * ```
4203
- */
4204
- async wrangler(args) {
4205
- (0, _moku_labs_common_cli.createBrandConsole)().heading(`wrangler ${args.join(" ")}`);
4206
- await ctx.require(deployPlugin).wrangler(args);
4207
- }
4208
- });
4209
- //#endregion
4210
- //#region src/plugins/cli/handlers.ts
4211
- /** Divider drawn before the native `wrangler dev` TUI so the moku preamble reads as one section. */
4212
- const WRANGLER_DIVIDER = ` ── wrangler ${"─".repeat(48)}`;
4213
- /** Deploy phases that are a slow, opaque wait (captured output) — worth a live spinner on a TTY. */
4214
- const SPINNER_PHASES = new Set(["upload", "deploy"]);
4215
- /** Braille spinner glyphs; advance one per tick. */
4216
- const SPINNER_FRAMES = [
4217
- "⠋",
4218
- "⠙",
4219
- "⠹",
4220
- "⠸",
4221
- "⠼",
4222
- "⠴",
4223
- "⠦",
4224
- "⠧",
4225
- "⠇",
4226
- "⠏"
4227
- ];
4228
- /** Spinner tick interval (ms). */
4229
- const SPINNER_TICK_MS = 80;
4230
- /** Carriage-return + blanks + carriage-return that wipes the transient spinner line before settling. */
4231
- const SPINNER_CLEAR = `\r${" ".repeat(72)}\r`;
4232
- /**
4233
- * Builds the hook handlers that turn global deploy events into a live progress TUI.
4234
- * Each logs a clean, prefix-free message via `ctx.log`; the branded log sink (installed
4235
- * by the cli plugin's onInit from `@moku-labs/common/cli`) adds the `›` marker, brand
4236
- * color, and stderr routing. Pure observers — print and return; never mutate state,
4237
- * never block the deploy pipeline (fire-and-forget, spec/07 §3,§4).
4238
- *
4239
- * @param ctx - CLI plugin context with injected log core API.
4240
- * @returns Hook map for the deploy/dev phase + completion events (provision detail is panel-rendered).
4241
- * @example
4242
- * ```ts
4243
- * const hooks = createCliHooks(ctx);
4244
- * hooks["deploy:phase"]({ phase: "detect" }); // logs "detect" → renders " › detect"
4245
- * hooks["dev:phase"]({ phase: "serve", detail: "http://localhost:8787" }); // "serve · …"
4246
- * hooks["deploy:complete"](); // settles the deploy spinner; the "Deployed" panel renders separately
4247
- * ```
4248
- */
4249
- const createCliHooks = (ctx) => {
4250
- const ui = (0, _moku_labs_common_cli.createBrandConsole)();
4251
- const { palette } = ui;
4252
- let spinnerTimer;
4253
- let spinnerLabel;
4254
- const stopSpinner = () => {
4255
- if (spinnerTimer !== void 0) {
4256
- clearInterval(spinnerTimer);
4257
- spinnerTimer = void 0;
4258
- }
4259
- if (spinnerLabel !== void 0) {
4260
- process.stdout.write(SPINNER_CLEAR);
4261
- ctx.log.info(spinnerLabel);
4262
- spinnerLabel = void 0;
4263
- }
4264
- };
4265
- const startSpinner = (label) => {
4266
- spinnerLabel = label;
4267
- let frame = 0;
4268
- const text = `${label} …`;
4269
- spinnerTimer = setInterval(() => {
4270
- const glyph = SPINNER_FRAMES[frame % SPINNER_FRAMES.length] ?? SPINNER_FRAMES[0];
4271
- frame += 1;
4272
- process.stdout.write(`\r ${palette.pink(glyph)} ${palette.dim(text)}`);
4273
- }, SPINNER_TICK_MS);
4274
- };
4275
- return {
4276
- /**
4277
- * Render one pipeline phase. Quick phases print a clean line ("phase" / "phase · detail"); the
4278
- * slow opaque waits (upload / deploy) animate a branded spinner on a TTY, settling to a line when
4279
- * the next phase or completion arrives. Off a TTY every phase is a plain line (unchanged).
4280
- *
4281
- * @param p - The deploy:phase event payload.
4282
- * @example
4283
- * ```ts
4284
- * handler({ phase: "detect" }); // "detect"
4285
- * handler({ phase: "deploy" }); // spins on a TTY, else "deploy"
4286
- * ```
4287
- */
4288
- "deploy:phase"(p) {
4289
- stopSpinner();
4290
- const label = p.detail ? `${p.phase} · ${p.detail}` : p.phase;
4291
- if (process.stdout.isTTY === true && SPINNER_PHASES.has(p.phase)) startSpinner(label);
4292
- else ctx.log.info(label);
4293
- },
4294
- /**
4295
- * Log one dev-session phase: "phase" or "phase · detail".
4296
- *
4297
- * @param p - The dev:phase event payload.
4298
- * @example
4299
- * ```ts
4300
- * handler({ phase: "serve", detail: "http://localhost:8787" }); // "serve · http://localhost:8787"
4301
- * ```
4302
- */
4303
- "dev:phase"(p) {
4304
- ctx.log.info(p.detail ? `${p.phase} · ${p.detail}` : p.phase);
4305
- if (p.phase === "serve") ui.line(WRANGLER_DIVIDER);
4306
- },
4307
- /**
4308
- * Log the site rebuild result: "site <n> files · <ms>ms" (omits the count when unknown).
4309
- *
4310
- * @param p - The dev:rebuilt event payload.
4311
- * @example
4312
- * ```ts
4313
- * handler({ files: 12, ms: 240 }); // "site 12 files · 240ms"
4314
- * handler({ files: 0, ms: 240 }); // "site · 240ms"
4315
- * ```
4316
- */
4317
- "dev:rebuilt"(p) {
4318
- ctx.log.info(p.files > 0 ? `site ${String(p.files)} files · ${String(p.ms)}ms` : `site · ${String(p.ms)}ms`);
4319
- },
4320
- /**
4321
- * Log a non-fatal dev build failure via warn (the session keeps serving the last good build).
4322
- *
4323
- * @param p - The dev:error event payload.
4324
- * @example
4325
- * ```ts
4326
- * handler({ message: "build failed" }); // warn "build failed"
4327
- * ```
4328
- */
4329
- "dev:error"(p) {
4330
- ctx.log.warn(p.message);
4331
- },
4332
- /**
4333
- * Settle the final deploy spinner. The deployed URL + summary now render as a branded panel (the
4334
- * deploy plugin's renderDeploySummary), so the cli no longer logs a duplicate `deployed → url`.
4335
- *
4336
- * @example
4337
- * ```ts
4338
- * handler(); // clears the `deploy` spinner; the "Deployed" panel follows from the deploy plugin
4339
- * ```
4340
- */
4341
- "deploy:complete"() {
4342
- stopSpinner();
4343
- }
4344
- };
4345
- };
4346
- /**
4347
- * Standard tier (node-only) — developer-facing CLI surface.
4348
- *
4349
- * Mounts `app.cli.dev()` and `app.cli.deploy()` as thin passthroughs to deployPlugin.
4350
- * Hooks subscribe to the global deploy:phase / provision:resource / deploy:complete events
4351
- * and print a live progress TUI via the injected ctx.log core API.
4352
- *
4353
- * Inline lambdas on `api`/`hooks` preserve event-name inference so the hook map keys
4354
- * are constrained to `WorkerEvents` keys (spec/15 §5).
4355
- *
4356
- * @see README.md
4357
- */
4358
- const cliPlugin = createPlugin("cli", {
4359
- depends: [deployPlugin],
4360
- config: {},
4361
- onInit: (ctx) => {
4362
- ctx.log.clearSinks();
4363
- ctx.log.addSink((0, _moku_labs_common_cli.brandedSink)("info"));
4364
- },
4365
- api: (ctx) => createCliApi(ctx),
4366
- hooks: (ctx) => createCliHooks(ctx)
4367
- });
4368
- //#endregion
4369
- Object.defineProperty(exports, "bindingsPlugin", {
4370
- enumerable: true,
4371
- get: function() {
4372
- return bindingsPlugin;
4373
- }
4374
- });
4375
- Object.defineProperty(exports, "cliPlugin", {
4376
- enumerable: true,
4377
- get: function() {
4378
- return cliPlugin;
4379
- }
4380
- });
4381
- Object.defineProperty(exports, "coreConfig", {
4382
- enumerable: true,
4383
- get: function() {
4384
- return coreConfig;
4385
- }
4386
- });
4387
- Object.defineProperty(exports, "createCore", {
4388
- enumerable: true,
4389
- get: function() {
4390
- return createCore;
4391
- }
4392
- });
4393
- Object.defineProperty(exports, "createPlugin", {
4394
- enumerable: true,
4395
- get: function() {
4396
- return createPlugin;
4397
- }
4398
- });
4399
- Object.defineProperty(exports, "d1Plugin", {
4400
- enumerable: true,
4401
- get: function() {
4402
- return d1Plugin;
4403
- }
4404
- });
4405
- Object.defineProperty(exports, "defineDurableObject", {
4406
- enumerable: true,
4407
- get: function() {
4408
- return defineDurableObject;
4409
- }
4410
- });
4411
- Object.defineProperty(exports, "deployPlugin", {
4412
- enumerable: true,
4413
- get: function() {
4414
- return deployPlugin;
4415
- }
4416
- });
4417
- Object.defineProperty(exports, "durableObjectsPlugin", {
4418
- enumerable: true,
4419
- get: function() {
4420
- return durableObjectsPlugin;
4421
- }
4422
- });
4423
- Object.defineProperty(exports, "kvPlugin", {
4424
- enumerable: true,
4425
- get: function() {
4426
- return kvPlugin;
4427
- }
4428
- });
4429
- Object.defineProperty(exports, "queuesPlugin", {
4430
- enumerable: true,
4431
- get: function() {
4432
- return queuesPlugin;
4433
- }
4434
- });
4435
- Object.defineProperty(exports, "stagePlugin", {
4436
- enumerable: true,
4437
- get: function() {
4438
- return stagePlugin;
4439
- }
4440
- });
4441
- Object.defineProperty(exports, "storagePlugin", {
4442
- enumerable: true,
4443
- get: function() {
4444
- return storagePlugin;
4445
- }
4446
- });