@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.js CHANGED
@@ -1,14 +1,14 @@
1
- import { AGENT_INSTANCE_HEADER, AGENT_SOCKET_PATH, AgentCloseCode, AgentMessage, AgentPermission, FileSource, MAX_SNAPSHOT_BYTES, generationEventTypes } from "./protocol.js";
1
+ import { AGENT_INSTANCE_HEADER, AGENT_SOCKET_PATH, AgentCloseCode, AgentMessage, AgentPermission, MAX_SNAPSHOT_BYTES, generationEventTypes } from "./protocol.js";
2
2
  import { createRequire } from "node:module";
3
3
  import { createHash, hash, randomUUID } from "node:crypto";
4
- import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
5
4
  import { tmpdir } from "node:os";
6
5
  import path, { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
6
  import process$1 from "node:process";
8
7
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
9
8
  import { pathToFileURL } from "node:url";
10
9
  import { existsSync } from "node:fs";
11
- import { Diagnostics, Hookable, cacheStorage, createKubb, fsStorage, getInputKind, isPackageName, memoryStorage, readInstalledPackages, resolveCacheDir, resolveMeta } from "@kubb/core";
10
+ import { mkdir, mkdir as mkdir$1, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
11
+ import { Diagnostics, Hookable, cacheStorage, createKubb, fsStorage, getInputKind, isPackageName, readInstalledPackages, resolveCacheDir, resolveMeta } from "@kubb/core";
12
12
  import { RpcTarget, newWebSocketRpcSession } from "capnweb";
13
13
  import { detectCodeFormat, parseExpression, parseModule } from "magicast";
14
14
  import { structuredPatch } from "diff";
@@ -117,6 +117,27 @@ async function read(path) {
117
117
  return readFile(path, { encoding: "utf8" });
118
118
  }
119
119
  /**
120
+ * Writes UTF-8 content verbatim, including empty files and surrounding whitespace.
121
+ * Uses Bun.write under Bun and creates missing parent directories under either runtime.
122
+ *
123
+ * @example
124
+ * await writeRaw('./cache/output.ts', ' // keep these bytes\n\n')
125
+ */
126
+ async function writeRaw(path, content) {
127
+ const resolved = resolve(path);
128
+ if (runtime.isBun) {
129
+ await Bun.write(resolved, content);
130
+ return;
131
+ }
132
+ try {
133
+ await writeFile(resolved, content, { encoding: "utf-8" });
134
+ } catch (error) {
135
+ if (error.code !== "ENOENT") throw error;
136
+ await mkdir(dirname(resolved), { recursive: true });
137
+ await writeFile(resolved, content, { encoding: "utf-8" });
138
+ }
139
+ }
140
+ /**
120
141
  * Resolves to `true` when `path` is `parent` itself or nested inside it. Both sides are resolved
121
142
  * to absolute paths first, so relative and `..`-containing inputs compare correctly.
122
143
  *
@@ -175,7 +196,7 @@ function getElapsedMs(hrStart) {
175
196
  }
176
197
  //#endregion
177
198
  //#region package.json
178
- var version = "5.11.3";
199
+ var version = "5.11.5";
179
200
  //#endregion
180
201
  //#region src/resolveConfig.ts
181
202
  /**
@@ -1037,13 +1058,17 @@ function applySet(source, call, path, value, quote) {
1037
1058
  }
1038
1059
  return { source: splice(source, rangeOf(target.value).start, rangeOf(target.value).end, printValue(value, quote)) };
1039
1060
  }
1040
- /** Deletes the option at `path`, falling the plugin back to its default for it. */
1061
+ /**
1062
+ * Deletes the option at `path`, falling the plugin back to its default for it. An option the file
1063
+ * does not set already has its default, so removing it changes nothing.
1064
+ */
1041
1065
  function applyRemove(source, call, path) {
1042
1066
  const options = call.arguments[0];
1043
- if (options?.type !== "ObjectExpression") return { reason: "the factory call has no options to remove" };
1067
+ if (!options) return { source };
1068
+ if (options.type !== "ObjectExpression") return { reason: "the factory was not called with an object literal" };
1044
1069
  const target = walk(options, path);
1045
1070
  if ("reason" in target) return target;
1046
- if ("missing" in target) return { reason: `${path.join(".")} is not set` };
1071
+ if ("missing" in target) return { source };
1047
1072
  return { source: removeMember(source, target.object, target.index) };
1048
1073
  }
1049
1074
  /**
@@ -1083,6 +1108,22 @@ function applyAddPlugin({ source, parsed, config, edit, quote, extensions }) {
1083
1108
  const next = insertItem(source, plugins, `${importName}(${Object.keys(options).length ? printValue(options, quote) : ""})`);
1084
1109
  return { source: taken ? next : insertImport(next, parsed, importName, edit.plugin) };
1085
1110
  }
1111
+ /**
1112
+ * Rewrites the config's `plugins` array with one item per line, so a call can be commented out without
1113
+ * taking its neighbors with it. Not done when a comment sits between the items, which would have to move.
1114
+ */
1115
+ function expandPluginsArray(source, config) {
1116
+ const array = property(config, "plugins");
1117
+ if (array?.type !== "ArrayExpression") return void 0;
1118
+ const items = array.elements.flatMap((element) => element ? [element] : []);
1119
+ const { start, end } = rangeOf(array);
1120
+ const ranges = items.map(rangeOf);
1121
+ 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))];
1122
+ if (!items.length || gaps.some((gap) => !/^[\s,]*$/.test(gap))) return void 0;
1123
+ const lineStart = source.lastIndexOf("\n", start - 1) + 1;
1124
+ const indent = source.slice(lineStart).match(/^[ \t]*/)?.[0] ?? "";
1125
+ return splice(source, start, end, `[\n${ranges.map((range) => `${indent} ${source.slice(range.start, range.end)},`).join("\n")}\n${indent}]`);
1126
+ }
1086
1127
  /** Comments out a plugin call in place, keeping its options on disk so `enable-plugin` can restore them exactly. */
