@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.js CHANGED
@@ -8,7 +8,6 @@ import path, { isAbsolute, join, relative, resolve, sep } from "node:path";
8
8
  import process$1 from "node:process";
9
9
  import { spawn } from "node:child_process";
10
10
  import { Diagnostics, Hookable, cacheStorage, createKubb, fsStorage, memoryStorage, resolveCacheDir } from "@kubb/core";
11
- import { x } from "tinyexec";
12
11
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
13
12
  import { FetchError, ofetch } from "ofetch";
14
13
  import { promisify, styleText } from "node:util";
@@ -22,6 +21,7 @@ import { gzip } from "node:zlib";
22
21
  import { build } from "tsdown";
23
22
  import { RpcTarget, newWebSocketRpcSession } from "capnweb";
24
23
  import WebSocket from "ws";
24
+ import { x } from "tinyexec";
25
25
  //#region ../../internals/utils/src/casing.ts
26
26
  /**
27
27
  * Shared implementation for camelCase and PascalCase conversion.
@@ -345,89 +345,9 @@ function getElapsedMs(hrStart) {
345
345
  }
346
346
  //#endregion
347
347
  //#region package.json
348
- var version = "5.3.17";
349
- //#endregion
350
- //#region src/hooks.ts
351
- /**
352
- * Register a `kubb:hook:start` listener that spawns the requested command via tinyexec,
353
- * streams each stdout line as a `kubb:hook:line` event, and calls `kubb:hook:end` with the result.
354
- * Streaming the output lets Kubb Studio render live hook progress over the WebSocket connection.
355
- *
356
- * Returns a remover, so a session that runs one generation after another on the same emitter does
357
- * not stack a listener per run.
358
- */
359
- function setupHookListener(hooks, root, signal) {
360
- return hooks.hook("kubb:hook:start", async (ctx) => {
361
- const { id, command, args } = ctx;
362
- if (!id) return;
363
- const commandWithArgs = args?.length ? `${command} ${args.join(" ")}` : command;
364
- try {
365
- const proc = x(command, [...args ?? []], {
366
- signal,
367
- nodeOptions: {
368
- cwd: root,
369
- detached: true
370
- }
371
- });
372
- for await (const line of proc) await hooks.callHook("kubb:hook:line", {
373
- id,
374
- line
375
- });
376
- const { exitCode } = await proc;
377
- if (exitCode !== 0) {
378
- const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
379
- await hooks.callHook("kubb:hook:end", {
380
- id,
381
- command,
382
- args,
383
- success: false,
384
- error
385
- });
386
- await hooks.callHook("kubb:error", { error });
387
- return;
388
- }
389
- await hooks.callHook("kubb:hook:end", {
390
- id,
391
- command,
392
- args,
393
- success: true,
394
- error: null
395
- });
396
- } catch (caughtError) {
397
- const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
398
- error.cause = caughtError;
399
- await hooks.callHook("kubb:hook:end", {
400
- id,
401
- command,
402
- args,
403
- success: false,
404
- error
405
- });
406
- await hooks.callHook("kubb:error", { error });
407
- }
408
- });
409
- }
410
- /**
411
- * Waits for the `kubb:hook:end` matching `hookId`. Register this before calling `kubb:hook:start`:
412
- * `callHook` awaits its listeners, and {@link setupHookListener} calls `kubb:hook:end` from inside
413
- * that same listener, so a handler added afterward would already have missed it.
414
- */
415
- function waitForHookEnd(hooks, hookId) {
416
- return new Promise((resolve, reject) => {
417
- const handleHookEnd = (ctx) => {
418
- if (ctx.id !== hookId) return;
419
- hooks.removeHook("kubb:hook:end", handleHookEnd);
420
- if (ctx.success) {
421
- resolve();
422
- return;
423
- }
424
- reject(ctx.error);
425
- };
426
- hooks.hook("kubb:hook:end", handleHookEnd);
427
- });
428
- }
348
+ var version = "5.3.19";
429
349
  //#endregion
430
- //#region src/machine.ts
350
+ //#region src/operations/machine.ts
431
351
  /**
432
352
  * Key-value storage the runtime uses for its machine secret and the last Studio config.
433
353
  *
@@ -487,7 +407,7 @@ async function getMachineToken() {
487
407
  return machineTokenFrom(await fallbackSecretPromise);
488
408
  }
489
409
  //#endregion
490
- //#region src/api.ts
410
+ //#region src/operations/api.ts
491
411
  /**
492
412
  * Reads a human-readable message from a Studio JSON error body, when it has one. `FetchError`'s own
493
413
  * message stops at the status line, so the detail Studio sends with a failure (an agent limit, a
@@ -623,7 +543,7 @@ function withJitter(ms) {
623
543
  * const finished = await waitForJob({ studioUrl, token, id: job.id })
624
544
  * ```
625
545
  */
