@moku-labs/web 1.0.1 → 1.1.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/index.mjs CHANGED
@@ -5761,6 +5761,19 @@ const CHECKOUT_SHA = "11bd71901bbe5b1630ceea73d27597364c9af683";
5761
5761
  const SETUP_BUN_SHA = "4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5";
5762
5762
  /** Pinned `cloudflare/wrangler-action` commit SHA. */
5763
5763
  const WRANGLER_ACTION_SHA = "f84a562284fc78278ff9052435d9526f9c718361";
5764
+ /** The `on:` block for each {@link WorkflowTrigger} (kept indentation-exact for YAML). */
5765
+ const TRIGGER_ON_BLOCKS = {
5766
+ auto: `on:
5767
+ push:
5768
+ branches: [main]
5769
+ workflow_dispatch:`,
5770
+ "versioned-tag": `on:
5771
+ push:
5772
+ tags: ["v*"]
5773
+ workflow_dispatch:`,
5774
+ dispatch: `on:
5775
+ workflow_dispatch:`
5776
+ };
5764
5777
  /**
5765
5778
  * Generate a SHA-pinned GitHub Actions workflow that builds and deploys to
5766
5779
  * Cloudflare Pages. Every action is pinned to a commit SHA (with a `# vX`
@@ -5770,9 +5783,10 @@ const WRANGLER_ACTION_SHA = "f84a562284fc78278ff9052435d9526f9c718361";
5770
5783
  *
5771
5784
  * @param input - The generator inputs.
5772
5785
  * @param input.slug - Cloudflare project-name slug used as the wrangler `--project-name`.
5786
+ * @param input.trigger - What fires the workflow (see {@link WorkflowTrigger}). Default `"auto"`.
5773
5787
  * @returns The workflow YAML.
5774
5788
  * @example
5775
- * generateGithubWorkflow({ slug: "my-site" });
5789
+ * generateGithubWorkflow({ slug: "my-site", trigger: "versioned-tag" });
5776
5790
  */
