@kubb/studio 5.9.3 → 5.9.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/README.md CHANGED
@@ -74,7 +74,7 @@ running in a developer's project and a container running a fixed plugin set.
74
74
  | `connect.ts` | `connectAgent`: the socket loop and the `Agent` that serves Studio's calls. |
75
75
  | `pair.ts` | `pairAgent`: RFC 8628 device pairing. |
76
76
  | `snapshot.ts` | `createSnapshot` for CI, and packing a generation into an npm tarball. |
77
- | `generations.ts` | Running a generation and keeping its files for `readFiles`. |
77
+ | `generations.ts` | Running a generation and keeping its files and AST for `readFiles`/`readInput`. |
78
78
  | `configFile.ts` | Reading and patching plugin options in `kubb.config.ts`. |
79
79
 
80
80
  ## Connection
@@ -107,7 +107,7 @@ Every permission is off by default, and each covers one trust boundary:
107
107
  | `allowWrite` | Generated files are written to disk. Off means they exist only in memory and stream to Studio. |
108
108
  | `allowExec` | The formatter, the linter, and `output.postGenerate` run as child processes. |
109
109
  | `allowConfigEdit` | Studio may change plugin options in `kubb.config.ts`. |
110
- | `allowRead` | Studio may read back the files a generation produced. |
110
+ | `allowRead` | Studio may read back the files a generation produced and its parsed spec. |
111
111
 
112
112
  A sandbox never writes or edits config, and always allows reads.
113
113
 
package/dist/index.cjs CHANGED
@@ -37,6 +37,7 @@ let capnweb = require("capnweb");
37
37
  let magicast = require("magicast");
38
38
  let node_module = require("node:module");
39
39
  let node_util = require("node:util");
40
+ let _kubb_ast = require("@kubb/ast");
40
41
  let ofetch = require("ofetch");
41
42
  let node_zlib = require("node:zlib");
42
43
  //#region ../../internals/utils/src/casing.ts
@@ -176,10 +177,18 @@ function getElapsedMs(hrStart) {
176
177
  }
177
178
  //#endregion
178
179
  //#region package.json
179
- var version = "5.9.3";
180
+ var version = "5.9.5";
180
181
  //#endregion
181
182
  //#region src/resolveConfig.ts
182
183
  /**
184
+ * Turns the JSON config Studio sends back into live Kubb objects.
185
+ *
186
+ * A plugin or adapter instance carries closures (`parse`, `getImports`, ...) that cannot survive
187
+ * JSON, so both sides pass options over the wire and the factory is re-invoked here with the merged
188
+ * result. Only packages {@link isAllowedPlugin} accepts are resolved this way, never an arbitrary
189
+ * module the payload names.
190
+ */
191
+ /**
183
192
  * Imports a package, falling back to how the user's project would resolve it.
184
193
  *
185
194
  * `import()` resolves from this file, so a linked or globally installed Studio (`pnpm link`,
@@ -258,8 +267,9 @@ function isAllowedPlugin({ packageName, extensions }) {
258
267
  return KUBB_PLUGIN_SPECIFIER.test(packageName) || extensions.get(packageName)?.manifest.kind === "plugin";
259
268
  }
260
269
  /**
261
- * Dynamically imports a plugin package and returns its factory. Only packages
262
- * {@link isAllowedPlugin} accepts reach `import()`.
270
+ * Dynamically imports an extension package and returns its factory. Callers check
271
+ * {@link isAllowedPlugin} or derive the package from a disk instance first, so an arbitrary module
272
+ * never reaches `import()`.
263
273
  *
264
274
  * Packages must be pre-installed in the Docker image at build time via the `KUBB_PACKAGES`
265
275
  * build ARG, no runtime installation is possible in the distroless container.
@@ -267,13 +277,9 @@ function isAllowedPlugin({ packageName, extensions }) {
267
277
  * Resolution order: the manifest's `export`, else the camelCase named export the package name
268
278
  * implies (e.g. `pluginTs`), then the default export.
269
279
  *
270
- * @throws if the package is not allowed, cannot be imported, or exports no callable factory.
280
+ * @throws if the package cannot be imported or exports no callable factory.
271
281
  */