626
- async function createJob({ studioUrl, token, type, agentId, name, version, commit, baseId, config, timeoutMs = 6e4, signal }) {
546
+ async function createJob({ studioUrl, token, type, agentId, name, version, commit, baseId, config, instanceId, timeoutMs = 6e4, signal }) {
627
547
  const deadline = Date.now() + timeoutMs;
628
548
  let interval = CREATE_JOB_INITIAL_DELAY_MS;
629
549
  for (;;) {
@@ -639,7 +559,8 @@ async function createJob({ studioUrl, token, type, agentId, name, version, commi
639
559
  version,
640
560
  commit,
641
561
  baseId,
642
- config
562
+ config,
563
+ instanceId
643
564
  },
644
565
  retry: false,
645
566
  timeout: Math.max(deadline - Date.now(), 1),
@@ -728,7 +649,7 @@ async function createAgent({ studioUrl, token, name, machineToken }) {
728
649
  }
729
650
  }
730
651
  //#endregion
731
- //#region src/resolveConfig.ts
652
+ //#region src/operations/resolveConfig.ts
732
653
  /**
733
654
  * Imports a package, falling back to how the user's project would resolve it.
734
655
  *
@@ -886,7 +807,7 @@ async function mergeAdapter(diskAdapter, studioOptions) {
886
807
  return factory(mergeDeep(diskAdapter.options ?? {}, studioOptions));
887
808
  }
888
809
  //#endregion
889
- //#region src/configFile.ts
810
+ //#region src/operations/configFile.ts
890
811
  /**
891
812
  * A valid JavaScript identifier, so an import name can only ever print as `import { name } from`,
892
813
  * never as source that breaks out of the import statement.
@@ -1426,6 +1347,11 @@ function applyConfigEdits(source, edits) {
1426
1347
  changed: current !== source
1427
1348
  };
1428
1349
  }
1350
+ async function writeConfigEdits({ filePath, edits }) {
1351
+ const result = applyConfigEdits(await read(filePath), edits);
1352
+ if (result.changed) await writeFile(filePath, result.source, "utf-8");
1353
+ return result;
1354
+ }
1429
1355
  /**
1430
1356
  * The 1-based line where the file's last import declaration ends, or `0` when it has none. Read
1431
1357
  * off the parsed module, so a multi-line `import {\n x,\n} from '...'` reports its closing line
@@ -1458,144 +1384,7 @@ function withTrailingNewline(code, hadTrailingNewline) {
1458
1384
  return `${code}\n`;
1459
1385
  }
1460
1386
  //#endregion
1461
- //#region src/generate.ts
1462
- /**
1463
- * `isToolAvailable` spawns a process, and a long-lived connection generates repeatedly, so each
1464
- * executable is probed once per process. The CLI deliberately does not memoize: a `--watch` build
1465
- * should keep noticing a tool installed mid-session.
1466
- */
1467
- const detectTool = memoize(/* @__PURE__ */ new Map(), detectTool$1);
1468
- /**
1469
- * The two post-build tool steps. Formatting and linting differ only in which tools they look for,
1470
- * so they run through one loop rather than two near-identical blocks.
1471
- *
1472
- * `noun` and `verbing` are spelled out instead of built from `kind`. Concatenating `` `${kind}ter` ``
1473
- * and `` `${kind}ting` `` works for `format`, but doubles the `t` in `lint`, giving "lintter" and
1474
- * "lintting" instead of "linter" and "linting".
1475
- */
1476
- const TOOL_STEPS = [{
1477
- kind: "format",
1478
- noun: "formatter",
1479
- verbing: "Formatting",
1480
- tools: formatters,
1481
- detect: FORMATTER_PREFERENCE
1482
- }, {
1483
- kind: "lint",
1484
- noun: "linter",
1485
- verbing: "Linting",
1486
- tools: linters,
1487
- detect: LINTER_PREFERENCE
1488
- }];
1489
- /**
1490
- * Absolute path of the directory the formatter and linter are pointed at.
1491
- */
1492
- function outputPath(config) {
1493
- return path.isAbsolute(config.output.path) ? config.output.path : path.resolve(process$1.cwd(), config.root, config.output.path);
1494
- }
1495
- /**
1496
- * Emits `kubb:hook:start` and waits for the matching `kubb:hook:end`. The host spawns the process:
1497
- * this only describes what to run and when it finished.
1498
- *
1499
- * @throws whatever the command failed with, so callers can report it their own way.
1500
- */
1501
- async function runHook({ hooks, id, command, args }) {
1502
- const hookId = hash("sha256", id);
1503
- const hookEnd = waitForHookEnd(hooks, hookId);
1504
- await hooks.callHook("kubb:hook:start", {
1505
- id: hookId,
1506
- command,
1507
- args: [...args]
1508
- });
1509
- await hookEnd;
1510
- }
1511
- function isProblemErrorDiagnostic(diagnostic) {
1512
- return (diagnostic.kind ?? "problem") === "problem" && diagnostic.severity === "error";
1513
- }
1514
- /**
1515
- * Folds error-severity diagnostics into one thrown error so logs name the failing plugin.
1516
- */
1517
- function formatGenerationFailure(diagnostics) {
1518
- const reasons = diagnostics.filter(isProblemErrorDiagnostic).map((diagnostic) => diagnostic.plugin ? `${diagnostic.plugin}: ${diagnostic.message}` : diagnostic.message);
1519
- if (!reasons.length) return /* @__PURE__ */ new Error("Generation failed");
1520
- return /* @__PURE__ */ new Error(`Generation failed: ${reasons.length} error${reasons.length === 1 ? "" : "s"}: ${reasons.join("; ")}`);
1521
- }
1522
- /**
1523
- * Runs a full Kubb code-generation cycle for the given config.
1524
- *
1525
- * Emits lifecycle events on the provided `hooks` emitter so callers (e.g. the WebSocket stream)
1526
- * can forward progress to connected clients. After a successful build, auto-formatting and
1527
- * linting are applied when configured, followed by any user-defined `hooks.done` commands.
1528
- */
1529
- async function generate({ config, hooks, signal }) {
1530
- signal?.throwIfAborted();
1531
- const hrStart = process$1.hrtime();
1532
- await hooks.callHook("kubb:generation:start", { config });
1533
- await hooks.callHook("kubb:info", { message: config.name ? `Setup generation ${config.name}` : "Setup generation" });
1534
- const kubb = createKubb(config, {
1535
- hooks,
1536
- signal
1537
- });
1538
- await kubb.setup();
1539
- await hooks.callHook("kubb:info", { message: config.name ? `Build generation ${config.name}` : "Build generation" });
1540
- const { files, diagnostics, storage } = await kubb.safeBuild();
1541
- signal?.throwIfAborted();
1542
- await hooks.callHook("kubb:info", { message: "Load summary" });
1543
- for (const diagnostic of diagnostics.filter(isProblemErrorDiagnostic)) await hooks.callHook("kubb:error", { error: new Error(diagnostic.plugin ? `${diagnostic.plugin}: ${diagnostic.message}` : diagnostic.message) });
1544
- const status = Diagnostics.hasError(diagnostics) ? "failed" : "success";
1545
- await hooks.callHook("kubb:generation:end", {
1546
- config,
1547
- storage: {
1548
- ...storage,
1549
- readKeys: async () => [...new Set(files.map((file) => file.path))]
1550
- },
1551
- diagnostics,
1552
- status,
1553
- hrStart,
1554
- filesCreated: files.length
1555
- });
1556
- if (status === "failed") throw formatGenerationFailure(diagnostics);
1557
- await hooks.callHook("kubb:success", { message: "Generation successfully" });
1558
- for (const step of TOOL_STEPS) {
1559
- const setting = config.output[step.kind];
1560
- if (!setting) continue;
1561
- await hooks.callHook(`kubb:${step.kind}:start`);
1562
- const tool = setting === "auto" ? await detectTool(step.detect) : setting;
1563
- if (!tool) await hooks.callHook("kubb:warn", { message: `No ${step.noun} found (${step.detect.join(", ")}). Skipping ${step.verbing.toLowerCase()}.` });
1564
- if (tool && setting === "auto") await hooks.callHook("kubb:info", { message: `Auto-detected ${step.noun}: ${styleText("dim", tool)}` });
1565
- const command = tool ? step.tools[tool] : void 0;
1566
- if (command) try {
1567
- await runHook({
1568
- hooks,
1569
- id: [config.name, tool].filter(Boolean).join("-"),
1570
- command: command.command,
1571
- args: command.args(outputPath(config))
1572
- });
1573
- await hooks.callHook("kubb:success", { message: `${step.verbing} with ${tool} successfully` });
1574
- } catch (caughtError) {
1575
- await hooks.callHook("kubb:error", { error: new Error(command.errorMessage, { cause: caughtError }) });
1576
- signal?.throwIfAborted();
1577
- }
1578
- await hooks.callHook(`kubb:${step.kind}:end`);
1579
- }
1580
- if (config.output.postGenerate?.length) {
1581
- await hooks.callHook("kubb:hooks:start");
1582
- for (const entry of config.output.postGenerate) {
1583
- const line = typeof entry === "string" ? entry : entry.command;
1584
- const [cmd, ...args] = tokenize(line);
1585
- if (!cmd) continue;
1586
- await runHook({
1587
- hooks,
1588
- id: line,
1589
- command: cmd,
1590
- args
1591
- });
1592
- await hooks.callHook("kubb:success", { message: `${line} successfully executed` });
1593
- }
1594
- await hooks.callHook("kubb:hooks:end");
1595
- }
1596
- }
1597
- //#endregion
1598
- //#region src/constants.ts
1387
+ //#region src/operations/constants.ts
1599
1388
  /**
1600
1389
  * Hosted Kubb Studio URL. Exported so credential stores can bind tokens to the resolved instance,
1601
1390
  * not whatever default the client would pick on its own.
@@ -1639,18 +1428,16 @@ function resolveGenerationLimits(env = process.env) {
1639
1428
  };
1640
1429
  }
1641
1430
  /**
1642
- * An agent's capacity read from `KUBB_AGENT_MAX_CONCURRENT` and `KUBB_AGENT_MEMORY_BUDGET_MB`. An
1643
- * unset or invalid value keeps the default: one job at a time, and no memory budget.
1431
+ * An agent's capacity read from `KUBB_AGENT_MAX_CONCURRENT`. An unset or invalid value keeps the
1432
+ * default: one job at a time.
1644
1433
  */
1645
1434
  function resolveAgentCapacity(env = process.env) {
1646
- return {
1647
- maxConcurrent: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_CONCURRENT) ?? agentDefaults.maxConcurrent)),
1648
- memoryBudgetMb: positiveNumber(env.KUBB_AGENT_MEMORY_BUDGET_MB)
1649
- };
1435
+ return { maxConcurrent: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_CONCURRENT) ?? agentDefaults.maxConcurrent)) };
1650
1436
  }