1087
1128
  function disablePlugin(source, mod, config, plugin) {
1088
1129
  const loc = factoryCalls(mod, config).find((entry) => entry.packageName === plugin)?.call.loc;
@@ -1110,9 +1151,17 @@ function applyEdit({ source, edit, quote, extensions }) {
1110
1151
  const parsed = parseConfigs(source);
1111
1152
  if ("reason" in parsed) return parsed;
1112
1153
  const ref = edit.config;
1113
- const config = typeof ref === "string" ? parsed.configs.find((entry) => configName(entry) === ref) : parsed.configs[ref ?? 0];
1154
+ const findConfig = (entries) => typeof ref === "string" ? entries.configs.find((entry) => configName(entry) === ref) : entries.configs[ref ?? 0];
1155
+ const config = findConfig(parsed);
1114
1156
  if (!config) return { reason: `no config entry found for ${JSON.stringify(ref)}` };
1115
- if (edit.operation === "disable-plugin") return disablePlugin(source, parsed.mod, config, edit.plugin);
1157
+ if (edit.operation === "disable-plugin") {
1158
+ const result = disablePlugin(source, parsed.mod, config, edit.plugin);
1159
+ if (!("reason" in result) || !result.reason.includes("shares a line")) return result;
1160
+ const expanded = expandPluginsArray(source, config);
1161
+ const reparsed = expanded === void 0 ? void 0 : parseConfigs(expanded);
1162
+ const reparsedConfig = reparsed && !("reason" in reparsed) ? findConfig(reparsed) : void 0;
1163
+ return expanded !== void 0 && reparsed && !("reason" in reparsed) && reparsedConfig ? disablePlugin(expanded, reparsed.mod, reparsedConfig, edit.plugin) : result;
1164
+ }
1116
1165
  if (edit.operation === "enable-plugin") return enablePlugin(source, config, edit.plugin);
1117
1166
  if (edit.operation === "add-plugin") return applyAddPlugin({
1118
1167
  source,
@@ -1371,21 +1420,20 @@ const hashOf = (content) => createHash("sha1").update(content).digest("hex").sli
1371
1420
  /**
1372
1421
  * The bytes `entries` keep on disk: output, written files, and kept input.
1373
1422
  */
1374
- const totalBytes = (entries) => entries.reduce((sum, { output, disk, inputBytes }) => sum + output.bytes + (disk?.bytes ?? 0) + (inputBytes ?? 0), 0);
1423
+ const totalBytes = (entries) => entries.reduce((sum, { output, inputBytes }) => sum + output.bytes + (inputBytes ?? 0), 0);
1375
1424
  function positiveNumber(value) {
1376
1425
  const parsed = Number(value);
1377
1426
  return value && Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
1378
1427
  }
1379
1428
  /**
1380
1429
  * How many generations an agent keeps and how large they may get, read from
1381
- * `KUBB_AGENT_MAX_GENERATIONS`, `KUBB_AGENT_MAX_GENERATIONS_MB` and `KUBB_AGENT_MAX_SNAPSHOT_MB`.
1430
+ * `KUBB_AGENT_MAX_GENERATIONS` and `KUBB_AGENT_MAX_GENERATIONS_MB`.
1382
1431
  * An unset or invalid value keeps the default.
1383
1432
  */
1384
1433
  function resolveGenerationLimits() {
1385
1434
  return {
1386
1435
  maxCount: Math.max(1, Math.floor(positiveNumber(process$1.env.KUBB_AGENT_MAX_GENERATIONS) ?? 8)),
1387
- maxMb: positiveNumber(process$1.env.KUBB_AGENT_MAX_GENERATIONS_MB) ?? 100,
1388
- maxSnapshotMb: positiveNumber(process$1.env.KUBB_AGENT_MAX_SNAPSHOT_MB) ?? 50
1436
+ maxMb: positiveNumber(process$1.env.KUBB_AGENT_MAX_GENERATIONS_MB) ?? 100
1389
1437
  };
1390
1438
  }
1391
1439
  /**
@@ -1432,7 +1480,7 @@ async function generate({ config, hooks, signal, allowExec }) {
1432
1480
  };
1433
1481
  }
1434
1482
  /**
1435
- * What the output directory holds on disk before a run. `undefined` when `outputPath` is not a real
1483
+ * What the output directory holds when the editor connects. `undefined` when `outputPath` is not a real
1436
1484
  * subdirectory of `root` (listing the root would take in every source file) or holds more than `maxFiles`.
1437
1485
  */
1438
1486
  async function listDisk({ root, outputPath, maxFiles }) {
@@ -1449,38 +1497,11 @@ async function listDisk({ root, outputPath, maxFiles }) {
1449
1497
  paths: new Set(paths)
1450
1498
  };
1451
1499
  }
1452
- async function readDiskFiles({ files: disk, paths, cursor = 0, limit }) {
1453
- const files = {};
1454
- const pages = {};
1455
- await inParallel({
1456
- items: paths.filter((path) => disk.paths.has(path)),
1457
- limit: READ_CONCURRENCY,
1458
- run: async (path) => {
1459
- const content = await disk.storage.readItem(resolve(disk.root, path));
1460
- if (content === null) return;
1461
- if (limit === void 0) {
1462
- files[path] = content;
1463
- return;
1464
- }
1465
- const lines = content.split(/\r\n|\r|\n/);
1466
- const end = Math.min(cursor + limit, lines.length);
1467
- files[path] = lines.slice(cursor, end).join("\n");
1468
- pages[path] = {
1469
- nextCursor: end < lines.length ? end : null,
1470
- totalLines: lines.length
1471
- };
1472
- }
1473
- });
1474
- return limit === void 0 ? { files } : {
1475
- files,
1476
- pages
1477
- };
1478
- }
1479
1500
  /**
1480
1501
  * Keeps recent generations by job id in `storage`, with an index next to them so a store on disk
1481
1502
  * survives a restart. A generation is only ever looked up by its job id, so on a pooled sandbox one
1482
- * tenant never reaches another's output. The oldest go past `maxCount` or `maxMb`, but the newest
1483
- * always stays.
1503
+ * tenant never reaches another's output. Initial disk output and generated jobs each get their
1504
+ * own `maxCount` and `maxMb` budget. Each budget always keeps its newest entry.
1484
1505
  */
1485
1506
  function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now }) {
1486
1507
  let index;
@@ -1499,7 +1520,7 @@ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now
1499
1520
  /**
1500
1521
  * Copies `files` into the store as one set of `jobId`. Above `maxSetMb` only the hashes are kept.
1501
1522
  */
1502
- async function keep({ jobId, source, files, maxSetMb, normalize = source === FileSource.OUTPUT }) {
1523
+ async function keep({ jobId, files, maxSetMb }) {
1503
1524
  const maxSetBytes = maxSetMb * MB;
1504
1525
  const hashes = {};
1505
1526
  let bytes = 0;
@@ -1510,14 +1531,13 @@ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now
1510
1531
  if (path.split("/").includes("..")) return;
1511
1532
  const content = await files.storage.readItem(resolve(files.root, path));
1512
1533
  if (content === null) return;
1513
- if (normalize && !content.trim()) return;
1514
- hashes[path] = hashOf(normalize ? `${content.trim()}\n` : content);
1534
+ hashes[path] = hashOf(content);
1515
1535
  bytes += Buffer.byteLength(content);
1516
- if (bytes <= maxSetBytes) await storage.writeItem(`${dirOf(jobId)}${source}/${path}`, content);
1536
+ if (bytes <= maxSetBytes) await storage.writeItem(`${dirOf(jobId)}output/${path}`, content);
1517
1537
  }
1518
1538
  });
1519
1539
  if (bytes > maxSetBytes) {
1520
- await storage.empty(`${dirOf(jobId)}${source}/`);
1540
+ await storage.empty(`${dirOf(jobId)}output/`);
1521
1541
  return {
1522
1542
  paths: [],
1523
1543
  hashes,
@@ -1533,10 +1553,50 @@ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now
1533
1553
  async function drop(jobId) {
1534
1554
  await storage.empty(dirOf(jobId));
1535
1555
  }
1556
+ async function add(generation) {
1557
+ const current = await load();
1558
+ for (const expired of current.filter((entry) => !isLive(entry))) await drop(expired.jobId);
1559
+ const live = current.filter((entry) => entry.jobId !== generation.jobId && isLive(entry));
1560
+ const sharesBudget = (entry) => Boolean(entry.initial) === Boolean(generation.initial);
1561
+ const entries = live.filter(sharesBudget);
1562
+ entries.push({
1563
+ ...generation,
1564
+ keptAt: now()
1565
+ });
1566
+ while (entries.length > 1 && (entries.length > maxCount || totalBytes(entries) > maxMb * MB)) await drop(entries.shift().jobId);
1567
+ index = [...live.filter((entry) => !sharesBudget(entry)), ...entries];
1568
+ await storage.writeItem(INDEX_KEY, JSON.stringify(index));
1569
+ }
1536
1570
  return {
1537
1571
  keep,
1538
1572
  drop,
1539
1573
  /**
1574
+ * Captures initial disk output, reusing a retained ID when its hashes and readable paths match.
1575
+ * A temporary candidate is discarded on reuse, so reconnects do not consume retention slots.
1576
+ */
1577
+ async keepInitial(files) {
1578
+ const jobId = randomUUID();
1579
+ const output = await keep({
1580
+ jobId,
1581
+ files,
1582
+ maxSetMb: maxMb
1583
+ });
1584
+ const hashes = Object.entries(output.hashes);
1585
+ 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));
1586
+ if (previous) {
1587
+ await drop(jobId);
1588
+ await add(previous);
1589
+ return previous;
1590
+ }
1591
+ const generation = {
1592
+ jobId,
1593
+ output,
1594
+ initial: true
1595
+ };
1596
+ await add(generation);
1597
+ return generation;
1598
+ },
1599
+ /**
1540
1600
  * Keeps the `InputNode` of `jobId` and returns its size in bytes, or 0 when it keeps none. A ref schema loses
1541
1601
  * the copy of its target, which `input.schemas` holds: with it, a large spec outgrows what `JSON.stringify` can return.
1542
1602
  */
@@ -1564,30 +1624,19 @@ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now
1564
1624
  return stored === null ? void 0 : JSON.parse(stored);
1565
1625
  },
1566
1626
  get: async (jobId) => (await load()).find((generation) => generation.jobId === jobId && isLive(generation)),
1567
- async add(generation) {
1568
- const current = await load();
1569
- for (const expired of current.filter((entry) => !isLive(entry))) await drop(expired.jobId);
1570
- const entries = current.filter((entry) => entry.jobId !== generation.jobId && isLive(entry));
1571
- entries.push({
1572
- ...generation,
1573
- keptAt: now()
1574
- });
1575
- while (entries.length > 1 && (entries.length > maxCount || totalBytes(entries) > maxMb * MB)) await drop(entries.shift().jobId);
1576
- index = entries;
1577
- await storage.writeItem(INDEX_KEY, JSON.stringify(entries));
1578
- },
1627
+ add,
1579
1628
  /**
1580
1629
  * Reads the requested paths the set holds, skipping any it does not, one page of lines when `limit` is given.
1581
1630
  */
1582
- async read({ generation, source, paths, cursor = 0, limit }) {
1583
- const kept = new Set(generation[source]?.paths);
1631
+ async read({ generation, paths, cursor = 0, limit }) {
1632
+ const kept = new Set(generation.output.paths);
1584
1633
  const files = {};
1585
1634
  const pages = {};
1586
1635
  await inParallel({
1587
1636
  items: paths.filter((path) => kept.has(path)),
1588
1637
  limit: READ_CONCURRENCY,
1589
1638
  run: async (path) => {
1590
- const content = await storage.readItem(`${dirOf(generation.jobId)}${source}/${path}`);
1639
+ const content = await storage.readItem(`${dirOf(generation.jobId)}output/${path}`);
1591
1640
  if (content === null) return;
1592
1641
  if (limit === void 0) {
1593
1642
  files[path] = content;
@@ -1862,6 +1911,25 @@ function isValidPage({ cursor, limit }) {
1862
1911
  if (cursor !== void 0 && (!Number.isSafeInteger(cursor) || cursor < 0)) return false;
1863
1912
  return Number.isSafeInteger(limit) && limit >= 1 && limit <= 2e3;
1864
1913
  }
1914
+ /**
1915
+ * Maps generated paths into a temporary directory while keeping the project root for input resolution.
1916
+ */
1917
+ function stagingStorage({ root, directory }) {
1918
+ const storage = fsStorage();
1919
+ const target = (key) => {
1920
+ const relative = path.relative(root, path.resolve(root, key));
1921
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw new Error("Output path escapes the project root");
1922
+ return path.join(directory, relative);
1923
+ };
1924
+ return {
1925
+ name: "fs",
1926
+ readItem: (key) => storage.readItem(target(key)),
1927
+ writeItem: (key, value) => storage.writeItem(target(key), value),
1928
+ removeItem: (key) => storage.removeItem(target(key)),
1929
+ readKeys: (base) => storage.readKeys(base ? target(base) : directory),
1930
+ empty: (base) => base ? storage.empty(target(base)) : Promise.resolve()
1931
+ };
1932
+ }
1865
1933
  var GenerationRunTarget = class extends RpcTarget {
1866
1934
  #run;
1867
1935
  constructor(run) {
@@ -1886,6 +1954,7 @@ var Agent = class extends RpcTarget {
1886
1954
  #session;
1887
1955
  #limits = resolveGenerationLimits();
1888
1956
  #store;
1957
+ #sandboxCacheRoot = path.join(tmpdir(), `kubb-studio-${randomUUID()}`);
1889
1958
  #diffs = /* @__PURE__ */ new Map();
1890
1959
  constructor({ options, session }) {
1891
1960
  super();
@@ -1907,13 +1976,19 @@ var Agent = class extends RpcTarget {
1907
1976
  }
1908
1977
  get #generations() {
1909
1978
  this.#store ??= createGenerationStore({
1910
- storage: this.#sandbox ? memoryStorage() : cacheStorage({ root: this.#options.root }),
1979
+ storage: cacheStorage({ root: this.#sandbox ? this.#sandboxCacheRoot : this.#options.root }),
1911
1980
  maxCount: this.#limits.maxCount,
1912
1981
  maxMb: this.#limits.maxMb,
1913
1982
  ttlMs: this.#sandbox ? SANDBOX_GENERATION_TTL_MS : void 0
1914
1983
  });
1915
1984
  return this.#store;
1916
1985
  }
1986
+ async clear() {
1987
+ if (this.#sandbox) await rm(resolveCacheDir(this.#sandboxCacheRoot), {
1988
+ recursive: true,
1989
+ force: true
1990
+ });
1991
+ }
1917
1992
  #warn({ message, permission }) {
1918
1993
  return emit(this.#options.hooks, "studio:warn", {
1919
1994
  message,
@@ -1938,10 +2013,12 @@ var Agent = class extends RpcTarget {
1938
2013
  message: "Studio must call connect() before any other command"
1939
2014
  });
1940
2015
  }
1941
- #logCommand(command, info) {
2016
+ #logCommand(command, info, requestedBy, failed) {
1942
2017
  return emit(this.#options.hooks, "studio:command", {
1943
2018
  command,
1944
- info
2019
+ info,
2020
+ requestedBy,
2021
+ failed
1945
2022
  });
1946
2023
  }
1947
2024
  async #readConfigFileView(source) {
@@ -2025,7 +2102,12 @@ var Agent = class extends RpcTarget {
2025
2102
  maxFiles: DISK_SNAPSHOT_MAX_FILES
2026
2103
  });
2027
2104
  if (!files) return void 0;
2028
- return { files: [...files.paths] };
2105
+ const { jobId, output } = await this.#generations.keepInitial(files);
2106
+ return {
2107
+ jobId,
2108
+ files: output.paths,
2109
+ hashes: output.hashes
2110
+ };
2029
2111
  }
2030
2112
  startGeneration(input) {
2031
2113
  const stream = createGenerationStream(this.#options.hooks, input.jobId);
@@ -2044,7 +2126,9 @@ var Agent = class extends RpcTarget {
2044
2126
  }
2045
2127
  });
2046
2128
  }
2047
- /** Runs one job and keeps its output, and its input with `allowRead`, for later reads. */
2129
+ /**
2130
+ * Keeps a job's output and permitted parsed input, then cleans up its temporary generation directories.
2131
+ */
2048
2132
  async #runGeneration(input, controller) {
2049
2133
  this.#assertConnected();
2050
2134
  if (this.#session.closed) throw new Diagnostics.Error({
@@ -2060,11 +2144,14 @@ var Agent = class extends RpcTarget {
2060
2144
  settled: settled.promise
2061
2145
  };
2062
2146
  let root = this.#options.root;
2147
+ let outputRoot;
2063
2148
  try {
2064
2149
  if (this.#sandbox) root = await mkdtemp(path.join(tmpdir(), "kubb-job-"));
2150
+ if (!this.#sandbox && !this.#permissions.allowWrite) outputRoot = await mkdtemp(path.join(tmpdir(), "kubb-output-"));
2065
2151
  return await this.#generate({
2066
2152
  input,
2067
2153
  root,
2154
+ outputRoot,
2068
2155
  signal: controller.signal
2069
2156
  });
2070
2157
  } finally {
@@ -2075,12 +2162,18 @@ var Agent = class extends RpcTarget {
2075
2162
  recursive: true,
2076
2163
  force: true
2077
2164
  })]).catch(() => {});
2165
+ if (outputRoot) await rm(outputRoot, {
2166
+ recursive: true,
2167
+ force: true
2168
+ });
2078
2169
  this.#session.active = void 0;
2079
2170
  settled.resolve();
2080
2171
  }
2081
2172
  }
2082
- /** Merges Studio's config over the disk config, refuses output paths outside the output folder, then generates in `root`. */
2083
- async #generate({ input, root, signal }) {
2173
+ /**
2174
+ * Merges Studio's config over the project config and confines generation to the configured output folder.
2175
+ */
2176
+ async #generate({ input, root, outputRoot, signal }) {
2084
2177
  const { jobId, config: patch } = input;
2085
2178
  const studioInput = this.#sandbox ? patch?.input : void 0;
2086
2179
  if (this.#sandbox && (typeof studioInput !== "string" || !studioInput.trim() || getInputKind(studioInput) !== "inline")) return this.#refuse({ message: AgentMessage.SANDBOX_INPUT_ONLY });
@@ -2111,37 +2204,29 @@ var Agent = class extends RpcTarget {
2111
2204
  });
2112
2205
  const activePlugins = plugins ?? config.plugins;
2113
2206
  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" });
2114
- const diskFiles = !this.#sandbox && this.#permissions.allowRead ? await listDisk({
2115
- root,
2116
- outputPath: config.output.path,
2117
- maxFiles: DISK_SNAPSHOT_MAX_FILES
2118
- }) : void 0;
2119
- const disk = diskFiles ? await this.#generations.keep({
2120
- jobId,
2121
- source: FileSource.DISK,
2122
- files: diskFiles,
2123
- maxSetMb: this.#limits.maxSnapshotMb
2124
- }) : void 0;
2125
2207
  const files = await generate({
2126
2208
  config: {
2127
2209
  ...config,
2128
2210
  root,
2129
2211
  input: studioInput ?? config.input,
2130
- storage: this.#permissions.allowWrite ? fsStorage() : memoryStorage(),
2212
+ storage: outputRoot ? stagingStorage({
2213
+ root,
2214
+ directory: outputRoot
2215
+ }) : fsStorage(),
2131
2216
  plugins: activePlugins,
2132
2217
  adapter,
2133
2218
  parsers
2134
2219
  },
2135
2220
  hooks: this.#options.hooks,
2136
2221
  signal,
2137
- allowExec: this.#permissions.allowExec
2222
+ allowExec: this.#permissions.allowWrite && this.#permissions.allowExec
2138
2223
  }).catch(async (error) => {
2139
2224
  await this.#generations.drop(jobId);
2225
+ await this.#logCommand("generate", getErrorMessage(error), input.requestedBy, true);
2140
2226
  throw error;
2141
2227
  });
