@moku-labs/web 0.1.0-alpha.1 → 0.1.0-alpha.3

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/test.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- import { C as EnvApi, D as EnvVarSpec, E as EnvState, O as LogApi, T as EnvProvider, d as RouteBuilder, k as LogState, r as WebAppConfig, w as EnvConfig } from "./route-builder-Lv6HUVvP.cjs";
1
+ import { A as EnvVarSpec, D as EnvConfig, E as EnvApi, M as LogState, O as EnvProvider, j as LogApi, k as EnvState, r as WebAppConfig, u as RouteBuilder } from "./route-builder-CKvvehVO.cjs";
2
2
  import { WebApp } from "./index.cjs";
3
3
  import * as _moku_labs_core0 from "@moku-labs/core";
4
4
 
package/dist/test.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { C as EnvApi, D as EnvVarSpec, E as EnvState, O as LogApi, T as EnvProvider, d as RouteBuilder, k as LogState, r as WebAppConfig, w as EnvConfig } from "./index-CWdZdegx.mjs";
1
+ import { A as EnvVarSpec, D as EnvConfig, E as EnvApi, M as LogState, O as EnvProvider, j as LogApi, k as EnvState, r as WebAppConfig, u as RouteBuilder } from "./index-CddOHo8I.mjs";
2
2
  import { WebApp } from "./index.mjs";
3
3
  import * as _moku_labs_core0 from "@moku-labs/core";
4
4
 
package/dist/test.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { _ as createEnvState, g as validateSchema, h as createLogApi, m as createLogState, v as createEnvApi } from "./factory-DwpBwjDk.mjs";
2
- import "./project-BTNUWbGQ.mjs";
2
+ import "./project-BaG_ipVz.mjs";
3
3
  import { createApp } from "./index.mjs";
4
4
  import { createCorePlugin } from "@moku-labs/core";
5
5
 