1651
1437
  //#endregion
1652
- //#region src/snapshotPackage.ts
1438
+ //#region src/operations/snapshotPackage.ts
1653
1439
  const gzipAsync = promisify(gzip);
1440
+ const UPLOAD_TIMEOUT_MS = 12e4;
1654
1441
  /**
1655
1442
  * Maps a generated file's path to its place inside the tarball, stripping everything before a
1656
1443
  * `src`/`dist` segment and any `..`/empty path segment so a crafted file name cannot escape the
@@ -1788,8 +1575,31 @@ async function createSnapshotPackage(files, packageInfo) {
1788
1575
  });
1789
1576
  }
1790
1577
  }
1578
+ async function uploadSnapshot({ bytes, uploadPath, studioUrl, token, shutdown }) {
1579
+ const uploadUrl = new URL(uploadPath, studioUrl);
1580
+ if (uploadUrl.origin !== new URL(studioUrl).origin) throw new Error("Snapshot upload path must stay on the Studio origin");
1581
+ const timeout = AbortSignal.timeout(UPLOAD_TIMEOUT_MS);
1582
+ const signal = shutdown ? AbortSignal.any([shutdown, timeout]) : timeout;
1583
+ const redirect = await fetch(uploadUrl, {
1584
+ method: "PUT",
1585
+ headers: { Authorization: `Bearer ${token}` },
1586
+ redirect: "manual",
1587
+ signal
1588
+ });
1589
+ const storageUrl = redirect.headers.get("location");
1590
+ if (redirect.status !== 307 || !storageUrl) throw new Error(`Studio did not provide a storage URL (status ${redirect.status})`);
1591
+ const storage = new URL(storageUrl);
1592
+ if (storage.protocol !== "https:" && storage.hostname !== "localhost" && storage.hostname !== "127.0.0.1") throw new Error(`Refusing snapshot upload to ${storage.origin}`);
1593
+ const response = await fetch(storage, {
1594
+ method: "PUT",
1595
+ body: new Uint8Array(bytes),
1596
+ redirect: "error",
1597
+ signal
1598
+ });
1599
+ if (!response.ok) throw new Error(`Snapshot upload failed with status ${response.status}`);
1600
+ }
1791
1601
  //#endregion
1792
- //#region src/generations.ts
1602
+ //#region src/operations/generations.ts
1793
1603
  const READ_CONCURRENCY = 50;
1794
1604
  const MB$1 = 1048576;
1795
1605
  const INDEX_KEY = "studio/generations.json";
@@ -1907,12 +1717,7 @@ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now
1907
1717
  };
1908
1718
  }
1909
1719
  //#endregion
1910
- //#region src/ws.ts
1911
- /**
1912
- * How long the initial handshake may take before the socket is closed and the reconnect loop
1913
- * takes over.
1914
- */
1915
- const CONNECT_TIMEOUT_MS = 5e3;
1720
+ //#region src/operations/generationEvents.ts
1916
1721
  const require = createRequire(import.meta.url);