2142
2228
  const output = await this.#generations.keep({
2143
2229
  jobId,
2144
- source: FileSource.OUTPUT,
2145
2230
  files,
2146
2231
  maxSetMb: this.#limits.maxMb
2147
2232
  });
@@ -2154,16 +2239,14 @@ var Agent = class extends RpcTarget {
2154
2239
  await this.#generations.add({
2155
2240
  jobId,
2156
2241
  output,
2157
- disk,
2158
2242
  inputBytes
2159
2243
  });
2160
- 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" : ""}`);
2244
+ 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);
2161
2245
  return {
2162
2246
  status: "success",
2163
2247
  files: [...files.paths],
2164
2248
  fileCount: files.paths.size,
2165
- hashes: output.hashes,
2166
- disk: disk ? { hashes: disk.hashes } : void 0
2249
+ hashes: output.hashes
2167
2250
  };
2168
2251
  }
2169
2252
  async saveConfig(input) {
@@ -2197,7 +2280,7 @@ var Agent = class extends RpcTarget {
2197
2280
  extensions: await this.#installedExtensions()
2198
2281
  });
2199
2282
  const applied = outcomes.filter((outcome) => outcome.applied).length;
2200
- await this.#logCommand("saveConfig", `applied ${applied}/${outcomes.length} edits to ${configPath}`);
2283
+ await this.#logCommand("saveConfig", `applied ${applied}/${outcomes.length} edits to ${configPath}`, input.requestedBy);
2201
2284
  return {
2202
2285
  outcomes,
2203
2286
  changed
@@ -2233,35 +2316,14 @@ var Agent = class extends RpcTarget {
2233
2316
  message: AgentMessage.READ_NOT_ALLOWED,
2234
2317
  permission: AgentPermission.READ
2235
2318
  });
