@moku-labs/worker 0.3.1 → 0.5.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 +1220 -93
- package/dist/cli.d.cts +183 -54
- package/dist/cli.d.mts +183 -54
- package/dist/cli.mjs +1221 -94
- package/dist/{config-Bj3GUJT_.d.cts → config-BYPJvEbl.d.cts} +25 -0
- package/dist/{config-Bj3GUJT_.d.mts → config-BYPJvEbl.d.mts} +25 -0
- package/dist/index.d.cts +2 -2
- package/dist/index.d.mts +1 -1
- package/package.json +9 -1
package/dist/cli.mjs
CHANGED
|
@@ -1,9 +1,379 @@
|
|
|
1
1
|
import { i as durableObjectsPlugin, n as queuesPlugin, o as d1Plugin, r as kvPlugin, t as storagePlugin, u as createPlugin } from "./storage-COo-F38H.mjs";
|
|
2
|
-
import { brandedSink } from "@moku-labs/common/cli";
|
|
2
|
+
import { brandedSink, createBrandConsole, createBrandPrompts } from "@moku-labs/common/cli";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
|
-
import {
|
|
4
|
+
import { existsSync, readFileSync, watch } from "node:fs";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import {
|
|
6
|
+
import { readdir, stat, writeFile } from "node:fs/promises";
|
|
7
|
+
//#region src/plugins/deploy/auth/permissions.ts
|
|
8
|
+
/** Permission groups every deploy needs, regardless of resources. */
|
|
9
|
+
const ALWAYS = [{
|
|
10
|
+
group: "Account · Workers Scripts",
|
|
11
|
+
scope: "Edit",
|
|
12
|
+
reason: "deploy",
|
|
13
|
+
inBaseTemplate: true
|
|
14
|
+
}, {
|
|
15
|
+
group: "Account · Account Settings",
|
|
16
|
+
scope: "Read",
|
|
17
|
+
reason: "account",
|
|
18
|
+
inBaseTemplate: true
|
|
19
|
+
}];
|
|
20
|
+
/**
|
|
21
|
+
* Per-resource-kind permission group. `do` needs nothing extra (Durable Objects ship with the
|
|
22
|
+
* Worker script, covered by Workers Scripts · Edit). `d1`/`queue` are NOT in the stock template.
|
|
23
|
+
*/
|
|
24
|
+
const BY_KIND = {
|
|
25
|
+
kv: {
|
|
26
|
+
group: "Account · Workers KV Storage",
|
|
27
|
+
scope: "Edit",
|
|
28
|
+
reason: "kv",
|
|
29
|
+
inBaseTemplate: true
|
|
30
|
+
},
|
|
31
|
+
r2: {
|
|
32
|
+
group: "Account · Workers R2 Storage",
|
|
33
|
+
scope: "Edit",
|
|
34
|
+
reason: "r2",
|
|
35
|
+
inBaseTemplate: true
|
|
36
|
+
},
|
|
37
|
+
d1: {
|
|
38
|
+
group: "Account · D1",
|
|
39
|
+
scope: "Edit",
|
|
40
|
+
reason: "d1",
|
|
41
|
+
inBaseTemplate: false
|
|
42
|
+
},
|
|
43
|
+
queue: {
|
|
44
|
+
group: "Account · Queues",
|
|
45
|
+
scope: "Edit",
|
|
46
|
+
reason: "queue",
|
|
47
|
+
inBaseTemplate: false
|
|
48
|
+
},
|
|
49
|
+
do: void 0
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Derive the Cloudflare API token requirement from an app manifest: the full permission set plus
|
|
53
|
+
* the subset that must be ADDED to the stock "Edit Cloudflare Workers" template.
|
|
54
|
+
*
|
|
55
|
+
* @param manifest - The assembled deploy manifest.
|
|
56
|
+
* @returns The token requirement (base template, full required set, and groups to add).
|
|
57
|
+
* @example
|
|
58
|
+
* ```ts
|
|
59
|
+
* const { toAdd } = requiredToken({ name: "w", compatibilityDate: "…", resources: [{ kind: "d1", binding: "DB" }] });
|
|
60
|
+
* // toAdd → [{ group: "Account · D1", scope: "Edit", … }]
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
const requiredToken = (manifest) => {
|
|
64
|
+
const required = [...ALWAYS];
|
|
65
|
+
const seen = new Set(required.map((permission) => permission.group));
|
|
66
|
+
for (const resource of manifest.resources) {
|
|
67
|
+
const permission = BY_KIND[resource.kind];
|
|
68
|
+
if (permission !== void 0 && !seen.has(permission.group)) {
|
|
69
|
+
required.push(permission);
|
|
70
|
+
seen.add(permission.group);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
base: "Edit Cloudflare Workers",
|
|
75
|
+
required,
|
|
76
|
+
toAdd: required.filter((permission) => !permission.inBaseTemplate)
|
|
77
|
+
};
|
|
78
|
+
};
|
|
79
|
+
/** Permission every CI/automation redeploy needs: ship the Worker script. */
|
|
80
|
+
const CI_ALWAYS = [{
|
|
81
|
+
group: "Account · Workers Scripts",
|
|
82
|
+
scope: "Edit",
|
|
83
|
+
reason: "deploy",
|
|
84
|
+
inBaseTemplate: true
|
|
85
|
+
}];
|
|
86
|
+
/**
|
|
87
|
+
* Per-resource-kind permission for the CI/automation token. After a first LOCAL deploy has
|
|
88
|
+
* provisioned everything, CI only needs to LIST existing infra (the idempotent preflight) and
|
|
89
|
+
* ship — so data resources drop to `Read`; R2 stays `Edit` because asset upload writes objects.
|
|
90
|
+
*/
|
|
91
|
+
const CI_BY_KIND = {
|
|
92
|
+
kv: {
|
|
93
|
+
group: "Account · Workers KV Storage",
|
|
94
|
+
scope: "Read",
|
|
95
|
+
reason: "kv (preflight)",
|
|
96
|
+
inBaseTemplate: true
|
|
97
|
+
},
|
|
98
|
+
r2: {
|
|
99
|
+
group: "Account · Workers R2 Storage",
|
|
100
|
+
scope: "Edit",
|
|
101
|
+
reason: "r2 (asset upload)",
|
|
102
|
+
inBaseTemplate: true
|
|
103
|
+
},
|
|
104
|
+
d1: {
|
|
105
|
+
group: "Account · D1",
|
|
106
|
+
scope: "Read",
|
|
107
|
+
reason: "d1 (preflight)",
|
|
108
|
+
inBaseTemplate: false
|
|
109
|
+
},
|
|
110
|
+
queue: {
|
|
111
|
+
group: "Account · Queues",
|
|
112
|
+
scope: "Read",
|
|
113
|
+
reason: "queue (preflight)",
|
|
114
|
+
inBaseTemplate: false
|
|
115
|
+
},
|
|
116
|
+
do: void 0
|
|
117
|
+
};
|
|
118
|
+
/**
|
|
119
|
+
* Derive the REDUCED Cloudflare API token for CI/automation redeploys, from the same manifest.
|
|
120
|
+
* Assumes a prior LOCAL deploy already provisioned the infra, so CI never creates: data resources
|
|
121
|
+
* need only `Read` (the idempotent preflight lists them), R2 keeps `Edit` for asset upload, and no
|
|
122
|
+
* `Account Settings · Read` is needed because CI pins `CLOUDFLARE_ACCOUNT_ID`. Pure: no network.
|
|
123
|
+
*
|
|
124
|
+
* @param manifest - The assembled deploy manifest.
|
|
125
|
+
* @returns The minimum permission groups for a CI redeploy token (deduped, manifest-scoped).
|
|
126
|
+
* @example
|
|
127
|
+
* ```ts
|
|
128
|
+
* const groups = ciToken({ name: "w", compatibilityDate: "…", resources: [{ kind: "d1", binding: "DB" }] });
|
|
129
|
+
* // → [Workers Scripts·Edit, D1·Read]
|
|
130
|
+
* ```
|
|
131
|
+
*/
|
|
132
|
+
const ciToken = (manifest) => {
|
|
133
|
+
const groups = [...CI_ALWAYS];
|
|
134
|
+
const seen = new Set(groups.map((permission) => permission.group));
|
|
135
|
+
for (const resource of manifest.resources) {
|
|
136
|
+
const permission = CI_BY_KIND[resource.kind];
|
|
137
|
+
if (permission !== void 0 && !seen.has(permission.group)) {
|
|
138
|
+
groups.push(permission);
|
|
139
|
+
seen.add(permission.group);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return groups;
|
|
143
|
+
};
|
|
144
|
+
//#endregion
|
|
145
|
+
//#region src/plugins/deploy/auth/setup.ts
|
|
146
|
+
/** Cloudflare's dashboard path for creating API tokens. */
|
|
147
|
+
const TOKENS_URL = "https://dash.cloudflare.com/profile/api-tokens";
|
|
148
|
+
/**
|
|
149
|
+
* Render the FULL local-first token section (the deploy that provisions everything): the permission
|
|
150
|
+
* table flagging template-missing rows, the template + "add these" steps, and the `.env.local` lines.
|
|
151
|
+
*
|
|
152
|
+
* @param requirement - The full token requirement (from requiredToken()).
|
|
153
|
+
* @returns The local-first section lines.
|
|
154
|
+
* @example
|
|
155
|
+
* ```ts
|
|
156
|
+
* const lines = localSection(requiredToken(manifest));
|
|
157
|
+
* ```
|
|
158
|
+
*/
|
|
159
|
+
const localSection = (requirement) => {
|
|
160
|
+
const permissionRows = requirement.required.map((permission) => {
|
|
161
|
+
const flag = permission.inBaseTemplate ? "" : " <- add to template";
|
|
162
|
+
return ` - ${permission.group} : ${permission.scope} (${permission.reason})${flag}`;
|
|
163
|
+
});
|
|
164
|
+
const step3 = requirement.toAdd.length > 0 ? [` 3. Under Permissions, ADD: ${requirement.toAdd.map((permission) => `${permission.group.replace("Account · ", "")} -> ${permission.scope}`).join(", ")}`, " (the template omits these; everything else is already included)"] : [` 3. The "${requirement.base}" template covers everything — no changes needed.`];
|
|
165
|
+
return [
|
|
166
|
+
"LOCAL — first deploy (provisions infra). A Cloudflare API token with these permissions:",
|
|
167
|
+
"",
|
|
168
|
+
...permissionRows,
|
|
169
|
+
"",
|
|
170
|
+
"Fastest path:",
|
|
171
|
+
` 1. ${TOKENS_URL} -> Create Token`,
|
|
172
|
+
` 2. Start from the "${requirement.base}" template.`,
|
|
173
|
+
...step3,
|
|
174
|
+
" 4. Account Resources -> Include -> your account.",
|
|
175
|
+
" 5. Create the token, copy it, then add it to .env.local:",
|
|
176
|
+
" CLOUDFLARE_API_TOKEN=<paste your token>",
|
|
177
|
+
" CLOUDFLARE_ACCOUNT_ID=<your account id>",
|
|
178
|
+
" 6. Verify it with `auth` (app.deploy.verifyAuth())."
|
|
179
|
+
];
|
|
180
|
+
};
|
|
181
|
+
/**
|
|
182
|
+
* Render the REDUCED CI/automation token section (redeploy-only): the scoped permission table plus
|
|
183
|
+
* the CI-secret + account-pin steps.
|
|
184
|
+
*
|
|
185
|
+
* @param groups - The CI permission groups (from ciToken()).
|
|
186
|
+
* @returns The CI section lines.
|
|
187
|
+
* @example
|
|
188
|
+
* ```ts
|
|
189
|
+
* const lines = ciSection(ciToken(manifest));
|
|
190
|
+
* ```
|
|
191
|
+
*/
|
|
192
|
+
const ciSection = (groups) => {
|
|
193
|
+
return [
|
|
194
|
+
"CI — automation redeploy (infra already provisioned by a local deploy). A SCOPED token with:",
|
|
195
|
+
"",
|
|
196
|
+
...groups.map((permission) => ` - ${permission.group} : ${permission.scope} (${permission.reason})`),
|
|
197
|
+
"",
|
|
198
|
+
` 1. ${TOKENS_URL} -> Create Token -> Create Custom Token.`,
|
|
199
|
+
" 2. Add exactly the permissions above (Read, not Edit, on data resources — CI never creates).",
|
|
200
|
+
" 3. Account Resources -> Include -> your account.",
|
|
201
|
+
" 4. Store it as the CLOUDFLARE_API_TOKEN secret in CI, and PIN the account so no account",
|
|
202
|
+
" lookup (and no Account Settings -> Read) is needed:",
|
|
203
|
+
" CLOUDFLARE_ACCOUNT_ID=<your account id>",
|
|
204
|
+
" CI reuses the same idempotent pipeline — it lists existing infra and ships. To let CI also",
|
|
205
|
+
" CREATE missing infra (self-heal), give it the LOCAL token above instead."
|
|
206
|
+
];
|
|
207
|
+
};
|
|
208
|
+
/**
|
|
209
|
+
* Render the `auth setup` instructions from the app manifest: the FULL local-first token (provisions
|
|
210
|
+
* everything) followed by the REDUCED CI/automation token (redeploy-only).
|
|
211
|
+
*
|
|
212
|
+
* @param manifest - The assembled deploy manifest.
|
|
213
|
+
* @returns A multi-line instruction string covering both tokens.
|
|
214
|
+
* @example
|
|
215
|
+
* ```ts
|
|
216
|
+
* const text = tokenInstructions(manifest);
|
|
217
|
+
* ```
|
|
218
|
+
*/
|
|
219
|
+
const tokenInstructions = (manifest) => [
|
|
220
|
+
...localSection(requiredToken(manifest)),
|
|
221
|
+
"",
|
|
222
|
+
...ciSection(ciToken(manifest))
|
|
223
|
+
].join("\n");
|
|
224
|
+
//#endregion
|
|
225
|
+
//#region src/plugins/deploy/infra/cloudflare.ts
|
|
226
|
+
/**
|
|
227
|
+
* @file deploy plugin — Cloudflare REST discovery client (infra preflight).
|
|
228
|
+
*
|
|
229
|
+
* Lists what already exists in a Cloudflare account so the deploy pipeline can create only the
|
|
230
|
+
* missing resources (idempotent provisioning) and recover real ids for existing kv/d1 bindings.
|
|
231
|
+
* Authenticated with the `.env` API token (CLOUDFLARE_API_TOKEN) — never an interactive login.
|
|
232
|
+
* Uses the global `fetch`; node-only, never imported by the runtime Worker bundle.
|
|
233
|
+
*/
|
|
234
|
+
const API_BASE = "https://api.cloudflare.com/client/v4";
|
|
235
|
+
/**
|
|
236
|
+
* GET a Cloudflare API path with the bearer token and unwrap the `result`.
|
|
237
|
+
*
|
|
238
|
+
* @param token - The Cloudflare API token (CLOUDFLARE_API_TOKEN).
|
|
239
|
+
* @param path - API path beneath the v4 base (e.g. "/accounts").
|
|
240
|
+
* @returns The unwrapped `result` payload, typed by the caller.
|
|
241
|
+
* @throws {Error} When the HTTP request fails or the API reports `success: false`.
|
|
242
|
+
* @example
|
|
243
|
+
* ```ts
|
|
244
|
+
* const accounts = await cfGet<Array<{ id: string }>>(token, "/accounts");
|
|
245
|
+
* ```
|
|
246
|
+
*/
|
|
247
|
+
const cfGet = async (token, path) => {
|
|
248
|
+
const response = await fetch(`${API_BASE}${path}`, { headers: {
|
|
249
|
+
Authorization: `Bearer ${token}`,
|
|
250
|
+
"Content-Type": "application/json"
|
|
251
|
+
} });
|
|
252
|
+
const body = await response.json();
|
|
253
|
+
if (!response.ok || !body.success) {
|
|
254
|
+
const detail = body.errors?.map((error) => error.message).join("; ") || `HTTP ${response.status}`;
|
|
255
|
+
throw new Error(`[moku-worker] Cloudflare API request failed (${path}): ${detail}`);
|
|
256
|
+
}
|
|
257
|
+
return body.result;
|
|
258
|
+
};
|
|
259
|
+
/**
|
|
260
|
+
* Resolve the Cloudflare account (id + display name) accessible to the token. Used when the
|
|
261
|
+
* consumer did not pin CLOUDFLARE_ACCOUNT_ID; the first accessible account is chosen.
|
|
262
|
+
*
|
|
263
|
+
* @param token - The Cloudflare API token.
|
|
264
|
+
* @returns The resolved account id and name.
|
|
265
|
+
* @throws {Error} When the token can access no account.
|
|
266
|
+
* @example
|
|
267
|
+
* ```ts
|
|
268
|
+
* const { id, name } = await resolveAccount(token);
|
|
269
|
+
* ```
|
|
270
|
+
*/
|
|
271
|
+
const resolveAccount = async (token) => {
|
|
272
|
+
const first = (await cfGet(token, "/accounts"))[0];
|
|
273
|
+
if (!first) throw new Error("[moku-worker] No Cloudflare account is accessible with this API token.");
|
|
274
|
+
return {
|
|
275
|
+
id: first.id,
|
|
276
|
+
name: first.name
|
|
277
|
+
};
|
|
278
|
+
};
|
|
279
|
+
/**
|
|
280
|
+
* Verify a Cloudflare API token via `GET /user/tokens/verify`. Returns its status (`"active"` for
|
|
281
|
+
* a usable token); throws (via cfGet) when the token is rejected outright (401/invalid).
|
|
282
|
+
*
|
|
283
|
+
* @param token - The Cloudflare API token to verify.
|
|
284
|
+
* @returns The token status string reported by Cloudflare.
|
|
285
|
+
* @throws {Error} When the verify request fails (invalid/expired token).
|
|
286
|
+
* @example
|
|
287
|
+
* ```ts
|
|
288
|
+
* const { status } = await verifyToken(token); // status === "active"
|
|
289
|
+
* ```
|
|
290
|
+
*/
|
|
291
|
+
const verifyToken = async (token) => {
|
|
292
|
+
return { status: (await cfGet(token, "/user/tokens/verify")).status };
|
|
293
|
+
};
|
|
294
|
+
/**
|
|
295
|
+
* List the resources that already exist in the account, querying ONLY the kinds the app declares
|
|
296
|
+
* (one request per declared kind, in parallel), indexed for the preflight diff. Scoping to the
|
|
297
|
+
* declared kinds keeps the API token minimal — an app with only KV never lists (and so never needs
|
|
298
|
+
* read permission on) D1, R2, or Queues.
|
|
299
|
+
*
|
|
300
|
+
* @param token - The Cloudflare API token.
|
|
301
|
+
* @param accountId - The Cloudflare account id to scope the listings to.
|
|
302
|
+
* @param kinds - The resource kinds present in the manifest (the only kinds queried).
|
|
303
|
+
* @returns The existing resources, indexed by kind (un-queried kinds resolve empty).
|
|
304
|
+
* @throws {Error} When any listing request fails.
|
|
305
|
+
* @example
|
|
306
|
+
* ```ts
|
|
307
|
+
* const existing = await listExisting(token, accountId, new Set(["kv", "d1"]));
|
|
308
|
+
* if (existing.kv.has("SESSIONS")) { ... }
|
|
309
|
+
* ```
|
|
310
|
+
*/
|
|
311
|
+
const listExisting = async (token, accountId, kinds) => {
|
|
312
|
+
const base = `/accounts/${accountId}`;
|
|
313
|
+
const [kv, d1, r2, queues] = await Promise.all([
|
|
314
|
+
kinds.has("kv") ? cfGet(token, `${base}/storage/kv/namespaces`) : Promise.resolve([]),
|
|
315
|
+
kinds.has("d1") ? cfGet(token, `${base}/d1/database`) : Promise.resolve([]),
|
|
316
|
+
kinds.has("r2") ? cfGet(token, `${base}/r2/buckets`) : Promise.resolve({}),
|
|
317
|
+
kinds.has("queue") ? cfGet(token, `${base}/queues`) : Promise.resolve([])
|
|
318
|
+
]);
|
|
319
|
+
return {
|
|
320
|
+
kv: new Map(kv.map((namespace) => [namespace.title, namespace.id])),
|
|
321
|
+
d1: new Map(d1.map((database) => [database.name, database.uuid])),
|
|
322
|
+
r2: new Set((r2.buckets ?? []).map((bucket) => bucket.name)),
|
|
323
|
+
queue: new Set(queues.map((queue) => queue.queue_name))
|
|
324
|
+
};
|
|
325
|
+
};
|
|
326
|
+
//#endregion
|
|
327
|
+
//#region src/plugins/deploy/auth/verify.ts
|
|
328
|
+
/**
|
|
329
|
+
* @file deploy plugin — `.env` token verification + account resolution.
|
|
330
|
+
*
|
|
331
|
+
* Reads CLOUDFLARE_API_TOKEN via ctx.env, verifies it is active against the Cloudflare API, and
|
|
332
|
+
* resolves the account. Emits auth:verified. Throws a branded, actionable error (pointing at
|
|
333
|
+
* `auth setup`) when the token is absent, invalid, or inactive — never an interactive login.
|
|
334
|
+
* Node-only; never imported by the runtime Worker bundle.
|
|
335
|
+
*/
|
|
336
|
+
/** Branded hint appended to every auth failure so the user knows the next step. */
|
|
337
|
+
const SETUP_HINT = "Run `auth setup` for the exact token to create.";
|
|
338
|
+
/**
|
|
339
|
+
* Verify the `.env` Cloudflare API token and resolve its account.
|
|
340
|
+
*
|
|
341
|
+
* @param ctx - The deploy plugin context (env + emit).
|
|
342
|
+
* @returns The verified auth status (account + id).
|
|
343
|
+
* @throws {Error} When the token is absent, invalid/expired, or not active.
|
|
344
|
+
* @example
|
|
345
|
+
* ```ts
|
|
346
|
+
* const { account, accountId } = await verifyAuth(ctx);
|
|
347
|
+
* ```
|
|
348
|
+
*/
|
|
349
|
+
const verifyAuth = async (ctx) => {
|
|
350
|
+
const token = ctx.env.get("CLOUDFLARE_API_TOKEN");
|
|
351
|
+
if (token === void 0 || token === "") throw new Error(`[moku-worker] CLOUDFLARE_API_TOKEN is not set. ${SETUP_HINT}`);
|
|
352
|
+
let status;
|
|
353
|
+
try {
|
|
354
|
+
({status} = await verifyToken(token));
|
|
355
|
+
} catch (error) {
|
|
356
|
+
throw new Error(`[moku-worker] Cloudflare API token is invalid or expired. ${SETUP_HINT}`, { cause: error });
|
|
357
|
+
}
|
|
358
|
+
if (status !== "active") throw new Error(`[moku-worker] Cloudflare API token is "${status}", not active. ${SETUP_HINT}`);
|
|
359
|
+
const pinnedAccountId = ctx.env.get("CLOUDFLARE_ACCOUNT_ID");
|
|
360
|
+
const account = pinnedAccountId === void 0 || pinnedAccountId === "" ? await resolveAccount(token) : {
|
|
361
|
+
id: pinnedAccountId,
|
|
362
|
+
name: pinnedAccountId
|
|
363
|
+
};
|
|
364
|
+
ctx.emit("auth:verified", {
|
|
365
|
+
account: account.name,
|
|
366
|
+
accountId: account.id,
|
|
367
|
+
scopes: []
|
|
368
|
+
});
|
|
369
|
+
return {
|
|
370
|
+
ok: true,
|
|
371
|
+
account: account.name,
|
|
372
|
+
accountId: account.id,
|
|
373
|
+
scopes: []
|
|
374
|
+
};
|
|
375
|
+
};
|
|
376
|
+
//#endregion
|
|
7
377
|
//#region src/plugins/deploy/runner.ts
|
|
8
378
|
/**
|
|
9
379
|
* @file deploy plugin — wrangler subprocess wrapper (node:child_process).
|
|
@@ -72,31 +442,453 @@ const runWrangler = (args) => new Promise((resolve, reject) => {
|
|
|
72
442
|
resolve(args[0] === "deploy" ? extractDeployedUrl(stdout) : stdout);
|
|
73
443
|
});
|
|
74
444
|
});
|
|
445
|
+
/**
|
|
446
|
+
* Spawn `wrangler` with the given args, inheriting stdio so its output streams live to the user's
|
|
447
|
+
* terminal (used by the generic passthrough and long-lived commands like `tail`).
|
|
448
|
+
*
|
|
449
|
+
* @param args - Wrangler CLI arguments (e.g. ["kv", "namespace", "list"]).
|
|
450
|
+
* @returns Resolves once wrangler exits successfully.
|
|
451
|
+
* @throws {Error} When wrangler cannot be spawned or exits non-zero.
|
|
452
|
+
* @example
|
|
453
|
+
* ```ts
|
|
454
|
+
* await runWranglerInherit(["kv", "namespace", "list"]);
|
|
455
|
+
* ```
|
|
456
|
+
*/
|
|
457
|
+
const runWranglerInherit = (args) => {
|
|
458
|
+
return new Promise((resolve, reject) => {
|
|
459
|
+
const child = spawn("wrangler", args, { stdio: "inherit" });
|
|
460
|
+
child.on("error", (error) => {
|
|
461
|
+
reject(/* @__PURE__ */ new Error(`[moku-worker] Failed to spawn wrangler.\n ${error.message}`));
|
|
462
|
+
});
|
|
463
|
+
child.on("close", (code) => {
|
|
464
|
+
if (code === 0) {
|
|
465
|
+
resolve();
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
reject(/* @__PURE__ */ new Error(`[moku-worker] wrangler exited with code ${String(code)}.`));
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
};
|
|
472
|
+
//#endregion
|
|
473
|
+
//#region src/plugins/deploy/dev/build.ts
|
|
474
|
+
/**
|
|
475
|
+
* @file deploy plugin — dev site-rebuild resolution.
|
|
476
|
+
*
|
|
477
|
+
* Resolves HOW to rebuild the Moku web site on change: the in-process `webBuild` hook (preferred,
|
|
478
|
+
* fast, typed — passed call-time from the consumer's script or set as a config default) → a
|
|
479
|
+
* `buildCommand` shell string → an auto-detected `scripts/build.ts`. When nothing is configured,
|
|
480
|
+
* dev serves the worker only and says so. Subprocesses inherit the parent env by default.
|
|
481
|
+
* Node-only; never imported by the runtime Worker bundle.
|
|
482
|
+
*/
|
|
483
|
+
/** Convention build script auto-detected when no webBuild/buildCommand is configured. */
|
|
484
|
+
const AUTO_DETECT = "scripts/build.ts";
|
|
485
|
+
/**
|
|
486
|
+
* Run a shell build command, resolving on a zero exit and rejecting otherwise.
|
|
487
|
+
*
|
|
488
|
+
* @param command - The shell command to run (the consumer's own configured build).
|
|
489
|
+
* @returns Resolves once the command exits successfully.
|
|
490
|
+
* @throws {Error} When the command fails to start or exits non-zero.
|
|
491
|
+
* @example
|
|
492
|
+
* ```ts
|
|
493
|
+
* await runShellBuild("bun run scripts/build.ts");
|
|
494
|
+
* ```
|
|
495
|
+
*/
|
|
496
|
+
const runShellBuild = (command) => {
|
|
497
|
+
return new Promise((resolve, reject) => {
|
|
498
|
+
const child = spawn(command, {
|
|
499
|
+
shell: true,
|
|
500
|
+
stdio: "inherit"
|
|
501
|
+
});
|
|
502
|
+
child.on("error", (error) => {
|
|
503
|
+
reject(/* @__PURE__ */ new Error(`[moku-worker] site build failed to start.\n ${error.message}`));
|
|
504
|
+
});
|
|
505
|
+
child.on("close", (code) => {
|
|
506
|
+
if (code === 0) {
|
|
507
|
+
resolve();
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
reject(/* @__PURE__ */ new Error(`[moku-worker] site build exited with code ${String(code)}.`));
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
};
|
|
514
|
+
/**
|
|
515
|
+
* Rebuild the Moku web site using the resolved strategy: the call-time `webBuild` hook (the
|
|
516
|
+
* script-driven path), else the `webBuild` config default, else the `buildCommand` shell string,
|
|
517
|
+
* else an auto-detected `scripts/build.ts`. A hook's result is normalized to a `{ files }` count
|
|
518
|
+
* (0 when the hook reports none, and for the shell path where it is unknown).
|
|
519
|
+
*
|
|
520
|
+
* @param ctx - The deploy plugin context (config + emit).
|
|
521
|
+
* @param webBuild - Optional call-time web build hook (takes precedence over `ctx.config.webBuild`).
|
|
522
|
+
* @returns The rebuilt file count (0 for the shell path / a countless hook).
|
|
523
|
+
* @throws {Error} When the resolved shell build fails.
|
|
524
|
+
* @example
|
|
525
|
+
* ```ts
|
|
526
|
+
* const { files } = await buildSite(ctx, () => web.cli.build());
|
|
527
|
+
* ```
|
|
528
|
+
*/
|
|
529
|
+
const buildSite = async (ctx, webBuild) => {
|
|
530
|
+
const hook = webBuild ?? ctx.config.webBuild;
|
|
531
|
+
if (hook !== void 0) return { files: (await hook())?.files ?? 0 };
|
|
532
|
+
const command = ctx.config.buildCommand || (existsSync(AUTO_DETECT) ? `bun run ${AUTO_DETECT}` : "");
|
|
533
|
+
if (command === "") {
|
|
534
|
+
ctx.emit("dev:error", { message: "No site build configured (pass webBuild or set buildCommand); serving worker only." });
|
|
535
|
+
return { files: 0 };
|
|
536
|
+
}
|
|
537
|
+
await runShellBuild(command);
|
|
538
|
+
return { files: 0 };
|
|
539
|
+
};
|
|
540
|
+
//#endregion
|
|
541
|
+
//#region src/plugins/deploy/dev/watch.ts
|
|
542
|
+
/**
|
|
543
|
+
* @file deploy plugin — debounced filesystem watcher for dev.
|
|
544
|
+
*
|
|
545
|
+
* Watches the top-level directories implied by the config globs (recursive) and fires a debounced
|
|
546
|
+
* change callback with the last changed path. Uses node:fs.watch — no extra dependency.
|
|
547
|
+
* Node-only; never imported by the runtime Worker bundle.
|
|
548
|
+
*/
|
|
549
|
+
/**
|
|
550
|
+
* Derive the set of top-level directories to watch from glob patterns.
|
|
551
|
+
*
|
|
552
|
+
* @param globs - Watch globs (e.g. ["src/**\/*.ts", "public/**\/*"]).
|
|
553
|
+
* @returns The distinct top-level directories (e.g. ["src", "public"]).
|
|
554
|
+
* @example
|
|
555
|
+
* ```ts
|
|
556
|
+
* watchDirectories(["src/**\/*.ts", "public/**\/*"]); // ["src", "public"]
|
|
557
|
+
* ```
|
|
558
|
+
*/
|
|
559
|
+
const watchDirectories = (globs) => {
|
|
560
|
+
const directories = /* @__PURE__ */ new Set();
|
|
561
|
+
for (const glob of globs) {
|
|
562
|
+
const globStart = glob.search(/[*?[{]/u);
|
|
563
|
+
const top = (globStart === -1 ? path.dirname(glob) : glob.slice(0, globStart)).split(/[/\\]/u).find((segment) => segment !== "") ?? ".";
|
|
564
|
+
directories.add(top);
|
|
565
|
+
}
|
|
566
|
+
return [...directories];
|
|
567
|
+
};
|
|
568
|
+
/**
|
|
569
|
+
* Watch the directories implied by `globs` and fire `onChange` (debounced by `debounceMs`) with
|
|
570
|
+
* the last changed path. Missing directories are skipped silently.
|
|
571
|
+
*
|
|
572
|
+
* @param globs - Watch globs.
|
|
573
|
+
* @param debounceMs - Coalesce rapid changes into one callback within this window.
|
|
574
|
+
* @param onChange - Called with the last changed path after the debounce settles.
|
|
575
|
+
* @returns A handle whose close() stops all watchers and cancels any pending callback.
|
|
576
|
+
* @example
|
|
577
|
+
* ```ts
|
|
578
|
+
* const handle = watchPaths(["src/**\/*.ts"], 120, p => rebuild(p));
|
|
579
|
+
* handle.close();
|
|
580
|
+
* ```
|
|
581
|
+
*/
|
|
582
|
+
const watchPaths = (globs, debounceMs, onChange) => {
|
|
583
|
+
let timer;
|
|
584
|
+
let lastPath = "";
|
|
585
|
+
const fire = (changedPath) => {
|
|
586
|
+
lastPath = changedPath;
|
|
587
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
588
|
+
timer = setTimeout(() => {
|
|
589
|
+
onChange(lastPath);
|
|
590
|
+
}, debounceMs);
|
|
591
|
+
};
|
|
592
|
+
const watchers = [];
|
|
593
|
+
for (const directory of watchDirectories(globs)) {
|
|
594
|
+
if (!existsSync(directory)) continue;
|
|
595
|
+
watchers.push(watch(directory, { recursive: true }, (_event, filename) => {
|
|
596
|
+
if (filename !== null) fire(path.join(directory, filename.toString()));
|
|
597
|
+
}));
|
|
598
|
+
}
|
|
599
|
+
return { close: () => {
|
|
600
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
601
|
+
for (const watcher of watchers) watcher.close();
|
|
602
|
+
} };
|
|
603
|
+
};
|
|
604
|
+
//#endregion
|
|
605
|
+
//#region src/plugins/deploy/dev/runner.ts
|
|
606
|
+
/**
|
|
607
|
+
* @file deploy plugin — dev watch/recompile orchestrator.
|
|
608
|
+
*
|
|
609
|
+
* One long-lived session: cold-build the Moku site, optionally apply local D1 migrations, spawn
|
|
610
|
+
* `wrangler dev --live-reload` ONCE, then watch the site sources and rebuild on change (wrangler's
|
|
611
|
+
* asset server live-reloads the browser). Build failures keep the session serving the last good
|
|
612
|
+
* build. Tears down cleanly on SIGINT. Side-effecting work is injected via DevDeps so the
|
|
613
|
+
* orchestration is unit-testable without real processes, watchers, or signals.
|
|
614
|
+
* Node-only; never imported by the runtime Worker bundle.
|
|
615
|
+
*/
|
|
616
|
+
/**
|
|
617
|
+
* Spawn the long-lived `wrangler dev` child (inherits the parent env; non-blocking).
|
|
618
|
+
*
|
|
619
|
+
* @param args - The `wrangler dev …` arguments.
|
|
620
|
+
* @returns A handle exposing kill().
|
|
621
|
+
* @example
|
|
622
|
+
* ```ts
|
|
623
|
+
* const child = spawnWranglerDev(["dev", "--port", "8787"]);
|
|
624
|
+
* ```
|
|
625
|
+
*/
|
|
626
|
+
const spawnWranglerDev = (args) => {
|
|
627
|
+
const child = spawn("wrangler", args, { stdio: "inherit" });
|
|
628
|
+
return { kill: () => child.kill() };
|
|
629
|
+
};
|
|
630
|
+
/**
|
|
631
|
+
* Resolve when the user first interrupts the dev session (SIGINT).
|
|
632
|
+
*
|
|
633
|
+
* @returns A promise that settles on the first SIGINT.
|
|
634
|
+
* @example
|
|
635
|
+
* ```ts
|
|
636
|
+
* await waitForSigint();
|
|
637
|
+
* ```
|
|
638
|
+
*/
|
|
639
|
+
const waitForSigint = () => {
|
|
640
|
+
return new Promise((resolve) => {
|
|
641
|
+
process.once("SIGINT", () => {
|
|
642
|
+
resolve();
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
};
|
|
646
|
+
/**
|
|
647
|
+
* Wall-clock timestamp in ms (extracted so realDevDeps holds only named references).
|
|
648
|
+
*
|
|
649
|
+
* @returns The current time in milliseconds.
|
|
650
|
+
* @example
|
|
651
|
+
* ```ts
|
|
652
|
+
* const t = nowMs();
|
|
653
|
+
* ```
|
|
654
|
+
*/
|
|
655
|
+
const nowMs = () => Date.now();
|
|
656
|
+
/**
|
|
657
|
+
* Build the real (side-effecting) dev deps used by api.dev(). Subprocesses inherit the parent env.
|
|
658
|
+
*
|
|
659
|
+
* @returns The production DevDeps (real spawn / fs.watch / SIGINT / Date.now).
|
|
660
|
+
* @example
|
|
661
|
+
* ```ts
|
|
662
|
+
* await runDev(ctx, opts, realDevDeps());
|
|
663
|
+
* ```
|
|
664
|
+
*/
|
|
665
|
+
const realDevDeps = () => ({
|
|
666
|
+
build: buildSite,
|
|
667
|
+
runWrangler,
|
|
668
|
+
spawnDev: spawnWranglerDev,
|
|
669
|
+
watch: watchPaths,
|
|
670
|
+
untilSignal: waitForSigint,
|
|
671
|
+
now: nowMs
|
|
672
|
+
});
|
|
673
|
+
/**
|
|
674
|
+
* The d1 binding to migrate locally, when a d1 plugin is present in the app.
|
|
675
|
+
*
|
|
676
|
+
* @param ctx - The deploy plugin context.
|
|
677
|
+
* @returns The d1 binding name, or undefined when no d1 plugin is present.
|
|
678
|
+
* @example
|
|
679
|
+
* ```ts
|
|
680
|
+
* const binding = d1Binding(ctx); // "DB" | undefined
|
|
681
|
+
* ```
|
|
682
|
+
*/
|
|
683
|
+
const d1Binding = (ctx) => ctx.has("d1") ? ctx.require(d1Plugin).deployManifest().binding : void 0;
|
|
684
|
+
/**
|
|
685
|
+
* Rebuild the site once and announce the result. A failed build keeps the session alive (it just
|
|
686
|
+
* emits dev:error and serves the last good build).
|
|
687
|
+
*
|
|
688
|
+
* @param ctx - The deploy plugin context.
|
|
689
|
+
* @param deps - The injected dev deps.
|
|
690
|
+
* @param changedPath - The path that triggered the rebuild.
|
|
691
|
+
* @param webBuild - Optional call-time web build hook threaded into the rebuild.
|
|
692
|
+
* @returns Resolves once the rebuild attempt completes.
|
|
693
|
+
* @example
|
|
694
|
+
* ```ts
|
|
695
|
+
* await rebuild(ctx, deps, "src/app.tsx", () => web.cli.build());
|
|
696
|
+
* ```
|
|
697
|
+
*/
|
|
698
|
+
const rebuild = async (ctx, deps, changedPath, webBuild) => {
|
|
699
|
+
ctx.emit("dev:phase", {
|
|
700
|
+
phase: "rebuild",
|
|
701
|
+
detail: changedPath
|
|
702
|
+
});
|
|
703
|
+
const started = deps.now();
|
|
704
|
+
try {
|
|
705
|
+
const { files } = await deps.build(ctx, webBuild);
|
|
706
|
+
ctx.emit("dev:rebuilt", {
|
|
707
|
+
files,
|
|
708
|
+
ms: deps.now() - started
|
|
709
|
+
});
|
|
710
|
+
} catch (error) {
|
|
711
|
+
ctx.emit("dev:error", { message: error instanceof Error ? error.message : String(error) });
|
|
712
|
+
}
|
|
713
|
+
};
|
|
714
|
+
/**
|
|
715
|
+
* Run a long-lived dev session: cold build → (local d1 migrate) → spawn `wrangler dev` →
|
|
716
|
+
* watch + rebuild on change → teardown on signal.
|
|
717
|
+
*
|
|
718
|
+
* @param ctx - The deploy plugin context (config + emit + require/has).
|
|
719
|
+
* @param opts - Optional options.
|
|
720
|
+
* @param opts.port - Local dev port (default 8787).
|
|
721
|
+
* @param opts.webBuild - Web build hook (re)run on cold build + each change (e.g. `() => web.cli.build()`).
|
|
722
|
+
* @param deps - Injected side effects (real ones from realDevDeps in production).
|
|
723
|
+
* @returns Resolves when the session ends (SIGINT).
|
|
724
|
+
* @example
|
|
725
|
+
* ```ts
|
|
726
|
+
* await runDev(ctx, { port: 8787, webBuild: () => web.cli.build() }, realDevDeps());
|
|
727
|
+
* ```
|
|
728
|
+
*/
|
|
729
|
+
const runDev = async (ctx, opts, deps) => {
|
|
730
|
+
const port = opts?.port ?? 8787;
|
|
731
|
+
const webBuild = opts?.webBuild;
|
|
732
|
+
ctx.emit("dev:phase", {
|
|
733
|
+
phase: "build",
|
|
734
|
+
detail: "site"
|
|
735
|
+
});
|
|
736
|
+
await deps.build(ctx, webBuild);
|
|
737
|
+
const binding = d1Binding(ctx);
|
|
738
|
+
if (ctx.config.migrateLocal && binding !== void 0) {
|
|
739
|
+
ctx.emit("dev:phase", {
|
|
740
|
+
phase: "migrate",
|
|
741
|
+
detail: "d1 (local)"
|
|
742
|
+
});
|
|
743
|
+
await deps.runWrangler([
|
|
744
|
+
"d1",
|
|
745
|
+
"migrations",
|
|
746
|
+
"apply",
|
|
747
|
+
binding,
|
|
748
|
+
"--local"
|
|
749
|
+
]);
|
|
750
|
+
}
|
|
751
|
+
ctx.emit("dev:phase", {
|
|
752
|
+
phase: "serve",
|
|
753
|
+
detail: `http://localhost:${String(port)}`
|
|
754
|
+
});
|
|
755
|
+
const child = deps.spawnDev([
|
|
756
|
+
"dev",
|
|
757
|
+
"--port",
|
|
758
|
+
String(port),
|
|
759
|
+
"--config",
|
|
760
|
+
ctx.config.configFile,
|
|
761
|
+
"--live-reload"
|
|
762
|
+
]);
|
|
763
|
+
const watcher = deps.watch(ctx.config.watch, ctx.config.debounceMs, (changedPath) => rebuild(ctx, deps, changedPath, webBuild));
|
|
764
|
+
await deps.untilSignal();
|
|
765
|
+
watcher.close();
|
|
766
|
+
child.kill();
|
|
767
|
+
ctx.emit("dev:phase", { phase: "stopped" });
|
|
768
|
+
};
|
|
769
|
+
//#endregion
|
|
770
|
+
//#region src/plugins/deploy/infra/plan.ts
|
|
771
|
+
/**
|
|
772
|
+
* Decide whether a single declared resource already exists in the account, recovering its id
|
|
773
|
+
* (kv/d1) when it does. Durable Objects are config-only (they ship with the script), so they are
|
|
774
|
+
* always treated as "missing" — provisioning them is a no-op that just records the binding.
|
|
775
|
+
*
|
|
776
|
+
* @param resource - The declared resource descriptor.
|
|
777
|
+
* @param existing - The indexed set of resources already in the account.
|
|
778
|
+
* @returns Whether it exists, plus the captured id for kv/d1.
|
|
779
|
+
* @example
|
|
780
|
+
* ```ts
|
|
781
|
+
* checkExisting({ kind: "kv", binding: "SESSIONS" }, existing); // { exists: true, id: "ns123" }
|
|
782
|
+
* ```
|
|
783
|
+
*/
|
|
784
|
+
const checkExisting = (resource, existing) => {
|
|
785
|
+
switch (resource.kind) {
|
|
786
|
+
case "kv": {
|
|
787
|
+
const id = existing.kv.get(resource.binding);
|
|
788
|
+
return id === void 0 ? { exists: false } : {
|
|
789
|
+
exists: true,
|
|
790
|
+
id
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
case "d1": {
|
|
794
|
+
const id = existing.d1.get(resource.binding);
|
|
795
|
+
return id === void 0 ? { exists: false } : {
|
|
796
|
+
exists: true,
|
|
797
|
+
id
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
case "r2": return { exists: existing.r2.has(resource.bucket) };
|
|
801
|
+
case "queue": return { exists: resource.producers.every((producer) => existing.queue.has(producer)) };
|
|
802
|
+
case "do": return { exists: false };
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
/**
|
|
806
|
+
* Run the read-only infra preflight: resolve the account, list existing resources, diff against
|
|
807
|
+
* the manifest, emit `provision:plan`, and return the plan. Writes nothing.
|
|
808
|
+
*
|
|
809
|
+
* @param ctx - The deploy plugin context (env + emit).
|
|
810
|
+
* @param manifest - The assembled (or caller-supplied) deploy manifest.
|
|
811
|
+
* @returns The infra plan: existing (with ids) vs missing resources.
|
|
812
|
+
* @throws {Error} When the token is absent/invalid or a Cloudflare listing fails.
|
|
813
|
+
* @example
|
|
814
|
+
* ```ts
|
|
815
|
+
* const plan = await planInfra(ctx, manifest);
|
|
816
|
+
* ```
|
|
817
|
+
*/
|
|
818
|
+
const planInfra = async (ctx, manifest) => {
|
|
819
|
+
const token = ctx.env.require("CLOUDFLARE_API_TOKEN");
|
|
820
|
+
const pinnedAccountId = ctx.env.get("CLOUDFLARE_ACCOUNT_ID");
|
|
821
|
+
const account = pinnedAccountId ? {
|
|
822
|
+
id: pinnedAccountId,
|
|
823
|
+
name: pinnedAccountId
|
|
824
|
+
} : await resolveAccount(token);
|
|
825
|
+
const kinds = /* @__PURE__ */ new Set();
|
|
826
|
+
for (const resource of manifest.resources) if (resource.kind !== "do") kinds.add(resource.kind);
|
|
827
|
+
const existing = await listExisting(token, account.id, kinds);
|
|
828
|
+
const exists = [];
|
|
829
|
+
const missing = [];
|
|
830
|
+
for (const resource of manifest.resources) {
|
|
831
|
+
const check = checkExisting(resource, existing);
|
|
832
|
+
if (check.exists) exists.push(check.id === void 0 ? { resource } : {
|
|
833
|
+
resource,
|
|
834
|
+
id: check.id
|
|
835
|
+
});
|
|
836
|
+
else missing.push(resource);
|
|
837
|
+
}
|
|
838
|
+
ctx.emit("provision:plan", {
|
|
839
|
+
exists: exists.length,
|
|
840
|
+
missing: missing.length,
|
|
841
|
+
account: account.name
|
|
842
|
+
});
|
|
843
|
+
return {
|
|
844
|
+
account: account.name,
|
|
845
|
+
accountId: account.id,
|
|
846
|
+
exists,
|
|
847
|
+
missing
|
|
848
|
+
};
|
|
849
|
+
};
|
|
75
850
|
//#endregion
|
|
76
851
|
//#region src/plugins/deploy/providers/d1.ts
|
|
77
852
|
/**
|
|
78
853
|
* @file deploy plugin — D1 provisioning adapter.
|
|
79
854
|
*
|
|
80
|
-
* Creates a Cloudflare D1 database via `wrangler d1 create <binding
|
|
855
|
+
* Creates a Cloudflare D1 database via `wrangler d1 create <binding>`, captures the created
|
|
856
|
+
* database id from wrangler's output (so writeWranglerConfig can write a real `database_id`
|
|
857
|
+
* instead of an empty placeholder), and applies migrations when declared.
|
|
81
858
|
* Node-only; never imported by the runtime Worker bundle.
|
|
82
859
|
*/
|
|
83
860
|
/**
|
|
84
|
-
*
|
|
861
|
+
* Parse the created D1 database id from `wrangler d1 create` output.
|
|
862
|
+
* Wrangler prints the new binding as JSON (`"database_id": "..."`) or TOML
|
|
863
|
+
* (`database_id = "..."`); the leading boundary keeps the match anchored to the field name.
|
|
864
|
+
*
|
|
865
|
+
* @param output - Raw stdout from the wrangler create command.
|
|
866
|
+
* @returns The database id, or undefined when none is found.
|
|
867
|
+
* @example
|
|
868
|
+
* ```ts
|
|
869
|
+
* parseD1DatabaseId('{ "database_id": "uuid-1234" }'); // "uuid-1234"
|
|
870
|
+
* ```
|
|
871
|
+
*/
|
|
872
|
+
const parseD1DatabaseId = (output) => {
|
|
873
|
+
return /(?:^|[\s,{])"?database_id"?\s*[:=]\s*"([^"]+)"/m.exec(output)?.[1];
|
|
874
|
+
};
|
|
875
|
+
/**
|
|
876
|
+
* Provision a D1 database via `wrangler d1 create`, capture its id, and apply migrations.
|
|
85
877
|
*
|
|
86
878
|
* @param manifest - The D1 resource descriptor.
|
|
87
879
|
* @param _ci - Whether running non-interactively.
|
|
88
|
-
* @returns
|
|
880
|
+
* @returns The captured database id when wrangler reported one, else an empty outcome.
|
|
89
881
|
* @example
|
|
90
882
|
* ```ts
|
|
91
|
-
* await provisionD1({ kind: "d1", binding: "DB", migrations: "./migrations" }, false);
|
|
883
|
+
* const { id } = await provisionD1({ kind: "d1", binding: "DB", migrations: "./migrations" }, false);
|
|
92
884
|
* ```
|
|
93
885
|
*/
|
|
94
886
|
const provisionD1 = async (manifest, _ci) => {
|
|
95
|
-
await runWrangler([
|
|
887
|
+
const id = parseD1DatabaseId(await runWrangler([
|
|
96
888
|
"d1",
|
|
97
889
|
"create",
|
|
98
890
|
manifest.binding
|
|
99
|
-
]);
|
|
891
|
+
]));
|
|
100
892
|
if (manifest.migrations) await runWrangler([
|
|
101
893
|
"d1",
|
|
102
894
|
"migrations",
|
|
@@ -104,6 +896,7 @@ const provisionD1 = async (manifest, _ci) => {
|
|
|
104
896
|
manifest.binding,
|
|
105
897
|
"--local"
|
|
106
898
|
]);
|
|
899
|
+
return id ? { id } : {};
|
|
107
900
|
};
|
|
108
901
|
//#endregion
|
|
109
902
|
//#region src/plugins/deploy/providers/do.ts
|
|
@@ -126,27 +919,46 @@ const provisionDurableObject = async (_manifest, _ci) => {};
|
|
|
126
919
|
/**
|
|
127
920
|
* @file deploy plugin — KV provisioning adapter.
|
|
128
921
|
*
|
|
129
|
-
* Creates a Cloudflare KV namespace via `wrangler kv namespace create <binding
|
|
130
|
-
*
|
|
922
|
+
* Creates a Cloudflare KV namespace via `wrangler kv namespace create <binding>` and captures
|
|
923
|
+
* the created namespace id from wrangler's output, so writeWranglerConfig can write a real `id`
|
|
924
|
+
* (not an empty placeholder) into the generated wrangler config — otherwise the binding resolves
|
|
925
|
+
* to nothing at runtime. Node-only; never imported by the runtime Worker bundle.
|
|
926
|
+
*/
|
|
927
|
+
/**
|
|
928
|
+
* Parse the created KV namespace id from `wrangler kv namespace create` output.
|
|
929
|
+
* Wrangler prints the new binding as JSON (`"id": "..."`) or TOML (`id = "..."`); the leading
|
|
930
|
+
* boundary (start / whitespace / `{` / `,`) keeps the match off a longer identifier such as
|
|
931
|
+
* `kv_namespace_id`.
|
|
932
|
+
*
|
|
933
|
+
* @param output - Raw stdout from the wrangler create command.
|
|
934
|
+
* @returns The namespace id, or undefined when none is found.
|
|
935
|
+
* @example
|
|
936
|
+
* ```ts
|
|
937
|
+
* parseKvNamespaceId('{ "id": "abc123" }'); // "abc123"
|
|
938
|
+
* ```
|
|
131
939
|
*/
|
|
940
|
+
const parseKvNamespaceId = (output) => {
|
|
941
|
+
return /(?:^|[\s,{])"?id"?\s*[:=]\s*"([^"]+)"/m.exec(output)?.[1];
|
|
942
|
+
};
|
|
132
943
|
/**
|
|
133
|
-
* Provision a KV namespace via `wrangler kv namespace create
|
|
944
|
+
* Provision a KV namespace via `wrangler kv namespace create` and capture its id.
|
|
134
945
|
*
|
|
135
946
|
* @param manifest - The KV resource descriptor.
|
|
136
947
|
* @param _ci - Whether running non-interactively (passed through; wrangler respects env vars).
|
|
137
|
-
* @returns
|
|
948
|
+
* @returns The captured namespace id when wrangler reported one, else an empty outcome.
|
|
138
949
|
* @example
|
|
139
950
|
* ```ts
|
|
140
|
-
* await provisionKv({ kind: "kv", binding: "CACHE" }, false);
|
|
951
|
+
* const { id } = await provisionKv({ kind: "kv", binding: "CACHE" }, false);
|
|
141
952
|
* ```
|
|
142
953
|
*/
|
|
143
954
|
const provisionKv = async (manifest, _ci) => {
|
|
144
|
-
await runWrangler([
|
|
955
|
+
const id = parseKvNamespaceId(await runWrangler([
|
|
145
956
|
"kv",
|
|
146
957
|
"namespace",
|
|
147
958
|
"create",
|
|
148
959
|
manifest.binding
|
|
149
|
-
]);
|
|
960
|
+
]));
|
|
961
|
+
return id ? { id } : {};
|
|
150
962
|
};
|
|
151
963
|
//#endregion
|
|
152
964
|
//#region src/plugins/deploy/providers/queues.ts
|
|
@@ -258,33 +1070,48 @@ const uploadDirToR2 = async (bucket, directory) => {
|
|
|
258
1070
|
*
|
|
259
1071
|
* @param resource - The resource descriptor to provision.
|
|
260
1072
|
* @param ci - Whether running non-interactively.
|
|
261
|
-
* @returns
|
|
1073
|
+
* @returns The provisioning outcome — `{ id }` for kv/d1, `{}` for r2/queue/do.
|
|
262
1074
|
* @example
|
|
263
1075
|
* ```ts
|
|
264
|
-
* await provisionResource({ kind: "kv", binding: "CACHE" }, false);
|
|
265
|
-
* await provisionResource({ kind: "r2", bucket: "ASSETS" }, false);
|
|
1076
|
+
* const { id } = await provisionResource({ kind: "kv", binding: "CACHE" }, false);
|
|
1077
|
+
* await provisionResource({ kind: "r2", bucket: "ASSETS" }, false); // {}
|
|
266
1078
|
* ```
|
|
267
1079
|
*/
|
|
268
1080
|
const provisionResource = async (resource, ci) => {
|
|
269
1081
|
switch (resource.kind) {
|
|
270
|
-
case "kv":
|
|
271
|
-
|
|
272
|
-
break;
|
|
1082
|
+
case "kv": return provisionKv(resource, ci);
|
|
1083
|
+
case "d1": return provisionD1(resource, ci);
|
|
273
1084
|
case "r2":
|
|
274
1085
|
await provisionR2(resource, ci);
|
|
275
|
-
|
|
276
|
-
case "d1":
|
|
277
|
-
await provisionD1(resource, ci);
|
|
278
|
-
break;
|
|
1086
|
+
return {};
|
|
279
1087
|
case "queue":
|
|
280
1088
|
await provisionQueue(resource, ci);
|
|
281
|
-
|
|
1089
|
+
return {};
|
|
282
1090
|
case "do":
|
|
283
1091
|
await provisionDurableObject(resource, ci);
|
|
284
|
-
|
|
1092
|
+
return {};
|
|
285
1093
|
}
|
|
286
1094
|
};
|
|
287
1095
|
//#endregion
|
|
1096
|
+
//#region src/plugins/deploy/tty.ts
|
|
1097
|
+
/**
|
|
1098
|
+
* @file deploy plugin — TTY detection (isolated so the guided flow is testable).
|
|
1099
|
+
*
|
|
1100
|
+
* The guided deploy only prompts on an interactive terminal; in a pipe or CI it must never block
|
|
1101
|
+
* on stdin. Kept in its own module so tests can mock it without stubbing `process.stdout`.
|
|
1102
|
+
* Node-only; never imported by the runtime Worker bundle.
|
|
1103
|
+
*/
|
|
1104
|
+
/**
|
|
1105
|
+
* Whether stdout is an interactive TTY (so prompts are safe to show).
|
|
1106
|
+
*
|
|
1107
|
+
* @returns True when stdout is a terminal.
|
|
1108
|
+
* @example
|
|
1109
|
+
* ```ts
|
|
1110
|
+
* if (stdoutIsTty()) await prompts.confirm("Deploy?");
|
|
1111
|
+
* ```
|
|
1112
|
+
*/
|
|
1113
|
+
const stdoutIsTty = () => process.stdout.isTTY === true;
|
|
1114
|
+
//#endregion
|
|
288
1115
|
//#region src/plugins/deploy/wrangler-config.ts
|
|
289
1116
|
/**
|
|
290
1117
|
* @file deploy plugin — wrangler config generation + scaffold.
|
|
@@ -315,15 +1142,16 @@ const parseJsonc = (source) => {
|
|
|
315
1142
|
* Build the wrangler `kv_namespaces` array from the manifest's kv resources.
|
|
316
1143
|
*
|
|
317
1144
|
* @param resources - All resource descriptors from the manifest.
|
|
318
|
-
* @
|
|
1145
|
+
* @param ids - Captured Cloudflare ids keyed by binding; the entry's `id` is filled from here.
|
|
1146
|
+
* @returns One wrangler KV namespace entry per kv resource (real `id` when known, else "").
|
|
319
1147
|
* @example
|
|
320
1148
|
* ```ts
|
|
321
|
-
* const kv = buildKvNamespaces([{ kind: "kv", binding: "CACHE" }]);
|
|
1149
|
+
* const kv = buildKvNamespaces([{ kind: "kv", binding: "CACHE" }], { CACHE: "ns123" });
|
|
322
1150
|
* ```
|
|
323
1151
|
*/
|
|
324
|
-
const buildKvNamespaces = (resources) => resources.filter((resource) => resource.kind === "kv").map((resource) => ({
|
|
1152
|
+
const buildKvNamespaces = (resources, ids) => resources.filter((resource) => resource.kind === "kv").map((resource) => ({
|
|
325
1153
|
binding: resource.binding,
|
|
326
|
-
id: ""
|
|
1154
|
+
id: ids[resource.binding] ?? ""
|
|
327
1155
|
}));
|
|
328
1156
|
/**
|
|
329
1157
|
* Build the wrangler `r2_buckets` array from the manifest's r2 resources.
|
|
@@ -343,17 +1171,18 @@ const buildR2Buckets = (resources) => resources.filter((resource) => resource.ki
|
|
|
343
1171
|
* Build the wrangler `d1_databases` array from the manifest's d1 resources.
|
|
344
1172
|
*
|
|
345
1173
|
* @param resources - All resource descriptors from the manifest.
|
|
1174
|
+
* @param ids - Captured Cloudflare ids keyed by binding; the entry's `database_id` is filled from here.
|
|
346
1175
|
* @returns One wrangler D1 database entry per d1 resource (migrations_dir set when present).
|
|
347
1176
|
* @example
|
|
348
1177
|
* ```ts
|
|
349
|
-
* const d1 = buildD1Databases([{ kind: "d1", binding: "DB" }]);
|
|
1178
|
+
* const d1 = buildD1Databases([{ kind: "d1", binding: "DB" }], { DB: "uuid-1234" });
|
|
350
1179
|
* ```
|
|
351
1180
|
*/
|
|
352
|
-
const buildD1Databases = (resources) => resources.filter((resource) => resource.kind === "d1").map((resource) => {
|
|
1181
|
+
const buildD1Databases = (resources, ids) => resources.filter((resource) => resource.kind === "d1").map((resource) => {
|
|
353
1182
|
const entry = {
|
|
354
1183
|
binding: resource.binding,
|
|
355
1184
|
database_name: resource.binding.toLowerCase(),
|
|
356
|
-
database_id: ""
|
|
1185
|
+
database_id: ids[resource.binding] ?? ""
|
|
357
1186
|
};
|
|
358
1187
|
if (resource.migrations) entry.migrations_dir = resource.migrations;
|
|
359
1188
|
return entry;
|
|
@@ -402,22 +1231,24 @@ const buildDurableObjects = (resources) => {
|
|
|
402
1231
|
*
|
|
403
1232
|
* @param configFile - Path to the wrangler config file.
|
|
404
1233
|
* @param manifest - The assembled deploy manifest.
|
|
1234
|
+
* @param ids - Captured Cloudflare ids keyed by binding (kv namespace id, d1 database id). Defaults
|
|
1235
|
+
* to an empty map, in which case `id`/`database_id` are written as "" (e.g. the universal path).
|
|
405
1236
|
* @returns Resolves once the file is written.
|
|
406
1237
|
* @example
|
|
407
1238
|
* ```ts
|
|
408
|
-
* await writeWranglerConfig("wrangler.jsonc", manifest);
|
|
1239
|
+
* await writeWranglerConfig("wrangler.jsonc", manifest, { CACHE: "ns123", DB: "uuid-1234" });
|
|
409
1240
|
* ```
|
|
410
1241
|
*/
|
|
411
|
-
const writeWranglerConfig = async (configFile, manifest) => {
|
|
1242
|
+
const writeWranglerConfig = async (configFile, manifest, ids = {}) => {
|
|
412
1243
|
let existing = {};
|
|
413
1244
|
if (existsSync(configFile)) try {
|
|
414
1245
|
existing = parseJsonc(readFileSync(configFile, "utf8"));
|
|
415
1246
|
} catch {
|
|
416
1247
|
existing = {};
|
|
417
1248
|
}
|
|
418
|
-
const kvNamespaces = buildKvNamespaces(manifest.resources);
|
|
1249
|
+
const kvNamespaces = buildKvNamespaces(manifest.resources, ids);
|
|
419
1250
|
const r2Buckets = buildR2Buckets(manifest.resources);
|
|
420
|
-
const d1Databases = buildD1Databases(manifest.resources);
|
|
1251
|
+
const d1Databases = buildD1Databases(manifest.resources, ids);
|
|
421
1252
|
const queues = buildQueues(manifest.resources);
|
|
422
1253
|
const durableObjects = buildDurableObjects(manifest.resources);
|
|
423
1254
|
const updated = {
|
|
@@ -456,21 +1287,22 @@ const scaffoldWranglerAndCi = async (configFile, _ci) => {
|
|
|
456
1287
|
//#endregion
|
|
457
1288
|
//#region src/plugins/deploy/api.ts
|
|
458
1289
|
/**
|
|
459
|
-
* @file deploy plugin — API factory (run, dev, init).
|
|
1290
|
+
* @file deploy plugin — API factory (run, dev, init, checkInfra, provisionInfra).
|
|
460
1291
|
*
|
|
461
1292
|
* 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,
|
|
1293
|
+
* deployManifest() api (never sibling pluginConfigs — design F6), runs an infra preflight
|
|
1294
|
+
* (check-before-create + capture real ids), generates/updates the wrangler config, uploads the
|
|
1295
|
+
* R2 upload dir, and runs wrangler deploy. Emits only global events: deploy:phase,
|
|
1296
|
+
* deploy:complete, provision:resource, provision:plan, provision:skip.
|
|
465
1297
|
*
|
|
466
|
-
* Node-only: uses node:child_process (via runner.ts)
|
|
467
|
-
* Never called in the deployed Worker runtime.
|
|
1298
|
+
* Node-only: uses node:child_process (via runner.ts), node:fs (via wrangler-config.ts), and the
|
|
1299
|
+
* Cloudflare REST API (via infra/). Never called in the deployed Worker runtime.
|
|
468
1300
|
*/
|
|
469
1301
|
/**
|
|
470
|
-
* Derive a human-readable name string from a resource descriptor (used in provision
|
|
1302
|
+
* Derive a human-readable name string from a resource descriptor (used in provision events).
|
|
471
1303
|
*
|
|
472
1304
|
* @param resource - The resource descriptor.
|
|
473
|
-
* @returns A name suitable for the provision:resource event payload.
|
|
1305
|
+
* @returns A name suitable for the provision:resource / provision:skip event payload.
|
|
474
1306
|
* @example
|
|
475
1307
|
* ```ts
|
|
476
1308
|
* resourceName({ kind: "kv", binding: "CACHE" }); // "CACHE"
|
|
@@ -485,12 +1317,74 @@ const resourceName = (resource) => {
|
|
|
485
1317
|
}
|
|
486
1318
|
};
|
|
487
1319
|
/**
|
|
488
|
-
*
|
|
489
|
-
*
|
|
490
|
-
*
|
|
1320
|
+
* Assemble the deploy manifest from each present resource plugin's OWN deployManifest() api,
|
|
1321
|
+
* gated by ctx.has(name) so absent plugins are skipped — never sibling pluginConfigs (F6).
|
|
1322
|
+
*
|
|
1323
|
+
* @param ctx - The deploy plugin context.
|
|
1324
|
+
* @returns The assembled manifest (name, compatibilityDate, resources).
|
|
1325
|
+
* @example
|
|
1326
|
+
* ```ts
|
|
1327
|
+
* const manifest = assembleManifest(ctx);
|
|
1328
|
+
* ```
|
|
1329
|
+
*/
|
|
1330
|
+
const assembleManifest = (ctx) => ({
|
|
1331
|
+
name: ctx.global.name,
|
|
1332
|
+
compatibilityDate: ctx.global.compatibilityDate,
|
|
1333
|
+
resources: [
|
|
1334
|
+
ctx.has("storage") ? ctx.require(storagePlugin).deployManifest() : void 0,
|
|
1335
|
+
ctx.has("kv") ? ctx.require(kvPlugin).deployManifest() : void 0,
|
|
1336
|
+
ctx.has("d1") ? ctx.require(d1Plugin).deployManifest() : void 0,
|
|
1337
|
+
ctx.has("queues") ? ctx.require(queuesPlugin).deployManifest() : void 0,
|
|
1338
|
+
ctx.has("durableObjects") ? ctx.require(durableObjectsPlugin).deployManifest() : void 0
|
|
1339
|
+
].filter((resource) => resource !== void 0)
|
|
1340
|
+
});
|
|
1341
|
+
/**
|
|
1342
|
+
* Act on an infra plan: skip the resources that already exist (reusing their ids), create only
|
|
1343
|
+
* the missing ones (capturing each new id), and announce each via provision:skip / :resource.
|
|
1344
|
+
*
|
|
1345
|
+
* @param ctx - The deploy plugin context.
|
|
1346
|
+
* @param plan - The infra plan from planInfra (existing vs missing).
|
|
1347
|
+
* @returns The provisioning result: created, skipped, and the merged binding → id map.
|
|
1348
|
+
* @example
|
|
1349
|
+
* ```ts
|
|
1350
|
+
* const { ids } = await applyPlan(ctx, plan);
|
|
1351
|
+
* ```
|
|
1352
|
+
*/
|
|
1353
|
+
const applyPlan = async (ctx, plan) => {
|
|
1354
|
+
const ids = {};
|
|
1355
|
+
for (const ref of plan.exists) {
|
|
1356
|
+
if (ref.id !== void 0 && (ref.resource.kind === "kv" || ref.resource.kind === "d1")) ids[ref.resource.binding] = ref.id;
|
|
1357
|
+
ctx.emit("provision:skip", {
|
|
1358
|
+
kind: ref.resource.kind,
|
|
1359
|
+
name: resourceName(ref.resource)
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
const created = [];
|
|
1363
|
+
for (const resource of plan.missing) {
|
|
1364
|
+
const { id } = await provisionResource(resource, ctx.config.ci);
|
|
1365
|
+
if (id !== void 0 && (resource.kind === "kv" || resource.kind === "d1")) ids[resource.binding] = id;
|
|
1366
|
+
created.push(id === void 0 ? { resource } : {
|
|
1367
|
+
resource,
|
|
1368
|
+
id
|
|
1369
|
+
});
|
|
1370
|
+
ctx.emit("provision:resource", {
|
|
1371
|
+
kind: resource.kind,
|
|
1372
|
+
name: resourceName(resource)
|
|
1373
|
+
});
|
|
1374
|
+
}
|
|
1375
|
+
return {
|
|
1376
|
+
created,
|
|
1377
|
+
skipped: plan.exists,
|
|
1378
|
+
ids
|
|
1379
|
+
};
|
|
1380
|
+
};
|
|
1381
|
+
/**
|
|
1382
|
+
* Create the deploy api. Assembles the manifest from each resource plugin's own deployManifest(),
|
|
1383
|
+
* runs an infra preflight (check-before-create + id capture), generates config, uploads, and runs
|
|
1384
|
+
* `wrangler deploy`, emitting global deploy events along the way.
|
|
491
1385
|
*
|
|
492
|
-
* @param ctx - Plugin context (own config + require + has + emit + global).
|
|
493
|
-
* @returns The app.deploy api: run / dev / init.
|
|
1386
|
+
* @param ctx - Plugin context (own config + require + has + emit + global + env).
|
|
1387
|
+
* @returns The app.deploy api: run / dev / init / checkInfra / provisionInfra.
|
|
494
1388
|
* @example
|
|
495
1389
|
* ```ts
|
|
496
1390
|
* const api = createDeployApi(ctx);
|
|
@@ -499,43 +1393,45 @@ const resourceName = (resource) => {
|
|
|
499
1393
|
*/
|
|
500
1394
|
const createDeployApi = (ctx) => ({
|
|
501
1395
|
/**
|
|
502
|
-
* Run the full deploy pipeline: detect →
|
|
503
|
-
*
|
|
1396
|
+
* Run the full deploy pipeline: detect → preflight (check-before-create) → provision (only the
|
|
1397
|
+
* missing) → wrangler-config (with real ids) → upload → deploy. When opts.manifest is supplied
|
|
1398
|
+
* it is used verbatim (universal path).
|
|
504
1399
|
*
|
|
505
1400
|
* @param opts - Optional run options.
|
|
506
|
-
* @param opts.guided - Enable interactive confirmation steps (
|
|
507
|
-
* @param opts.yes - Auto-confirm all prompts.
|
|
1401
|
+
* @param opts.guided - Enable interactive confirmation steps (only on a TTY, non-CI).
|
|
1402
|
+
* @param opts.yes - Auto-confirm all prompts (non-interactive / CI).
|
|
1403
|
+
* @param opts.webBuild - Build the web site first (e.g. `() => webApp.cli.build()`), before deploy.
|
|
508
1404
|
* @param opts.manifest - Caller-supplied manifest (bypasses deployManifest() assembly).
|
|
509
1405
|
* @returns Resolves once the deploy completes.
|
|
510
1406
|
* @example
|
|
511
1407
|
* ```ts
|
|
512
|
-
* await api.run({ guided: true });
|
|
1408
|
+
* await api.run({ guided: true, webBuild: () => web.cli.build() });
|
|
513
1409
|
* await api.run({ manifest: { name: "w", compatibilityDate: "2026-06-17", resources: [] } });
|
|
514
1410
|
* ```
|
|
515
1411
|
*/
|
|
516
1412
|
async run(opts) {
|
|
1413
|
+
const confirm = (opts?.guided ?? false) && !ctx.config.ci && !(opts?.yes ?? false) && stdoutIsTty() ? createBrandPrompts().confirm : async (_question) => true;
|
|
1414
|
+
ctx.emit("deploy:phase", { phase: "auth" });
|
|
1415
|
+
await verifyAuth(ctx);
|
|
1416
|
+
const webBuild = opts?.webBuild ?? ctx.config.webBuild;
|
|
1417
|
+
if (webBuild !== void 0) {
|
|
1418
|
+
ctx.emit("deploy:phase", {
|
|
1419
|
+
phase: "build",
|
|
1420
|
+
detail: "web"
|
|
1421
|
+
});
|
|
1422
|
+
await webBuild();
|
|
1423
|
+
}
|
|
517
1424
|
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
|
-
};
|
|
1425
|
+
const manifest = opts?.manifest ?? assembleManifest(ctx);
|
|
529
1426
|
ctx.emit("deploy:phase", { phase: "provision" });
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
ctx.emit("
|
|
533
|
-
|
|
534
|
-
name: resourceName(resource)
|
|
535
|
-
});
|
|
1427
|
+
const plan = await planInfra(ctx, manifest);
|
|
1428
|
+
if (plan.missing.length > 0 && !await confirm(`Create ${plan.missing.length} missing resource(s) in "${plan.account}"?`)) {
|
|
1429
|
+
ctx.emit("deploy:phase", { phase: "aborted" });
|
|
1430
|
+
return;
|
|
536
1431
|
}
|
|
1432
|
+
const { ids } = await applyPlan(ctx, plan);
|
|
537
1433
|
ctx.emit("deploy:phase", { phase: "wrangler-config" });
|
|
538
|
-
await writeWranglerConfig(ctx.config.configFile, manifest);
|
|
1434
|
+
await writeWranglerConfig(ctx.config.configFile, manifest, ids);
|
|
539
1435
|
const r2Resource = manifest.resources.find((resource) => resource.kind === "r2");
|
|
540
1436
|
if (r2Resource?.upload) {
|
|
541
1437
|
const count = await uploadDirToR2(r2Resource.bucket, r2Resource.upload);
|
|
@@ -544,6 +1440,10 @@ const createDeployApi = (ctx) => ({
|
|
|
544
1440
|
detail: `${String(count)} files`
|
|
545
1441
|
});
|
|
546
1442
|
}
|
|
1443
|
+
if (!await confirm(`Deploy "${manifest.name}" to ${ctx.global.stage}?`)) {
|
|
1444
|
+
ctx.emit("deploy:phase", { phase: "aborted" });
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
547
1447
|
ctx.emit("deploy:phase", { phase: "deploy" });
|
|
548
1448
|
const url = await runWrangler([
|
|
549
1449
|
"deploy",
|
|
@@ -553,25 +1453,20 @@ const createDeployApi = (ctx) => ({
|
|
|
553
1453
|
ctx.emit("deploy:complete", { url });
|
|
554
1454
|
},
|
|
555
1455
|
/**
|
|
556
|
-
* Start a local
|
|
1456
|
+
* Start a long-lived local dev session: cold-build the Moku site, spawn `wrangler dev
|
|
1457
|
+
* --live-reload`, and watch the site sources — rebuilding on change (wrangler live-reloads the
|
|
1458
|
+
* browser). Resolves on SIGINT.
|
|
557
1459
|
*
|
|
558
1460
|
* @param opts - Optional options.
|
|
559
1461
|
* @param opts.port - Local dev port (default 8787).
|
|
1462
|
+
* @param opts.webBuild - Rebuild the web site on change (e.g. `() => webApp.cli.build()`).
|
|
560
1463
|
* @returns Resolves when the dev session ends.
|
|
561
1464
|
* @example
|
|
562
1465
|
* ```ts
|
|
563
|
-
* await api.dev({ port: 8787 });
|
|
1466
|
+
* await api.dev({ port: 8787, webBuild: () => web.cli.build() });
|
|
564
1467
|
* ```
|
|
565
1468
|
*/
|
|
566
|
-
dev:
|
|
567
|
-
await runWrangler([
|
|
568
|
-
"dev",
|
|
569
|
-
"--port",
|
|
570
|
-
String(opts?.port ?? 8787),
|
|
571
|
-
"--config",
|
|
572
|
-
ctx.config.configFile
|
|
573
|
-
]);
|
|
574
|
-
},
|
|
1469
|
+
dev: (opts) => runDev(ctx, opts, realDevDeps()),
|
|
575
1470
|
/**
|
|
576
1471
|
* Scaffold a starting wrangler config (and CI files when ci is set).
|
|
577
1472
|
* Idempotent: an existing config file is left untouched.
|
|
@@ -586,7 +1481,71 @@ const createDeployApi = (ctx) => ({
|
|
|
586
1481
|
*/
|
|
587
1482
|
init: async (opts) => {
|
|
588
1483
|
await scaffoldWranglerAndCi(ctx.config.configFile, opts?.ci ?? ctx.config.ci);
|
|
589
|
-
}
|
|
1484
|
+
},
|
|
1485
|
+
/**
|
|
1486
|
+
* Read-only infra preflight: assemble the manifest, resolve the account, list what exists in
|
|
1487
|
+
* Cloudflare, diff, emit provision:plan, and return the plan. Writes nothing.
|
|
1488
|
+
*
|
|
1489
|
+
* @returns The infra plan (existing vs missing resources, with captured ids).
|
|
1490
|
+
* @example
|
|
1491
|
+
* ```ts
|
|
1492
|
+
* const plan = await api.checkInfra();
|
|
1493
|
+
* ```
|
|
1494
|
+
*/
|
|
1495
|
+
checkInfra: () => planInfra(ctx, assembleManifest(ctx)),
|
|
1496
|
+
/**
|
|
1497
|
+
* Create only the resources missing from the plan (skipping existing), capturing each id.
|
|
1498
|
+
*
|
|
1499
|
+
* @param plan - A plan produced by checkInfra().
|
|
1500
|
+
* @returns The provisioning result: created, skipped, and the merged id map.
|
|
1501
|
+
* @example
|
|
1502
|
+
* ```ts
|
|
1503
|
+
* const { created } = await api.provisionInfra(await api.checkInfra());
|
|
1504
|
+
* ```
|
|
1505
|
+
*/
|
|
1506
|
+
provisionInfra: (plan) => applyPlan(ctx, plan),
|
|
1507
|
+
/**
|
|
1508
|
+
* Verify the `.env` Cloudflare API token (must be active) and resolve its account; emits
|
|
1509
|
+
* auth:verified. Throws a branded error pointing at `auth setup` when absent/invalid/inactive.
|
|
1510
|
+
*
|
|
1511
|
+
* @returns The verified auth status (account + id).
|
|
1512
|
+
* @example
|
|
1513
|
+
* ```ts
|
|
1514
|
+
* const { account } = await api.verifyAuth();
|
|
1515
|
+
* ```
|
|
1516
|
+
*/
|
|
1517
|
+
verifyAuth: () => verifyAuth(ctx),
|
|
1518
|
+
/**
|
|
1519
|
+
* Derive the minimum Cloudflare API token this app needs from its manifest (pure, no network).
|
|
1520
|
+
*
|
|
1521
|
+
* @returns The token requirement (full set + groups to add to the stock template).
|
|
1522
|
+
* @example
|
|
1523
|
+
* ```ts
|
|
1524
|
+
* const { toAdd } = api.requiredToken();
|
|
1525
|
+
* ```
|
|
1526
|
+
*/
|
|
1527
|
+
requiredToken: () => requiredToken(assembleManifest(ctx)),
|
|
1528
|
+
/**
|
|
1529
|
+
* Render the `auth setup` guidance from the derived token requirement (pure, no network).
|
|
1530
|
+
*
|
|
1531
|
+
* @returns The rendered instruction text.
|
|
1532
|
+
* @example
|
|
1533
|
+
* ```ts
|
|
1534
|
+
* const text = api.tokenInstructions();
|
|
1535
|
+
* ```
|
|
1536
|
+
*/
|
|
1537
|
+
tokenInstructions: () => tokenInstructions(assembleManifest(ctx)),
|
|
1538
|
+
/**
|
|
1539
|
+
* Run an arbitrary wrangler command, streaming its output (the branded CLI escape hatch).
|
|
1540
|
+
*
|
|
1541
|
+
* @param args - The wrangler arguments.
|
|
1542
|
+
* @returns Resolves once wrangler exits.
|
|
1543
|
+
* @example
|
|
1544
|
+
* ```ts
|
|
1545
|
+
* await api.wrangler(["kv", "namespace", "list"]);
|
|
1546
|
+
* ```
|
|
1547
|
+
*/
|
|
1548
|
+
wrangler: (args) => runWranglerInherit(args)
|
|
590
1549
|
});
|
|
591
1550
|
/**
|
|
592
1551
|
* Complex tier (node-only) — build-time deploy orchestrator over the five resource plugins.
|
|
@@ -603,7 +1562,11 @@ const createDeployApi = (ctx) => ({
|
|
|
603
1562
|
const deployPlugin = createPlugin("deploy", {
|
|
604
1563
|
config: {
|
|
605
1564
|
configFile: "wrangler.jsonc",
|
|
606
|
-
ci: false
|
|
1565
|
+
ci: false,
|
|
1566
|
+
watch: ["src/**/*.{ts,tsx,css}", "public/**/*"],
|
|
1567
|
+
buildCommand: "",
|
|
1568
|
+
migrateLocal: true,
|
|
1569
|
+
debounceMs: 120
|
|
607
1570
|
},
|
|
608
1571
|
depends: [
|
|
609
1572
|
storagePlugin,
|
|
@@ -617,6 +1580,9 @@ const deployPlugin = createPlugin("deploy", {
|
|
|
617
1580
|
//#endregion
|
|
618
1581
|
//#region src/plugins/cli/api.ts
|
|
619
1582
|
/**
|
|
1583
|
+
* @file cli plugin — API factory (dev, deploy, auth, doctor).
|
|
1584
|
+
*/
|
|
1585
|
+
/**
|
|
620
1586
|
* Builds app.cli.* — thin passthroughs to the deploy plugin via ctx.require(deployPlugin).
|
|
621
1587
|
* Both verbs forward their opts verbatim; `dev` defaults port to ctx.config.port when no
|
|
622
1588
|
* opts are supplied.
|
|
@@ -632,37 +1598,137 @@ const deployPlugin = createPlugin("deploy", {
|
|
|
632
1598
|
*/
|
|
633
1599
|
const createCliApi = (ctx) => ({
|
|
634
1600
|
/**
|
|
635
|
-
* Run the Worker locally; defaults port to ctx.config.port (8787) when no opts supplied.
|
|
1601
|
+
* Run the Worker locally; defaults port to ctx.config.port (8787) when no opts supplied. A
|
|
1602
|
+
* `webBuild` hook (e.g. `() => webApp.cli.build()`) wires the web build into the dev loop so the
|
|
1603
|
+
* site recompiles on change — this is how an app-side script composes web + worker.
|
|
636
1604
|
*
|
|
637
1605
|
* @param opts - Optional local dev options.
|
|
638
1606
|
* @param opts.port - Local dev port to bind. Defaults to ctx.config.port (8787).
|
|
1607
|
+
* @param opts.webBuild - Rebuild the web site on change (e.g. `() => webApp.cli.build()`).
|
|
639
1608
|
* @returns Resolves when the dev session ends.
|
|
640
1609
|
* @example
|
|
641
1610
|
* ```ts
|
|
642
|
-
* await api.dev();
|
|
643
|
-
* await api.dev({
|
|
1611
|
+
* await api.dev(); // port 8787, worker only
|
|
1612
|
+
* await api.dev({ webBuild: () => web.cli.build() }); // wire the web build in
|
|
644
1613
|
* ```
|
|
645
1614
|
*/
|
|
646
1615
|
dev(opts) {
|
|
647
|
-
|
|
1616
|
+
const port = opts?.port ?? ctx.config.port;
|
|
1617
|
+
return ctx.require(deployPlugin).dev(opts?.webBuild ? {
|
|
1618
|
+
port,
|
|
1619
|
+
webBuild: opts.webBuild
|
|
1620
|
+
} : { port });
|
|
648
1621
|
},
|
|
649
1622
|
/**
|
|
650
1623
|
* One-command guided Cloudflare deploy; forwards flags verbatim to deploy.run.
|
|
651
|
-
* Passes `undefined` when called with no opts (not a default empty object).
|
|
1624
|
+
* Passes `undefined` when called with no opts (not a default empty object). A `webBuild` hook
|
|
1625
|
+
* builds the web site first (before `wrangler deploy`) — how an app-side script ships web + worker.
|
|
652
1626
|
*
|
|
653
1627
|
* @param opts - Optional deploy options.
|
|
654
1628
|
* @param opts.guided - Walk through each step interactively.
|
|
655
1629
|
* @param opts.yes - Skip confirmation prompts (non-interactive / CI).
|
|
1630
|
+
* @param opts.webBuild - Build the web site first (e.g. `() => webApp.cli.build()`), before deploy.
|
|
656
1631
|
* @returns Resolves once the deploy completes.
|
|
657
1632
|
* @example
|
|
658
1633
|
* ```ts
|
|
659
|
-
* await api.deploy({ guided: true });
|
|
1634
|
+
* await api.deploy({ guided: true, webBuild: () => web.cli.build() });
|
|
660
1635
|
* await api.deploy({ yes: true }); // CI
|
|
661
1636
|
* await api.deploy(); // opts === undefined
|
|
662
1637
|
* ```
|
|
663
1638
|
*/
|
|
664
1639
|
deploy(opts) {
|
|
665
1640
|
return ctx.require(deployPlugin).run(opts);
|
|
1641
|
+
},
|
|
1642
|
+
/**
|
|
1643
|
+
* Verify the `.env` token (no sub) or print the config-derived token guidance (`"setup"`),
|
|
1644
|
+
* rendered in Moku style. `setup` works without a token; verify reports the resolved account.
|
|
1645
|
+
*
|
|
1646
|
+
* @param sub - Pass "setup" to print guidance; omit to verify the current token.
|
|
1647
|
+
* @returns Resolves once the check or guidance render completes.
|
|
1648
|
+
* @example
|
|
1649
|
+
* ```ts
|
|
1650
|
+
* await api.auth("setup"); // print what token to create
|
|
1651
|
+
* await api.auth(); // verify the current token
|
|
1652
|
+
* ```
|
|
1653
|
+
*/
|
|
1654
|
+
async auth(sub) {
|
|
1655
|
+
const deploy = ctx.require(deployPlugin);
|
|
1656
|
+
const ui = createBrandConsole();
|
|
1657
|
+
if (sub === "setup") {
|
|
1658
|
+
for (const line of deploy.tokenInstructions().split("\n")) ui.line(line);
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
try {
|
|
1662
|
+
const status = await deploy.verifyAuth();
|
|
1663
|
+
ui.check(true, "token valid", `account "${status.account}" (${status.accountId})`);
|
|
1664
|
+
} catch (error) {
|
|
1665
|
+
ui.error(error instanceof Error ? error.message : String(error));
|
|
1666
|
+
}
|
|
1667
|
+
},
|
|
1668
|
+
/**
|
|
1669
|
+
* One-shot preflight report: token + account (verifyAuth) then infra drift (checkInfra),
|
|
1670
|
+
* each as a branded check line. Stops after the token check when auth fails.
|
|
1671
|
+
*
|
|
1672
|
+
* @returns Resolves once the report is printed.
|
|
1673
|
+
* @example
|
|
1674
|
+
* ```ts
|
|
1675
|
+
* await api.doctor();
|
|
1676
|
+
* ```
|
|
1677
|
+
*/
|
|
1678
|
+
async doctor() {
|
|
1679
|
+
const deploy = ctx.require(deployPlugin);
|
|
1680
|
+
const ui = createBrandConsole();
|
|
1681
|
+
ui.heading("doctor");
|
|
1682
|
+
let tokenOk = false;
|
|
1683
|
+
try {
|
|
1684
|
+
const status = await deploy.verifyAuth();
|
|
1685
|
+
tokenOk = true;
|
|
1686
|
+
ui.check(true, "token", `valid · account "${status.account}" (${status.accountId})`);
|
|
1687
|
+
} catch (error) {
|
|
1688
|
+
ui.check(false, "token", error instanceof Error ? error.message : String(error));
|
|
1689
|
+
}
|
|
1690
|
+
if (!tokenOk) {
|
|
1691
|
+
ui.line("Run `auth setup` for the exact token to create.");
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
try {
|
|
1695
|
+
const plan = await deploy.checkInfra();
|
|
1696
|
+
ui.check(true, "infra", `${plan.exists.length} exist, ${plan.missing.length} to create in "${plan.account}"`);
|
|
1697
|
+
} catch (error) {
|
|
1698
|
+
ui.check(false, "infra", error instanceof Error ? error.message : String(error));
|
|
1699
|
+
}
|
|
1700
|
+
},
|
|
1701
|
+
/**
|
|
1702
|
+
* Print the resolved Cloudflare account for the current `.env` token.
|
|
1703
|
+
*
|
|
1704
|
+
* @returns Resolves once the account summary is printed.
|
|
1705
|
+
* @example
|
|
1706
|
+
* ```ts
|
|
1707
|
+
* await api.whoami();
|
|
1708
|
+
* ```
|
|
1709
|
+
*/
|
|
1710
|
+
async whoami() {
|
|
1711
|
+
const ui = createBrandConsole();
|
|
1712
|
+
try {
|
|
1713
|
+
const status = await ctx.require(deployPlugin).verifyAuth();
|
|
1714
|
+
ui.check(true, "account", `${status.account} (${status.accountId})`);
|
|
1715
|
+
} catch (error) {
|
|
1716
|
+
ui.error(error instanceof Error ? error.message : String(error));
|
|
1717
|
+
}
|
|
1718
|
+
},
|
|
1719
|
+
/**
|
|
1720
|
+
* Run an arbitrary wrangler command through the branded CLI (escape hatch). Streams its output.
|
|
1721
|
+
*
|
|
1722
|
+
* @param args - The wrangler arguments.
|
|
1723
|
+
* @returns Resolves once wrangler exits.
|
|
1724
|
+
* @example
|
|
1725
|
+
* ```ts
|
|
1726
|
+
* await api.wrangler(["kv", "namespace", "list"]);
|
|
1727
|
+
* ```
|
|
1728
|
+
*/
|
|
1729
|
+
async wrangler(args) {
|
|
1730
|
+
createBrandConsole().heading(`wrangler ${args.join(" ")}`);
|
|
1731
|
+
await ctx.require(deployPlugin).wrangler(args);
|
|
666
1732
|
}
|
|
667
1733
|
});
|
|
668
1734
|
//#endregion
|
|
@@ -699,6 +1765,18 @@ const createCliHooks = (ctx) => ({
|
|
|
699
1765
|
ctx.log.info(p.detail ? `${p.phase} · ${p.detail}` : p.phase);
|
|
700
1766
|
},
|
|
701
1767
|
/**
|
|
1768
|
+
* Log the infra preflight summary: "infra · N exist, M to create · account".
|
|
1769
|
+
*
|
|
1770
|
+
* @param p - The provision:plan event payload.
|
|
1771
|
+
* @example
|
|
1772
|
+
* ```ts
|
|
1773
|
+
* handler({ exists: 2, missing: 1, account: "Play Co" }); // "infra · 2 exist, 1 to create · Play Co"
|
|
1774
|
+
* ```
|
|
1775
|
+
*/
|
|
1776
|
+
"provision:plan"(p) {
|
|
1777
|
+
ctx.log.info(`infra · ${p.exists} exist, ${p.missing} to create · ${p.account}`);
|
|
1778
|
+
},
|
|
1779
|
+
/**
|
|
702
1780
|
* Log one clean line per provisioned resource: "kind name".
|
|
703
1781
|
*
|
|
704
1782
|
* @param p - The provision:resource event payload.
|
|
@@ -711,6 +1789,55 @@ const createCliHooks = (ctx) => ({
|
|
|
711
1789
|
ctx.log.info(`${p.kind} ${p.name}`);
|
|
712
1790
|
},
|
|
713
1791
|
/**
|
|
1792
|
+
* Log one clean line per already-existing resource (skipped): "kind name (exists)".
|
|
1793
|
+
*
|
|
1794
|
+
* @param p - The provision:skip event payload.
|
|
1795
|
+
* @example
|
|
1796
|
+
* ```ts
|
|
1797
|
+
* handler({ kind: "kv", name: "KV" }); // "kv KV (exists)"
|
|
1798
|
+
* ```
|
|
1799
|
+
*/
|
|
1800
|
+
"provision:skip"(p) {
|
|
1801
|
+
ctx.log.info(`${p.kind} ${p.name} (exists)`);
|
|
1802
|
+
},
|
|
1803
|
+
/**
|
|
1804
|
+
* Log one dev-session phase: "phase" or "phase · detail".
|
|
1805
|
+
*
|
|
1806
|
+
* @param p - The dev:phase event payload.
|
|
1807
|
+
* @example
|
|
1808
|
+
* ```ts
|
|
1809
|
+
* handler({ phase: "serve", detail: "http://localhost:8787" }); // "serve · http://localhost:8787"
|
|
1810
|
+
* ```
|
|
1811
|
+
*/
|
|
1812
|
+
"dev:phase"(p) {
|
|
1813
|
+
ctx.log.info(p.detail ? `${p.phase} · ${p.detail}` : p.phase);
|
|
1814
|
+
},
|
|
1815
|
+
/**
|
|
1816
|
+
* Log the site rebuild result: "site <n> files · <ms>ms" (omits the count when unknown).
|
|
1817
|
+
*
|
|
1818
|
+
* @param p - The dev:rebuilt event payload.
|
|
1819
|
+
* @example
|
|
1820
|
+
* ```ts
|
|
1821
|
+
* handler({ files: 12, ms: 240 }); // "site 12 files · 240ms"
|
|
1822
|
+
* handler({ files: 0, ms: 240 }); // "site · 240ms"
|
|
1823
|
+
* ```
|
|
1824
|
+
*/
|
|
1825
|
+
"dev:rebuilt"(p) {
|
|
1826
|
+
ctx.log.info(p.files > 0 ? `site ${String(p.files)} files · ${String(p.ms)}ms` : `site · ${String(p.ms)}ms`);
|
|
1827
|
+
},
|
|
1828
|
+
/**
|
|
1829
|
+
* Log a non-fatal dev build failure via warn (the session keeps serving the last good build).
|
|
1830
|
+
*
|
|
1831
|
+
* @param p - The dev:error event payload.
|
|
1832
|
+
* @example
|
|
1833
|
+
* ```ts
|
|
1834
|
+
* handler({ message: "build failed" }); // warn "build failed"
|
|
1835
|
+
* ```
|
|
1836
|
+
*/
|
|
1837
|
+
"dev:error"(p) {
|
|
1838
|
+
ctx.log.warn(p.message);
|
|
1839
|
+
},
|
|
1840
|
+
/**
|
|
714
1841
|
* Log the terminal success line with the deployed URL.
|
|
715
1842
|
*
|
|
716
1843
|
* @param p - The deploy:complete event payload.
|