@moku-labs/web 0.1.0-alpha.1 → 0.1.0-alpha.4
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/README.md +36 -1
- package/dist/bin/moku.cjs +575 -1
- package/dist/bin/moku.mjs +576 -2
- package/dist/chunk-DQk6qfdC.mjs +18 -0
- package/dist/{factory-CixCpR9C.cjs → factory-CMOo4n6a.cjs} +13 -1
- package/dist/{factory-BBVQO5ZG.d.mts → factory-DRFGSslp.d.mts} +26 -2
- package/dist/{factory-DwpBwjDk.mjs → factory-DiKypQqs.mjs} +2 -2
- package/dist/{factory-D0m7Xil2.d.cts → factory-k-YoScgB.d.cts} +26 -2
- package/dist/{index-CWdZdegx.d.mts → index-DH3jlpNi.d.mts} +215 -61
- package/dist/{route-builder-Lv6HUVvP.d.cts → index-DaY7vTuo.d.cts} +215 -61
- package/dist/index.cjs +75 -3
- package/dist/index.d.cts +41 -40
- package/dist/index.d.mts +41 -40
- package/dist/index.mjs +6 -5
- package/dist/plugins/head/build.d.cts +1 -1
- package/dist/plugins/head/build.d.mts +1 -1
- package/dist/plugins/head/build.mjs +1 -1
- package/dist/plugins/spa/index.cjs +1 -1
- package/dist/plugins/spa/index.d.cts +1 -1
- package/dist/plugins/spa/index.d.mts +1 -1
- package/dist/plugins/spa/index.mjs +1 -1
- package/dist/{primitives-BBo4wxUL.d.cts → primitives-DKgZfRAO.d.mts} +4 -2
- package/dist/{primitives-kuZFxqV7.d.mts → primitives-yZqQkOVR.d.cts} +4 -2
- package/dist/{project-C1vtMxE8.cjs → project-B8z4jeMC.cjs} +307 -5
- package/dist/{project-BTNUWbGQ.mjs → project-guCYpUeD.mjs} +232 -8
- package/dist/test.cjs +2 -2
- package/dist/test.d.cts +1 -1
- package/dist/test.d.mts +1 -1
- package/dist/test.mjs +2 -2
- package/dist/wrangler-BlZWVmX9.mjs +369 -0
- package/dist/wrangler-Bomk9mU-.cjs +423 -0
- package/package.json +9 -1
- /package/dist/{primitives-gO5i1tD8.mjs → primitives-Dhko-oLM.mjs} +0 -0
package/README.md
CHANGED
|
@@ -28,10 +28,45 @@ await app.build.run()
|
|
|
28
28
|
|
|
29
29
|
## Architecture
|
|
30
30
|
|
|
31
|
-
- **
|
|
31
|
+
- **10 domain-grouped plugins** (`log`, `env`, `site`, `i18n`, `content`, `router`, `head`, `build`, `spa`, `deploy`) wired via `@moku-labs/core` micro-kernel
|
|
32
32
|
- **Bun-native bundler** (no Vite subprocess)
|
|
33
33
|
- **Generic-preserving `createApp` wrapper** projects flat config → `pluginConfigs`
|
|
34
34
|
- **Dev mode** lives in `bin/moku.ts dev` (NOT a plugin)
|
|
35
|
+
- **Cloudflare Pages deploy** via the `deploy` plugin and `moku deploy` CLI (see `src/plugins/deploy/README.md`)
|
|
36
|
+
|
|
37
|
+
## Writing custom plugins
|
|
38
|
+
|
|
39
|
+
Consumer apps can extend the framework with their own plugins using the framework-bound `createPlugin` (NOT the raw one from `@moku-labs/core`):
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { createApp, createPlugin, i18n, type Site } from '@moku-labs/web'
|
|
43
|
+
|
|
44
|
+
const analytics = createPlugin('analytics', {
|
|
45
|
+
depends: [i18n],
|
|
46
|
+
config: { trackingId: '' },
|
|
47
|
+
createState: () => ({ events: [] as string[] }),
|
|
48
|
+
api: (ctx) => ({ track: (name: string) => { ctx.state.events.push(name) } }),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const siteConfig: Site.SiteConfig = {
|
|
52
|
+
name: 'My Blog',
|
|
53
|
+
url: 'https://example.com',
|
|
54
|
+
author: 'Jane Doe',
|
|
55
|
+
description: 'Personal blog',
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
All 10 plugin instances (`log, env, site, i18n, content, router, head, build, spa, deploy`) and their namespaced config types (`Log, Env, Site, I18n, Content, Router, Head, Build, Spa, Deploy`) are exported from the main entry — use them for `ctx.require(...)` access and for typing `pluginConfigs` overrides.
|
|
60
|
+
|
|
61
|
+
## Test helpers (`@moku-labs/web/test`)
|
|
62
|
+
|
|
63
|
+
Framework-aware test factories for isolating plugin behavior in unit tests:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { createTestApp, createTestLog, createTestEnv } from '@moku-labs/web/test'
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Each factory returns a FRESH instance per call — never a module-level singleton — safe for parallel vitest workers.
|
|
35
70
|
|
|
36
71
|
## License
|
|
37
72
|
|
package/dist/bin/moku.cjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
const require_project = require('../project-
|
|
2
|
+
const require_project = require('../project-B8z4jeMC.cjs');
|
|
3
|
+
const require_wrangler = require('../wrangler-Bomk9mU-.cjs');
|
|
3
4
|
let node_fs = require("node:fs");
|
|
5
|
+
let node_fs_promises = require("node:fs/promises");
|
|
4
6
|
let node_path = require("node:path");
|
|
5
7
|
let node_util = require("node:util");
|
|
6
8
|
|
|
@@ -25,6 +27,7 @@ Commands:
|
|
|
25
27
|
build [folder] Build static site (default folder: src/)
|
|
26
28
|
dev [folder] Start dev server with watch + rebuild
|
|
27
29
|
preview [folder] Build and serve site for local preview
|
|
30
|
+
deploy [folder] Deploy dist/ to Cloudflare Pages (run \`deploy init\` once first)
|
|
28
31
|
|
|
29
32
|
Options:
|
|
30
33
|
--version Show version number
|
|
@@ -62,6 +65,30 @@ Options:
|
|
|
62
65
|
--port, -p <n> Server port (default: 4173)
|
|
63
66
|
--help, -h Show help`.trim();
|
|
64
67
|
/**
|
|
68
|
+
* Format the help text for `moku deploy`.
|
|
69
|
+
*
|
|
70
|
+
* @returns The help text.
|
|
71
|
+
*/
|
|
72
|
+
const formatDeployHelp = () => `
|
|
73
|
+
Usage: moku deploy [folder] [options]
|
|
74
|
+
moku deploy init [folder] [options]
|
|
75
|
+
|
|
76
|
+
Arguments:
|
|
77
|
+
folder Source folder containing main.ts (default: src/)
|
|
78
|
+
|
|
79
|
+
Deploy options:
|
|
80
|
+
--build Run \`moku build\` before deploying
|
|
81
|
+
--branch <name> Branch to deploy to (default: from wrangler.jsonc workflow)
|
|
82
|
+
--help, -h Show help
|
|
83
|
+
|
|
84
|
+
Init options:
|
|
85
|
+
--ci Also generate .github/workflows/deploy.yml
|
|
86
|
+
--force Overwrite existing files (warns on slug change)
|
|
87
|
+
--create-project Transactionally create the Cloudflare project before writing wrangler.jsonc
|
|
88
|
+
--check Diff only, no writes; exits non-zero on drift
|
|
89
|
+
--branch <name> Override the auto-detected default branch
|
|
90
|
+
--help, -h Show help`.trim();
|
|
91
|
+
/**
|
|
65
92
|
* Format the help text for `moku preview`.
|
|
66
93
|
*
|
|
67
94
|
* @returns The help text.
|
|
@@ -224,6 +251,103 @@ const parseDev = (argv) => {
|
|
|
224
251
|
}
|
|
225
252
|
};
|
|
226
253
|
/**
|
|
254
|
+
* Parse `moku deploy` arguments.
|
|
255
|
+
*
|
|
256
|
+
* @param argv - Argv after the `deploy` keyword (NOT including `init`).
|
|
257
|
+
* @returns A {@link ParseResult} carrying a {@link DeployArgs} or an error message.
|
|
258
|
+
*/
|
|
259
|
+
const parseDeploy = (argv) => {
|
|
260
|
+
try {
|
|
261
|
+
const { values, positionals } = (0, node_util.parseArgs)({
|
|
262
|
+
args: argv,
|
|
263
|
+
options: {
|
|
264
|
+
build: {
|
|
265
|
+
type: "boolean",
|
|
266
|
+
default: false
|
|
267
|
+
},
|
|
268
|
+
branch: { type: "string" },
|
|
269
|
+
help: {
|
|
270
|
+
type: "boolean",
|
|
271
|
+
short: "h",
|
|
272
|
+
default: false
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
allowPositionals: true,
|
|
276
|
+
strict: true
|
|
277
|
+
});
|
|
278
|
+
return {
|
|
279
|
+
ok: true,
|
|
280
|
+
value: {
|
|
281
|
+
folder: positionals[0] ?? "src",
|
|
282
|
+
help: values.help,
|
|
283
|
+
build: values.build,
|
|
284
|
+
...values.branch === void 0 ? {} : { branch: values.branch }
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
} catch (error) {
|
|
288
|
+
return {
|
|
289
|
+
ok: false,
|
|
290
|
+
message: error.message
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
/**
|
|
295
|
+
* Parse `moku deploy init` arguments.
|
|
296
|
+
*
|
|
297
|
+
* @param argv - Argv after the `deploy init` keywords.
|
|
298
|
+
* @returns A {@link ParseResult} carrying a {@link DeployInitArgs} or an error message.
|
|
299
|
+
*/
|
|
300
|
+
const parseDeployInit = (argv) => {
|
|
301
|
+
try {
|
|
302
|
+
const { values, positionals } = (0, node_util.parseArgs)({
|
|
303
|
+
args: argv,
|
|
304
|
+
options: {
|
|
305
|
+
ci: {
|
|
306
|
+
type: "boolean",
|
|
307
|
+
default: false
|
|
308
|
+
},
|
|
309
|
+
force: {
|
|
310
|
+
type: "boolean",
|
|
311
|
+
default: false
|
|
312
|
+
},
|
|
313
|
+
"create-project": {
|
|
314
|
+
type: "boolean",
|
|
315
|
+
default: false
|
|
316
|
+
},
|
|
317
|
+
check: {
|
|
318
|
+
type: "boolean",
|
|
319
|
+
default: false
|
|
320
|
+
},
|
|
321
|
+
branch: { type: "string" },
|
|
322
|
+
help: {
|
|
323
|
+
type: "boolean",
|
|
324
|
+
short: "h",
|
|
325
|
+
default: false
|
|
326
|
+
}
|
|
327
|
+
},
|
|
328
|
+
allowPositionals: true,
|
|
329
|
+
strict: true
|
|
330
|
+
});
|
|
331
|
+
return {
|
|
332
|
+
ok: true,
|
|
333
|
+
value: {
|
|
334
|
+
folder: positionals[0] ?? "src",
|
|
335
|
+
help: values.help,
|
|
336
|
+
ci: values.ci,
|
|
337
|
+
force: values.force,
|
|
338
|
+
createProject: values["create-project"],
|
|
339
|
+
check: values.check,
|
|
340
|
+
...values.branch === void 0 ? {} : { branch: values.branch }
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
} catch (error) {
|
|
344
|
+
return {
|
|
345
|
+
ok: false,
|
|
346
|
+
message: error.message
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
/**
|
|
227
351
|
* Parse `moku preview` arguments.
|
|
228
352
|
*
|
|
229
353
|
* @param argv - Argv after the `preview` keyword.
|
|
@@ -376,6 +500,7 @@ const runCli = async (argv, deps) => {
|
|
|
376
500
|
case "build": return deps.buildCommand(top.rest);
|
|
377
501
|
case "dev": return deps.devCommand(top.rest);
|
|
378
502
|
case "preview": return deps.previewCommand(top.rest);
|
|
503
|
+
case "deploy": return deps.deployCommand(top.rest);
|
|
379
504
|
default:
|
|
380
505
|
deps.stderr(`Unknown command: ${top.command}`);
|
|
381
506
|
deps.stdout(formatHelp());
|
|
@@ -465,6 +590,449 @@ const buildCommand = async (argv, deps) => {
|
|
|
465
590
|
return runBuildOnce(prepared.app, deps.stdout, deps.stderr);
|
|
466
591
|
};
|
|
467
592
|
|
|
593
|
+
//#endregion
|
|
594
|
+
//#region src/plugins/deploy/generators/github-workflow.ts
|
|
595
|
+
/** @file deploy plugin GitHub Actions workflow generator — SHA-pinned, single-source-of-truth wrangler version. */
|
|
596
|
+
/** Pinned SHAs for the standard runner actions. Update on each plugin release. */
|
|
597
|
+
const CHECKOUT_ACTION_SHA = "11bd71901bbe5b1630ceea73d27597364c9af683";
|
|
598
|
+
const SETUP_BUN_ACTION_SHA = "4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5";
|
|
599
|
+
/**
|
|
600
|
+
* Generate the textual contents of `.github/workflows/deploy.yml`.
|
|
601
|
+
*
|
|
602
|
+
* Notes baked in:
|
|
603
|
+
* - SHA-pinned actions (supply-chain hygiene).
|
|
604
|
+
* - `wranglerVersion` pinned via {@link MOKU_WRANGLER_VERSION} — single SoT with `ensureWrangler()`.
|
|
605
|
+
* - Command line uses `buildWranglerArgs(...)` so runtime and CI emit identical argv.
|
|
606
|
+
* - No `--project-name` flag — wrangler reads `name` from `wrangler.jsonc`.
|
|
607
|
+
* - Two-step pattern: `bun run moku build` then `wrangler-action` (enables Actions cache reuse).
|
|
608
|
+
* - `gitHubToken` enables GitHub Deployments status updates on PRs and commits.
|
|
609
|
+
*
|
|
610
|
+
* @param input - Target, outdir, and branch.
|
|
611
|
+
* @returns YAML text suitable for writing to `.github/workflows/deploy.yml`.
|
|
612
|
+
*/
|
|
613
|
+
const generateGitHubWorkflow = (input) => {
|
|
614
|
+
const args = require_wrangler.buildWranglerArgs(input.target, input.outdir, input.branch).join(" ");
|
|
615
|
+
return `# .github/workflows/deploy.yml — generated by \`moku deploy init --ci\`.
|
|
616
|
+
# To re-sync after config changes, run \`moku deploy init --ci --force\`.
|
|
617
|
+
|
|
618
|
+
name: Deploy
|
|
619
|
+
|
|
620
|
+
on:
|
|
621
|
+
push:
|
|
622
|
+
branches: [${input.branch}]
|
|
623
|
+
workflow_dispatch:
|
|
624
|
+
|
|
625
|
+
permissions:
|
|
626
|
+
contents: read
|
|
627
|
+
|
|
628
|
+
jobs:
|
|
629
|
+
deploy:
|
|
630
|
+
runs-on: ubuntu-latest
|
|
631
|
+
steps:
|
|
632
|
+
- uses: actions/checkout@${CHECKOUT_ACTION_SHA}
|
|
633
|
+
- uses: oven-sh/setup-bun@${SETUP_BUN_ACTION_SHA}
|
|
634
|
+
- run: bun install --frozen-lockfile
|
|
635
|
+
# Two-step pattern (build then deploy) enables GitHub Actions cache reuse.
|
|
636
|
+
- run: bun run moku build
|
|
637
|
+
- uses: cloudflare/wrangler-action@${require_wrangler.WRANGLER_ACTION_SHA}
|
|
638
|
+
with:
|
|
639
|
+
apiToken: \${{ secrets.CLOUDFLARE_API_TOKEN }}
|
|
640
|
+
accountId: \${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
|
641
|
+
wranglerVersion: "${require_wrangler.MOKU_WRANGLER_VERSION}"
|
|
642
|
+
gitHubToken: \${{ secrets.GITHUB_TOKEN }}
|
|
643
|
+
# buildWranglerArgs() — keep aligned with runtime argv. wrangler.jsonc#name is SSoT
|
|
644
|
+
# for the project name, so we deliberately do NOT pass --project-name here.
|
|
645
|
+
command: ${args}
|
|
646
|
+
`;
|
|
647
|
+
};
|
|
648
|
+
/** Resolve the workflow path inside `cwd`. */
|
|
649
|
+
const workflowPath = (cwd) => (0, node_path.join)(cwd, ".github", "workflows", "deploy.yml");
|
|
650
|
+
/** Check whether `.github/workflows/deploy.yml` exists in `cwd`. */
|
|
651
|
+
const githubWorkflowExists = (cwd) => (0, node_fs.existsSync)(workflowPath(cwd));
|
|
652
|
+
/** Write the generated workflow file, creating `.github/workflows/` as needed. */
|
|
653
|
+
const writeGitHubWorkflow = async (cwd, content) => {
|
|
654
|
+
const path = workflowPath(cwd);
|
|
655
|
+
(0, node_fs.mkdirSync)((0, node_path.dirname)(path), { recursive: true });
|
|
656
|
+
await (0, node_fs_promises.writeFile)(path, content, "utf8");
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
//#endregion
|
|
660
|
+
//#region src/plugins/deploy/slug.ts
|
|
661
|
+
/** @file deploy plugin slug derivation — Cloudflare Pages project-name validation + slugification. */
|
|
662
|
+
/**
|
|
663
|
+
* Cloudflare Pages project-name regex: lowercase alphanumerics and dashes only,
|
|
664
|
+
* 1–58 chars, no leading/trailing dash, no underscores. Source: workers-sdk #3222.
|
|
665
|
+
*/
|
|
666
|
+
const CLOUDFLARE_PROJECT_NAME_REGEX = /^[a-z0-9][a-z0-9-]{0,56}[a-z0-9]$/;
|
|
667
|
+
const MAX_PROJECT_NAME_LENGTH = 58;
|
|
668
|
+
/**
|
|
669
|
+
* Derive a Cloudflare Pages–valid project slug from an arbitrary site name.
|
|
670
|
+
*
|
|
671
|
+
* Algorithm: lowercase → replace non-alphanumeric with `-` → collapse repeated
|
|
672
|
+
* dashes → strip leading/trailing dashes → truncate to 58 chars → strip
|
|
673
|
+
* trailing dashes again after truncation.
|
|
674
|
+
*
|
|
675
|
+
* @param siteName - The consumer-facing site name (typically `site.name`).
|
|
676
|
+
* @returns A slug matching {@link CLOUDFLARE_PROJECT_NAME_REGEX}.
|
|
677
|
+
* @throws Error when the input slugifies to an empty string or fails regex validation.
|
|
678
|
+
*/
|
|
679
|
+
const slugify = (siteName) => {
|
|
680
|
+
const raw = siteName.toLowerCase().replaceAll(/[^a-z0-9-]/g, "-").replaceAll(/-+/g, "-").replace(/^-+/, "").replace(/-+$/, "").slice(0, MAX_PROJECT_NAME_LENGTH).replace(/-+$/, "");
|
|
681
|
+
if (raw === "") throw new Error(`deploy: cannot derive a Cloudflare project slug from "${siteName}" — result is empty after sanitization`);
|
|
682
|
+
if (!CLOUDFLARE_PROJECT_NAME_REGEX.test(raw)) throw new Error(`deploy: derived slug "${raw}" does not match Cloudflare Pages project-name regex`);
|
|
683
|
+
return raw;
|
|
684
|
+
};
|
|
685
|
+
/**
|
|
686
|
+
* Validate that an explicit project name matches Cloudflare's rules.
|
|
687
|
+
*
|
|
688
|
+
* @param name - Candidate project name.
|
|
689
|
+
* @throws Error if the name violates {@link CLOUDFLARE_PROJECT_NAME_REGEX}.
|
|
690
|
+
*/
|
|
691
|
+
const assertValidProjectName = (name) => {
|
|
692
|
+
if (!CLOUDFLARE_PROJECT_NAME_REGEX.test(name)) throw new Error(`deploy: project name "${name}" must match ^[a-z0-9][a-z0-9-]{0,56}[a-z0-9]$ (1–58 chars, lowercase alphanumerics and dashes only, no leading/trailing dash, no underscores)`);
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
//#endregion
|
|
696
|
+
//#region src/plugins/deploy/init.ts
|
|
697
|
+
/** @file deploy plugin init orchestrator — branch detection, slug derivation, generators, checklist. */
|
|
698
|
+
const DEFAULT_BRANCH = "main";
|
|
699
|
+
const sanitizedProcessEnv = () => {
|
|
700
|
+
const out = {};
|
|
701
|
+
for (const [k, v] of Object.entries(process.env)) if (typeof v === "string") out[k] = v;
|
|
702
|
+
return out;
|
|
703
|
+
};
|
|
704
|
+
const getBun = () => globalThis;
|
|
705
|
+
const runGitSymbolicRef = async () => {
|
|
706
|
+
const { Bun: bun } = getBun();
|
|
707
|
+
if (bun === void 0) return null;
|
|
708
|
+
try {
|
|
709
|
+
const proc = bun.spawn([
|
|
710
|
+
"git",
|
|
711
|
+
"symbolic-ref",
|
|
712
|
+
"refs/remotes/origin/HEAD"
|
|
713
|
+
], {
|
|
714
|
+
env: sanitizedProcessEnv(),
|
|
715
|
+
stdout: "pipe",
|
|
716
|
+
stderr: "pipe"
|
|
717
|
+
});
|
|
718
|
+
const stdout = await new Response(proc.stdout).text();
|
|
719
|
+
if (await proc.exited !== 0) return null;
|
|
720
|
+
return stdout.trim().match(/refs\/remotes\/origin\/(.+)$/)?.[1] ?? null;
|
|
721
|
+
} catch {
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
const runGitInitDefaultBranch = async () => {
|
|
726
|
+
const { Bun: bun } = getBun();
|
|
727
|
+
if (bun === void 0) return null;
|
|
728
|
+
try {
|
|
729
|
+
const proc = bun.spawn([
|
|
730
|
+
"git",
|
|
731
|
+
"config",
|
|
732
|
+
"--get",
|
|
733
|
+
"init.defaultBranch"
|
|
734
|
+
], {
|
|
735
|
+
env: sanitizedProcessEnv(),
|
|
736
|
+
stdout: "pipe",
|
|
737
|
+
stderr: "pipe"
|
|
738
|
+
});
|
|
739
|
+
const stdout = (await new Response(proc.stdout).text()).trim();
|
|
740
|
+
return await proc.exited === 0 && stdout !== "" ? stdout : null;
|
|
741
|
+
} catch {
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
/** Spawn `git symbolic-ref` with a fallback to `git config --get init.defaultBranch`. */
|
|
746
|
+
const defaultDetectDefaultBranch = async () => {
|
|
747
|
+
const primary = await runGitSymbolicRef();
|
|
748
|
+
if (primary !== null) return primary;
|
|
749
|
+
return await runGitInitDefaultBranch() ?? DEFAULT_BRANCH;
|
|
750
|
+
};
|
|
751
|
+
/** Format a drift list as a printable diff block. */
|
|
752
|
+
const formatDriftReport = (drift) => {
|
|
753
|
+
if (drift.length === 0) return "No drift detected.";
|
|
754
|
+
const lines = ["Drift detected:"];
|
|
755
|
+
for (const entry of drift) {
|
|
756
|
+
lines.push(` ${entry.field}:`);
|
|
757
|
+
lines.push(` - ${entry.current}`);
|
|
758
|
+
lines.push(` + ${entry.proposed}`);
|
|
759
|
+
}
|
|
760
|
+
return lines.join("\n");
|
|
761
|
+
};
|
|
762
|
+
/** Print the post-init setup checklist to stdout. */
|
|
763
|
+
const printChecklist = (stdout, slug, branch, createdProject) => {
|
|
764
|
+
stdout("");
|
|
765
|
+
stdout("Cloudflare Pages deploy — setup checklist");
|
|
766
|
+
stdout("");
|
|
767
|
+
stdout("1. Find your account ID:");
|
|
768
|
+
stdout(" https://dash.cloudflare.com → string after dash.cloudflare.com/ in the URL.");
|
|
769
|
+
stdout("");
|
|
770
|
+
if (createdProject) stdout(`2. Pages project '${slug}' already exists (created by --create-project).`);
|
|
771
|
+
else {
|
|
772
|
+
stdout("2. Create the Pages project (skip if already created):");
|
|
773
|
+
stdout(` wrangler pages project create ${slug}`);
|
|
774
|
+
}
|
|
775
|
+
stdout("");
|
|
776
|
+
stdout("3. Mint a Cloudflare API token:");
|
|
777
|
+
stdout(" https://dash.cloudflare.com/profile/api-tokens");
|
|
778
|
+
stdout(" → Create Token → Custom Token →");
|
|
779
|
+
stdout(" → Permissions: Account → Cloudflare Pages → Edit (NOT \"Read\" — Read causes silent 403)");
|
|
780
|
+
stdout(" Click \"Create Token\", copy the value (shown only once).");
|
|
781
|
+
stdout("");
|
|
782
|
+
stdout("4. Add GitHub Actions secrets:");
|
|
783
|
+
stdout(" Settings → Secrets and variables → Actions → New repository secret");
|
|
784
|
+
stdout(" CLOUDFLARE_API_TOKEN = (the token from step 3)");
|
|
785
|
+
stdout(" CLOUDFLARE_ACCOUNT_ID = (the ID from step 1)");
|
|
786
|
+
stdout("");
|
|
787
|
+
stdout("5. (Optional) Place _headers and _redirects in `public/` to control Cloudflare response headers and redirects.");
|
|
788
|
+
stdout("");
|
|
789
|
+
stdout(`6. Push to ${branch} — the generated workflow fires automatically.`);
|
|
790
|
+
stdout("");
|
|
791
|
+
stdout("Tip: run `moku deploy init --check` in release CI to assert config freshness.");
|
|
792
|
+
stdout("Note: Cloudflare dashboard git-push auto-build does NOT recognize wrangler.jsonc — use this workflow.");
|
|
793
|
+
};
|
|
794
|
+
const defaultPromptYesNo = async (_message, defaultYes) => Promise.resolve(defaultYes);
|
|
795
|
+
/** Warn-and-overwrite branch of the slug drift decision (interactive "n" or `--force`). */
|
|
796
|
+
const warnSlugChanged = (ctx, existingName, slug) => {
|
|
797
|
+
ctx.log.warn("deploy:init:slug-changed", {
|
|
798
|
+
existing: existingName,
|
|
799
|
+
computed: slug,
|
|
800
|
+
note: `Project rename: run \`wrangler pages project create ${slug}\`. The old project remains deployed under ${existingName}.pages.dev.`
|
|
801
|
+
});
|
|
802
|
+
};
|
|
803
|
+
const promptKeepExisting = async (ctx, existingName, slug) => {
|
|
804
|
+
return (ctx.promptYesNo ?? defaultPromptYesNo)(`${`wrangler.jsonc#name = "${existingName}" (existing) vs slug("${ctx.siteName}") = "${slug}" (computed).`}\nKeep existing? [Y/n]`, true);
|
|
805
|
+
};
|
|
806
|
+
/** Internal "should we write wrangler.jsonc?" decision around the diff-on-rename gate. */
|
|
807
|
+
const resolveSlugWriteDecision = async (ctx, options, existing, slug, interactive) => {
|
|
808
|
+
if (existing === null || existing.name === slug) return true;
|
|
809
|
+
if (options.force === true) {
|
|
810
|
+
warnSlugChanged(ctx, existing.name, slug);
|
|
811
|
+
return true;
|
|
812
|
+
}
|
|
813
|
+
if (!interactive) {
|
|
814
|
+
ctx.log.info("deploy:init:slug-keep-existing", {
|
|
815
|
+
existing: existing.name,
|
|
816
|
+
computed: slug
|
|
817
|
+
});
|
|
818
|
+
return false;
|
|
819
|
+
}
|
|
820
|
+
if (await promptKeepExisting(ctx, existing.name, slug)) return false;
|
|
821
|
+
warnSlugChanged(ctx, existing.name, slug);
|
|
822
|
+
return true;
|
|
823
|
+
};
|
|
824
|
+
/** Run `wrangler pages project create` for the `--create-project` flag. Treats "already exists" as success. */
|
|
825
|
+
const tryCreateCloudflareProject = async (ctx, slug) => {
|
|
826
|
+
const env = ctx.env ?? sanitizedProcessEnv();
|
|
827
|
+
try {
|
|
828
|
+
await require_wrangler.runWrangler([
|
|
829
|
+
"pages",
|
|
830
|
+
"project",
|
|
831
|
+
"create",
|
|
832
|
+
slug
|
|
833
|
+
], env, ctx.spawn);
|
|
834
|
+
return true;
|
|
835
|
+
} catch (error) {
|
|
836
|
+
const wranglerError = error instanceof Error ? error : new Error(String(error));
|
|
837
|
+
if (/already exists/i.test(wranglerError.message)) return true;
|
|
838
|
+
throw wranglerError;
|
|
839
|
+
}
|
|
840
|
+
};
|
|
841
|
+
/** Maybe write the GitHub Actions workflow file. */
|
|
842
|
+
const maybeWriteWorkflow = async (ctx, options, cwd, branch) => {
|
|
843
|
+
if (options.ci !== true) return;
|
|
844
|
+
if (githubWorkflowExists(cwd) && options.force !== true) {
|
|
845
|
+
ctx.log.info("deploy:init:workflow-skipped", { reason: "exists; pass --force to overwrite" });
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
await writeGitHubWorkflow(cwd, generateGitHubWorkflow({
|
|
849
|
+
target: ctx.config.target,
|
|
850
|
+
outdir: ctx.config.outdir,
|
|
851
|
+
branch
|
|
852
|
+
}));
|
|
853
|
+
};
|
|
854
|
+
/** Emit the "no build plugin registered" informational warning when appropriate. */
|
|
855
|
+
const maybeWarnMissingBuild = (ctx) => {
|
|
856
|
+
if (!ctx.buildPluginRegistered && ctx.config.outdir === "dist") ctx.log.warn("deploy:init:no-build-plugin", { note: "No 'build' plugin registered and no explicit outdir set — using default 'dist'. Configure pluginConfigs.deploy.outdir if your build output lives elsewhere." });
|
|
857
|
+
};
|
|
858
|
+
/** Handle the `--check` short-circuit. */
|
|
859
|
+
const handleCheckMode = (ctx, cwd, slug, outdir, branch) => {
|
|
860
|
+
const drift = require_wrangler.diffWranglerConfig(require_wrangler.readWranglerConfig(cwd), {
|
|
861
|
+
slug,
|
|
862
|
+
outdir
|
|
863
|
+
});
|
|
864
|
+
ctx.stdout(formatDriftReport(drift));
|
|
865
|
+
return {
|
|
866
|
+
slug,
|
|
867
|
+
outdir,
|
|
868
|
+
branch,
|
|
869
|
+
drift,
|
|
870
|
+
wroteFiles: false
|
|
871
|
+
};
|
|
872
|
+
};
|
|
873
|
+
const resolveInitInputs = async (ctx, options) => {
|
|
874
|
+
const detectBranch = ctx.detectDefaultBranch ?? defaultDetectDefaultBranch;
|
|
875
|
+
const branch = options.branch ?? await detectBranch();
|
|
876
|
+
const slug = ctx.config.projectName ?? slugify(ctx.siteName);
|
|
877
|
+
assertValidProjectName(slug);
|
|
878
|
+
const interactive = options.interactive ?? Boolean(process.stdout.isTTY);
|
|
879
|
+
return {
|
|
880
|
+
branch,
|
|
881
|
+
slug,
|
|
882
|
+
outdir: ctx.config.outdir,
|
|
883
|
+
interactive
|
|
884
|
+
};
|
|
885
|
+
};
|
|
886
|
+
/** Compose the writable artifacts step — wrangler.jsonc + workflow + warnings. */
|
|
887
|
+
const writeArtifacts = async (ctx, options, cwd, inputs, writeJsonc) => {
|
|
888
|
+
if (writeJsonc) await require_wrangler.writeWranglerConfig(cwd, require_wrangler.generateWranglerConfig({
|
|
889
|
+
slug: inputs.slug,
|
|
890
|
+
outdir: inputs.outdir
|
|
891
|
+
}));
|
|
892
|
+
await maybeWriteWorkflow(ctx, options, cwd, inputs.branch);
|
|
893
|
+
maybeWarnMissingBuild(ctx);
|
|
894
|
+
};
|
|
895
|
+
/**
|
|
896
|
+
* Run `moku deploy init`.
|
|
897
|
+
*
|
|
898
|
+
* @param ctx - {@link InitContext} carrying logger, stdout, and injectable spawn/git deps.
|
|
899
|
+
* @param options - {@link InitOptions} flag set.
|
|
900
|
+
* @returns A {@link InitResult} summarizing what happened.
|
|
901
|
+
*/
|
|
902
|
+
const runInit = async (ctx, options = {}) => {
|
|
903
|
+
const cwd = options.cwd ?? process.cwd();
|
|
904
|
+
const inputs = await resolveInitInputs(ctx, options);
|
|
905
|
+
if (options.check === true) return handleCheckMode(ctx, cwd, inputs.slug, inputs.outdir, inputs.branch);
|
|
906
|
+
const writeJsonc = await resolveSlugWriteDecision(ctx, options, require_wrangler.readWranglerConfig(cwd), inputs.slug, inputs.interactive);
|
|
907
|
+
const projectCreated = options.createProject === true ? await tryCreateCloudflareProject(ctx, inputs.slug) : void 0;
|
|
908
|
+
await writeArtifacts(ctx, options, cwd, inputs, writeJsonc);
|
|
909
|
+
const wroteFiles = writeJsonc || options.ci === true;
|
|
910
|
+
if (wroteFiles) printChecklist(ctx.stdout, inputs.slug, inputs.branch, projectCreated === true);
|
|
911
|
+
return {
|
|
912
|
+
slug: inputs.slug,
|
|
913
|
+
outdir: inputs.outdir,
|
|
914
|
+
branch: inputs.branch,
|
|
915
|
+
wroteFiles,
|
|
916
|
+
...projectCreated === void 0 ? {} : { projectCreated }
|
|
917
|
+
};
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
//#endregion
|
|
921
|
+
//#region src/bin/commands/deploy.ts
|
|
922
|
+
/** @file `moku deploy` command — load app, run app.deploy.run() or runInit(). */
|
|
923
|
+
/**
|
|
924
|
+
* Top-level dispatcher for `moku deploy` and `moku deploy init`.
|
|
925
|
+
*
|
|
926
|
+
* @param argv - Argv after the `deploy` keyword.
|
|
927
|
+
* @param deps - Injected IO + loader dependencies.
|
|
928
|
+
* @returns Exit code: 0 ok, 1 load error, 2 deploy error, 3 arg error.
|
|
929
|
+
*/
|
|
930
|
+
const deployCommand = async (argv, deps) => {
|
|
931
|
+
const [first, ...rest] = argv;
|
|
932
|
+
if (first === "init") return runInitCommand(rest, deps);
|
|
933
|
+
return runDeployCommand(argv, deps);
|
|
934
|
+
};
|
|
935
|
+
const runDeployCommand = async (argv, deps) => {
|
|
936
|
+
const prepared = await prepareApp({
|
|
937
|
+
argv,
|
|
938
|
+
parse: parseDeploy,
|
|
939
|
+
loadApp: deps.loadApp,
|
|
940
|
+
cwd: deps.cwd
|
|
941
|
+
});
|
|
942
|
+
if (prepared.kind === "help") {
|
|
943
|
+
deps.stdout(formatDeployHelp());
|
|
944
|
+
return { code: 0 };
|
|
945
|
+
}
|
|
946
|
+
if (prepared.kind === "bad-args") {
|
|
947
|
+
deps.stderr(prepared.message);
|
|
948
|
+
deps.stdout(formatDeployHelp());
|
|
949
|
+
return { code: 3 };
|
|
950
|
+
}
|
|
951
|
+
if (prepared.kind === "load-failed") {
|
|
952
|
+
deps.stderr(prepared.message);
|
|
953
|
+
return { code: 1 };
|
|
954
|
+
}
|
|
955
|
+
if (prepared.app.deploy === void 0) {
|
|
956
|
+
deps.stderr("deploy: this app does not register the deploy plugin.");
|
|
957
|
+
return { code: 1 };
|
|
958
|
+
}
|
|
959
|
+
try {
|
|
960
|
+
const result = await prepared.app.deploy.run({
|
|
961
|
+
...prepared.args.branch === void 0 ? {} : { branch: prepared.args.branch },
|
|
962
|
+
build: prepared.args.build
|
|
963
|
+
});
|
|
964
|
+
deps.stdout(`Deployed to ${result.url} (branch=${result.branch}, ${result.durationMs}ms)`);
|
|
965
|
+
return { code: 0 };
|
|
966
|
+
} catch (error) {
|
|
967
|
+
deps.stderr(`Deploy failed: ${error.message}`);
|
|
968
|
+
return { code: 2 };
|
|
969
|
+
}
|
|
970
|
+
};
|
|
971
|
+
const resolveDeployConfig = (app) => {
|
|
972
|
+
const fromAppConfig = app.config?.deploy;
|
|
973
|
+
if (fromAppConfig !== void 0) return fromAppConfig;
|
|
974
|
+
return {
|
|
975
|
+
target: "pages",
|
|
976
|
+
outdir: "dist",
|
|
977
|
+
productionBranch: "main"
|
|
978
|
+
};
|
|
979
|
+
};
|
|
980
|
+
const makeInitLogger = (deps) => ({
|
|
981
|
+
info: (event, data) => deps.stdout(`[info] ${event} ${data ? JSON.stringify(data) : ""}`),
|
|
982
|
+
warn: (event, data) => deps.stderr(`[warn] ${event} ${data ? JSON.stringify(data) : ""}`),
|
|
983
|
+
error: (event, data) => deps.stderr(`[error] ${event} ${data ? JSON.stringify(data) : ""}`)
|
|
984
|
+
});
|
|
985
|
+
const runInitForPreparedApp = async (deps, app, args) => {
|
|
986
|
+
const siteName = app.site?.name() ?? "";
|
|
987
|
+
if (siteName === "") {
|
|
988
|
+
deps.stderr("deploy init: site.name is empty — set it in your app config.");
|
|
989
|
+
return { code: 1 };
|
|
990
|
+
}
|
|
991
|
+
try {
|
|
992
|
+
const result = await runInit({
|
|
993
|
+
siteName,
|
|
994
|
+
config: resolveDeployConfig(app),
|
|
995
|
+
buildPluginRegistered: app.build !== void 0,
|
|
996
|
+
log: makeInitLogger(deps),
|
|
997
|
+
stdout: deps.stdout
|
|
998
|
+
}, {
|
|
999
|
+
cwd: deps.cwd,
|
|
1000
|
+
ci: args.ci,
|
|
1001
|
+
force: args.force,
|
|
1002
|
+
createProject: args.createProject,
|
|
1003
|
+
check: args.check,
|
|
1004
|
+
...args.branch === void 0 ? {} : { branch: args.branch }
|
|
1005
|
+
});
|
|
1006
|
+
if (args.check && result.drift !== void 0 && result.drift.length > 0) return { code: 2 };
|
|
1007
|
+
return { code: 0 };
|
|
1008
|
+
} catch (error) {
|
|
1009
|
+
deps.stderr(`Deploy init failed: ${error.message}`);
|
|
1010
|
+
return { code: 2 };
|
|
1011
|
+
}
|
|
1012
|
+
};
|
|
1013
|
+
const runInitCommand = async (argv, deps) => {
|
|
1014
|
+
const prepared = await prepareApp({
|
|
1015
|
+
argv,
|
|
1016
|
+
parse: parseDeployInit,
|
|
1017
|
+
loadApp: deps.loadApp,
|
|
1018
|
+
cwd: deps.cwd
|
|
1019
|
+
});
|
|
1020
|
+
if (prepared.kind === "help") {
|
|
1021
|
+
deps.stdout(formatDeployHelp());
|
|
1022
|
+
return { code: 0 };
|
|
1023
|
+
}
|
|
1024
|
+
if (prepared.kind === "bad-args") {
|
|
1025
|
+
deps.stderr(prepared.message);
|
|
1026
|
+
deps.stdout(formatDeployHelp());
|
|
1027
|
+
return { code: 3 };
|
|
1028
|
+
}
|
|
1029
|
+
if (prepared.kind === "load-failed") {
|
|
1030
|
+
deps.stderr(prepared.message);
|
|
1031
|
+
return { code: 1 };
|
|
1032
|
+
}
|
|
1033
|
+
return runInitForPreparedApp(deps, prepared.app, prepared.args);
|
|
1034
|
+
};
|
|
1035
|
+
|
|
468
1036
|
//#endregion
|
|
469
1037
|
//#region src/bin/commands/dev.ts
|
|
470
1038
|
/** @file `moku dev` command — watch + invalidate + rebuild + serve loop. */
|
|
@@ -800,6 +1368,12 @@ const main = async () => {
|
|
|
800
1368
|
stderr,
|
|
801
1369
|
loadApp,
|
|
802
1370
|
serve: serveStatic
|
|
1371
|
+
}),
|
|
1372
|
+
deployCommand: (argv) => deployCommand(argv, {
|
|
1373
|
+
cwd: process.cwd(),
|
|
1374
|
+
stdout,
|
|
1375
|
+
stderr,
|
|
1376
|
+
loadApp
|
|
803
1377
|
})
|
|
804
1378
|
});
|
|
805
1379
|
process.exit(result.code);
|