@kubb/studio 5.3.17 → 5.3.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -35,7 +35,6 @@ let node_process = require("node:process");
35
35
  node_process = __toESM(node_process, 1);
36
36
  let node_child_process = require("node:child_process");
37
37
  let _kubb_core = require("@kubb/core");
38
- let tinyexec = require("tinyexec");
39
38
  let node_timers_promises = require("node:timers/promises");
40
39
  let ofetch = require("ofetch");
41
40
  let node_util = require("node:util");
@@ -52,6 +51,7 @@ let tsdown = require("tsdown");
52
51
  let capnweb = require("capnweb");
53
52
  let ws = require("ws");
54
53
  ws = __toESM(ws, 1);
54
+ let tinyexec = require("tinyexec");
55
55
  //#region ../../internals/utils/src/casing.ts
56
56
  /**
57
57
  * Shared implementation for camelCase and PascalCase conversion.
@@ -375,89 +375,9 @@ function getElapsedMs(hrStart) {
375
375
  }
376
376
  //#endregion
377
377
  //#region package.json
378
- var version = "5.3.17";
379
- //#endregion
380
- //#region src/hooks.ts
381
- /**
382
- * Register a `kubb:hook:start` listener that spawns the requested command via tinyexec,
383
- * streams each stdout line as a `kubb:hook:line` event, and calls `kubb:hook:end` with the result.
384
- * Streaming the output lets Kubb Studio render live hook progress over the WebSocket connection.
385
- *
386
- * Returns a remover, so a session that runs one generation after another on the same emitter does
387
- * not stack a listener per run.
388
- */
389
- function setupHookListener(hooks, root, signal) {
390
- return hooks.hook("kubb:hook:start", async (ctx) => {
391
- const { id, command, args } = ctx;
392
- if (!id) return;
393
- const commandWithArgs = args?.length ? `${command} ${args.join(" ")}` : command;
394
- try {
395
- const proc = (0, tinyexec.x)(command, [...args ?? []], {
396
- signal,
397
- nodeOptions: {
398
- cwd: root,
399
- detached: true
400
- }
401
- });
402
- for await (const line of proc) await hooks.callHook("kubb:hook:line", {
403
- id,
404
- line
405
- });
406
- const { exitCode } = await proc;
407
- if (exitCode !== 0) {
408
- const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
409
- await hooks.callHook("kubb:hook:end", {
410
- id,
411
- command,
412
- args,
413
- success: false,
414
- error
415
- });
416
- await hooks.callHook("kubb:error", { error });
417
- return;
418
- }
419
- await hooks.callHook("kubb:hook:end", {
420
- id,
421
- command,
422
- args,
423
- success: true,
424
- error: null
425
- });
426
- } catch (caughtError) {
427
- const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
428
- error.cause = caughtError;
429
- await hooks.callHook("kubb:hook:end", {
430
- id,
431
- command,
432
- args,
433
- success: false,
434
- error
435
- });
436
- await hooks.callHook("kubb:error", { error });
437
- }
438
- });
439
- }
440
- /**
441
- * Waits for the `kubb:hook:end` matching `hookId`. Register this before calling `kubb:hook:start`:
442
- * `callHook` awaits its listeners, and {@link setupHookListener} calls `kubb:hook:end` from inside
443
- * that same listener, so a handler added afterward would already have missed it.
444
- */
445
- function waitForHookEnd(hooks, hookId) {
446
- return new Promise((resolve, reject) => {
447
- const handleHookEnd = (ctx) => {
448
- if (ctx.id !== hookId) return;
449
- hooks.removeHook("kubb:hook:end", handleHookEnd);
450
- if (ctx.success) {
451
- resolve();
452
- return;
453
- }
454
- reject(ctx.error);
455
- };
456
- hooks.hook("kubb:hook:end", handleHookEnd);
457
- });
458
- }
378
+ var version = "5.3.19";
459
379
  //#endregion
460
- //#region src/machine.ts
380
+ //#region src/operations/machine.ts
461
381
  /**
462
382
  * Key-value storage the runtime uses for its machine secret and the last Studio config.
463
383
  *
@@ -517,7 +437,7 @@ async function getMachineToken() {
517
437
  return machineTokenFrom(await fallbackSecretPromise);
518
438
  }
519
439
  //#endregion
520
- //#region src/api.ts
440
+ //#region src/operations/api.ts
521
441
  /**
522
442
  * Reads a human-readable message from a Studio JSON error body, when it has one. `FetchError`'s own
523
443
  * message stops at the status line, so the detail Studio sends with a failure (an agent limit, a
@@ -653,7 +573,7 @@ function withJitter(ms) {
653
573
  * const finished = await waitForJob({ studioUrl, token, id: job.id })
654
574
  * ```
655
575
  */