2236
- if (!Array.isArray(input.paths) || typeof input.jobId !== "string" || !input.jobId || !["generation", "currentDisk"].includes(input.jobType)) return this.#refuse({ message: AgentMessage.FILE_REQUEST_INVALID });
2319
+ if (!Array.isArray(input.paths) || typeof input.jobId !== "string" || !input.jobId) return this.#refuse({ message: AgentMessage.FILE_REQUEST_INVALID });
2237
2320
  const { paths } = input;
2238
2321
  if (paths.length > 50) return this.#refuse({ message: AgentMessage.TOO_MANY_FILES });
2239
2322
  if (!isValidPage(input)) return this.#refuse({ message: AgentMessage.INVALID_PAGE });
2240
- if (input.jobType === "currentDisk") {
2241
- if (this.#sandbox) return this.#refuse({ message: AgentMessage.SANDBOX_DISK_UNAVAILABLE });
2242
- const config = await this.#options.loadConfig();
2243
- const disk = await listDisk({
2244
- root: this.#options.root,
2245
- outputPath: config.output.path,
2246
- maxFiles: DISK_SNAPSHOT_MAX_FILES
2247
- });
2248
- if (!disk) return this.#refuse({ message: AgentMessage.GENERATION_GONE });
2249
- const result = await readDiskFiles({
2250
- files: disk,
2251
- paths,
2252
- cursor: input.cursor,
2253
- limit: input.limit
2254
- });
2255
- await this.#logCommand("readFiles", `read ${Object.keys(result.files).length}/${paths.length} requested file${paths.length === 1 ? "" : "s"}`);
2256
- return result;
2257
- }
2258
2323
  const generation = await this.#generations.get(input.jobId);
2259
2324
  if (!generation) return this.#refuse({ message: AgentMessage.GENERATION_GONE });
2260
- const source = input.source === FileSource.DISK ? FileSource.DISK : FileSource.OUTPUT;
2261
- if (!generation[source]) return this.#refuse({ message: AgentMessage.NO_DISK_SNAPSHOT });
2262
2325
  const result = await this.#generations.read({
2263
2326
  generation,
2264
- source,
2265
2327
  paths,
2266
2328
  cursor: input.cursor,
2267
2329
  limit: input.limit
@@ -2286,15 +2348,11 @@ var Agent = class extends RpcTarget {
2286
2348
  })) return this.#refuse({ message: AgentMessage.INVALID_PAGE });