1917
1722
  function relativeStoragePath(root, filePath) {
1918
1723
  return (isAbsolute(filePath) ? relative(resolve(root), filePath) : filePath).replaceAll("\\", "/");
@@ -1942,27 +1747,24 @@ async function resolvePeerDependencies(names) {
1942
1747
  missingDependencies
1943
1748
  };
1944
1749
  }
1945
- /**
1946
- * Opens a Studio WebSocket connection and closes it when the initial handshake exceeds the configured timeout.
1947
- */
1948
- function createWebsocket(url, options) {
1949
- const ws = new WebSocket(url, options);
1950
- const timer = setTimeout(() => {
1951
- if (ws.readyState === WebSocket.CONNECTING) ws.close(3008, "Connection timeout");
1952
- }, CONNECT_TIMEOUT_MS);
1953
- ws.once("open", () => clearTimeout(timer));
1954
- ws.once("close", () => clearTimeout(timer));
1955
- return ws;
1956
- }
1750
+ const MAX_QUEUED_EVENTS = 1024;
1751
+ const RESERVED_EVENTS = 64;
1752
+ const isDiscardable = (event) => event.type === "kubb:files:processing:update" || event.type === "kubb:info" || event.type === "kubb:success";
1957
1753
  /** Forwards selected Kubb lifecycle events to a native Cap'n Web stream. */
1958
1754
  function createGenerationStream(hooks, jobId, options = {}) {
1959
1755
  const unhooks = [];
1960
1756
  let root = "";
1961
- const transform = new TransformStream(void 0, void 0, { highWaterMark: Infinity });
1962
- const writer = transform.writable.getWriter();
1963
- let writes = Promise.resolve();
1757
+ let controller;
1964
1758
  let closed = false;
1965
- let streamError;
1759
+ const stream = new ReadableStream({
1760
+ start: (value) => {
1761
+ controller = value;
1762
+ },
1763
+ cancel: () => {
1764
+ closed = true;
1765
+ detach();
1766
+ }
1767
+ }, { highWaterMark: MAX_QUEUED_EVENTS });
1966
1768
  /**
1967
1769
  * Registers a listener and keeps its remover, so one generation's listeners come off the session
1968
1770
  * emitter again when that generation ends.
@@ -1971,6 +1773,7 @@ function createGenerationStream(hooks, jobId, options = {}) {
1971
1773
  unhooks.push(hooks.hook(name, handler));
1972
1774
  }
1973
1775
  function emitEvent(type, data) {
1776
+ if (closed) return;
1974
1777
  const event = {
1975
1778
  jobId,
1976
1779
  type,
@@ -1978,9 +1781,12 @@ function createGenerationStream(hooks, jobId, options = {}) {
1978
1781
  version: 1,
1979
1782
  timestamp: Date.now()
1980
1783
  };
1981
- writes = writes.then(() => writer.write(event)).catch((error) => {
1982
- streamError = error;
1983
- });
1784
+ if (controller.desiredSize <= RESERVED_EVENTS && isDiscardable(event)) return;
1785
+ if (controller.desiredSize <= 0) {
1786
+ fail(/* @__PURE__ */ new Error(`Generation event stream exceeded ${MAX_QUEUED_EVENTS} queued events`));
1787
+ return;
1788
+ }
1789
+ controller.enqueue(event);
1984
1790
  }