656
- async function createJob({ studioUrl, token, type, agentId, name, version, commit, baseId, config, timeoutMs = 6e4, signal }) {
576
+ async function createJob({ studioUrl, token, type, agentId, name, version, commit, baseId, config, instanceId, timeoutMs = 6e4, signal }) {
657
577
  const deadline = Date.now() + timeoutMs;
658
578
  let interval = CREATE_JOB_INITIAL_DELAY_MS;
659
579
  for (;;) {
@@ -669,7 +589,8 @@ async function createJob({ studioUrl, token, type, agentId, name, version, commi
669
589
  version,
670
590
  commit,
671
591
  baseId,
672
- config
592
+ config,
593
+ instanceId
673
594
  },
674
595
  retry: false,
675
596
  timeout: Math.max(deadline - Date.now(), 1),
@@ -758,7 +679,7 @@ async function createAgent({ studioUrl, token, name, machineToken }) {
758
679
  }
759
680
  }
760
681
  //#endregion
761
- //#region src/resolveConfig.ts
682
+ //#region src/operations/resolveConfig.ts
762
683
  /**
763
684
  * Imports a package, falling back to how the user's project would resolve it.
764
685
  *
@@ -916,7 +837,7 @@ async function mergeAdapter(diskAdapter, studioOptions) {
916
837
  return factory((0, remeda.mergeDeep)(diskAdapter.options ?? {}, studioOptions));
917
838
  }
918
839
  //#endregion
919
- //#region src/configFile.ts
840
+ //#region src/operations/configFile.ts
920
841
  /**
921
842
  * A valid JavaScript identifier, so an import name can only ever print as `import { name } from`,
922
843
  * never as source that breaks out of the import statement.
@@ -1456,6 +1377,11 @@ function applyConfigEdits(source, edits) {
1456
1377
  changed: current !== source
1457
1378
  };
1458
1379
  }
1380
+ async function writeConfigEdits({ filePath, edits }) {
1381
+ const result = applyConfigEdits(await read(filePath), edits);
1382
+ if (result.changed) await (0, node_fs_promises.writeFile)(filePath, result.source, "utf-8");
1383
+ return result;
1384
+ }
1459
1385
  /**
1460
1386
  * The 1-based line where the file's last import declaration ends, or `0` when it has none. Read
1461
1387
  * off the parsed module, so a multi-line `import {\n x,\n} from '...'` reports its closing line
@@ -1488,144 +1414,7 @@ function withTrailingNewline(code, hadTrailingNewline) {
1488
1414
  return `${code}\n`;
1489
1415
  }
1490
1416
  //#endregion
1491
- //#region src/generate.ts
1492
- /**
1493
- * `isToolAvailable` spawns a process, and a long-lived connection generates repeatedly, so each
1494
- * executable is probed once per process. The CLI deliberately does not memoize: a `--watch` build
1495
- * should keep noticing a tool installed mid-session.
1496
- */
1497
- const detectTool = memoize(/* @__PURE__ */ new Map(), detectTool$1);
1498
- /**
1499
- * The two post-build tool steps. Formatting and linting differ only in which tools they look for,
1500
- * so they run through one loop rather than two near-identical blocks.
1501
- *
1502
- * `noun` and `verbing` are spelled out instead of built from `kind`. Concatenating `` `${kind}ter` ``
1503
- * and `` `${kind}ting` `` works for `format`, but doubles the `t` in `lint`, giving "lintter" and
1504
- * "lintting" instead of "linter" and "linting".
1505
- */
1506
- const TOOL_STEPS = [{
1507
- kind: "format",
1508
- noun: "formatter",
1509
- verbing: "Formatting",
1510
- tools: formatters,
1511
- detect: FORMATTER_PREFERENCE
1512
- }, {
1513
- kind: "lint",
1514
- noun: "linter",
1515
- verbing: "Linting",
1516
- tools: linters,
1517
- detect: LINTER_PREFERENCE
1518
- }];
1519
- /**
1520
- * Absolute path of the directory the formatter and linter are pointed at.
1521
- */
1522
- function outputPath(config) {
1523
- return node_path.default.isAbsolute(config.output.path) ? config.output.path : node_path.default.resolve(node_process.default.cwd(), config.root, config.output.path);
1524
- }
1525
- /**
1526
- * Emits `kubb:hook:start` and waits for the matching `kubb:hook:end`. The host spawns the process:
1527
- * this only describes what to run and when it finished.
1528
- *
1529
- * @throws whatever the command failed with, so callers can report it their own way.
1530
- */
1531
- async function runHook({ hooks, id, command, args }) {
1532
- const hookId = (0, node_crypto.hash)("sha256", id);
1533
- const hookEnd = waitForHookEnd(hooks, hookId);
1534
- await hooks.callHook("kubb:hook:start", {
1535
- id: hookId,
1536
- command,
1537
- args: [...args]
1538
- });
1539
- await hookEnd;
1540
- }
1541
- function isProblemErrorDiagnostic(diagnostic) {
1542
- return (diagnostic.kind ?? "problem") === "problem" && diagnostic.severity === "error";
1543
- }
1544
- /**
1545
- * Folds error-severity diagnostics into one thrown error so logs name the failing plugin.
1546
- */
1547
- function formatGenerationFailure(diagnostics) {
1548
- const reasons = diagnostics.filter(isProblemErrorDiagnostic).map((diagnostic) => diagnostic.plugin ? `${diagnostic.plugin}: ${diagnostic.message}` : diagnostic.message);
1549
- if (!reasons.length) return /* @__PURE__ */ new Error("Generation failed");
1550
- return /* @__PURE__ */ new Error(`Generation failed: ${reasons.length} error${reasons.length === 1 ? "" : "s"}: ${reasons.join("; ")}`);
1551
- }
1552
- /**
1553
- * Runs a full Kubb code-generation cycle for the given config.
1554
- *
1555
- * Emits lifecycle events on the provided `hooks` emitter so callers (e.g. the WebSocket stream)
1556
- * can forward progress to connected clients. After a successful build, auto-formatting and
1557
- * linting are applied when configured, followed by any user-defined `hooks.done` commands.
1558
- */
1559
- async function generate({ config, hooks, signal }) {
1560
- signal?.throwIfAborted();
1561
- const hrStart = node_process.default.hrtime();
1562
- await hooks.callHook("kubb:generation:start", { config });
1563
- await hooks.callHook("kubb:info", { message: config.name ? `Setup generation ${config.name}` : "Setup generation" });
1564
- const kubb = (0, _kubb_core.createKubb)(config, {
1565
- hooks,
1566
- signal
1567
- });
1568
- await kubb.setup();
1569
- await hooks.callHook("kubb:info", { message: config.name ? `Build generation ${config.name}` : "Build generation" });
1570
- const { files, diagnostics, storage } = await kubb.safeBuild();
1571
- signal?.throwIfAborted();
1572
- await hooks.callHook("kubb:info", { message: "Load summary" });
1573
- for (const diagnostic of diagnostics.filter(isProblemErrorDiagnostic)) await hooks.callHook("kubb:error", { error: new Error(diagnostic.plugin ? `${diagnostic.plugin}: ${diagnostic.message}` : diagnostic.message) });
1574
- const status = _kubb_core.Diagnostics.hasError(diagnostics) ? "failed" : "success";
1575
- await hooks.callHook("kubb:generation:end", {
1576
- config,
1577
- storage: {
1578
- ...storage,
1579
- readKeys: async () => [...new Set(files.map((file) => file.path))]
1580
- },
1581
- diagnostics,
1582
- status,
1583
- hrStart,
1584
- filesCreated: files.length
1585
- });
1586
- if (status === "failed") throw formatGenerationFailure(diagnostics);
1587
- await hooks.callHook("kubb:success", { message: "Generation successfully" });
1588
- for (const step of TOOL_STEPS) {
1589
- const setting = config.output[step.kind];
1590
- if (!setting) continue;
1591
- await hooks.callHook(`kubb:${step.kind}:start`);
1592
- const tool = setting === "auto" ? await detectTool(step.detect) : setting;
1593
- if (!tool) await hooks.callHook("kubb:warn", { message: `No ${step.noun} found (${step.detect.join(", ")}). Skipping ${step.verbing.toLowerCase()}.` });
1594
- if (tool && setting === "auto") await hooks.callHook("kubb:info", { message: `Auto-detected ${step.noun}: ${(0, node_util.styleText)("dim", tool)}` });
1595
- const command = tool ? step.tools[tool] : void 0;
1596
- if (command) try {
1597
- await runHook({
1598
- hooks,
1599
- id: [config.name, tool].filter(Boolean).join("-"),
1600
- command: command.command,
1601
- args: command.args(outputPath(config))
1602
- });
1603
- await hooks.callHook("kubb:success", { message: `${step.verbing} with ${tool} successfully` });
1604
- } catch (caughtError) {
1605
- await hooks.callHook("kubb:error", { error: new Error(command.errorMessage, { cause: caughtError }) });
1606
- signal?.throwIfAborted();
1607
- }
1608
- await hooks.callHook(`kubb:${step.kind}:end`);
1609
- }
1610
- if (config.output.postGenerate?.length) {
1611
- await hooks.callHook("kubb:hooks:start");
1612
- for (const entry of config.output.postGenerate) {
1613
- const line = typeof entry === "string" ? entry : entry.command;
1614
- const [cmd, ...args] = tokenize(line);
1615
- if (!cmd) continue;
1616
- await runHook({
1617
- hooks,
1618
- id: line,
1619
- command: cmd,
1620
- args
1621
- });
1622
- await hooks.callHook("kubb:success", { message: `${line} successfully executed` });
1623
- }
1624
- await hooks.callHook("kubb:hooks:end");
1625
- }
1626
- }
1627
- //#endregion
1628
- //#region src/constants.ts
1417
+ //#region src/operations/constants.ts
1629
1418
  /**
1630
1419
  * Hosted Kubb Studio URL. Exported so credential stores can bind tokens to the resolved instance,
1631
1420
  * not whatever default the client would pick on its own.
@@ -1669,18 +1458,16 @@ function resolveGenerationLimits(env = process.env) {
1669
1458
  };
1670
1459
  }
1671
1460
  /**
1672
- * An agent's capacity read from `KUBB_AGENT_MAX_CONCURRENT` and `KUBB_AGENT_MEMORY_BUDGET_MB`. An
1673
- * unset or invalid value keeps the default: one job at a time, and no memory budget.
1461
+ * An agent's capacity read from `KUBB_AGENT_MAX_CONCURRENT`. An unset or invalid value keeps the
1462
+ * default: one job at a time.
1674
1463
  */
1675
1464
  function resolveAgentCapacity(env = process.env) {
1676
- return {
1677
- maxConcurrent: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_CONCURRENT) ?? agentDefaults.maxConcurrent)),
1678
- memoryBudgetMb: positiveNumber(env.KUBB_AGENT_MEMORY_BUDGET_MB)
1679
- };
1465
+ return { maxConcurrent: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_CONCURRENT) ?? agentDefaults.maxConcurrent)) };
1680
1466
  }
