@moku-labs/worker 0.9.1 → 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.
@@ -1512,6 +1512,287 @@ const verifyAuth = async (ctx) => {
1512
1512
  };
1513
1513
  };
1514
1514
  //#endregion
1515
+ //#region src/plugins/deploy/infra/render.ts
1516
+ /**
1517
+ * Derive a human-readable name from a resource descriptor: the Cloudflare resource `name` for the
1518
+ * provisioned kinds (kv/r2/d1/queue), or the exported `className` for a Durable Object (which has no
1519
+ * provisioned name). Used in both the provision events and the branded panels so the two agree.
1520
+ *
1521
+ * @param resource - The resource descriptor.
1522
+ * @returns A short name identifying the resource.
1523
+ * @example
1524
+ * ```ts
1525
+ * resourceName({ kind: "kv", name: "tracker-cache", binding: "CACHE" }); // "tracker-cache"
1526
+ * ```
1527
+ */
1528
+ const resourceName = (resource) => resource.kind === "do" ? resource.className : resource.name;
1529
+ /**
1530
+ * Format a `kind name` cell, padding the kind so the names line up in a column.
1531
+ *
1532
+ * @param kind - The resource kind (kv / r2 / d1 / queue / do).
1533
+ * @param name - The resource name.
1534
+ * @returns The aligned `kind name` cell.
1535
+ * @example
1536
+ * ```ts
1537
+ * cell("kv", "CACHE"); // "kv CACHE"
1538
+ * ```
1539
+ */
1540
+ const cell = (kind, name) => `${kind.padEnd(6)}${name}`;
1541
+ /**
1542
+ * Row tag for a Durable Object — it ships with the Worker (`wrangler deploy` creates the namespace),
1543
+ * so it is NEVER labelled `(exists)` (the planner never queried the account for it). Shared by the
1544
+ * plan and provision-result panels so the two always read the same.
1545
+ */
1546
+ const SHIPS_WITH_WORKER = "(ships with worker)";
1547
+ /**
1548
+ * ANSI SGR matcher — built from `String.fromCharCode(27)` (the ESC byte) so no control character
1549
+ * appears in a regex literal (which both linters reject).
1550
+ */
1551
+ const ANSI_SGR = new RegExp(String.raw`${String.fromCodePoint(27)}\[[0-9;]*m`, "gu");
1552
+ /**
1553
+ * Strip ANSI SGR escape sequences so a captured (colorized) error renders as plain, readable text.
1554
+ *
1555
+ * @param text - The (possibly colorized) text.
1556
+ * @returns The text with ANSI color codes removed.
1557
+ * @example
1558
+ * ```ts
1559
+ * stripAnsi(`${String.fromCharCode(27)}[31mX${String.fromCharCode(27)}[0m`); // "X"
1560
+ * ```
1561
+ */
1562
+ const stripAnsi = (text) => text.replaceAll(ANSI_SGR, "");
1563
+ /**
1564
+ * Clean a captured (colorized, multi-line, wrapper-wrapped) provision error down to its meaningful
1565
+ * text: strip ANSI, drop the wrapper lines (the branded prefix, wrangler's log-file pointer), strip
1566
+ * each `✘ [ERROR]` marker, and join what's left. Returns the FULL message (the caller word-wraps it)
1567
+ * so the user reads the actual reason — never a truncated `…`.
1568
+ *
1569
+ * @param message - The captured error message.
1570
+ * @returns The full, plain failure reason.
1571
+ * @example
1572
+ * ```ts
1573
+ * cleanError("[moku-worker] wrangler exited…\n ✘ [ERROR] The bucket name is invalid.");
1574
+ * // "The bucket name is invalid."
1575
+ * ```
1576
+ */
1577
+ const cleanError = (message) => {
1578
+ 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(" ");
1579
+ return cleaned.length > 0 ? cleaned : stripAnsi(message).trim();
1580
+ };
1581
+ /**
1582
+ * Word-wrap text to `width` columns (never splitting inside a word), so a long failure reason reads
1583
+ * as a tidy indented block instead of forcing the box wide or scrolling off the edge.
1584
+ *
1585
+ * @param text - The text to wrap.
1586
+ * @param width - The maximum column width per line.
1587
+ * @returns The wrapped lines.
1588
+ * @example
1589
+ * ```ts
1590
+ * wrapText("a long sentence to wrap", 10); // ["a long", "sentence", "to wrap"]
1591
+ * ```
1592
+ */
1593
+ const wrapText = (text, width) => {
1594
+ const lines = [];
1595
+ let line = "";
1596
+ for (const word of text.split(/\s+/u).filter(Boolean)) if (line.length === 0) line = word;
1597
+ else if (line.length + 1 + word.length <= width) line += ` ${word}`;
1598
+ else {
1599
+ lines.push(line);
1600
+ line = word;
1601
+ }
1602
+ if (line.length > 0) lines.push(line);
1603
+ return lines;
1604
+ };
1605
+ /**
1606
+ * Render the infra preflight plan as a branded panel: a dim summary line (counts + account) then one
1607
+ * row per declared resource — a pink `+` for those to create, a dim `~ (exists)` for those already
1608
+ * present, and a dim `~ (ships with worker)` for Durable Objects (created by `wrangler deploy`, never
1609
+ * pre-provisioned). When nothing needs creating it still renders, so the user sees the full picture.
1610
+ *
1611
+ * @param ui - The branded console to render through.
1612
+ * @param plan - The infra plan (existing vs missing vs ships-with-Worker) from checkInfra()/planInfra().
1613
+ * @example
1614
+ * ```ts
1615
+ * renderPlan(ui, await planInfra(ctx, manifest));
1616
+ * ```
1617
+ */
1618
+ const renderPlan = (ui, plan) => {
1619
+ const { palette } = ui;
1620
+ const counts = [`${String(plan.missing.length)} to create`, `${String(plan.exists.length)} exist`];
1621
+ if (plan.ships.length > 0) counts.push(`${String(plan.ships.length)} with worker`);
1622
+ const summary = palette.dim(`${counts.join(" · ")} · ${plan.account}`);
1623
+ const createRows = plan.missing.map((resource) => `${palette.pink("+")} ${cell(resource.kind, resourceName(resource))}`);
1624
+ const existsRows = plan.exists.map((ref) => `${palette.dim("~")} ${cell(ref.resource.kind, resourceName(ref.resource))} ${palette.dim("(exists)")}`);
1625
+ const shipsRows = plan.ships.map((resource) => `${palette.dim("~")} ${cell(resource.kind, resourceName(resource))} ${palette.dim(SHIPS_WITH_WORKER)}`);
1626
+ ui.heading("Infra plan");
1627
+ ui.box([
1628
+ summary,
1629
+ "",
1630
+ ...createRows,
1631
+ ...existsRows,
1632
+ ...shipsRows
1633
+ ]);
1634
+ };
1635
+ /**
1636
+ * Render the provision result as a branded panel — a green `✓` per created resource, a dim `~` per
1637
+ * skipped, a dim `~ (ships with worker)` per Durable Object, a red `✗` per failure, then a summary
1638
+ * line (failed count red when non-zero) — followed, when anything failed, by a detail block printing
1639
+ * each failure's FULL reason (ANSI-stripped and word-wrapped) so it is actually readable instead of
1640
+ * truncated inside the box.
1641
+ *
1642
+ * @param ui - The branded console to render through.
1643
+ * @param result - The provision result from provisionInfra()/the deploy pipeline.
1644
+ * @example
1645
+ * ```ts
1646
+ * renderProvisionResult(ui, await provisionInfra(plan));
1647
+ * ```
1648
+ */
1649
+ const renderProvisionResult = (ui, result) => {
1650
+ const { palette } = ui;
1651
+ const createdRows = result.created.map((ref) => `${palette.green("✓")} ${cell(ref.resource.kind, resourceName(ref.resource))}`);
1652
+ const skippedRows = result.skipped.map((ref) => `${palette.dim("~")} ${cell(ref.resource.kind, resourceName(ref.resource))} ${palette.dim("(exists)")}`);
1653
+ const bundledRows = result.bundled.map((resource) => `${palette.dim("~")} ${cell(resource.kind, resourceName(resource))} ${palette.dim(SHIPS_WITH_WORKER)}`);
1654
+ const failedRows = result.failed.map((failure) => `${palette.red("✗")} ${cell(failure.resource.kind, resourceName(failure.resource))}`);
1655
+ const failedCount = result.failed.length > 0 ? palette.red(`${String(result.failed.length)} failed`) : "0 failed";
1656
+ const counts = [`${String(result.created.length)} created`, `${String(result.skipped.length)} exist`];
1657
+ if (result.bundled.length > 0) counts.push(`${String(result.bundled.length)} with worker`);
1658
+ const summary = `${counts.join(" · ")} · ${failedCount}`;
1659
+ ui.heading("Provisioned");
1660
+ ui.box([
1661
+ ...createdRows,
1662
+ ...skippedRows,
1663
+ ...bundledRows,
1664
+ ...failedRows,
1665
+ "",
1666
+ summary
1667
+ ]);
1668
+ if (result.failed.length > 0) {
1669
+ ui.line();
1670
+ for (const failure of result.failed) {
1671
+ ui.line(` ${palette.red("✗")} ${cell(failure.resource.kind, resourceName(failure.resource))}`);
1672
+ for (const wrapped of wrapText(cleanError(failure.error), ui.width - 4)) ui.line(palette.dim(` ${wrapped}`));
1673
+ }
1674
+ }
1675
+ };
1676
+ /**
1677
+ * Format an elapsed duration compactly: sub-second as `820ms`, otherwise one-decimal seconds (`4.2s`),
1678
+ * and minutes once it crosses 60s (`1m04s`) so a long deploy stays readable.
1679
+ *
1680
+ * @param ms - The elapsed milliseconds.
1681
+ * @returns The compact duration string.
1682
+ * @example
1683
+ * ```ts
1684
+ * formatDuration(4234); // "4.2s"
1685
+ * ```
1686
+ */
1687
+ const formatDuration = (ms) => {
1688
+ if (ms < 1e3) return `${String(ms)}ms`;
1689
+ const seconds = ms / 1e3;
1690
+ if (seconds < 60) return `${seconds.toFixed(1)}s`;
1691
+ const whole = Math.floor(seconds);
1692
+ return `${String(Math.floor(whole / 60))}m${String(whole % 60).padStart(2, "0")}s`;
1693
+ };
1694
+ /**
1695
+ * Render the terminal deploy summary as a branded panel — the headline the user actually wants. The
1696
+ * live URL leads on its own line (pink, so it is the first thing the eye lands on), then a dim
1697
+ * key/value block: the target stage, the resource tally (with a red `failed` count when non-zero),
1698
+ * and the wall-clock time the whole deploy took. Replaces the prior single `deployed → url` line.
1699
+ *
1700
+ * @param ui - The branded console to render through.
1701
+ * @param summary - The deploy summary fields.
1702
+ * @param summary.url - The live deployed URL (the panel headline).
1703
+ * @param summary.stage - The target stage the worker deployed to.
1704
+ * @param summary.created - How many resources were created this run.
1705
+ * @param summary.exists - How many resources already existed (skipped).
1706
+ * @param summary.bundled - How many Durable Objects shipped with the Worker.
1707
+ * @param summary.failed - How many resources failed to provision.
1708
+ * @param summary.elapsedMs - The wall-clock deploy duration in milliseconds.
1709
+ * @example
1710
+ * ```ts
1711
+ * renderDeploySummary(ui, { url, stage: "production", created: 0, exists: 5, bundled: 1, failed: 0, elapsedMs: 4234 });
1712
+ * ```
1713
+ */
1714
+ const renderDeploySummary = (ui, summary) => {
1715
+ const { palette } = ui;
1716
+ const parts = [`${String(summary.exists)} exist`, `${String(summary.created)} created`];
1717
+ if (summary.bundled > 0) parts.push(`${String(summary.bundled)} with worker`);
1718
+ const tally = parts.join(" · ");
1719
+ const failedLabel = palette.red(`${String(summary.failed)} failed`);
1720
+ const resources = summary.failed > 0 ? `${tally} · ${failedLabel}` : tally;
1721
+ ui.heading("Deployed");
1722
+ ui.box([
1723
+ palette.pink(summary.url),
1724
+ "",
1725
+ `${palette.dim("stage".padEnd(10))}${summary.stage}`,
1726
+ `${palette.dim("resources".padEnd(10))}${resources}`,
1727
+ `${palette.dim("took".padEnd(10))}${formatDuration(summary.elapsedMs)}`
1728
+ ]);
1729
+ };
1730
+ /**
1731
+ * Render the D1 migration outcome as a branded panel — the readable replacement for wrangler's raw
1732
+ * `d1 migrations apply` TUI. One row per database: the `d1 <binding>` cell plus a pink `N applied`
1733
+ * count (or a dim `up to date` when nothing was pending), with each applied migration filename listed
1734
+ * beneath as a green `✓`. A dim scope footer (`remote` / `local`) names which database was touched.
1735
+ * The caller renders this only when at least one database actually ran migrations.
1736
+ *
1737
+ * @param ui - The branded console to render through.
1738
+ * @param outcomes - The per-database migration outcomes (one per d1 instance that declares migrations).
1739
+ * @param scope - Which database the migrations ran against: `remote` (Cloudflare) or `local` (dev).
1740
+ * @example
1741
+ * ```ts
1742
+ * renderMigrateSummary(ui, [{ binding: "DB", applied: ["0003_x.sql"], upToDate: false }], "remote");
1743
+ * ```
1744
+ */
1745
+ const renderMigrateSummary = (ui, outcomes, scope) => {
1746
+ const { palette } = ui;
1747
+ const rows = [];
1748
+ for (const outcome of outcomes) {
1749
+ const count = outcome.applied.length;
1750
+ const appliedLabel = palette.pink(count > 0 ? `${String(count)} applied` : "applied");
1751
+ const status = outcome.upToDate ? palette.dim("up to date") : appliedLabel;
1752
+ rows.push(`${cell("d1", outcome.binding)} ${status}`);
1753
+ for (const name of outcome.applied) rows.push(` ${palette.green("✓")} ${palette.dim(name)}`);
1754
+ }
1755
+ ui.heading("Migrated");
1756
+ ui.box([
1757
+ ...rows,
1758
+ "",
1759
+ palette.dim(scope)
1760
+ ]);
1761
+ };
1762
+ /**
1763
+ * Render the seed outcome as a branded panel — the readable replacement for wrangler's raw
1764
+ * `d1 execute` / `kv key delete` TUI. Leads with the loaded `file → binding` (pink file), an optional
1765
+ * dim stats line (rows written / statements, only the parts wrangler reported), then — when the seed
1766
+ * cleared cached KV keys — a `KV reset` block listing each `~ binding key` so the user sees exactly
1767
+ * what was dropped. A dim scope footer (`remote` / `local`) names which database was seeded.
1768
+ *
1769
+ * @param ui - The branded console to render through.
1770
+ * @param outcome - The seed outcome (file, target binding, best-effort counts, the KV keys reset).
1771
+ * @param scope - Which database the seed ran against: `remote` (Cloudflare) or `local` (dev).
1772
+ * @example
1773
+ * ```ts
1774
+ * renderSeedSummary(ui, { file: "db/seed.sql", binding: "DB", rowsWritten: 18, resetKv: [] }, "remote");
1775
+ * ```
1776
+ */
1777
+ const renderSeedSummary = (ui, outcome, scope) => {
1778
+ const { palette } = ui;
1779
+ const lines = [`${palette.pink(outcome.file)} ${palette.dim("→")} ${outcome.binding}`];
1780
+ const stats = [];
1781
+ if (outcome.rowsWritten !== void 0) stats.push(`${String(outcome.rowsWritten)} rows written`);
1782
+ if (outcome.statements !== void 0) stats.push(`${String(outcome.statements)} statements`);
1783
+ if (stats.length > 0) lines.push(palette.dim(stats.join(" · ")));
1784
+ if (outcome.resetKv.length > 0) {
1785
+ lines.push("", palette.dim("KV reset"));
1786
+ for (const entry of outcome.resetKv) lines.push(`${palette.dim("~")} ${entry.binding} ${palette.dim(entry.key)}`);
1787
+ }
1788
+ ui.heading("Seeded");
1789
+ ui.box([
1790
+ ...lines,
1791
+ "",
1792
+ palette.dim(scope)
1793
+ ]);
1794
+ };
1795
+ //#endregion
1515
1796
  //#region src/plugins/deploy/runner.ts
