@kubb/studio 5.12.1 → 5.12.3

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,20 +23,20 @@ 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 = require("node:fs");
27
+ let node_fs_promises = require("node:fs/promises");
28
+ let node_module = require("node:module");
26
29
  let node_os = require("node:os");
27
30
  let node_path = require("node:path");
28
31
  node_path = __toESM(node_path, 1);
29
32
  let node_process = require("node:process");
30
33
  node_process = __toESM(node_process, 1);
31
34
  let node_timers_promises = require("node:timers/promises");
32
- let node_url = require("node:url");
33
- let node_stream = require("node:stream");
34
- let node_fs = require("node:fs");
35
- let node_fs_promises = require("node:fs/promises");
35
+ let tinyexec = require("tinyexec");
36
36
  let _kubb_core = require("@kubb/core");
37
37
  let capnweb = require("capnweb");
38
38
  let magicast = require("magicast");
39
- let node_module = require("node:module");
39
+ let node_url = require("node:url");
40
40
  let diff = require("diff");
41
41
  let node_util = require("node:util");
42
42
  let _kubb_ast = require("@kubb/ast");
@@ -48,6 +48,7 @@ let node_https = require("node:https");
48
48
  node_https = __toESM(node_https, 1);
49
49
  let node_net = require("node:net");
50
50
  node_net = __toESM(node_net, 1);
51
+ let node_stream = require("node:stream");
51
52
  let ofetch = require("ofetch");
52
53
  let node_zlib = require("node:zlib");
53
54
  //#region ../../internals/utils/src/casing.ts
@@ -229,7 +230,7 @@ function getElapsedMs(hrStart) {
229
230
  }
230
231
  //#endregion
231
232
  //#region package.json
232
- var version = "5.12.1";
233
+ var version = "5.12.3";
233
234
  //#endregion
234
235
  //#region src/resolveConfig.ts
235
236
  /**
@@ -627,13 +628,7 @@ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
627
628
  * line count, so `enable-plugin` restores exactly those lines and not whatever comments follow.
628
629
  */
629
630
  const DISABLED_MARKER = /^\/\/ kubb:disabled (.+)\s+(\d+)$/;
