@moku-labs/web 1.0.1 → 1.2.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/README.md +1 -1
- package/dist/index.cjs +1120 -86
- package/dist/index.d.cts +116 -13
- package/dist/index.d.mts +116 -13
- package/dist/index.mjs +1121 -87
- package/package.json +2 -1
package/dist/index.mjs
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { t as __exportAll } from "./chunk-D7D4PA-g.mjs";
|
|
2
2
|
import { n as relativeDataFile, t as dataSuffix } from "./convention-CepUwWmT.mjs";
|
|
3
3
|
import { createCoreConfig, createCorePlugin } from "@moku-labs/core";
|
|
4
|
-
import { existsSync, readFileSync, readdirSync, statSync, watch } from "node:fs";
|
|
4
|
+
import { appendFileSync, existsSync, readFileSync, readdirSync, realpathSync, statSync, watch } from "node:fs";
|
|
5
5
|
import { createHash, randomUUID } from "node:crypto";
|
|
6
6
|
import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { Feed } from "feed";
|
|
9
9
|
import { h } from "preact";
|
|
10
10
|
import { renderToString } from "preact-render-to-string";
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
11
12
|
import { createInterface } from "node:readline";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
12
14
|
import { networkInterfaces } from "node:os";
|
|
13
15
|
import matter from "gray-matter";
|
|
14
16
|
import rehypeShiki from "@shikijs/rehype";
|
|
@@ -4877,10 +4879,47 @@ function findRootHtml(rendered) {
|
|
|
4877
4879
|
return rendered.find((page) => page.url === "/" || page.url === "")?.html ?? null;
|
|
4878
4880
|
}
|
|
4879
4881
|
/**
|
|
4882
|
+
* Pages rendered concurrently per batch. Kept small so the macrotask yield between
|
|
4883
|
+
* batches fires frequently — a large batch renders for seconds before yielding, which
|
|
4884
|
+
* leaves a watching dev server's spinner repainting only every few seconds (sluggish).
|
|
4885
|
+
* Smaller batches trade a little write-concurrency for a smooth, responsive spinner.
|
|
4886
|
+
*/
|
|
4887
|
+
const RENDER_BATCH_SIZE = 2;
|
|
4888
|
+
/**
|
|
4889
|
+
* Render `items` through `worker` in bounded-size batches, yielding a macrotask
|
|
4890
|
+
* (`setImmediate`) between batches. Beyond bounding peak concurrency/memory for large
|
|
4891
|
+
* sites, the yield lets the single JS thread breathe: one un-yielded `Promise.all` over
|
|
4892
|
+
* hundreds of synchronous `renderToString` calls starves the event loop, which freezes a
|
|
4893
|
+
* watching dev server's progress spinner until the whole phase resolves. Output order is
|
|
4894
|
+
* preserved (batch order + `Promise.all` order within a batch).
|
|
4895
|
+
*
|
|
4896
|
+
* @template Item - The input item type.
|
|
4897
|
+
* @template Out - The rendered output type.
|
|
4898
|
+
* @param items - The items to render.
|
|
4899
|
+
* @param batchSize - Maximum items rendered concurrently per batch.
|
|
4900
|
+
* @param worker - Renders one item to its output.
|
|
4901
|
+
* @returns All rendered outputs in input order.
|
|
4902
|
+
* @example
|
|
4903
|
+
* ```ts
|
|
4904
|
+
* const pages = await renderInBatches(instances, 32, i => renderInstance(ctx, i, shell));
|
|
4905
|
+
* ```
|
|
4906
|
+
*/
|
|
4907
|
+
async function renderInBatches(items, batchSize, worker) {
|
|
4908
|
+
const out = [];
|
|
4909
|
+
for (let start = 0; start < items.length; start += batchSize) {
|
|
4910
|
+
const batch = items.slice(start, start + batchSize);
|
|
4911
|
+
out.push(...await Promise.all(batch.map((item) => worker(item))));
|
|
4912
|
+
if (start + batchSize < items.length) await new Promise((resolve) => {
|
|
4913
|
+
setImmediate(resolve);
|
|
4914
|
+
});
|
|
4915
|
+
}
|
|
4916
|
+
return out;
|
|
4917
|
+
}
|
|
4918
|
+
/**
|
|
4880
4919
|
* Renders every route in the manifest to `outDir/<path>/index.html`. Reads as a
|
|
4881
|
-
* pipeline: resolve deps → prepare the shared shell → expand instances → render
|
|
4882
|
-
*
|
|
4883
|
-
*
|
|
4920
|
+
* pipeline: resolve deps → prepare the shared shell → expand instances → render in
|
|
4921
|
+
* bounded batches ({@link renderInBatches}) → write data sidecars (hybrid/spa) →
|
|
4922
|
+
* capture the root page's HTML for the root-index phase.
|
|
4884
4923
|
*
|
|
4885
4924
|
* @param ctx - Plugin context (provides `require`, `state`, `config`, `log`, `has`).
|
|
4886
4925
|
* @returns The number of pages rendered and the captured default-page HTML.
|
|
@@ -4896,8 +4935,7 @@ async function renderPages(ctx) {
|
|
|
4896
4935
|
const locales = ctx.require(i18nPlugin).locales();
|
|
4897
4936
|
const byPattern = makeEntryMap(router);
|
|
4898
4937
|
const shell = await prepareShell(ctx);
|
|
4899
|
-
const
|
|
4900
|
-
const rendered = await Promise.all(instances.map((instance) => renderInstance(ctx, instance, shell)));
|
|
4938
|
+
const rendered = await renderInBatches(await expandAllInstances(manifest, locales, byPattern, ctx), RENDER_BATCH_SIZE, (instance) => renderInstance(ctx, instance, shell));
|
|
4901
4939
|
await writeDataSidecars(ctx, rendered, router.mode());
|
|
4902
4940
|
ctx.log.debug("build:pages", { count: rendered.length });
|
|
4903
4941
|
return {
|
|
@@ -5761,6 +5799,19 @@ const CHECKOUT_SHA = "11bd71901bbe5b1630ceea73d27597364c9af683";
|
|
|
5761
5799
|
const SETUP_BUN_SHA = "4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5";
|
|
5762
5800
|
/** Pinned `cloudflare/wrangler-action` commit SHA. */
|
|
5763
5801
|
const WRANGLER_ACTION_SHA = "f84a562284fc78278ff9052435d9526f9c718361";
|
|
5802
|
+
/** The `on:` block for each {@link WorkflowTrigger} (kept indentation-exact for YAML). */
|
|
5803
|
+
const TRIGGER_ON_BLOCKS = {
|
|
5804
|
+
auto: `on:
|
|
5805
|
+
push:
|
|
5806
|
+
branches: [main]
|
|
5807
|
+
workflow_dispatch:`,
|
|
5808
|
+
"versioned-tag": `on:
|
|
5809
|
+
push:
|
|
5810
|
+
tags: ["v*"]
|
|
5811
|
+
workflow_dispatch:`,
|
|
5812
|
+
dispatch: `on:
|
|
5813
|
+
workflow_dispatch:`
|
|
5814
|
+
};
|
|
5764
5815
|
/**
|
|
5765
5816
|
* Generate a SHA-pinned GitHub Actions workflow that builds and deploys to
|
|
5766
5817
|
* Cloudflare Pages. Every action is pinned to a commit SHA (with a `# vX`
|
|
@@ -5770,9 +5821,10 @@ const WRANGLER_ACTION_SHA = "f84a562284fc78278ff9052435d9526f9c718361";
|
|
|
5770
5821
|
*
|
|
5771
5822
|
* @param input - The generator inputs.
|
|
5772
5823
|
* @param input.slug - Cloudflare project-name slug used as the wrangler `--project-name`.
|
|
5824
|
+
* @param input.trigger - What fires the workflow (see {@link WorkflowTrigger}). Default `"auto"`.
|
|
5773
5825
|
* @returns The workflow YAML.
|
|
5774
5826
|
* @example
|
|
5775
|
-
* generateGithubWorkflow({ slug: "my-site" });
|
|
5827
|
+
* generateGithubWorkflow({ slug: "my-site", trigger: "versioned-tag" });
|
|
5776
5828
|
*/
|
|
5777
5829
|
function generateGithubWorkflow(input) {
|
|
5778
5830
|
return `# .github/workflows/deploy.yml — generated by \`app.deploy.init({ ci: true })\`.
|
|
@@ -5780,10 +5832,7 @@ function generateGithubWorkflow(input) {
|
|
|
5780
5832
|
|
|
5781
5833
|
name: Deploy
|
|
5782
5834
|
|
|
5783
|
-
|
|
5784
|
-
push:
|
|
5785
|
-
branches: [main]
|
|
5786
|
-
workflow_dispatch:
|
|
5835
|
+
${TRIGGER_ON_BLOCKS[input.trigger ?? "auto"]}
|
|
5787
5836
|
|
|
5788
5837
|
permissions:
|
|
5789
5838
|
contents: read
|
|
@@ -5915,7 +5964,10 @@ async function writeScaffolding(input) {
|
|
|
5915
5964
|
});
|
|
5916
5965
|
if (ci) await reconcile({
|
|
5917
5966
|
relativePath: WORKFLOW_PATH,
|
|
5918
|
-
expected: generateGithubWorkflow({
|
|
5967
|
+
expected: generateGithubWorkflow({
|
|
5968
|
+
slug,
|
|
5969
|
+
...options.workflowTrigger ? { trigger: options.workflowTrigger } : {}
|
|
5970
|
+
}),
|
|
5919
5971
|
existing: await readMaybe(cwd, WORKFLOW_PATH),
|
|
5920
5972
|
cwd,
|
|
5921
5973
|
check,
|
|
@@ -6433,6 +6485,224 @@ const deployPlugin = createPlugin$1("deploy", {
|
|
|
6433
6485
|
api: createApi$2
|
|
6434
6486
|
});
|
|
6435
6487
|
//#endregion
|
|
6488
|
+
//#region src/plugins/cli/deploy-wizard.ts
|
|
6489
|
+
/**
|
|
6490
|
+
* @file cli plugin — the guided deploy wizard (`cli.deploy({ guided: true })`, the default
|
|
6491
|
+
* for `bun run deploy`; the direct `--cli` path stays in `api.ts`). Walks a human through a
|
|
6492
|
+
* Cloudflare Pages deploy: checks prerequisites (wrangler config + the Cloudflare
|
|
6493
|
+
* credentials) with concrete fix guidance, offers to scaffold what is missing (a
|
|
6494
|
+
* `wrangler.jsonc`, and a placeholder `.env` for any missing credentials), HARD-GATES the
|
|
6495
|
+
* deploy on everything being green, runs a local build smoke test, confirms, deploys, then
|
|
6496
|
+
* offers to scaffold a GitHub Actions workflow (auto on push to main, or a versioned/manual
|
|
6497
|
+
* trigger). Every prompt + line of output flows through injectable `state` seams.
|
|
6498
|
+
*/
|
|
6499
|
+
/** How to create a Cloudflare API token + where to make it available locally. */
|
|
6500
|
+
const TOKEN_HELP = [
|
|
6501
|
+
"Create one at https://dash.cloudflare.com/profile/api-tokens → Create Token →",
|
|
6502
|
+
"use the \"Cloudflare Pages — Edit\" template (or a custom token with the",
|
|
6503
|
+
"Account › Cloudflare Pages › Edit permission). Then make it available:",
|
|
6504
|
+
" export CLOUDFLARE_API_TOKEN=… (shell) or add it to .env (gitignored)."
|
|
6505
|
+
].join("\n");
|
|
6506
|
+
/** Where to find the Cloudflare account id + where to make it available locally. */
|
|
6507
|
+
const ACCOUNT_HELP = [
|
|
6508
|
+
"Find it on the Cloudflare dashboard → Workers & Pages: the Account ID is in the",
|
|
6509
|
+
"right-hand sidebar (also in the dashboard URL). Then make it available:",
|
|
6510
|
+
" export CLOUDFLARE_ACCOUNT_ID=… or add it to .env."
|
|
6511
|
+
].join("\n");
|
|
6512
|
+
/** The GitHub repo secrets the generated workflow consumes. */
|
|
6513
|
+
const SECRETS_HELP = ["Add these repo secrets (GitHub → Settings → Secrets and variables → Actions):", "CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID"].join("\n");
|
|
6514
|
+
/**
|
|
6515
|
+
* Evaluate the three deploy prerequisites against the current project: the Cloudflare
|
|
6516
|
+
* wrangler config exists, and both Cloudflare credentials are present in the environment.
|
|
6517
|
+
*
|
|
6518
|
+
* @param cwd - The project root (where `wrangler.jsonc` lives).
|
|
6519
|
+
* @returns The ordered prerequisite checks.
|
|
6520
|
+
* @example
|
|
6521
|
+
* const prereqs = diagnose(process.cwd());
|
|
6522
|
+
*/
|
|
6523
|
+
function diagnose(cwd) {
|
|
6524
|
+
const wranglerOk = existsSync(path.join(cwd, "wrangler.jsonc"));
|
|
6525
|
+
const tokenOk = (process.env.CLOUDFLARE_API_TOKEN ?? "") !== "";
|
|
6526
|
+
const accountOk = (process.env.CLOUDFLARE_ACCOUNT_ID ?? "") !== "";
|
|
6527
|
+
return [
|
|
6528
|
+
{
|
|
6529
|
+
ok: wranglerOk,
|
|
6530
|
+
label: "wrangler.jsonc (Cloudflare project config)",
|
|
6531
|
+
detail: wranglerOk ? void 0 : "Missing — scaffold it (offered below) or run app.deploy.init().",
|
|
6532
|
+
scaffoldable: true
|
|
6533
|
+
},
|
|
6534
|
+
{
|
|
6535
|
+
ok: tokenOk,
|
|
6536
|
+
label: "CLOUDFLARE_API_TOKEN is set",
|
|
6537
|
+
detail: tokenOk ? void 0 : TOKEN_HELP,
|
|
6538
|
+
scaffoldable: false
|
|
6539
|
+
},
|
|
6540
|
+
{
|
|
6541
|
+
ok: accountOk,
|
|
6542
|
+
label: "CLOUDFLARE_ACCOUNT_ID is set",
|
|
6543
|
+
detail: accountOk ? void 0 : ACCOUNT_HELP,
|
|
6544
|
+
scaffoldable: false
|
|
6545
|
+
}
|
|
6546
|
+
];
|
|
6547
|
+
}
|
|
6548
|
+
/**
|
|
6549
|
+
* Offer to scaffold a missing `wrangler.jsonc` (the only auto-fixable prerequisite),
|
|
6550
|
+
* generating it via the deploy plugin when the user accepts.
|
|
6551
|
+
*
|
|
6552
|
+
* @param ctx - The cli plugin context.
|
|
6553
|
+
* @param prereqs - The current prerequisite checks.
|
|
6554
|
+
* @returns Resolves once any accepted fix has run.
|
|
6555
|
+
* @example
|
|
6556
|
+
* await offerScaffold(ctx, diagnose(cwd));
|
|
6557
|
+
*/
|
|
6558
|
+
async function offerScaffold(ctx, prereqs) {
|
|
6559
|
+
if (!prereqs.some((item) => item.scaffoldable && !item.ok)) return;
|
|
6560
|
+
if (!await ctx.state.confirm("Scaffold wrangler.jsonc now?")) return;
|
|
6561
|
+
await ctx.require(deployPlugin).init({});
|
|
6562
|
+
ctx.state.render.check(true, "wrangler.jsonc scaffolded");
|
|
6563
|
+
}
|
|
6564
|
+
/** The Cloudflare credentials the deploy needs, with the comment written above each in a scaffolded `.env`. */
|
|
6565
|
+
const ENV_CREDENTIALS = [{
|
|
6566
|
+
key: "CLOUDFLARE_API_TOKEN",
|
|
6567
|
+
comment: "# Cloudflare API token — https://dash.cloudflare.com/profile/api-tokens (template: Cloudflare Pages — Edit)"
|
|
6568
|
+
}, {
|
|
6569
|
+
key: "CLOUDFLARE_ACCOUNT_ID",
|
|
6570
|
+
comment: "# Cloudflare account id — dashboard → Workers & Pages → right-hand sidebar"
|
|
6571
|
+
}];
|
|
6572
|
+
/**
|
|
6573
|
+
* Offer to scaffold a `.env` with placeholders for whichever Cloudflare credentials are
|
|
6574
|
+
* missing — created when absent, appended to (never clobbering a key already present)
|
|
6575
|
+
* when it exists. The placeholders are empty, so the deploy still hard-gates until the
|
|
6576
|
+
* user fills them in; this just removes the "where do I even put these?" friction.
|
|
6577
|
+
*
|
|
6578
|
+
* @param ctx - The cli plugin context.
|
|
6579
|
+
* @param cwd - The project root (where `.env` lives).
|
|
6580
|
+
* @returns Resolves once any accepted scaffold has been written.
|
|
6581
|
+
* @example
|
|
6582
|
+
* await offerEnvScaffold(ctx, process.cwd());
|
|
6583
|
+
*/
|
|
6584
|
+
async function offerEnvScaffold(ctx, cwd) {
|
|
6585
|
+
const missing = ENV_CREDENTIALS.filter(({ key }) => (process.env[key] ?? "") === "");
|
|
6586
|
+
if (missing.length === 0) return;
|
|
6587
|
+
const envPath = path.join(cwd, ".env");
|
|
6588
|
+
const exists = existsSync(envPath);
|
|
6589
|
+
const verb = exists ? "Add placeholders for the missing secret(s) to" : "Create";
|
|
6590
|
+
if (!await ctx.state.confirm(`${verb} .env?`)) return;
|
|
6591
|
+
const lines = exists ? readFileSync(envPath, "utf8").split(/\r?\n/) : [];
|
|
6592
|
+
const toAdd = missing.filter(({ key }) => !lines.some((line) => line.trimStart().startsWith(`${key}=`)));
|
|
6593
|
+
if (toAdd.length === 0) {
|
|
6594
|
+
ctx.state.render.info(".env already lists those keys — fill in their values, then re-run.");
|
|
6595
|
+
return;
|
|
6596
|
+
}
|
|
6597
|
+
appendFileSync(envPath, `${exists ? "\n" : "# Cloudflare Pages deploy credentials — fill these in (keep .env gitignored).\n"}${toAdd.map(({ key, comment }) => `${comment}\n${key}=`).join("\n\n")}\n`);
|
|
6598
|
+
const names = toAdd.map(({ key }) => key).join(", ");
|
|
6599
|
+
ctx.state.render.check(true, `${exists ? "added placeholders to" : "created"} .env`, `fill in ${names}, then re-run \`bun run deploy\`.`);
|
|
6600
|
+
}
|
|
6601
|
+
/**
|
|
6602
|
+
* Map a top-level workflow choice (and, for the versioned option, a sub-choice) to the
|
|
6603
|
+
* concrete {@link WorkflowTrigger}, or `null` when the user chose to skip setup.
|
|
6604
|
+
*
|
|
6605
|
+
* @param ctx - The cli plugin context (for the follow-up sub-choice prompt).
|
|
6606
|
+
* @param choice - The selected zero-based index of the top-level options.
|
|
6607
|
+
* @returns The resolved trigger, or `null` to skip.
|
|
6608
|
+
* @example
|
|
6609
|
+
* const trigger = await resolveTrigger(ctx, 1);
|
|
6610
|
+
*/
|
|
6611
|
+
async function resolveTrigger(ctx, choice) {
|
|
6612
|
+
if (choice === 2) return null;
|
|
6613
|
+
if (choice === 0) return "auto";
|
|
6614
|
+
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";
|
|
6615
|
+
}
|
|
6616
|
+
/**
|
|
6617
|
+
* Offer to scaffold a GitHub Actions deploy workflow, letting the user choose how it is
|
|
6618
|
+
* triggered, then remind them which repo secrets to add. A no-op past a "skip" choice.
|
|
6619
|
+
*
|
|
6620
|
+
* @param ctx - The cli plugin context.
|
|
6621
|
+
* @returns Resolves once any chosen workflow has been scaffolded.
|
|
6622
|
+
* @example
|
|
6623
|
+
* await offerWorkflowSetup(ctx);
|
|
6624
|
+
*/
|
|
6625
|
+
async function offerWorkflowSetup(ctx) {
|
|
6626
|
+
ctx.state.render.heading("Automate future deploys (GitHub Actions)");
|
|
6627
|
+
const trigger = await resolveTrigger(ctx, await ctx.state.select("Set up a deploy workflow?", [
|
|
6628
|
+
"Auto-deploy on every push to main",
|
|
6629
|
+
"Manual / versioned deploy (choose trigger)",
|
|
6630
|
+
"Skip for now"
|
|
6631
|
+
]));
|
|
6632
|
+
if (trigger === null) return;
|
|
6633
|
+
const result = await ctx.require(deployPlugin).init({
|
|
6634
|
+
ci: true,
|
|
6635
|
+
workflowTrigger: trigger
|
|
6636
|
+
});
|
|
6637
|
+
const workflowPath = ".github/workflows/deploy.yml";
|
|
6638
|
+
const wrote = result.written.includes(workflowPath);
|
|
6639
|
+
ctx.state.render.check(true, wrote ? `wrote ${workflowPath}` : `${workflowPath} already exists (left unchanged)`);
|
|
6640
|
+
ctx.state.render.info(SECRETS_HELP);
|
|
6641
|
+
}
|
|
6642
|
+
/**
|
|
6643
|
+
* Run the deploy step: confirm (unless `yes`), then deploy via the deploy plugin and
|
|
6644
|
+
* report the outcome. A declined confirm returns `{ deployed: false, reason: "declined" }`.
|
|
6645
|
+
*
|
|
6646
|
+
* @param ctx - The cli plugin context.
|
|
6647
|
+
* @param options - The deploy options (branch override + `yes`).
|
|
6648
|
+
* @returns The deploy outcome.
|
|
6649
|
+
* @example
|
|
6650
|
+
* const outcome = await runDeployStep(ctx, { yes: true });
|
|
6651
|
+
*/
|
|
6652
|
+
async function runDeployStep(ctx, options) {
|
|
6653
|
+
ctx.state.render.heading("Deploy");
|
|
6654
|
+
if (!(options.yes === true || await ctx.state.confirm(`Deploy ${ctx.config.outDir}/ to Cloudflare Pages now?`))) {
|
|
6655
|
+
ctx.state.render.warn("deploy skipped");
|
|
6656
|
+
return {
|
|
6657
|
+
deployed: false,
|
|
6658
|
+
reason: "declined"
|
|
6659
|
+
};
|
|
6660
|
+
}
|
|
6661
|
+
return {
|
|
6662
|
+
deployed: true,
|
|
6663
|
+
...await ctx.require(deployPlugin).run(options.branch === void 0 ? {} : { branch: options.branch })
|
|
6664
|
+
};
|
|
6665
|
+
}
|
|
6666
|
+
/**
|
|
6667
|
+
* Run the guided deploy wizard end to end: diagnose prerequisites (offering to scaffold
|
|
6668
|
+
* the wrangler config), HARD-GATE on the remaining blockers, run a local build smoke
|
|
6669
|
+
* test, deploy (with confirmation), then offer to scaffold a CI workflow. Returns
|
|
6670
|
+
* `{ deployed: false, reason: "blocked" }` when prerequisites are unmet, so a thin script
|
|
6671
|
+
* can exit non-zero. Assumes the caller already rendered the `deploy` header.
|
|
6672
|
+
*
|
|
6673
|
+
* @param ctx - The cli plugin context (state seams + `require` + config).
|
|
6674
|
+
* @param options - The deploy options (branch override, `yes`, `guided`).
|
|
6675
|
+
* @returns The deploy outcome (`deployed`, or a `declined`/`blocked` skip).
|
|
6676
|
+
* @example
|
|
6677
|
+
* const outcome = await runDeployWizard(ctx, { guided: true });
|
|
6678
|
+
*/
|
|
6679
|
+
async function runDeployWizard(ctx, options) {
|
|
6680
|
+
const cwd = process.cwd();
|
|
6681
|
+
ctx.state.render.heading("Checking prerequisites");
|
|
6682
|
+
for (const item of diagnose(cwd)) ctx.state.render.check(item.ok, item.label, item.detail);
|
|
6683
|
+
await offerScaffold(ctx, diagnose(cwd));
|
|
6684
|
+
await offerEnvScaffold(ctx, cwd);
|
|
6685
|
+
const blockers = diagnose(cwd).filter((item) => !item.ok);
|
|
6686
|
+
if (blockers.length > 0) {
|
|
6687
|
+
ctx.state.render.heading("Not ready to deploy");
|
|
6688
|
+
for (const item of blockers) ctx.state.render.check(false, item.label, item.detail);
|
|
6689
|
+
ctx.state.render.warn(`Fix the ${blockers.length} item(s) above, then re-run \`bun run deploy\`.`);
|
|
6690
|
+
return {
|
|
6691
|
+
deployed: false,
|
|
6692
|
+
reason: "blocked"
|
|
6693
|
+
};
|
|
6694
|
+
}
|
|
6695
|
+
ctx.state.render.heading("Local test");
|
|
6696
|
+
const summary = await ctx.require(buildPlugin).run();
|
|
6697
|
+
ctx.state.render.check(true, `built ${summary.pageCount} pages → ${summary.outDir}/`);
|
|
6698
|
+
const notFoundOk = existsSync(path.join(ctx.config.outDir, ctx.config.notFoundFile));
|
|
6699
|
+
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).");
|
|
6700
|
+
ctx.state.render.info("Tip: run `bun run preview` to eyeball the built site before deploying.");
|
|
6701
|
+
const outcome = await runDeployStep(ctx, options);
|
|
6702
|
+
await offerWorkflowSetup(ctx);
|
|
6703
|
+
return outcome;
|
|
6704
|
+
}
|
|
6705
|
+
//#endregion
|
|
6436
6706
|
//#region src/plugins/cli/errors.ts
|
|
6437
6707
|
/** Error prefix for cli config/validation/runtime failures (spec/11 Part-3). */
|
|
6438
6708
|
const ERROR_PREFIX$3 = "[web] cli";
|
|
@@ -6466,11 +6736,12 @@ function injectReloadClient(html) {
|
|
|
6466
6736
|
return index === -1 ? html + RELOAD_CLIENT : html.slice(0, index) + RELOAD_CLIENT + html.slice(index);
|
|
6467
6737
|
}
|
|
6468
6738
|
/**
|
|
6469
|
-
* Run one rebuild and report the result.
|
|
6470
|
-
*
|
|
6739
|
+
* Run one rebuild and report the result. Announces the start (`onRebuildStart`), then
|
|
6740
|
+
* routes success to `onReloaded` and failure to `onError`.
|
|
6471
6741
|
*
|
|
6472
6742
|
* @param input - The rebuild dependencies + the changed file.
|
|
6473
6743
|
* @param input.runBuild - Runs one build and resolves with its summary.
|
|
6744
|
+
* @param input.onRebuildStart - Called with the changed file just before the build runs.
|
|
6474
6745
|
* @param input.onReloaded - Called with the changed file + summary after a rebuild.
|
|
6475
6746
|
* @param input.onError - Called when a rebuild throws.
|
|
6476
6747
|
* @param input.file - The changed file to report alongside the summary.
|
|
@@ -6479,6 +6750,7 @@ function injectReloadClient(html) {
|
|
|
6479
6750
|
* await runOneRebuild({ runBuild, onReloaded, onError, file: "a.md" });
|
|
6480
6751
|
*/
|
|
6481
6752
|
async function runOneRebuild(input) {
|
|
6753
|
+
input.onRebuildStart?.(input.file);
|
|
6482
6754
|
try {
|
|
6483
6755
|
const summary = await input.runBuild();
|
|
6484
6756
|
input.onReloaded({
|
|
@@ -6500,6 +6772,7 @@ async function runOneRebuild(input) {
|
|
|
6500
6772
|
* @param input - The rebuild dependencies.
|
|
6501
6773
|
* @param input.debounceMs - Debounce window in milliseconds.
|
|
6502
6774
|
* @param input.runBuild - Runs one build and resolves with its summary.
|
|
6775
|
+
* @param input.onRebuildStart - Called with the changed file just before each build runs.
|
|
6503
6776
|
* @param input.onReloaded - Called with the changed file + summary after a rebuild.
|
|
6504
6777
|
* @param input.onError - Called when a rebuild throws.
|
|
6505
6778
|
* @returns The debounced rebuild driver.
|
|
@@ -6526,6 +6799,7 @@ function createRebuilder(input) {
|
|
|
6526
6799
|
dirty = false;
|
|
6527
6800
|
await runOneRebuild({
|
|
6528
6801
|
runBuild: input.runBuild,
|
|
6802
|
+
...input.onRebuildStart ? { onRebuildStart: input.onRebuildStart } : {},
|
|
6529
6803
|
onReloaded: input.onReloaded,
|
|
6530
6804
|
onError: input.onError,
|
|
6531
6805
|
file: pendingFile
|
|
@@ -6579,6 +6853,70 @@ function createRebuilder(input) {
|
|
|
6579
6853
|
};
|
|
6580
6854
|
}
|
|
6581
6855
|
/**
|
|
6856
|
+
* Whether a changed path (relative to a watched dir) is editor/OS noise that is never a
|
|
6857
|
+
* page source: any hidden segment (`.DS_Store`, anything under `.git/` or `.cache/`,
|
|
6858
|
+
* vim `.*.swp`) or a `~` backup file. Checks every segment, not just the basename.
|
|
6859
|
+
*
|
|
6860
|
+
* @param filename - The changed path relative to its watched directory.
|
|
6861
|
+
* @returns `true` when the change should be ignored as noise.
|
|
6862
|
+
* @example
|
|
6863
|
+
* isNoisePath(".git/HEAD"); // true
|
|
6864
|
+
*/
|
|
6865
|
+
function isNoisePath(filename) {
|
|
6866
|
+
return filename.split(/[/\\]/).some((segment) => segment.startsWith(".")) || filename.endsWith("~");
|
|
6867
|
+
}
|
|
6868
|
+
/**
|
|
6869
|
+
* Create a {@link ChangeGate} that drops three kinds of spurious change events before
|
|
6870
|
+
* they reach the debounced rebuilder: editor/OS noise (dotfiles, backups), writes under
|
|
6871
|
+
* `outDir` (the build's own output — a loop guard), and the stale duplicate/parent-dir
|
|
6872
|
+
* echoes macOS fires for one save. Staleness is judged by a build-start high-water mark:
|
|
6873
|
+
* a change whose file mtime is at or before the last build we started was already
|
|
6874
|
+
* captured (or is a late echo), so it is ignored — while a genuinely newer edit (even
|
|
6875
|
+
* one made mid-build) and a deletion (missing file) always pass. The single timestamp
|
|
6876
|
+
* also means no per-path map grows over a long session.
|
|
6877
|
+
*
|
|
6878
|
+
* @param input - The gate dependencies.
|
|
6879
|
+
* @param input.outDir - The build output directory whose writes must never re-trigger a build.
|
|
6880
|
+
* @param input.fileMtime - Resolves a path's mtime in ms (or `null` when missing).
|
|
6881
|
+
* @param input.now - Monotonic wall clock (ms) used for the build-start high-water mark.
|
|
6882
|
+
* @returns The change gate.
|
|
6883
|
+
* @example
|
|
6884
|
+
* const gate = createChangeGate({ outDir: "dist", fileMtime: state.fileMtime, now: state.clock });
|
|
6885
|
+
*/
|
|
6886
|
+
function createChangeGate(input) {
|
|
6887
|
+
const outDirAbs = path.resolve(input.outDir);
|
|
6888
|
+
let lastBuildStartedAt = input.now();
|
|
6889
|
+
return {
|
|
6890
|
+
/**
|
|
6891
|
+
* Decide whether a change beneath `dir` warrants a rebuild (see {@link ChangeGate.accept}).
|
|
6892
|
+
*
|
|
6893
|
+
* @param dir - The watched directory the event fired on.
|
|
6894
|
+
* @param filename - The changed path relative to `dir` (or `undefined`).
|
|
6895
|
+
* @returns `true` to schedule a rebuild, `false` to ignore.
|
|
6896
|
+
* @example
|
|
6897
|
+
* gate.accept("content", "post/en.md");
|
|
6898
|
+
*/
|
|
6899
|
+
accept(dir, filename) {
|
|
6900
|
+
if (filename === void 0) return true;
|
|
6901
|
+
if (isNoisePath(filename)) return false;
|
|
6902
|
+
const changed = path.resolve(dir, filename);
|
|
6903
|
+
if (changed === outDirAbs || changed.startsWith(`${outDirAbs}${path.sep}`)) return false;
|
|
6904
|
+
const mtime = input.fileMtime(changed);
|
|
6905
|
+
if (mtime !== null && mtime < lastBuildStartedAt) return false;
|
|
6906
|
+
return true;
|
|
6907
|
+
},
|
|
6908
|
+
/**
|
|
6909
|
+
* Advance the high-water mark to now (see {@link ChangeGate.markBuildStart}).
|
|
6910
|
+
*
|
|
6911
|
+
* @example
|
|
6912
|
+
* gate.markBuildStart();
|
|
6913
|
+
*/
|
|
6914
|
+
markBuildStart() {
|
|
6915
|
+
lastBuildStartedAt = input.now();
|
|
6916
|
+
}
|
|
6917
|
+
};
|
|
6918
|
+
}
|
|
6919
|
+
/**
|
|
6582
6920
|
* Install SIGINT/SIGTERM handlers that run `teardown()` and resolve the returned
|
|
6583
6921
|
* promise, so a long-running command (`serve`/`preview`) unblocks its `await` on
|
|
6584
6922
|
* Ctrl-C / termination and detaches its own listeners. Used by both servers.
|
|
@@ -6610,18 +6948,45 @@ function installSignalTeardown(teardown) {
|
|
|
6610
6948
|
const SSE_OPEN = ": connected\n\n";
|
|
6611
6949
|
/** The SSE frame pushed to reload a connected browser. */
|
|
6612
6950
|
const SSE_RELOAD = "event: reload\ndata: 1\n\n";
|
|
6951
|
+
/** The SSE comment frame sent on the heartbeat to keep an idle stream warm. */
|
|
6952
|
+
const SSE_PING = ": ping\n\n";
|
|
6953
|
+
/** Default heartbeat interval (ms): one ping well under any 30s+ proxy idle window. */
|
|
6954
|
+
const DEFAULT_HEARTBEAT_MS = 15e3;
|
|
6613
6955
|
/**
|
|
6614
6956
|
* Create a {@link ReloadHub} backed by `ReadableStream` controllers. Each `connect()`
|
|
6615
6957
|
* enqueues into a new stream; `reloadAll()` writes the reload frame to every live
|
|
6616
|
-
* controller (dropping any that have closed).
|
|
6958
|
+
* controller (dropping any that have closed). A periodic heartbeat comment keeps idle
|
|
6959
|
+
* streams warm — belt-and-suspenders alongside the dev server's `idleTimeout: 0`, so a
|
|
6960
|
+
* quiet connection is never severed (which the browser surfaces as
|
|
6961
|
+
* `ERR_INCOMPLETE_CHUNKED_ENCODING` and then reconnects in a storm).
|
|
6617
6962
|
*
|
|
6963
|
+
* @param options - Optional heartbeat tuning.
|
|
6964
|
+
* @param options.heartbeatMs - Heartbeat interval in ms (`0` disables). Default `15000`.
|
|
6618
6965
|
* @returns The reload hub.
|
|
6619
6966
|
* @example
|
|
6620
6967
|
* const hub = createReloadHub();
|
|
6621
6968
|
*/
|
|
6622
|
-
function createReloadHub() {
|
|
6969
|
+
function createReloadHub(options = {}) {
|
|
6623
6970
|
const encoder = new TextEncoder();
|
|
6624
6971
|
const clients = /* @__PURE__ */ new Set();
|
|
6972
|
+
/**
|
|
6973
|
+
* Enqueue one frame to every live controller, dropping any that have closed.
|
|
6974
|
+
*
|
|
6975
|
+
* @param frame - The SSE wire text to broadcast.
|
|
6976
|
+
* @example
|
|
6977
|
+
* broadcast(SSE_RELOAD);
|
|
6978
|
+
*/
|
|
6979
|
+
const broadcast = (frame) => {
|
|
6980
|
+
const bytes = encoder.encode(frame);
|
|
6981
|
+
for (const controller of clients) try {
|
|
6982
|
+
controller.enqueue(bytes);
|
|
6983
|
+
} catch {
|
|
6984
|
+
clients.delete(controller);
|
|
6985
|
+
}
|
|
6986
|
+
};
|
|
6987
|
+
const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS;
|
|
6988
|
+
const heartbeat = heartbeatMs > 0 ? setInterval(() => broadcast(SSE_PING), heartbeatMs) : void 0;
|
|
6989
|
+
heartbeat?.unref?.();
|
|
6625
6990
|
return {
|
|
6626
6991
|
/**
|
|
6627
6992
|
* Open one SSE connection, register its controller, and return the streaming
|
|
@@ -6669,11 +7034,7 @@ function createReloadHub() {
|
|
|
6669
7034
|
* hub.reloadAll();
|
|
6670
7035
|
*/
|
|
6671
7036
|
reloadAll() {
|
|
6672
|
-
|
|
6673
|
-
controller.enqueue(encoder.encode(SSE_RELOAD));
|
|
6674
|
-
} catch {
|
|
6675
|
-
clients.delete(controller);
|
|
6676
|
-
}
|
|
7037
|
+
broadcast(SSE_RELOAD);
|
|
6677
7038
|
},
|
|
6678
7039
|
/**
|
|
6679
7040
|
* The number of currently-connected clients.
|
|
@@ -6684,6 +7045,19 @@ function createReloadHub() {
|
|
|
6684
7045
|
*/
|
|
6685
7046
|
size() {
|
|
6686
7047
|
return clients.size;
|
|
7048
|
+
},
|
|
7049
|
+
/**
|
|
7050
|
+
* Stop the heartbeat and close every live SSE stream (SIGINT/SIGTERM teardown).
|
|
7051
|
+
*
|
|
7052
|
+
* @example
|
|
7053
|
+
* hub.close();
|
|
7054
|
+
*/
|
|
7055
|
+
close() {
|
|
7056
|
+
if (heartbeat !== void 0) clearInterval(heartbeat);
|
|
7057
|
+
for (const controller of clients) try {
|
|
7058
|
+
controller.close();
|
|
7059
|
+
} catch {}
|
|
7060
|
+
clients.clear();
|
|
6687
7061
|
}
|
|
6688
7062
|
};
|
|
6689
7063
|
}
|
|
@@ -6747,8 +7121,14 @@ async function runDevServer(ctx, port) {
|
|
|
6747
7121
|
const hub = createReloadHub();
|
|
6748
7122
|
const server = ctx.state.serveStatic({
|
|
6749
7123
|
port,
|
|
7124
|
+
idleTimeout: 0,
|
|
6750
7125
|
fetch: createDevHandler(ctx, hub)
|
|
6751
7126
|
});
|
|
7127
|
+
const gate = createChangeGate({
|
|
7128
|
+
outDir: ctx.config.outDir,
|
|
7129
|
+
fileMtime: ctx.state.fileMtime,
|
|
7130
|
+
now: ctx.state.clock
|
|
7131
|
+
});
|
|
6752
7132
|
const rebuilder = createRebuilder({
|
|
6753
7133
|
debounceMs: ctx.config.debounceMs,
|
|
6754
7134
|
/**
|
|
@@ -6762,6 +7142,17 @@ async function runDevServer(ctx, port) {
|
|
|
6762
7142
|
return ctx.require(buildPlugin).run();
|
|
6763
7143
|
},
|
|
6764
7144
|
/**
|
|
7145
|
+
* Show the compact in-place "rebuilding {label}" line before the build runs.
|
|
7146
|
+
*
|
|
7147
|
+
* @param file - The changed watch target shown in the line.
|
|
7148
|
+
* @example
|
|
7149
|
+
* onRebuildStart("content");
|
|
7150
|
+
*/
|
|
7151
|
+
onRebuildStart(file) {
|
|
7152
|
+
gate.markBuildStart();
|
|
7153
|
+
ctx.state.render.rebuildStart(file);
|
|
7154
|
+
},
|
|
7155
|
+
/**
|
|
6765
7156
|
* Render the reload line and push a browser reload after a rebuild.
|
|
6766
7157
|
*
|
|
6767
7158
|
* @param info - The changed file plus the rebuild's page count and duration.
|
|
@@ -6783,7 +7174,9 @@ async function runDevServer(ctx, port) {
|
|
|
6783
7174
|
ctx.state.render.error("rebuild failed", error);
|
|
6784
7175
|
}
|
|
6785
7176
|
});
|
|
6786
|
-
const watchers = ctx.config.watchDirs.map((dir) => ctx.state.watch(dir, () =>
|
|
7177
|
+
const watchers = ctx.config.watchDirs.map((dir) => ctx.state.watch(dir, (filename) => {
|
|
7178
|
+
if (gate.accept(dir, filename)) rebuilder.schedule(dir);
|
|
7179
|
+
}));
|
|
6787
7180
|
ctx.state.render.serverReady({
|
|
6788
7181
|
local: `http://localhost:${port}`,
|
|
6789
7182
|
network: ctx.state.networkUrl(port),
|
|
@@ -6792,7 +7185,9 @@ async function runDevServer(ctx, port) {
|
|
|
6792
7185
|
return installSignalTeardown(() => {
|
|
6793
7186
|
rebuilder.cancel();
|
|
6794
7187
|
for (const watcher of watchers) watcher.close();
|
|
7188
|
+
hub.close();
|
|
6795
7189
|
server.stop();
|
|
7190
|
+
ctx.state.render.dispose();
|
|
6796
7191
|
});
|
|
6797
7192
|
}
|
|
6798
7193
|
//#endregion
|
|
@@ -6993,6 +7388,28 @@ async function confirmDeploy(ctx, yes) {
|
|
|
6993
7388
|
if (!confirmed) ctx.state.render.warn("deploy skipped");
|
|
6994
7389
|
return confirmed;
|
|
6995
7390
|
}
|
|
7391
|
+
/** Matches the prerequisite/credential failures a direct deploy most often hits (missing token/account). */
|
|
7392
|
+
const PREREQUISITE_ERROR = /required variable|not defined|cloudflare|token|account|unauthor|wrangler/i;
|
|
7393
|
+
/**
|
|
7394
|
+
* A short, actionable "how to fix" hint for a failed deploy, rendered under the error so
|
|
7395
|
+
* the user is never left at a raw stack trace. A missing-credential/prerequisite failure
|
|
7396
|
+
* (the common case for a first direct deploy) gets the concrete secret-setup steps;
|
|
7397
|
+
* anything else points at the guided deploy, which diagnoses prerequisites step by step.
|
|
7398
|
+
*
|
|
7399
|
+
* @param error - The thrown deploy error.
|
|
7400
|
+
* @returns The multi-line hint (newline-separated; rendered indented under a `›`).
|
|
7401
|
+
* @example
|
|
7402
|
+
* render.info(deployFailureHint(err));
|
|
7403
|
+
*/
|
|
7404
|
+
function deployFailureHint(error) {
|
|
7405
|
+
if (PREREQUISITE_ERROR.test(String(error))) return [
|
|
7406
|
+
"how to fix:",
|
|
7407
|
+
"1. run `bun run deploy` (without `--cli`) — the guided setup diagnoses prerequisites and offers to create a starter .env",
|
|
7408
|
+
"2. or set them yourself in .env: CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID",
|
|
7409
|
+
" token: https://dash.cloudflare.com/profile/api-tokens (template: Cloudflare Pages — Edit)"
|
|
7410
|
+
].join("\n");
|
|
7411
|
+
return "how to fix: run `bun run deploy` (without `--cli`) — the guided setup diagnoses prerequisites — then retry";
|
|
7412
|
+
}
|
|
6996
7413
|
/**
|
|
6997
7414
|
* Create the cli plugin API surface — exactly `build`, `serve`, `preview`, `deploy`.
|
|
6998
7415
|
* Each method renders `state.render.header(<command>)` first, then does its work;
|
|
@@ -7051,29 +7468,37 @@ function createApi$1(ctx) {
|
|
|
7051
7468
|
return runPreviewServer(ctx, port);
|
|
7052
7469
|
},
|
|
7053
7470
|
/**
|
|
7054
|
-
*
|
|
7055
|
-
*
|
|
7056
|
-
*
|
|
7057
|
-
* `
|
|
7058
|
-
*
|
|
7471
|
+
* Deploy to Cloudflare Pages. Two modes: `{ guided: true }` runs the interactive
|
|
7472
|
+
* setup wizard (diagnose prerequisites, guide fixes, gate, local test, deploy, offer
|
|
7473
|
+
* a CI workflow); the default direct path scaffolds, gates on a y/N confirm (shown
|
|
7474
|
+
* only to an interactive TTY with `CI` unset — non-interactive runs proceed so a
|
|
7475
|
+
* pipeline never hangs), then deploys. `options.yes` forces the skip. A "no" returns
|
|
7476
|
+
* `{ deployed: false, reason: "declined" }`; the wizard may return `"blocked"`.
|
|
7059
7477
|
*
|
|
7060
|
-
* @param options - Optional branch override and `
|
|
7061
|
-
* @returns The deploy outcome (completed details, or `declined`
|
|
7478
|
+
* @param options - Optional branch override, `yes` flag, and `guided` toggle.
|
|
7479
|
+
* @returns The deploy outcome (completed details, or a `declined`/`blocked` skip).
|
|
7062
7480
|
* @example
|
|
7063
|
-
* await api.deploy({
|
|
7481
|
+
* await api.deploy({ guided: true });
|
|
7064
7482
|
*/
|
|
7065
7483
|
async deploy(options = {}) {
|
|
7066
7484
|
const { branch, yes = false } = options;
|
|
7067
7485
|
ctx.state.render.header("deploy");
|
|
7068
|
-
|
|
7069
|
-
|
|
7070
|
-
|
|
7071
|
-
|
|
7072
|
-
|
|
7073
|
-
|
|
7074
|
-
|
|
7075
|
-
|
|
7076
|
-
|
|
7486
|
+
if (options.guided === true) return runDeployWizard(ctx, options);
|
|
7487
|
+
try {
|
|
7488
|
+
await ctx.require(deployPlugin).init({ ci: true });
|
|
7489
|
+
if (!await confirmDeploy(ctx, yes)) return {
|
|
7490
|
+
deployed: false,
|
|
7491
|
+
reason: "declined"
|
|
7492
|
+
};
|
|
7493
|
+
return {
|
|
7494
|
+
deployed: true,
|
|
7495
|
+
...await ctx.require(deployPlugin).run(branch === void 0 ? {} : { branch })
|
|
7496
|
+
};
|
|
7497
|
+
} catch (error) {
|
|
7498
|
+
ctx.state.render.error("deploy failed", error);
|
|
7499
|
+
ctx.state.render.info(deployFailureHint(error));
|
|
7500
|
+
throw error;
|
|
7501
|
+
}
|
|
7077
7502
|
}
|
|
7078
7503
|
};
|
|
7079
7504
|
}
|
|
@@ -7164,6 +7589,61 @@ const ANSI = {
|
|
|
7164
7589
|
cyan: `${ESC}[36m`,
|
|
7165
7590
|
gray: `${ESC}[90m`
|
|
7166
7591
|
};
|
|
7592
|
+
/**
|
|
7593
|
+
* The Moku brand pink (`#FF1E6F`) as an RGB triple, used for 24-bit truecolor output.
|
|
7594
|
+
* Degrades to {@link ANSI.magenta} on a 16-color TTY and to plain text off a TTY.
|
|
7595
|
+
*/
|
|
7596
|
+
const BRAND_PINK = {
|
|
7597
|
+
r: 255,
|
|
7598
|
+
g: 30,
|
|
7599
|
+
b: 111
|
|
7600
|
+
};
|
|
7601
|
+
/**
|
|
7602
|
+
* Build a 24-bit (truecolor) SGR foreground escape for the given RGB triple.
|
|
7603
|
+
*
|
|
7604
|
+
* @param r - Red channel (0–255).
|
|
7605
|
+
* @param g - Green channel (0–255).
|
|
7606
|
+
* @param b - Blue channel (0–255).
|
|
7607
|
+
* @returns The `ESC[38;2;r;g;bm` foreground sequence.
|
|
7608
|
+
* @example
|
|
7609
|
+
* fg24(255, 30, 111); // "\x1b[38;2;255;30;111m"
|
|
7610
|
+
*/
|
|
7611
|
+
function fg24(r, g, b) {
|
|
7612
|
+
return `${ESC}[38;2;${r};${g};${b}m`;
|
|
7613
|
+
}
|
|
7614
|
+
/** ANSI: erase the entire current line, leaving the cursor where it is. */
|
|
7615
|
+
const CLEAR_LINE = `${ESC}[2K`;
|
|
7616
|
+
/** ANSI: erase from the cursor to the end of the screen (drops stale trailing rows). */
|
|
7617
|
+
const CLEAR_BELOW = `${ESC}[0J`;
|
|
7618
|
+
/**
|
|
7619
|
+
* Braille spinner frames for live "working…" indicators on a TTY (advance one per tick).
|
|
7620
|
+
* Off a TTY the Panel never animates, so this is unused in plain/CI output.
|
|
7621
|
+
*/
|
|
7622
|
+
const SPINNER_FRAMES = [
|
|
7623
|
+
"⠋",
|
|
7624
|
+
"⠙",
|
|
7625
|
+
"⠹",
|
|
7626
|
+
"⠸",
|
|
7627
|
+
"⠼",
|
|
7628
|
+
"⠴",
|
|
7629
|
+
"⠦",
|
|
7630
|
+
"⠧",
|
|
7631
|
+
"⠇",
|
|
7632
|
+
"⠏"
|
|
7633
|
+
];
|
|
7634
|
+
/**
|
|
7635
|
+
* The ANSI sequence to move the cursor up `n` lines (empty string for `n <= 0`). The
|
|
7636
|
+
* Panel uses it to repaint a live block in place — move up over the previous draw, then
|
|
7637
|
+
* rewrite each row — so progress updates a fixed region instead of scrolling new lines.
|
|
7638
|
+
*
|
|
7639
|
+
* @param n - Number of lines to move the cursor up.
|
|
7640
|
+
* @returns The cursor-up escape sequence, or `""` when `n <= 0`.
|
|
7641
|
+
* @example
|
|
7642
|
+
* cursorUp(3); // "\x1b[3A"
|
|
7643
|
+
*/
|
|
7644
|
+
function cursorUp(n) {
|
|
7645
|
+
return n > 0 ? `${ESC}[${n}A` : "";
|
|
7646
|
+
}
|
|
7167
7647
|
/** Unicode rounded box glyphs used when output is a color-capable TTY. */
|
|
7168
7648
|
const UNICODE_BOX = {
|
|
7169
7649
|
topLeft: "╭",
|
|
@@ -7202,6 +7682,36 @@ function supportsColor(stream = process.stdout, noColor = process.env.NO_COLOR)
|
|
|
7202
7682
|
return stream.isTTY === true && noColor === void 0;
|
|
7203
7683
|
}
|
|
7204
7684
|
/**
|
|
7685
|
+
* Whether the terminal advertises 24-bit (truecolor) support via `COLORTERM`, so the
|
|
7686
|
+
* renderer may emit the exact brand pink ({@link BRAND_PINK}) instead of the 16-color
|
|
7687
|
+
* `magenta` approximation. Always layered on top of {@link supportsColor} — truecolor
|
|
7688
|
+
* is never used when color itself is disabled.
|
|
7689
|
+
*
|
|
7690
|
+
* @param colorTerm - The `COLORTERM` value (defaults to `process.env.COLORTERM`).
|
|
7691
|
+
* @returns `true` when `COLORTERM` is `truecolor` or `24bit`.
|
|
7692
|
+
* @example
|
|
7693
|
+
* supportsTruecolor("truecolor"); // true
|
|
7694
|
+
*/
|
|
7695
|
+
function supportsTruecolor(colorTerm = process.env.COLORTERM) {
|
|
7696
|
+
return colorTerm === "truecolor" || colorTerm === "24bit";
|
|
7697
|
+
}
|
|
7698
|
+
/**
|
|
7699
|
+
* The braille spinner glyph for a given elapsed time, advancing one frame per
|
|
7700
|
+
* `frameMs`. Deriving the frame from wall-clock elapsed (rather than a tick counter)
|
|
7701
|
+
* keeps the spinner correct even when the animation ticker is briefly starved by a
|
|
7702
|
+
* synchronous build phase and several ticks coalesce — the glyph still reflects real
|
|
7703
|
+
* elapsed time instead of freezing on a stale frame.
|
|
7704
|
+
*
|
|
7705
|
+
* @param elapsedMs - Milliseconds since the live region opened.
|
|
7706
|
+
* @param frameMs - Milliseconds per frame (defaults to `80`).
|
|
7707
|
+
* @returns The active spinner glyph.
|
|
7708
|
+
* @example
|
|
7709
|
+
* spinnerFrameAt(240); // "⠹" (the 4th frame at 80ms/frame)
|
|
7710
|
+
*/
|
|
7711
|
+
function spinnerFrameAt(elapsedMs, frameMs = 80) {
|
|
7712
|
+
return SPINNER_FRAMES[Math.floor(Math.max(0, elapsedMs) / frameMs) % SPINNER_FRAMES.length] ?? "⠋";
|
|
7713
|
+
}
|
|
7714
|
+
/**
|
|
7205
7715
|
* Select the box glyph set for the given color mode (Unicode on a TTY, ASCII off it).
|
|
7206
7716
|
*
|
|
7207
7717
|
* @param color - Whether color/Unicode output is enabled.
|
|
@@ -7229,12 +7739,14 @@ function visibleWidth(text) {
|
|
|
7229
7739
|
* output in CI/pipes.
|
|
7230
7740
|
*
|
|
7231
7741
|
* @param color - Whether color is enabled (typically `supportsColor()`).
|
|
7742
|
+
* @param truecolor - Whether 24-bit output is enabled (typically `supportsTruecolor()`);
|
|
7743
|
+
* only consulted by {@link Palette.pink}. Defaults to `false` (16-color magenta).
|
|
7232
7744
|
* @returns The bound color palette.
|
|
7233
7745
|
* @example
|
|
7234
|
-
* const palette = makePalette(supportsColor());
|
|
7746
|
+
* const palette = makePalette(supportsColor(), supportsTruecolor());
|
|
7235
7747
|
* const line = palette.green("done");
|
|
7236
7748
|
*/
|
|
7237
|
-
function makePalette(color) {
|
|
7749
|
+
function makePalette(color, truecolor = false) {
|
|
7238
7750
|
return {
|
|
7239
7751
|
enabled: color,
|
|
7240
7752
|
/**
|
|
@@ -7314,23 +7826,39 @@ function makePalette(color) {
|
|
|
7314
7826
|
*/
|
|
7315
7827
|
cyan(text) {
|
|
7316
7828
|
return this.paint(ANSI.cyan, text);
|
|
7829
|
+
},
|
|
7830
|
+
/**
|
|
7831
|
+
* Color the given text the Moku brand pink: exact `#FF1E6F` (24-bit) when truecolor
|
|
7832
|
+
* is enabled, the 16-color `magenta` approximation otherwise, unchanged in plain mode.
|
|
7833
|
+
*
|
|
7834
|
+
* @param text - The text to colorize.
|
|
7835
|
+
* @returns The pink (or unchanged) text.
|
|
7836
|
+
* @example
|
|
7837
|
+
* palette.pink("▟▙ moku web");
|
|
7838
|
+
*/
|
|
7839
|
+
pink(text) {
|
|
7840
|
+
if (!color) return text;
|
|
7841
|
+
if (truecolor) return `${fg24(BRAND_PINK.r, BRAND_PINK.g, BRAND_PINK.b)}${text}${ANSI.reset}`;
|
|
7842
|
+
return this.paint(ANSI.magenta, text);
|
|
7317
7843
|
}
|
|
7318
7844
|
};
|
|
7319
7845
|
}
|
|
7320
7846
|
/**
|
|
7321
7847
|
* Frame a list of already-rendered content lines in a box, padding each line to the
|
|
7322
|
-
*
|
|
7323
|
-
*
|
|
7848
|
+
* widest visible line (or `minInnerWidth`, whichever is larger — so several boxes can be
|
|
7849
|
+
* forced to a shared width). Uses Unicode borders when `color` is enabled and ASCII
|
|
7850
|
+
* otherwise. Visible width ignores embedded ANSI so colored lines align.
|
|
7324
7851
|
*
|
|
7325
7852
|
* @param lines - The content lines (may contain ANSI color codes).
|
|
7326
7853
|
* @param color - Whether to use Unicode borders (and assume color-capable output).
|
|
7854
|
+
* @param minInnerWidth - Minimum inner (content) width to pad every row to. Defaults to `0`.
|
|
7327
7855
|
* @returns The boxed lines (top border, content rows, bottom border).
|
|
7328
7856
|
* @example
|
|
7329
|
-
* box(["Local: http://localhost:4173"], true);
|
|
7857
|
+
* box(["Local: http://localhost:4173"], true, 62);
|
|
7330
7858
|
*/
|
|
7331
|
-
function box(lines, color) {
|
|
7859
|
+
function box(lines, color, minInnerWidth = 0) {
|
|
7332
7860
|
const glyphs = boxGlyphs(color);
|
|
7333
|
-
const inner = Math.max(0, ...lines.map((line) => visibleWidth(line)));
|
|
7861
|
+
const inner = Math.max(0, minInnerWidth, ...lines.map((line) => visibleWidth(line)));
|
|
7334
7862
|
const horizontal = glyphs.horizontal.repeat(inner + 2);
|
|
7335
7863
|
const top = `${glyphs.topLeft}${horizontal}${glyphs.topRight}`;
|
|
7336
7864
|
const bottom = `${glyphs.bottomLeft}${horizontal}${glyphs.bottomRight}`;
|
|
@@ -7345,13 +7873,70 @@ function box(lines, color) {
|
|
|
7345
7873
|
}
|
|
7346
7874
|
//#endregion
|
|
7347
7875
|
//#region src/plugins/cli/render/panel.ts
|
|
7348
|
-
/** Per-command label shown
|
|
7876
|
+
/** Per-command label shown beside the lockup wordmark. */
|
|
7349
7877
|
const COMMAND_LABEL = {
|
|
7350
7878
|
build: "build",
|
|
7351
7879
|
serve: "serve · dev",
|
|
7352
7880
|
preview: "preview",
|
|
7353
7881
|
deploy: "deploy"
|
|
7354
7882
|
};
|
|
7883
|
+
/** Total visible width the header rule spans and the per-row timing column right-aligns to. */
|
|
7884
|
+
const RAIL_WIDTH = 66;
|
|
7885
|
+
/** Animation repaint cadence (ms) — how often the live region is redrawn when the loop is free. */
|
|
7886
|
+
const TICK_MS = 40;
|
|
7887
|
+
/** Spinner frame interval (ms) — one braille glyph advance per this many elapsed ms. */
|
|
7888
|
+
const SPIN_MS = 60;
|
|
7889
|
+
/** Inner (content) width of the BUILD/server boxes so their right edge lines up with the phase tree. */
|
|
7890
|
+
const BOX_INNER = RAIL_WIDTH - 4;
|
|
7891
|
+
/** The eight block glyphs the per-phase time-profile sparkline maps durations onto. */
|
|
7892
|
+
const SPARK_BARS = "▁▂▃▄▅▆▇█";
|
|
7893
|
+
/**
|
|
7894
|
+
* Build a sparkline from a list of values — one block glyph per value, height scaled to
|
|
7895
|
+
* the largest value so the tallest bar is `█`. A real micro-histogram (no fake data):
|
|
7896
|
+
* under the BUILD summary each bar is one phase's duration, so the slowest phase stands
|
|
7897
|
+
* out at a glance. Returns `""` for an empty list.
|
|
7898
|
+
*
|
|
7899
|
+
* @param values - The values to plot (e.g. per-phase durations in ms).
|
|
7900
|
+
* @returns The sparkline string.
|
|
7901
|
+
* @example
|
|
7902
|
+
* sparkline([12, 1701, 19698, 9]); // "▁▁█▁"
|
|
7903
|
+
*/
|
|
7904
|
+
function sparkline(values) {
|
|
7905
|
+
if (values.length === 0) return "";
|
|
7906
|
+
const max = Math.max(...values, 1);
|
|
7907
|
+
return values.map((value) => {
|
|
7908
|
+
return SPARK_BARS[Math.min(7, Math.floor(value / max * 7))] ?? SPARK_BARS[0];
|
|
7909
|
+
}).join("");
|
|
7910
|
+
}
|
|
7911
|
+
/**
|
|
7912
|
+
* The structural glyph set for the active color mode: Unicode on a color-capable TTY,
|
|
7913
|
+
* ASCII fallbacks off it. Only the NEW Velocity chrome (cube, rule, tree, bar, live
|
|
7914
|
+
* dot) degrades here — the `✓ ✗ ~ ➜ ›` status marks stay as-is in both modes.
|
|
7915
|
+
*
|
|
7916
|
+
* @param color - Whether color/Unicode output is enabled.
|
|
7917
|
+
* @returns The matching glyph set.
|
|
7918
|
+
* @example
|
|
7919
|
+
* const g = glyphSet(true);
|
|
7920
|
+
*/
|
|
7921
|
+
function glyphSet(color) {
|
|
7922
|
+
return color ? {
|
|
7923
|
+
cube: "▟▙",
|
|
7924
|
+
rule: "─",
|
|
7925
|
+
tree: "├─",
|
|
7926
|
+
barFill: "━",
|
|
7927
|
+
barTrack: "╴",
|
|
7928
|
+
liveOn: "◍",
|
|
7929
|
+
liveOff: "○"
|
|
7930
|
+
} : {
|
|
7931
|
+
cube: "*",
|
|
7932
|
+
rule: "-",
|
|
7933
|
+
tree: "-",
|
|
7934
|
+
barFill: "#",
|
|
7935
|
+
barTrack: "-",
|
|
7936
|
+
liveOn: "*",
|
|
7937
|
+
liveOff: "*"
|
|
7938
|
+
};
|
|
7939
|
+
}
|
|
7355
7940
|
/**
|
|
7356
7941
|
* Render one human-readable duration suffix (e.g. `· 84ms`).
|
|
7357
7942
|
*
|
|
@@ -7366,22 +7951,177 @@ function durationSuffix(palette, durationMs) {
|
|
|
7366
7951
|
return ` ${palette.dim(`· ${durationMs}ms`)}`;
|
|
7367
7952
|
}
|
|
7368
7953
|
/**
|
|
7954
|
+
* Right-align `right` against `left` within {@link RAIL_WIDTH}, measuring visible width
|
|
7955
|
+
* so embedded ANSI never throws the timing column off.
|
|
7956
|
+
*
|
|
7957
|
+
* @param left - The left segment (may contain ANSI).
|
|
7958
|
+
* @param right - The right segment (may contain ANSI).
|
|
7959
|
+
* @param width - Total visible width to fill (defaults to {@link RAIL_WIDTH}).
|
|
7960
|
+
* @returns The padded line.
|
|
7961
|
+
* @example
|
|
7962
|
+
* railLine(" ├─ ✓ pages", "· 12ms");
|
|
7963
|
+
*/
|
|
7964
|
+
function railLine(left, right, width = RAIL_WIDTH) {
|
|
7965
|
+
const gap = Math.max(1, width - visibleWidth(left) - visibleWidth(right));
|
|
7966
|
+
return `${left}${" ".repeat(gap)}${right}`;
|
|
7967
|
+
}
|
|
7968
|
+
/**
|
|
7969
|
+
* The runtime facts line shown under the banner: the pinned core version (when known)
|
|
7970
|
+
* plus the live Node/Bun versions + platform — the ACTUAL running runtime, not the
|
|
7971
|
+
* `engines` floor. Every value is real (read from `@moku-labs/core`'s pinned dependency
|
|
7972
|
+
* and `process.versions`), so nothing on this line is faked.
|
|
7973
|
+
*
|
|
7974
|
+
* @param coreVersion - The pinned `@moku-labs/core` version (appended last — it rarely
|
|
7975
|
+
* matters — and omitted entirely when unknown).
|
|
7976
|
+
* @returns The facts string (e.g. `node 24.3.0 · bun 1.3.9 · darwin arm64 · core 0.1.0-alpha.6`).
|
|
7977
|
+
* @example
|
|
7978
|
+
* runtimeFacts("0.1.0-alpha.6");
|
|
7979
|
+
*/
|
|
7980
|
+
function runtimeFacts(coreVersion) {
|
|
7981
|
+
const node = `node ${process.versions.node}`;
|
|
7982
|
+
const bun = process.versions.bun ? ` · bun ${process.versions.bun}` : "";
|
|
7983
|
+
const core = coreVersion ? ` · core ${coreVersion}` : "";
|
|
7984
|
+
return `${node}${bun} · ${process.platform} ${process.arch}${core}`;
|
|
7985
|
+
}
|
|
7986
|
+
/**
|
|
7369
7987
|
* Create the Panel {@link CliRenderer}. Output is written through the injected sink
|
|
7370
|
-
* (default `console.log`/`console.error`) and colorized only when color is enabled,
|
|
7371
|
-
*
|
|
7372
|
-
* ASCII lines in CI/pipes.
|
|
7988
|
+
* (default `console.log`/`console.error`) and colorized only when color is enabled, so
|
|
7989
|
+
* the identical render path yields the animated, box-free Velocity UI on a TTY and
|
|
7990
|
+
* plain ASCII lines in CI/pipes.
|
|
7373
7991
|
*
|
|
7374
|
-
* @param options - Optional sinks
|
|
7992
|
+
* @param options - Optional sinks, color/truecolor overrides, clock, and version (see
|
|
7993
|
+
* {@link PanelOptions}).
|
|
7375
7994
|
* @returns The renderer mounted on `state.render` and driven by the API + hooks.
|
|
7376
7995
|
* @example
|
|
7377
|
-
* const render = createPanelRenderer();
|
|
7996
|
+
* const render = createPanelRenderer({ version: "0.1.0-alpha" });
|
|
7378
7997
|
* render.header("build");
|
|
7379
7998
|
*/
|
|
7380
7999
|
function createPanelRenderer(options = {}) {
|
|
7381
8000
|
const write = options.write ?? ((line) => console.log(line));
|
|
7382
8001
|
const writeError = options.writeError ?? ((line) => console.error(line));
|
|
8002
|
+
const writeRaw = options.writeRaw ?? ((chunk) => {
|
|
8003
|
+
process.stdout.write(chunk);
|
|
8004
|
+
});
|
|
8005
|
+
const now = options.now ?? Date.now;
|
|
7383
8006
|
const color = options.color ?? supportsColor();
|
|
7384
|
-
const palette = makePalette(color);
|
|
8007
|
+
const palette = makePalette(color, options.truecolor ?? (color && supportsTruecolor()));
|
|
8008
|
+
const version = options.version ?? "dev";
|
|
8009
|
+
const coreVersion = options.coreVersion;
|
|
8010
|
+
const g = glyphSet(color);
|
|
8011
|
+
let phaseRows = [];
|
|
8012
|
+
let phaseDrawn = 0;
|
|
8013
|
+
let phaseOpen = false;
|
|
8014
|
+
let blockStartedAt = 0;
|
|
8015
|
+
let rebuilding = false;
|
|
8016
|
+
let rebuildLabel = "";
|
|
8017
|
+
let rebuildStartedAt = 0;
|
|
8018
|
+
let idle = false;
|
|
8019
|
+
let idleStartedAt = 0;
|
|
8020
|
+
let serveMode = false;
|
|
8021
|
+
let ticker;
|
|
8022
|
+
/**
|
|
8023
|
+
* Render one phase-tree row: a spinning cyan glyph + dim name while running, or a green
|
|
8024
|
+
* `✓` + name with the duration right-aligned in the dim timing column once done.
|
|
8025
|
+
*
|
|
8026
|
+
* @param row - The phase row to render.
|
|
8027
|
+
* @returns The rendered row line (no trailing newline).
|
|
8028
|
+
* @example
|
|
8029
|
+
* renderPhaseRow({ name: "pages", done: true, durationMs: 12 });
|
|
8030
|
+
*/
|
|
8031
|
+
const renderPhaseRow = (row) => {
|
|
8032
|
+
const branch = palette.dim(g.tree);
|
|
8033
|
+
if (row.done) return railLine(` ${branch} ${palette.green("✓")} ${row.name}`, palette.dim(`· ${row.durationMs}ms`));
|
|
8034
|
+
return ` ${branch} ${palette.cyan(spinnerFrameAt(now() - blockStartedAt, SPIN_MS))} ${palette.dim(row.name)}`;
|
|
8035
|
+
};
|
|
8036
|
+
/**
|
|
8037
|
+
* Render the indeterminate "comet" build bar — a short pink fill window sweeping across
|
|
8038
|
+
* a dim track — for the given elapsed time. Animated purely from wall-clock elapsed so
|
|
8039
|
+
* it never needs a known phase total.
|
|
8040
|
+
*
|
|
8041
|
+
* @param elapsedMs - Milliseconds since the phase block opened.
|
|
8042
|
+
* @returns The rendered bar row (no trailing newline).
|
|
8043
|
+
* @example
|
|
8044
|
+
* renderBuildBar(300);
|
|
8045
|
+
*/
|
|
8046
|
+
const renderBuildBar = (elapsedMs) => {
|
|
8047
|
+
const length = 28;
|
|
8048
|
+
const window = 6;
|
|
8049
|
+
const head = Math.floor(elapsedMs / 28) % 34;
|
|
8050
|
+
let bar = "";
|
|
8051
|
+
for (let index = 0; index < length; index++) {
|
|
8052
|
+
const lit = index <= head && index > head - window;
|
|
8053
|
+
bar += lit ? palette.pink(g.barFill) : palette.dim(g.barTrack);
|
|
8054
|
+
}
|
|
8055
|
+
return ` ${bar}`;
|
|
8056
|
+
};
|
|
8057
|
+
/**
|
|
8058
|
+
* Repaint the live phase block in place (tree rows + animated build bar): move up over
|
|
8059
|
+
* the prior draw, rewrite each row, then the bar, clearing any stale trailing lines.
|
|
8060
|
+
*
|
|
8061
|
+
* @example
|
|
8062
|
+
* paintPhaseBlock();
|
|
8063
|
+
*/
|
|
8064
|
+
const paintPhaseBlock = () => {
|
|
8065
|
+
let frame = cursorUp(phaseDrawn);
|
|
8066
|
+
for (const row of phaseRows) frame += `${CLEAR_LINE}${renderPhaseRow(row)}\n`;
|
|
8067
|
+
frame += `${CLEAR_LINE}${renderBuildBar(now() - blockStartedAt)}\n`;
|
|
8068
|
+
writeRaw(frame + CLEAR_BELOW);
|
|
8069
|
+
phaseDrawn = phaseRows.length + 1;
|
|
8070
|
+
};
|
|
8071
|
+
/**
|
|
8072
|
+
* Repaint the single in-place rebuild line (spinner + label + live elapsed seconds).
|
|
8073
|
+
*
|
|
8074
|
+
* @example
|
|
8075
|
+
* paintRebuildLine();
|
|
8076
|
+
*/
|
|
8077
|
+
const paintRebuildLine = () => {
|
|
8078
|
+
const spinner = palette.cyan(spinnerFrameAt(now() - rebuildStartedAt, SPIN_MS));
|
|
8079
|
+
const elapsed = palette.dim(`· ${((now() - rebuildStartedAt) / 1e3).toFixed(1)}s`);
|
|
8080
|
+
writeRaw(`\r${CLEAR_LINE} ${spinner} rebuilding ${rebuildLabel} ${elapsed}`);
|
|
8081
|
+
};
|
|
8082
|
+
/**
|
|
8083
|
+
* Repaint the persistent in-place `◍ live` idle pulse beneath the serve panel — the
|
|
8084
|
+
* dot breathes (pink → dim) on a calm ~0.6s cycle so a quiet dev session always reads
|
|
8085
|
+
* as alive without strobing.
|
|
8086
|
+
*
|
|
8087
|
+
* @example
|
|
8088
|
+
* paintIdleLine();
|
|
8089
|
+
*/
|
|
8090
|
+
const paintIdleLine = () => {
|
|
8091
|
+
writeRaw(`\r${CLEAR_LINE} ${Math.floor((now() - idleStartedAt) / 450) % 2 === 0 ? palette.pink(g.liveOn) : palette.dim(g.liveOff)} ${palette.dim("live · waiting for changes…")}`);
|
|
8092
|
+
};
|
|
8093
|
+
/**
|
|
8094
|
+
* Advance whichever live region is active by one frame (driven by the shared ticker).
|
|
8095
|
+
*
|
|
8096
|
+
* @example
|
|
8097
|
+
* onTick();
|
|
8098
|
+
*/
|
|
8099
|
+
const onTick = () => {
|
|
8100
|
+
if (rebuilding) paintRebuildLine();
|
|
8101
|
+
else if (phaseOpen) paintPhaseBlock();
|
|
8102
|
+
else if (idle) paintIdleLine();
|
|
8103
|
+
};
|
|
8104
|
+
/**
|
|
8105
|
+
* Start the animation ticker (TTY only; idempotent; `unref`'d so it never blocks exit).
|
|
8106
|
+
*
|
|
8107
|
+
* @example
|
|
8108
|
+
* startTicker();
|
|
8109
|
+
*/
|
|
8110
|
+
const startTicker = () => {
|
|
8111
|
+
if (!color || ticker) return;
|
|
8112
|
+
ticker = setInterval(onTick, TICK_MS);
|
|
8113
|
+
ticker.unref?.();
|
|
8114
|
+
};
|
|
8115
|
+
/**
|
|
8116
|
+
* Stop the animation ticker if running.
|
|
8117
|
+
*
|
|
8118
|
+
* @example
|
|
8119
|
+
* stopTicker();
|
|
8120
|
+
*/
|
|
8121
|
+
const stopTicker = () => {
|
|
8122
|
+
if (ticker) clearInterval(ticker);
|
|
8123
|
+
ticker = void 0;
|
|
8124
|
+
};
|
|
7385
8125
|
/**
|
|
7386
8126
|
* Write each line of a multi-line block through the stdout sink.
|
|
7387
8127
|
*
|
|
@@ -7392,82 +8132,184 @@ function createPanelRenderer(options = {}) {
|
|
|
7392
8132
|
const writeBlock = (lines) => {
|
|
7393
8133
|
for (const line of lines) write(line);
|
|
7394
8134
|
};
|
|
8135
|
+
/**
|
|
8136
|
+
* Resume the serve idle pulse on a fresh bottom line (TTY serve sessions only). A no-op
|
|
8137
|
+
* outside serve so standalone rebuild/error calls in unit tests never leave a ticker
|
|
8138
|
+
* running.
|
|
8139
|
+
*
|
|
8140
|
+
* @example
|
|
8141
|
+
* resumeIdle();
|
|
8142
|
+
*/
|
|
8143
|
+
const resumeIdle = () => {
|
|
8144
|
+
if (!(color && serveMode)) {
|
|
8145
|
+
stopTicker();
|
|
8146
|
+
return;
|
|
8147
|
+
}
|
|
8148
|
+
idle = true;
|
|
8149
|
+
idleStartedAt = now();
|
|
8150
|
+
paintIdleLine();
|
|
8151
|
+
startTicker();
|
|
8152
|
+
};
|
|
7395
8153
|
return {
|
|
7396
8154
|
/**
|
|
7397
|
-
* Render the
|
|
8155
|
+
* Render the `▟▙ moku web` lockup + per-command label, a dim rule, and the runtime
|
|
8156
|
+
* facts line (live Node/Bun versions + platform). Called once per command (one
|
|
8157
|
+
* command = one process), so it never repeats within a run.
|
|
7398
8158
|
*
|
|
7399
|
-
* @param command - The command being run, shown beside the
|
|
8159
|
+
* @param command - The command being run, shown beside the wordmark.
|
|
7400
8160
|
* @example
|
|
7401
8161
|
* render.header("serve");
|
|
7402
8162
|
*/
|
|
7403
8163
|
header(command) {
|
|
7404
|
-
writeBlock(
|
|
8164
|
+
writeBlock([
|
|
8165
|
+
railLine(` ${palette.pink(g.cube)} ${palette.pink(palette.bold("moku web"))} ${palette.dim(COMMAND_LABEL[command])}`, palette.dim(version)),
|
|
8166
|
+
` ${palette.dim(g.rule.repeat(RAIL_WIDTH - 1))}`,
|
|
8167
|
+
` ${palette.dim(runtimeFacts(coreVersion))}`
|
|
8168
|
+
]);
|
|
7405
8169
|
},
|
|
7406
8170
|
/**
|
|
7407
|
-
* Render a live per-phase row from a `build:phase` event.
|
|
8171
|
+
* Render a live per-phase row from a `build:phase` event. On a TTY each phase is ONE
|
|
8172
|
+
* tree row that updates in place (spinning glyph while running → green ✓ + duration
|
|
8173
|
+
* when done) beneath an animated indeterminate build bar; off a TTY one line is
|
|
8174
|
+
* printed per completed phase (no start/done duplication). A no-op while a serve()
|
|
8175
|
+
* rebuild is in flight — those show the compact rebuild line.
|
|
7408
8176
|
*
|
|
7409
8177
|
* @param phase - The `build:phase` payload.
|
|
7410
8178
|
* @example
|
|
7411
8179
|
* render.phase({ phase: "pages", status: "done", durationMs: 12 });
|
|
7412
8180
|
*/
|
|
7413
8181
|
phase(phase) {
|
|
8182
|
+
if (rebuilding) return;
|
|
8183
|
+
if (!color) {
|
|
8184
|
+
if (phase.status === "done") write(` ${palette.green("✓")} ${phase.phase}${durationSuffix(palette, phase.durationMs)}`);
|
|
8185
|
+
return;
|
|
8186
|
+
}
|
|
8187
|
+
if (!phaseOpen) {
|
|
8188
|
+
phaseRows = [];
|
|
8189
|
+
phaseDrawn = 0;
|
|
8190
|
+
phaseOpen = true;
|
|
8191
|
+
blockStartedAt = now();
|
|
8192
|
+
}
|
|
7414
8193
|
const done = phase.status === "done";
|
|
7415
|
-
|
|
8194
|
+
const existing = phaseRows.find((row) => row.name === phase.phase);
|
|
8195
|
+
if (existing) {
|
|
8196
|
+
existing.done = done;
|
|
8197
|
+
existing.durationMs = phase.durationMs;
|
|
8198
|
+
} else phaseRows.push({
|
|
8199
|
+
name: phase.phase,
|
|
8200
|
+
done,
|
|
8201
|
+
durationMs: phase.durationMs
|
|
8202
|
+
});
|
|
8203
|
+
paintPhaseBlock();
|
|
8204
|
+
startTicker();
|
|
7416
8205
|
},
|
|
7417
8206
|
/**
|
|
7418
|
-
* Render the BUILD summary
|
|
8207
|
+
* Render the BUILD summary line + a one-shot throughput sparkline from a
|
|
8208
|
+
* `build:complete` event, finalizing the live phase tree (dropping its animated bar)
|
|
8209
|
+
* first.
|
|
7419
8210
|
*
|
|
7420
8211
|
* @param summary - The `build:complete` payload.
|
|
7421
8212
|
* @example
|
|
7422
8213
|
* render.built({ outDir: "dist", pageCount: 12, durationMs: 840 });
|
|
7423
8214
|
*/
|
|
7424
8215
|
built(summary) {
|
|
8216
|
+
if (rebuilding) return;
|
|
8217
|
+
if (color && phaseOpen) {
|
|
8218
|
+
let frame = cursorUp(phaseDrawn);
|
|
8219
|
+
for (const row of phaseRows) frame += `${CLEAR_LINE}${renderPhaseRow(row)}\n`;
|
|
8220
|
+
writeRaw(frame + CLEAR_BELOW);
|
|
8221
|
+
}
|
|
8222
|
+
const phaseDurations = phaseRows.map((row) => row.durationMs).filter((value) => value !== void 0);
|
|
8223
|
+
phaseOpen = false;
|
|
8224
|
+
phaseDrawn = 0;
|
|
8225
|
+
stopTicker();
|
|
7425
8226
|
const pages = palette.bold(String(summary.pageCount));
|
|
7426
|
-
|
|
7427
|
-
|
|
7428
|
-
|
|
7429
|
-
|
|
7430
|
-
|
|
7431
|
-
|
|
8227
|
+
const dot = palette.dim("·");
|
|
8228
|
+
const lines = [railLine(`${palette.green("✓")} ${palette.bold("BUILD")} ${dot} ${pages} pages`, `${summary.durationMs}ms ${dot} ${summary.outDir}/`, BOX_INNER)];
|
|
8229
|
+
if (color && summary.durationMs > 0) {
|
|
8230
|
+
const rate = Math.max(1, Math.round(summary.pageCount / (summary.durationMs / 1e3)));
|
|
8231
|
+
const spark = phaseDurations.length > 0 ? palette.pink(sparkline(phaseDurations)) : "";
|
|
8232
|
+
const rateLabel = palette.dim(`${rate} pages/s`);
|
|
8233
|
+
lines.push(railLine(spark, rateLabel, BOX_INNER));
|
|
8234
|
+
}
|
|
8235
|
+
writeBlock(box(lines, color, BOX_INNER));
|
|
7432
8236
|
},
|
|
7433
8237
|
/**
|
|
7434
|
-
* Render the
|
|
8238
|
+
* Render the server-ready rail (Local / Network URLs + watched dirs) and, on a TTY,
|
|
8239
|
+
* begin the persistent breathing `◍ live` idle pulse beneath it.
|
|
7435
8240
|
*
|
|
7436
8241
|
* @param info - Local/Network URLs and optionally the watched directories.
|
|
7437
8242
|
* @example
|
|
7438
8243
|
* render.serverReady({ local: "http://localhost:4173", network: null });
|
|
7439
8244
|
*/
|
|
7440
8245
|
serverReady(info) {
|
|
7441
|
-
const
|
|
7442
|
-
|
|
7443
|
-
|
|
8246
|
+
const network = info.network ? palette.cyan(info.network) : palette.dim("unavailable");
|
|
8247
|
+
const lines = [`${palette.green("➜")} ${palette.bold("Local")} ${palette.cyan(info.local)}`, `${palette.green("➜")} ${palette.bold("Network")} ${network}`];
|
|
8248
|
+
if (info.watching && info.watching.length > 0) lines.push(`${palette.dim("watching")} ${palette.dim(info.watching.join(", "))}`);
|
|
8249
|
+
writeBlock(box(lines, color, BOX_INNER));
|
|
8250
|
+
if (color) {
|
|
8251
|
+
serveMode = true;
|
|
8252
|
+
idle = true;
|
|
8253
|
+
idleStartedAt = now();
|
|
8254
|
+
paintIdleLine();
|
|
8255
|
+
startTicker();
|
|
8256
|
+
}
|
|
7444
8257
|
},
|
|
7445
8258
|
/**
|
|
7446
|
-
*
|
|
8259
|
+
* Begin a serve() rebuild: show ONE compact "rebuilding {label}" line (an animated
|
|
8260
|
+
* spinner with live elapsed on a TTY; a plain "~ {label}" line otherwise), taking over
|
|
8261
|
+
* the idle-pulse line, and mute the verbose phase tree + BUILD summary until
|
|
8262
|
+
* {@link reload}/{@link error} settles it.
|
|
8263
|
+
*
|
|
8264
|
+
* @param label - The changed watch target shown in the line.
|
|
8265
|
+
* @example
|
|
8266
|
+
* render.rebuildStart("content");
|
|
8267
|
+
*/
|
|
8268
|
+
rebuildStart(label) {
|
|
8269
|
+
rebuilding = true;
|
|
8270
|
+
idle = false;
|
|
8271
|
+
rebuildLabel = label;
|
|
8272
|
+
rebuildStartedAt = now();
|
|
8273
|
+
if (!color) {
|
|
8274
|
+
write(` ${palette.yellow("~")} ${label}`);
|
|
8275
|
+
return;
|
|
8276
|
+
}
|
|
8277
|
+
paintRebuildLine();
|
|
8278
|
+
startTicker();
|
|
8279
|
+
},
|
|
8280
|
+
/**
|
|
8281
|
+
* Settle the current rebuild: replace the in-place "rebuilding…" line with a compact
|
|
8282
|
+
* "✓ rebuilt N pages · Xms · reloaded", then (in a serve session) resume the idle pulse
|
|
8283
|
+
* on a fresh bottom line. Called standalone (no preceding {@link rebuildStart}) it also
|
|
8284
|
+
* prints the "~ file" line so the changed target stays visible.
|
|
7447
8285
|
*
|
|
7448
8286
|
* @param info - The changed file plus the rebuild's page count and duration.
|
|
7449
8287
|
* @example
|
|
7450
8288
|
* render.reload({ file: "content/a.md", pageCount: 12, durationMs: 84 });
|
|
7451
8289
|
*/
|
|
7452
8290
|
reload(info) {
|
|
7453
|
-
|
|
7454
|
-
|
|
8291
|
+
const settledRebuild = rebuilding;
|
|
8292
|
+
rebuilding = false;
|
|
8293
|
+
const line = ` ${palette.green("✓")} rebuilt ${palette.bold(String(info.pageCount))} pages ${palette.dim(`· ${info.durationMs}ms · reloaded`)}`;
|
|
8294
|
+
if (settledRebuild && color) {
|
|
8295
|
+
writeRaw(`\r${CLEAR_LINE}${line}\n`);
|
|
8296
|
+
resumeIdle();
|
|
8297
|
+
return;
|
|
8298
|
+
}
|
|
8299
|
+
if (!settledRebuild) write(` ${palette.yellow("~")} ${info.file}`);
|
|
8300
|
+
write(line);
|
|
7455
8301
|
},
|
|
7456
8302
|
/**
|
|
7457
|
-
* Render the deploy result
|
|
8303
|
+
* Render the deploy result from a `deploy:complete` event: a `✓ DEPLOYED → url` line
|
|
8304
|
+
* with the URL the hero value, then a dim `branch · id · time` line beneath it.
|
|
7458
8305
|
*
|
|
7459
8306
|
* @param result - The `deploy:complete` payload.
|
|
7460
8307
|
* @example
|
|
7461
8308
|
* render.deployed({ url: "https://x.pages.dev", deploymentId: "id", branch: "main", durationMs: 1200 });
|
|
7462
8309
|
*/
|
|
7463
8310
|
deployed(result) {
|
|
7464
|
-
|
|
7465
|
-
|
|
7466
|
-
`${palette.dim("url")} ${palette.cyan(result.url)}`,
|
|
7467
|
-
`${palette.dim("branch")} ${result.branch}`,
|
|
7468
|
-
`${palette.dim("id")} ${result.deploymentId}`,
|
|
7469
|
-
`${palette.dim("time")} ${result.durationMs}ms`
|
|
7470
|
-
], color));
|
|
8311
|
+
const meta = palette.dim(`branch ${result.branch} · ${result.deploymentId} · ${result.durationMs}ms`);
|
|
8312
|
+
writeBlock([` ${palette.green("✓")} ${palette.bold("DEPLOYED")} ${palette.dim("→")} ${palette.cyan(result.url)}`, ` ${meta}`]);
|
|
7471
8313
|
},
|
|
7472
8314
|
/**
|
|
7473
8315
|
* Render a neutral informational line.
|
|
@@ -7477,7 +8319,9 @@ function createPanelRenderer(options = {}) {
|
|
|
7477
8319
|
* render.info("watching for changes…");
|
|
7478
8320
|
*/
|
|
7479
8321
|
info(message) {
|
|
7480
|
-
|
|
8322
|
+
const [first = "", ...rest] = message.split("\n");
|
|
8323
|
+
write(` ${palette.cyan("›")} ${first}`);
|
|
8324
|
+
for (const line of rest) write(` ${line}`);
|
|
7481
8325
|
},
|
|
7482
8326
|
/**
|
|
7483
8327
|
* Render a warning line (to stderr).
|
|
@@ -7490,7 +8334,8 @@ function createPanelRenderer(options = {}) {
|
|
|
7490
8334
|
writeError(` ${palette.yellow("⚠")} ${message}`);
|
|
7491
8335
|
},
|
|
7492
8336
|
/**
|
|
7493
|
-
* Render an error line (to stderr), optionally with a cause.
|
|
8337
|
+
* Render an error line (to stderr), optionally with a cause. A failing rebuild settles
|
|
8338
|
+
* its in-place spinner line first; in a serve session the idle pulse then resumes.
|
|
7494
8339
|
*
|
|
7495
8340
|
* @param message - The error summary to print.
|
|
7496
8341
|
* @param cause - Optional underlying error/value to print beneath the summary.
|
|
@@ -7498,8 +8343,53 @@ function createPanelRenderer(options = {}) {
|
|
|
7498
8343
|
* render.error("build failed", err);
|
|
7499
8344
|
*/
|
|
7500
8345
|
error(message, cause) {
|
|
8346
|
+
const wasRebuilding = rebuilding;
|
|
8347
|
+
if (rebuilding) {
|
|
8348
|
+
rebuilding = false;
|
|
8349
|
+
if (color) writeRaw(`\r${CLEAR_LINE}`);
|
|
8350
|
+
}
|
|
7501
8351
|
writeError(` ${palette.red("✗")} ${message}`);
|
|
7502
8352
|
if (cause !== void 0) writeError(String(cause));
|
|
8353
|
+
if (wasRebuilding) resumeIdle();
|
|
8354
|
+
else stopTicker();
|
|
8355
|
+
},
|
|
8356
|
+
/**
|
|
8357
|
+
* Render a section heading (a blank line + a bold pink label) for a multi-step flow.
|
|
8358
|
+
*
|
|
8359
|
+
* @param text - The heading label.
|
|
8360
|
+
* @example
|
|
8361
|
+
* render.heading("Diagnostics");
|
|
8362
|
+
*/
|
|
8363
|
+
heading(text) {
|
|
8364
|
+
write("");
|
|
8365
|
+
write(` ${palette.bold(palette.pink(text))}`);
|
|
8366
|
+
},
|
|
8367
|
+
/**
|
|
8368
|
+
* Render a diagnostic line: green `✓` (pass) or red `✗` (fail) + label, with optional
|
|
8369
|
+
* dim, indented detail beneath (e.g. a fix hint for a failing check).
|
|
8370
|
+
*
|
|
8371
|
+
* @param ok - Whether the check passed.
|
|
8372
|
+
* @param label - The check label.
|
|
8373
|
+
* @param detail - Optional multi-line guidance shown indented under the line.
|
|
8374
|
+
* @example
|
|
8375
|
+
* render.check(false, "CLOUDFLARE_API_TOKEN is set", "Create one at …");
|
|
8376
|
+
*/
|
|
8377
|
+
check(ok, label, detail) {
|
|
8378
|
+
write(` ${ok ? palette.green("✓") : palette.red("✗")} ${label}`);
|
|
8379
|
+
if (detail !== void 0) for (const line of detail.split("\n")) write(` ${palette.dim(line)}`);
|
|
8380
|
+
},
|
|
8381
|
+
/**
|
|
8382
|
+
* Stop every animation and release the interval timer (serve()'s teardown calls this).
|
|
8383
|
+
*
|
|
8384
|
+
* @example
|
|
8385
|
+
* render.dispose();
|
|
8386
|
+
*/
|
|
8387
|
+
dispose() {
|
|
8388
|
+
stopTicker();
|
|
8389
|
+
idle = false;
|
|
8390
|
+
rebuilding = false;
|
|
8391
|
+
phaseOpen = false;
|
|
8392
|
+
serveMode = false;
|
|
7503
8393
|
}
|
|
7504
8394
|
};
|
|
7505
8395
|
}
|
|
@@ -7578,18 +8468,44 @@ function defaultConfirm(question) {
|
|
|
7578
8468
|
});
|
|
7579
8469
|
}
|
|
7580
8470
|
/**
|
|
8471
|
+
* Default stdin single-choice prompt. Prints the choices numbered from 1, reads a line
|
|
8472
|
+
* via `node:readline`, and resolves the chosen zero-based index (empty / out-of-range
|
|
8473
|
+
* falls back to 0). Tests inject a canned selection so no real TTY interaction occurs.
|
|
8474
|
+
*
|
|
8475
|
+
* @param question - The prompt to display.
|
|
8476
|
+
* @param choices - The selectable option labels.
|
|
8477
|
+
* @returns Resolves the chosen zero-based index.
|
|
8478
|
+
* @example
|
|
8479
|
+
* await defaultSelect("Trigger?", ["Auto on push", "Manual only"]);
|
|
8480
|
+
*/
|
|
8481
|
+
function defaultSelect(question, choices) {
|
|
8482
|
+
return new Promise((resolve) => {
|
|
8483
|
+
const readline = createInterface({
|
|
8484
|
+
input: process.stdin,
|
|
8485
|
+
output: process.stdout
|
|
8486
|
+
});
|
|
8487
|
+
for (const [index, choice] of choices.entries()) console.log(` ${index + 1}) ${choice}`);
|
|
8488
|
+
readline.question(`${question} [1-${choices.length}] `, (answer) => {
|
|
8489
|
+
readline.close();
|
|
8490
|
+
const picked = Number.parseInt(answer.trim(), 10);
|
|
8491
|
+
resolve(Number.isInteger(picked) && picked >= 1 && picked <= choices.length ? picked - 1 : 0);
|
|
8492
|
+
});
|
|
8493
|
+
});
|
|
8494
|
+
}
|
|
8495
|
+
/**
|
|
7581
8496
|
* Default recursive directory watcher — wraps `node:fs.watch` with `{ recursive: true }`
|
|
7582
8497
|
* and adapts its handle to {@link WatchHandle}. Tests inject a fake emitter so no real
|
|
7583
8498
|
* FS watch is registered.
|
|
7584
8499
|
*
|
|
7585
8500
|
* @param dir - The directory to watch recursively.
|
|
7586
|
-
* @param onChange - Invoked on any change beneath `dir
|
|
8501
|
+
* @param onChange - Invoked on any change beneath `dir`, forwarding the changed path
|
|
8502
|
+
* relative to `dir` when `node:fs.watch` reports it (`undefined` otherwise).
|
|
7587
8503
|
* @returns A handle whose `close()` detaches the watcher.
|
|
7588
8504
|
* @example
|
|
7589
|
-
* const handle = defaultWatch("content",
|
|
8505
|
+
* const handle = defaultWatch("content", file => rebuild(file));
|
|
7590
8506
|
*/
|
|
7591
8507
|
function defaultWatch(dir, onChange) {
|
|
7592
|
-
const watcher = watch(dir, { recursive: true }, () => onChange());
|
|
8508
|
+
const watcher = watch(dir, { recursive: true }, (_event, filename) => onChange(typeof filename === "string" ? filename : void 0));
|
|
7593
8509
|
return {
|
|
7594
8510
|
/**
|
|
7595
8511
|
* Detach the underlying `node:fs.watch` listener.
|
|
@@ -7602,6 +8518,23 @@ close() {
|
|
|
7602
8518
|
} };
|
|
7603
8519
|
}
|
|
7604
8520
|
/**
|
|
8521
|
+
* Default file-mtime probe — `node:fs.statSync(path).mtimeMs`, returning `null` for a
|
|
8522
|
+
* missing path (so a deleted file still reads as a change). serve() compares this
|
|
8523
|
+
* across `fs.watch` events to drop the duplicate notifications macOS fires per save.
|
|
8524
|
+
*
|
|
8525
|
+
* @param filePath - The absolute path to stat.
|
|
8526
|
+
* @returns The modification time in epoch milliseconds, or `null` when absent.
|
|
8527
|
+
* @example
|
|
8528
|
+
* const mtime = defaultFileMtime("/abs/content/a.md");
|
|
8529
|
+
*/
|
|
8530
|
+
function defaultFileMtime(filePath) {
|
|
8531
|
+
try {
|
|
8532
|
+
return statSync(filePath).mtimeMs;
|
|
8533
|
+
} catch {
|
|
8534
|
+
return null;
|
|
8535
|
+
}
|
|
8536
|
+
}
|
|
8537
|
+
/**
|
|
7605
8538
|
* Default LAN network-URL deriver — wraps {@link networkUrl} so the production seam
|
|
7606
8539
|
* reads `node:os` interfaces while tests can inject a deterministic value.
|
|
7607
8540
|
*
|
|
@@ -7613,6 +8546,101 @@ close() {
|
|
|
7613
8546
|
function defaultNetworkUrl(port) {
|
|
7614
8547
|
return networkUrl(port);
|
|
7615
8548
|
}
|
|
8549
|
+
/** Memoized banner facts — resolution touches the filesystem + git once, then caches. */
|
|
8550
|
+
let cachedBanner;
|
|
8551
|
+
/**
|
|
8552
|
+
* Run a read-only `git` command in `dir`, returning its trimmed stdout (`undefined` on
|
|
8553
|
+
* any failure — not a checkout, git missing, etc.). A thin wrapper so the version
|
|
8554
|
+
* resolver can issue a couple of git queries without repeating the spawn boilerplate.
|
|
8555
|
+
*
|
|
8556
|
+
* @param dir - The working directory to run git in.
|
|
8557
|
+
* @param args - The git arguments (no user input is ever interpolated).
|
|
8558
|
+
* @returns The trimmed command output, or `undefined` on failure.
|
|
8559
|
+
* @example
|
|
8560
|
+
* git("/Users/me/moku/web", ["tag", "--list", "v*"]);
|
|
8561
|
+
*/
|
|
8562
|
+
function git(dir, args) {
|
|
8563
|
+
try {
|
|
8564
|
+
return execFileSync("git", args, {
|
|
8565
|
+
cwd: dir,
|
|
8566
|
+
encoding: "utf8",
|
|
8567
|
+
stdio: [
|
|
8568
|
+
"ignore",
|
|
8569
|
+
"pipe",
|
|
8570
|
+
"ignore"
|
|
8571
|
+
]
|
|
8572
|
+
}).trim();
|
|
8573
|
+
} catch {
|
|
8574
|
+
return;
|
|
8575
|
+
}
|
|
8576
|
+
}
|
|
8577
|
+
/**
|
|
8578
|
+
* The framework's source/dev version, derived the SAME way the publish workflow computes
|
|
8579
|
+
* a release: the highest semver `v*` tag is the source of truth (`@moku-labs/web` is
|
|
8580
|
+
* released tag-only — the working-tree `package.json` deliberately carries no `version`).
|
|
8581
|
+
* A `-dev` suffix marks it as a local build off that release line (e.g. `v1.1.0-dev`), so
|
|
8582
|
+
* it never masquerades as the published release. Falls back to the short commit (then
|
|
8583
|
+
* `undefined`) only when no tags exist. `undefined` when `dir` is not a git checkout (a
|
|
8584
|
+
* published npm install — which carries its real `version` instead).
|
|
8585
|
+
*
|
|
8586
|
+
* @param dir - A directory inside the framework's own repository (the realpath of the
|
|
8587
|
+
* package root, so a symlinked local checkout reports the framework's tag — not the
|
|
8588
|
+
* consumer's).
|
|
8589
|
+
* @returns The dev version (e.g. `v1.1.0-dev`), or `undefined`.
|
|
8590
|
+
* @example
|
|
8591
|
+
* devVersion("/Users/me/moku/web"); // "v1.1.0-dev"
|
|
8592
|
+
*/
|
|
8593
|
+
function devVersion(dir) {
|
|
8594
|
+
const latestTag = git(dir, [
|
|
8595
|
+
"tag",
|
|
8596
|
+
"--list",
|
|
8597
|
+
"v*",
|
|
8598
|
+
"--sort=-v:refname"
|
|
8599
|
+
])?.split("\n")[0]?.trim();
|
|
8600
|
+
if (latestTag) return `${latestTag}-dev`;
|
|
8601
|
+
const sha = git(dir, [
|
|
8602
|
+
"rev-parse",
|
|
8603
|
+
"--short",
|
|
8604
|
+
"HEAD"
|
|
8605
|
+
]);
|
|
8606
|
+
return sha ? `${sha}-dev` : void 0;
|
|
8607
|
+
}
|
|
8608
|
+
/**
|
|
8609
|
+
* Resolve the real version/runtime facts shown in the Panel banner (memoized). Reads the
|
|
8610
|
+
* `package.json` shipped beside the built bundle (`dist/../package.json`): a PUBLISHED
|
|
8611
|
+
* release carries a `version` and reports `v{version}`; a source/dev build (no `version`
|
|
8612
|
+
* field — `@moku-labs/web` is released tag-only) reports the latest semver tag + `-dev`
|
|
8613
|
+
* (e.g. `v1.1.0-dev`, the same tag the publish workflow treats as the version source), or
|
|
8614
|
+
* `"dev"` when git is unavailable. The pinned `@moku-labs/core` version comes from the
|
|
8615
|
+
* same file's `dependencies`.
|
|
8616
|
+
*
|
|
8617
|
+
* @returns The resolved {@link BannerFacts}.
|
|
8618
|
+
* @example
|
|
8619
|
+
* resolveBanner(); // { version: "v1.1.0-dev", coreVersion: "0.1.0-alpha.6" }
|
|
8620
|
+
*/
|
|
8621
|
+
function resolveBanner() {
|
|
8622
|
+
if (cachedBanner) return cachedBanner;
|
|
8623
|
+
let pkg = {};
|
|
8624
|
+
let pkgDir;
|
|
8625
|
+
try {
|
|
8626
|
+
const pkgUrl = new URL("../package.json", import.meta.url);
|
|
8627
|
+
pkgDir = realpathSync(path.dirname(fileURLToPath(pkgUrl)));
|
|
8628
|
+
pkg = JSON.parse(readFileSync(pkgUrl, "utf8"));
|
|
8629
|
+
} catch {}
|
|
8630
|
+
const coreVersion = (pkg.dependencies?.["@moku-labs/core"] ?? "").replace(/^\D*/, "") || "unknown";
|
|
8631
|
+
const released = pkg.version;
|
|
8632
|
+
let version = "dev";
|
|
8633
|
+
if (released) version = `v${released}`;
|
|
8634
|
+
else {
|
|
8635
|
+
const dev = devVersion(pkgDir ?? process.cwd());
|
|
8636
|
+
if (dev) version = dev;
|
|
8637
|
+
}
|
|
8638
|
+
cachedBanner = {
|
|
8639
|
+
version,
|
|
8640
|
+
coreVersion
|
|
8641
|
+
};
|
|
8642
|
+
return cachedBanner;
|
|
8643
|
+
}
|
|
7616
8644
|
/**
|
|
7617
8645
|
* Create the initial cli plugin state with the production seams wired. Every field is
|
|
7618
8646
|
* an injectable seam (`render`, `confirm`, `clock`, `watch`, the server factories,
|
|
@@ -7626,14 +8654,20 @@ function defaultNetworkUrl(port) {
|
|
|
7626
8654
|
* const state = createState({ global: {}, config });
|
|
7627
8655
|
*/
|
|
7628
8656
|
function createState$1(_ctx) {
|
|
8657
|
+
const banner = resolveBanner();
|
|
7629
8658
|
return {
|
|
7630
|
-
render: createPanelRenderer(
|
|
8659
|
+
render: createPanelRenderer({
|
|
8660
|
+
version: banner.version,
|
|
8661
|
+
coreVersion: banner.coreVersion
|
|
8662
|
+
}),
|
|
7631
8663
|
confirm: defaultConfirm,
|
|
8664
|
+
select: defaultSelect,
|
|
7632
8665
|
clock: Date.now,
|
|
7633
8666
|
watch: defaultWatch,
|
|
7634
8667
|
serveStatic: defaultServeStatic,
|
|
7635
8668
|
fileResponse: defaultFileResponse,
|
|
7636
|
-
networkUrl: defaultNetworkUrl
|
|
8669
|
+
networkUrl: defaultNetworkUrl,
|
|
8670
|
+
fileMtime: defaultFileMtime
|
|
7637
8671
|
};
|
|
7638
8672
|
}
|
|
7639
8673
|
//#endregion
|