@kubb/studio 5.11.3 → 5.11.5

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
@@ -23,7 +23,6 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
23
23
  //#endregion
24
24
  const require_protocol = require("./protocol.cjs");
25
25
  let node_crypto = require("node:crypto");
26
- let node_fs_promises = require("node:fs/promises");
27
26
  let node_os = require("node:os");
28
27
  let node_path = require("node:path");
29
28
  node_path = __toESM(node_path, 1);
@@ -32,6 +31,7 @@ node_process = __toESM(node_process, 1);
32
31
  let node_timers_promises = require("node:timers/promises");
33
32
  let node_url = require("node:url");
34
33
  let node_fs = require("node:fs");
34
+ let node_fs_promises = require("node:fs/promises");
35
35
  let _kubb_core = require("@kubb/core");
36
36
  let capnweb = require("capnweb");
37
37
  let magicast = require("magicast");
@@ -141,6 +141,27 @@ async function read(path) {
141
141
  return (0, node_fs_promises.readFile)(path, { encoding: "utf8" });
142
142
  }
143
143
  /**
144
+ * Writes UTF-8 content verbatim, including empty files and surrounding whitespace.
145
+ * Uses Bun.write under Bun and creates missing parent directories under either runtime.
146
+ *
147
+ * @example
148
+ * await writeRaw('./cache/output.ts', ' // keep these bytes\n\n')
149
+ */
150
+ async function writeRaw(path, content) {
151
+ const resolved = (0, node_path.resolve)(path);
152
+ if (runtime.isBun) {
153
+ await Bun.write(resolved, content);
154
+ return;
155
+ }
156
+ try {
157
+ await (0, node_fs_promises.writeFile)(resolved, content, { encoding: "utf-8" });
158
+ } catch (error) {
159
+ if (error.code !== "ENOENT") throw error;
160
+ await (0, node_fs_promises.mkdir)((0, node_path.dirname)(resolved), { recursive: true });
161
+ await (0, node_fs_promises.writeFile)(resolved, content, { encoding: "utf-8" });
162
+ }
163
+ }
164
+ /**
144
165
  * Resolves to `true` when `path` is `parent` itself or nested inside it. Both sides are resolved
145
166
  * to absolute paths first, so relative and `..`-containing inputs compare correctly.
146
167
  *
@@ -199,7 +220,7 @@ function getElapsedMs(hrStart) {
199
220
  }
200
221
  //#endregion
201
222
  //#region package.json
202
- var version = "5.11.3";
223
+ var version = "5.11.5";
203
224
  //#endregion
204
225
  //#region src/resolveConfig.ts
205
226
  /**
@@ -1061,13 +1082,17 @@ function applySet(source, call, path, value, quote) {
1061
1082
  }
1062
1083
  return { source: splice(source, rangeOf(target.value).start, rangeOf(target.value).end, printValue(value, quote)) };
1063
1084
  }
1064
- /** Deletes the option at `path`, falling the plugin back to its default for it. */
1085
+ /**
1086
+ * Deletes the option at `path`, falling the plugin back to its default for it. An option the file
1087
+ * does not set already has its default, so removing it changes nothing.
1088
+ */
1065
1089
  function applyRemove(source, call, path) {
1066
1090
  const options = call.arguments[0];
1067
- if (options?.type !== "ObjectExpression") return { reason: "the factory call has no options to remove" };
1091
+ if (!options) return { source };
1092
+ if (options.type !== "ObjectExpression") return { reason: "the factory was not called with an object literal" };
1068
1093
  const target = walk(options, path);
1069
1094
  if ("reason" in target) return target;
1070
- if ("missing" in target) return { reason: `${path.join(".")} is not set` };
1095
+ if ("missing" in target) return { source };
1071
1096
  return { source: removeMember(source, target.object, target.index) };
1072
1097
  }
1073
1098
  /**
@@ -1107,6 +1132,22 @@ function applyAddPlugin({ source, parsed, config, edit, quote, extensions }) {
1107
1132
  const next = insertItem(source, plugins, `${importName}(${Object.keys(options).length ? printValue(options, quote) : ""})`);
1108
1133
  return { source: taken ? next : insertImport(next, parsed, importName, edit.plugin) };
1109
1134
  }
1135
+ /**
1136
+ * Rewrites the config's `plugins` array with one item per line, so a call can be commented out without
1137
+ * taking its neighbors with it. Not done when a comment sits between the items, which would have to move.
1138
+ */
1139
+ function expandPluginsArray(source, config) {
1140
+ const array = property(config, "plugins");
1141
+ if (array?.type !== "ArrayExpression") return void 0;
1142
+ const items = array.elements.flatMap((element) => element ? [element] : []);
1143
+ const { start, end } = rangeOf(array);
1144
+ const ranges = items.map(rangeOf);
1145
+ const gaps = [source.slice(start + 1, ranges[0]?.start ?? end - 1), ...ranges.map((range, index) => source.slice(range.end, ranges[index + 1]?.start ?? end - 1))];
1146
+ if (!items.length || gaps.some((gap) => !/^[\s,]*$/.test(gap))) return void 0;
1147
+ const lineStart = source.lastIndexOf("\n", start - 1) + 1;
1148
+ const indent = source.slice(lineStart).match(/^[ \t]*/)?.[0] ?? "";
1149
+ return splice(source, start, end, `[\n${ranges.map((range) => `${indent} ${source.slice(range.start, range.end)},`).join("\n")}\n${indent}]`);
1150
+ }
1110
1151
  /** Comments out a plugin call in place, keeping its options on disk so `enable-plugin` can restore them exactly. */