5777
5791
  function generateGithubWorkflow(input) {
5778
5792
  return `# .github/workflows/deploy.yml — generated by \`app.deploy.init({ ci: true })\`.
@@ -5780,10 +5794,7 @@ function generateGithubWorkflow(input) {
5780
5794
 
5781
5795
  name: Deploy
5782
5796
 
5783
- on:
5784
- push:
5785
- branches: [main]
5786
- workflow_dispatch:
5797
+ ${TRIGGER_ON_BLOCKS[input.trigger ?? "auto"]}
5787
5798
 
5788
5799
  permissions:
5789
5800
  contents: read
@@ -5915,7 +5926,10 @@ async function writeScaffolding(input) {
5915
5926
  });
5916
5927
  if (ci) await reconcile({
5917
5928
  relativePath: WORKFLOW_PATH,
5918
- expected: generateGithubWorkflow({ slug }),
5929
+ expected: generateGithubWorkflow({
5930
+ slug,
5931
+ ...options.workflowTrigger ? { trigger: options.workflowTrigger } : {}
5932
+ }),
5919
5933
  existing: await readMaybe(cwd, WORKFLOW_PATH),
5920
5934
  cwd,
5921
5935
  check,
@@ -6433,6 +6447,185 @@ const deployPlugin = createPlugin$1("deploy", {
6433
6447
  api: createApi$2
6434
6448
  });
6435
6449
  //#endregion
6450
+ //#region src/plugins/cli/deploy-wizard.ts
6451
+ /**
6452
+ * @file cli plugin — the guided deploy wizard (`cli.deploy({ guided: true })`). Walks a
6453
+ * human through a Cloudflare Pages deploy: checks prerequisites (wrangler config + the
6454
+ * Cloudflare credentials) with concrete fix guidance, offers to scaffold/build what is
6455
+ * missing, HARD-GATES the deploy on everything being green, runs a local build smoke
6456
+ * test, confirms, deploys, then offers to scaffold a GitHub Actions workflow (auto on
6457
+ * push to main, or a versioned/manual trigger). The non-guided `--cli` path stays in
6458
+ * `api.ts`. Every prompt + line of output flows through injectable `state` seams.
6459
+ */
6460
+ /** How to create a Cloudflare API token + where to make it available locally. */
6461
+ const TOKEN_HELP = [
6462
+ "Create one at https://dash.cloudflare.com/profile/api-tokens → Create Token →",
6463
+ "use the \"Cloudflare Pages — Edit\" template (or a custom token with the",
6464
+ "Account › Cloudflare Pages › Edit permission). Then make it available:",
6465
+ " export CLOUDFLARE_API_TOKEN=… (shell) or add it to .env (gitignored)."
6466
+ ].join("\n");
6467
+ /** Where to find the Cloudflare account id + where to make it available locally. */
6468
+ const ACCOUNT_HELP = [
6469
+ "Find it on the Cloudflare dashboard → Workers & Pages: the Account ID is in the",
6470
+ "right-hand sidebar (also in the dashboard URL). Then make it available:",
6471
+ " export CLOUDFLARE_ACCOUNT_ID=… or add it to .env."
6472
+ ].join("\n");
6473
+ /** The GitHub repo secrets the generated workflow consumes. */
6474
+ const SECRETS_HELP = ["Add these repo secrets (GitHub → Settings → Secrets and variables → Actions):", "CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID"].join("\n");
6475
+ /**
6476
+ * Evaluate the three deploy prerequisites against the current project: the Cloudflare
6477
+ * wrangler config exists, and both Cloudflare credentials are present in the environment.
6478
+ *
6479
+ * @param cwd - The project root (where `wrangler.jsonc` lives).
6480
+ * @returns The ordered prerequisite checks.
6481
+ * @example
6482
+ * const prereqs = diagnose(process.cwd());
6483
+ */
6484
+ function diagnose(cwd) {
6485
+ const wranglerOk = existsSync(path.join(cwd, "wrangler.jsonc"));
6486
+ const tokenOk = (process.env.CLOUDFLARE_API_TOKEN ?? "") !== "";
6487
+ const accountOk = (process.env.CLOUDFLARE_ACCOUNT_ID ?? "") !== "";
6488
+ return [
6489
+ {
6490
+ ok: wranglerOk,
6491
+ label: "wrangler.jsonc (Cloudflare project config)",
6492
+ detail: wranglerOk ? void 0 : "Missing — scaffold it (offered below) or run app.deploy.init().",
6493
+ scaffoldable: true
6494
+ },
6495
+ {
6496
+ ok: tokenOk,
6497
+ label: "CLOUDFLARE_API_TOKEN is set",
6498
+ detail: tokenOk ? void 0 : TOKEN_HELP,
6499
+ scaffoldable: false
6500
+ },
6501
+ {
6502
+ ok: accountOk,
6503
+ label: "CLOUDFLARE_ACCOUNT_ID is set",
6504
+ detail: accountOk ? void 0 : ACCOUNT_HELP,
6505
+ scaffoldable: false
6506
+ }
6507
+ ];
6508
+ }
6509
+ /**
6510
+ * Offer to scaffold a missing `wrangler.jsonc` (the only auto-fixable prerequisite),
6511
+ * generating it via the deploy plugin when the user accepts.
6512
+ *
6513
+ * @param ctx - The cli plugin context.
6514
+ * @param prereqs - The current prerequisite checks.
6515
+ * @returns Resolves once any accepted fix has run.
6516
+ * @example
6517
+ * await offerScaffold(ctx, diagnose(cwd));
6518
+ */
6519
+ async function offerScaffold(ctx, prereqs) {
6520
+ if (!prereqs.some((item) => item.scaffoldable && !item.ok)) return;
6521
+ if (!await ctx.state.confirm("Scaffold wrangler.jsonc now?")) return;
6522
+ await ctx.require(deployPlugin).init({});
6523
+ ctx.state.render.check(true, "wrangler.jsonc scaffolded");
6524
+ }
6525
+ /**
6526
+ * Map a top-level workflow choice (and, for the versioned option, a sub-choice) to the
6527
+ * concrete {@link WorkflowTrigger}, or `null` when the user chose to skip setup.
6528
+ *
6529
+ * @param ctx - The cli plugin context (for the follow-up sub-choice prompt).
6530
+ * @param choice - The selected zero-based index of the top-level options.
6531
+ * @returns The resolved trigger, or `null` to skip.
6532
+ * @example
6533
+ * const trigger = await resolveTrigger(ctx, 1);
6534
+ */
6535
+ async function resolveTrigger(ctx, choice) {
6536
+ if (choice === 2) return null;
6537
+ if (choice === 0) return "auto";
6538
+ return await ctx.state.select("How should the versioned deploy be triggered?", ["On a version tag push (v*) + the manual Run-workflow button", "Manual Run-workflow button only (workflow_dispatch)"]) === 0 ? "versioned-tag" : "dispatch";
6539
+ }
6540
+ /**
6541
+ * Offer to scaffold a GitHub Actions deploy workflow, letting the user choose how it is
6542
+ * triggered, then remind them which repo secrets to add. A no-op past a "skip" choice.
6543
+ *
6544
+ * @param ctx - The cli plugin context.
6545
+ * @returns Resolves once any chosen workflow has been scaffolded.
6546
+ * @example
6547
+ * await offerWorkflowSetup(ctx);
6548
+ */
6549
+ async function offerWorkflowSetup(ctx) {
6550
+ ctx.state.render.heading("Automate future deploys (GitHub Actions)");
6551
+ const trigger = await resolveTrigger(ctx, await ctx.state.select("Set up a deploy workflow?", [
6552
+ "Auto-deploy on every push to main",
6553
+ "Manual / versioned deploy (choose trigger)",
6554
+ "Skip for now"
6555
+ ]));
6556
+ if (trigger === null) return;
6557
+ const result = await ctx.require(deployPlugin).init({
6558
+ ci: true,
6559
+ workflowTrigger: trigger
6560
+ });
6561
+ const workflowPath = ".github/workflows/deploy.yml";
6562
+ const wrote = result.written.includes(workflowPath);
6563
+ ctx.state.render.check(true, wrote ? `wrote ${workflowPath}` : `${workflowPath} already exists (left unchanged)`);
6564
+ ctx.state.render.info(SECRETS_HELP);
6565
+ }
6566
+ /**
6567
+ * Run the deploy step: confirm (unless `yes`), then deploy via the deploy plugin and
6568
+ * report the outcome. A declined confirm returns `{ deployed: false, reason: "declined" }`.
6569
+ *
6570
+ * @param ctx - The cli plugin context.
6571
+ * @param options - The deploy options (branch override + `yes`).
6572
+ * @returns The deploy outcome.
6573
+ * @example
6574
+ * const outcome = await runDeployStep(ctx, { yes: true });
6575
+ */
6576
+ async function runDeployStep(ctx, options) {
6577
+ ctx.state.render.heading("Deploy");
6578
+ if (!(options.yes === true || await ctx.state.confirm(`Deploy ${ctx.config.outDir}/ to Cloudflare Pages now?`))) {
6579
+ ctx.state.render.warn("deploy skipped");
6580
+ return {
6581
+ deployed: false,
6582
+ reason: "declined"
6583
+ };
6584
+ }
6585
+ return {
6586
+ deployed: true,
6587
+ ...await ctx.require(deployPlugin).run(options.branch === void 0 ? {} : { branch: options.branch })
6588
+ };
6589
+ }
6590
+ /**
6591
+ * Run the guided deploy wizard end to end: diagnose prerequisites (offering to scaffold
6592
+ * the wrangler config), HARD-GATE on the remaining blockers, run a local build smoke
6593
+ * test, deploy (with confirmation), then offer to scaffold a CI workflow. Returns
6594
+ * `{ deployed: false, reason: "blocked" }` when prerequisites are unmet, so a thin script
6595
+ * can exit non-zero. Assumes the caller already rendered the `deploy` header.
6596
+ *
6597
+ * @param ctx - The cli plugin context (state seams + `require` + config).
6598
+ * @param options - The deploy options (branch override, `yes`, `guided`).
6599
+ * @returns The deploy outcome (`deployed`, or a `declined`/`blocked` skip).
6600
+ * @example
6601
+ * const outcome = await runDeployWizard(ctx, { guided: true });
6602
+ */
6603
+ async function runDeployWizard(ctx, options) {
6604
+ const cwd = process.cwd();
6605
+ ctx.state.render.heading("Checking prerequisites");
6606
+ for (const item of diagnose(cwd)) ctx.state.render.check(item.ok, item.label, item.detail);
6607
+ await offerScaffold(ctx, diagnose(cwd));
6608
+ const blockers = diagnose(cwd).filter((item) => !item.ok);
6609
+ if (blockers.length > 0) {
6610
+ ctx.state.render.heading("Not ready to deploy");
6611
+ for (const item of blockers) ctx.state.render.check(false, item.label, item.detail);
6612
+ ctx.state.render.warn(`Fix the ${blockers.length} item(s) above, then re-run \`bun run deploy\`.`);
6613
+ return {
6614
+ deployed: false,
6615
+ reason: "blocked"
6616
+ };
6617
+ }
6618
+ ctx.state.render.heading("Local test");
6619
+ const summary = await ctx.require(buildPlugin).run();
6620
+ ctx.state.render.check(true, `built ${summary.pageCount} pages → ${summary.outDir}/`);
6621
+ const notFoundOk = existsSync(path.join(ctx.config.outDir, ctx.config.notFoundFile));
6622
+ ctx.state.render.check(notFoundOk, `${ctx.config.notFoundFile} present`, notFoundOk ? void 0 : "Set build.notFound so the SSG emits it (CF Pages else flips to SPA mode).");
6623
+ ctx.state.render.info("Tip: run `bun run preview` to eyeball the built site before deploying.");
6624
+ const outcome = await runDeployStep(ctx, options);
6625
+ await offerWorkflowSetup(ctx);
6626
+ return outcome;
6627
+ }
6628
+ //#endregion
6436
6629
  //#region src/plugins/cli/errors.ts