630
- const OPERATIONS = /* @__PURE__ */ new Set([
631
- "set",
632
- "remove",
633
- "add-plugin",
634
- "disable-plugin",
635
- "enable-plugin"
636
- ]);
631
+ const OPERATIONS = new Set(Object.values(require_protocol.ConfigEditOperation));
637
632
  const KINDS = /* @__PURE__ */ new Set([
638
633
  "plugin",
639
634
  "adapter",
@@ -644,13 +639,14 @@ const KINDS = /* @__PURE__ */ new Set([
644
639
  * The edit crossed the wire, so its declared type is a promise the sender may not have kept.
645
640
  */
646
641
  function invalidEdit(edit) {
647
- const { operation, plugin, config, path, kind } = typeof edit === "object" && edit !== null ? edit : {};
642
+ const { operation, plugin, config, path, kind, input } = typeof edit === "object" && edit !== null ? edit : {};
648
643
  if (typeof operation !== "string" || !OPERATIONS.has(operation)) return `unknown operation ${JSON.stringify(operation)}`;
644
+ if (config !== void 0 && typeof config !== "string" && !Number.isSafeInteger(config)) return "config must name an entry or give its index";
645
+ if (operation === require_protocol.ConfigEditOperation.SET_INPUT) return typeof input === "string" && input.trim() ? void 0 : "the edit names no input";
649
646
  if (typeof plugin !== "string" || !plugin) return "the edit names no plugin";
650
647
  if (kind !== void 0 && (typeof kind !== "string" || !KINDS.has(kind))) return `unknown kind ${JSON.stringify(kind)}`;
651
- if (kind !== void 0 && operation !== "set" && operation !== "remove") return `${operation} edits the plugins array, so it takes no kind`;
652
- if (config !== void 0 && typeof config !== "string" && !Number.isSafeInteger(config)) return "config must name an entry or give its index";
653
- if (operation === "set" || operation === "remove") {
648
+ if (kind !== void 0 && operation !== require_protocol.ConfigEditOperation.SET && operation !== require_protocol.ConfigEditOperation.REMOVE) return `${operation} edits the plugins array, so it takes no kind`;
649
+ if (operation === require_protocol.ConfigEditOperation.SET || operation === require_protocol.ConfigEditOperation.REMOVE) {
654
650
  if (!Array.isArray(path) || !path.every((key) => typeof key === "string" && key)) return "path must be a list of option names";
655
651
  if (!path.length) return "no option path given";
656
652
  const unsafe = path.find((key) => !isSafeKey(key));
@@ -1091,6 +1087,15 @@ function applySet(source, call, path, value, quote) {
1091
1087
  }
1092
1088
  return { source: splice(source, rangeOf(target.value).start, rangeOf(target.value).end, printValue(value, quote)) };
1093
1089
  }
1090
+ /** Writes the config's top-level `input` as a string literal, replacing what it held. */
1091
+ function applySetInput(source, config, input, quote) {
1092
+ const value = printValue(input, quote);
1093
+ const found = findMember(config, "input");
1094
+ if (!found) return { source: insertItem(source, config, `input: ${value}`) };
1095
+ if (found.member.type !== "ObjectProperty") return { reason: "input is not a plain property" };
1096
+ if (isShadowed(config, found.index)) return { reason: "a spread after input could override it" };
1097
+ return { source: splice(source, rangeOf(found.member.value).start, rangeOf(found.member.value).end, value) };
1098
+ }
1094
1099
  /**
1095
1100
  * Deletes the option at `path`, falling the plugin back to its default for it. An option the file
1096
1101
  * does not set already has its default, so removing it changes nothing.
@@ -1187,7 +1192,8 @@ function applyEdit({ source, edit, quote, extensions }) {
1187
1192
  const findConfig = (entries) => typeof ref === "string" ? entries.configs.find((entry) => configName(entry) === ref) : entries.configs[ref ?? 0];
1188
1193
  const config = findConfig(parsed);
1189
1194
  if (!config) return { reason: `no config entry found for ${JSON.stringify(ref)}` };
1190
- if (edit.operation === "disable-plugin") {
1195
+ if (edit.operation === require_protocol.ConfigEditOperation.SET_INPUT) return applySetInput(source, config, edit.input, quote);
1196
+ if (edit.operation === require_protocol.ConfigEditOperation.DISABLE_PLUGIN) {
1191
1197
  const result = disablePlugin(source, parsed.mod, config, edit.plugin);
1192
1198
  if (!("reason" in result) || !result.reason.includes("shares a line")) return result;
1193
1199
  const expanded = expandPluginsArray(source, config);
@@ -1195,8 +1201,8 @@ function applyEdit({ source, edit, quote, extensions }) {
1195
1201
  const reparsedConfig = reparsed && !("reason" in reparsed) ? findConfig(reparsed) : void 0;
1196
1202
  return expanded !== void 0 && reparsed && !("reason" in reparsed) && reparsedConfig ? disablePlugin(expanded, reparsed.mod, reparsedConfig, edit.plugin) : result;
1197
1203
  }
1198
- if (edit.operation === "enable-plugin") return enablePlugin(source, config, edit.plugin);
1199
- if (edit.operation === "add-plugin") return applyAddPlugin({
1204
+ if (edit.operation === require_protocol.ConfigEditOperation.ENABLE_PLUGIN) return enablePlugin(source, config, edit.plugin);
1205
+ if (edit.operation === require_protocol.ConfigEditOperation.ADD_PLUGIN) return applyAddPlugin({
1200
1206
  source,
1201
1207
  parsed,
1202
1208
  config,
@@ -1212,7 +1218,7 @@ function applyEdit({ source, edit, quote, extensions }) {
1212
1218
  if (kind === "adapter") return { reason: `${edit.plugin} is not the config's adapter` };
1213
1219
  return { reason: kind === "plugin" && disabledBlocks(source, config).some((block) => block.packageName === edit.plugin) ? `${edit.plugin} is disabled, enable it first` : `${edit.plugin} is not in the ${kind}s array` };
1214
1220
  }
1215
- return edit.operation === "set" ? applySet(source, call, edit.path, edit.value, quote) : applyRemove(source, call, edit.path);
1221
+ return edit.operation === require_protocol.ConfigEditOperation.SET ? applySet(source, call, edit.path, edit.value, quote) : applyRemove(source, call, edit.path);
1216
1222
  }
1217
1223
  /**
1218
1224
  * Applies edits to a `kubb.config.ts` in place. Each edit rewrites only the text it targets, so
@@ -2041,6 +2047,43 @@ var IncompatibleAgentError = class extends Error {
2041
2047
  this.name = "IncompatibleAgentError";
2042
2048
  }
2043
2049
  };
2050
+ const INSTALL_TIMEOUT_MS = 3e5;
2051
+ /** `owner/slug` or `slug`, as `kubb add` takes it. Checked before it becomes an argument. */
2052
+ const REGISTRY_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)?$/;
2053
+ /** The CLI the project depends on, so `kubb add` installs next to what the config imports. */
2054
+ function findKubbBin(root) {
2055
+ const require$1 = (0, node_module.createRequire)(node_path.default.resolve(root, "package.json"));
2056
+ const names = ["kubb", "@kubb/cli"];
2057
+ return (require$1.resolve.paths("kubb") ?? []).flatMap((dir) => names.map((name) => node_path.default.join(dir, name, "bin", "kubb.js"))).find((bin) => (0, node_fs.existsSync)(bin));
2058
+ }
2059
+ /** Runs a `kubb` command without a shell, and keeps the end of its output for the failure reason. */
2060
+ async function runKubb({ bin, args, cwd, signal }) {
2061
+ try {
2062
+ const { stdout, stderr, exitCode } = await (0, tinyexec.x)(node_process.default.execPath, [bin, ...args], {
2063
+ signal,
2064
+ timeout: INSTALL_TIMEOUT_MS,
2065
+ throwOnError: false,
2066
+ nodeOptions: {
2067
+ cwd,
2068
+ env: {
2069
+ ...node_process.default.env,
2070
+ NO_COLOR: "1",
2071
+ FORCE_COLOR: "0"
2072
+ }
2073
+ }
2074
+ });
2075
+ const output = `${stdout}\n${stderr}`.trim().split("\n").slice(-20).join("\n");
2076
+ return {
2077
+ failed: exitCode !== 0,
2078
+ output: output || `kubb ${args[0]} exited with ${exitCode ?? "a signal"}`
2079
+ };
2080
+ } catch (error) {
2081
+ return {
2082
+ failed: true,
2083
+ output: `kubb ${args[0]} did not run: ${getErrorMessage(error)}`
2084
+ };
2085
+ }
2086
+ }
2044
2087
  function emit(hooks, name, ...args) {
2045
2088
  return Promise.resolve(hooks.callHook(name, ...args)).catch(() => {});
2046
2089
  }
@@ -2111,6 +2154,9 @@ var Agent = class extends capnweb.RpcTarget {
2111
2154
  ...permissions,
2112
2155
  allowWrite: false,
2113
2156
  allowConfigEdit: false,
2157
+ allowInstall: false,
2158
+ allowUpdate: false,
2159
+ allowSaveInput: false,
2114
2160
  allowRead: true,
2115
2161
  allowReadAst: true
2116
2162
  } : permissions;
@@ -2136,13 +2182,13 @@ var Agent = class extends capnweb.RpcTarget {
2136
2182
  permission
2137
2183
  });
2138
2184
  }
2139
- async #refuse({ message, permission }) {
2185
+ async #refuse({ message, permission, code = _kubb_core.Diagnostics.code.unknown }) {
2140
2186
  await this.#warn({
2141
2187
  message,
2142
2188
  permission
2143
2189
  });
2144
2190
  throw new _kubb_core.Diagnostics.Error({
2145
- code: _kubb_core.Diagnostics.code.unknown,
2191
+ code,
2146
2192
  severity: "error",
2147
2193
  message
2148
2194
  });
@@ -2262,6 +2308,8 @@ var Agent = class extends capnweb.RpcTarget {
2262
2308
  message: "Connection is closed"
2263
2309
  });