1516
1797
  /**
1517
1798
  * @file deploy plugin — wrangler subprocess wrapper (node:child_process).
@@ -1619,6 +1900,61 @@ const runWranglerInherit = (args) => {
1619
1900
  * Node-only; never imported by the runtime Worker bundle.
1620
1901
  */
1621
1902
  /**
1903
+ * Parse the best-effort row/statement counts from wrangler's `d1 execute` output so the branded seed
1904
+ * panel can report them — degrading gracefully (each field simply omitted) when wrangler's format
1905
+ * differs or the runner streamed instead of captured. Wrangler prints lines like "🚣 18 commands
1906
+ * executed" and a rows-written total; both are matched loosely (case-insensitive).
1907
+ *
1908
+ * @param output - The captured stdout from `wrangler d1 execute` (empty when the runner streamed).
1909
+ * @returns The parsed counts — each field present only when found.
1910
+ * @example
1911
+ * ```ts
1912
+ * parseSeedStats("🚣 18 commands executed (30 rows written)"); // { statements: 18, rowsWritten: 30 }
1913
+ * ```
1914
+ */
1915
+ const parseSeedStats = (output) => {
1916
+ const rows = /(\d{1,12}) rows? written/iu.exec(output);
1917
+ const commands = /(\d{1,12}) commands? executed/iu.exec(output) ?? /executed (\d{1,12}) commands?/iu.exec(output);
1918
+ const result = {};
1919
+ if (commands?.[1] !== void 0) result.statements = Number(commands[1]);
1920
+ if (rows?.[1] !== void 0) result.rowsWritten = Number(rows[1]);
1921
+ return result;
1922
+ };
1923
+ /**
1924
+ * Parse which migrations wrangler applied from its captured `d1 migrations apply` output, so the
1925
+ * branded migrate panel can name them instead of dumping wrangler's raw migration TUI. `upToDate` is
1926
+ * true when wrangler reported nothing pending ("No migrations to apply"); otherwise every
1927
+ * `NNNN_name.sql` filename token in the output is collected in order (de-duplicated). Degrades
1928
+ * safely — an unrecognized format yields no names, and the panel falls back to a generic "applied".
1929
+ * Lives here (not in api.ts) so both the deploy path and the dev path parse it without a cycle.
1930
+ *
1931
+ * @param output - The captured stdout from `wrangler d1 migrations apply`.
1932
+ * @returns The applied migration filenames and whether the database was already up to date.
1933
+ * @example
1934
+ * ```ts
1935
+ * parseMigrationsApplied("Applied 0003_x.sql\n0004_y.sql"); // { applied: ["0003_x.sql", "0004_y.sql"], upToDate: false }
1936
+ * ```
1937
+ */
1938
+ const parseMigrationsApplied = (output) => {
1939
+ if (/no migrations to apply/iu.test(output)) return {
1940
+ applied: [],
1941
+ upToDate: true
1942
+ };
1943
+ const applied = [];
1944
+ const seen = /* @__PURE__ */ new Set();
1945
+ for (const match of output.matchAll(/\b\d{3,}_[A-Za-z0-9_-]+\.sql\b/gu)) {
1946
+ const name = match[0];
1947
+ if (!seen.has(name)) {
1948
+ seen.add(name);
1949
+ applied.push(name);
1950
+ }
1951
+ }
1952
+ return {
1953
+ applied,
1954
+ upToDate: false
1955
+ };
1956
+ };
1957
+ /**
1622
1958
  * Resolve the single configured d1 database (or the one bound to `binding` when several exist) from
1623
1959
  * the d1 plugin's manifest. The shared resolver behind `seed()`, the post-deploy seed, and the dev
1624
1960
  * seed; throws a branded error when the choice is ambiguous (none/several, no binding) or unknown.
@@ -1647,27 +1983,30 @@ const resolveD1 = (ctx, binding) => {
1647
1983
  * injectable dev path.
1648
1984
  *
1649
1985
  * @param ctx - The deploy plugin context.
1650
- * @param run - The wrangler runner to execute each command through.
1986
+ * @param run - The wrangler runner to execute each command through (a CAPTURING runner lets the
1987
+ * returned outcome report row/statement counts; a streaming one still works, just without them).
1651
1988
  * @param seed - The resolved seed config (SQL file, optional binding, KV keys to reset).
1652
1989
  * @param scope - The wrangler scope: `--remote` (deploy) or `--local` (dev).
1653
- * @returns Resolves once the seed file has executed and every cached KV key is cleared.
1990
+ * @returns The seed outcome (file, target binding, best-effort counts, and the KV keys that were reset).
1654
1991
  * @throws {Error} When no d1 database is configured, or the seed's binding cannot be resolved.
1655
1992
  * @example
1656
1993
  * ```ts
1657
- * await runConfiguredSeed(ctx, runWranglerInherit, ctx.config.seed, "--remote");
1994
+ * const outcome = await runConfiguredSeed(ctx, runWrangler, ctx.config.seed, "--remote");
1658
1995
  * ```
1659
1996
  */