1985
1791
  on("kubb:plugin:start", (ctx) => {
1986
1792
  emitEvent("kubb:plugin:start", [{ plugin: { name: ctx.plugin.name } }]);
@@ -2126,25 +1932,38 @@ function createGenerationStream(hooks, jobId, options = {}) {
2126
1932
  if (closed) return;
2127
1933
  closed = true;
2128
1934
  detach();
2129
- await writes;
2130
- if (streamError) return;
2131
- await writer.close().catch(() => void 0);
1935
+ controller.close();
2132
1936
  }
2133
1937
  function fail(error) {
2134
1938
  detach();
2135
1939
  if (closed) return;
2136
1940
  closed = true;
2137
- writer.abort(error).catch(() => void 0);
1941
+ controller.error(error);
2138
1942
  }
2139
1943
  return {
2140
- stream: transform.readable,
1944
+ stream,
2141
1945
  close,
2142
1946
  dispose: () => fail(),
2143
1947
  fail
2144
1948
  };
2145
1949
  }
2146
1950
  //#endregion
2147
- //#region src/rpc.ts
1951
+ //#region src/operations/websocket.ts
1952
+ const CONNECT_TIMEOUT_MS = 5e3;
1953
+ /**
1954
+ * Opens a Studio WebSocket connection and closes it when the initial handshake exceeds the configured timeout.
1955
+ */
1956
+ function createWebsocket(url, options) {
1957
+ const ws = new WebSocket(url, options);
1958
+ const timer = setTimeout(() => {
1959
+ if (ws.readyState === WebSocket.CONNECTING) ws.close(3008, "Connection timeout");
1960
+ }, CONNECT_TIMEOUT_MS);
1961
+ ws.once("open", () => clearTimeout(timer));
1962
+ ws.once("close", () => clearTimeout(timer));
1963
+ return ws;
1964
+ }
1965
+ //#endregion
1966
+ //#region src/operations/rpc.ts
2148
1967
  /**
2149
1968
  * The only methods Studio may call on an agent. A `StudioSession` carries far more than
2150
1969
  * {@link AgentApi}, so it is wrapped rather than exposed: what Cap'n Web can reach is exactly what
@@ -2205,16 +2024,253 @@ const connectWebSocketRpc = async ({ url, token, instanceId, local }) => {
2205
2024
  };
2206
2025
  };
2207
2026
  //#endregion
2208
- //#region src/StudioSession.ts
2027
+ //#region src/operations/hooks.ts
2209
2028
  /**
2210
- * Past this many files in the output directory, no snapshot of it is taken before a run.
2029
+ * Register a `kubb:hook:start` listener that spawns the requested command via tinyexec,
2030
+ * streams each stdout line as a `kubb:hook:line` event, and calls `kubb:hook:end` with the result.
2031
+ * Streaming the output lets Kubb Studio render live hook progress over the WebSocket connection.
2032
+ *
2033
+ * Returns a remover, so a session that runs one generation after another on the same emitter does
2034
+ * not stack a listener per run.
2211
2035
  */
2036
+ function setupHookListener(hooks, root, signal) {
2037
+ return hooks.hook("kubb:hook:start", async (ctx) => {
2038
+ const { id, command, args } = ctx;
2039
+ if (!id) return;
2040
+ const commandWithArgs = args?.length ? `${command} ${args.join(" ")}` : command;
2041
+ try {
2042
+ const proc = x(command, [...args ?? []], {
2043
+ signal,
2044
+ nodeOptions: {
2045
+ cwd: root,
2046
+ detached: true
2047
+ }
2048
+ });
2049
+ for await (const line of proc) await hooks.callHook("kubb:hook:line", {
2050
+ id,
2051
+ line
2052
+ });
2053
+ const { exitCode } = await proc;
2054
+ if (exitCode !== 0) {
2055
+ const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
2056
+ await hooks.callHook("kubb:hook:end", {
2057
+ id,
2058
+ command,
2059
+ args,
2060
+ success: false,
2061
+ error
2062
+ });
2063
+ await hooks.callHook("kubb:error", { error });
2064
+ return;
2065
+ }
2066
+ await hooks.callHook("kubb:hook:end", {
2067
+ id,
2068
+ command,
2069
+ args,
2070
+ success: true,
2071
+ error: null
2072
+ });
2073
+ } catch (caughtError) {
2074
+ const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
2075
+ error.cause = caughtError;
2076
+ await hooks.callHook("kubb:hook:end", {
2077
+ id,
2078
+ command,
2079
+ args,
2080
+ success: false,
2081
+ error
2082
+ });
2083
+ await hooks.callHook("kubb:error", { error });
2084
+ }
2085
+ });
2086
+ }
2087
+ /**
2088
+ * Waits for the `kubb:hook:end` matching `hookId`. Register this before calling `kubb:hook:start`:
2089
+ * `callHook` awaits its listeners, and {@link setupHookListener} calls `kubb:hook:end` from inside
2090
+ * that same listener, so a handler added afterward would already have missed it.
2091
+ */
2092
+ function waitForHookEnd(hooks, hookId) {
2093
+ return new Promise((resolve, reject) => {
2094
+ const handleHookEnd = (ctx) => {
2095
+ if (ctx.id !== hookId) return;
2096
+ hooks.removeHook("kubb:hook:end", handleHookEnd);
2097
+ if (ctx.success) {
2098
+ resolve();
2099
+ return;
2100
+ }
2101
+ reject(ctx.error);
2102
+ };
2103
+ hooks.hook("kubb:hook:end", handleHookEnd);
2104
+ });
2105
+ }
2106
+ //#endregion
2107
+ //#region src/operations/generate.ts
2212
2108
  const DISK_SNAPSHOT_MAX_FILES = 1e4;
2213
2109
  /**
2214
- * How long a sandbox keeps a generation readable. Its store is in memory and holds every tenant's
2215
- * runs, so an old one has to go even when count and size leave room. A local agent keeps its runs
2216
- * until count or size pushes them out, so a later run can still diff against the one before it.
2110
+ * `isToolAvailable` spawns a process, and a long-lived connection generates repeatedly, so each
2111
+ * executable is probed once per process. The CLI deliberately does not memoize: a `--watch` build
2112
+ * should keep noticing a tool installed mid-session.
2113
+ */
2114
+ const detectTool = memoize(/* @__PURE__ */ new Map(), detectTool$1);
2115
+ /**
2116
+ * The two post-build tool steps. Formatting and linting differ only in which tools they look for,
2117
+ * so they run through one loop rather than two near-identical blocks.
2118
+ *
2119
+ * `noun` and `verbing` are spelled out instead of built from `kind`. Concatenating `` `${kind}ter` ``
2120
+ * and `` `${kind}ting` `` works for `format`, but doubles the `t` in `lint`, giving "lintter" and
2121
+ * "lintting" instead of "linter" and "linting".
2122
+ */
2123
+ const TOOL_STEPS = [{
2124
+ kind: "format",
2125
+ noun: "formatter",
2126
+ verbing: "Formatting",
2127
+ tools: formatters,
2128
+ detect: FORMATTER_PREFERENCE
2129
+ }, {
2130
+ kind: "lint",
2131
+ noun: "linter",
2132
+ verbing: "Linting",
2133
+ tools: linters,
2134
+ detect: LINTER_PREFERENCE
2135
+ }];
2136
+ /**
2137
+ * Absolute path of the directory the formatter and linter are pointed at.
2138
+ */
2139
+ function outputPath(config) {
2140
+ return path.isAbsolute(config.output.path) ? config.output.path : path.resolve(process$1.cwd(), config.root, config.output.path);
2141
+ }
2142
+ /**
2143
+ * Emits `kubb:hook:start` and waits for the matching `kubb:hook:end`. The host spawns the process:
2144
+ * this only describes what to run and when it finished.
2145
+ *
2146
+ * @throws whatever the command failed with, so callers can report it their own way.
2147
+ */
2148
+ async function runHook({ hooks, id, command, args }) {
2149
+ const hookId = hash("sha256", id);
2150
+ const hookEnd = waitForHookEnd(hooks, hookId);
2151
+ await hooks.callHook("kubb:hook:start", {
2152
+ id: hookId,
2153
+ command,
2154
+ args: [...args]
2155
+ });
2156
+ await hookEnd;
2157
+ }
2158
+ function isProblemErrorDiagnostic(diagnostic) {
2159
+ return (diagnostic.kind ?? "problem") === "problem" && diagnostic.severity === "error";
2160
+ }
2161
+ /**
2162
+ * Folds error-severity diagnostics into one thrown error so logs name the failing plugin.
2163
+ */
2164
+ function formatGenerationFailure(diagnostics) {
2165
+ const reasons = diagnostics.filter(isProblemErrorDiagnostic).map((diagnostic) => diagnostic.plugin ? `${diagnostic.plugin}: ${diagnostic.message}` : diagnostic.message);
2166
+ if (!reasons.length) return /* @__PURE__ */ new Error("Generation failed");
2167
+ return /* @__PURE__ */ new Error(`Generation failed: ${reasons.length} error${reasons.length === 1 ? "" : "s"}: ${reasons.join("; ")}`);
2168
+ }
2169
+ /**
2170
+ * Runs a full Kubb code-generation cycle for the given config.
2171
+ *
2172
+ * Emits lifecycle events on the provided `hooks` emitter so callers (e.g. the WebSocket stream)
2173
+ * can forward progress to connected clients. After a successful build, auto-formatting and
2174
+ * linting are applied when configured, followed by any user-defined `hooks.done` commands.
2217
2175
  */
2176
+ async function generate({ config, hooks, signal }) {
2177
+ signal?.throwIfAborted();
2178
+ const hrStart = process$1.hrtime();
2179
+ await hooks.callHook("kubb:generation:start", { config });
2180
+ await hooks.callHook("kubb:info", { message: config.name ? `Setup generation ${config.name}` : "Setup generation" });
2181
+ const kubb = createKubb(config, {
2182
+ hooks,
2183
+ signal
2184
+ });
2185
+ await kubb.setup();
2186
+ await hooks.callHook("kubb:info", { message: config.name ? `Build generation ${config.name}` : "Build generation" });
2187
+ const { files, diagnostics, storage } = await kubb.safeBuild();
2188
+ signal?.throwIfAborted();
2189
+ await hooks.callHook("kubb:info", { message: "Load summary" });
2190
+ for (const diagnostic of diagnostics.filter(isProblemErrorDiagnostic)) await hooks.callHook("kubb:error", { error: new Error(diagnostic.plugin ? `${diagnostic.plugin}: ${diagnostic.message}` : diagnostic.message) });
2191
+ const status = Diagnostics.hasError(diagnostics) ? "failed" : "success";
2192
+ await hooks.callHook("kubb:generation:end", {
2193
+ config,
2194
+ storage: {
2195
+ ...storage,
2196
+ readKeys: async () => [...new Set(files.map((file) => file.path))]
2197
+ },
2198
+ diagnostics,
2199
+ status,
2200
+ hrStart,
2201
+ filesCreated: files.length
2202
+ });
2203
+ if (status === "failed") throw formatGenerationFailure(diagnostics);
2204
+ await hooks.callHook("kubb:success", { message: "Generation successfully" });
2205
+ for (const step of TOOL_STEPS) {
2206
+ const setting = config.output[step.kind];
2207
+ if (!setting) continue;
2208
+ await hooks.callHook(`kubb:${step.kind}:start`);
2209
+ const tool = setting === "auto" ? await detectTool(step.detect) : setting;
2210
+ if (!tool) await hooks.callHook("kubb:warn", { message: `No ${step.noun} found (${step.detect.join(", ")}). Skipping ${step.verbing.toLowerCase()}.` });
2211
+ if (tool && setting === "auto") await hooks.callHook("kubb:info", { message: `Auto-detected ${step.noun}: ${styleText("dim", tool)}` });
2212
+ const command = tool ? step.tools[tool] : void 0;
2213
+ if (command) try {
2214
+ await runHook({
2215
+ hooks,
2216
+ id: [config.name, tool].filter(Boolean).join("-"),
2217
+ command: command.command,
2218
+ args: command.args(outputPath(config))
2219
+ });
2220
+ await hooks.callHook("kubb:success", { message: `${step.verbing} with ${tool} successfully` });
2221
+ } catch (caughtError) {
2222
+ await hooks.callHook("kubb:error", { error: new Error(command.errorMessage, { cause: caughtError }) });
2223
+ signal?.throwIfAborted();
2224
+ }
2225
+ await hooks.callHook(`kubb:${step.kind}:end`);
2226
+ }
2227
+ if (config.output.postGenerate?.length) {
2228
+ await hooks.callHook("kubb:hooks:start");
2229
+ for (const entry of config.output.postGenerate) {
2230
+ const line = typeof entry === "string" ? entry : entry.command;
2231
+ const [cmd, ...args] = tokenize(line);
2232
+ if (!cmd) continue;
2233
+ await runHook({
2234
+ hooks,
2235
+ id: line,
2236
+ command: cmd,
2237
+ args
2238
+ });
2239
+ await hooks.callHook("kubb:success", { message: `${line} successfully executed` });
2240
+ }
2241
+ await hooks.callHook("kubb:hooks:end");
2242
+ }
2243
+ }
2244
+ async function runGenerationOperation({ config, hooks, signal, jobId, store, snapshotRoot, maxSnapshotMb }) {
2245
+ const diskFiles = snapshotRoot ? await listDisk({
2246
+ root: snapshotRoot,
2247
+ outputPath: config.output.path,
2248
+ maxFiles: DISK_SNAPSHOT_MAX_FILES
2249
+ }) : void 0;
2250
+ const disk = diskFiles ? await store.keep({
2251
+ jobId,
2252
+ source: "disk",
2253
+ files: diskFiles,
2254
+ maxSetMb: maxSnapshotMb
2255
+ }) : void 0;
2256
+ const removeHookListener = setupHookListener(hooks, config.root, signal);
2257
+ try {
2258
+ await generate({
2259
+ config,
2260
+ hooks,
2261
+ signal
2262
+ });
2263
+ } catch (error) {
2264
+ await store.drop(jobId);
2265
+ throw error;
2266
+ } finally {
2267
+ removeHookListener();
2268
+ }
2269
+ return disk;
2270
+ }
2271
+ //#endregion
2272
+ //#region src/runtime/StudioSession.ts
2273
+ /** A sandbox shares one in-memory generation store across tenants, so old generations expire. */
2218
2274
  const SANDBOX_GENERATION_TTL_MS = 9e5;
2219
2275
  /**
2220
2276
  * A fresh root for one sandbox job. Kubb keys its output manifest cache by root, so tenants that
@@ -2276,7 +2332,6 @@ function applyStudioDefaults(options) {
2276
2332
  permissions: {
2277
2333
  allowWrite: false,
2278
2334
  allowConfigEdit: false,
2279
- allowInput: false,
2280
2335
  allowExec: false,
2281
2336
  allowRead: false,
2282
2337
  ...options.permissions
@@ -2412,13 +2467,6 @@ var StudioSession = class {
2412
2467
  return !this.#isSandbox && this.#options.permissions.allowConfigEdit;
2413
2468
  }
2414
2469
  /**
2415
- * A sandbox agent always generates from the spec Studio supplies. A local agent only when the
2416
- * host opted in.
2417
- */
2418
- get #canUseInput() {
2419
- return this.#isSandbox || this.#options.permissions.allowInput;
2420
- }
2421
- /**
2422
2470
  * A sandbox agent always allows reading its output back; a local agent only when opted in.
2423
2471
  */
