@moku-labs/worker 0.9.2 → 0.10.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.
@@ -1535,6 +1535,287 @@ const verifyAuth = async (ctx) => {
1535
1535
  };
1536
1536
  };
1537
1537
  //#endregion
1538
+ //#region src/plugins/deploy/infra/render.ts
1539
+ /**
1540
+ * Derive a human-readable name from a resource descriptor: the Cloudflare resource `name` for the
1541
+ * provisioned kinds (kv/r2/d1/queue), or the exported `className` for a Durable Object (which has no
1542
+ * provisioned name). Used in both the provision events and the branded panels so the two agree.
1543
+ *
1544
+ * @param resource - The resource descriptor.
1545
+ * @returns A short name identifying the resource.
1546
+ * @example
1547
+ * ```ts
1548
+ * resourceName({ kind: "kv", name: "tracker-cache", binding: "CACHE" }); // "tracker-cache"
1549
+ * ```
1550
+ */
1551
+ const resourceName = (resource) => resource.kind === "do" ? resource.className : resource.name;
1552
+ /**
1553
+ * Format a `kind name` cell, padding the kind so the names line up in a column.
1554
+ *
1555
+ * @param kind - The resource kind (kv / r2 / d1 / queue / do).
1556
+ * @param name - The resource name.
1557
+ * @returns The aligned `kind name` cell.
1558
+ * @example
1559
+ * ```ts
1560
+ * cell("kv", "CACHE"); // "kv CACHE"
1561
+ * ```
1562
+ */
1563
+ const cell = (kind, name) => `${kind.padEnd(6)}${name}`;
1564
+ /**
1565
+ * Row tag for a Durable Object — it ships with the Worker (`wrangler deploy` creates the namespace),
1566
+ * so it is NEVER labelled `(exists)` (the planner never queried the account for it). Shared by the
1567
+ * plan and provision-result panels so the two always read the same.
1568
+ */
1569
+ const SHIPS_WITH_WORKER = "(ships with worker)";
1570
+ /**
1571
+ * ANSI SGR matcher — built from `String.fromCharCode(27)` (the ESC byte) so no control character
1572
+ * appears in a regex literal (which both linters reject).
1573
+ */
1574
+ const ANSI_SGR = new RegExp(String.raw`${String.fromCodePoint(27)}\[[0-9;]*m`, "gu");
1575
+ /**
1576
+ * Strip ANSI SGR escape sequences so a captured (colorized) error renders as plain, readable text.
1577
+ *
1578
+ * @param text - The (possibly colorized) text.
1579
+ * @returns The text with ANSI color codes removed.
1580
+ * @example
1581
+ * ```ts
1582
+ * stripAnsi(`${String.fromCharCode(27)}[31mX${String.fromCharCode(27)}[0m`); // "X"
1583
+ * ```
1584
+ */
1585
+ const stripAnsi = (text) => text.replaceAll(ANSI_SGR, "");
1586
+ /**
1587
+ * Clean a captured (colorized, multi-line, wrapper-wrapped) provision error down to its meaningful
1588
+ * text: strip ANSI, drop the wrapper lines (the branded prefix, wrangler's log-file pointer), strip
1589
+ * each `✘ [ERROR]` marker, and join what's left. Returns the FULL message (the caller word-wraps it)
1590
+ * so the user reads the actual reason — never a truncated `…`.
1591
+ *
1592
+ * @param message - The captured error message.
1593
+ * @returns The full, plain failure reason.
1594
+ * @example
1595
+ * ```ts
1596
+ * cleanError("[moku-worker] wrangler exited…\n ✘ [ERROR] The bucket name is invalid.");
1597
+ * // "The bucket name is invalid."
1598
+ * ```
1599
+ */
1600
+ const cleanError = (message) => {
1601
+ const cleaned = stripAnsi(message).split("\n").map((line) => line.trim()).filter((line) => line.length > 0).filter((line) => !/^\[moku-worker\]/u.test(line)).filter((line) => !/logs were written to/iu.test(line)).map((line) => line.replace(/^✘\s*/u, "").replace(/^\[error\]\s*/iu, "")).join(" ");
1602
+ return cleaned.length > 0 ? cleaned : stripAnsi(message).trim();
1603
+ };
1604
+ /**
1605
+ * Word-wrap text to `width` columns (never splitting inside a word), so a long failure reason reads
1606
+ * as a tidy indented block instead of forcing the box wide or scrolling off the edge.
1607
+ *
1608
+ * @param text - The text to wrap.
1609
+ * @param width - The maximum column width per line.
1610
+ * @returns The wrapped lines.
1611
+ * @example
1612
+ * ```ts
1613
+ * wrapText("a long sentence to wrap", 10); // ["a long", "sentence", "to wrap"]
1614
+ * ```
1615
+ */
1616
+ const wrapText = (text, width) => {
1617
+ const lines = [];
1618
+ let line = "";
1619
+ for (const word of text.split(/\s+/u).filter(Boolean)) if (line.length === 0) line = word;
1620
+ else if (line.length + 1 + word.length <= width) line += ` ${word}`;
1621
+ else {
1622
+ lines.push(line);
1623
+ line = word;
1624
+ }
1625
+ if (line.length > 0) lines.push(line);
1626
+ return lines;
1627
+ };
1628
+ /**
1629
+ * Render the infra preflight plan as a branded panel: a dim summary line (counts + account) then one
1630
+ * row per declared resource — a pink `+` for those to create, a dim `~ (exists)` for those already
1631
+ * present, and a dim `~ (ships with worker)` for Durable Objects (created by `wrangler deploy`, never
1632
+ * pre-provisioned). When nothing needs creating it still renders, so the user sees the full picture.
1633
+ *
1634
+ * @param ui - The branded console to render through.
1635
+ * @param plan - The infra plan (existing vs missing vs ships-with-Worker) from checkInfra()/planInfra().
1636
+ * @example
1637
+ * ```ts
1638
+ * renderPlan(ui, await planInfra(ctx, manifest));
1639
+ * ```
1640
+ */
1641
+ const renderPlan = (ui, plan) => {
1642
+ const { palette } = ui;
1643
+ const counts = [`${String(plan.missing.length)} to create`, `${String(plan.exists.length)} exist`];
1644
+ if (plan.ships.length > 0) counts.push(`${String(plan.ships.length)} with worker`);
1645
+ const summary = palette.dim(`${counts.join(" · ")} · ${plan.account}`);
1646
+ const createRows = plan.missing.map((resource) => `${palette.pink("+")} ${cell(resource.kind, resourceName(resource))}`);
1647
+ const existsRows = plan.exists.map((ref) => `${palette.dim("~")} ${cell(ref.resource.kind, resourceName(ref.resource))} ${palette.dim("(exists)")}`);
1648
+ const shipsRows = plan.ships.map((resource) => `${palette.dim("~")} ${cell(resource.kind, resourceName(resource))} ${palette.dim(SHIPS_WITH_WORKER)}`);
1649
+ ui.heading("Infra plan");
1650
+ ui.box([
1651
+ summary,
1652
+ "",
1653
+ ...createRows,
1654
+ ...existsRows,
1655
+ ...shipsRows
1656
+ ]);
1657
+ };
1658
+ /**
1659
+ * Render the provision result as a branded panel — a green `✓` per created resource, a dim `~` per
1660
+ * skipped, a dim `~ (ships with worker)` per Durable Object, a red `✗` per failure, then a summary
1661
+ * line (failed count red when non-zero) — followed, when anything failed, by a detail block printing
1662
+ * each failure's FULL reason (ANSI-stripped and word-wrapped) so it is actually readable instead of
1663
+ * truncated inside the box.
1664
+ *
1665
+ * @param ui - The branded console to render through.
1666
+ * @param result - The provision result from provisionInfra()/the deploy pipeline.
1667
+ * @example
1668
+ * ```ts
1669
+ * renderProvisionResult(ui, await provisionInfra(plan));
1670
+ * ```
1671
+ */
1672
+ const renderProvisionResult = (ui, result) => {
1673
+ const { palette } = ui;
1674
+ const createdRows = result.created.map((ref) => `${palette.green("✓")} ${cell(ref.resource.kind, resourceName(ref.resource))}`);
1675
+ const skippedRows = result.skipped.map((ref) => `${palette.dim("~")} ${cell(ref.resource.kind, resourceName(ref.resource))} ${palette.dim("(exists)")}`);
1676
+ const bundledRows = result.bundled.map((resource) => `${palette.dim("~")} ${cell(resource.kind, resourceName(resource))} ${palette.dim(SHIPS_WITH_WORKER)}`);
1677
+ const failedRows = result.failed.map((failure) => `${palette.red("✗")} ${cell(failure.resource.kind, resourceName(failure.resource))}`);
1678
+ const failedCount = result.failed.length > 0 ? palette.red(`${String(result.failed.length)} failed`) : "0 failed";
1679
+ const counts = [`${String(result.created.length)} created`, `${String(result.skipped.length)} exist`];
1680
+ if (result.bundled.length > 0) counts.push(`${String(result.bundled.length)} with worker`);
1681
+ const summary = `${counts.join(" · ")} · ${failedCount}`;
1682
+ ui.heading("Provisioned");
1683
+ ui.box([
1684
+ ...createdRows,
1685
+ ...skippedRows,
1686
+ ...bundledRows,
1687
+ ...failedRows,
1688
+ "",
1689
+ summary
1690
+ ]);
1691
+ if (result.failed.length > 0) {
1692
+ ui.line();
1693
+ for (const failure of result.failed) {
1694
+ ui.line(` ${palette.red("✗")} ${cell(failure.resource.kind, resourceName(failure.resource))}`);
1695
+ for (const wrapped of wrapText(cleanError(failure.error), ui.width - 4)) ui.line(palette.dim(` ${wrapped}`));
1696
+ }
1697
+ }
1698
+ };
1699
+ /**
1700
+ * Format an elapsed duration compactly: sub-second as `820ms`, otherwise one-decimal seconds (`4.2s`),
1701
+ * and minutes once it crosses 60s (`1m04s`) so a long deploy stays readable.
1702
+ *
1703
+ * @param ms - The elapsed milliseconds.
1704
+ * @returns The compact duration string.
1705
+ * @example
1706
+ * ```ts
1707
+ * formatDuration(4234); // "4.2s"
1708
+ * ```
1709
+ */
1710
+ const formatDuration = (ms) => {
1711
+ if (ms < 1e3) return `${String(ms)}ms`;
1712
+ const seconds = ms / 1e3;
1713
+ if (seconds < 60) return `${seconds.toFixed(1)}s`;
1714
+ const whole = Math.floor(seconds);
1715
+ return `${String(Math.floor(whole / 60))}m${String(whole % 60).padStart(2, "0")}s`;
1716
+ };
1717
+ /**
1718
+ * Render the terminal deploy summary as a branded panel — the headline the user actually wants. The
1719
+ * live URL leads on its own line (pink, so it is the first thing the eye lands on), then a dim
1720
+ * key/value block: the target stage, the resource tally (with a red `failed` count when non-zero),
1721
+ * and the wall-clock time the whole deploy took. Replaces the prior single `deployed → url` line.
1722
+ *
1723
+ * @param ui - The branded console to render through.
1724
+ * @param summary - The deploy summary fields.
1725
+ * @param summary.url - The live deployed URL (the panel headline).
1726
+ * @param summary.stage - The target stage the worker deployed to.
1727
+ * @param summary.created - How many resources were created this run.
1728
+ * @param summary.exists - How many resources already existed (skipped).
1729
+ * @param summary.bundled - How many Durable Objects shipped with the Worker.
1730
+ * @param summary.failed - How many resources failed to provision.
1731
+ * @param summary.elapsedMs - The wall-clock deploy duration in milliseconds.
1732
+ * @example
1733
+ * ```ts
1734
+ * renderDeploySummary(ui, { url, stage: "production", created: 0, exists: 5, bundled: 1, failed: 0, elapsedMs: 4234 });
1735
+ * ```
1736
+ */
1737
+ const renderDeploySummary = (ui, summary) => {
1738
+ const { palette } = ui;
1739
+ const parts = [`${String(summary.exists)} exist`, `${String(summary.created)} created`];
1740
+ if (summary.bundled > 0) parts.push(`${String(summary.bundled)} with worker`);
1741
+ const tally = parts.join(" · ");
1742
+ const failedLabel = palette.red(`${String(summary.failed)} failed`);
1743
+ const resources = summary.failed > 0 ? `${tally} · ${failedLabel}` : tally;
1744
+ ui.heading("Deployed");
1745
+ ui.box([
1746
+ palette.pink(summary.url),
1747
+ "",
1748
+ `${palette.dim("stage".padEnd(10))}${summary.stage}`,
1749
+ `${palette.dim("resources".padEnd(10))}${resources}`,
1750
+ `${palette.dim("took".padEnd(10))}${formatDuration(summary.elapsedMs)}`
1751
+ ]);
1752
+ };
1753
+ /**
1754
+ * Render the D1 migration outcome as a branded panel — the readable replacement for wrangler's raw
1755
+ * `d1 migrations apply` TUI. One row per database: the `d1 <binding>` cell plus a pink `N applied`
1756
+ * count (or a dim `up to date` when nothing was pending), with each applied migration filename listed
1757
+ * beneath as a green `✓`. A dim scope footer (`remote` / `local`) names which database was touched.
1758
+ * The caller renders this only when at least one database actually ran migrations.
1759
+ *
1760
+ * @param ui - The branded console to render through.
1761
+ * @param outcomes - The per-database migration outcomes (one per d1 instance that declares migrations).
1762
+ * @param scope - Which database the migrations ran against: `remote` (Cloudflare) or `local` (dev).
1763
+ * @example
1764
+ * ```ts
1765
+ * renderMigrateSummary(ui, [{ binding: "DB", applied: ["0003_x.sql"], upToDate: false }], "remote");
1766
+ * ```
1767
+ */
1768
+ const renderMigrateSummary = (ui, outcomes, scope) => {
1769
+ const { palette } = ui;
1770
+ const rows = [];
1771
+ for (const outcome of outcomes) {
1772
+ const count = outcome.applied.length;
1773
+ const appliedLabel = palette.pink(count > 0 ? `${String(count)} applied` : "applied");
1774
+ const status = outcome.upToDate ? palette.dim("up to date") : appliedLabel;
1775
+ rows.push(`${cell("d1", outcome.binding)} ${status}`);
1776
+ for (const name of outcome.applied) rows.push(` ${palette.green("✓")} ${palette.dim(name)}`);
1777
+ }
1778
+ ui.heading("Migrated");
1779
+ ui.box([
1780
+ ...rows,
1781
+ "",
1782
+ palette.dim(scope)
1783
+ ]);
1784
+ };
1785
+ /**
1786
+ * Render the seed outcome as a branded panel — the readable replacement for wrangler's raw
1787
+ * `d1 execute` / `kv key delete` TUI. Leads with the loaded `file → binding` (pink file), an optional
1788
+ * dim stats line (rows written / statements, only the parts wrangler reported), then — when the seed
1789
+ * cleared cached KV keys — a `KV reset` block listing each `~ binding key` so the user sees exactly
1790
+ * what was dropped. A dim scope footer (`remote` / `local`) names which database was seeded.
1791
+ *
1792
+ * @param ui - The branded console to render through.
1793
+ * @param outcome - The seed outcome (file, target binding, best-effort counts, the KV keys reset).
1794
+ * @param scope - Which database the seed ran against: `remote` (Cloudflare) or `local` (dev).
1795
+ * @example
1796
+ * ```ts
1797
+ * renderSeedSummary(ui, { file: "db/seed.sql", binding: "DB", rowsWritten: 18, resetKv: [] }, "remote");
1798
+ * ```
1799
+ */
1800
+ const renderSeedSummary = (ui, outcome, scope) => {
1801
+ const { palette } = ui;
1802
+ const lines = [`${palette.pink(outcome.file)} ${palette.dim("→")} ${outcome.binding}`];
1803
+ const stats = [];
1804
+ if (outcome.rowsWritten !== void 0) stats.push(`${String(outcome.rowsWritten)} rows written`);
1805
+ if (outcome.statements !== void 0) stats.push(`${String(outcome.statements)} statements`);
1806
+ if (stats.length > 0) lines.push(palette.dim(stats.join(" · ")));
1807
+ if (outcome.resetKv.length > 0) {
1808
+ lines.push("", palette.dim("KV reset"));
1809
+ for (const entry of outcome.resetKv) lines.push(`${palette.dim("~")} ${entry.binding} ${palette.dim(entry.key)}`);
1810
+ }
1811
+ ui.heading("Seeded");
1812
+ ui.box([
1813
+ ...lines,
1814
+ "",
1815
+ palette.dim(scope)
1816
+ ]);
1817
+ };
1818
+ //#endregion
1538
1819
  //#region src/plugins/deploy/runner.ts
