@moku-labs/worker 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.cjs CHANGED
@@ -28,6 +28,169 @@ let node_fs_promises = require("node:fs/promises");
28
28
  let node_path = require("node:path");
29
29
  node_path = __toESM(node_path, 1);
30
30
  let node_fs = require("node:fs");
31
+ //#region src/plugins/deploy/infra/cloudflare.ts
32
+ /**
33
+ * @file deploy plugin — Cloudflare REST discovery client (infra preflight).
34
+ *
35
+ * Lists what already exists in a Cloudflare account so the deploy pipeline can create only the
36
+ * missing resources (idempotent provisioning) and recover real ids for existing kv/d1 bindings.
37
+ * Authenticated with the `.env` API token (CLOUDFLARE_API_TOKEN) — never an interactive login.
38
+ * Uses the global `fetch`; node-only, never imported by the runtime Worker bundle.
39
+ */
40
+ const API_BASE = "https://api.cloudflare.com/client/v4";
41
+ /**
42
+ * GET a Cloudflare API path with the bearer token and unwrap the `result`.
43
+ *
44
+ * @param token - The Cloudflare API token (CLOUDFLARE_API_TOKEN).
45
+ * @param path - API path beneath the v4 base (e.g. "/accounts").
46
+ * @returns The unwrapped `result` payload, typed by the caller.
47
+ * @throws {Error} When the HTTP request fails or the API reports `success: false`.
48
+ * @example
49
+ * ```ts
50
+ * const accounts = await cfGet<Array<{ id: string }>>(token, "/accounts");
51
+ * ```
52
+ */
53
+ const cfGet = async (token, path) => {
54
+ const response = await fetch(`${API_BASE}${path}`, { headers: {
55
+ Authorization: `Bearer ${token}`,
56
+ "Content-Type": "application/json"
57
+ } });
58
+ const body = await response.json();
59
+ if (!response.ok || !body.success) {
60
+ const detail = body.errors?.map((error) => error.message).join("; ") || `HTTP ${response.status}`;
61
+ throw new Error(`[moku-worker] Cloudflare API request failed (${path}): ${detail}`);
62
+ }
63
+ return body.result;
64
+ };
65
+ /**
66
+ * Resolve the Cloudflare account (id + display name) accessible to the token. Used when the
67
+ * consumer did not pin CLOUDFLARE_ACCOUNT_ID; the first accessible account is chosen.
68
+ *
69
+ * @param token - The Cloudflare API token.
70
+ * @returns The resolved account id and name.
71
+ * @throws {Error} When the token can access no account.
72
+ * @example
73
+ * ```ts
74
+ * const { id, name } = await resolveAccount(token);
75
+ * ```
76
+ */
77
+ const resolveAccount = async (token) => {
78
+ const first = (await cfGet(token, "/accounts"))[0];
79
+ if (!first) throw new Error("[moku-worker] No Cloudflare account is accessible with this API token.");
80
+ return {
81
+ id: first.id,
82
+ name: first.name
83
+ };
84
+ };
85
+ /**
86
+ * List every kv / d1 / r2 / queue resource that already exists in the account (one request per
87
+ * kind, in parallel), indexed for the preflight diff.
88
+ *
89
+ * @param token - The Cloudflare API token.
90
+ * @param accountId - The Cloudflare account id to scope the listings to.
91
+ * @returns The existing resources, indexed by kind.
92
+ * @throws {Error} When any listing request fails.
93
+ * @example
94
+ * ```ts
95
+ * const existing = await listExisting(token, accountId);
96
+ * if (existing.kv.has("SESSIONS")) { ... }
97
+ * ```
98
+ */
99
+ const listExisting = async (token, accountId) => {
100
+ const base = `/accounts/${accountId}`;
101
+ const [kv, d1, r2, queues] = await Promise.all([
102
+ cfGet(token, `${base}/storage/kv/namespaces`),
103
+ cfGet(token, `${base}/d1/database`),
104
+ cfGet(token, `${base}/r2/buckets`),
105
+ cfGet(token, `${base}/queues`)
106
+ ]);
107
+ return {
108
+ kv: new Map(kv.map((namespace) => [namespace.title, namespace.id])),
109
+ d1: new Map(d1.map((database) => [database.name, database.uuid])),
110
+ r2: new Set((r2.buckets ?? []).map((bucket) => bucket.name)),
111
+ queue: new Set(queues.map((queue) => queue.queue_name))
112
+ };
113
+ };
114
+ //#endregion
115
+ //#region src/plugins/deploy/infra/plan.ts
116
+ /**
117
+ * Decide whether a single declared resource already exists in the account, recovering its id
118
+ * (kv/d1) when it does. Durable Objects are config-only (they ship with the script), so they are
119
+ * always treated as "missing" — provisioning them is a no-op that just records the binding.
120
+ *
121
+ * @param resource - The declared resource descriptor.
122
+ * @param existing - The indexed set of resources already in the account.
123
+ * @returns Whether it exists, plus the captured id for kv/d1.
124
+ * @example
125
+ * ```ts
126
+ * checkExisting({ kind: "kv", binding: "SESSIONS" }, existing); // { exists: true, id: "ns123" }
127
+ * ```
128
+ */
129
+ const checkExisting = (resource, existing) => {
130
+ switch (resource.kind) {
131
+ case "kv": {
132
+ const id = existing.kv.get(resource.binding);
133
+ return id === void 0 ? { exists: false } : {
134
+ exists: true,
135
+ id
136
+ };
137
+ }
138
+ case "d1": {
139
+ const id = existing.d1.get(resource.binding);
140
+ return id === void 0 ? { exists: false } : {
141
+ exists: true,
142
+ id
143
+ };
144
+ }
145
+ case "r2": return { exists: existing.r2.has(resource.bucket) };
146
+ case "queue": return { exists: resource.producers.every((producer) => existing.queue.has(producer)) };
147
+ case "do": return { exists: false };
148
+ }
149
+ };
150
+ /**
151
+ * Run the read-only infra preflight: resolve the account, list existing resources, diff against
152
+ * the manifest, emit `provision:plan`, and return the plan. Writes nothing.
153
+ *
154
+ * @param ctx - The deploy plugin context (env + emit).
155
+ * @param manifest - The assembled (or caller-supplied) deploy manifest.
156
+ * @returns The infra plan: existing (with ids) vs missing resources.
157
+ * @throws {Error} When the token is absent/invalid or a Cloudflare listing fails.
158
+ * @example
159
+ * ```ts
160
+ * const plan = await planInfra(ctx, manifest);
161
+ * ```
162
+ */
163
+ const planInfra = async (ctx, manifest) => {
164
+ const token = ctx.env.require("CLOUDFLARE_API_TOKEN");
165
+ const pinnedAccountId = ctx.env.get("CLOUDFLARE_ACCOUNT_ID");
166
+ const account = pinnedAccountId ? {
167
+ id: pinnedAccountId,
168
+ name: pinnedAccountId
169
+ } : await resolveAccount(token);
170
+ const existing = await listExisting(token, account.id);
171
+ const exists = [];
172
+ const missing = [];
173
+ for (const resource of manifest.resources) {
174
+ const check = checkExisting(resource, existing);
175
+ if (check.exists) exists.push(check.id === void 0 ? { resource } : {
176
+ resource,
177
+ id: check.id
178
+ });
179
+ else missing.push(resource);
180
+ }
181
+ ctx.emit("provision:plan", {
182
+ exists: exists.length,
183
+ missing: missing.length,
184
+ account: account.name
185
+ });
186
+ return {
187
+ account: account.name,
188
+ accountId: account.id,
189
+ exists,
190
+ missing
191
+ };
192
+ };
193
+ //#endregion
31
194
  //#region src/plugins/deploy/runner.ts