2424
2472
  get #canRead() {
@@ -2524,20 +2572,12 @@ var StudioSession = class {
2524
2572
  clearTimeout(timer);
2525
2573
  }
2526
2574
  }
2527
- /**
2528
- * Whether memory still leaves room for another job. Always, when the host set no budget.
2529
- */
2530
- #isAccepting(memoryMb = rssMb()) {
2531
- const budget = this.#options.capacity.memoryBudgetMb;
2532
- return budget === void 0 || memoryMb <= budget * 1.5;
2533
- }
2534
2575
  async #load() {
2535
- const memoryMb = rssMb();
2536
2576
  return {
2537
2577
  running: this.#isGenerating ? 1 : 0,
2538
- rssMb: Math.round(memoryMb),
2578
+ rssMb: Math.round(rssMb()),
2539
2579
  storeBytes: await this.#generations.bytes(),
2540
- accepting: this.#isAccepting(memoryMb)
2580
+ accepting: true
2541
2581
  };
2542
2582
  }
2543
2583
  /**
@@ -2575,7 +2615,6 @@ var StudioSession = class {
2575
2615
  permissions: {
2576
2616
  ...permissions,
2577
2617
  allowWrite: this.#canWrite,
2578
- allowInput: this.#canUseInput,
2579
2618
  allowConfigEdit: this.#canEditConfig,
2580
2619
  allowRead: this.#canRead
2581
2620
  }
@@ -2646,7 +2685,6 @@ var StudioSession = class {
2646
2685
  }
2647
2686
  async #runGeneration(data, controller, cancelRun) {
2648
2687
  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");
2649
- 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");
2650
2688
  this.#isGenerating = true;
2651
2689
  this.#activeJob = {
2652
2690
  jobId: data.jobId,
@@ -2662,48 +2700,33 @@ var StudioSession = class {
2662
2700
  const patch = data.config;
2663
2701
  const plugins = await mergePlugins(config.plugins, patch?.plugins);
2664
2702
  const adapter = await mergeAdapter(config.adapter, patch?.adapter);
2665
- const inputOverride = this.#isSandbox ? patch?.input ?? "" : permissions.allowInput && patch?.input || void 0;
2703
+ const inputOverride = this.#isSandbox ? patch?.input ?? "" : void 0;
2666
2704
  if (permissions.allowWrite && this.#isSandbox) await this.#warn("Running in a sandbox, so writing files is disabled");
2667
- if (patch?.input && !this.#canUseInput) await this.#warn("Ignored the spec from Studio: generating from a Studio spec was not granted", "allowInput");
2705
+ 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");
2668
2706
  const resolvedPlugins = plugins ?? config.plugins;
2669
2707
  this.#lastGeneration = void 0;
2670
- const diskFiles = this.#hasProjectOnDisk ? await listDisk({
2671
- root,
2672
- outputPath: config.output.path,
2673
- maxFiles: DISK_SNAPSHOT_MAX_FILES
2674
- }) : void 0;
2675
- const disk = diskFiles ? await this.#generations.keep({
2676
- jobId: data.jobId,
2677
- source: "disk",
2678
- files: diskFiles,
2679
- maxSetMb: this.#limits.maxSnapshotMb
2680
- }) : void 0;
2681
- const detach = [setupHookListener(this.#hooks, root, controller.signal)];
2682
- try {
2683
- await generate({
2684
- config: {
2685
- ...config,
2686
- root,
2687
- input: inputOverride ?? config.input,
2688
- storage: this.#canWrite ? fsStorage() : memoryStorage(),
2689
- output: permissions.allowExec ? { ...config.output } : {
2690
- ...config.output,
2691
- format: false,
2692
- lint: false,
2693
- postGenerate: []
2694
- },
2695
- plugins: resolvedPlugins,
2696
- adapter
2708
+ const disk = await runGenerationOperation({
2709
+ config: {
2710
+ ...config,
2711
+ root,
2712
+ input: inputOverride ?? config.input,
2713
+ storage: this.#canWrite ? fsStorage() : memoryStorage(),
2714
+ output: permissions.allowExec ? { ...config.output } : {
2715
+ ...config.output,
2716
+ format: false,
2717
+ lint: false,
2718
+ postGenerate: []
2697
2719
  },
2698
- hooks: this.#hooks,
2699
- signal: controller.signal
2700
- });
2701
- } catch (error) {
2702
- await this.#generations.drop(data.jobId);
2703
- throw error;
2704
- } finally {
2705
- for (const remove of detach) remove();
2706
- }
2720
+ plugins: resolvedPlugins,
2721
+ adapter
2722
+ },
2723
+ hooks: this.#hooks,
2724
+ signal: controller.signal,
2725
+ jobId: data.jobId,
2726
+ store: this.#generations,
2727
+ snapshotRoot: this.#hasProjectOnDisk ? root : void 0,
2728
+ maxSnapshotMb: this.#limits.maxSnapshotMb
2729
+ });
2707
2730
  await this.#hooks.callHook("studio:command:end", {
2708
2731
  command,
2709
2732
  info: `${resolvedPlugins.length} plugin${resolvedPlugins.length === 1 ? "" : "s"}, ${this.#canWrite ? "written to disk" : "in memory"}${inputOverride !== void 0 ? ", from a Studio spec" : ""}`
@@ -2765,8 +2788,10 @@ var StudioSession = class {
2765
2788
  }
2766
2789
  if (this.#isGenerating) return refuse("a generation is in progress");
2767
2790
  try {
2768
- const { source: patched, outcomes, changed } = applyConfigEdits(await read(configFile), edits);
2769
- if (changed) await writeFile(configFile, patched, "utf-8");
2791
+ const { source: patched, outcomes, changed } = await writeConfigEdits({
2792
+ filePath: configFile,
2793
+ edits
2794
+ });
2770
2795
  const applied = outcomes.filter((outcome) => outcome.applied).length;
2771
2796
  await this.#hooks.callHook("studio:command:end", {
2772
2797
  command,
@@ -2804,24 +2829,14 @@ var StudioSession = class {
2804
2829
  version,
2805
2830
  peerDependencies: generation.peerDependencies
2806
2831
  });
2807
- const { token, studioUrl } = this.#options;
2808
- const uploadUrl = new URL(uploadPath, studioUrl);
2809
- if (uploadUrl.origin !== new URL(studioUrl).origin) throw new Error("Snapshot upload path must stay on the Studio origin");
2810
- const redirect = await fetch(uploadUrl, {
2811
- method: "PUT",
2812
- headers: { Authorization: `Bearer ${token}` },
2813
- redirect: "manual"
2814
- });
2815
- const storageUrl = redirect.headers.get("location");
2816
- if (redirect.status !== 307 || !storageUrl) throw new Error(`Studio did not provide a storage URL (status ${redirect.status})`);
2817
- const storage = new URL(storageUrl);
2818
- if (storage.protocol !== "https:" && storage.hostname !== "localhost" && storage.hostname !== "127.0.0.1") throw new Error(`Refusing snapshot upload to ${storage.origin}`);
2819
- const response = await fetch(storage, {
2820
- method: "PUT",
2821
- body: new Uint8Array(bytes),
2822
- redirect: "error"
2832
+ const { token, studioUrl, signal } = this.#options;
2833
+ await uploadSnapshot({
2834
+ bytes,
2835
+ uploadPath,
2836
+ studioUrl,
2837
+ token,
2838
+ shutdown: signal
2823
2839
  });
2824
- if (!response.ok) throw new Error(`Snapshot upload failed with status ${response.status}`);
2825
2840
  await this.#hooks.callHook("studio:command:end", {
2826
2841
  command,
2827
2842
  info: `packed ${Object.keys(files).length} file${Object.keys(files).length === 1 ? "" : "s"}`
@@ -2835,10 +2850,7 @@ var StudioSession = class {
2835
2850
  throw error;
2836
2851
  }
2837
2852
  }
2838
- /**
2839
- * An agent with a project on disk can show a run against what its output directory held before.
2840
- * A sandbox agent has no project.
2841
- */
2853
+ /** A sandbox has no project directory to snapshot. */
2842
2854
  get #hasProjectOnDisk() {