@@ -0,0 +1,423 @@
1
+ const require_project = require('./project-1pAh4RxJ.cjs');
2
+ let node_fs = require("node:fs");
3
+ let node_fs_promises = require("node:fs/promises");
4
+ let node_path = require("node:path");
5
+ let node_os = require("node:os");
6
+
7
+ //#region src/plugins/deploy/generators/wrangler-config.ts
8
+ /** @file deploy plugin wrangler.jsonc generator — emit + read + diff. */
9
+ const todayIsoDate = () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
10
+ /**
11
+ * Generate the textual contents of `wrangler.jsonc`.
12
+ *
13
+ * The file is written by `init` once and treated as the deploy-time SSoT
14
+ * thereafter. A header comment warns against using Cloudflare's dashboard
15
+ * git-auto-build, which does NOT recognize `.jsonc`.
16
+ *
17
+ * @param input - Slug, outdir, and optional compatibility date.
18
+ * @returns JSONC text suitable for writing to `wrangler.jsonc`.
19
+ */
20
+ const generateWranglerConfig = (input) => {
21
+ const compat = input.compatibilityDate ?? todayIsoDate();
22
+ const body = {
23
+ name: input.slug,
24
+ pages_build_output_dir: input.outdir,
25
+ compatibility_date: compat
26
+ };
27
+ return [
28
+ "// wrangler.jsonc — generated by `moku deploy init`.",
29
+ "// Do NOT use Cloudflare Pages dashboard git-push auto-build with this config —",
30
+ "// wrangler.jsonc is recognized by wrangler CLI only. Use .github/workflows/deploy.yml instead.",
31
+ "//",
32
+ "// Edit `name` to change the Cloudflare Pages project this repo deploys to.",
33
+ "// Edit `pages_build_output_dir` to change the upload directory.",
34
+ JSON.stringify(body, null, 2),
35
+ ""
36
+ ].join("\n");
37
+ };
38
+ /** Strip `//` line comments from JSONC text (block comments are not supported by the generator). */
39
+ const stripLineComments = (text) => text.split("\n").map((line) => {
40
+ const idx = line.indexOf("//");
41
+ if (idx === -1) return line;
42
+ const before = line.slice(0, idx);
43
+ if ((before.match(/"/g) ?? []).length % 2 === 1) return line;
44
+ return before.replace(/\s+$/, "");
45
+ }).join("\n");
46
+ /** Narrow an unknown parsed value into the {@link WranglerConfigShape} or `null`. */
47
+ const parseShape = (value) => {
48
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
49
+ const fields = {};
50
+ for (const [k, v] of Object.entries(value)) fields[k] = v;
51
+ const name = fields.name;
52
+ const outdir = fields.pages_build_output_dir;
53
+ const compat = fields.compatibility_date;
54
+ if (typeof name !== "string" || typeof outdir !== "string" || typeof compat !== "string") return null;
55
+ return {
56
+ name,
57
+ pages_build_output_dir: outdir,
58
+ compatibility_date: compat
59
+ };
60
+ };
61
+ /**
62
+ * Read and parse `wrangler.jsonc` from disk.
63
+ *
64
+ * @param cwd - Project root.
65
+ * @returns The parsed shape, or `null` if missing or unparseable.
66
+ */
67
+ const readWranglerConfig = (cwd) => {
68
+ const filePath = (0, node_path.join)(cwd, "wrangler.jsonc");
69
+ if (!(0, node_fs.existsSync)(filePath)) return null;
70
+ try {
71
+ const stripped = stripLineComments((0, node_fs.readFileSync)(filePath, "utf8"));
72
+ return parseShape(JSON.parse(stripped));
73
+ } catch {
74
+ return null;
75
+ }
76
+ };
77
+ /**
78
+ * Compute the diff between an on-disk config and the proposed new values.
79
+ *
80
+ * @param current - Parsed wrangler.jsonc on disk, or null if file is missing.
81
+ * @param proposed - Newly computed values (slug, outdir).
82
+ * @returns Array of {@link DiffEntry} — empty when no drift.
83
+ */
84
+ const diffWranglerConfig = (current, proposed) => {
85
+ if (current === null) return [{
86
+ field: "name",
87
+ current: "(missing)",
88
+ proposed: proposed.slug
89
+ }, {
90
+ field: "pages_build_output_dir",
91
+ current: "(missing)",
92
+ proposed: proposed.outdir
93
+ }];
94
+ const out = [];
95
+ if (current.name !== proposed.slug) out.push({
96
+ field: "name",
97
+ current: current.name,
98
+ proposed: proposed.slug
99
+ });
100
+ if (current.pages_build_output_dir !== proposed.outdir) out.push({
101
+ field: "pages_build_output_dir",
102
+ current: current.pages_build_output_dir,
103
+ proposed: proposed.outdir
104
+ });
105
+ return out;
106
+ };
107
+ /** Write `wrangler.jsonc` content to disk. */
108
+ const writeWranglerConfig = async (cwd, content) => {
109
+ await (0, node_fs_promises.writeFile)((0, node_path.join)(cwd, "wrangler.jsonc"), content, "utf8");
110
+ };
111
+
112
+ //#endregion
113
+ //#region src/plugins/deploy/wrangler.ts
114
+ /** @file deploy plugin subprocess wrapper — Bun.spawn wrangler invocation, scrubSecrets, error taxonomy.
115
+ *
116
+ * Security note: This file is the credential seam — every wrangler call flows through here. `scrubSecrets()`
117
+ * must be applied to every stderr capture BEFORE any `ctx.log` call. Tests for `scrubSecrets` must pass
118
+ * before any other module touches wrangler stderr.
119
+ */
120
+ /**
121
+ * Single source of truth for the wrangler version, consumed by `ensureWrangler()` AND
122
+ * `generators/github-workflow.ts`. Bumping this constant atomically updates both local and
123
+ * CI surfaces. 4.34.0 is the documented minimum for 100k-file paid-tier Cloudflare Pages deploys.
124
+ */
125
+ const MOKU_WRANGLER_VERSION = "4.34.0";
126
+ /**
127
+ * Pinned SHA of `cloudflare/wrangler-action`. Reviewable on each plugin release.
128
+ * Semver tags are mutable and supply-chain-risky; SHA-pinning is required by
129
+ * GitHub org/enterprise policy since August 2025.
130
+ *
131
+ * To bump: pick a fresh commit SHA from https://github.com/cloudflare/wrangler-action/commits/main,
132
+ * paste it here, and add a CHANGELOG entry.
133
+ */
134
+ const WRANGLER_ACTION_SHA = "f84a562284fc78278ff9052435d9526f9c718361";
135
+ const MIN_ENTROPY_BITS_PER_CHAR = 3.5;
136
+ const MIN_SECRET_LENGTH = 16;
137
+ const ERROR_CODE_PROJECT_NOT_FOUND = 8000007;
138
+ /** Default allowlist — values for these env vars are NEVER scrubbed. */
139
+ const DEFAULT_SCRUB_ALLOWLIST = new Set(["CLOUDFLARE_ACCOUNT_ID"]);
140
+ /**
141
+ * Build the wrangler argv array — pure function consumed by BOTH `runWrangler()` (runtime)
142
+ * AND `generators/github-workflow.ts` (CI workflow command string). Single point of change
143
+ * for the future `target: 'workers'` migration.
144
+ *
145
+ * The Pages branch deliberately omits `--project-name` — wrangler reads the project name
146
+ * from `wrangler.jsonc#name`, which is the deploy-time single source of truth.
147
+ *
148
+ * @param target - Deploy target. `'workers'` is not implemented in Phase 1.
149
+ * @param outdir - Resolved build output directory (typically read from wrangler.jsonc).
150
+ * @param branch - Branch name to deploy to (defaults to production branch).
151
+ * @returns Argv array suitable for `Bun.spawn(['bunx', 'wrangler@VERSION', ...args])`.
152
+ * @throws Error when `target === 'workers'`.
153
+ */
154
+ const buildWranglerArgs = (target, outdir, branch) => {
155
+ if (target === "workers") throw new Error("deploy: target \"workers\" is not implemented in Phase 1 — Cloudflare Pages only");
156
+ return [
157
+ "pages",
158
+ "deploy",
159
+ outdir,
160
+ "--branch",
161
+ branch
162
+ ];
163
+ };
164
+ /**
165
+ * Compute Shannon entropy (bits per character) of a string.
166
+ *
167
+ * Exported for testing — used by `scrubSecrets` to decide whether a candidate value
168
+ * is high-entropy enough to be treated as a secret.
169
+ *
170
+ * @param value - The string to measure.
171
+ * @returns Entropy in bits per character (0 for empty strings).
172
+ */
173
+ const shannonEntropy = (value) => {
174
+ if (value.length === 0) return 0;
175
+ const counts = /* @__PURE__ */ new Map();
176
+ for (const ch of value) counts.set(ch, (counts.get(ch) ?? 0) + 1);
177
+ let entropy = 0;
178
+ for (const count of counts.values()) {
179
+ const p = count / value.length;
180
+ entropy -= p * Math.log2(p);
181
+ }
182
+ return entropy;
183
+ };
184
+ /** Escape a string for safe insertion into a regular expression. */
185
+ const escapeRegExp = (value) => value.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
186
+ /**
187
+ * Replace high-entropy environment-variable values in `text` with `[REDACTED]`.
188
+ *
189
+ * Algorithm: for each env var, scrub its value when (a) the var name is NOT in
190
+ * `allowlist`, (b) the value's length is at least {@link MIN_SECRET_LENGTH}, and
191
+ * (c) the value's Shannon entropy is at least {@link MIN_ENTROPY_BITS_PER_CHAR} bits/char.
192
+ *
193
+ * Account IDs and similar low-entropy non-secret identifiers are NEVER scrubbed
194
+ * (they appear in legitimate diagnostic URLs). Error code prefixes like `8000007`
195
+ * are too short and too low-entropy to be flagged.
196
+ *
197
+ * @param text - The text to scrub (typically wrangler stderr).
198
+ * @param env - Environment variable map whose values may appear in `text`.
199
+ * @param allowlist - Env var names whose values must NEVER be scrubbed. Defaults to {@link DEFAULT_SCRUB_ALLOWLIST}.
200
+ * @returns The scrubbed text — high-entropy values replaced by `[REDACTED]`.
201
+ */
202
+ const scrubSecrets = (text, env, allowlist = DEFAULT_SCRUB_ALLOWLIST) => {
203
+ let scrubbed = text;
204
+ for (const [name, value] of Object.entries(env)) {
205
+ if (value === void 0 || value === "") continue;
206
+ if (allowlist.has(name)) continue;
207
+ if (value.length < MIN_SECRET_LENGTH) continue;
208
+ if (shannonEntropy(value) < MIN_ENTROPY_BITS_PER_CHAR) continue;
209
+ scrubbed = scrubbed.replaceAll(new RegExp(escapeRegExp(value), "g"), "[REDACTED]");
210
+ }
211
+ return scrubbed;
212
+ };
213
+ /** Read the optional `Bun` global without using an inline type assertion. */
214
+ const getBunGlobal = () => globalThis;
215
+ const defaultSpawn = (cmd, options) => {
216
+ const { Bun: bun } = getBunGlobal();
217
+ if (bun === void 0) throw new Error("deploy: Bun runtime is required to spawn wrangler subprocesses");
218
+ return bun.spawn(cmd, options);
219
+ };
220
+ /** Structured wrangler error carrying scrubbed stderr and classified `kind`. */
221
+ var WranglerError = class extends Error {
222
+ kind;
223
+ exitCode;
224
+ scrubbedStderr;
225
+ constructor(kind, exitCode, scrubbedStderr, message) {
226
+ super(message ?? `wrangler exited with ${exitCode} (${kind})`);
227
+ this.name = "WranglerError";
228
+ this.kind = kind;
229
+ this.exitCode = exitCode;
230
+ this.scrubbedStderr = scrubbedStderr;
231
+ }
232
+ };
233
+ /**
234
+ * Classify a wrangler failure into a structured {@link WranglerError}.
235
+ *
236
+ * Inspects parsed ND-JSON output first (looking for error code 8000007), then
237
+ * falls back to stderr pattern matching for JWT-expiry, network, and auth signals.
238
+ *
239
+ * @param exitCode - Process exit code.
240
+ * @param scrubbedStderr - Stderr text AFTER `scrubSecrets`.
241
+ * @param ndJson - Parsed ND-JSON entries from `WRANGLER_OUTPUT_FILE_PATH` (may be empty).
242
+ * @returns A {@link WranglerError} with a user-actionable message.
243
+ */
244
+ const classifyWranglerError = (exitCode, scrubbedStderr, ndJson) => {
245
+ for (const entry of ndJson) {
246
+ const code = entry.code;
247
+ if (typeof code === "number" && code === ERROR_CODE_PROJECT_NOT_FOUND) return new WranglerError("project-not-found", exitCode, scrubbedStderr, "Cloudflare project not found. Run `wrangler pages project create <slug>` or `moku deploy init --create-project`.");
248
+ }
249
+ if (/Expired JWT|403[^\n]*upload/i.test(scrubbedStderr)) return new WranglerError("jwt-expired", exitCode, scrubbedStderr, "Wrangler JWT expired mid-upload (large site). Re-run `moku deploy` to retry.");
250
+ if (/ETIMEDOUT|ENETUNREACH|ECONNREFUSED/i.test(scrubbedStderr)) return new WranglerError("network", exitCode, scrubbedStderr, "Network error contacting Cloudflare. Check connectivity and retry.");
251
+ if (exitCode === 401 || exitCode === 403 || /unauthorized|forbidden/i.test(scrubbedStderr)) return new WranglerError("auth", exitCode, scrubbedStderr, "Cloudflare auth failed. Local: run `wrangler login`. CI: verify CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID secrets are set and the token has \"Account → Cloudflare Pages → Edit\" permission.");
252
+ return new WranglerError("unknown", exitCode, scrubbedStderr, `wrangler exited with code ${exitCode}.`);
253
+ };
254
+ /** Cast a parsed JSON value to a typed Record without an inline assertion. */
255
+ const asObject = (value) => {
256
+ if (typeof value !== "object" || value === null) return null;
257
+ if (Array.isArray(value)) return null;
258
+ const result = {};
259
+ for (const [k, v] of Object.entries(value)) result[k] = v;
260
+ return result;
261
+ };
262
+ /**
263
+ * Parse a wrangler ND-JSON output file (one JSON object per line).
264
+ *
265
+ * Lines that fail to parse are silently skipped — wrangler emits human-readable
266
+ * lines interleaved with structured entries in some failure modes.
267
+ *
268
+ * @param filePath - Path written by `WRANGLER_OUTPUT_FILE_PATH`.
269
+ * @returns Array of parsed entries; empty array if the file is missing or empty.
270
+ */
271
+ const parseNdJson = async (filePath) => {
272
+ let raw;
273
+ try {
274
+ raw = await (0, node_fs_promises.readFile)(filePath, "utf8");
275
+ } catch {
276
+ return [];
277
+ }
278
+ const lines = raw.split("\n").filter((line) => line.trim() !== "");
279
+ const out = [];
280
+ for (const line of lines) try {
281
+ const parsed = asObject(JSON.parse(line));
282
+ if (parsed !== null) out.push(parsed);
283
+ } catch {}
284
+ return out;
285
+ };
286
+ /**
287
+ * Spawn `bunx wrangler@VERSION <args>` and return structured output.
288
+ *
289
+ * Always sets `WRANGLER_OUTPUT_FILE_PATH` (ND-JSON) and `WRANGLER_SEND_METRICS=false`.
290
+ * Always scrubs stderr via {@link scrubSecrets} before returning or throwing.
291
+ *
292
+ * @param args - Wrangler subcommand argv (e.g., `['pages', 'deploy', 'dist', '--branch', 'main']`).
293
+ * @param env - Environment passed to the subprocess. Token-bearing values are scrubbed from stderr output.
294
+ * @param spawn - Spawn implementation. Injectable for tests.
295
+ * @returns Stdout, scrubbed stderr, parsed ND-JSON, and exit code.
296
+ * @throws {@link WranglerError} on non-zero exit.
297
+ */
298
+ const runWrangler = async (args, env, spawn = defaultSpawn) => {
299
+ const temporaryDir = await (0, node_fs_promises.mkdtemp)((0, node_path.join)((0, node_os.tmpdir)(), "moku-deploy-"));
300
+ const outputPath = (0, node_path.join)(temporaryDir, "wrangler-output.ndjson");
301
+ const spawnEnv = {
302
+ ...env,
303
+ WRANGLER_OUTPUT_FILE_PATH: outputPath,
304
+ WRANGLER_SEND_METRICS: "false"
305
+ };
306
+ try {
307
+ const proc = spawn([
308
+ "bunx",
309
+ `wrangler@${MOKU_WRANGLER_VERSION}`,
310
+ ...args
311
+ ], {
312
+ env: spawnEnv,
313
+ stdout: "pipe",
314
+ stderr: "pipe"
315
+ });
316
+ const [stdoutText, stderrText] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
317
+ const exitCode = await proc.exited;
318
+ const scrubbedStderr = scrubSecrets(stderrText, env);
319
+ const ndJson = await parseNdJson(outputPath);
320
+ if (exitCode !== 0) throw classifyWranglerError(exitCode, scrubbedStderr, ndJson);
321
+ return {
322
+ stdout: stdoutText,
323
+ scrubbedStderr,
324
+ ndJson,
325
+ exitCode
326
+ };
327
+ } finally {
328
+ await (0, node_fs_promises.rm)(temporaryDir, {
329
+ recursive: true,
330
+ force: true
331
+ }).catch(() => {});
332
+ }
333
+ };
334
+ /** Read `url` + deployment id from a single ND-JSON entry, if both are present. */
335
+ const readDeploymentEntry = (entry) => {
336
+ const url = entry.url;
337
+ const id = entry.deployment_id ?? entry.id;
338
+ if (typeof url !== "string" || typeof id !== "string") return null;
339
+ return {
340
+ url,
341
+ deploymentId: id
342
+ };
343
+ };
344
+ /**
345
+ * Extract the deployment URL and deployment ID from wrangler's ND-JSON output.
346
+ *
347
+ * Wrangler `pages deploy` writes a `deployment` entry containing the live URL
348
+ * and ID once the upload finalizes. Falls back to scanning the entries for any
349
+ * `url` field if the canonical entry shape is missing.
350
+ *
351
+ * @param ndJson - Parsed entries from {@link parseNdJson}.
352
+ * @returns `{ url, deploymentId }` if discoverable; `null` otherwise.
353
+ */
354
+ const extractDeploymentInfo = (ndJson) => {
355
+ for (const entry of ndJson) {
356
+ const type = entry.type;
357
+ if (type === "deployment" || type === "pages-deployment") {
358
+ const info = readDeploymentEntry(entry);
359
+ if (info !== null) return info;
360
+ }
361
+ }
362
+ for (const entry of ndJson) {
363
+ const info = readDeploymentEntry(entry);
364
+ if (info !== null) return info;
365
+ }
366
+ return null;
367
+ };
368
+
369
+ //#endregion
370
+ Object.defineProperty(exports, 'MOKU_WRANGLER_VERSION', {
371
+ enumerable: true,
372
+ get: function () {
373
+ return MOKU_WRANGLER_VERSION;
374
+ }
375
+ });
376
+ Object.defineProperty(exports, 'WRANGLER_ACTION_SHA', {
377
+ enumerable: true,
378
+ get: function () {
379
+ return WRANGLER_ACTION_SHA;
380
+ }
381
+ });
382
+ Object.defineProperty(exports, 'buildWranglerArgs', {
383
+ enumerable: true,
384
+ get: function () {
385
+ return buildWranglerArgs;
386
+ }
387
+ });
388
+ Object.defineProperty(exports, 'diffWranglerConfig', {
389
+ enumerable: true,
390
+ get: function () {
391
+ return diffWranglerConfig;
392
+ }
393
+ });
394
+ Object.defineProperty(exports, 'extractDeploymentInfo', {
395
+ enumerable: true,
396
+ get: function () {
397
+ return extractDeploymentInfo;
398
+ }
399
+ });
400
+ Object.defineProperty(exports, 'generateWranglerConfig', {
401
+ enumerable: true,
402
+ get: function () {
403
+ return generateWranglerConfig;
404
+ }
405
+ });
406
+ Object.defineProperty(exports, 'readWranglerConfig', {
407
+ enumerable: true,
408
+ get: function () {
409
+ return readWranglerConfig;
410
+ }
411
+ });
412
+ Object.defineProperty(exports, 'runWrangler', {
413
+ enumerable: true,
414
+ get: function () {
415
+ return runWrangler;
416
+ }
417
+ });
418
+ Object.defineProperty(exports, 'writeWranglerConfig', {
419
+ enumerable: true,
420
+ get: function () {
421
+ return writeWranglerConfig;
422
+ }
423
+ });