1111
1152
  function disablePlugin(source, mod, config, plugin) {
1112
1153
  const loc = factoryCalls(mod, config).find((entry) => entry.packageName === plugin)?.call.loc;
@@ -1134,9 +1175,17 @@ function applyEdit({ source, edit, quote, extensions }) {
1134
1175
  const parsed = parseConfigs(source);
1135
1176
  if ("reason" in parsed) return parsed;
1136
1177
  const ref = edit.config;
1137
- const config = typeof ref === "string" ? parsed.configs.find((entry) => configName(entry) === ref) : parsed.configs[ref ?? 0];
1178
+ const findConfig = (entries) => typeof ref === "string" ? entries.configs.find((entry) => configName(entry) === ref) : entries.configs[ref ?? 0];
1179
+ const config = findConfig(parsed);
1138
1180
  if (!config) return { reason: `no config entry found for ${JSON.stringify(ref)}` };
1139
- if (edit.operation === "disable-plugin") return disablePlugin(source, parsed.mod, config, edit.plugin);
1181
+ if (edit.operation === "disable-plugin") {
1182
+ const result = disablePlugin(source, parsed.mod, config, edit.plugin);
1183
+ if (!("reason" in result) || !result.reason.includes("shares a line")) return result;
1184
+ const expanded = expandPluginsArray(source, config);
1185
+ const reparsed = expanded === void 0 ? void 0 : parseConfigs(expanded);
1186
+ const reparsedConfig = reparsed && !("reason" in reparsed) ? findConfig(reparsed) : void 0;
1187
+ return expanded !== void 0 && reparsed && !("reason" in reparsed) && reparsedConfig ? disablePlugin(expanded, reparsed.mod, reparsedConfig, edit.plugin) : result;
1188
+ }
1140
1189
  if (edit.operation === "enable-plugin") return enablePlugin(source, config, edit.plugin);
1141
1190
  if (edit.operation === "add-plugin") return applyAddPlugin({
1142
1191
  source,
@@ -1395,21 +1444,20 @@ const hashOf = (content) => (0, node_crypto.createHash)("sha1").update(content).
1395
1444
  /**
1396
1445
  * The bytes `entries` keep on disk: output, written files, and kept input.
1397
1446
  */
1398
- const totalBytes = (entries) => entries.reduce((sum, { output, disk, inputBytes }) => sum + output.bytes + (disk?.bytes ?? 0) + (inputBytes ?? 0), 0);
1447
+ const totalBytes = (entries) => entries.reduce((sum, { output, inputBytes }) => sum + output.bytes + (inputBytes ?? 0), 0);
1399
1448
  function positiveNumber(value) {
1400
1449
  const parsed = Number(value);
1401
1450
  return value && Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
1402
1451
  }
1403
1452
  /**
1404
1453
  * How many generations an agent keeps and how large they may get, read from
1405
- * `KUBB_AGENT_MAX_GENERATIONS`, `KUBB_AGENT_MAX_GENERATIONS_MB` and `KUBB_AGENT_MAX_SNAPSHOT_MB`.
1454
+ * `KUBB_AGENT_MAX_GENERATIONS` and `KUBB_AGENT_MAX_GENERATIONS_MB`.
1406
1455
  * An unset or invalid value keeps the default.
1407
1456
  */
1408
1457
  function resolveGenerationLimits() {
1409
1458
  return {
1410
1459
  maxCount: Math.max(1, Math.floor(positiveNumber(node_process.default.env.KUBB_AGENT_MAX_GENERATIONS) ?? 8)),
1411
- maxMb: positiveNumber(node_process.default.env.KUBB_AGENT_MAX_GENERATIONS_MB) ?? 100,
1412
- maxSnapshotMb: positiveNumber(node_process.default.env.KUBB_AGENT_MAX_SNAPSHOT_MB) ?? 50
1460
+ maxMb: positiveNumber(node_process.default.env.KUBB_AGENT_MAX_GENERATIONS_MB) ?? 100
1413
1461
  };
1414
1462
  }
1415
1463
  /**
@@ -1456,7 +1504,7 @@ async function generate({ config, hooks, signal, allowExec }) {
1456
1504
  };
1457
1505
  }
1458
1506
  /**
1459
- * What the output directory holds on disk before a run. `undefined` when `outputPath` is not a real
1507
+ * What the output directory holds when the editor connects. `undefined` when `outputPath` is not a real
1460
1508
  * subdirectory of `root` (listing the root would take in every source file) or holds more than `maxFiles`.
1461
1509
  */
1462
1510
  async function listDisk({ root, outputPath, maxFiles }) {
@@ -1473,38 +1521,11 @@ async function listDisk({ root, outputPath, maxFiles }) {
1473
1521
  paths: new Set(paths)
1474
1522
  };
1475
1523
  }
1476
- async function readDiskFiles({ files: disk, paths, cursor = 0, limit }) {
1477
- const files = {};
1478
- const pages = {};
1479
- await inParallel({
1480
- items: paths.filter((path) => disk.paths.has(path)),
1481
- limit: READ_CONCURRENCY,
1482
- run: async (path) => {
1483
- const content = await disk.storage.readItem((0, node_path.resolve)(disk.root, path));
1484
- if (content === null) return;
1485
- if (limit === void 0) {
1486
- files[path] = content;
1487
- return;
1488
- }
1489
- const lines = content.split(/\r\n|\r|\n/);
1490
- const end = Math.min(cursor + limit, lines.length);
1491
- files[path] = lines.slice(cursor, end).join("\n");
1492
- pages[path] = {
1493
- nextCursor: end < lines.length ? end : null,
1494
- totalLines: lines.length
1495
- };
1496
- }
1497
- });
1498
- return limit === void 0 ? { files } : {
1499
- files,
1500
- pages
1501
- };
1502
- }
1503
1524
  /**
1504
1525
  * Keeps recent generations by job id in `storage`, with an index next to them so a store on disk
1505
1526
  * survives a restart. A generation is only ever looked up by its job id, so on a pooled sandbox one
1506
- * tenant never reaches another's output. The oldest go past `maxCount` or `maxMb`, but the newest
1507
- * always stays.
1527
+ * tenant never reaches another's output. Initial disk output and generated jobs each get their
1528
+ * own `maxCount` and `maxMb` budget. Each budget always keeps its newest entry.
1508
1529
  */
1509
1530
  function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now }) {
1510
1531
  let index;
@@ -1523,7 +1544,7 @@ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now
1523
1544
  /**
1524
1545
  * Copies `files` into the store as one set of `jobId`. Above `maxSetMb` only the hashes are kept.
1525
1546
  */
1526
- async function keep({ jobId, source, files, maxSetMb, normalize = source === require_protocol.FileSource.OUTPUT }) {
1547
+ async function keep({ jobId, files, maxSetMb }) {
1527
1548
  const maxSetBytes = maxSetMb * MB;
1528
1549
  const hashes = {};
1529
1550
  let bytes = 0;
@@ -1534,14 +1555,13 @@ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now
1534
1555
  if (path.split("/").includes("..")) return;
1535
1556
  const content = await files.storage.readItem((0, node_path.resolve)(files.root, path));
1536
1557
  if (content === null) return;
1537
- if (normalize && !content.trim()) return;
1538
- hashes[path] = hashOf(normalize ? `${content.trim()}\n` : content);
1558
+ hashes[path] = hashOf(content);
1539
1559
  bytes += Buffer.byteLength(content);
1540
- if (bytes <= maxSetBytes) await storage.writeItem(`${dirOf(jobId)}${source}/${path}`, content);
1560
+ if (bytes <= maxSetBytes) await storage.writeItem(`${dirOf(jobId)}output/${path}`, content);
1541
1561
  }
1542
1562
  });
1543
1563
  if (bytes > maxSetBytes) {
1544
- await storage.empty(`${dirOf(jobId)}${source}/`);
1564
+ await storage.empty(`${dirOf(jobId)}output/`);
1545
1565
  return {
1546
1566
  paths: [],
1547
1567
  hashes,
@@ -1557,10 +1577,50 @@ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now
1557
1577
  async function drop(jobId) {
1558
1578
  await storage.empty(dirOf(jobId));
1559
1579
  }
1580
+ async function add(generation) {
1581
+ const current = await load();
1582
+ for (const expired of current.filter((entry) => !isLive(entry))) await drop(expired.jobId);
1583
+ const live = current.filter((entry) => entry.jobId !== generation.jobId && isLive(entry));
1584
+ const sharesBudget = (entry) => Boolean(entry.initial) === Boolean(generation.initial);
1585
+ const entries = live.filter(sharesBudget);
1586
+ entries.push({
1587
+ ...generation,
1588
+ keptAt: now()
1589
+ });
1590
+ while (entries.length > 1 && (entries.length > maxCount || totalBytes(entries) > maxMb * MB)) await drop(entries.shift().jobId);
1591
+ index = [...live.filter((entry) => !sharesBudget(entry)), ...entries];
1592
+ await storage.writeItem(INDEX_KEY, JSON.stringify(index));
1593
+ }
1560
1594
  return {
1561
1595
  keep,
1562
1596
  drop,
1563
1597
  /**
1598
+ * Captures initial disk output, reusing a retained ID when its hashes and readable paths match.
1599
+ * A temporary candidate is discarded on reuse, so reconnects do not consume retention slots.
1600
+ */
1601
+ async keepInitial(files) {
1602
+ const jobId = (0, node_crypto.randomUUID)();
1603
+ const output = await keep({
1604
+ jobId,
1605
+ files,
1606
+ maxSetMb: maxMb
1607
+ });
1608
+ const hashes = Object.entries(output.hashes);
1609
+ const previous = (await load()).findLast((entry) => entry.initial && isLive(entry) && entry.output.paths.length === output.paths.length && Object.keys(entry.output.hashes).length === hashes.length && hashes.every(([path, hash]) => entry.output.hashes[path] === hash));
1610
+ if (previous) {
1611
+ await drop(jobId);
1612
+ await add(previous);
1613
+ return previous;
1614
+ }
1615
+ const generation = {
1616
+ jobId,
1617
+ output,
1618
+ initial: true
1619
+ };
1620
+ await add(generation);
1621
+ return generation;
1622
+ },
1623
+ /**
1564
1624
  * Keeps the `InputNode` of `jobId` and returns its size in bytes, or 0 when it keeps none. A ref schema loses
1565
1625
  * the copy of its target, which `input.schemas` holds: with it, a large spec outgrows what `JSON.stringify` can return.
1566
1626
  */
@@ -1588,30 +1648,19 @@ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now
1588
1648
  return stored === null ? void 0 : JSON.parse(stored);
1589
1649
  },
1590
1650
  get: async (jobId) => (await load()).find((generation) => generation.jobId === jobId && isLive(generation)),
1591
- async add(generation) {
1592
- const current = await load();
1593
- for (const expired of current.filter((entry) => !isLive(entry))) await drop(expired.jobId);
1594
- const entries = current.filter((entry) => entry.jobId !== generation.jobId && isLive(entry));
1595
- entries.push({
1596
- ...generation,
1597
- keptAt: now()
1598
- });
1599
- while (entries.length > 1 && (entries.length > maxCount || totalBytes(entries) > maxMb * MB)) await drop(entries.shift().jobId);
1600
- index = entries;
1601
- await storage.writeItem(INDEX_KEY, JSON.stringify(entries));
1602
- },
1651
+ add,
1603
1652
  /**
1604
1653
  * Reads the requested paths the set holds, skipping any it does not, one page of lines when `limit` is given.
1605
1654
  */
1606
- async read({ generation, source, paths, cursor = 0, limit }) {
1607
- const kept = new Set(generation[source]?.paths);
1655
+ async read({ generation, paths, cursor = 0, limit }) {
1656
+ const kept = new Set(generation.output.paths);
1608
1657
  const files = {};
1609
1658
  const pages = {};
1610
1659
  await inParallel({
1611
1660
  items: paths.filter((path) => kept.has(path)),
1612
1661
  limit: READ_CONCURRENCY,
1613
1662
  run: async (path) => {
1614
- const content = await storage.readItem(`${dirOf(generation.jobId)}${source}/${path}`);
1663
+ const content = await storage.readItem(`${dirOf(generation.jobId)}output/${path}`);
1615
1664
  if (content === null) return;
1616
1665
  if (limit === void 0) {
1617
1666
  files[path] = content;
@@ -1886,6 +1935,25 @@ function isValidPage({ cursor, limit }) {
1886
1935
  if (cursor !== void 0 && (!Number.isSafeInteger(cursor) || cursor < 0)) return false;
1887
1936
  return Number.isSafeInteger(limit) && limit >= 1 && limit <= 2e3;
1888
1937
  }
1938
+ /**
1939
+ * Maps generated paths into a temporary directory while keeping the project root for input resolution.
1940
+ */
1941
+ function stagingStorage({ root, directory }) {
1942
+ const storage = (0, _kubb_core.fsStorage)();
1943
+ const target = (key) => {
1944
+ const relative = node_path.default.relative(root, node_path.default.resolve(root, key));
1945
+ if (relative === ".." || relative.startsWith(`..${node_path.default.sep}`) || node_path.default.isAbsolute(relative)) throw new Error("Output path escapes the project root");
1946
+ return node_path.default.join(directory, relative);
1947
+ };
1948
+ return {
1949
+ name: "fs",
1950
+ readItem: (key) => storage.readItem(target(key)),
1951
+ writeItem: (key, value) => storage.writeItem(target(key), value),
1952
+ removeItem: (key) => storage.removeItem(target(key)),
1953
+ readKeys: (base) => storage.readKeys(base ? target(base) : directory),
1954
+ empty: (base) => base ? storage.empty(target(base)) : Promise.resolve()
1955
+ };
1956
+ }
1889
1957
  var GenerationRunTarget = class extends capnweb.RpcTarget {
1890
1958
  #run;
1891
1959
  constructor(run) {
@@ -1910,6 +1978,7 @@ var Agent = class extends capnweb.RpcTarget {
1910
1978
  #session;
1911
1979
  #limits = resolveGenerationLimits();
1912
1980
  #store;
1981
+ #sandboxCacheRoot = node_path.default.join((0, node_os.tmpdir)(), `kubb-studio-${(0, node_crypto.randomUUID)()}`);
1913
1982
  #diffs = /* @__PURE__ */ new Map();
1914
1983
  constructor({ options, session }) {
1915
1984
  super();
@@ -1931,13 +2000,19 @@ var Agent = class extends capnweb.RpcTarget {
1931
2000
  }
1932
2001
  get #generations() {
1933
2002
  this.#store ??= createGenerationStore({
1934
- storage: this.#sandbox ? (0, _kubb_core.memoryStorage)() : (0, _kubb_core.cacheStorage)({ root: this.#options.root }),
2003
+ storage: (0, _kubb_core.cacheStorage)({ root: this.#sandbox ? this.#sandboxCacheRoot : this.#options.root }),
1935
2004
  maxCount: this.#limits.maxCount,
1936
2005
  maxMb: this.#limits.maxMb,
1937
2006
  ttlMs: this.#sandbox ? SANDBOX_GENERATION_TTL_MS : void 0
1938
2007
  });
1939
2008
  return this.#store;
1940
2009
  }
2010
+ async clear() {
2011
+ if (this.#sandbox) await (0, node_fs_promises.rm)((0, _kubb_core.resolveCacheDir)(this.#sandboxCacheRoot), {
2012
+ recursive: true,
2013
+ force: true
2014
+ });
2015
+ }
1941
2016
  #warn({ message, permission }) {
1942
2017
  return emit(this.#options.hooks, "studio:warn", {
1943
2018
  message,
@@ -1962,10 +2037,12 @@ var Agent = class extends capnweb.RpcTarget {
1962
2037
  message: "Studio must call connect() before any other command"
1963
2038
  });
1964
2039
  }
1965
- #logCommand(command, info) {
2040
+ #logCommand(command, info, requestedBy, failed) {
1966
2041
  return emit(this.#options.hooks, "studio:command", {
1967
2042
  command,
1968
- info
2043
+ info,
2044
+ requestedBy,
2045
+ failed
1969
2046
  });
1970
2047
  }
1971
2048
  async #readConfigFileView(source) {
@@ -2049,7 +2126,12 @@ var Agent = class extends capnweb.RpcTarget {
2049
2126
  maxFiles: DISK_SNAPSHOT_MAX_FILES
2050
2127
  });
2051
2128
  if (!files) return void 0;
2052
- return { files: [...files.paths] };
2129
+ const { jobId, output } = await this.#generations.keepInitial(files);
2130
+ return {
2131
+ jobId,
2132
+ files: output.paths,
2133
+ hashes: output.hashes
2134
+ };
2053
2135
  }
2054
2136
  startGeneration(input) {
2055
2137
  const stream = createGenerationStream(this.#options.hooks, input.jobId);
@@ -2068,7 +2150,9 @@ var Agent = class extends capnweb.RpcTarget {
2068
2150
  }
2069
2151
  });
2070
2152
  }
2071
- /** Runs one job and keeps its output, and its input with `allowRead`, for later reads. */
2153
+ /**
2154
+ * Keeps a job's output and permitted parsed input, then cleans up its temporary generation directories.
2155
+ */
2072
2156
  async #runGeneration(input, controller) {
2073
2157
  this.#assertConnected();
2074
2158
  if (this.#session.closed) throw new _kubb_core.Diagnostics.Error({
@@ -2084,11 +2168,14 @@ var Agent = class extends capnweb.RpcTarget {
2084
2168
  settled: settled.promise
2085
2169
  };
2086
2170
  let root = this.#options.root;
2171
+ let outputRoot;
2087
2172
  try {
2088
2173
  if (this.#sandbox) root = await (0, node_fs_promises.mkdtemp)(node_path.default.join((0, node_os.tmpdir)(), "kubb-job-"));
2174
+ if (!this.#sandbox && !this.#permissions.allowWrite) outputRoot = await (0, node_fs_promises.mkdtemp)(node_path.default.join((0, node_os.tmpdir)(), "kubb-output-"));
2089
2175
  return await this.#generate({
2090
2176
  input,
2091
2177
  root,
2178
+ outputRoot,
2092
2179
  signal: controller.signal
2093
2180
  });
2094
2181
  } finally {
@@ -2099,12 +2186,18 @@ var Agent = class extends capnweb.RpcTarget {
2099
2186
  recursive: true,
2100
2187
  force: true
2101
2188
  })]).catch(() => {});
2189
+ if (outputRoot) await (0, node_fs_promises.rm)(outputRoot, {
2190
+ recursive: true,
2191
+ force: true
2192
+ });
2102
2193
  this.#session.active = void 0;
2103
2194
  settled.resolve();
2104
2195
  }
2105
2196
  }
2106
- /** Merges Studio's config over the disk config, refuses output paths outside the output folder, then generates in `root`. */
2107
- async #generate({ input, root, signal }) {
2197
+ /**
2198
+ * Merges Studio's config over the project config and confines generation to the configured output folder.
2199
+ */
2200
+ async #generate({ input, root, outputRoot, signal }) {
2108
2201
  const { jobId, config: patch } = input;
2109
2202
  const studioInput = this.#sandbox ? patch?.input : void 0;
2110
2203
  if (this.#sandbox && (typeof studioInput !== "string" || !studioInput.trim() || (0, _kubb_core.getInputKind)(studioInput) !== "inline")) return this.#refuse({ message: require_protocol.AgentMessage.SANDBOX_INPUT_ONLY });
@@ -2135,37 +2228,29 @@ var Agent = class extends capnweb.RpcTarget {
2135
2228
  });
2136
2229
  const activePlugins = plugins ?? config.plugins;
2137
2230
  if (patch?.input && !this.#sandbox) await emit(this.#options.hooks, "kubb:warn", { message: "Ignored the spec from Studio: generating from a Studio spec is only available to a sandbox agent" });
2138
- const diskFiles = !this.#sandbox && this.#permissions.allowRead ? await listDisk({
2139
- root,
2140
- outputPath: config.output.path,
2141
- maxFiles: DISK_SNAPSHOT_MAX_FILES
2142
- }) : void 0;
2143
- const disk = diskFiles ? await this.#generations.keep({
2144
- jobId,
2145
- source: require_protocol.FileSource.DISK,
2146
- files: diskFiles,
2147
- maxSetMb: this.#limits.maxSnapshotMb
2148
- }) : void 0;
2149
2231
  const files = await generate({
2150
2232
  config: {
2151
2233
  ...config,
2152
2234
  root,
2153
2235
  input: studioInput ?? config.input,
2154
- storage: this.#permissions.allowWrite ? (0, _kubb_core.fsStorage)() : (0, _kubb_core.memoryStorage)(),
2236
+ storage: outputRoot ? stagingStorage({
2237
+ root,
2238
+ directory: outputRoot
2239
+ }) : (0, _kubb_core.fsStorage)(),
2155
2240
  plugins: activePlugins,
2156
2241
  adapter,
2157
2242
  parsers
2158
2243
  },
2159
2244
  hooks: this.#options.hooks,
2160
2245
  signal,
2161
- allowExec: this.#permissions.allowExec
2246
+ allowExec: this.#permissions.allowWrite && this.#permissions.allowExec
2162
2247
  }).catch(async (error) => {
2163
2248
  await this.#generations.drop(jobId);
2249
+ await this.#logCommand("generate", getErrorMessage(error), input.requestedBy, true);
2164
2250
  throw error;
2165
2251
  });