1539
1820
  /**
1540
1821
  * @file deploy plugin — wrangler subprocess wrapper (node:child_process).
@@ -1642,6 +1923,61 @@ const runWranglerInherit = (args) => {
1642
1923
  * Node-only; never imported by the runtime Worker bundle.
1643
1924
  */
1644
1925
  /**
1926
+ * Parse the best-effort row/statement counts from wrangler's `d1 execute` output so the branded seed
1927
+ * panel can report them — degrading gracefully (each field simply omitted) when wrangler's format
1928
+ * differs or the runner streamed instead of captured. Wrangler prints lines like "🚣 18 commands
1929
+ * executed" and a rows-written total; both are matched loosely (case-insensitive).
1930
+ *
1931
+ * @param output - The captured stdout from `wrangler d1 execute` (empty when the runner streamed).
1932
+ * @returns The parsed counts — each field present only when found.
1933
+ * @example
1934
+ * ```ts
1935
+ * parseSeedStats("🚣 18 commands executed (30 rows written)"); // { statements: 18, rowsWritten: 30 }
1936
+ * ```
1937
+ */
1938
+ const parseSeedStats = (output) => {
1939
+ const rows = /(\d{1,12}) rows? written/iu.exec(output);
1940
+ const commands = /(\d{1,12}) commands? executed/iu.exec(output) ?? /executed (\d{1,12}) commands?/iu.exec(output);
1941
+ const result = {};
1942
+ if (commands?.[1] !== void 0) result.statements = Number(commands[1]);
1943
+ if (rows?.[1] !== void 0) result.rowsWritten = Number(rows[1]);
1944
+ return result;
1945
+ };
1946
+ /**
1947
+ * Parse which migrations wrangler applied from its captured `d1 migrations apply` output, so the
1948
+ * branded migrate panel can name them instead of dumping wrangler's raw migration TUI. `upToDate` is
1949
+ * true when wrangler reported nothing pending ("No migrations to apply"); otherwise every
1950
+ * `NNNN_name.sql` filename token in the output is collected in order (de-duplicated). Degrades
1951
+ * safely — an unrecognized format yields no names, and the panel falls back to a generic "applied".
1952
+ * Lives here (not in api.ts) so both the deploy path and the dev path parse it without a cycle.
1953
+ *
1954
+ * @param output - The captured stdout from `wrangler d1 migrations apply`.
1955
+ * @returns The applied migration filenames and whether the database was already up to date.
1956
+ * @example
1957
+ * ```ts
1958
+ * parseMigrationsApplied("Applied 0003_x.sql\n0004_y.sql"); // { applied: ["0003_x.sql", "0004_y.sql"], upToDate: false }
1959
+ * ```
1960
+ */
1961
+ const parseMigrationsApplied = (output) => {
1962
+ if (/no migrations to apply/iu.test(output)) return {
1963
+ applied: [],
1964
+ upToDate: true
1965
+ };
1966
+ const applied = [];
1967
+ const seen = /* @__PURE__ */ new Set();
1968
+ for (const match of output.matchAll(/\b\d{3,}_[A-Za-z0-9_-]+\.sql\b/gu)) {
1969
+ const name = match[0];
1970
+ if (!seen.has(name)) {
1971
+ seen.add(name);
1972
+ applied.push(name);
1973
+ }
1974
+ }
1975
+ return {
1976
+ applied,
1977
+ upToDate: false
1978
+ };
1979
+ };
1980
+ /**
1645
1981
  * Resolve the single configured d1 database (or the one bound to `binding` when several exist) from
1646
1982
  * the d1 plugin's manifest. The shared resolver behind `seed()`, the post-deploy seed, and the dev
1647
1983
  * seed; throws a branded error when the choice is ambiguous (none/several, no binding) or unknown.
@@ -1670,27 +2006,30 @@ const resolveD1 = (ctx, binding) => {
1670
2006
  * injectable dev path.
1671
2007
  *
1672
2008
  * @param ctx - The deploy plugin context.
1673
- * @param run - The wrangler runner to execute each command through.
2009
+ * @param run - The wrangler runner to execute each command through (a CAPTURING runner lets the
2010
+ * returned outcome report row/statement counts; a streaming one still works, just without them).
1674
2011
  * @param seed - The resolved seed config (SQL file, optional binding, KV keys to reset).
1675
2012
  * @param scope - The wrangler scope: `--remote` (deploy) or `--local` (dev).
1676
- * @returns Resolves once the seed file has executed and every cached KV key is cleared.
2013
+ * @returns The seed outcome (file, target binding, best-effort counts, and the KV keys that were reset).
1677
2014
  * @throws {Error} When no d1 database is configured, or the seed's binding cannot be resolved.
1678
2015
  * @example
1679
2016
  * ```ts
1680
- * await runConfiguredSeed(ctx, runWranglerInherit, ctx.config.seed, "--remote");
2017
+ * const outcome = await runConfiguredSeed(ctx, runWrangler, ctx.config.seed, "--remote");
1681
2018
  * ```
1682
2019
  */