6437
6630
  /** Error prefix for cli config/validation/runtime failures (spec/11 Part-3). */
6438
6631
  const ERROR_PREFIX$3 = "[web] cli";
@@ -6466,11 +6659,12 @@ function injectReloadClient(html) {
6466
6659
  return index === -1 ? html + RELOAD_CLIENT : html.slice(0, index) + RELOAD_CLIENT + html.slice(index);
6467
6660
  }
6468
6661
  /**
6469
- * Run one rebuild and report the result. Skips re-entrancy via the shared `building`
6470
- * flag and routes success to `onReloaded`, failure to `onError`.
6662
+ * Run one rebuild and report the result. Announces the start (`onRebuildStart`), then
6663
+ * routes success to `onReloaded` and failure to `onError`.
6471
6664
  *
6472
6665
  * @param input - The rebuild dependencies + the changed file.
6473
6666
  * @param input.runBuild - Runs one build and resolves with its summary.
6667
+ * @param input.onRebuildStart - Called with the changed file just before the build runs.
6474
6668
  * @param input.onReloaded - Called with the changed file + summary after a rebuild.
6475
6669
  * @param input.onError - Called when a rebuild throws.
6476
6670
  * @param input.file - The changed file to report alongside the summary.
@@ -6479,6 +6673,7 @@ function injectReloadClient(html) {
6479
6673
  * await runOneRebuild({ runBuild, onReloaded, onError, file: "a.md" });
6480
6674
  */