272
- async function loadPluginFactory({ packageName, extensions }) {
273
- if (!isAllowedPlugin({
274
- packageName,
275
- extensions
276
- })) throw new Error(`Plugin "${packageName}" is not an installed Kubb plugin. Studio loads @kubb/plugin-* packages and installed packages that ship a kubb-extension.json with kind "plugin".`);
282
+ async function loadFactory({ packageName, extensions }) {
277
283
  let mod;
278
284
  try {
279
285
  mod = await importFromProject(packageName);
@@ -292,7 +298,7 @@ async function loadPluginFactory({ packageName, extensions }) {
292
298
  }
293
299
  /**
294
300
  * Resolves each plugin entry by dynamically importing its package and calling its factory with the
295
- * provided options. See {@link loadPluginFactory} for which packages are allowed.
301
+ * provided options. Only packages {@link isAllowedPlugin} accepts are loaded.
296
302
  *
297
303
  * @example
298
304
  * ```ts
@@ -306,7 +312,11 @@ async function loadPluginFactory({ packageName, extensions }) {
306
312
  */
307
313
  async function resolvePlugins({ plugins, extensions = /* @__PURE__ */ new Map() }) {
308
314
  return Promise.all(plugins.map(async ({ name, options }) => {
309
- return (await loadPluginFactory({
315
+ if (!isAllowedPlugin({
316
+ packageName: name,
317
+ extensions
318
+ })) throw new Error(`Plugin "${name}" is not an installed Kubb plugin. Studio loads @kubb/plugin-* packages and installed packages that ship a kubb-extension.json with kind "plugin".`);
319
+ return (await loadFactory({
310
320
  packageName: name,
311
321
  extensions
312
322
  }))(options ?? {});
@@ -454,13 +464,12 @@ async function mergePlugins({ diskPlugins, studioPlugins, extensions = /* @__PUR
454
464
  async function rebuildExtension({ disk, studioOptions, kind, extensions }) {
455
465
  if (!studioOptions || !Object.keys(studioOptions).length) return disk;
456
466
  const { name } = (0, _kubb_core.resolveMeta)(disk);
457
- const installed = [...extensions.values()].find(({ manifest }) => manifest.kind === kind && manifest.name === name);
458
- const packageName = installed?.package ?? `@kubb/${kind}-${name}`;
459
- const factory = await importFromProject(packageName).then((mod) => mod[installed?.manifest.export ?? toExportName(packageName)]).catch((cause) => {
467
+ return (await loadFactory({
468
+ packageName: [...extensions.values()].find(({ manifest }) => manifest.kind === kind && manifest.name === name)?.package ?? `@kubb/${kind}-${name}`,
469
+ extensions
470
+ }).catch((cause) => {
460
471
  throw new Error(`The ${kind} "${name}" is not an installed Kubb extension, so Studio cannot change its options. Edit it in the config file.`, { cause });
461
- });
462
- if (typeof factory !== "function") throw new Error(`The ${kind} "${name}" is not an installed Kubb extension, so Studio cannot change its options. Edit it in the config file.`);
463
- return factory(mergeOptions(disk.options ?? {}, studioOptions));
472
+ }))(mergeOptions(disk.options ?? {}, studioOptions));
464
473
  }
465
474
  /**
466
475
  * Merges Studio-provided adapter option overrides into the disk config's adapter, see {@link rebuildExtension}.
@@ -1099,7 +1108,10 @@ function relativeStoragePath({ root, filePath }) {
1099
1108
  * The content fingerprint Studio compares snapshots by. Agent runs and CI uploads must agree on it.
1100
1109
  */
1101
1110
  const hashOf = (content) => (0, node_crypto.createHash)("sha1").update(content).digest("hex").slice(0, 16);
1102
- const totalBytes = (entries) => entries.reduce((sum, { output, disk }) => sum + output.bytes + (disk?.bytes ?? 0), 0);
1111
+ /**
1112
+ * The bytes `entries` keep on disk: output, written files, and kept input.
1113
+ */
1114
+ const totalBytes = (entries) => entries.reduce((sum, { output, disk, inputBytes }) => sum + output.bytes + (disk?.bytes ?? 0) + (inputBytes ?? 0), 0);
1103
1115
  function positiveNumber(value) {
1104
1116
  const parsed = Number(value);
1105
1117
  return value && Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
@@ -1142,7 +1154,11 @@ async function generate({ config, hooks, signal, allowExec }) {
1142
1154
  hooks,
1143
1155
  signal
1144
1156
  });
1145
- const result = await kubb.generate();
1157
+ let input = null;
1158
+ const unhook = kubb.hooks.hook("kubb:build:end", (ctx) => {
1159
+ if (ctx.config === kubb.config) input = kubb.driver.inputNode;
1160
+ });
1161
+ const result = await kubb.generate().finally(unhook);
1146
1162
  if (!result.success) throw generationFailure(result.diagnostics);
1147
1163
  const { storage, root } = kubb.config;
1148
1164
  return {
@@ -1151,7 +1167,8 @@ async function generate({ config, hooks, signal, allowExec }) {
1151
1167
  paths: new Set(result.files.map((file) => relativeStoragePath({
1152
1168
  root,
1153
1169
  filePath: file.path
1154
- })))
1170
+ }))),
1171
+ input
1155
1172
  };
1156
1173
  }
1157
1174
  /**
@@ -1232,6 +1249,33 @@ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now
1232
1249
  return {
1233
1250
  keep,
1234
1251
  drop,
1252
+ /**
1253
+ * Keeps the `InputNode` of `jobId` and returns its size in bytes, or 0 when it keeps none. A ref schema loses
1254
+ * the copy of its target, which `input.schemas` holds: with it, a large spec outgrows what `JSON.stringify` can return.
1255
+ */
1256
+ async keepInput({ jobId, input, maxSetMb }) {
1257
+ if (!input) return 0;
1258
+ let json;
1259
+ try {
1260
+ json = JSON.stringify(_kubb_ast.ast.transform(input, { schema: (node) => node.type === "ref" && node.schema ? {
1261
+ ...node,
1262
+ schema: void 0
1263
+ } : void 0 }));
1264
+ } catch {
1265
+ return 0;
1266
+ }
1267
+ const bytes = Buffer.byteLength(json);
1268
+ if (bytes > maxSetMb * MB) return 0;
1269
+ await storage.writeItem(`${dirOf(jobId)}input.json`, json);
1270
+ return bytes;
1271
+ },
1272
+ /**
1273
+ * The input `keepInput` kept for `jobId`, or `undefined` when there is none.
1274
+ */
1275
+ async readInput(jobId) {
1276
+ const stored = await storage.readItem(`${dirOf(jobId)}input.json`);
1277
+ return stored === null ? void 0 : JSON.parse(stored);
1278
+ },
1235
1279
  get: async (jobId) => (await load()).find((generation) => generation.jobId === jobId && isLive(generation)),
1236
1280
  async add(generation) {
1237
1281
  const current = await load();
@@ -1455,7 +1499,6 @@ function createGenerationStream(hooks, jobId) {
1455
1499
  return {
1456
1500
  stream,
1457
1501
  close,
1458
- dispose: () => fail(),
1459
1502
  fail
1460
1503
  };
1461
1504
  }
@@ -1588,29 +1631,29 @@ var Agent = class extends capnweb.RpcTarget {
1588
1631
  });
1589
1632
  throw new Error(message);
1590
1633
  }
1591
- async #command({ command, run }) {
1634
+ #assertConnected() {
1592
1635
  if (!this.#session.info) throw new Error("Studio must call connect() before any other command");
1593
- const { value, info } = await run();
1594
- if (info !== void 0) await emit(this.#options.hooks, "studio:command", {
1636
+ }
1637
+ #logCommand(command, info) {
1638
+ return emit(this.#options.hooks, "studio:command", {
1595
1639
  command,
1596
1640
  info
1597
1641
  });
1598
- return value;
1599
1642
  }
1600
- async #readConfigFileView() {
1643
+ async #readConfigFileView(source) {
1601
1644
  if (!this.#permissions.allowConfigEdit) return void 0;
1602
1645
  try {
1603
- return readConfig(await read(this.#options.configFile));
1646
+ return readConfig(await source);
1604
1647
  } catch (error) {
1605
1648
  await this.#warn({ message: `Could not read ${this.#options.configFile}: ${getErrorMessage(error)}` });
1606
1649
  return;
1607
1650
  }
1608
1651
  }
1609
- async #installedExtensions() {
1610
- const source = await read(this.#options.configFile).catch(() => "");
1652
+ async #installedExtensions(source = read(this.#options.configFile)) {
1653
+ const imports = await source.then(readImports, () => []);
1611
1654
  const installed = await (0, _kubb_core.readInstalledPackages)({
1612
1655
  root: this.#options.root,
1613
- packages: [...readImports(source), ...BUILT_IN_PACKAGES],
1656
+ packages: [...imports, ...BUILT_IN_PACKAGES],
1614
1657
  resolveFrom: ["kubb"]
1615
1658
  });
1616
1659
  return new Map(installed.flatMap(({ package: name, version, manifest }) => version !== void 0 && manifest ? [[name, {
@@ -1621,11 +1664,12 @@ var Agent = class extends capnweb.RpcTarget {
1621
1664
  }
1622
1665
  async connect(info) {
1623
1666
  this.#session.info = info;
1624
- const { configPath, root, version: version$1, loadConfig } = this.#options;
1667
+ const { configPath, root, version: version$1, loadConfig, configFile } = this.#options;
1668
+ const source = read(configFile);
1625
1669
  const [config, file, extensions] = await Promise.all([
1626
1670
  loadConfig(),
1627
- this.#readConfigFileView(),
1628
- this.#installedExtensions()
1671
+ this.#readConfigFileView(source),
1672
+ this.#installedExtensions(source)
1629
1673
  ]);
1630
1674
  this.#session.connected.resolve(info);
1631
1675
  return {
@@ -1678,11 +1722,13 @@ var Agent = class extends capnweb.RpcTarget {
1678
1722
  result,
1679
1723
  stop: () => {
1680
1724
  controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
1681
- stream.dispose();
1725
+ stream.fail();
1682
1726
  }
1683
1727
  });
1684
1728
  }
1729
+ /** Runs one job and keeps its output, and its input with `allowRead`, for later reads. */
1685
1730
  async #runGeneration(input, controller) {
1731
+ this.#assertConnected();
1686
1732
  if (this.#session.closed) throw new Error("Connection is closed");
1687
1733
  if (this.#session.active) return this.#refuse({ message: "A generation is already in progress, please wait for it to finish" });
1688
1734
  const settled = Promise.withResolvers();
@@ -1694,13 +1740,10 @@ var Agent = class extends capnweb.RpcTarget {
1694
1740
  let root = this.#options.root;
1695
1741
  try {
1696
1742
  if (this.#sandbox) root = await (0, node_fs_promises.mkdtemp)(node_path.default.join((0, node_os.tmpdir)(), "kubb-job-"));
1697
- return await this.#command({
1698
- command: "generate",
1699
- run: () => this.#generate({
1700
- input,
1701
- root,
1702
- signal: controller.signal
1703
- })
1743
+ return await this.#generate({
1744
+ input,
1745
+ root,
1746
+ signal: controller.signal
1704
1747
  });
1705
1748
  } finally {
1706
1749
  if (root !== this.#options.root) await Promise.all([(0, node_fs_promises.rm)(root, {
@@ -1771,37 +1814,36 @@ var Agent = class extends capnweb.RpcTarget {
1771
1814
  files,
1772
1815
  maxSetMb: this.#limits.maxMb
1773
1816
  });
1817
+ const inputBytes = await this.#generations.keepInput({
1818
+ jobId,
1819
+ input: this.#permissions.allowRead ? files.input : null,
1820
+ maxSetMb: this.#limits.maxMb
1821
+ });
1774
1822
  if (!output.paths.length && Object.keys(output.hashes).length) await this.#warn({ message: "Kept only the hashes of this generation: its output is too large to keep" });
1775
1823
  await this.#generations.add({
1776
1824
  jobId,
1777
1825
  output,
1778
- disk
1826
+ disk,
1827
+ inputBytes
1779
1828
  });
1829
+ 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" : ""}`);
1780
1830
  return {
1781
- value: {
1782
- status: "success",
1783
- files: [...files.paths],
1784
- fileCount: files.paths.size,
1785
- hashes: output.hashes,
1786
- disk: disk ? { hashes: disk.hashes } : void 0
1787
- },
1788
- info: `${activePlugins.length} plugin${activePlugins.length === 1 ? "" : "s"}, ${this.#permissions.allowWrite ? "written to disk" : "in memory"}${studioInput !== void 0 ? ", from a Studio spec" : ""}`
1831
+ status: "success",
1832
+ files: [...files.paths],
1833
+ fileCount: files.paths.size,
1834
+ hashes: output.hashes,
1835
+ disk: disk ? { hashes: disk.hashes } : void 0
1789
1836
  };
1790
1837
  }
1791
- saveConfig(input) {
1792
- return this.#command({
1793
- command: "saveConfig",
1794
- run: () => this.#saveConfig(input)
1795
- });
1796
- }
1797
- async #saveConfig(input) {
1838
+ async saveConfig(input) {
1839
+ this.#assertConnected();
1798
1840
  const { configPath, configFile } = this.#options;
1799
1841
  if (!Array.isArray(input.edits)) {
1800
1842
  await this.#warn({ message: "Ignored save: the message carried no edits" });
1801
- return { value: {
1843
+ return {
1802
1844
  outcomes: [],
1803
1845
  changed: false
1804
- } };
1846
+ };
1805
1847
  }
1806
1848
  const edits = input.edits;
1807
1849
  const refuse = (reason) => ({
@@ -1814,9 +1856,9 @@ var Agent = class extends capnweb.RpcTarget {
1814
1856
  });
1815
1857
  if (!this.#permissions.allowConfigEdit) {
1816
1858
  await this.#warn({ message: "Ignored save: editing kubb.config.ts was not granted" });
1817
- return { value: refuse("the agent was not granted permission to edit kubb.config.ts") };
1859
+ return refuse("the agent was not granted permission to edit kubb.config.ts");
1818
1860
  }
1819
- if (this.#session.active) return { value: refuse("a generation is in progress") };
1861
+ if (this.#session.active) return refuse("a generation is in progress");
1820
1862
  try {
1821
1863
  const { outcomes, changed } = await writeConfigEdits({
1822
1864
  filePath: configFile,
@@ -1824,27 +1866,40 @@ var Agent = class extends capnweb.RpcTarget {
1824
1866
  extensions: await this.#installedExtensions()
1825
1867
  });
1826
1868
  const applied = outcomes.filter((outcome) => outcome.applied).length;
1869
+ await this.#logCommand("saveConfig", `applied ${applied}/${outcomes.length} edits to ${configPath}`);
1827
1870
  return {
1828
- value: {
1829
- outcomes,
1830
- changed
1831
- },
1832
- info: `applied ${applied}/${outcomes.length} edits to ${configPath}`
1871
+ outcomes,
1872
+ changed
1833
1873
  };
1834
1874
  } catch (error) {
1835
1875
  await emit(this.#options.hooks, "studio:error", { error: toError(error) });
1836
- return { value: refuse(getErrorMessage(error)) };
1876
+ return refuse(getErrorMessage(error));
1837
1877
  }
1838
1878
  }
1839
- readFiles(input) {
1840
- return this.#command({
1841
- command: "readFiles",
1842
- run: () => this.#readFiles(input)
1879
+ /**
1880
+ * Returns the Kubb AST a kept job parsed its spec into. Refused without `allowRead`.
1881
+ */
1882
+ async readInput(input) {
1883
+ this.#assertConnected();
1884
+ if (!this.#permissions.allowRead) return this.#refuse({
1885
+ message: require_protocol.READ_NOT_ALLOWED_MESSAGE,
1886
+ permission: "allowRead"
1843
1887
  });
1888
+ if (typeof input.jobId !== "string" || !input.jobId) return this.#refuse({ message: "The request must name a job" });
1889
+ const generation = await this.#generations.get(input.jobId);
1890
+ if (!generation) return this.#refuse({ message: require_protocol.GENERATION_GONE_MESSAGE });
1891
+ const kept = generation.inputBytes ? await this.#generations.readInput(input.jobId) : void 0;
1892
+ if (!kept) return this.#refuse({ message: require_protocol.NO_INPUT_MESSAGE });
1893
+ await this.#logCommand("readInput", "read the API input");
1894
+ return { input: kept };
1844
1895
  }
1845
- async #readFiles(input) {
1896
+ /**
1897
+ * Returns the requested files of a kept job, one page of lines at a time. Refused without `allowRead`.
1898
+ */
1899
+ async readFiles(input) {
1900
+ this.#assertConnected();
1846
1901
  if (!this.#permissions.allowRead) return this.#refuse({
1847
- message: "The agent was not granted permission to read generated files",
1902
+ message: require_protocol.READ_NOT_ALLOWED_MESSAGE,
1848
1903
  permission: "allowRead"
1849
1904
  });
1850
1905
  if (!Array.isArray(input.paths) || typeof input.jobId !== "string" || !input.jobId) return this.#refuse({ message: "The request must name a job and carry paths" });
@@ -1862,10 +1917,8 @@ var Agent = class extends capnweb.RpcTarget {
1862
1917
  cursor: input.cursor,
1863
1918
  limit: input.limit
1864
1919
  });
1865
- return {
1866
- value: result,
1867
- info: `read ${Object.keys(result.files).length}/${paths.length} requested file${paths.length === 1 ? "" : "s"}`
1868
- };
1920
+ await this.#logCommand("readFiles", `read ${Object.keys(result.files).length}/${paths.length} requested file${paths.length === 1 ? "" : "s"}`);
1921
+ return result;
1869
1922
  }
1870
1923
  };
1871
1924
  const openWebSocketRpc = ({ url, token, instanceId, agent }) => {
@@ -2156,7 +2209,6 @@ async function pairAgent({ onCode, onRetry, maxAttempts = 1, ...options }) {
2156
2209
  }
2157
2210
  //#endregion
2158
2211
  //#region src/snapshot.ts
2159
- const gzipAsync = (0, node_util.promisify)(node_zlib.gzip);
2160
2212
  const UPLOAD_TIMEOUT_MS = 12e4;
2161
2213
  const BUNDLED_DEPENDENCIES = ["@kubb/plugin-barrel"];
2162
2214
  /**
@@ -2276,7 +2328,7 @@ async function createSnapshotPackage({ files, packageInfo }) {
2276
2328
  Buffer.alloc((512 - bytes.length % 512) % 512)
2277
2329
  ];
2278
2330
  });
2279
- return await gzipAsync(Buffer.concat([...chunks, Buffer.alloc(1024)]));
2331
+ return (0, node_zlib.gzipSync)(Buffer.concat([...chunks, Buffer.alloc(1024)]));
2280
2332
  } finally {
2281
2333
  await (0, node_fs_promises.rm)(root, {
2282
2334
  recursive: true,