2166
2252
  const output = await this.#generations.keep({
2167
2253
  jobId,
2168
- source: require_protocol.FileSource.OUTPUT,
2169
2254
  files,
2170
2255
  maxSetMb: this.#limits.maxMb
2171
2256
  });
@@ -2178,16 +2263,14 @@ var Agent = class extends capnweb.RpcTarget {
2178
2263
  await this.#generations.add({
2179
2264
  jobId,
2180
2265
  output,
2181
- disk,
2182
2266
  inputBytes
2183
2267
  });
2184
- await this.#logCommand("generate", `${activePlugins.length} plugin${activePlugins.length === 1 ? "" : "s"}, ${this.#permissions.allowWrite ? "written to disk" : "in memory"}${studioInput !== void 0 ? ", from a Studio spec" : ""}`);
2268
+ await this.#logCommand("generate", `${activePlugins.length} plugin${activePlugins.length === 1 ? "" : "s"}, ${this.#permissions.allowWrite ? "written to disk" : "staged on disk"}${studioInput !== void 0 ? ", from a Studio spec" : ""}`, input.requestedBy);
2185
2269
  return {
2186
2270
  status: "success",
2187
2271
  files: [...files.paths],
2188
2272
  fileCount: files.paths.size,
2189
- hashes: output.hashes,
2190
- disk: disk ? { hashes: disk.hashes } : void 0
2273
+ hashes: output.hashes
2191
2274
  };