1681
1467
  //#endregion
1682
- //#region src/snapshotPackage.ts
1468
+ //#region src/operations/snapshotPackage.ts
1683
1469
  const gzipAsync = (0, node_util.promisify)(node_zlib.gzip);
1470
+ const UPLOAD_TIMEOUT_MS = 12e4;
1684
1471
  /**
1685
1472
  * Maps a generated file's path to its place inside the tarball, stripping everything before a
1686
1473
  * `src`/`dist` segment and any `..`/empty path segment so a crafted file name cannot escape the
@@ -1818,8 +1605,31 @@ async function createSnapshotPackage(files, packageInfo) {
1818
1605
  });
1819
1606
  }
1820
1607
  }
1608
+ async function uploadSnapshot({ bytes, uploadPath, studioUrl, token, shutdown }) {
1609
+ const uploadUrl = new URL(uploadPath, studioUrl);
1610
+ if (uploadUrl.origin !== new URL(studioUrl).origin) throw new Error("Snapshot upload path must stay on the Studio origin");
1611
+ const timeout = AbortSignal.timeout(UPLOAD_TIMEOUT_MS);
1612
+ const signal = shutdown ? AbortSignal.any([shutdown, timeout]) : timeout;
1613
+ const redirect = await fetch(uploadUrl, {
1614
+ method: "PUT",
1615
+ headers: { Authorization: `Bearer ${token}` },
1616
+ redirect: "manual",
1617
+ signal
1618
+ });
1619
+ const storageUrl = redirect.headers.get("location");
1620
+ if (redirect.status !== 307 || !storageUrl) throw new Error(`Studio did not provide a storage URL (status ${redirect.status})`);
1621
+ const storage = new URL(storageUrl);
1622
+ if (storage.protocol !== "https:" && storage.hostname !== "localhost" && storage.hostname !== "127.0.0.1") throw new Error(`Refusing snapshot upload to ${storage.origin}`);
1623
+ const response = await fetch(storage, {
1624
+ method: "PUT",
1625
+ body: new Uint8Array(bytes),
1626
+ redirect: "error",
1627
+ signal
1628
+ });
1629
+ if (!response.ok) throw new Error(`Snapshot upload failed with status ${response.status}`);
1630
+ }
1821
1631
  //#endregion
1822
- //#region src/generations.ts
1632
+ //#region src/operations/generations.ts
1823
1633
  const READ_CONCURRENCY = 50;
1824
1634
  const MB$1 = 1048576;
1825
1635
  const INDEX_KEY = "studio/generations.json";
@@ -1937,12 +1747,7 @@ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now
1937
1747
  };
1938
1748
  }
1939
1749
  //#endregion
1940
- //#region src/ws.ts
1941
- /**
1942
- * How long the initial handshake may take before the socket is closed and the reconnect loop
1943
- * takes over.
1944
- */
1945
- const CONNECT_TIMEOUT_MS = 5e3;
1750
+ //#region src/operations/generationEvents.ts
1946
1751
  const require$1 = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href);
