@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 +367 -73
- package/dist/cli.d.cts +29 -0
- package/dist/cli.d.mts +29 -0
- package/dist/cli.mjs +367 -73
- package/dist/{config-Bj3GUJT_.d.cts → config-50jmPyMv.d.cts} +9 -0
- package/dist/{config-Bj3GUJT_.d.mts → config-50jmPyMv.d.mts} +9 -0
- package/dist/index.cjs +84 -1
- package/dist/index.d.cts +61 -35
- package/dist/index.d.mts +60 -34
- package/dist/index.mjs +84 -1
- package/package.json +1 -1
package/dist/cli.d.mts
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 };
|
package/dist/cli.mjs
CHANGED
|
@@ -4,6 +4,169 @@ import { spawn } from "node:child_process";
|
|
|
4
4
|
import { readdir, stat, writeFile } from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
//#region src/plugins/deploy/infra/cloudflare.ts
|
|
8
|
+
/**
|
|
9
|
+
* @file deploy plugin — Cloudflare REST discovery client (infra preflight).
|
|
10
|
+
*
|
|
11
|
+
* Lists what already exists in a Cloudflare account so the deploy pipeline can create only the
|
|
12
|
+
* missing resources (idempotent provisioning) and recover real ids for existing kv/d1 bindings.
|
|
13
|
+
* Authenticated with the `.env` API token (CLOUDFLARE_API_TOKEN) — never an interactive login.
|
|
14
|
+
* Uses the global `fetch`; node-only, never imported by the runtime Worker bundle.
|
|
15
|
+
*/
|
|
16
|
+
const API_BASE = "https://api.cloudflare.com/client/v4";
|
|
17
|
+
/**
|
|
18
|
+
* GET a Cloudflare API path with the bearer token and unwrap the `result`.
|
|
19
|
+
*
|
|
20
|
+
* @param token - The Cloudflare API token (CLOUDFLARE_API_TOKEN).
|
|
21
|
+
* @param path - API path beneath the v4 base (e.g. "/accounts").
|
|
22
|
+
* @returns The unwrapped `result` payload, typed by the caller.
|
|
23
|
+
* @throws {Error} When the HTTP request fails or the API reports `success: false`.
|
|
24
|
+
* @example
|
|
25
|
+
* ```ts
|
|
26
|
+
* const accounts = await cfGet<Array<{ id: string }>>(token, "/accounts");
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
const cfGet = async (token, path) => {
|
|
30
|
+
const response = await fetch(`${API_BASE}${path}`, { headers: {
|
|
31
|
+
Authorization: `Bearer ${token}`,
|
|
32
|
+
"Content-Type": "application/json"
|
|
33
|
+
} });
|
|
34
|
+
const body = await response.json();
|
|
35
|
+
if (!response.ok || !body.success) {
|
|
36
|
+
const detail = body.errors?.map((error) => error.message).join("; ") || `HTTP ${response.status}`;
|
|
37
|
+
throw new Error(`[moku-worker] Cloudflare API request failed (${path}): ${detail}`);
|
|
38
|
+
}
|
|
39
|
+
return body.result;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the Cloudflare account (id + display name) accessible to the token. Used when the
|
|
43
|
+
* consumer did not pin CLOUDFLARE_ACCOUNT_ID; the first accessible account is chosen.
|
|
44
|
+
*
|
|
45
|
+
* @param token - The Cloudflare API token.
|
|
46
|
+
* @returns The resolved account id and name.
|
|
47
|
+
* @throws {Error} When the token can access no account.
|
|
48
|
+
* @example
|
|
49
|
+
* ```ts
|
|
50
|
+
* const { id, name } = await resolveAccount(token);
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
const resolveAccount = async (token) => {
|
|
54
|
+
const first = (await cfGet(token, "/accounts"))[0];
|
|
55
|
+
if (!first) throw new Error("[moku-worker] No Cloudflare account is accessible with this API token.");
|
|
56
|
+
return {
|
|
57
|
+
id: first.id,
|
|
58
|
+
name: first.name
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* List every kv / d1 / r2 / queue resource that already exists in the account (one request per
|
|
63
|
+
* kind, in parallel), indexed for the preflight diff.
|
|
64
|
+
*
|
|
65
|
+
* @param token - The Cloudflare API token.
|
|
66
|
+
* @param accountId - The Cloudflare account id to scope the listings to.
|
|
67
|
+
* @returns The existing resources, indexed by kind.
|
|
68
|
+
* @throws {Error} When any listing request fails.
|
|
69
|
+
* @example
|
|
70
|
+
* ```ts
|
|
71
|
+
* const existing = await listExisting(token, accountId);
|
|
72
|
+
* if (existing.kv.has("SESSIONS")) { ... }
|
|
73
|
+
* ```
|
|
74
|
+
*/
|
|
75
|
+
const listExisting = async (token, accountId) => {
|
|
76
|
+
const base = `/accounts/${accountId}`;
|
|
77
|
+
const [kv, d1, r2, queues] = await Promise.all([
|
|
78
|
+
cfGet(token, `${base}/storage/kv/namespaces`),
|
|
79
|
+
cfGet(token, `${base}/d1/database`),
|
|
80
|
+
cfGet(token, `${base}/r2/buckets`),
|
|
81
|
+
cfGet(token, `${base}/queues`)
|
|
82
|
+
]);
|
|
83
|
+
return {
|
|
84
|
+
kv: new Map(kv.map((namespace) => [namespace.title, namespace.id])),
|
|
85
|
+
d1: new Map(d1.map((database) => [database.name, database.uuid])),
|
|
86
|
+
r2: new Set((r2.buckets ?? []).map((bucket) => bucket.name)),
|
|
87
|
+
queue: new Set(queues.map((queue) => queue.queue_name))
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
//#endregion
|
|
91
|
+
//#region src/plugins/deploy/infra/plan.ts
|
|
92
|
+
/**
|
|
93
|
+
* Decide whether a single declared resource already exists in the account, recovering its id
|
|
94
|
+
* (kv/d1) when it does. Durable Objects are config-only (they ship with the script), so they are
|
|
95
|
+
* always treated as "missing" — provisioning them is a no-op that just records the binding.
|
|
96
|
+
*
|
|
97
|
+
* @param resource - The declared resource descriptor.
|
|
98
|
+
* @param existing - The indexed set of resources already in the account.
|
|
99
|
+
* @returns Whether it exists, plus the captured id for kv/d1.
|
|
100
|
+
* @example
|
|
101
|
+
* ```ts
|
|
102
|
+
* checkExisting({ kind: "kv", binding: "SESSIONS" }, existing); // { exists: true, id: "ns123" }
|
|
103
|
+
* ```
|
|
104
|
+
*/
|
|
105
|
+
const checkExisting = (resource, existing) => {
|
|
106
|
+
switch (resource.kind) {
|
|
107
|
+
case "kv": {
|
|
108
|
+
const id = existing.kv.get(resource.binding);
|
|
109
|
+
return id === void 0 ? { exists: false } : {
|
|
110
|
+
exists: true,
|
|
111
|
+
id
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
case "d1": {
|
|
115
|
+
const id = existing.d1.get(resource.binding);
|
|
116
|
+
return id === void 0 ? { exists: false } : {
|
|
117
|
+
exists: true,
|
|
118
|
+
id
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
case "r2": return { exists: existing.r2.has(resource.bucket) };
|
|
122
|
+
case "queue": return { exists: resource.producers.every((producer) => existing.queue.has(producer)) };
|
|
123
|
+
case "do": return { exists: false };
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
/**
|
|
127
|
+
* Run the read-only infra preflight: resolve the account, list existing resources, diff against
|
|
128
|
+
* the manifest, emit `provision:plan`, and return the plan. Writes nothing.
|
|
129
|
+
*
|
|
130
|
+
* @param ctx - The deploy plugin context (env + emit).
|
|
131
|
+
* @param manifest - The assembled (or caller-supplied) deploy manifest.
|
|
132
|
+
* @returns The infra plan: existing (with ids) vs missing resources.
|
|
133
|
+
* @throws {Error} When the token is absent/invalid or a Cloudflare listing fails.
|
|
134
|
+
* @example
|
|
135
|
+
* ```ts
|
|
136
|
+
* const plan = await planInfra(ctx, manifest);
|
|
137
|
+
* ```
|
|
138
|
+
*/
|
|
139
|
+
const planInfra = async (ctx, manifest) => {
|
|
140
|
+
const token = ctx.env.require("CLOUDFLARE_API_TOKEN");
|
|
141
|
+
const pinnedAccountId = ctx.env.get("CLOUDFLARE_ACCOUNT_ID");
|
|
142
|
+
const account = pinnedAccountId ? {
|
|
143
|
+
id: pinnedAccountId,
|
|
144
|
+
name: pinnedAccountId
|
|
145
|
+
} : await resolveAccount(token);
|
|
146
|
+
const existing = await listExisting(token, account.id);
|
|
147
|
+
const exists = [];
|
|
148
|
+
const missing = [];
|
|
149
|
+
for (const resource of manifest.resources) {
|
|
150
|
+
const check = checkExisting(resource, existing);
|
|
151
|
+
if (check.exists) exists.push(check.id === void 0 ? { resource } : {
|
|
152
|
+
resource,
|
|
153
|
+
id: check.id
|
|
154
|
+
});
|
|
155
|
+
else missing.push(resource);
|
|
156
|
+
}
|
|
157
|
+
ctx.emit("provision:plan", {
|
|
158
|
+
exists: exists.length,
|
|
159
|
+
missing: missing.length,
|
|
160
|
+
account: account.name
|
|
161
|
+
});
|
|
162
|
+
return {
|
|
163
|
+
account: account.name,
|
|
164
|
+
accountId: account.id,
|
|
165
|
+
exists,
|
|
166
|
+
missing
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
//#endregion
|
|
7
170
|
//#region src/plugins/deploy/runner.ts
|
|
8
171
|
/**
|
|
9
172
|
* @file deploy plugin — wrangler subprocess wrapper (node:child_process).
|
|
@@ -77,26 +240,43 @@ const runWrangler = (args) => new Promise((resolve, reject) => {
|
|
|
77
240
|
/**
|
|
78
241
|
* @file deploy plugin — D1 provisioning adapter.
|
|
79
242
|
*
|
|
80
|
-
* Creates a Cloudflare D1 database via `wrangler d1 create <binding
|
|
243
|
+
* Creates a Cloudflare D1 database via `wrangler d1 create <binding>`, captures the created
|
|
244
|
+
* database id from wrangler's output (so writeWranglerConfig can write a real `database_id`
|
|
245
|
+
* instead of an empty placeholder), and applies migrations when declared.
|
|
81
246
|
* Node-only; never imported by the runtime Worker bundle.
|
|
82
247
|
*/
|
|
83
248
|
/**
|
|
84
|
-
*
|
|
249
|
+
* Parse the created D1 database id from `wrangler d1 create` output.
|
|
250
|
+
* Wrangler prints the new binding as JSON (`"database_id": "..."`) or TOML
|
|
251
|
+
* (`database_id = "..."`); the leading boundary keeps the match anchored to the field name.
|
|
252
|
+
*
|
|
253
|
+
* @param output - Raw stdout from the wrangler create command.
|
|
254
|
+
* @returns The database id, or undefined when none is found.
|
|
255
|
+
* @example
|
|
256
|
+
* ```ts
|
|
257
|
+
* parseD1DatabaseId('{ "database_id": "uuid-1234" }'); // "uuid-1234"
|
|
258
|
+
* ```
|
|
259
|
+
*/
|
|
260
|
+
const parseD1DatabaseId = (output) => {
|
|
261
|
+
return /(?:^|[\s,{])"?database_id"?\s*[:=]\s*"([^"]+)"/m.exec(output)?.[1];
|
|
262
|
+
};
|
|
263
|
+
/**
|
|
264
|
+
* Provision a D1 database via `wrangler d1 create`, capture its id, and apply migrations.
|
|
85
265
|
*
|
|
86
266
|
* @param manifest - The D1 resource descriptor.
|
|
87
267
|
* @param _ci - Whether running non-interactively.
|
|
88
|
-
* @returns
|
|
268
|
+
* @returns The captured database id when wrangler reported one, else an empty outcome.
|
|
89
269
|
* @example
|
|
90
270
|
* ```ts
|
|
91
|
-
* await provisionD1({ kind: "d1", binding: "DB", migrations: "./migrations" }, false);
|
|
271
|
+
* const { id } = await provisionD1({ kind: "d1", binding: "DB", migrations: "./migrations" }, false);
|
|
92
272
|
* ```
|
|
93
273
|
*/
|
|
94
274
|
const provisionD1 = async (manifest, _ci) => {
|
|
95
|
-
await runWrangler([
|
|
275
|
+
const id = parseD1DatabaseId(await runWrangler([
|
|
96
276
|
"d1",
|
|
97
277
|
"create",
|
|
98
278
|
manifest.binding
|
|
99
|
-
]);
|
|
279
|
+
]));
|
|
100
280
|
if (manifest.migrations) await runWrangler([
|
|
101
281
|
"d1",
|
|
102
282
|
"migrations",
|
|
@@ -104,6 +284,7 @@ const provisionD1 = async (manifest, _ci) => {
|
|
|
104
284
|
manifest.binding,
|
|
105
285
|
"--local"
|
|
106
286
|
]);
|
|
287
|
+
return id ? { id } : {};
|
|
107
288
|
};
|
|
108
289
|
//#endregion
|
|
109
290
|
//#region src/plugins/deploy/providers/do.ts
|
|
@@ -126,27 +307,46 @@ const provisionDurableObject = async (_manifest, _ci) => {};
|
|
|
126
307
|
/**
|
|
127
308
|
* @file deploy plugin — KV provisioning adapter.
|
|
128
309
|
*
|
|
129
|
-
* Creates a Cloudflare KV namespace via `wrangler kv namespace create <binding
|
|
130
|
-
*
|
|
310
|
+
* Creates a Cloudflare KV namespace via `wrangler kv namespace create <binding>` and captures
|
|
311
|
+
* the created namespace id from wrangler's output, so writeWranglerConfig can write a real `id`
|
|
312
|
+
* (not an empty placeholder) into the generated wrangler config — otherwise the binding resolves
|
|
313
|
+
* to nothing at runtime. Node-only; never imported by the runtime Worker bundle.
|
|
131
314
|
*/
|
|
132
315
|
/**
|
|
133
|
-
*
|
|
316
|
+
* Parse the created KV namespace id from `wrangler kv namespace create` output.
|
|
317
|
+
* Wrangler prints the new binding as JSON (`"id": "..."`) or TOML (`id = "..."`); the leading
|
|
318
|
+
* boundary (start / whitespace / `{` / `,`) keeps the match off a longer identifier such as
|
|
319
|
+
* `kv_namespace_id`.
|
|
320
|
+
*
|
|
321
|
+
* @param output - Raw stdout from the wrangler create command.
|
|
322
|
+
* @returns The namespace id, or undefined when none is found.
|
|
323
|
+
* @example
|
|
324
|
+
* ```ts
|
|
325
|
+
* parseKvNamespaceId('{ "id": "abc123" }'); // "abc123"
|
|
326
|
+
* ```
|
|
327
|
+
*/
|
|
328
|
+
const parseKvNamespaceId = (output) => {
|
|
329
|
+
return /(?:^|[\s,{])"?id"?\s*[:=]\s*"([^"]+)"/m.exec(output)?.[1];
|
|
330
|
+
};
|
|
331
|
+
/**
|
|
332
|
+
* Provision a KV namespace via `wrangler kv namespace create` and capture its id.
|
|
134
333
|
*
|
|
135
334
|
* @param manifest - The KV resource descriptor.
|
|
136
335
|
* @param _ci - Whether running non-interactively (passed through; wrangler respects env vars).
|
|
137
|
-
* @returns
|
|
336
|
+
* @returns The captured namespace id when wrangler reported one, else an empty outcome.
|
|
138
337
|
* @example
|
|
139
338
|
* ```ts
|
|
140
|
-
* await provisionKv({ kind: "kv", binding: "CACHE" }, false);
|
|
339
|
+
* const { id } = await provisionKv({ kind: "kv", binding: "CACHE" }, false);
|
|
141
340
|
* ```
|
|
142
341
|
*/
|
|
143
342
|
const provisionKv = async (manifest, _ci) => {
|
|
144
|
-
await runWrangler([
|
|
343
|
+
const id = parseKvNamespaceId(await runWrangler([
|
|
145
344
|
"kv",
|
|
146
345
|
"namespace",
|
|
147
346
|
"create",
|
|
148
347
|
manifest.binding
|
|
149
|
-
]);
|
|
348
|
+
]));
|
|
349
|
+
return id ? { id } : {};
|
|
150
350
|
};
|
|
151
351
|
//#endregion
|
|
152
352
|
//#region src/plugins/deploy/providers/queues.ts
|
|
@@ -258,30 +458,26 @@ const uploadDirToR2 = async (bucket, directory) => {
|
|
|
258
458
|
*
|
|
259
459
|
* @param resource - The resource descriptor to provision.
|
|
260
460
|
* @param ci - Whether running non-interactively.
|
|
261
|
-
* @returns
|
|
461
|
+
* @returns The provisioning outcome — `{ id }` for kv/d1, `{}` for r2/queue/do.
|
|
262
462
|
* @example
|
|
263
463
|
* ```ts
|
|
264
|
-
* await provisionResource({ kind: "kv", binding: "CACHE" }, false);
|
|
265
|
-
* await provisionResource({ kind: "r2", bucket: "ASSETS" }, false);
|
|
464
|
+
* const { id } = await provisionResource({ kind: "kv", binding: "CACHE" }, false);
|
|
465
|
+
* await provisionResource({ kind: "r2", bucket: "ASSETS" }, false); // {}
|
|
266
466
|
* ```
|
|
267
467
|
*/
|
|
268
468
|
const provisionResource = async (resource, ci) => {
|
|
269
469
|
switch (resource.kind) {
|
|
270
|
-
case "kv":
|
|
271
|
-
|
|
272
|
-
break;
|
|
470
|
+
case "kv": return provisionKv(resource, ci);
|
|
471
|
+
case "d1": return provisionD1(resource, ci);
|
|
273
472
|
case "r2":
|
|
274
473
|
await provisionR2(resource, ci);
|
|
275
|
-
|
|
276
|
-
case "d1":
|
|
277
|
-
await provisionD1(resource, ci);
|
|
278
|
-
break;
|
|
474
|
+
return {};
|
|
279
475
|
case "queue":
|
|
280
476
|
await provisionQueue(resource, ci);
|
|
281
|
-
|
|
477
|
+
return {};
|
|
282
478
|
case "do":
|
|
283
479
|
await provisionDurableObject(resource, ci);
|
|
284
|
-
|
|
480
|
+
return {};
|
|
285
481
|
}
|
|
286
482
|
};
|
|
287
483
|
//#endregion
|
|
@@ -315,15 +511,16 @@ const parseJsonc = (source) => {
|
|
|
315
511
|
* Build the wrangler `kv_namespaces` array from the manifest's kv resources.
|
|
316
512
|
*
|
|
317
513
|
* @param resources - All resource descriptors from the manifest.
|
|
318
|
-
* @
|
|
514
|
+
* @param ids - Captured Cloudflare ids keyed by binding; the entry's `id` is filled from here.
|
|
515
|
+
* @returns One wrangler KV namespace entry per kv resource (real `id` when known, else "").
|
|
319
516
|
* @example
|
|
320
517
|
* ```ts
|
|
321
|
-
* const kv = buildKvNamespaces([{ kind: "kv", binding: "CACHE" }]);
|
|
518
|
+
* const kv = buildKvNamespaces([{ kind: "kv", binding: "CACHE" }], { CACHE: "ns123" });
|
|
322
519
|
* ```
|
|
323
520
|
*/
|
|
324
|
-
const buildKvNamespaces = (resources) => resources.filter((resource) => resource.kind === "kv").map((resource) => ({
|
|
521
|
+
const buildKvNamespaces = (resources, ids) => resources.filter((resource) => resource.kind === "kv").map((resource) => ({
|
|
325
522
|
binding: resource.binding,
|
|
326
|
-
id: ""
|
|
523
|
+
id: ids[resource.binding] ?? ""
|
|
327
524
|
}));
|
|
328
525
|
/**
|
|
329
526
|
* Build the wrangler `r2_buckets` array from the manifest's r2 resources.
|
|
@@ -343,17 +540,18 @@ const buildR2Buckets = (resources) => resources.filter((resource) => resource.ki
|
|
|
343
540
|
* Build the wrangler `d1_databases` array from the manifest's d1 resources.
|
|
344
541
|
*
|
|
345
542
|
* @param resources - All resource descriptors from the manifest.
|
|
543
|
+
* @param ids - Captured Cloudflare ids keyed by binding; the entry's `database_id` is filled from here.
|
|
346
544
|
* @returns One wrangler D1 database entry per d1 resource (migrations_dir set when present).
|
|
347
545
|
* @example
|
|
348
546
|
* ```ts
|
|
349
|
-
* const d1 = buildD1Databases([{ kind: "d1", binding: "DB" }]);
|
|
547
|
+
* const d1 = buildD1Databases([{ kind: "d1", binding: "DB" }], { DB: "uuid-1234" });
|
|
350
548
|
* ```
|
|
351
549
|
*/
|
|
352
|
-
const buildD1Databases = (resources) => resources.filter((resource) => resource.kind === "d1").map((resource) => {
|
|
550
|
+
const buildD1Databases = (resources, ids) => resources.filter((resource) => resource.kind === "d1").map((resource) => {
|
|
353
551
|
const entry = {
|
|
354
552
|
binding: resource.binding,
|
|
355
553
|
database_name: resource.binding.toLowerCase(),
|
|
356
|
-
database_id: ""
|
|
554
|
+
database_id: ids[resource.binding] ?? ""
|
|
357
555
|
};
|
|
358
556
|
if (resource.migrations) entry.migrations_dir = resource.migrations;
|
|
359
557
|
return entry;
|
|
@@ -402,22 +600,24 @@ const buildDurableObjects = (resources) => {
|
|
|
402
600
|
*
|
|
403
601
|
* @param configFile - Path to the wrangler config file.
|
|
404
602
|
* @param manifest - The assembled deploy manifest.
|
|
603
|
+
* @param ids - Captured Cloudflare ids keyed by binding (kv namespace id, d1 database id). Defaults
|
|
604
|
+
* to an empty map, in which case `id`/`database_id` are written as "" (e.g. the universal path).
|
|
405
605
|
* @returns Resolves once the file is written.
|
|
406
606
|
* @example
|
|
407
607
|
* ```ts
|
|
408
|
-
* await writeWranglerConfig("wrangler.jsonc", manifest);
|
|
608
|
+
* await writeWranglerConfig("wrangler.jsonc", manifest, { CACHE: "ns123", DB: "uuid-1234" });
|
|
409
609
|
* ```
|
|
410
610
|
*/
|
|
411
|
-
const writeWranglerConfig = async (configFile, manifest) => {
|
|
611
|
+
const writeWranglerConfig = async (configFile, manifest, ids = {}) => {
|
|
412
612
|
let existing = {};
|
|
413
613
|
if (existsSync(configFile)) try {
|
|
414
614
|
existing = parseJsonc(readFileSync(configFile, "utf8"));
|
|
415
615
|
} catch {
|
|
416
616
|
existing = {};
|
|
417
617
|
}
|
|
418
|
-
const kvNamespaces = buildKvNamespaces(manifest.resources);
|
|
618
|
+
const kvNamespaces = buildKvNamespaces(manifest.resources, ids);
|
|
419
619
|
const r2Buckets = buildR2Buckets(manifest.resources);
|
|
420
|
-
const d1Databases = buildD1Databases(manifest.resources);
|
|
620
|
+
const d1Databases = buildD1Databases(manifest.resources, ids);
|
|
421
621
|
const queues = buildQueues(manifest.resources);
|
|
422
622
|
const durableObjects = buildDurableObjects(manifest.resources);
|
|
423
623
|
const updated = {
|
|
@@ -456,21 +656,22 @@ const scaffoldWranglerAndCi = async (configFile, _ci) => {
|
|
|
456
656
|
//#endregion
|
|
457
657
|
//#region src/plugins/deploy/api.ts
|
|
458
658
|
/**
|
|
459
|
-
* @file deploy plugin — API factory (run, dev, init).
|
|
659
|
+
* @file deploy plugin — API factory (run, dev, init, checkInfra, provisionInfra).
|
|
460
660
|
*
|
|
461
661
|
* Pure ctx-taking factory. Assembles the deploy manifest from each resource plugin's own
|
|
462
|
-
* deployManifest() api (never sibling pluginConfigs — design F6),
|
|
463
|
-
* generates/updates the wrangler config, uploads the
|
|
464
|
-
* Emits only global events: deploy:phase,
|
|
662
|
+
* deployManifest() api (never sibling pluginConfigs — design F6), runs an infra preflight
|
|
663
|
+
* (check-before-create + capture real ids), generates/updates the wrangler config, uploads the
|
|
664
|
+
* R2 upload dir, and runs wrangler deploy. Emits only global events: deploy:phase,
|
|
665
|
+
* deploy:complete, provision:resource, provision:plan, provision:skip.
|
|
465
666
|
*
|
|
466
|
-
* Node-only: uses node:child_process (via runner.ts)
|
|
467
|
-
* Never called in the deployed Worker runtime.
|
|
667
|
+
* Node-only: uses node:child_process (via runner.ts), node:fs (via wrangler-config.ts), and the
|
|
668
|
+
* Cloudflare REST API (via infra/). Never called in the deployed Worker runtime.
|
|
468
669
|
*/
|
|
469
670
|
/**
|
|
470
|
-
* Derive a human-readable name string from a resource descriptor (used in provision
|
|
671
|
+
* Derive a human-readable name string from a resource descriptor (used in provision events).
|
|
471
672
|
*
|
|
472
673
|
* @param resource - The resource descriptor.
|
|
473
|
-
* @returns A name suitable for the provision:resource event payload.
|
|
674
|
+
* @returns A name suitable for the provision:resource / provision:skip event payload.
|
|
474
675
|
* @example
|
|
475
676
|
* ```ts
|
|
476
677
|
* resourceName({ kind: "kv", binding: "CACHE" }); // "CACHE"
|
|
@@ -485,12 +686,74 @@ const resourceName = (resource) => {
|
|
|
485
686
|
}
|
|
486
687
|
};
|
|
487
688
|
/**
|
|
488
|
-
*
|
|
489
|
-
*
|
|
490
|
-
* and runs `wrangler deploy`, emitting global deploy events along the way.
|
|
689
|
+
* Assemble the deploy manifest from each present resource plugin's OWN deployManifest() api,
|
|
690
|
+
* gated by ctx.has(name) so absent plugins are skipped — never sibling pluginConfigs (F6).
|
|
491
691
|
*
|
|
492
|
-
* @param ctx -
|
|
493
|
-
* @returns The
|
|
692
|
+
* @param ctx - The deploy plugin context.
|
|
693
|
+
* @returns The assembled manifest (name, compatibilityDate, resources).
|
|
694
|
+
* @example
|
|
695
|
+
* ```ts
|
|
696
|
+
* const manifest = assembleManifest(ctx);
|
|
697
|
+
* ```
|
|
698
|
+
*/
|
|
699
|
+
const assembleManifest = (ctx) => ({
|
|
700
|
+
name: ctx.global.name,
|
|
701
|
+
compatibilityDate: ctx.global.compatibilityDate,
|
|
702
|
+
resources: [
|
|
703
|
+
ctx.has("storage") ? ctx.require(storagePlugin).deployManifest() : void 0,
|
|
704
|
+
ctx.has("kv") ? ctx.require(kvPlugin).deployManifest() : void 0,
|
|
705
|
+
ctx.has("d1") ? ctx.require(d1Plugin).deployManifest() : void 0,
|
|
706
|
+
ctx.has("queues") ? ctx.require(queuesPlugin).deployManifest() : void 0,
|
|
707
|
+
ctx.has("durableObjects") ? ctx.require(durableObjectsPlugin).deployManifest() : void 0
|
|
708
|
+
].filter((resource) => resource !== void 0)
|
|
709
|
+
});
|
|
710
|
+
/**
|
|
711
|
+
* Act on an infra plan: skip the resources that already exist (reusing their ids), create only
|
|
712
|
+
* the missing ones (capturing each new id), and announce each via provision:skip / :resource.
|
|
713
|
+
*
|
|
714
|
+
* @param ctx - The deploy plugin context.
|
|
715
|
+
* @param plan - The infra plan from planInfra (existing vs missing).
|
|
716
|
+
* @returns The provisioning result: created, skipped, and the merged binding → id map.
|
|
717
|
+
* @example
|
|
718
|
+
* ```ts
|
|
719
|
+
* const { ids } = await applyPlan(ctx, plan);
|
|
720
|
+
* ```
|
|
721
|
+
*/
|
|
722
|
+
const applyPlan = async (ctx, plan) => {
|
|
723
|
+
const ids = {};
|
|
724
|
+
for (const ref of plan.exists) {
|
|
725
|
+
if (ref.id !== void 0 && (ref.resource.kind === "kv" || ref.resource.kind === "d1")) ids[ref.resource.binding] = ref.id;
|
|
726
|
+
ctx.emit("provision:skip", {
|
|
727
|
+
kind: ref.resource.kind,
|
|
728
|
+
name: resourceName(ref.resource)
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
const created = [];
|
|
732
|
+
for (const resource of plan.missing) {
|
|
733
|
+
const { id } = await provisionResource(resource, ctx.config.ci);
|
|
734
|
+
if (id !== void 0 && (resource.kind === "kv" || resource.kind === "d1")) ids[resource.binding] = id;
|
|
735
|
+
created.push(id === void 0 ? { resource } : {
|
|
736
|
+
resource,
|
|
737
|
+
id
|
|
738
|
+
});
|
|
739
|
+
ctx.emit("provision:resource", {
|
|
740
|
+
kind: resource.kind,
|
|
741
|
+
name: resourceName(resource)
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
return {
|
|
745
|
+
created,
|
|
746
|
+
skipped: plan.exists,
|
|
747
|
+
ids
|
|
748
|
+
};
|
|
749
|
+
};
|
|
750
|
+
/**
|
|
751
|
+
* Create the deploy api. Assembles the manifest from each resource plugin's own deployManifest(),
|
|
752
|
+
* runs an infra preflight (check-before-create + id capture), generates config, uploads, and runs
|
|
753
|
+
* `wrangler deploy`, emitting global deploy events along the way.
|
|
754
|
+
*
|
|
755
|
+
* @param ctx - Plugin context (own config + require + has + emit + global + env).
|
|
756
|
+
* @returns The app.deploy api: run / dev / init / checkInfra / provisionInfra.
|
|
494
757
|
* @example
|
|
495
758
|
* ```ts
|
|
496
759
|
* const api = createDeployApi(ctx);
|
|
@@ -499,12 +762,13 @@ const resourceName = (resource) => {
|
|
|
499
762
|
*/
|
|
500
763
|
const createDeployApi = (ctx) => ({
|
|
501
764
|
/**
|
|
502
|
-
* Run the full deploy pipeline: detect →
|
|
503
|
-
*
|
|
765
|
+
* Run the full deploy pipeline: detect → preflight (check-before-create) → provision (only the
|
|
766
|
+
* missing) → wrangler-config (with real ids) → upload → deploy. When opts.manifest is supplied
|
|
767
|
+
* it is used verbatim (universal path).
|
|
504
768
|
*
|
|
505
769
|
* @param opts - Optional run options.
|
|
506
|
-
* @param opts.guided - Enable interactive confirmation steps (
|
|
507
|
-
* @param opts.yes - Auto-confirm all prompts.
|
|
770
|
+
* @param opts.guided - Enable interactive confirmation steps (wired in a later phase).
|
|
771
|
+
* @param opts.yes - Auto-confirm all prompts (wired in a later phase).
|
|
508
772
|
* @param opts.manifest - Caller-supplied manifest (bypasses deployManifest() assembly).
|
|
509
773
|
* @returns Resolves once the deploy completes.
|
|
510
774
|
* @example
|
|
@@ -515,27 +779,11 @@ const createDeployApi = (ctx) => ({
|
|
|
515
779
|
*/
|
|
516
780
|
async run(opts) {
|
|
517
781
|
ctx.emit("deploy:phase", { phase: "detect" });
|
|
518
|
-
const manifest = opts?.manifest ??
|
|
519
|
-
name: ctx.global.name,
|
|
520
|
-
compatibilityDate: ctx.global.compatibilityDate,
|
|
521
|
-
resources: [
|
|
522
|
-
ctx.has("storage") ? ctx.require(storagePlugin).deployManifest() : void 0,
|
|
523
|
-
ctx.has("kv") ? ctx.require(kvPlugin).deployManifest() : void 0,
|
|
524
|
-
ctx.has("d1") ? ctx.require(d1Plugin).deployManifest() : void 0,
|
|
525
|
-
ctx.has("queues") ? ctx.require(queuesPlugin).deployManifest() : void 0,
|
|
526
|
-
ctx.has("durableObjects") ? ctx.require(durableObjectsPlugin).deployManifest() : void 0
|
|
527
|
-
].filter((resource) => resource !== void 0)
|
|
528
|
-
};
|
|
782
|
+
const manifest = opts?.manifest ?? assembleManifest(ctx);
|
|
529
783
|
ctx.emit("deploy:phase", { phase: "provision" });
|
|
530
|
-
|
|
531
|
-
await provisionResource(resource, ctx.config.ci);
|
|
532
|
-
ctx.emit("provision:resource", {
|
|
533
|
-
kind: resource.kind,
|
|
534
|
-
name: resourceName(resource)
|
|
535
|
-
});
|
|
536
|
-
}
|
|
784
|
+
const { ids } = await applyPlan(ctx, await planInfra(ctx, manifest));
|
|
537
785
|
ctx.emit("deploy:phase", { phase: "wrangler-config" });
|
|
538
|
-
await writeWranglerConfig(ctx.config.configFile, manifest);
|
|
786
|
+
await writeWranglerConfig(ctx.config.configFile, manifest, ids);
|
|
539
787
|
const r2Resource = manifest.resources.find((resource) => resource.kind === "r2");
|
|
540
788
|
if (r2Resource?.upload) {
|
|
541
789
|
const count = await uploadDirToR2(r2Resource.bucket, r2Resource.upload);
|
|
@@ -586,7 +834,29 @@ const createDeployApi = (ctx) => ({
|
|
|
586
834
|
*/
|
|
587
835
|
init: async (opts) => {
|
|
588
836
|
await scaffoldWranglerAndCi(ctx.config.configFile, opts?.ci ?? ctx.config.ci);
|
|
589
|
-
}
|
|
837
|
+
},
|
|
838
|
+
/**
|
|
839
|
+
* Read-only infra preflight: assemble the manifest, resolve the account, list what exists in
|
|
840
|
+
* Cloudflare, diff, emit provision:plan, and return the plan. Writes nothing.
|
|
841
|
+
*
|
|
842
|
+
* @returns The infra plan (existing vs missing resources, with captured ids).
|
|
843
|
+
* @example
|
|
844
|
+
* ```ts
|
|
845
|
+
* const plan = await api.checkInfra();
|
|
846
|
+
* ```
|
|
847
|
+
*/
|
|
848
|
+
checkInfra: () => planInfra(ctx, assembleManifest(ctx)),
|
|
849
|
+
/**
|
|
850
|
+
* Create only the resources missing from the plan (skipping existing), capturing each id.
|
|
851
|
+
*
|
|
852
|
+
* @param plan - A plan produced by checkInfra().
|
|
853
|
+
* @returns The provisioning result: created, skipped, and the merged id map.
|
|
854
|
+
* @example
|
|
855
|
+
* ```ts
|
|
856
|
+
* const { created } = await api.provisionInfra(await api.checkInfra());
|
|
857
|
+
* ```
|
|
858
|
+
*/
|
|
859
|
+
provisionInfra: (plan) => applyPlan(ctx, plan)
|
|
590
860
|
});
|
|
591
861
|
/**
|
|
592
862
|
* Complex tier (node-only) — build-time deploy orchestrator over the five resource plugins.
|
|
@@ -699,6 +969,18 @@ const createCliHooks = (ctx) => ({
|
|
|
699
969
|
ctx.log.info(p.detail ? `${p.phase} · ${p.detail}` : p.phase);
|
|
700
970
|
},
|
|
701
971
|
/**
|
|
972
|
+
* Log the infra preflight summary: "infra · N exist, M to create · account".
|
|
973
|
+
*
|
|
974
|
+
* @param p - The provision:plan event payload.
|
|
975
|
+
* @example
|
|
976
|
+
* ```ts
|
|
977
|
+
* handler({ exists: 2, missing: 1, account: "Play Co" }); // "infra · 2 exist, 1 to create · Play Co"
|
|
978
|
+
* ```
|
|
979
|
+
*/
|
|
980
|
+
"provision:plan"(p) {
|
|
981
|
+
ctx.log.info(`infra · ${p.exists} exist, ${p.missing} to create · ${p.account}`);
|
|
982
|
+
},
|
|
983
|
+
/**
|
|
702
984
|
* Log one clean line per provisioned resource: "kind name".
|
|
703
985
|
*
|
|
704
986
|
* @param p - The provision:resource event payload.
|
|
@@ -711,6 +993,18 @@ const createCliHooks = (ctx) => ({
|
|
|
711
993
|
ctx.log.info(`${p.kind} ${p.name}`);
|
|
712
994
|
},
|
|
713
995
|
/**
|
|
996
|
+
* Log one clean line per already-existing resource (skipped): "kind name (exists)".
|
|
997
|
+
*
|
|
998
|
+
* @param p - The provision:skip event payload.
|
|
999
|
+
* @example
|
|
1000
|
+
* ```ts
|
|
1001
|
+
* handler({ kind: "kv", name: "KV" }); // "kv KV (exists)"
|
|
1002
|
+
* ```
|
|
1003
|
+
*/
|
|
1004
|
+
"provision:skip"(p) {
|
|
1005
|
+
ctx.log.info(`${p.kind} ${p.name} (exists)`);
|
|
1006
|
+
},
|
|
1007
|
+
/**
|
|
714
1008
|
* Log the terminal success line with the deployed URL.
|
|
715
1009
|
*
|
|
716
1010
|
* @param p - The deploy:complete event payload.
|
|
@@ -33,6 +33,15 @@ type WorkerEvents = {
|
|
|
33
33
|
kind: "kv" | "r2" | "d1" | "queue" | "do";
|
|
34
34
|
name: string;
|
|
35
35
|
};
|
|
36
|
+
"provision:plan": {
|
|
37
|
+
exists: number;
|
|
38
|
+
missing: number;
|
|
39
|
+
account: string;
|
|
40
|
+
};
|
|
41
|
+
"provision:skip": {
|
|
42
|
+
kind: "kv" | "r2" | "d1" | "queue" | "do";
|
|
43
|
+
name: string;
|
|
44
|
+
};
|
|
36
45
|
};
|
|
37
46
|
/**
|
|
38
47
|
* Worker-bound plugin context for Layer-3 consumer plugins. Aliases the core
|