2264
2310
  if (this.#session.active) return this.#refuse({ message: require_protocol.AgentMessage.GENERATION_IN_PROGRESS });
2311
+ if (this.#session.install) return this.#refuse({ message: require_protocol.AgentMessage.INSTALL_IN_PROGRESS });
2312
+ if (this.#session.saving) return this.#refuse({ message: require_protocol.AgentMessage.SAVE_IN_PROGRESS });
2265
2313
  const settled = Promise.withResolvers();
2266
2314
  this.#session.active = {
2267
2315
  jobId: input.jobId,
@@ -2332,7 +2380,13 @@ var Agent = class extends capnweb.RpcTarget {
2332
2380
  });
2333
2381
  const activePlugins = plugins ?? config.plugins;
2334
2382
  if (patch?.input && !this.#sandbox && !this.#managed) await emit(this.#options.hooks, "kubb:warn", { message: "Ignored the spec from Studio: generating from a Studio spec is only available to a sandbox or managed agent" });
2335
- const checkout = this.#managed && studioInput !== void 0 && (0, _kubb_core.getInputKind)(studioInput) === "git" ? await (0, _kubb_core.checkoutGitInput)(studioInput, signal, { publicOnly: true }).catch((error) => this.#refuse({ message: getErrorMessage(error) })) : void 0;
2383
+ const checkout = this.#managed && studioInput !== void 0 && (0, _kubb_core.getInputKind)(studioInput) === "git" ? await (0, _kubb_core.checkoutGitInput)(studioInput, signal, {
2384
+ publicOnly: true,
2385
+ authorization: input.gitAuth?.authorization
2386
+ }).catch((error) => this.#refuse({
2387
+ message: getErrorMessage(error),
2388
+ code: _kubb_core.Diagnostics.code.gitInputFailed
2389
+ })) : void 0;
2336
2390
  const run = () => generate({
2337
2391
  config: {
2338
2392
  ...config,
@@ -2402,13 +2456,33 @@ var Agent = class extends capnweb.RpcTarget {
2402
2456
  await this.#warn({ message: "Ignored save: editing kubb.config.ts was not granted" });
2403
2457
  return refuse("the agent was not granted permission to edit kubb.config.ts");
2404
2458
  }
2405
- if (this.#session.active) return refuse("a generation is in progress");
2459
+ const busy = this.#busy();
2460
+ if (busy) return refuse(busy);
2461
+ this.#session.saving = true;
2406
2462
  try {
2407
- const { outcomes, changed } = await writeConfigEdits({
2463
+ const kept = [];
2464
+ for (const edit of edits) kept.push(await this.#keepInput(edit));
2465
+ const written = await writeConfigEdits({
2408
2466
  filePath: configFile,
2409
- edits,
2467
+ edits: kept.flatMap((edit) => "reason" in edit ? [] : [edit]),
2410
2468
  extensions: await this.#installedExtensions()
2411
2469
  });
2470
+ let next = 0;
2471
+ const outcomes = kept.map((edit, index) => "reason" in edit ? {
2472
+ edit: edits[index],
2473
+ applied: false,
2474
+ reason: edit.reason
2475
+ } : {
2476
+ ...written.outcomes[next++],
2477
+ edit: edits[index]
2478
+ });
2479
+ let wroteDocument = false;
2480
+ for (const [index, edit] of kept.entries()) {
2481
+ if (!("document" in edit) || !edit.document || !outcomes[index].applied) continue;
2482
+ await (0, node_fs_promises.writeFile)(node_path.default.join(this.#options.root, edit.document.fileName), edit.document.text, "utf-8");
2483
+ wroteDocument = true;
2484
+ }
2485
+ const changed = written.changed || wroteDocument;
2412
2486
  const applied = outcomes.filter((outcome) => outcome.applied).length;
2413
2487
  await this.#logCommand("saveConfig", `applied ${applied}/${outcomes.length} edits to ${configPath}`, input.requestedBy);
2414
2488
  return {
@@ -2418,6 +2492,137 @@ var Agent = class extends capnweb.RpcTarget {
2418
2492
  } catch (error) {
2419
2493
  await emit(this.#options.hooks, "studio:error", { error: toError(error) });
2420
2494
  return refuse(getErrorMessage(error));
2495
+ } finally {
2496
+ this.#session.saving = false;
2497
+ }
2498
+ }
2499
+ /**
2500
+ * Readies a `set-input` edit for the config file: a document is pointed at `openapi.yaml` (`openapi.json`
2501
+ * for JSON) in the project, one file per config entry, for `saveConfig` to write once the edit applies.
2502
+ * A managed agent refuses a file path, as in a generation.
2503
+ */
2504
+ async #keepInput(edit) {
2505
+ if (edit?.operation !== require_protocol.ConfigEditOperation.SET_INPUT || typeof edit.input !== "string" || !edit.input.trim()) return edit;
2506
+ if (!this.#permissions.allowSaveInput) {
2507
+ await this.#warn({
2508
+ message: "Ignored the spec to save: saving the input was not granted",
2509
+ permission: require_protocol.AgentPermission.SAVE_INPUT
2510
+ });
2511
+ return { reason: "the agent was not granted permission to save the input" };
2512
+ }
2513
+ const kind = (0, _kubb_core.getInputKind)(edit.input);
2514
+ if (this.#managed && !MANAGED_INPUT_KINDS.has(kind)) return { reason: require_protocol.AgentMessage.MANAGED_INPUT_KIND };
2515
+ if (kind !== "inline") return edit;
2516
+ const fileName = `openapi${edit.config === void 0 || edit.config === 0 ? "" : `-${String(edit.config).replace(/[^\w.-]/g, "-")}`}.${isJson(edit.input) ? "json" : "yaml"}`;
2517
+ return {
2518
+ ...edit,
2519
+ input: `./${fileName}`,
2520
+ document: {
2521
+ fileName,
2522
+ text: edit.input
2523
+ }
2524
+ };
2525
+ }
2526
+ /**
2527
+ * Installs a registry extension into the project with `kubb add <id> --yes`, which also adds a
2528
+ * plugin to `kubb.config.ts`. Studio calls `connect()` again afterwards to see the result.
2529
+ */
2530
+ async installExtension(input) {
2531
+ this.#assertConnected();
2532
+ const { root, configFile, kubbBin = findKubbBin(root) } = this.#options;
2533
+ const refuse = async (reason, permission) => {
2534
+ await this.#warn({
2535
+ message: `Ignored install: ${reason}`,
2536
+ permission
2537
+ });
2538
+ return {
2539
+ installed: false,
2540
+ reason
2541
+ };
2542
+ };
2543
+ if (typeof input?.id !== "string" || !REGISTRY_ID.test(input.id)) return refuse("the message carried no registry id");
2544
+ if (!this.#permissions.allowInstall) return refuse("installing extensions was not granted", require_protocol.AgentPermission.INSTALL);
2545
+ if (!this.#permissions.allowConfigEdit) return refuse("editing kubb.config.ts was not granted", require_protocol.AgentPermission.CONFIG_EDIT);
2546
+ const busy = this.#busy();
2547
+ if (busy) return refuse(busy);
2548
+ if (!kubbBin) return refuse("kubb is not installed in the project");
2549
+ const { failed, output } = await this.#runKubb({
2550
+ bin: kubbBin,
2551
+ args: [
2552
+ "add",
2553
+ input.id,
2554
+ "--yes",
2555
+ "--config",
2556
+ configFile
2557
+ ]
2558
+ });
2559
+ await this.#logCommand("installExtension", `${failed ? "failed to install" : "installed"} ${input.id}`, input.requestedBy, failed);
2560
+ return failed ? {
2561
+ installed: false,
2562
+ reason: output
2563
+ } : { installed: true };
2564
+ }
2565
+ /**
2566
+ * Moves installed extensions to the registry's version with `kubb update [ids] --yes`, which
2567
+ * installs packages and leaves `kubb.config.ts` alone. Studio calls `connect()` again afterwards.
2568
+ */
2569
+ async updateExtensions(input) {
2570
+ this.#assertConnected();
2571
+ const { root, kubbBin = findKubbBin(root) } = this.#options;
2572
+ const refuse = async (reason, permission) => {
2573
+ await this.#warn({
2574
+ message: `Ignored update: ${reason}`,
2575
+ permission
2576
+ });
2577
+ return {
2578
+ updated: false,
2579
+ reason
2580
+ };
2581
+ };
2582
+ const ids = input?.ids ?? [];
2583
+ if (!Array.isArray(ids) || !ids.every((id) => typeof id === "string" && REGISTRY_ID.test(id))) return refuse("the message carried an invalid registry id");
2584
+ if (!this.#permissions.allowUpdate) return refuse("updating extensions was not granted", require_protocol.AgentPermission.UPDATE);
2585
+ const busy = this.#busy();
2586
+ if (busy) return refuse(busy);
2587
+ if (!kubbBin) return refuse("kubb is not installed in the project");
2588
+ const { failed, output } = await this.#runKubb({
2589
+ bin: kubbBin,
2590
+ args: [
2591
+ "update",
2592
+ ...ids,
2593
+ "--yes"
2594
+ ]
2595
+ });
2596
+ await this.#logCommand("updateExtensions", `${failed ? "failed to update" : "updated"} ${ids.join(", ") || "all extensions"}`, input?.requestedBy, failed);
2597
+ return failed ? {
2598
+ updated: false,
2599
+ reason: output
2600
+ } : { updated: true };
2601
+ }
2602
+ /** Why a command that touches the project cannot start right now. */
2603
+ #busy() {
2604
+ if (this.#session.active) return "a generation is in progress";
2605
+ if (this.#session.install) return "an install or update is in progress";
2606
+ if (this.#session.saving) return "a save is in progress";
2607
+ }
2608
+ /** Runs `kubb` in the project while holding off saves and generations, and ends it when the socket closes. */
2609
+ async #runKubb({ bin, args }) {
2610
+ const controller = new AbortController();
2611
+ const settled = Promise.withResolvers();
2612
+ this.#session.install = {
2613
+ abort: () => controller.abort(),
2614
+ settled: settled.promise
2615
+ };
2616
+ try {
2617
+ return await runKubb({
2618
+ bin,
2619
+ args,
2620
+ cwd: this.#options.root,
2621
+ signal: controller.signal
2622
+ });
2623
+ } finally {
2624
+ this.#session.install = void 0;
2625
+ settled.resolve();
2421
2626
  }
2422
2627
  }
2423
2628
  /**
@@ -2602,6 +2807,8 @@ async function runSession({ options, openRpc, url, instanceId }) {
2602
2807
  await emit(hooks, "studio:warn", { message: "Canceled the running generation: the connection to Kubb Studio closed" });
2603
2808
  }
2604
2809
  await active?.settled;
2810
+ session.install?.abort();
2811
+ await session.install?.settled;
2605
2812
  await agent.clear();
2606
2813
  return {
2607
2814
  ...close,
@@ -2617,6 +2824,15 @@ function pickConfig(configs, ref) {
2617
2824
  if (ref !== void 0 && !Number.isSafeInteger(ref)) return void 0;
2618
2825
  return configs[ref ?? 0];
2619
2826
  }
2827
+ /** Whether `text` parses as JSON, so a pasted document is saved under the matching extension. */
2828
+ function isJson(text) {
2829
+ try {
2830
+ JSON.parse(text);
2831
+ return true;
2832
+ } catch {
2833
+ return false;
2834
+ }
2835
+ }
2620
2836
  /** The input path or URL Studio may show: no inline document, and no credentials from a URL. */
2621
2837
  function reportedInput(input) {
2622
2838
  if (typeof input !== "string") return void 0;
@@ -2670,6 +2886,9 @@ async function runAgent({ options, openRpc }) {
2670
2886
  permissions: {
2671
2887
  allowWrite: false,
2672
2888
  allowConfigEdit: false,
2889
+ allowInstall: false,
2890
+ allowUpdate: false,
2891
+ allowSaveInput: false,
2673
2892
  allowExec: false,
2674
2893
  allowRead: false,
2675
2894
  allowReadAst: false,