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