1947
1752
  function relativeStoragePath(root, filePath) {
1948
1753
  return ((0, node_path.isAbsolute)(filePath) ? (0, node_path.relative)((0, node_path.resolve)(root), filePath) : filePath).replaceAll("\\", "/");
@@ -1972,27 +1777,24 @@ async function resolvePeerDependencies(names) {
1972
1777
  missingDependencies
1973
1778
  };
1974
1779
  }
1975
- /**
1976
- * Opens a Studio WebSocket connection and closes it when the initial handshake exceeds the configured timeout.
1977
- */
1978
- function createWebsocket(url, options) {
1979
- const ws$1 = new ws.default(url, options);
1980
- const timer = setTimeout(() => {
1981
- if (ws$1.readyState === ws.default.CONNECTING) ws$1.close(3008, "Connection timeout");
1982
- }, CONNECT_TIMEOUT_MS);
1983
- ws$1.once("open", () => clearTimeout(timer));
1984
- ws$1.once("close", () => clearTimeout(timer));
1985
- return ws$1;
1986
- }
1780
+ const MAX_QUEUED_EVENTS = 1024;
1781
+ const RESERVED_EVENTS = 64;
1782
+ const isDiscardable = (event) => event.type === "kubb:files:processing:update" || event.type === "kubb:info" || event.type === "kubb:success";
1987
1783
  /** Forwards selected Kubb lifecycle events to a native Cap'n Web stream. */
1988
1784
  function createGenerationStream(hooks, jobId, options = {}) {
1989
1785
  const unhooks = [];
1990
1786
  let root = "";
1991
- const transform = new TransformStream(void 0, void 0, { highWaterMark: Infinity });
1992
- const writer = transform.writable.getWriter();
1993
- let writes = Promise.resolve();
1787
+ let controller;
1994
1788
  let closed = false;
1995
- let streamError;
1789
+ const stream = new ReadableStream({
1790
+ start: (value) => {
1791
+ controller = value;
1792
+ },
1793
+ cancel: () => {
1794
+ closed = true;
1795
+ detach();
1796
+ }
1797
+ }, { highWaterMark: MAX_QUEUED_EVENTS });
1996
1798
  /**
1997
1799
  * Registers a listener and keeps its remover, so one generation's listeners come off the session
1998
1800
  * emitter again when that generation ends.
@@ -2001,6 +1803,7 @@ function createGenerationStream(hooks, jobId, options = {}) {
2001
1803
  unhooks.push(hooks.hook(name, handler));
2002
1804
  }
2003
1805
  function emitEvent(type, data) {
1806
+ if (closed) return;
2004
1807
  const event = {
2005
1808
  jobId,
2006
1809
  type,
@@ -2008,9 +1811,12 @@ function createGenerationStream(hooks, jobId, options = {}) {
2008
1811
  version: 1,
2009
1812
  timestamp: Date.now()
2010
1813
  };
2011
- writes = writes.then(() => writer.write(event)).catch((error) => {
2012
- streamError = error;
2013
- });
1814
+ if (controller.desiredSize <= RESERVED_EVENTS && isDiscardable(event)) return;
1815
+ if (controller.desiredSize <= 0) {
1816
+ fail(/* @__PURE__ */ new Error(`Generation event stream exceeded ${MAX_QUEUED_EVENTS} queued events`));
1817
+ return;
1818
+ }
1819
+ controller.enqueue(event);
2014
1820
  }