2192
2275
  }
2193
2276
  async saveConfig(input) {
@@ -2221,7 +2304,7 @@ var Agent = class extends capnweb.RpcTarget {
2221
2304
  extensions: await this.#installedExtensions()
2222
2305
  });
2223
2306
  const applied = outcomes.filter((outcome) => outcome.applied).length;
2224
- await this.#logCommand("saveConfig", `applied ${applied}/${outcomes.length} edits to ${configPath}`);
2307
+ await this.#logCommand("saveConfig", `applied ${applied}/${outcomes.length} edits to ${configPath}`, input.requestedBy);
2225
2308
  return {
2226
2309
  outcomes,
2227
2310
  changed
@@ -2257,35 +2340,14 @@ var Agent = class extends capnweb.RpcTarget {
2257
2340
  message: require_protocol.AgentMessage.READ_NOT_ALLOWED,
2258
2341
  permission: require_protocol.AgentPermission.READ
2259
2342
  });
2260
- if (!Array.isArray(input.paths) || typeof input.jobId !== "string" || !input.jobId || !["generation", "currentDisk"].includes(input.jobType)) return this.#refuse({ message: require_protocol.AgentMessage.FILE_REQUEST_INVALID });
2343
+ if (!Array.isArray(input.paths) || typeof input.jobId !== "string" || !input.jobId) return this.#refuse({ message: require_protocol.AgentMessage.FILE_REQUEST_INVALID });
2261
2344
  const { paths } = input;