2843
2855
  return !this.#isSandbox && this.#canRead;
2844
2856
  }
@@ -2867,7 +2879,7 @@ var StudioSession = class {
2867
2879
  }
2868
2880
  };
2869
2881
  //#endregion
2870
- //#region src/client.ts
2882
+ //#region src/runtime/client.ts
2871
2883
  /**
2872
2884
  * Creates the Kubb Studio client: the connection, the command loop, and the generation event
2873
2885
  * stream shared by the `kubb studio` CLI command and the Docker agent.
@@ -2883,7 +2895,7 @@ var StudioSession = class {
2883
2895
  */
2884
2896
  function createClient({ onAuthRequired, ...options }) {
2885
2897
  const controller = new AbortController();
2886
- const instanceId = randomUUID();
2898
+ const instanceId = options.instanceId ?? randomUUID();
2887
2899
  function notifyAuthRequired(error) {
2888
2900
  if (controller.signal.aborted) return;
2889
2901
  controller.abort();
@@ -2904,7 +2916,7 @@ function createClient({ onAuthRequired, ...options }) {
2904
2916
  };
2905
2917
  }
2906
2918
  //#endregion
2907
- //#region src/runConnection.ts
2919
+ //#region src/runtime/runConnection.ts
2908
2920
  /**
2909
2921
  * Waits for whichever comes first: the shutdown signal, or Studio rejecting the token during a
2910
2922
  * background reconnect. Resolves with the rejection, or nothing when the run is being shut down.
@@ -2977,7 +2989,7 @@ async function runConnection({ credentials, clientOptions, onTokenRejected, sign
2977
2989
  }
2978
2990
  }
2979
2991
  //#endregion
2980
- //#region src/pair.ts
2992
+ //#region src/operations/pair.ts
2981
2993
  /** Labels, not secrets: a person approving the code in the browser is what authorizes a pairing. */
2982
2994
  const CLIENT_IDS = {
2983
2995
  cli: "kubb-cli",