2015
1821
  on("kubb:plugin:start", (ctx) => {
2016
1822
  emitEvent("kubb:plugin:start", [{ plugin: { name: ctx.plugin.name } }]);
@@ -2156,25 +1962,38 @@ function createGenerationStream(hooks, jobId, options = {}) {
2156
1962
  if (closed) return;
2157
1963
  closed = true;
2158
1964
  detach();
2159
- await writes;
2160
- if (streamError) return;
2161
- await writer.close().catch(() => void 0);
1965
+ controller.close();
2162
1966
  }
2163
1967
  function fail(error) {
2164
1968
  detach();
2165
1969
  if (closed) return;
2166
1970
  closed = true;
2167
- writer.abort(error).catch(() => void 0);
1971
+ controller.error(error);
2168
1972
  }
2169
1973
  return {
2170
- stream: transform.readable,
1974
+ stream,
2171
1975
  close,
2172
1976
  dispose: () => fail(),
2173
1977
  fail
2174
1978
  };
2175
1979
  }
2176
1980
  //#endregion
2177
- //#region src/rpc.ts
1981
+ //#region src/operations/websocket.ts
1982
+ const CONNECT_TIMEOUT_MS = 5e3;
1983
+ /**
1984
+ * Opens a Studio WebSocket connection and closes it when the initial handshake exceeds the configured timeout.
1985
+ */
1986
+ function createWebsocket(url, options) {
1987
+ const ws$1 = new ws.default(url, options);
1988
+ const timer = setTimeout(() => {
1989
+ if (ws$1.readyState === ws.default.CONNECTING) ws$1.close(3008, "Connection timeout");
1990
+ }, CONNECT_TIMEOUT_MS);
1991
+ ws$1.once("open", () => clearTimeout(timer));
1992
+ ws$1.once("close", () => clearTimeout(timer));
1993
+ return ws$1;
1994
+ }
1995
+ //#endregion
1996
+ //#region src/operations/rpc.ts
2178
1997
  /**
2179
1998
  * The only methods Studio may call on an agent. A `StudioSession` carries far more than
2180
1999
  * {@link AgentApi}, so it is wrapped rather than exposed: what Cap'n Web can reach is exactly what
@@ -2235,16 +2054,253 @@ const connectWebSocketRpc = async ({ url, token, instanceId, local }) => {
2235
2054
  };
2236
2055
  };
2237
2056
  //#endregion
2238
- //#region src/StudioSession.ts
2057
+ //#region src/operations/hooks.ts
2239
2058
  /**
2240
- * Past this many files in the output directory, no snapshot of it is taken before a run.
2059
+ * Register a `kubb:hook:start` listener that spawns the requested command via tinyexec,
2060
+ * streams each stdout line as a `kubb:hook:line` event, and calls `kubb:hook:end` with the result.
2061
+ * Streaming the output lets Kubb Studio render live hook progress over the WebSocket connection.
2062
+ *
2063
+ * Returns a remover, so a session that runs one generation after another on the same emitter does
2064
+ * not stack a listener per run.
2241
2065
  */
2066
+ function setupHookListener(hooks, root, signal) {
2067
+ return hooks.hook("kubb:hook:start", async (ctx) => {
2068
+ const { id, command, args } = ctx;
2069
+ if (!id) return;
2070
+ const commandWithArgs = args?.length ? `${command} ${args.join(" ")}` : command;
2071
+ try {
2072
+ const proc = (0, tinyexec.x)(command, [...args ?? []], {
2073
+ signal,
2074
+ nodeOptions: {
2075
+ cwd: root,
2076
+ detached: true
2077
+ }
2078
+ });
2079
+ for await (const line of proc) await hooks.callHook("kubb:hook:line", {
2080
+ id,
2081
+ line
2082
+ });
2083
+ const { exitCode } = await proc;
2084
+ if (exitCode !== 0) {
2085
+ const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
2086
+ await hooks.callHook("kubb:hook:end", {
2087
+ id,
2088
+ command,
2089
+ args,
2090
+ success: false,
2091
+ error
2092
+ });
2093
+ await hooks.callHook("kubb:error", { error });
2094
+ return;
2095
+ }
2096
+ await hooks.callHook("kubb:hook:end", {
2097
+ id,
2098
+ command,
2099
+ args,
2100
+ success: true,
2101
+ error: null
2102
+ });
2103
+ } catch (caughtError) {
2104
+ const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
2105
+ error.cause = caughtError;
2106
+ await hooks.callHook("kubb:hook:end", {
2107
+ id,
2108
+ command,
2109
+ args,
2110
+ success: false,
2111
+ error
2112
+ });
2113
+ await hooks.callHook("kubb:error", { error });
2114
+ }
2115
+ });
2116
+ }
2117
+ /**
2118
+ * Waits for the `kubb:hook:end` matching `hookId`. Register this before calling `kubb:hook:start`:
2119
+ * `callHook` awaits its listeners, and {@link setupHookListener} calls `kubb:hook:end` from inside
2120
+ * that same listener, so a handler added afterward would already have missed it.
2121
+ */
2122
+ function waitForHookEnd(hooks, hookId) {
2123
+ return new Promise((resolve, reject) => {
2124
+ const handleHookEnd = (ctx) => {
2125
+ if (ctx.id !== hookId) return;
2126
+ hooks.removeHook("kubb:hook:end", handleHookEnd);
2127
+ if (ctx.success) {
2128
+ resolve();
2129
+ return;
2130
+ }
2131
+ reject(ctx.error);
2132
+ };
2133
+ hooks.hook("kubb:hook:end", handleHookEnd);
2134
+ });
2135
+ }
2136
+ //#endregion
2137
+ //#region src/operations/generate.ts
2242
2138
  const DISK_SNAPSHOT_MAX_FILES = 1e4;
2243
2139
  /**
2244
- * How long a sandbox keeps a generation readable. Its store is in memory and holds every tenant's
2245
- * runs, so an old one has to go even when count and size leave room. A local agent keeps its runs
2246
- * until count or size pushes them out, so a later run can still diff against the one before it.
2140
+ * `isToolAvailable` spawns a process, and a long-lived connection generates repeatedly, so each
2141
+ * executable is probed once per process. The CLI deliberately does not memoize: a `--watch` build
2142
+ * should keep noticing a tool installed mid-session.
2143
+ */
2144
+ const detectTool = memoize(/* @__PURE__ */ new Map(), detectTool$1);
2145
+ /**
2146
+ * The two post-build tool steps. Formatting and linting differ only in which tools they look for,
2147
+ * so they run through one loop rather than two near-identical blocks.
2148
+ *
2149
+ * `noun` and `verbing` are spelled out instead of built from `kind`. Concatenating `` `${kind}ter` ``
2150
+ * and `` `${kind}ting` `` works for `format`, but doubles the `t` in `lint`, giving "lintter" and
2151
+ * "lintting" instead of "linter" and "linting".
2152
+ */
2153
+ const TOOL_STEPS = [{
2154
+ kind: "format",
2155
+ noun: "formatter",
2156
+ verbing: "Formatting",
2157
+ tools: formatters,
2158
+ detect: FORMATTER_PREFERENCE
2159
+ }, {
2160
+ kind: "lint",
2161
+ noun: "linter",
2162
+ verbing: "Linting",
2163
+ tools: linters,
2164
+ detect: LINTER_PREFERENCE
2165
+ }];
2166
+ /**
2167
+ * Absolute path of the directory the formatter and linter are pointed at.
2168
+ */
2169
+ function outputPath(config) {
2170
+ return node_path.default.isAbsolute(config.output.path) ? config.output.path : node_path.default.resolve(node_process.default.cwd(), config.root, config.output.path);
2171
+ }
2172
+ /**
2173
+ * Emits `kubb:hook:start` and waits for the matching `kubb:hook:end`. The host spawns the process:
2174
+ * this only describes what to run and when it finished.
2175
+ *
2176
+ * @throws whatever the command failed with, so callers can report it their own way.
2177
+ */
2178
+ async function runHook({ hooks, id, command, args }) {
2179
+ const hookId = (0, node_crypto.hash)("sha256", id);
2180
+ const hookEnd = waitForHookEnd(hooks, hookId);
2181
+ await hooks.callHook("kubb:hook:start", {
2182
+ id: hookId,
2183
+ command,
2184
+ args: [...args]
2185
+ });
2186
+ await hookEnd;
2187
+ }
2188
+ function isProblemErrorDiagnostic(diagnostic) {
2189
+ return (diagnostic.kind ?? "problem") === "problem" && diagnostic.severity === "error";
2190
+ }
2191
+ /**
2192
+ * Folds error-severity diagnostics into one thrown error so logs name the failing plugin.
2193
+ */
2194
+ function formatGenerationFailure(diagnostics) {
2195
+ const reasons = diagnostics.filter(isProblemErrorDiagnostic).map((diagnostic) => diagnostic.plugin ? `${diagnostic.plugin}: ${diagnostic.message}` : diagnostic.message);
2196
+ if (!reasons.length) return /* @__PURE__ */ new Error("Generation failed");
2197
+ return /* @__PURE__ */ new Error(`Generation failed: ${reasons.length} error${reasons.length === 1 ? "" : "s"}: ${reasons.join("; ")}`);
2198
+ }
2199
+ /**
2200
+ * Runs a full Kubb code-generation cycle for the given config.
2201
+ *
2202
+ * Emits lifecycle events on the provided `hooks` emitter so callers (e.g. the WebSocket stream)
2203
+ * can forward progress to connected clients. After a successful build, auto-formatting and
2204
+ * linting are applied when configured, followed by any user-defined `hooks.done` commands.
2247
2205
  */
2206
+ async function generate({ config, hooks, signal }) {
2207
+ signal?.throwIfAborted();
2208
+ const hrStart = node_process.default.hrtime();
2209
+ await hooks.callHook("kubb:generation:start", { config });
2210
+ await hooks.callHook("kubb:info", { message: config.name ? `Setup generation ${config.name}` : "Setup generation" });
2211
+ const kubb = (0, _kubb_core.createKubb)(config, {
2212
+ hooks,
2213
+ signal
2214
+ });
2215
+ await kubb.setup();
2216
+ await hooks.callHook("kubb:info", { message: config.name ? `Build generation ${config.name}` : "Build generation" });
2217
+ const { files, diagnostics, storage } = await kubb.safeBuild();
2218
+ signal?.throwIfAborted();
2219
+ await hooks.callHook("kubb:info", { message: "Load summary" });
2220
+ for (const diagnostic of diagnostics.filter(isProblemErrorDiagnostic)) await hooks.callHook("kubb:error", { error: new Error(diagnostic.plugin ? `${diagnostic.plugin}: ${diagnostic.message}` : diagnostic.message) });
2221
+ const status = _kubb_core.Diagnostics.hasError(diagnostics) ? "failed" : "success";
2222
+ await hooks.callHook("kubb:generation:end", {
2223
+ config,
2224
+ storage: {
2225
+ ...storage,
2226
+ readKeys: async () => [...new Set(files.map((file) => file.path))]
2227
+ },
2228
+ diagnostics,
2229
+ status,
2230
+ hrStart,
2231
+ filesCreated: files.length
2232
+ });
2233
+ if (status === "failed") throw formatGenerationFailure(diagnostics);
2234
+ await hooks.callHook("kubb:success", { message: "Generation successfully" });
2235
+ for (const step of TOOL_STEPS) {
2236
+ const setting = config.output[step.kind];
2237
+ if (!setting) continue;
2238
+ await hooks.callHook(`kubb:${step.kind}:start`);
2239
+ const tool = setting === "auto" ? await detectTool(step.detect) : setting;
2240
+ if (!tool) await hooks.callHook("kubb:warn", { message: `No ${step.noun} found (${step.detect.join(", ")}). Skipping ${step.verbing.toLowerCase()}.` });
2241
+ if (tool && setting === "auto") await hooks.callHook("kubb:info", { message: `Auto-detected ${step.noun}: ${(0, node_util.styleText)("dim", tool)}` });
2242
+ const command = tool ? step.tools[tool] : void 0;
2243
+ if (command) try {
2244
+ await runHook({
2245
+ hooks,
2246
+ id: [config.name, tool].filter(Boolean).join("-"),
2247
+ command: command.command,
2248
+ args: command.args(outputPath(config))
2249
+ });
2250
+ await hooks.callHook("kubb:success", { message: `${step.verbing} with ${tool} successfully` });
2251
+ } catch (caughtError) {
2252
+ await hooks.callHook("kubb:error", { error: new Error(command.errorMessage, { cause: caughtError }) });
2253
+ signal?.throwIfAborted();
2254
+ }
2255
+ await hooks.callHook(`kubb:${step.kind}:end`);
2256
+ }
2257
+ if (config.output.postGenerate?.length) {
2258
+ await hooks.callHook("kubb:hooks:start");
2259
+ for (const entry of config.output.postGenerate) {
2260
+ const line = typeof entry === "string" ? entry : entry.command;
2261
+ const [cmd, ...args] = tokenize(line);
2262
+ if (!cmd) continue;
2263
+ await runHook({
2264
+ hooks,
2265
+ id: line,
2266
+ command: cmd,
2267
+ args
2268
+ });
2269
+ await hooks.callHook("kubb:success", { message: `${line} successfully executed` });
2270
+ }
2271
+ await hooks.callHook("kubb:hooks:end");
2272
+ }
2273
+ }
2274
+ async function runGenerationOperation({ config, hooks, signal, jobId, store, snapshotRoot, maxSnapshotMb }) {
2275
+ const diskFiles = snapshotRoot ? await listDisk({
2276
+ root: snapshotRoot,
2277
+ outputPath: config.output.path,
2278
+ maxFiles: DISK_SNAPSHOT_MAX_FILES
2279
+ }) : void 0;
2280
+ const disk = diskFiles ? await store.keep({
2281
+ jobId,
2282
+ source: "disk",
2283
+ files: diskFiles,
2284
+ maxSetMb: maxSnapshotMb
2285
+ }) : void 0;
2286
+ const removeHookListener = setupHookListener(hooks, config.root, signal);
2287
+ try {
2288
+ await generate({
2289
+ config,
2290
+ hooks,
2291
+ signal
2292
+ });
2293
+ } catch (error) {
2294
+ await store.drop(jobId);
2295
+ throw error;
2296
+ } finally {
2297
+ removeHookListener();
2298
+ }
2299
+ return disk;
2300
+ }
2301
+ //#endregion
2302
+ //#region src/runtime/StudioSession.ts
2303
+ /** A sandbox shares one in-memory generation store across tenants, so old generations expire. */
2248
2304
  const SANDBOX_GENERATION_TTL_MS = 9e5;
2249
2305
  /**
2250
2306
  * A fresh root for one sandbox job. Kubb keys its output manifest cache by root, so tenants that
@@ -2306,7 +2362,6 @@ function applyStudioDefaults(options) {
2306
2362
  permissions: {
2307
2363
  allowWrite: false,
2308
2364
  allowConfigEdit: false,
2309
- allowInput: false,
2310
2365
  allowExec: false,
2311
2366
  allowRead: false,
2312
2367
  ...options.permissions
@@ -2442,13 +2497,6 @@ var StudioSession = class {
2442
2497
  return !this.#isSandbox && this.#options.permissions.allowConfigEdit;
2443
2498
  }
2444
2499
  /**
2445
- * A sandbox agent always generates from the spec Studio supplies. A local agent only when the
2446
- * host opted in.
2447
- */
2448
- get #canUseInput() {
2449
- return this.#isSandbox || this.#options.permissions.allowInput;
2450
- }
2451
- /**
2452
2500
  * A sandbox agent always allows reading its output back; a local agent only when opted in.
2453
2501
  */