2262
2345
  if (paths.length > 50) return this.#refuse({ message: require_protocol.AgentMessage.TOO_MANY_FILES });
2263
2346
  if (!isValidPage(input)) return this.#refuse({ message: require_protocol.AgentMessage.INVALID_PAGE });
2264
- if (input.jobType === "currentDisk") {
2265
- if (this.#sandbox) return this.#refuse({ message: require_protocol.AgentMessage.SANDBOX_DISK_UNAVAILABLE });
2266
- const config = await this.#options.loadConfig();
2267
- const disk = await listDisk({
2268
- root: this.#options.root,
2269
- outputPath: config.output.path,
2270
- maxFiles: DISK_SNAPSHOT_MAX_FILES
2271
- });
2272
- if (!disk) return this.#refuse({ message: require_protocol.AgentMessage.GENERATION_GONE });
2273
- const result = await readDiskFiles({
2274
- files: disk,
2275
- paths,
2276
- cursor: input.cursor,
2277
- limit: input.limit
2278
- });
2279
- await this.#logCommand("readFiles", `read ${Object.keys(result.files).length}/${paths.length} requested file${paths.length === 1 ? "" : "s"}`);
2280
- return result;
2281
- }
2282
2347
  const generation = await this.#generations.get(input.jobId);
2283
2348
  if (!generation) return this.#refuse({ message: require_protocol.AgentMessage.GENERATION_GONE });
2284
- const source = input.source === require_protocol.FileSource.DISK ? require_protocol.FileSource.DISK : require_protocol.FileSource.OUTPUT;
2285
- if (!generation[source]) return this.#refuse({ message: require_protocol.AgentMessage.NO_DISK_SNAPSHOT });
2286
2349
  const result = await this.#generations.read({
2287
2350
  generation,
2288
- source,
2289
2351
  paths,
2290
2352
  cursor: input.cursor,
2291
2353
  limit: input.limit
@@ -2310,15 +2372,11 @@ var Agent = class extends capnweb.RpcTarget {
2310
2372
  })) return this.#refuse({ message: require_protocol.AgentMessage.INVALID_PAGE });