6481
6675
  async function runOneRebuild(input) {
6676
+ input.onRebuildStart?.(input.file);
6482
6677
  try {
6483
6678
  const summary = await input.runBuild();
6484
6679
  input.onReloaded({
@@ -6500,6 +6695,7 @@ async function runOneRebuild(input) {
6500
6695
  * @param input - The rebuild dependencies.
6501
6696
  * @param input.debounceMs - Debounce window in milliseconds.
6502
6697
  * @param input.runBuild - Runs one build and resolves with its summary.
6698
+ * @param input.onRebuildStart - Called with the changed file just before each build runs.
6503
6699
  * @param input.onReloaded - Called with the changed file + summary after a rebuild.
6504
6700
  * @param input.onError - Called when a rebuild throws.
6505
6701
  * @returns The debounced rebuild driver.
@@ -6526,6 +6722,7 @@ function createRebuilder(input) {
6526
6722
  dirty = false;
6527
6723
  await runOneRebuild({
6528
6724
  runBuild: input.runBuild,
6725
+ ...input.onRebuildStart ? { onRebuildStart: input.onRebuildStart } : {},
6529
6726
  onReloaded: input.onReloaded,
6530
6727
  onError: input.onError,
6531
6728
  file: pendingFile
@@ -6579,6 +6776,70 @@ function createRebuilder(input) {
6579
6776
  };
6580
6777
  }
6581
6778
  /**
6779
+ * Whether a changed path (relative to a watched dir) is editor/OS noise that is never a
6780
+ * page source: any hidden segment (`.DS_Store`, anything under `.git/` or `.cache/`,
6781
+ * vim `.*.swp`) or a `~` backup file. Checks every segment, not just the basename.
6782
+ *
6783
+ * @param filename - The changed path relative to its watched directory.
6784
+ * @returns `true` when the change should be ignored as noise.
6785
+ * @example
6786
+ * isNoisePath(".git/HEAD"); // true
6787
+ */
6788
+ function isNoisePath(filename) {
6789
+ return filename.split(/[/\\]/).some((segment) => segment.startsWith(".")) || filename.endsWith("~");
6790
+ }
6791
+ /**
6792
+ * Create a {@link ChangeGate} that drops three kinds of spurious change events before
6793
+ * they reach the debounced rebuilder: editor/OS noise (dotfiles, backups), writes under
6794
+ * `outDir` (the build's own output — a loop guard), and the stale duplicate/parent-dir
6795
+ * echoes macOS fires for one save. Staleness is judged by a build-start high-water mark:
6796
+ * a change whose file mtime is at or before the last build we started was already
6797
+ * captured (or is a late echo), so it is ignored — while a genuinely newer edit (even
6798
+ * one made mid-build) and a deletion (missing file) always pass. The single timestamp
6799
+ * also means no per-path map grows over a long session.
6800
+ *
6801
+ * @param input - The gate dependencies.
6802
+ * @param input.outDir - The build output directory whose writes must never re-trigger a build.
6803
+ * @param input.fileMtime - Resolves a path's mtime in ms (or `null` when missing).
6804
+ * @param input.now - Monotonic wall clock (ms) used for the build-start high-water mark.
6805
+ * @returns The change gate.
6806
+ * @example
6807
+ * const gate = createChangeGate({ outDir: "dist", fileMtime: state.fileMtime, now: state.clock });
6808
+ */
6809
+ function createChangeGate(input) {
6810
+ const outDirAbs = path.resolve(input.outDir);
6811
+ let lastBuildStartedAt = input.now();
6812
+ return {
6813
+ /**
6814
+ * Decide whether a change beneath `dir` warrants a rebuild (see {@link ChangeGate.accept}).
6815
+ *
6816
+ * @param dir - The watched directory the event fired on.
6817
+ * @param filename - The changed path relative to `dir` (or `undefined`).
6818
+ * @returns `true` to schedule a rebuild, `false` to ignore.
6819
+ * @example
6820
+ * gate.accept("content", "post/en.md");
6821
+ */
6822
+ accept(dir, filename) {
6823
+ if (filename === void 0) return true;
6824
+ if (isNoisePath(filename)) return false;
6825
+ const changed = path.resolve(dir, filename);
6826
+ if (changed === outDirAbs || changed.startsWith(`${outDirAbs}${path.sep}`)) return false;
6827
+ const mtime = input.fileMtime(changed);
6828
+ if (mtime !== null && mtime < lastBuildStartedAt) return false;
6829
+ return true;
6830
+ },
6831
+ /**
6832
+ * Advance the high-water mark to now (see {@link ChangeGate.markBuildStart}).
6833
+ *
6834
+ * @example
6835
+ * gate.markBuildStart();
6836
+ */
6837
+ markBuildStart() {
6838
+ lastBuildStartedAt = input.now();
6839
+ }
6840
+ };
6841
+ }
6842
+ /**
6582
6843
  * Install SIGINT/SIGTERM handlers that run `teardown()` and resolve the returned
6583
6844
  * promise, so a long-running command (`serve`/`preview`) unblocks its `await` on
6584
6845
  * Ctrl-C / termination and detaches its own listeners. Used by both servers.
@@ -6610,18 +6871,45 @@ function installSignalTeardown(teardown) {
6610
6871
  const SSE_OPEN = ": connected\n\n";
6611
6872
  /** The SSE frame pushed to reload a connected browser. */
6612
6873
  const SSE_RELOAD = "event: reload\ndata: 1\n\n";
6874
+ /** The SSE comment frame sent on the heartbeat to keep an idle stream warm. */
6875
+ const SSE_PING = ": ping\n\n";
6876
+ /** Default heartbeat interval (ms): one ping well under any 30s+ proxy idle window. */
6877
+ const DEFAULT_HEARTBEAT_MS = 15e3;
6613
6878
  /**
6614
6879
  * Create a {@link ReloadHub} backed by `ReadableStream` controllers. Each `connect()`
6615
6880
  * enqueues into a new stream; `reloadAll()` writes the reload frame to every live
6616
- * controller (dropping any that have closed).
6881
+ * controller (dropping any that have closed). A periodic heartbeat comment keeps idle
6882
+ * streams warm — belt-and-suspenders alongside the dev server's `idleTimeout: 0`, so a
6883
+ * quiet connection is never severed (which the browser surfaces as
6884
+ * `ERR_INCOMPLETE_CHUNKED_ENCODING` and then reconnects in a storm).
6617
6885
  *
6886
+ * @param options - Optional heartbeat tuning.
6887
+ * @param options.heartbeatMs - Heartbeat interval in ms (`0` disables). Default `15000`.
6618
6888
  * @returns The reload hub.
6619
6889
  * @example
6620
6890
  * const hub = createReloadHub();
6621
6891
  */
6622
- function createReloadHub() {
6892
+ function createReloadHub(options = {}) {
6623
6893
  const encoder = new TextEncoder();
6624
6894
  const clients = /* @__PURE__ */ new Set();
6895
+ /**
6896
+ * Enqueue one frame to every live controller, dropping any that have closed.
6897
+ *
6898
+ * @param frame - The SSE wire text to broadcast.
6899
+ * @example
6900
+ * broadcast(SSE_RELOAD);
6901
+ */
6902
+ const broadcast = (frame) => {
6903
+ const bytes = encoder.encode(frame);
6904
+ for (const controller of clients) try {
6905
+ controller.enqueue(bytes);
6906
+ } catch {
6907
+ clients.delete(controller);
6908
+ }
6909
+ };
6910
+ const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
6911
+ const heartbeat = heartbeatMs > 0 ? setInterval(() => broadcast(SSE_PING), heartbeatMs) : void 0;
6912
+ heartbeat?.unref?.();
6625
6913
  return {
6626
6914
  /**
6627
6915
  * Open one SSE connection, register its controller, and return the streaming
@@ -6669,11 +6957,7 @@ function createReloadHub() {
6669
6957
  * hub.reloadAll();
6670
6958
  */
6671
6959
  reloadAll() {
6672
- for (const controller of clients) try {
6673
- controller.enqueue(encoder.encode(SSE_RELOAD));
6674
- } catch {
6675
- clients.delete(controller);
6676
- }
6960
+ broadcast(SSE_RELOAD);
6677
6961
  },
6678
6962
  /**
6679
6963
  * The number of currently-connected clients.
@@ -6684,6 +6968,19 @@ function createReloadHub() {
6684
6968
  */
6685
6969
  size() {
6686
6970
  return clients.size;
6971
+ },
6972
+ /**
6973
+ * Stop the heartbeat and close every live SSE stream (SIGINT/SIGTERM teardown).
6974
+ *
6975
+ * @example
6976
+ * hub.close();
6977
+ */
6978
+ close() {
6979
+ if (heartbeat !== void 0) clearInterval(heartbeat);
6980
+ for (const controller of clients) try {
6981
+ controller.close();
6982
+ } catch {}
6983
+ clients.clear();
6687
6984
  }
6688
6985
  };
6689
6986
  }
@@ -6747,8 +7044,14 @@ async function runDevServer(ctx, port) {
6747
7044
  const hub = createReloadHub();
6748
7045
  const server = ctx.state.serveStatic({
6749
7046
  port,
7047
+ idleTimeout: 0,
6750
7048
  fetch: createDevHandler(ctx, hub)
6751
7049
  });
7050
+ const gate = createChangeGate({
7051
+ outDir: ctx.config.outDir,
7052
+ fileMtime: ctx.state.fileMtime,
7053
+ now: ctx.state.clock
7054
+ });
6752
7055
  const rebuilder = createRebuilder({
6753
7056
  debounceMs: ctx.config.debounceMs,
6754
7057
  /**
@@ -6762,6 +7065,17 @@ async function runDevServer(ctx, port) {
6762
7065
  return ctx.require(buildPlugin).run();
6763
7066
  },
6764
7067
  /**
7068
+ * Show the compact in-place "rebuilding {label}" line before the build runs.
7069
+ *
7070
+ * @param file - The changed watch target shown in the line.
7071
+ * @example
7072
+ * onRebuildStart("content");
7073
+ */
7074
+ onRebuildStart(file) {
7075
+ gate.markBuildStart();
7076
+ ctx.state.render.rebuildStart(file);
7077
+ },
7078
+ /**
6765
7079
  * Render the reload line and push a browser reload after a rebuild.
6766
7080
  *
6767
7081
  * @param info - The changed file plus the rebuild's page count and duration.
@@ -6783,7 +7097,9 @@ async function runDevServer(ctx, port) {
6783
7097
  ctx.state.render.error("rebuild failed", error);
6784
7098
  }
6785
7099
  });
6786
- const watchers = ctx.config.watchDirs.map((dir) => ctx.state.watch(dir, () => rebuilder.schedule(dir)));
7100
+ const watchers = ctx.config.watchDirs.map((dir) => ctx.state.watch(dir, (filename) => {
7101
+ if (gate.accept(dir, filename)) rebuilder.schedule(dir);
7102
+ }));
6787
7103
  ctx.state.render.serverReady({
6788
7104
  local: `http://localhost:${port}`,
6789
7105
  network: ctx.state.networkUrl(port),
@@ -6792,6 +7108,7 @@ async function runDevServer(ctx, port) {
6792
7108
  return installSignalTeardown(() => {
6793
7109
  rebuilder.cancel();
6794
7110
  for (const watcher of watchers) watcher.close();
7111
+ hub.close();
6795
7112
  server.stop();
6796
7113
  });
6797
7114
  }
@@ -7051,20 +7368,22 @@ function createApi$1(ctx) {
7051
7368
  return runPreviewServer(ctx, port);
7052
7369
  },
7053
7370
  /**
7054
- * Scaffold, then deploy. A y/N confirm is shown only when a human is present (an
7055
- * interactive TTY, with `CI` unset). Non-interactive runs (CI, or any non-TTY)
7056
- * skip the prompt and deploy, so the consumer scripts never hang a pipeline.
7057
- * `options.yes` forces the skip anywhere. An interactive "no" returns
7058
- * `{ deployed: false, reason: "declined" }`.
7371
+ * Deploy to Cloudflare Pages. Two modes: `{ guided: true }` runs the interactive
7372
+ * setup wizard (diagnose prerequisites, guide fixes, gate, local test, deploy, offer
7373
+ * a CI workflow); the default direct path scaffolds, gates on a y/N confirm (shown
7374
+ * only to an interactive TTY with `CI` unset non-interactive runs proceed so a
7375
+ * pipeline never hangs), then deploys. `options.yes` forces the skip. A "no" returns
7376
+ * `{ deployed: false, reason: "declined" }`; the wizard may return `"blocked"`.
7059
7377
  *
7060
- * @param options - Optional branch override and `yes` flag.
7061
- * @returns The deploy outcome (completed details, or `declined` if a TTY user says no).
7378
+ * @param options - Optional branch override, `yes` flag, and `guided` toggle.
7379
+ * @returns The deploy outcome (completed details, or a `declined`/`blocked` skip).
7062
7380
  * @example
7063
- * await api.deploy({ branch: "preview/x", yes: true });
7381
+ * await api.deploy({ guided: true });
7064
7382
  */
7065
7383
  async deploy(options = {}) {
7066
7384
  const { branch, yes = false } = options;
7067
7385
  ctx.state.render.header("deploy");
7386
+ if (options.guided === true) return runDeployWizard(ctx, options);
7068
7387
  await ctx.require(deployPlugin).init({ ci: true });
7069
7388
  if (!await confirmDeploy(ctx, yes)) return {
7070
7389
  deployed: false,
@@ -7164,6 +7483,39 @@ const ANSI = {
7164
7483
  cyan: `${ESC}[36m`,
7165
7484
  gray: `${ESC}[90m`
7166
7485
  };
7486
+ /** ANSI: erase the entire current line, leaving the cursor where it is. */
7487
+ const CLEAR_LINE = `${ESC}[2K`;
7488
+ /** ANSI: erase from the cursor to the end of the screen (drops stale trailing rows). */
7489
+ const CLEAR_BELOW = `${ESC}[0J`;
7490
+ /**
7491
+ * Braille spinner frames for live "working…" indicators on a TTY (advance one per tick).
7492
+ * Off a TTY the Panel never animates, so this is unused in plain/CI output.
7493
+ */
7494
+ const SPINNER_FRAMES = [
7495
+ "⠋",
7496
+ "⠙",
7497
+ "⠹",
7498
+ "⠸",
7499
+ "⠼",
7500
+ "⠴",
7501
+ "⠦",
7502
+ "⠧",
7503
+ "⠇",
7504
+ "⠏"
7505
+ ];
7506
+ /**
7507
+ * The ANSI sequence to move the cursor up `n` lines (empty string for `n <= 0`). The
7508
+ * Panel uses it to repaint a live block in place — move up over the previous draw, then
7509
+ * rewrite each row — so progress updates a fixed region instead of scrolling new lines.
7510
+ *
7511
+ * @param n - Number of lines to move the cursor up.
7512
+ * @returns The cursor-up escape sequence, or `""` when `n <= 0`.
7513
+ * @example
7514
+ * cursorUp(3); // "\x1b[3A"
7515
+ */
7516
+ function cursorUp(n) {
7517
+ return n > 0 ? `${ESC}[${n}A` : "";
7518
+ }
7167
7519
  /** Unicode rounded box glyphs used when output is a color-capable TTY. */
7168
7520
  const UNICODE_BOX = {
7169
7521
  topLeft: "╭",
@@ -7380,8 +7732,97 @@ function durationSuffix(palette, durationMs) {
7380
7732
  function createPanelRenderer(options = {}) {
7381
7733
  const write = options.write ?? ((line) => console.log(line));
7382
7734
  const writeError = options.writeError ?? ((line) => console.error(line));
7735
+ const writeRaw = options.writeRaw ?? ((chunk) => {
7736
+ process.stdout.write(chunk);
7737
+ });
7738
+ const now = options.now ?? Date.now;
7383
7739
  const color = options.color ?? supportsColor();
7384
7740
  const palette = makePalette(color);
7741
+ let phaseRows = [];
7742
+ let phaseDrawn = 0;
7743
+ let phaseOpen = false;
7744
+ let rebuilding = false;
7745
+ let rebuildLabel = "";
7746
+ let rebuildStartedAt = 0;
7747
+ let spinnerFrame = 0;
7748
+ let ticker;
7749
+ /**
7750
+ * The current spinner glyph (with a static fallback under `noUncheckedIndexedAccess`).
7751
+ *
7752
+ * @returns The active braille spinner frame.
7753
+ * @example
7754
+ * frameGlyph(); // "⠙"
7755
+ */
7756
+ const frameGlyph = () => SPINNER_FRAMES[spinnerFrame % SPINNER_FRAMES.length] ?? "⠋";
7757
+ /**
7758
+ * Render one phase row: a green `✓ name · time` when done, else a spinning cyan glyph
7759
+ * before the dim name.
7760
+ *
7761
+ * @param row - The phase row to render.
7762
+ * @returns The rendered row line (no trailing newline).
7763
+ * @example
7764
+ * renderPhaseRow({ name: "pages", done: true, durationMs: 12 });
7765
+ */
7766
+ const renderPhaseRow = (row) => {
7767
+ if (row.done) return ` ${palette.green("✓")} ${row.name}${durationSuffix(palette, row.durationMs)}`;
7768
+ return ` ${palette.cyan(frameGlyph())} ${palette.dim(row.name)}`;
7769
+ };
7770
+ /**
7771
+ * Repaint the live phase block in place: move up over the prior draw, then rewrite each
7772
+ * row (clearing any stale trailing lines).
7773
+ *
7774
+ * @example
7775
+ * paintPhaseBlock();
7776
+ */
7777
+ const paintPhaseBlock = () => {
7778
+ let frame = cursorUp(phaseDrawn);
7779
+ for (const row of phaseRows) frame += `${CLEAR_LINE}${renderPhaseRow(row)}\n`;
7780
+ writeRaw(frame + CLEAR_BELOW);
7781
+ phaseDrawn = phaseRows.length;
7782
+ };
7783
+ /**
7784
+ * Repaint the single in-place rebuild line (spinner + label + live elapsed seconds).
7785
+ *
7786
+ * @example
7787
+ * paintRebuildLine();
7788
+ */
7789
+ const paintRebuildLine = () => {
7790
+ const elapsed = ((now() - rebuildStartedAt) / 1e3).toFixed(1);
7791
+ const meta = palette.dim(`· ${elapsed}s`);
7792
+ writeRaw(`\r${CLEAR_LINE} ${palette.cyan(frameGlyph())} rebuilding ${rebuildLabel} ${meta}`);
7793
+ };
7794
+ /**
7795
+ * Advance the spinner one frame and repaint whichever live region is active.
7796
+ *
7797
+ * @example
7798
+ * onTick();
7799
+ */
7800
+ const onTick = () => {
7801
+ spinnerFrame += 1;
7802
+ if (rebuilding) paintRebuildLine();
7803
+ else if (phaseOpen) paintPhaseBlock();
7804
+ };
7805
+ /**
7806
+ * Start the animation ticker (TTY only; idempotent; `unref`'d so it never blocks exit).
7807
+ *
7808
+ * @example
7809
+ * startTicker();
7810
+ */
7811
+ const startTicker = () => {
7812
+ if (!color || ticker) return;
7813
+ ticker = setInterval(onTick, 80);
7814
+ ticker.unref?.();
7815
+ };
7816
+ /**
7817
+ * Stop the animation ticker if running.
7818
+ *
7819
+ * @example
7820
+ * stopTicker();
7821
+ */
7822
+ const stopTicker = () => {
7823
+ if (ticker) clearInterval(ticker);
7824
+ ticker = void 0;
7825
+ };
7385
7826
  /**
7386
7827
  * Write each line of a multi-line block through the stdout sink.
7387
7828
  *
@@ -7404,15 +7845,38 @@ function createPanelRenderer(options = {}) {
7404
7845
  writeBlock(box([`${palette.bold(palette.cyan("MOKU WEB"))} ${palette.dim(COMMAND_LABEL[command])}`], color));
7405
7846
  },
7406
7847
  /**
7407
- * Render a live per-phase row from a `build:phase` event.
7848
+ * Render a per-phase row from a `build:phase` event. On a TTY each phase is ONE row
7849
+ * that updates in place (spinning glyph while running → green ✓ + duration when done);
7850
+ * off a TTY one line is printed per completed phase (no start/done duplication). A
7851
+ * no-op while a serve() rebuild is in flight — those show the compact rebuild line.
7408
7852
  *
7409
7853
  * @param phase - The `build:phase` payload.
7410
7854
  * @example
7411
7855
  * render.phase({ phase: "pages", status: "done", durationMs: 12 });
7412
7856
  */
7413
7857
  phase(phase) {
7858
+ if (rebuilding) return;
7859
+ if (!color) {
7860
+ if (phase.status === "done") write(` ${palette.green("✓")} ${phase.phase}${durationSuffix(palette, phase.durationMs)}`);
7861
+ return;
7862
+ }
7863
+ if (!phaseOpen) {
7864
+ phaseRows = [];
7865
+ phaseDrawn = 0;
7866
+ phaseOpen = true;
7867
+ }
7414
7868
  const done = phase.status === "done";
7415
- write(` ${done ? palette.green("✓") : palette.dim("•")} ${done ? phase.phase : palette.dim(phase.phase)}${durationSuffix(palette, phase.durationMs)}`);
7869
+ const existing = phaseRows.find((row) => row.name === phase.phase);
7870
+ if (existing) {
7871
+ existing.done = done;
7872
+ existing.durationMs = phase.durationMs;
7873
+ } else phaseRows.push({
7874
+ name: phase.phase,
7875
+ done,
7876
+ durationMs: phase.durationMs
7877
+ });
7878
+ paintPhaseBlock();
7879
+ startTicker();
7416
7880
  },
7417
7881
  /**
7418
7882
  * Render the BUILD summary block from a `build:complete` event.
@@ -7422,6 +7886,10 @@ function createPanelRenderer(options = {}) {
7422
7886
  * render.built({ outDir: "dist", pageCount: 12, durationMs: 840 });
7423
7887
  */
7424
7888
  built(summary) {
7889
+ if (rebuilding) return;
7890
+ phaseOpen = false;
7891
+ phaseDrawn = 0;
7892
+ stopTicker();
7425
7893
  const pages = palette.bold(String(summary.pageCount));
7426
7894
  writeBlock(box([
7427
7895
  `${palette.green("✓")} ${palette.bold("BUILD")} complete`,
@@ -7443,15 +7911,47 @@ function createPanelRenderer(options = {}) {
7443
7911
  writeBlock(box(lines, color));
7444
7912
  },
7445
7913
  /**
7446
- * Render the post-rebuild line ("~ file" + " rebuilt N pages · Xms · reloaded").
7914
+ * Begin a serve() rebuild: show ONE compact "rebuilding {label}" line (an animated
7915
+ * spinner with live elapsed on a TTY; a plain "~ {label}" line otherwise) and mute
7916
+ * the verbose phase rows + BUILD box until {@link reload}/{@link error} settles it.
7917
+ *
7918
+ * @param label - The changed watch target shown in the line.
7919
+ * @example
7920
+ * render.rebuildStart("content");
7921
+ */
7922
+ rebuildStart(label) {
7923
+ rebuilding = true;
7924
+ rebuildLabel = label;
7925
+ rebuildStartedAt = now();
7926
+ spinnerFrame = 0;
7927
+ if (!color) {
7928
+ write(` ${palette.yellow("~")} ${label}`);
7929
+ return;
7930
+ }
7931
+ paintRebuildLine();
7932
+ startTicker();
7933
+ },
7934
+ /**
7935
+ * Settle the current rebuild: replace the in-place "rebuilding…" line with a compact
7936
+ * "✓ rebuilt N pages · Xms · reloaded" (on a TTY) and re-enable verbose build output.
7937
+ * Called standalone (no preceding {@link rebuildStart}) it also prints the "~ file"
7938
+ * line so the changed target stays visible.
7447
7939
  *
7448
7940
  * @param info - The changed file plus the rebuild's page count and duration.
7449
7941
  * @example
7450
7942
  * render.reload({ file: "content/a.md", pageCount: 12, durationMs: 84 });
7451
7943
  */
7452
7944
  reload(info) {
7453
- write(` ${palette.yellow("~")} ${info.file}`);
7454
- write(` ${palette.green("✓")} rebuilt ${palette.bold(String(info.pageCount))} pages ${palette.dim(`· ${info.durationMs}ms · browser reloaded`)}`);
7945
+ const settledRebuild = rebuilding;
7946
+ rebuilding = false;
7947
+ stopTicker();
7948
+ const line = ` ${palette.green("✓")} rebuilt ${palette.bold(String(info.pageCount))} pages ${palette.dim(`· ${info.durationMs}ms · reloaded`)}`;
7949
+ if (settledRebuild && color) {
7950
+ writeRaw(`\r${CLEAR_LINE}${line}\n`);
7951
+ return;
7952
+ }
7953
+ if (!settledRebuild) write(` ${palette.yellow("~")} ${info.file}`);
7954
+ write(line);
7455
7955
  },
7456
7956
  /**
7457
7957
  * Render the deploy result panel from a `deploy:complete` event.
@@ -7477,7 +7977,9 @@ function createPanelRenderer(options = {}) {
7477
7977
  * render.info("watching for changes…");
7478
7978
  */
7479
7979
  info(message) {
7480
- write(` ${palette.cyan("")} ${message}`);
7980
+ const [first = "", ...rest] = message.split("\n");
7981
+ write(` ${palette.cyan("›")} ${first}`);
7982
+ for (const line of rest) write(` ${line}`);
7481
7983
  },
7482
7984
  /**
7483
7985
  * Render a warning line (to stderr).
@@ -7498,8 +8000,38 @@ function createPanelRenderer(options = {}) {
7498
8000
  * render.error("build failed", err);
7499
8001
  */
7500
8002
  error(message, cause) {
8003
+ if (rebuilding) {
8004
+ rebuilding = false;
8005
+ stopTicker();
8006
+ if (color) writeRaw(`\r${CLEAR_LINE}`);
8007
+ }
7501
8008
  writeError(` ${palette.red("✗")} ${message}`);
7502
8009
  if (cause !== void 0) writeError(String(cause));
8010
+ },
8011
+ /**
8012
+ * Render a section heading (a blank line + a bold cyan label) for a multi-step flow.
8013
+ *
8014
+ * @param text - The heading label.
8015
+ * @example
8016
+ * render.heading("Diagnostics");
8017
+ */
8018
+ heading(text) {
8019
+ write("");
8020
+ write(` ${palette.bold(palette.cyan(text))}`);
8021
+ },
8022
+ /**
8023
+ * Render a diagnostic line: green `✓` (pass) or red `✗` (fail) + label, with optional
8024
+ * dim, indented detail beneath (e.g. a fix hint for a failing check).
8025
+ *
8026
+ * @param ok - Whether the check passed.
8027
+ * @param label - The check label.
8028
+ * @param detail - Optional multi-line guidance shown indented under the line.
8029
+ * @example
8030
+ * render.check(false, "CLOUDFLARE_API_TOKEN is set", "Create one at …");
8031
+ */
8032
+ check(ok, label, detail) {
8033
+ write(` ${ok ? palette.green("✓") : palette.red("✗")} ${label}`);
8034
+ if (detail !== void 0) for (const line of detail.split("\n")) write(` ${palette.dim(line)}`);
7503
8035
  }
7504
8036
  };
7505
8037
  }
@@ -7578,18 +8110,44 @@ function defaultConfirm(question) {
7578
8110
  });
7579
8111
  }
7580
8112
  /**
8113
+ * Default stdin single-choice prompt. Prints the choices numbered from 1, reads a line
8114
+ * via `node:readline`, and resolves the chosen zero-based index (empty / out-of-range
8115
+ * falls back to 0). Tests inject a canned selection so no real TTY interaction occurs.
8116
+ *
8117
+ * @param question - The prompt to display.
8118
+ * @param choices - The selectable option labels.
8119
+ * @returns Resolves the chosen zero-based index.
8120
+ * @example
8121
+ * await defaultSelect("Trigger?", ["Auto on push", "Manual only"]);
8122
+ */
8123
+ function defaultSelect(question, choices) {
8124
+ return new Promise((resolve) => {
8125
+ const readline = createInterface({
8126
+ input: process.stdin,
8127
+ output: process.stdout
8128
+ });
8129
+ for (const [index, choice] of choices.entries()) console.log(` ${index + 1}) ${choice}`);
8130
+ readline.question(`${question} [1-${choices.length}] `, (answer) => {
8131
+ readline.close();
8132
+ const picked = Number.parseInt(answer.trim(), 10);
8133
+ resolve(Number.isInteger(picked) && picked >= 1 && picked <= choices.length ? picked - 1 : 0);
8134
+ });
8135
+ });
8136
+ }
8137
+ /**
7581
8138
  * Default recursive directory watcher — wraps `node:fs.watch` with `{ recursive: true }`
7582
8139
  * and adapts its handle to {@link WatchHandle}. Tests inject a fake emitter so no real
7583
8140
  * FS watch is registered.
7584
8141
  *
7585
8142
  * @param dir - The directory to watch recursively.
7586
- * @param onChange - Invoked on any change beneath `dir`.
8143
+ * @param onChange - Invoked on any change beneath `dir`, forwarding the changed path
8144
+ * relative to `dir` when `node:fs.watch` reports it (`undefined` otherwise).
7587
8145
  * @returns A handle whose `close()` detaches the watcher.
7588
8146
  * @example
7589
- * const handle = defaultWatch("content", () => rebuild());
8147
+ * const handle = defaultWatch("content", file => rebuild(file));
7590
8148
  */
7591
8149
  function defaultWatch(dir, onChange) {
7592
- const watcher = watch(dir, { recursive: true }, () => onChange());
8150
+ const watcher = watch(dir, { recursive: true }, (_event, filename) => onChange(typeof filename === "string" ? filename : void 0));
7593
8151
  return {
7594
8152
  /**
7595
8153
  * Detach the underlying `node:fs.watch` listener.
@@ -7602,6 +8160,23 @@ close() {
7602
8160
  } };
7603
8161
  }
7604
8162
  /**
8163
+ * Default file-mtime probe — `node:fs.statSync(path).mtimeMs`, returning `null` for a
8164
+ * missing path (so a deleted file still reads as a change). serve() compares this
8165
+ * across `fs.watch` events to drop the duplicate notifications macOS fires per save.
8166
+ *
8167
+ * @param filePath - The absolute path to stat.
8168
+ * @returns The modification time in epoch milliseconds, or `null` when absent.
8169
+ * @example
8170
+ * const mtime = defaultFileMtime("/abs/content/a.md");
8171
+ */
8172
+ function defaultFileMtime(filePath) {
8173
+ try {
8174
+ return statSync(filePath).mtimeMs;
8175
+ } catch {
8176
+ return null;
8177
+ }
8178
+ }
8179
+ /**
7605
8180
  * Default LAN network-URL deriver — wraps {@link networkUrl} so the production seam
7606
8181
  * reads `node:os` interfaces while tests can inject a deterministic value.
7607
8182
  *
@@ -7629,11 +8204,13 @@ function createState$1(_ctx) {
7629
8204
  return {
7630
8205
  render: createPanelRenderer(),
7631
8206
  confirm: defaultConfirm,
8207
+ select: defaultSelect,
7632
8208
  clock: Date.now,
7633
8209
  watch: defaultWatch,
7634
8210
  serveStatic: defaultServeStatic,
7635
8211
  fileResponse: defaultFileResponse,
7636
- networkUrl: defaultNetworkUrl
8212
+ networkUrl: defaultNetworkUrl,
8213
+ fileMtime: defaultFileMtime
7637
8214
  };
7638
8215
  }
7639
8216
  //#endregion