2454
2502
  get #canRead() {
@@ -2554,20 +2602,12 @@ var StudioSession = class {
2554
2602
  clearTimeout(timer);
2555
2603
  }
2556
2604
  }
2557
- /**
2558
- * Whether memory still leaves room for another job. Always, when the host set no budget.
2559
- */
2560
- #isAccepting(memoryMb = rssMb()) {
2561
- const budget = this.#options.capacity.memoryBudgetMb;
2562
- return budget === void 0 || memoryMb <= budget * 1.5;
2563
- }
2564
2605
  async #load() {
2565
- const memoryMb = rssMb();
2566
2606
  return {
2567
2607
  running: this.#isGenerating ? 1 : 0,
2568
- rssMb: Math.round(memoryMb),
2608
+ rssMb: Math.round(rssMb()),
2569
2609
  storeBytes: await this.#generations.bytes(),
2570
- accepting: this.#isAccepting(memoryMb)
2610
+ accepting: true
2571
2611
  };
2572
2612
  }
2573
2613
  /**
@@ -2605,7 +2645,6 @@ var StudioSession = class {
2605
2645
  permissions: {
2606
2646
  ...permissions,
2607
2647
  allowWrite: this.#canWrite,
2608
- allowInput: this.#canUseInput,
2609
2648
  allowConfigEdit: this.#canEditConfig,
2610
2649
  allowRead: this.#canRead
2611
2650
  }
@@ -2676,7 +2715,6 @@ var StudioSession = class {
2676
2715
  }
2677
2716
  async #runGeneration(data, controller, cancelRun) {
2678
2717
  if (this.#isGenerating) return this.#refuse("Ignored generate: a generation is already in progress", "A generation is already in progress, please wait for it to finish");
2679
- if (!this.#isAccepting()) return this.#refuse("Ignored generate: the agent is past its memory budget", "The agent is past its memory budget, try again once it frees memory");
2680
2718
  this.#isGenerating = true;
2681
2719
  this.#activeJob = {
2682
2720
  jobId: data.jobId,
@@ -2692,48 +2730,33 @@ var StudioSession = class {
2692
2730
  const patch = data.config;
2693
2731
  const plugins = await mergePlugins(config.plugins, patch?.plugins);
2694
2732
  const adapter = await mergeAdapter(config.adapter, patch?.adapter);
2695
- const inputOverride = this.#isSandbox ? patch?.input ?? "" : permissions.allowInput && patch?.input || void 0;
2733
+ const inputOverride = this.#isSandbox ? patch?.input ?? "" : void 0;
2696
2734
  if (permissions.allowWrite && this.#isSandbox) await this.#warn("Running in a sandbox, so writing files is disabled");
2697
- if (patch?.input && !this.#canUseInput) await this.#warn("Ignored the spec from Studio: generating from a Studio spec was not granted", "allowInput");
2735
+ if (patch?.input && !this.#isSandbox) await this.#warn("Ignored the spec from Studio: generating from a Studio spec is only available to a sandbox agent");
2698
2736
  const resolvedPlugins = plugins ?? config.plugins;
2699
2737
  this.#lastGeneration = void 0;
2700
- const diskFiles = this.#hasProjectOnDisk ? await listDisk({
2701
- root,
2702
- outputPath: config.output.path,
2703
- maxFiles: DISK_SNAPSHOT_MAX_FILES
2704
- }) : void 0;
2705
- const disk = diskFiles ? await this.#generations.keep({
2706
- jobId: data.jobId,
2707
- source: "disk",
2708
- files: diskFiles,
2709
- maxSetMb: this.#limits.maxSnapshotMb
2710
- }) : void 0;
2711
- const detach = [setupHookListener(this.#hooks, root, controller.signal)];
2712
- try {
2713
- await generate({
2714
- config: {
2715
- ...config,
2716
- root,
2717
- input: inputOverride ?? config.input,
2718
- storage: this.#canWrite ? (0, _kubb_core.fsStorage)() : (0, _kubb_core.memoryStorage)(),
2719
- output: permissions.allowExec ? { ...config.output } : {
2720
- ...config.output,
2721
- format: false,
2722
- lint: false,
2723
- postGenerate: []
2724
- },
2725
- plugins: resolvedPlugins,
2726
- adapter
2738
+ const disk = await runGenerationOperation({
2739
+ config: {
2740
+ ...config,
2741
+ root,
2742
+ input: inputOverride ?? config.input,
2743
+ storage: this.#canWrite ? (0, _kubb_core.fsStorage)() : (0, _kubb_core.memoryStorage)(),
2744
+ output: permissions.allowExec ? { ...config.output } : {
2745
+ ...config.output,
2746
+ format: false,
2747
+ lint: false,
2748
+ postGenerate: []
2727
2749
  },
2728
- hooks: this.#hooks,
2729
- signal: controller.signal
2730
- });
2731
- } catch (error) {
2732
- await this.#generations.drop(data.jobId);
2733
- throw error;
2734
- } finally {
2735
- for (const remove of detach) remove();
2736
- }
2750
+ plugins: resolvedPlugins,
2751
+ adapter
2752
+ },
2753
+ hooks: this.#hooks,
2754
+ signal: controller.signal,
2755
+ jobId: data.jobId,
2756
+ store: this.#generations,
2757
+ snapshotRoot: this.#hasProjectOnDisk ? root : void 0,
2758
+ maxSnapshotMb: this.#limits.maxSnapshotMb
2759
+ });
2737
2760
  await this.#hooks.callHook("studio:command:end", {
2738
2761
  command,
2739
2762
  info: `${resolvedPlugins.length} plugin${resolvedPlugins.length === 1 ? "" : "s"}, ${this.#canWrite ? "written to disk" : "in memory"}${inputOverride !== void 0 ? ", from a Studio spec" : ""}`
@@ -2795,8 +2818,10 @@ var StudioSession = class {
2795
2818
  }
2796
2819
  if (this.#isGenerating) return refuse("a generation is in progress");
2797
2820
  try {
2798
- const { source: patched, outcomes, changed } = applyConfigEdits(await read(configFile), edits);
2799
- if (changed) await (0, node_fs_promises.writeFile)(configFile, patched, "utf-8");
2821
+ const { source: patched, outcomes, changed } = await writeConfigEdits({
2822
+ filePath: configFile,
2823
+ edits
2824
+ });
2800
2825
  const applied = outcomes.filter((outcome) => outcome.applied).length;
2801
2826
  await this.#hooks.callHook("studio:command:end", {
2802
2827
  command,
@@ -2834,24 +2859,14 @@ var StudioSession = class {
2834
2859
  version,
2835
2860
  peerDependencies: generation.peerDependencies
2836
2861
  });
2837
- const { token, studioUrl } = this.#options;
2838
- const uploadUrl = new URL(uploadPath, studioUrl);
2839
- if (uploadUrl.origin !== new URL(studioUrl).origin) throw new Error("Snapshot upload path must stay on the Studio origin");
2840
- const redirect = await fetch(uploadUrl, {
2841
- method: "PUT",
2842
- headers: { Authorization: `Bearer ${token}` },
2843
- redirect: "manual"
2844
- });
2845
- const storageUrl = redirect.headers.get("location");
2846
- if (redirect.status !== 307 || !storageUrl) throw new Error(`Studio did not provide a storage URL (status ${redirect.status})`);
2847
- const storage = new URL(storageUrl);
2848
- if (storage.protocol !== "https:" && storage.hostname !== "localhost" && storage.hostname !== "127.0.0.1") throw new Error(`Refusing snapshot upload to ${storage.origin}`);
2849
- const response = await fetch(storage, {
2850
- method: "PUT",
2851
- body: new Uint8Array(bytes),
2852
- redirect: "error"
2862
+ const { token, studioUrl, signal } = this.#options;
2863
+ await uploadSnapshot({
2864
+ bytes,
2865
+ uploadPath,
2866
+ studioUrl,
2867
+ token,
2868
+ shutdown: signal
2853
2869
  });
2854
- if (!response.ok) throw new Error(`Snapshot upload failed with status ${response.status}`);
2855
2870
  await this.#hooks.callHook("studio:command:end", {
2856
2871
  command,
2857
2872
  info: `packed ${Object.keys(files).length} file${Object.keys(files).length === 1 ? "" : "s"}`
@@ -2865,10 +2880,7 @@ var StudioSession = class {
2865
2880
  throw error;
2866
2881
  }
2867
2882
  }
2868
- /**
2869
- * An agent with a project on disk can show a run against what its output directory held before.
2870
- * A sandbox agent has no project.
2871
- */
2883
+ /** A sandbox has no project directory to snapshot. */
2872
2884
  get #hasProjectOnDisk() {