32
195
  /**
33
196
  * @file deploy plugin — wrangler subprocess wrapper (node:child_process).
@@ -101,26 +264,43 @@ const runWrangler = (args) => new Promise((resolve, reject) => {
101
264
  /**
102
265
  * @file deploy plugin — D1 provisioning adapter.
103
266
  *
104
- * Creates a Cloudflare D1 database via `wrangler d1 create <binding>`.
267
+ * Creates a Cloudflare D1 database via `wrangler d1 create <binding>`, captures the created
268
+ * database id from wrangler's output (so writeWranglerConfig can write a real `database_id`
269
+ * instead of an empty placeholder), and applies migrations when declared.
105
270
  * Node-only; never imported by the runtime Worker bundle.
106
271
  */
107
272
  /**
108
- * Provision a D1 database via `wrangler d1 create` and apply migrations.
273
+ * Parse the created D1 database id from `wrangler d1 create` output.
274
+ * Wrangler prints the new binding as JSON (`"database_id": "..."`) or TOML
275
+ * (`database_id = "..."`); the leading boundary keeps the match anchored to the field name.
276
+ *
277
+ * @param output - Raw stdout from the wrangler create command.
278
+ * @returns The database id, or undefined when none is found.
279
+ * @example
280
+ * ```ts
281
+ * parseD1DatabaseId('{ "database_id": "uuid-1234" }'); // "uuid-1234"
282
+ * ```
283
+ */
284
+ const parseD1DatabaseId = (output) => {
285
+ return /(?:^|[\s,{])"?database_id"?\s*[:=]\s*"([^"]+)"/m.exec(output)?.[1];
286
+ };
287
+ /**
288
+ * Provision a D1 database via `wrangler d1 create`, capture its id, and apply migrations.
109
289
  *
110
290
  * @param manifest - The D1 resource descriptor.
111
291
  * @param _ci - Whether running non-interactively.
112
- * @returns Resolves once the database is created (and migrations applied when specified).
292
+ * @returns The captured database id when wrangler reported one, else an empty outcome.
113
293
  * @example
114
294
  * ```ts
115
- * await provisionD1({ kind: "d1", binding: "DB", migrations: "./migrations" }, false);
295
+ * const { id } = await provisionD1({ kind: "d1", binding: "DB", migrations: "./migrations" }, false);
116
296
  * ```
117
297
  */