1683
2020
  const runConfiguredSeed = async (ctx, run, seed, scope) => {
1684
2021
  if (!ctx.has("d1")) throw new Error("[moku-worker] seed: no d1 database is configured.");
1685
- await run([
2022
+ const database = resolveD1(ctx, seed.binding);
2023
+ const executed = await run([
1686
2024
  "d1",
1687
2025
  "execute",
1688
- resolveD1(ctx, seed.binding).binding,
2026
+ database.binding,
1689
2027
  scope,
1690
2028
  "--file",
1691
2029
  seed.file
1692
2030
  ]);
1693
- for (const entry of seed.resetKv ?? []) await run([
2031
+ const resetKv = seed.resetKv ?? [];
2032
+ for (const entry of resetKv) await run([
1694
2033
  "kv",
1695
2034
  "key",
1696
2035
  "delete",
@@ -1699,6 +2038,12 @@ const runConfiguredSeed = async (ctx, run, seed, scope) => {
1699
2038
  entry.binding,
1700
2039
  scope
1701
2040
  ]);
2041
+ return {
2042
+ file: seed.file,
2043
+ binding: database.binding,
2044
+ resetKv,
2045
+ ...parseSeedStats(typeof executed === "string" ? executed : "")
2046
+ };
1702
2047
  };
1703
2048
  //#endregion
1704
2049
  //#region src/plugins/deploy/dev/build.ts
@@ -2044,7 +2389,8 @@ const seedLocal = async (ctx, deps) => {
2044
2389
  phase: "seed",
2045
2390
  detail: config.file
2046
2391
  });
2047
- await runConfiguredSeed(ctx, deps.runWrangler, config, "--local");
2392
+ const outcome = await runConfiguredSeed(ctx, deps.runWrangler, config, "--local");
2393
+ renderSeedSummary((0, _moku_labs_common_cli.createBrandConsole)(), outcome, "local");
2048
2394
  };
2049
2395
  /**
2050
2396
  * Run a long-lived dev session: cold build → (local d1 migrate) → (local seed) → spawn `wrangler
@@ -2080,13 +2426,21 @@ const runDev = async (ctx, opts, deps) => {
2080
2426
  phase: "migrate",
2081
2427
  detail: "d1 (local)"
2082
2428
  });
2083
- for (const binding of migrationBindings) await deps.runWrangler([
2084
- "d1",
2085
- "migrations",
2086
- "apply",
2087
- binding,
2088
- "--local"
2089
- ]);
2429
+ const outcomes = [];
2430
+ for (const binding of migrationBindings) {
2431
+ const output = await deps.runWrangler([
2432
+ "d1",
2433
+ "migrations",
2434
+ "apply",
2435
+ binding,
2436
+ "--local"
2437
+ ]);
2438
+ outcomes.push({
2439
+ binding,
2440
+ ...parseMigrationsApplied(output)
2441
+ });
2442
+ }
2443
+ renderMigrateSummary((0, _moku_labs_common_cli.createBrandConsole)(), outcomes, "local");
2090
2444
  }
2091
2445
  if (seed) await seedLocal(ctx, deps);
2092
2446
  ctx.emit("dev:phase", {
@@ -2199,222 +2553,6 @@ const planInfra = async (ctx, manifest) => {
2199
2553
  };
2200
2554
  };
2201
2555
  //#endregion
2202
- //#region src/plugins/deploy/infra/render.ts
2203
- /**
2204
- * Derive a human-readable name from a resource descriptor: the Cloudflare resource `name` for the
2205
- * provisioned kinds (kv/r2/d1/queue), or the exported `className` for a Durable Object (which has no
2206
- * provisioned name). Used in both the provision events and the branded panels so the two agree.
2207
- *
2208
- * @param resource - The resource descriptor.
2209
- * @returns A short name identifying the resource.
2210
- * @example
2211
- * ```ts
2212
- * resourceName({ kind: "kv", name: "tracker-cache", binding: "CACHE" }); // "tracker-cache"
2213
- * ```
2214
- */
2215
- const resourceName = (resource) => resource.kind === "do" ? resource.className : resource.name;
2216
- /**
2217
- * Format a `kind name` cell, padding the kind so the names line up in a column.
2218
- *
2219
- * @param kind - The resource kind (kv / r2 / d1 / queue / do).
2220
- * @param name - The resource name.
2221
- * @returns The aligned `kind name` cell.
2222
- * @example
2223
- * ```ts
2224
- * cell("kv", "CACHE"); // "kv CACHE"
2225
- * ```
2226
- */
2227
- const cell = (kind, name) => `${kind.padEnd(6)}${name}`;
2228
- /**
2229
- * Row tag for a Durable Object — it ships with the Worker (`wrangler deploy` creates the namespace),
2230
- * so it is NEVER labelled `(exists)` (the planner never queried the account for it). Shared by the
2231
- * plan and provision-result panels so the two always read the same.
2232
- */
2233
- const SHIPS_WITH_WORKER = "(ships with worker)";
2234
- /**
2235
- * ANSI SGR matcher — built from `String.fromCharCode(27)` (the ESC byte) so no control character
2236
- * appears in a regex literal (which both linters reject).
2237
- */
2238
- const ANSI_SGR = new RegExp(String.raw`${String.fromCodePoint(27)}\[[0-9;]*m`, "gu");
2239
- /**
2240
- * Strip ANSI SGR escape sequences so a captured (colorized) error renders as plain, readable text.
2241
- *
2242
- * @param text - The (possibly colorized) text.
2243
- * @returns The text with ANSI color codes removed.
2244
- * @example
2245
- * ```ts
2246
- * stripAnsi(`${String.fromCharCode(27)}[31mX${String.fromCharCode(27)}[0m`); // "X"
2247
- * ```
2248
- */
2249
- const stripAnsi = (text) => text.replaceAll(ANSI_SGR, "");
2250
- /**
2251
- * Clean a captured (colorized, multi-line, wrapper-wrapped) provision error down to its meaningful
2252
- * text: strip ANSI, drop the wrapper lines (the branded prefix, wrangler's log-file pointer), strip
2253
- * each `✘ [ERROR]` marker, and join what's left. Returns the FULL message (the caller word-wraps it)
2254
- * so the user reads the actual reason — never a truncated `…`.
2255
- *
2256
- * @param message - The captured error message.
2257
- * @returns The full, plain failure reason.
2258
- * @example
2259
- * ```ts
2260
- * cleanError("[moku-worker] wrangler exited…\n ✘ [ERROR] The bucket name is invalid.");
2261
- * // "The bucket name is invalid."
2262
- * ```
2263
- */
2264
- const cleanError = (message) => {
2265
- const cleaned = stripAnsi(message).split("\n").map((line) => line.trim()).filter((line) => line.length > 0).filter((line) => !/^\[moku-worker\]/u.test(line)).filter((line) => !/logs were written to/iu.test(line)).map((line) => line.replace(/^✘\s*/u, "").replace(/^\[error\]\s*/iu, "")).join(" ");
2266
- return cleaned.length > 0 ? cleaned : stripAnsi(message).trim();
2267
- };
2268
- /**
2269
- * Word-wrap text to `width` columns (never splitting inside a word), so a long failure reason reads
2270
- * as a tidy indented block instead of forcing the box wide or scrolling off the edge.
2271
- *
2272
- * @param text - The text to wrap.
2273
- * @param width - The maximum column width per line.
2274
- * @returns The wrapped lines.
2275
- * @example
2276
- * ```ts
2277
- * wrapText("a long sentence to wrap", 10); // ["a long", "sentence", "to wrap"]
2278
- * ```
2279
- */
2280
- const wrapText = (text, width) => {
2281
- const lines = [];
2282
- let line = "";
2283
- for (const word of text.split(/\s+/u).filter(Boolean)) if (line.length === 0) line = word;
2284
- else if (line.length + 1 + word.length <= width) line += ` ${word}`;
2285
- else {
2286
- lines.push(line);
2287
- line = word;
2288
- }
2289
- if (line.length > 0) lines.push(line);
2290
- return lines;
2291
- };
2292
- /**
2293
- * Render the infra preflight plan as a branded panel: a dim summary line (counts + account) then one
2294
- * row per declared resource — a pink `+` for those to create, a dim `~ (exists)` for those already
2295
- * present, and a dim `~ (ships with worker)` for Durable Objects (created by `wrangler deploy`, never
2296
- * pre-provisioned). When nothing needs creating it still renders, so the user sees the full picture.
2297
- *
2298
- * @param ui - The branded console to render through.
2299
- * @param plan - The infra plan (existing vs missing vs ships-with-Worker) from checkInfra()/planInfra().
2300
- * @example
2301
- * ```ts
2302
- * renderPlan(ui, await planInfra(ctx, manifest));
2303
- * ```
2304
- */
2305
- const renderPlan = (ui, plan) => {
2306
- const { palette } = ui;
2307
- const counts = [`${String(plan.missing.length)} to create`, `${String(plan.exists.length)} exist`];
2308
- if (plan.ships.length > 0) counts.push(`${String(plan.ships.length)} with worker`);
2309
- const summary = palette.dim(`${counts.join(" · ")} · ${plan.account}`);
2310
- const createRows = plan.missing.map((resource) => `${palette.pink("+")} ${cell(resource.kind, resourceName(resource))}`);
2311
- const existsRows = plan.exists.map((ref) => `${palette.dim("~")} ${cell(ref.resource.kind, resourceName(ref.resource))} ${palette.dim("(exists)")}`);
2312
- const shipsRows = plan.ships.map((resource) => `${palette.dim("~")} ${cell(resource.kind, resourceName(resource))} ${palette.dim(SHIPS_WITH_WORKER)}`);
2313
- ui.heading("Infra plan");
2314
- ui.box([
2315
- summary,
2316
- "",
2317
- ...createRows,
2318
- ...existsRows,
2319
- ...shipsRows
2320
- ]);
2321
- };
2322
- /**
2323
- * Render the provision result as a branded panel — a green `✓` per created resource, a dim `~` per
2324
- * skipped, a dim `~ (ships with worker)` per Durable Object, a red `✗` per failure, then a summary
2325
- * line (failed count red when non-zero) — followed, when anything failed, by a detail block printing
2326
- * each failure's FULL reason (ANSI-stripped and word-wrapped) so it is actually readable instead of
2327
- * truncated inside the box.
2328
- *
2329
- * @param ui - The branded console to render through.
2330
- * @param result - The provision result from provisionInfra()/the deploy pipeline.
2331
- * @example
2332
- * ```ts
2333
- * renderProvisionResult(ui, await provisionInfra(plan));
2334
- * ```
2335
- */
2336
- const renderProvisionResult = (ui, result) => {
2337
- const { palette } = ui;
2338
- const createdRows = result.created.map((ref) => `${palette.green("✓")} ${cell(ref.resource.kind, resourceName(ref.resource))}`);
2339
- const skippedRows = result.skipped.map((ref) => `${palette.dim("~")} ${cell(ref.resource.kind, resourceName(ref.resource))} ${palette.dim("(exists)")}`);
2340
- const bundledRows = result.bundled.map((resource) => `${palette.dim("~")} ${cell(resource.kind, resourceName(resource))} ${palette.dim(SHIPS_WITH_WORKER)}`);
2341
- const failedRows = result.failed.map((failure) => `${palette.red("✗")} ${cell(failure.resource.kind, resourceName(failure.resource))}`);
2342
- const failedCount = result.failed.length > 0 ? palette.red(`${String(result.failed.length)} failed`) : "0 failed";
2343
- const counts = [`${String(result.created.length)} created`, `${String(result.skipped.length)} exist`];
2344
- if (result.bundled.length > 0) counts.push(`${String(result.bundled.length)} with worker`);
2345
- const summary = `${counts.join(" · ")} · ${failedCount}`;
2346
- ui.heading("Provisioned");
2347
- ui.box([
2348
- ...createdRows,
2349
- ...skippedRows,
2350
- ...bundledRows,
2351
- ...failedRows,
2352
- "",
2353
- summary
2354
- ]);
2355
- if (result.failed.length > 0) {
2356
- ui.line();
2357
- for (const failure of result.failed) {
2358
- ui.line(` ${palette.red("✗")} ${cell(failure.resource.kind, resourceName(failure.resource))}`);
2359
- for (const wrapped of wrapText(cleanError(failure.error), ui.width - 4)) ui.line(palette.dim(` ${wrapped}`));
2360
- }
2361
- }
2362
- };
2363
- /**
2364
- * Format an elapsed duration compactly: sub-second as `820ms`, otherwise one-decimal seconds (`4.2s`),
2365
- * and minutes once it crosses 60s (`1m04s`) so a long deploy stays readable.
2366
- *
2367
- * @param ms - The elapsed milliseconds.
2368
- * @returns The compact duration string.
2369
- * @example
2370
- * ```ts
2371
- * formatDuration(4234); // "4.2s"
2372
- * ```
2373
- */
2374
- const formatDuration = (ms) => {
2375
- if (ms < 1e3) return `${String(ms)}ms`;
2376
- const seconds = ms / 1e3;
2377
- if (seconds < 60) return `${seconds.toFixed(1)}s`;
2378
- const whole = Math.floor(seconds);
2379
- return `${String(Math.floor(whole / 60))}m${String(whole % 60).padStart(2, "0")}s`;
2380
- };
2381
- /**
2382
- * Render the terminal deploy summary as a branded panel — the headline the user actually wants. The
2383
- * live URL leads on its own line (pink, so it is the first thing the eye lands on), then a dim
2384
- * key/value block: the target stage, the resource tally (with a red `failed` count when non-zero),
2385
- * and the wall-clock time the whole deploy took. Replaces the prior single `deployed → url` line.
2386
- *
2387
- * @param ui - The branded console to render through.
2388
- * @param summary - The deploy summary fields.
2389
- * @param summary.url - The live deployed URL (the panel headline).
2390
- * @param summary.stage - The target stage the worker deployed to.
2391
- * @param summary.created - How many resources were created this run.
2392
- * @param summary.exists - How many resources already existed (skipped).
2393
- * @param summary.bundled - How many Durable Objects shipped with the Worker.
2394
- * @param summary.failed - How many resources failed to provision.
2395
- * @param summary.elapsedMs - The wall-clock deploy duration in milliseconds.
2396
- * @example
2397
- * ```ts
2398
- * renderDeploySummary(ui, { url, stage: "production", created: 0, exists: 5, bundled: 1, failed: 0, elapsedMs: 4234 });
2399
- * ```
2400
- */
2401
- const renderDeploySummary = (ui, summary) => {
2402
- const { palette } = ui;
2403
- const parts = [`${String(summary.exists)} exist`, `${String(summary.created)} created`];
2404
- if (summary.bundled > 0) parts.push(`${String(summary.bundled)} with worker`);
2405
- const tally = parts.join(" · ");
2406
- const failedLabel = palette.red(`${String(summary.failed)} failed`);
2407
- const resources = summary.failed > 0 ? `${tally} · ${failedLabel}` : tally;
2408
- ui.heading("Deployed");
2409
- ui.box([
2410
- palette.pink(summary.url),
2411
- "",
2412
- `${palette.dim("stage".padEnd(10))}${summary.stage}`,
2413
- `${palette.dim("resources".padEnd(10))}${resources}`,
2414
- `${palette.dim("took".padEnd(10))}${formatDuration(summary.elapsedMs)}`
2415
- ]);
2416
- };
2417
- //#endregion
2418
2556
  //#region src/plugins/deploy/naming.ts
2419
2557
  /**
2420
2558
  * @file deploy plugin — stage-aware resource naming.
@@ -3375,25 +3513,34 @@ const guidedDeployStep = async (ctx, manifest, stage, deps) => {
3375
3513
  * migrations dir — the generic, deploy-owned analogue of `wrangler d1 migrations apply <binding>
3376
3514
  * --remote`. The wrangler config was written earlier in the pipeline, so each binding resolves. The
3377
3515
  * caller runs this only AFTER a successful deploy, so a deploy that never happened never migrates a
3378
- * remote DB. Streams wrangler's output; throws on the first non-zero exit (the caller folds it into
3379
- * the report).
3516
+ * remote DB. CAPTURES wrangler's output (the raw TUI is hidden; {@link parseMigrationsApplied} turns
3517
+ * it into the branded panel's facts); throws on the first non-zero exit (the caller folds it into the
3518
+ * report, where the captured error is still surfaced).
3380
3519
  *
3381
3520
  * @param ctx - The deploy plugin context.
3382
- * @returns Resolves once every configured database's remote migrations have been applied.
3521
+ * @returns The per-database migration outcomes (one per d1 instance that declares migrations).
3383
3522
  * @example
3384
3523
  * ```ts
3385
- * await applyRemoteMigrations(ctx);
3524
+ * const outcomes = await applyRemoteMigrations(ctx);
3386
3525
  * ```
3387
3526
  */
3388
3527
  const applyRemoteMigrations = async (ctx) => {
3389
- if (!ctx.has("d1")) return;
3390
- for (const database of ctx.require(d1Plugin).deployManifest()) if (database.migrations !== void 0) await runWranglerInherit([
3391
- "d1",
3392
- "migrations",
3393
- "apply",
3394
- database.binding,
3395
- "--remote"
3396
- ]);
3528
+ if (!ctx.has("d1")) return [];
3529
+ const outcomes = [];
3530
+ for (const database of ctx.require(d1Plugin).deployManifest()) if (database.migrations !== void 0) {
3531
+ const output = await runWrangler([
3532
+ "d1",
3533
+ "migrations",
3534
+ "apply",
3535
+ database.binding,
3536
+ "--remote"
3537
+ ]);
3538
+ outcomes.push({
3539
+ binding: database.binding,
3540
+ ...parseMigrationsApplied(output)
3541
+ });
3542
+ }
3543
+ return outcomes;
3397
3544
  };
3398
3545
  /**
3399
3546
  * Render a post-deploy step's failure as a branded line and capture its message into `errors` —
@@ -3437,9 +3584,13 @@ const runPostDeploy = async (ctx, want) => {
3437
3584
  const errors = [];
3438
3585
  let migration = "skipped";
3439
3586
  if (want.migration) try {
3440
- await applyRemoteMigrations(ctx);
3587
+ ctx.emit("deploy:phase", {
3588
+ phase: "migrate",
3589
+ detail: "remote D1"
3590
+ });
3591
+ const outcomes = await applyRemoteMigrations(ctx);
3441
3592
  migration = "applied";
3442
- ui.check(true, "migrated", "remote D1");
3593
+ if (outcomes.length > 0) renderMigrateSummary(ui, outcomes, "remote");
3443
3594
  } catch (error) {
3444
3595
  migration = "failed";
3445
3596
  captureFailure(ui, errors, error);
@@ -3454,9 +3605,13 @@ const runPostDeploy = async (ctx, want) => {
3454
3605
  seed = "failed";
3455
3606
  captureFailure(ui, errors, /* @__PURE__ */ new Error("[moku-worker] deploy({ seed: true }) but no seed is configured — set pluginConfigs.deploy.seed."));
3456
3607
  } else try {
3457
- await runConfiguredSeed(ctx, runWranglerInherit, config, "--remote");
3608
+ ctx.emit("deploy:phase", {
3609
+ phase: "seed",
3610
+ detail: config.file
3611
+ });
3612
+ const outcome = await runConfiguredSeed(ctx, runWrangler, config, "--remote");
3458
3613
  seed = "applied";
3459
- ui.check(true, "seeded", config.file);
3614
+ renderSeedSummary(ui, outcome, "remote");
3460
3615
  } catch (error) {
3461
3616
  seed = "failed";
3462
3617
  captureFailure(ui, errors, error);
@@ -3594,14 +3749,16 @@ const createDeployApi = (ctx) => ({
3594
3749
  * Execute a SQL file against a configured D1 database via `wrangler d1 execute` — for seeding dev
3595
3750
  * data. Local by default (applies that database's migrations first so the file's tables exist);
3596
3751
  * `opts.remote` seeds Cloudflare (schema is applied by `deploy`). Generates the wrangler config up
3597
- * front so the binding resolves even on a first run. Streams wrangler's output.
3752
+ * front so the binding resolves even on a first run. CAPTURES wrangler's output and renders a
3753
+ * branded "Migrated" / "Seeded" summary (the raw migration/execute TUI is hidden) so the command
3754
+ * reads the same as the rest of the deploy UX; a failure still surfaces the real wrangler error.
3598
3755
  *
3599
3756
  * @param sqlFile - Path to the SQL file to execute (e.g. "db/seed.sql").
3600
3757
  * @param opts - Optional options.
3601
3758
  * @param opts.stage - Stage for the generated config's resource names (defaults to the app stage).
3602
3759
  * @param opts.binding - The d1 binding to target when more than one is configured (e.g. "DB").
3603
3760
  * @param opts.remote - Seed the remote (Cloudflare) D1 instead of the local one.
3604
- * @returns Resolves once wrangler finishes executing the file.
3761
+ * @returns Resolves once wrangler finishes executing the file and the summary is rendered.
3605
3762
  * @example
3606
3763
  * ```ts
3607
3764
  * await api.seed("db/seed.sql"); // local default d1 (migrate, then execute)
@@ -3614,14 +3771,22 @@ const createDeployApi = (ctx) => ({
3614
3771
  await writeWranglerConfig(ctx.config.configFile, assembleManifest(ctx, stage), {}, wranglerExtra(ctx.config));
3615
3772
  const target = resolveD1(ctx, opts?.binding);
3616
3773
  const scope = opts?.remote === true ? "--remote" : "--local";
3617
- if (scope === "--local" && target.migrations !== void 0) await runWranglerInherit([
3618
- "d1",
3619
- "migrations",
3620
- "apply",
3621
- target.binding,
3622
- "--local"
3623
- ]);
3624
- await runWranglerInherit([
3774
+ const where = opts?.remote === true ? "remote" : "local";
3775
+ const ui = (0, _moku_labs_common_cli.createBrandConsole)();
3776
+ if (scope === "--local" && target.migrations !== void 0) {
3777
+ const migrated = await runWrangler([
3778
+ "d1",
3779
+ "migrations",
3780
+ "apply",
3781
+ target.binding,
3782
+ "--local"
3783
+ ]);
3784
+ renderMigrateSummary(ui, [{
3785
+ binding: target.binding,
3786
+ ...parseMigrationsApplied(migrated)
3787
+ }], where);
3788
+ }
3789
+ const executed = await runWrangler([
3625
3790
  "d1",
3626
3791
  "execute",
3627
3792
  target.binding,
@@ -3629,6 +3794,12 @@ const createDeployApi = (ctx) => ({
3629
3794
  "--file",
3630
3795
  sqlFile
3631
3796
  ]);
3797
+ renderSeedSummary(ui, {
3798
+ file: sqlFile,
3799
+ binding: target.binding,
3800
+ resetKv: [],
3801
+ ...parseSeedStats(executed)
3802
+ }, where);
3632
3803
  },
3633
3804
  /**
3634
3805
  * Scaffold a starting wrangler config (and CI files when ci is set).