2873
2885
  return !this.#isSandbox && this.#canRead;
2874
2886
  }
@@ -2897,7 +2909,7 @@ var StudioSession = class {
2897
2909
  }
2898
2910
  };
2899
2911
  //#endregion
2900
- //#region src/client.ts
2912
+ //#region src/runtime/client.ts
2901
2913
  /**
2902
2914
  * Creates the Kubb Studio client: the connection, the command loop, and the generation event
2903
2915
  * stream shared by the `kubb studio` CLI command and the Docker agent.
@@ -2913,7 +2925,7 @@ var StudioSession = class {
2913
2925
  */
2914
2926
  function createClient({ onAuthRequired, ...options }) {
2915
2927
  const controller = new AbortController();
2916
- const instanceId = (0, node_crypto.randomUUID)();
2928
+ const instanceId = options.instanceId ?? (0, node_crypto.randomUUID)();
2917
2929
  function notifyAuthRequired(error) {
2918
2930
  if (controller.signal.aborted) return;
2919
2931
  controller.abort();
@@ -2934,7 +2946,7 @@ function createClient({ onAuthRequired, ...options }) {
2934
2946
  };
2935
2947
  }
2936
2948
  //#endregion
2937
- //#region src/runConnection.ts
2949
+ //#region src/runtime/runConnection.ts
2938
2950
  /**
2939
2951
  * Waits for whichever comes first: the shutdown signal, or Studio rejecting the token during a
2940
2952
  * background reconnect. Resolves with the rejection, or nothing when the run is being shut down.
@@ -3007,7 +3019,7 @@ async function runConnection({ credentials, clientOptions, onTokenRejected, sign
3007
3019
  }
3008
3020
  }
3009
3021
  //#endregion
3010
- //#region src/pair.ts
3022
+ //#region src/operations/pair.ts
3011
3023
  /** Labels, not secrets: a person approving the code in the browser is what authorizes a pairing. */
3012
3024
  const CLIENT_IDS = {
3013
3025
  cli: "kubb-cli",