118
298
  const provisionD1 = async (manifest, _ci) => {
119
- await runWrangler([
299
+ const id = parseD1DatabaseId(await runWrangler([
120
300
  "d1",
121
301
  "create",
122
302
  manifest.binding
123
- ]);
303
+ ]));
124
304
  if (manifest.migrations) await runWrangler([
125
305
  "d1",
126
306
  "migrations",
@@ -128,6 +308,7 @@ const provisionD1 = async (manifest, _ci) => {
128
308
  manifest.binding,
129
309
  "--local"
130
310
  ]);
311
+ return id ? { id } : {};
131
312
  };
132
313
  //#endregion
133
314
  //#region src/plugins/deploy/providers/do.ts
@@ -150,27 +331,46 @@ const provisionDurableObject = async (_manifest, _ci) => {};
150
331
  /**
151
332
  * @file deploy plugin — KV provisioning adapter.
152
333
  *
153
- * Creates a Cloudflare KV namespace via `wrangler kv namespace create <binding>`.
154
- * Node-only; never imported by the runtime Worker bundle.
334
+ * Creates a Cloudflare KV namespace via `wrangler kv namespace create <binding>` and captures
335
+ * the created namespace id from wrangler's output, so writeWranglerConfig can write a real `id`
336
+ * (not an empty placeholder) into the generated wrangler config — otherwise the binding resolves
337
+ * to nothing at runtime. Node-only; never imported by the runtime Worker bundle.
338
+ */
339
+ /**
340
+ * Parse the created KV namespace id from `wrangler kv namespace create` output.
341
+ * Wrangler prints the new binding as JSON (`"id": "..."`) or TOML (`id = "..."`); the leading
342
+ * boundary (start / whitespace / `{` / `,`) keeps the match off a longer identifier such as
343
+ * `kv_namespace_id`.
344
+ *
345
+ * @param output - Raw stdout from the wrangler create command.
346
+ * @returns The namespace id, or undefined when none is found.
347
+ * @example
348
+ * ```ts
349
+ * parseKvNamespaceId('{ "id": "abc123" }'); // "abc123"
350
+ * ```
155
351
  */
352
+ const parseKvNamespaceId = (output) => {
353
+ return /(?:^|[\s,{])"?id"?\s*[:=]\s*"([^"]+)"/m.exec(output)?.[1];
354
+ };
156
355
  /**
157
- * Provision a KV namespace via `wrangler kv namespace create`.
356
+ * Provision a KV namespace via `wrangler kv namespace create` and capture its id.
158
357
  *
159
358
  * @param manifest - The KV resource descriptor.
160
359
  * @param _ci - Whether running non-interactively (passed through; wrangler respects env vars).
161
- * @returns Resolves once the namespace is created.
360
+ * @returns The captured namespace id when wrangler reported one, else an empty outcome.
162
361
  * @example
163
362
  * ```ts
164
- * await provisionKv({ kind: "kv", binding: "CACHE" }, false);
363
+ * const { id } = await provisionKv({ kind: "kv", binding: "CACHE" }, false);
165
364
  * ```
166
365
  */