2287
2349
  const [generation, baseGeneration] = await Promise.all([this.#generations.get(jobId), this.#generations.get(base.jobId)]);
2288
2350
  if (!generation || !baseGeneration) return this.#refuse({ message: AgentMessage.GENERATION_GONE });
2289
- const baseSource = base.source === FileSource.DISK ? FileSource.DISK : FileSource.OUTPUT;
2290
- if (!baseGeneration[baseSource]) return this.#refuse({ message: AgentMessage.NO_DISK_SNAPSHOT });
2291
2351
  const [after, before] = await Promise.all([this.#generations.read({
2292
2352
  generation,
2293
- source: FileSource.OUTPUT,
2294
2353
  paths: [path]
2295
2354
  }), this.#generations.read({
2296
2355
  generation: baseGeneration,
2297
- source: baseSource,
2298
2356
  paths: [path]
2299
2357
  })]);
2300
2358
  const afterContent = after.files[path];
@@ -2315,7 +2373,6 @@ var Agent = class extends RpcTarget {
2315
2373
  const key = JSON.stringify([
2316
2374
  jobId,
2317
2375
  base.jobId,
2318
- baseSource,
2319
2376
  path
2320
2377
  ]);
2321
2378
  let diff = this.#diffs.get(key);
@@ -2372,14 +2429,15 @@ async function runSession({ options, openRpc, url, instanceId }) {
2372
2429
  connected: Promise.withResolvers(),
2373
2430
  closed: false
2374
2431
  };
2432
+ const agent = new Agent({
2433
+ options,
2434
+ session
2435
+ });
2375
2436
  const rpc = openRpc({
2376
2437
  url,
2377
2438
  token,
2378
2439
  instanceId,
2379
- agent: new Agent({
2380
- options,
2381
- session
2382
- })
2440
+ agent
2383
2441
  });
2384
2442
  signal?.addEventListener("abort", rpc.close, { once: true });
2385
2443
  let timer;
@@ -2414,6 +2472,7 @@ async function runSession({ options, openRpc, url, instanceId }) {
2414
2472
  await emit(hooks, "studio:warn", { message: "Canceled the running generation: the connection to Kubb Studio closed" });
2415
2473
  }
2416
2474
  await active?.settled;
2475
+ await agent.clear();
2417
2476
  return {
2418
2477
  ...close,
2419
2478
  connected: session.info !== void 0
@@ -2721,10 +2780,9 @@ async function createSnapshotPackage({ files, packageInfo }) {
2721
2780
  const root = await mkdtemp(join(tmpdir(), "kubb-snapshot-"));
2722
2781
  const dist = join(root, "dist");
2723
2782
  try {
2724
- await mkdir(dist);
2783
+ await mkdir$1(dist);
2725
2784
  await Promise.all([...sources].map(async ([path, { content }]) => {
2726
- await mkdir(dirname(join(root, path)), { recursive: true });
2727
- await writeFile(join(root, path), content);
2785
+ await writeRaw(join(root, path), content);
2728
2786
  }));
2729
2787
  const entry = [...sources.keys()].filter((path) => /\.(?:[cm]?[jt]sx?)$/.test(path)).map((path) => join(root, path));
2730
2788
  if (entry.length) {
@@ -2764,7 +2822,7 @@ async function createSnapshotPackage({ files, packageInfo }) {
2764
2822
  const chunks = [...new Map([
2765
2823
  ["package.json", JSON.stringify(manifest, null, 2)],
2766
2824
  ...[...sources].map(([path, { content }]) => [path, content]),
2767
- ...await Promise.all(built.map(async (key) => [`dist/${key}`, await readFile(join(dist, key), "utf8")]))
2825
+ ...await Promise.all(built.map(async (key) => [`dist/${key}`, await read(join(dist, key))]))
2768
2826
  ])].flatMap(([path, content]) => {
2769
2827
  const bytes = Buffer.from(content);
2770
2828
  return [
@@ -2795,7 +2853,10 @@ async function createSnapshot({ studioUrl = defaultStudioUrl, apiKey, config, ho
2795
2853
  path: "/api/snapshots"
2796
2854
  });
2797
2855
  const generated = await generate({
2798
- config,
2856
+ config: {
2857
+ ...config,
2858
+ storage: fsStorage()
2859
+ },
2799
2860
  hooks,
2800
2861
  signal,
2801
2862
  allowExec: true
@@ -2874,6 +2935,6 @@ async function createSnapshot({ studioUrl = defaultStudioUrl, apiKey, config, ho
2874
2935
  return _data.snapshot;
2875
2936
  }
2876
2937
  //#endregion
2877
- export { IncompatibleAgentError, InvalidAgentTokenError, PairingCanceledError, PairingDeniedError, PairingExpiredError, connectAgent, createSnapshot, defaultStudioUrl, pairAgent, writeConfigEdits };
2938
+ export { IncompatibleAgentError, InvalidAgentTokenError, PairingCanceledError, PairingDeniedError, PairingExpiredError, applyConfigEdits, connectAgent, createSnapshot, defaultStudioUrl, pairAgent, writeConfigEdits };
2878
2939
 
2879
2940
  //# sourceMappingURL=index.js.map