2311
2373
  const [generation, baseGeneration] = await Promise.all([this.#generations.get(jobId), this.#generations.get(base.jobId)]);
2312
2374
  if (!generation || !baseGeneration) return this.#refuse({ message: require_protocol.AgentMessage.GENERATION_GONE });
2313
- const baseSource = base.source === require_protocol.FileSource.DISK ? require_protocol.FileSource.DISK : require_protocol.FileSource.OUTPUT;
2314
- if (!baseGeneration[baseSource]) return this.#refuse({ message: require_protocol.AgentMessage.NO_DISK_SNAPSHOT });
2315
2375
  const [after, before] = await Promise.all([this.#generations.read({
2316
2376
  generation,
2317
- source: require_protocol.FileSource.OUTPUT,
2318
2377
  paths: [path]
2319
2378
  }), this.#generations.read({
2320
2379
  generation: baseGeneration,
2321
- source: baseSource,
2322
2380
  paths: [path]
2323
2381
  })]);
2324
2382
  const afterContent = after.files[path];
@@ -2339,7 +2397,6 @@ var Agent = class extends capnweb.RpcTarget {
2339
2397
  const key = JSON.stringify([
2340
2398
  jobId,
2341
2399
  base.jobId,
2342
- baseSource,
2343
2400
  path
2344
2401
  ]);
2345
2402
  let diff = this.#diffs.get(key);
@@ -2396,14 +2453,15 @@ async function runSession({ options, openRpc, url, instanceId }) {
2396
2453
  connected: Promise.withResolvers(),
2397
2454
  closed: false
2398
2455
  };
2456
+ const agent = new Agent({
2457
+ options,
2458
+ session
2459
+ });
2399
2460
  const rpc = openRpc({
2400
2461
  url,
2401
2462
  token,
2402
2463
  instanceId,
2403
- agent: new Agent({
2404
- options,
2405
- session
2406
- })
2464
+ agent
2407
2465
  });
2408
2466
  signal?.addEventListener("abort", rpc.close, { once: true });
2409
2467
  let timer;
@@ -2438,6 +2496,7 @@ async function runSession({ options, openRpc, url, instanceId }) {
2438
2496
  await emit(hooks, "studio:warn", { message: "Canceled the running generation: the connection to Kubb Studio closed" });
2439
2497
  }
2440
2498
  await active?.settled;
2499
+ await agent.clear();
2441
2500
  return {
2442
2501
  ...close,
2443
2502
  connected: session.info !== void 0
@@ -2747,8 +2806,7 @@ async function createSnapshotPackage({ files, packageInfo }) {
2747
2806
  try {
2748
2807
  await (0, node_fs_promises.mkdir)(dist);
2749
2808
  await Promise.all([...sources].map(async ([path, { content }]) => {
2750
- await (0, node_fs_promises.mkdir)((0, node_path.dirname)((0, node_path.join)(root, path)), { recursive: true });
2751
- await (0, node_fs_promises.writeFile)((0, node_path.join)(root, path), content);
2809
+ await writeRaw((0, node_path.join)(root, path), content);
2752
2810
  }));
2753
2811
  const entry = [...sources.keys()].filter((path) => /\.(?:[cm]?[jt]sx?)$/.test(path)).map((path) => (0, node_path.join)(root, path));
2754
2812
  if (entry.length) {
@@ -2788,7 +2846,7 @@ async function createSnapshotPackage({ files, packageInfo }) {
2788
2846
  const chunks = [...new Map([
2789
2847
  ["package.json", JSON.stringify(manifest, null, 2)],
2790
2848
  ...[...sources].map(([path, { content }]) => [path, content]),
2791
- ...await Promise.all(built.map(async (key) => [`dist/${key}`, await (0, node_fs_promises.readFile)((0, node_path.join)(dist, key), "utf8")]))
2849
+ ...await Promise.all(built.map(async (key) => [`dist/${key}`, await read((0, node_path.join)(dist, key))]))
2792
2850
  ])].flatMap(([path, content]) => {
2793
2851
  const bytes = Buffer.from(content);
2794
2852
  return [
@@ -2819,7 +2877,10 @@ async function createSnapshot({ studioUrl = defaultStudioUrl, apiKey, config, ho
2819
2877
  path: "/api/snapshots"
2820
2878
  });
2821
2879
  const generated = await generate({
2822
- config,
2880
+ config: {
2881
+ ...config,
2882
+ storage: (0, _kubb_core.fsStorage)()
2883
+ },
2823
2884
  hooks,
2824
2885
  signal,
2825
2886
  allowExec: true
@@ -2903,6 +2964,7 @@ exports.InvalidAgentTokenError = InvalidAgentTokenError;
2903
2964
  exports.PairingCanceledError = PairingCanceledError;
2904
2965
  exports.PairingDeniedError = PairingDeniedError;
2905
2966
  exports.PairingExpiredError = PairingExpiredError;
2967
+ exports.applyConfigEdits = applyConfigEdits;
2906
2968
  exports.connectAgent = connectAgent;
2907
2969
  exports.createSnapshot = createSnapshot;
2908
2970
  exports.defaultStudioUrl = defaultStudioUrl;