167
366
  const provisionKv = async (manifest, _ci) => {
168
- await runWrangler([
367
+ const id = parseKvNamespaceId(await runWrangler([
169
368
  "kv",
170
369
  "namespace",
171
370
  "create",
172
371
  manifest.binding
173
- ]);
372
+ ]));
373
+ return id ? { id } : {};
174
374
  };
175
375
  //#endregion
176
376
  //#region src/plugins/deploy/providers/queues.ts
@@ -282,30 +482,26 @@ const uploadDirToR2 = async (bucket, directory) => {
282
482
  *
283
483
  * @param resource - The resource descriptor to provision.
284
484
  * @param ci - Whether running non-interactively.
285
- * @returns Resolves once the resource is provisioned.
485
+ * @returns The provisioning outcome `{ id }` for kv/d1, `{}` for r2/queue/do.
286
486
  * @example
287
487
  * ```ts
288
- * await provisionResource({ kind: "kv", binding: "CACHE" }, false);
289
- * await provisionResource({ kind: "r2", bucket: "ASSETS" }, false);
488
+ * const { id } = await provisionResource({ kind: "kv", binding: "CACHE" }, false);
489
+ * await provisionResource({ kind: "r2", bucket: "ASSETS" }, false); // {}
290
490
  * ```
291
491
  */
292
492
  const provisionResource = async (resource, ci) => {
293
493
  switch (resource.kind) {
294
- case "kv":
295
- await provisionKv(resource, ci);
296
- break;
494
+ case "kv": return provisionKv(resource, ci);
495
+ case "d1": return provisionD1(resource, ci);
297
496
  case "r2":
298
497
  await provisionR2(resource, ci);
299
- break;
300
- case "d1":
301
- await provisionD1(resource, ci);
302
- break;
498
+ return {};
303
499
  case "queue":
304
500
  await provisionQueue(resource, ci);
305
- break;
501
+ return {};
306
502
  case "do":
307
503
  await provisionDurableObject(resource, ci);
308
- break;
504
+ return {};
309
505
  }
310
506
  };
311
507
  //#endregion
@@ -339,15 +535,16 @@ const parseJsonc = (source) => {
339
535
  * Build the wrangler `kv_namespaces` array from the manifest's kv resources.
340
536
  *
341
537
  * @param resources - All resource descriptors from the manifest.
342
- * @returns One wrangler KV namespace entry per kv resource.
538
+ * @param ids - Captured Cloudflare ids keyed by binding; the entry's `id` is filled from here.
539
+ * @returns One wrangler KV namespace entry per kv resource (real `id` when known, else "").
343
540
  * @example
344
541
  * ```ts
345
- * const kv = buildKvNamespaces([{ kind: "kv", binding: "CACHE" }]);
542
+ * const kv = buildKvNamespaces([{ kind: "kv", binding: "CACHE" }], { CACHE: "ns123" });
346
543
  * ```
347
544
  */
348
- const buildKvNamespaces = (resources) => resources.filter((resource) => resource.kind === "kv").map((resource) => ({
545
+ const buildKvNamespaces = (resources, ids) => resources.filter((resource) => resource.kind === "kv").map((resource) => ({
349
546
  binding: resource.binding,
350
- id: ""
547
+ id: ids[resource.binding] ?? ""
351
548
  }));
352
549
  /**
353
550
  * Build the wrangler `r2_buckets` array from the manifest's r2 resources.
@@ -367,17 +564,18 @@ const buildR2Buckets = (resources) => resources.filter((resource) => resource.ki
367
564
  * Build the wrangler `d1_databases` array from the manifest's d1 resources.
368
565
  *
369
566
  * @param resources - All resource descriptors from the manifest.
567
+ * @param ids - Captured Cloudflare ids keyed by binding; the entry's `database_id` is filled from here.
370
568
  * @returns One wrangler D1 database entry per d1 resource (migrations_dir set when present).
371
569
  * @example
372
570
  * ```ts
373
- * const d1 = buildD1Databases([{ kind: "d1", binding: "DB" }]);
571
+ * const d1 = buildD1Databases([{ kind: "d1", binding: "DB" }], { DB: "uuid-1234" });
374
572
  * ```
375
573
  */
376
- const buildD1Databases = (resources) => resources.filter((resource) => resource.kind === "d1").map((resource) => {
574
+ const buildD1Databases = (resources, ids) => resources.filter((resource) => resource.kind === "d1").map((resource) => {
377
575
  const entry = {
378
576
  binding: resource.binding,
379
577
  database_name: resource.binding.toLowerCase(),
380
- database_id: ""
578
+ database_id: ids[resource.binding] ?? ""
381
579
  };
382
580
  if (resource.migrations) entry.migrations_dir = resource.migrations;
383
581
  return entry;
@@ -426,22 +624,24 @@ const buildDurableObjects = (resources) => {
426
624
  *
427
625
  * @param configFile - Path to the wrangler config file.
428
626
  * @param manifest - The assembled deploy manifest.
627
+ * @param ids - Captured Cloudflare ids keyed by binding (kv namespace id, d1 database id). Defaults
628
+ * to an empty map, in which case `id`/`database_id` are written as "" (e.g. the universal path).
429
629
  * @returns Resolves once the file is written.
430
630
  * @example
431
631
  * ```ts
432
- * await writeWranglerConfig("wrangler.jsonc", manifest);
632
+ * await writeWranglerConfig("wrangler.jsonc", manifest, { CACHE: "ns123", DB: "uuid-1234" });
433
633
  * ```
434
634
  */
435
- const writeWranglerConfig = async (configFile, manifest) => {
635
+ const writeWranglerConfig = async (configFile, manifest, ids = {}) => {
436
636
  let existing = {};
437
637
  if ((0, node_fs.existsSync)(configFile)) try {
438
638
  existing = parseJsonc((0, node_fs.readFileSync)(configFile, "utf8"));
439
639
  } catch {
440
640
  existing = {};
441
641
  }
442
- const kvNamespaces = buildKvNamespaces(manifest.resources);
642
+ const kvNamespaces = buildKvNamespaces(manifest.resources, ids);
443
643
  const r2Buckets = buildR2Buckets(manifest.resources);
444
- const d1Databases = buildD1Databases(manifest.resources);
644
+ const d1Databases = buildD1Databases(manifest.resources, ids);
445
645
  const queues = buildQueues(manifest.resources);
446
646
  const durableObjects = buildDurableObjects(manifest.resources);
447
647
  const updated = {
@@ -480,21 +680,22 @@ const scaffoldWranglerAndCi = async (configFile, _ci) => {
480
680
  //#endregion
481
681
  //#region src/plugins/deploy/api.ts
482
682
  /**
483
- * @file deploy plugin — API factory (run, dev, init).
683
+ * @file deploy plugin — API factory (run, dev, init, checkInfra, provisionInfra).
484
684
  *
485
685
  * Pure ctx-taking factory. Assembles the deploy manifest from each resource plugin's own
486
- * deployManifest() api (never sibling pluginConfigs — design F6), provisions resources,
487
- * generates/updates the wrangler config, uploads the R2 upload dir, and runs wrangler deploy.
488
- * Emits only global events: deploy:phase, deploy:complete, provision:resource.
686
+ * deployManifest() api (never sibling pluginConfigs — design F6), runs an infra preflight
687
+ * (check-before-create + capture real ids), generates/updates the wrangler config, uploads the
688
+ * R2 upload dir, and runs wrangler deploy. Emits only global events: deploy:phase,
689
+ * deploy:complete, provision:resource, provision:plan, provision:skip.
489
690
  *
490
- * Node-only: uses node:child_process (via runner.ts) and node:fs (via wrangler-config.ts).
491
- * Never called in the deployed Worker runtime.
691
+ * Node-only: uses node:child_process (via runner.ts), node:fs (via wrangler-config.ts), and the
692
+ * Cloudflare REST API (via infra/). Never called in the deployed Worker runtime.
492
693
  */
493
694
  /**
494
- * Derive a human-readable name string from a resource descriptor (used in provision:resource).
695
+ * Derive a human-readable name string from a resource descriptor (used in provision events).
495
696
  *
496
697
  * @param resource - The resource descriptor.
497
- * @returns A name suitable for the provision:resource event payload.
698
+ * @returns A name suitable for the provision:resource / provision:skip event payload.
498
699
  * @example
499
700
  * ```ts
500
701
  * resourceName({ kind: "kv", binding: "CACHE" }); // "CACHE"
@@ -509,12 +710,74 @@ const resourceName = (resource) => {
509
710
  }
510
711
  };
511
712
  /**
512
- * Create the deploy api. Assembles the manifest from each resource plugin's own
513
- * deployManifest() (never sibling config), provisions, generates config, uploads,
514
- * and runs `wrangler deploy`, emitting global deploy events along the way.
713
+ * Assemble the deploy manifest from each present resource plugin's OWN deployManifest() api,
714
+ * gated by ctx.has(name) so absent plugins are skipped never sibling pluginConfigs (F6).
715
+ *
716
+ * @param ctx - The deploy plugin context.
717
+ * @returns The assembled manifest (name, compatibilityDate, resources).
718
+ * @example
719
+ * ```ts
720
+ * const manifest = assembleManifest(ctx);
721
+ * ```
722
+ */
723
+ const assembleManifest = (ctx) => ({
724
+ name: ctx.global.name,
725
+ compatibilityDate: ctx.global.compatibilityDate,
726
+ resources: [
727
+ ctx.has("storage") ? ctx.require(require_storage.storagePlugin).deployManifest() : void 0,
728
+ ctx.has("kv") ? ctx.require(require_storage.kvPlugin).deployManifest() : void 0,
729
+ ctx.has("d1") ? ctx.require(require_storage.d1Plugin).deployManifest() : void 0,
730
+ ctx.has("queues") ? ctx.require(require_storage.queuesPlugin).deployManifest() : void 0,
731
+ ctx.has("durableObjects") ? ctx.require(require_storage.durableObjectsPlugin).deployManifest() : void 0
732
+ ].filter((resource) => resource !== void 0)
733
+ });
734
+ /**
735
+ * Act on an infra plan: skip the resources that already exist (reusing their ids), create only
736
+ * the missing ones (capturing each new id), and announce each via provision:skip / :resource.
737
+ *
738
+ * @param ctx - The deploy plugin context.
739
+ * @param plan - The infra plan from planInfra (existing vs missing).
740
+ * @returns The provisioning result: created, skipped, and the merged binding → id map.
741
+ * @example
742
+ * ```ts
743
+ * const { ids } = await applyPlan(ctx, plan);
744
+ * ```
745
+ */
746
+ const applyPlan = async (ctx, plan) => {
747
+ const ids = {};
748
+ for (const ref of plan.exists) {
749
+ if (ref.id !== void 0 && (ref.resource.kind === "kv" || ref.resource.kind === "d1")) ids[ref.resource.binding] = ref.id;
750
+ ctx.emit("provision:skip", {
751
+ kind: ref.resource.kind,
752
+ name: resourceName(ref.resource)
753
+ });
754
+ }
755
+ const created = [];
756
+ for (const resource of plan.missing) {
757
+ const { id } = await provisionResource(resource, ctx.config.ci);
758
+ if (id !== void 0 && (resource.kind === "kv" || resource.kind === "d1")) ids[resource.binding] = id;
759
+ created.push(id === void 0 ? { resource } : {
760
+ resource,
761
+ id
762
+ });
763
+ ctx.emit("provision:resource", {
764
+ kind: resource.kind,
765
+ name: resourceName(resource)
766
+ });
767
+ }
768
+ return {
769
+ created,
770
+ skipped: plan.exists,
771
+ ids
772
+ };
773
+ };
774
+ /**
775
+ * Create the deploy api. Assembles the manifest from each resource plugin's own deployManifest(),
776
+ * runs an infra preflight (check-before-create + id capture), generates config, uploads, and runs
777
+ * `wrangler deploy`, emitting global deploy events along the way.
515
778
  *
516
- * @param ctx - Plugin context (own config + require + has + emit + global).
517
- * @returns The app.deploy api: run / dev / init.
779
+ * @param ctx - Plugin context (own config + require + has + emit + global + env).
780
+ * @returns The app.deploy api: run / dev / init / checkInfra / provisionInfra.
518
781
  * @example
519
782
  * ```ts
520
783
  * const api = createDeployApi(ctx);
@@ -523,12 +786,13 @@ const resourceName = (resource) => {
523
786
  */
524
787
  const createDeployApi = (ctx) => ({
525
788
  /**
526
- * Run the full deploy pipeline: detect → provision → wrangler-configupload deploy.
527
- * When opts.manifest is supplied, it is used verbatim (universal path).
789
+ * Run the full deploy pipeline: detect → preflight (check-before-create)provision (only the
790
+ * missing) wrangler-config (with real ids) upload deploy. When opts.manifest is supplied
791
+ * it is used verbatim (universal path).
528
792
  *
529
793
  * @param opts - Optional run options.
530
- * @param opts.guided - Enable interactive confirmation steps (skipped when ci=true).
531
- * @param opts.yes - Auto-confirm all prompts.
794
+ * @param opts.guided - Enable interactive confirmation steps (wired in a later phase).
795
+ * @param opts.yes - Auto-confirm all prompts (wired in a later phase).
532
796
  * @param opts.manifest - Caller-supplied manifest (bypasses deployManifest() assembly).
533
797
  * @returns Resolves once the deploy completes.
534
798
  * @example
@@ -539,27 +803,11 @@ const createDeployApi = (ctx) => ({
539
803
  */
540
804
  async run(opts) {
541
805
  ctx.emit("deploy:phase", { phase: "detect" });
542
- const manifest = opts?.manifest ?? {
543
- name: ctx.global.name,
544
- compatibilityDate: ctx.global.compatibilityDate,
545
- resources: [
546
- ctx.has("storage") ? ctx.require(require_storage.storagePlugin).deployManifest() : void 0,
547
- ctx.has("kv") ? ctx.require(require_storage.kvPlugin).deployManifest() : void 0,
548
- ctx.has("d1") ? ctx.require(require_storage.d1Plugin).deployManifest() : void 0,
549
- ctx.has("queues") ? ctx.require(require_storage.queuesPlugin).deployManifest() : void 0,
550
- ctx.has("durableObjects") ? ctx.require(require_storage.durableObjectsPlugin).deployManifest() : void 0
551
- ].filter((resource) => resource !== void 0)
552
- };
806
+ const manifest = opts?.manifest ?? assembleManifest(ctx);
553
807
  ctx.emit("deploy:phase", { phase: "provision" });
554
- for (const resource of manifest.resources) {
555
- await provisionResource(resource, ctx.config.ci);
556
- ctx.emit("provision:resource", {
557
- kind: resource.kind,
558
- name: resourceName(resource)
559
- });
560
- }
808
+ const { ids } = await applyPlan(ctx, await planInfra(ctx, manifest));
561
809
  ctx.emit("deploy:phase", { phase: "wrangler-config" });
562
- await writeWranglerConfig(ctx.config.configFile, manifest);
810
+ await writeWranglerConfig(ctx.config.configFile, manifest, ids);
563
811
  const r2Resource = manifest.resources.find((resource) => resource.kind === "r2");
564
812
  if (r2Resource?.upload) {
565
813
  const count = await uploadDirToR2(r2Resource.bucket, r2Resource.upload);
@@ -610,7 +858,29 @@ const createDeployApi = (ctx) => ({
610
858
  */
611
859
  init: async (opts) => {
612
860
  await scaffoldWranglerAndCi(ctx.config.configFile, opts?.ci ?? ctx.config.ci);
613
- }
861
+ },
862
+ /**
863
+ * Read-only infra preflight: assemble the manifest, resolve the account, list what exists in
864
+ * Cloudflare, diff, emit provision:plan, and return the plan. Writes nothing.
865
+ *
866
+ * @returns The infra plan (existing vs missing resources, with captured ids).
867
+ * @example
868
+ * ```ts
869
+ * const plan = await api.checkInfra();
870
+ * ```
871
+ */
872
+ checkInfra: () => planInfra(ctx, assembleManifest(ctx)),
873
+ /**
874
+ * Create only the resources missing from the plan (skipping existing), capturing each id.
875
+ *
876
+ * @param plan - A plan produced by checkInfra().
877
+ * @returns The provisioning result: created, skipped, and the merged id map.
878
+ * @example
879
+ * ```ts
880
+ * const { created } = await api.provisionInfra(await api.checkInfra());
881
+ * ```
882
+ */
883
+ provisionInfra: (plan) => applyPlan(ctx, plan)
614
884
  });
615
885
  /**
616
886
  * Complex tier (node-only) — build-time deploy orchestrator over the five resource plugins.
@@ -723,6 +993,18 @@ const createCliHooks = (ctx) => ({
723
993
  ctx.log.info(p.detail ? `${p.phase} · ${p.detail}` : p.phase);
724
994
  },
725
995
  /**
996
+ * Log the infra preflight summary: "infra · N exist, M to create · account".
997
+ *
998
+ * @param p - The provision:plan event payload.
999
+ * @example
1000
+ * ```ts
1001
+ * handler({ exists: 2, missing: 1, account: "Play Co" }); // "infra · 2 exist, 1 to create · Play Co"
1002
+ * ```
1003
+ */
1004
+ "provision:plan"(p) {
1005
+ ctx.log.info(`infra · ${p.exists} exist, ${p.missing} to create · ${p.account}`);
1006
+ },
1007
+ /**
726
1008
  * Log one clean line per provisioned resource: "kind name".
727
1009
  *
728
1010
  * @param p - The provision:resource event payload.
@@ -735,6 +1017,18 @@ const createCliHooks = (ctx) => ({
735
1017
  ctx.log.info(`${p.kind} ${p.name}`);
736
1018
  },
737
1019
  /**
1020
+ * Log one clean line per already-existing resource (skipped): "kind name (exists)".
1021
+ *
1022
+ * @param p - The provision:skip event payload.
1023
+ * @example
1024
+ * ```ts
1025
+ * handler({ kind: "kv", name: "KV" }); // "kv KV (exists)"
1026
+ * ```
1027
+ */
1028
+ "provision:skip"(p) {
1029
+ ctx.log.info(`${p.kind} ${p.name} (exists)`);
1030
+ },
1031
+ /**
738
1032
  * Log the terminal success line with the deployed URL.
739
1033
  *
740
1034
  * @param p - The deploy:complete event payload.
package/dist/cli.d.cts CHANGED
@@ -111,6 +111,33 @@ type ExternalManifest = {
111
111
  compatibilityDate: string; /** Resource descriptors to provision. */
112
112
  resources: ResourceManifest[];
113
113
  };
114
+ /**
115
+ * A resource that already exists in the account (the infra preflight discovered it), with its
116
+ * captured Cloudflare id when the kind has one (kv namespace id, d1 database id).
117
+ */
118
+ type ProvisionedRef = {
119
+ /** The resource descriptor from the manifest. */resource: ResourceManifest; /** The existing resource's Cloudflare id (kv/d1 only). */
120
+ id?: string;
121
+ };
122
+ /**
123
+ * Read-only infra preflight result: which declared resources already exist in the Cloudflare
124
+ * account versus which are still missing and must be created. Produced by `checkInfra()`.
125
+ */
126
+ type InfraPlan = {
127
+ /** Resolved account display name (or id when the name is unknown). */account: string; /** Resolved Cloudflare account id used for the existence checks. */
128
+ accountId: string; /** Declared resources that already exist (with their captured ids where applicable). */
129
+ exists: ProvisionedRef[]; /** Declared resources that do not yet exist and must be created. */
130
+ missing: ResourceManifest[];
131
+ };
132
+ /**
133
+ * Outcome of acting on an {@link InfraPlan}: the resources just created, those skipped because
134
+ * they already existed, and the merged id map (binding → Cloudflare id) for the config writer.
135
+ */
136
+ type ProvisionResult = {
137
+ /** Resources created during this run. */created: ProvisionedRef[]; /** Resources skipped because they already existed. */
138
+ skipped: ProvisionedRef[]; /** Merged binding → Cloudflare id map (existing + created) for writeWranglerConfig. */
139
+ ids: Record<string, string>;
140
+ };
114
141
  //#endregion
115
142
  //#region src/plugins/deploy/index.d.ts
116
143
  /**
@@ -137,6 +164,8 @@ declare const deployPlugin: import("@moku-labs/core").PluginInstance<"deploy", C
137
164
  init: (opts?: {
138
165
  ci?: boolean;
139
166
  }) => Promise<void>;
167
+ checkInfra: () => Promise<InfraPlan>;
168
+ provisionInfra: (plan: InfraPlan) => Promise<ProvisionResult>;
140
169
  }, {}> & Record<never, never>;
141
170
  //#endregion
142
171
  export { type ExternalManifest, type ResourceManifest, cliPlugin, deployPlugin };