1660
1997
  const runConfiguredSeed = async (ctx, run, seed, scope) => {
1661
1998
  if (!ctx.has("d1")) throw new Error("[moku-worker] seed: no d1 database is configured.");
1662
- await run([
1999
+ const database = resolveD1(ctx, seed.binding);
2000
+ const executed = await run([
1663
2001
  "d1",
1664
2002
  "execute",
1665
- resolveD1(ctx, seed.binding).binding,
2003
+ database.binding,
1666
2004
  scope,
1667
2005
  "--file",
1668
2006
  seed.file
1669
2007
  ]);
1670
- for (const entry of seed.resetKv ?? []) await run([
2008
+ const resetKv = seed.resetKv ?? [];
2009
+ for (const entry of resetKv) await run([
1671
2010
  "kv",
1672
2011
  "key",
1673
2012
  "delete",
@@ -1676,6 +2015,12 @@ const runConfiguredSeed = async (ctx, run, seed, scope) => {
1676
2015
  entry.binding,
1677
2016
  scope
1678
2017
  ]);
2018
+ return {
2019
+ file: seed.file,
2020
+ binding: database.binding,
2021
+ resetKv,
2022
+ ...parseSeedStats(typeof executed === "string" ? executed : "")
2023
+ };
1679
2024
  };
1680
2025
  //#endregion
1681
2026
  //#region src/plugins/deploy/dev/build.ts
@@ -2021,7 +2366,8 @@ const seedLocal = async (ctx, deps) => {
2021
2366
  phase: "seed",
2022
2367
  detail: config.file
2023
2368
  });
2024
- await runConfiguredSeed(ctx, deps.runWrangler, config, "--local");
2369
+ const outcome = await runConfiguredSeed(ctx, deps.runWrangler, config, "--local");
2370
+ renderSeedSummary(createBrandConsole(), outcome, "local");
2025
2371
  };
2026
2372
  /**
2027
2373
  * Run a long-lived dev session: cold build → (local d1 migrate) → (local seed) → spawn `wrangler
@@ -2057,13 +2403,21 @@ const runDev = async (ctx, opts, deps) => {
2057
2403
  phase: "migrate",
2058
2404
  detail: "d1 (local)"
2059
2405
  });
2060
- for (const binding of migrationBindings) await deps.runWrangler([
2061
- "d1",
2062
- "migrations",
2063
- "apply",
2064
- binding,
2065
- "--local"
2066
- ]);
2406
+ const outcomes = [];
2407
+ for (const binding of migrationBindings) {
2408
+ const output = await deps.runWrangler([
2409
+ "d1",
2410
+ "migrations",
2411
+ "apply",
2412
+ binding,
2413
+ "--local"
2414
+ ]);
2415
+ outcomes.push({
2416
+ binding,
2417
+ ...parseMigrationsApplied(output)
2418
+ });
2419
+ }
2420
+ renderMigrateSummary(createBrandConsole(), outcomes, "local");
2067
2421
  }
2068
2422
  if (seed) await seedLocal(ctx, deps);
2069
2423
  ctx.emit("dev:phase", {
@@ -2176,222 +2530,6 @@ const planInfra = async (ctx, manifest) => {
2176
2530
  };
2177
2531
  };
2178
2532
  //#endregion
2179
- //#region src/plugins/deploy/infra/render.ts
2180
- /**
2181
- * Derive a human-readable name from a resource descriptor: the Cloudflare resource `name` for the
2182
- * provisioned kinds (kv/r2/d1/queue), or the exported `className` for a Durable Object (which has no
2183
- * provisioned name). Used in both the provision events and the branded panels so the two agree.
2184
- *
2185
- * @param resource - The resource descriptor.
2186
- * @returns A short name identifying the resource.
2187
- * @example
2188
- * ```ts
2189
- * resourceName({ kind: "kv", name: "tracker-cache", binding: "CACHE" }); // "tracker-cache"
2190
- * ```
2191
- */
2192
- const resourceName = (resource) => resource.kind === "do" ? resource.className : resource.name;
2193
- /**
2194
- * Format a `kind name` cell, padding the kind so the names line up in a column.
2195
- *
2196
- * @param kind - The resource kind (kv / r2 / d1 / queue / do).
2197
- * @param name - The resource name.
2198
- * @returns The aligned `kind name` cell.
2199
- * @example
2200
- * ```ts
2201
- * cell("kv", "CACHE"); // "kv CACHE"
2202
- * ```
2203
- */
2204
- const cell = (kind, name) => `${kind.padEnd(6)}${name}`;
2205
- /**
2206
- * Row tag for a Durable Object — it ships with the Worker (`wrangler deploy` creates the namespace),
2207
- * so it is NEVER labelled `(exists)` (the planner never queried the account for it). Shared by the
2208
- * plan and provision-result panels so the two always read the same.
2209
- */
2210
- const SHIPS_WITH_WORKER = "(ships with worker)";
2211
- /**
2212
- * ANSI SGR matcher — built from `String.fromCharCode(27)` (the ESC byte) so no control character
2213
- * appears in a regex literal (which both linters reject).
2214
- */
2215
- const ANSI_SGR = new RegExp(String.raw`${String.fromCodePoint(27)}\[[0-9;]*m`, "gu");
2216
- /**
2217
- * Strip ANSI SGR escape sequences so a captured (colorized) error renders as plain, readable text.
2218
- *
2219
- * @param text - The (possibly colorized) text.
2220
- * @returns The text with ANSI color codes removed.
2221
- * @example
2222
- * ```ts
2223
- * stripAnsi(`${String.fromCharCode(27)}[31mX${String.fromCharCode(27)}[0m`); // "X"
2224
- * ```
2225
- */
2226
- const stripAnsi = (text) => text.replaceAll(ANSI_SGR, "");
2227
- /**
2228
- * Clean a captured (colorized, multi-line, wrapper-wrapped) provision error down to its meaningful
2229
- * text: strip ANSI, drop the wrapper lines (the branded prefix, wrangler's log-file pointer), strip
2230
- * each `✘ [ERROR]` marker, and join what's left. Returns the FULL message (the caller word-wraps it)
2231
- * so the user reads the actual reason — never a truncated `…`.
2232
- *
2233
- * @param message - The captured error message.
2234
- * @returns The full, plain failure reason.
2235
- * @example
2236
- * ```ts
2237
- * cleanError("[moku-worker] wrangler exited…\n ✘ [ERROR] The bucket name is invalid.");
2238
- * // "The bucket name is invalid."
2239
- * ```
2240
- */
2241
- const cleanError = (message) => {
2242
- 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(" ");
2243
- return cleaned.length > 0 ? cleaned : stripAnsi(message).trim();
2244
- };
2245
- /**
2246
- * Word-wrap text to `width` columns (never splitting inside a word), so a long failure reason reads
2247
- * as a tidy indented block instead of forcing the box wide or scrolling off the edge.
2248
- *
2249
- * @param text - The text to wrap.
2250
- * @param width - The maximum column width per line.
2251
- * @returns The wrapped lines.
2252
- * @example
2253
- * ```ts
2254
- * wrapText("a long sentence to wrap", 10); // ["a long", "sentence", "to wrap"]
2255
- * ```
2256
- */
2257
- const wrapText = (text, width) => {
2258
- const lines = [];
2259
- let line = "";
2260
- for (const word of text.split(/\s+/u).filter(Boolean)) if (line.length === 0) line = word;
2261
- else if (line.length + 1 + word.length <= width) line += ` ${word}`;
2262
- else {
2263
- lines.push(line);
2264
- line = word;
2265
- }
2266
- if (line.length > 0) lines.push(line);
2267
- return lines;
2268
- };
2269
- /**
2270
- * Render the infra preflight plan as a branded panel: a dim summary line (counts + account) then one
2271
- * row per declared resource — a pink `+` for those to create, a dim `~ (exists)` for those already
2272
- * present, and a dim `~ (ships with worker)` for Durable Objects (created by `wrangler deploy`, never
2273
- * pre-provisioned). When nothing needs creating it still renders, so the user sees the full picture.
2274
- *
2275
- * @param ui - The branded console to render through.
2276
- * @param plan - The infra plan (existing vs missing vs ships-with-Worker) from checkInfra()/planInfra().
2277
- * @example
2278
- * ```ts
2279
- * renderPlan(ui, await planInfra(ctx, manifest));
2280
- * ```
2281
- */
2282
- const renderPlan = (ui, plan) => {
2283
- const { palette } = ui;
2284
- const counts = [`${String(plan.missing.length)} to create`, `${String(plan.exists.length)} exist`];
2285
- if (plan.ships.length > 0) counts.push(`${String(plan.ships.length)} with worker`);
2286
- const summary = palette.dim(`${counts.join(" · ")} · ${plan.account}`);
2287
- const createRows = plan.missing.map((resource) => `${palette.pink("+")} ${cell(resource.kind, resourceName(resource))}`);
2288
- const existsRows = plan.exists.map((ref) => `${palette.dim("~")} ${cell(ref.resource.kind, resourceName(ref.resource))} ${palette.dim("(exists)")}`);
2289
- const shipsRows = plan.ships.map((resource) => `${palette.dim("~")} ${cell(resource.kind, resourceName(resource))} ${palette.dim(SHIPS_WITH_WORKER)}`);
2290
- ui.heading("Infra plan");
2291
- ui.box([
2292
- summary,
2293
- "",
2294
- ...createRows,
2295
- ...existsRows,
2296
- ...shipsRows
2297
- ]);
2298
- };
2299
- /**
2300
- * Render the provision result as a branded panel — a green `✓` per created resource, a dim `~` per
2301
- * skipped, a dim `~ (ships with worker)` per Durable Object, a red `✗` per failure, then a summary
2302
- * line (failed count red when non-zero) — followed, when anything failed, by a detail block printing
2303
- * each failure's FULL reason (ANSI-stripped and word-wrapped) so it is actually readable instead of
2304
- * truncated inside the box.
2305
- *
2306
- * @param ui - The branded console to render through.
2307
- * @param result - The provision result from provisionInfra()/the deploy pipeline.
2308
- * @example
2309
- * ```ts
2310
- * renderProvisionResult(ui, await provisionInfra(plan));
2311
- * ```
2312
- */
2313
- const renderProvisionResult = (ui, result) => {
2314
- const { palette } = ui;
2315
- const createdRows = result.created.map((ref) => `${palette.green("✓")} ${cell(ref.resource.kind, resourceName(ref.resource))}`);
2316
- const skippedRows = result.skipped.map((ref) => `${palette.dim("~")} ${cell(ref.resource.kind, resourceName(ref.resource))} ${palette.dim("(exists)")}`);
2317
- const bundledRows = result.bundled.map((resource) => `${palette.dim("~")} ${cell(resource.kind, resourceName(resource))} ${palette.dim(SHIPS_WITH_WORKER)}`);
2318
- const failedRows = result.failed.map((failure) => `${palette.red("✗")} ${cell(failure.resource.kind, resourceName(failure.resource))}`);
2319
- const failedCount = result.failed.length > 0 ? palette.red(`${String(result.failed.length)} failed`) : "0 failed";
2320
- const counts = [`${String(result.created.length)} created`, `${String(result.skipped.length)} exist`];
2321
- if (result.bundled.length > 0) counts.push(`${String(result.bundled.length)} with worker`);
2322
- const summary = `${counts.join(" · ")} · ${failedCount}`;
2323
- ui.heading("Provisioned");
2324
- ui.box([
2325
- ...createdRows,
2326
- ...skippedRows,
2327
- ...bundledRows,
2328
- ...failedRows,
2329
- "",
2330
- summary
2331
- ]);
2332
- if (result.failed.length > 0) {
2333
- ui.line();
2334
- for (const failure of result.failed) {
2335
- ui.line(` ${palette.red("✗")} ${cell(failure.resource.kind, resourceName(failure.resource))}`);
2336
- for (const wrapped of wrapText(cleanError(failure.error), ui.width - 4)) ui.line(palette.dim(` ${wrapped}`));
2337
- }
2338
- }
2339
- };
2340
- /**
2341
- * Format an elapsed duration compactly: sub-second as `820ms`, otherwise one-decimal seconds (`4.2s`),
2342
- * and minutes once it crosses 60s (`1m04s`) so a long deploy stays readable.
2343
- *
2344
- * @param ms - The elapsed milliseconds.
2345
- * @returns The compact duration string.
2346
- * @example
2347
- * ```ts
2348
- * formatDuration(4234); // "4.2s"
2349
- * ```
2350
- */
2351
- const formatDuration = (ms) => {
2352
- if (ms < 1e3) return `${String(ms)}ms`;
2353
- const seconds = ms / 1e3;
2354
- if (seconds < 60) return `${seconds.toFixed(1)}s`;
2355
- const whole = Math.floor(seconds);
2356
- return `${String(Math.floor(whole / 60))}m${String(whole % 60).padStart(2, "0")}s`;
2357
- };
2358
- /**
2359
- * Render the terminal deploy summary as a branded panel — the headline the user actually wants. The
2360
- * live URL leads on its own line (pink, so it is the first thing the eye lands on), then a dim
2361
- * key/value block: the target stage, the resource tally (with a red `failed` count when non-zero),
2362
- * and the wall-clock time the whole deploy took. Replaces the prior single `deployed → url` line.
2363
- *
2364
- * @param ui - The branded console to render through.
2365
- * @param summary - The deploy summary fields.
2366
- * @param summary.url - The live deployed URL (the panel headline).
2367
- * @param summary.stage - The target stage the worker deployed to.
2368
- * @param summary.created - How many resources were created this run.
2369
- * @param summary.exists - How many resources already existed (skipped).
2370
- * @param summary.bundled - How many Durable Objects shipped with the Worker.
2371
- * @param summary.failed - How many resources failed to provision.
2372
- * @param summary.elapsedMs - The wall-clock deploy duration in milliseconds.
2373
- * @example
2374
- * ```ts
2375
- * renderDeploySummary(ui, { url, stage: "production", created: 0, exists: 5, bundled: 1, failed: 0, elapsedMs: 4234 });
2376
- * ```
2377
- */
2378
- const renderDeploySummary = (ui, summary) => {
2379
- const { palette } = ui;
2380
- const parts = [`${String(summary.exists)} exist`, `${String(summary.created)} created`];
2381
- if (summary.bundled > 0) parts.push(`${String(summary.bundled)} with worker`);
2382
- const tally = parts.join(" · ");
2383
- const failedLabel = palette.red(`${String(summary.failed)} failed`);
2384
- const resources = summary.failed > 0 ? `${tally} · ${failedLabel}` : tally;
2385
- ui.heading("Deployed");
2386
- ui.box([
2387
- palette.pink(summary.url),
2388
- "",
2389
- `${palette.dim("stage".padEnd(10))}${summary.stage}`,
2390
- `${palette.dim("resources".padEnd(10))}${resources}`,
2391
- `${palette.dim("took".padEnd(10))}${formatDuration(summary.elapsedMs)}`
2392
- ]);
2393
- };
2394
- //#endregion
2395
2533
  //#region src/plugins/deploy/naming.ts
2396
2534
  /**
2397
2535
  * @file deploy plugin — stage-aware resource naming.
@@ -3352,25 +3490,34 @@ const guidedDeployStep = async (ctx, manifest, stage, deps) => {
3352
3490
  * migrations dir — the generic, deploy-owned analogue of `wrangler d1 migrations apply <binding>
3353
3491
  * --remote`. The wrangler config was written earlier in the pipeline, so each binding resolves. The
3354
3492
  * caller runs this only AFTER a successful deploy, so a deploy that never happened never migrates a
3355
- * remote DB. Streams wrangler's output; throws on the first non-zero exit (the caller folds it into
3356
- * the report).
3493
+ * remote DB. CAPTURES wrangler's output (the raw TUI is hidden; {@link parseMigrationsApplied} turns
3494
+ * it into the branded panel's facts); throws on the first non-zero exit (the caller folds it into the
3495
+ * report, where the captured error is still surfaced).
3357
3496
  *
3358
3497
  * @param ctx - The deploy plugin context.
3359
- * @returns Resolves once every configured database's remote migrations have been applied.
3498
+ * @returns The per-database migration outcomes (one per d1 instance that declares migrations).
3360
3499
  * @example
3361
3500
  * ```ts
3362
- * await applyRemoteMigrations(ctx);
3501
+ * const outcomes = await applyRemoteMigrations(ctx);
3363
3502
  * ```
3364
3503
  */
3365
3504
  const applyRemoteMigrations = async (ctx) => {
3366
- if (!ctx.has("d1")) return;
3367
- for (const database of ctx.require(d1Plugin).deployManifest()) if (database.migrations !== void 0) await runWranglerInherit([
3368
- "d1",
3369
- "migrations",
3370
- "apply",
3371
- database.binding,
3372
- "--remote"
3373
- ]);
3505
+ if (!ctx.has("d1")) return [];
3506
+ const outcomes = [];
3507
+ for (const database of ctx.require(d1Plugin).deployManifest()) if (database.migrations !== void 0) {
3508
+ const output = await runWrangler([
3509
+ "d1",
3510
+ "migrations",
3511
+ "apply",
3512
+ database.binding,
3513
+ "--remote"
3514
+ ]);
3515
+ outcomes.push({
3516
+ binding: database.binding,
3517
+ ...parseMigrationsApplied(output)
3518
+ });
3519
+ }
3520
+ return outcomes;
3374
3521
  };
3375
3522
  /**
3376
3523
  * Render a post-deploy step's failure as a branded line and capture its message into `errors` —
@@ -3414,9 +3561,13 @@ const runPostDeploy = async (ctx, want) => {
3414
3561
  const errors = [];
3415
3562
  let migration = "skipped";
3416
3563
  if (want.migration) try {
3417
- await applyRemoteMigrations(ctx);
3564
+ ctx.emit("deploy:phase", {
3565
+ phase: "migrate",
3566
+ detail: "remote D1"
3567
+ });
3568
+ const outcomes = await applyRemoteMigrations(ctx);
3418
3569
  migration = "applied";
3419
- ui.check(true, "migrated", "remote D1");
3570
+ if (outcomes.length > 0) renderMigrateSummary(ui, outcomes, "remote");
3420
3571
  } catch (error) {
3421
3572
  migration = "failed";
3422
3573
  captureFailure(ui, errors, error);
@@ -3431,9 +3582,13 @@ const runPostDeploy = async (ctx, want) => {
3431
3582
  seed = "failed";
3432
3583
  captureFailure(ui, errors, /* @__PURE__ */ new Error("[moku-worker] deploy({ seed: true }) but no seed is configured — set pluginConfigs.deploy.seed."));
3433
3584
  } else try {
3434
- await runConfiguredSeed(ctx, runWranglerInherit, config, "--remote");
3585
+ ctx.emit("deploy:phase", {
3586
+ phase: "seed",
3587
+ detail: config.file
3588
+ });
3589
+ const outcome = await runConfiguredSeed(ctx, runWrangler, config, "--remote");
3435
3590
  seed = "applied";
3436
- ui.check(true, "seeded", config.file);
3591
+ renderSeedSummary(ui, outcome, "remote");
3437
3592
  } catch (error) {
3438
3593
  seed = "failed";
3439
3594
  captureFailure(ui, errors, error);
@@ -3571,14 +3726,16 @@ const createDeployApi = (ctx) => ({
3571
3726
  * Execute a SQL file against a configured D1 database via `wrangler d1 execute` — for seeding dev
3572
3727
  * data. Local by default (applies that database's migrations first so the file's tables exist);
3573
3728
  * `opts.remote` seeds Cloudflare (schema is applied by `deploy`). Generates the wrangler config up
3574
- * front so the binding resolves even on a first run. Streams wrangler's output.
3729
+ * front so the binding resolves even on a first run. CAPTURES wrangler's output and renders a
3730
+ * branded "Migrated" / "Seeded" summary (the raw migration/execute TUI is hidden) so the command
3731
+ * reads the same as the rest of the deploy UX; a failure still surfaces the real wrangler error.
3575
3732
  *
3576
3733
  * @param sqlFile - Path to the SQL file to execute (e.g. "db/seed.sql").
3577
3734
  * @param opts - Optional options.
3578
3735
  * @param opts.stage - Stage for the generated config's resource names (defaults to the app stage).
3579
3736
  * @param opts.binding - The d1 binding to target when more than one is configured (e.g. "DB").
3580
3737
  * @param opts.remote - Seed the remote (Cloudflare) D1 instead of the local one.
3581
- * @returns Resolves once wrangler finishes executing the file.
3738
+ * @returns Resolves once wrangler finishes executing the file and the summary is rendered.
3582
3739
  * @example
3583
3740
  * ```ts
3584
3741
  * await api.seed("db/seed.sql"); // local default d1 (migrate, then execute)
@@ -3591,14 +3748,22 @@ const createDeployApi = (ctx) => ({
3591
3748
  await writeWranglerConfig(ctx.config.configFile, assembleManifest(ctx, stage), {}, wranglerExtra(ctx.config));
3592
3749
  const target = resolveD1(ctx, opts?.binding);
3593
3750
  const scope = opts?.remote === true ? "--remote" : "--local";
3594
- if (scope === "--local" && target.migrations !== void 0) await runWranglerInherit([
3595
- "d1",
3596
- "migrations",
3597
- "apply",
3598
- target.binding,
3599
- "--local"
3600
- ]);
3601
- await runWranglerInherit([
3751
+ const where = opts?.remote === true ? "remote" : "local";
3752
+ const ui = createBrandConsole();
3753
+ if (scope === "--local" && target.migrations !== void 0) {
3754
+ const migrated = await runWrangler([
3755
+ "d1",
3756
+ "migrations",
3757
+ "apply",
3758
+ target.binding,
3759
+ "--local"
3760
+ ]);
3761
+ renderMigrateSummary(ui, [{
3762
+ binding: target.binding,
3763
+ ...parseMigrationsApplied(migrated)
3764
+ }], where);
3765
+ }
3766
+ const executed = await runWrangler([
3602
3767
  "d1",
3603
3768
  "execute",
3604
3769
  target.binding,
@@ -3606,6 +3771,12 @@ const createDeployApi = (ctx) => ({
3606
3771
  "--file",
3607
3772
  sqlFile
3608
3773
  ]);
3774
+ renderSeedSummary(ui, {
3775
+ file: sqlFile,
3776
+ binding: target.binding,
3777
+ resetKv: [],
3778
+ ...parseSeedStats(executed)
3779
+ }, where);
3609
3780
  },
3610
3781
  /**
3611
3782
  * Scaffold a starting wrangler config (and CI files when ci is set).