@kubb/studio 5.12.2 → 5.12.4

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
@@ -24,6 +24,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  const require_protocol = require("./protocol.cjs");
25
25
  let node_crypto = require("node:crypto");
26
26
  let node_fs = require("node:fs");
27
+ let node_fs_promises = require("node:fs/promises");
27
28
  let node_module = require("node:module");
28
29
  let node_os = require("node:os");
29
30
  let node_path = require("node:path");
@@ -32,7 +33,6 @@ let node_process = require("node:process");
32
33
  node_process = __toESM(node_process, 1);
33
34
  let node_timers_promises = require("node:timers/promises");
34
35
  let tinyexec = require("tinyexec");
35
- let node_fs_promises = require("node:fs/promises");
36
36
  let _kubb_core = require("@kubb/core");
37
37
  let capnweb = require("capnweb");
38
38
  let magicast = require("magicast");
@@ -230,7 +230,7 @@ function getElapsedMs(hrStart) {
230
230
  }
231
231
  //#endregion
232
232
  //#region package.json
233
- var version = "5.12.2";
233
+ var version = "5.12.4";
234
234
  //#endregion
235
235
  //#region src/resolveConfig.ts
236
236
  /**
@@ -586,6 +586,44 @@ async function rebuildExtension({ disk, studioOptions, kind, extensions }) {
586
586
  });
587
587
  }))(mergeOptions(disk.options ?? {}, studioOptions));
588
588
  }
589
+ /** The root `output` members Studio may set, with the values each takes. The rest, `path` and `postGenerate` among them, stay with the config file. */
590
+ const OUTPUT_VALUES = {
591
+ barrel: (value) => value === false || isPlainObject(value) && Object.keys(value).length === 1 && (value.type === "named" || value.type === "all"),
592
+ format: (value) => value === false || [
593
+ "auto",
594
+ "prettier",
595
+ "biome",
596
+ "oxfmt"
597
+ ].includes(value),
598
+ lint: (value) => value === false || [
599
+ "auto",
600
+ "eslint",
601
+ "biome",
602
+ "oxlint"
603
+ ].includes(value),
604
+ clean: (value) => typeof value === "boolean",
605
+ defaultBanner: (value) => value === false || value === "simple" || value === "full"
606
+ };
607
+ /** Whether Studio may set the root `output` member `key`. */
608
+ function isOutputKey(key) {
609
+ return Object.hasOwn(OUTPUT_VALUES, key);
610
+ }
611
+ /** Whether Studio may set the root `output` member `key` to `value`. */
612
+ function isOutputValue({ key, value }) {
613
+ return isOutputKey(key) && OUTPUT_VALUES[key](value);
614
+ }
615
+ /** The members of `output` Studio may see and set. */
616
+ function pickOutput(output) {
617
+ return Object.fromEntries(Object.entries(output).filter(([key]) => isOutputKey(key)));
618
+ }
619
+ /** Merges Studio's valid `output` overrides, with `null` unsetting a member. */
620
+ function mergeOutput({ output, studio }) {
621
+ if (!isPlainObject(studio)) return output;
622
+ return mergeOptions(output, Object.fromEntries(Object.entries(studio).filter(([key, value]) => value === null ? isOutputKey(key) : isOutputValue({
623
+ key,
624
+ value
625
+ }))));
626
+ }
589
627
  /**
590
628
  * Merges Studio-provided adapter option overrides into the disk config's adapter, see {@link rebuildExtension}.
591
629
  */
@@ -628,13 +666,7 @@ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
628
666
  * line count, so `enable-plugin` restores exactly those lines and not whatever comments follow.
629
667
  */
630
668
  const DISABLED_MARKER = /^\/\/ kubb:disabled (.+)\s+(\d+)$/;
631
- const OPERATIONS = /* @__PURE__ */ new Set([
632
- "set",
633
- "remove",
634
- "add-plugin",
635
- "disable-plugin",
636
- "enable-plugin"
637
- ]);
669
+ const OPERATIONS = new Set(Object.values(require_protocol.ConfigEditOperation));
638
670
  const KINDS = /* @__PURE__ */ new Set([
639
671
  "plugin",
640
672
  "adapter",
@@ -645,17 +677,20 @@ const KINDS = /* @__PURE__ */ new Set([
645
677
  * The edit crossed the wire, so its declared type is a promise the sender may not have kept.
646
678
  */
647
679
  function invalidEdit(edit) {
648
- const { operation, plugin, config, path, kind } = typeof edit === "object" && edit !== null ? edit : {};
680
+ const { operation, plugin, config, path, kind, input } = typeof edit === "object" && edit !== null ? edit : {};
649
681
  if (typeof operation !== "string" || !OPERATIONS.has(operation)) return `unknown operation ${JSON.stringify(operation)}`;
650
- if (typeof plugin !== "string" || !plugin) return "the edit names no plugin";
651
- if (kind !== void 0 && (typeof kind !== "string" || !KINDS.has(kind))) return `unknown kind ${JSON.stringify(kind)}`;
652
- if (kind !== void 0 && operation !== "set" && operation !== "remove") return `${operation} edits the plugins array, so it takes no kind`;
653
682
  if (config !== void 0 && typeof config !== "string" && !Number.isSafeInteger(config)) return "config must name an entry or give its index";
654
- if (operation === "set" || operation === "remove") {
683
+ if (operation === require_protocol.ConfigEditOperation.SET_INPUT) return typeof input === "string" && input.trim() ? void 0 : "the edit names no input";
684
+ const isOutput = operation === require_protocol.ConfigEditOperation.SET_OUTPUT || operation === require_protocol.ConfigEditOperation.REMOVE_OUTPUT;
685
+ if (!isOutput && (typeof plugin !== "string" || !plugin)) return "the edit names no plugin";
686
+ if (kind !== void 0 && (typeof kind !== "string" || !KINDS.has(kind))) return `unknown kind ${JSON.stringify(kind)}`;
687
+ 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`;
688
+ if (isOutput || operation === require_protocol.ConfigEditOperation.SET || operation === require_protocol.ConfigEditOperation.REMOVE) {
655
689
  if (!Array.isArray(path) || !path.every((key) => typeof key === "string" && key)) return "path must be a list of option names";
656
690
  if (!path.length) return "no option path given";
657
691
  const unsafe = path.find((key) => !isSafeKey(key));
658
692
  if (unsafe !== void 0) return `${unsafe} cannot be used as an option name`;
693
+ if (isOutput && !isOutputKey(path[0])) return `output.${path[0]} can only be changed in the config file`;
659
694
  }
660
695
  }
661
696
  /** Where a node sits in the source text. */
@@ -956,9 +991,12 @@ function readPartial(node, prefix = []) {
956
991
  }
957
992
  /** The top-level options of a plugin call, each flagged with whether Studio may write it. */
958
993
  function readOptions(call) {
959
- const entries = {};
960
994
  const options = call.arguments[0];
961
- if (options?.type !== "ObjectExpression") return entries;
995
+ return options?.type === "ObjectExpression" ? readObject(options) : {};
996
+ }
997
+ /** The members of an object literal, each flagged with whether Studio may write it. */
998
+ function readObject(options) {
999
+ const entries = {};
962
1000
  for (const [index, entry] of options.properties.entries()) {
963
1001
  const key = memberKey(entry);
964
1002
  if (key === void 0 || !isSafeKey(key)) continue;
@@ -1031,11 +1069,13 @@ function readConfig(source) {
1031
1069
  });
1032
1070
  const [adapter] = factoryCalls(mod, config, "adapter").map(toView);
1033
1071
  const parsers = factoryCalls(mod, config, "parser").map(toView);
1072
+ const output = outputObject(config);
1034
1073
  return {
1035
1074
  name: configName(config),
1036
1075
  plugins,
1037
1076
  ...adapter && { adapter },
1038
- ...parsers.length ? { parsers } : {}
1077
+ ...parsers.length ? { parsers } : {},
1078
+ ...!("reason" in output) && { output: pickOutput(readObject(output.object)) }
1039
1079
  };
1040
1080
  })
1041
1081
  };
@@ -1084,6 +1124,16 @@ function applySet(source, call, path, value, quote) {
1084
1124
  return { source: splice(source, closing, closing, printValue(nest(path, value), quote)) };
1085
1125
  }
1086
1126
  if (options.type !== "ObjectExpression") return { reason: "the factory was not called with an object literal" };
1127
+ return setIn({
1128
+ source,
1129
+ options,
1130
+ path,
1131
+ value,
1132
+ quote
1133
+ });
1134
+ }
1135
+ /** Writes the literal `value` at `path` inside `options`, adding any intermediate object it needs. */
1136
+ function setIn({ source, options, path, value, quote }) {
1087
1137
  const target = walk(options, path);
1088
1138
  if ("reason" in target) return target;
1089
1139
  if ("missing" in target) {
@@ -1092,6 +1142,43 @@ function applySet(source, call, path, value, quote) {
1092
1142
  }
1093
1143
  return { source: splice(source, rangeOf(target.value).start, rangeOf(target.value).end, printValue(value, quote)) };
1094
1144
  }
1145
+ /** Writes the config's top-level `input` as a string literal, replacing what it held. */
1146
+ function applySetInput(source, config, input, quote) {
1147
+ const value = printValue(input, quote);
1148
+ const found = findMember(config, "input");
1149
+ if (!found) return { source: insertItem(source, config, `input: ${value}`) };
1150
+ if (found.member.type !== "ObjectProperty") return { reason: "input is not a plain property" };
1151
+ if (isShadowed(config, found.index)) return { reason: "a spread after input could override it" };
1152
+ return { source: splice(source, rangeOf(found.member.value).start, rangeOf(found.member.value).end, value) };
1153
+ }
1154
+ /** The config's root `output` object literal, or why Studio cannot edit inside it. */
1155
+ function outputObject(config) {
1156
+ const found = findMember(config, "output");
1157
+ if (found?.member.type !== "ObjectProperty" || found.member.value.type !== "ObjectExpression") return { reason: "output is not an object literal" };
1158
+ if (isShadowed(config, found.index)) return { reason: "a spread after output could override it" };
1159
+ return { object: found.member.value };
1160
+ }
1161
+ /** Writes or drops a member of the config's root `output` object. */
1162
+ function applyOutputEdit({ source, config, edit, quote }) {
1163
+ const output = outputObject(config);
1164
+ if ("reason" in output) return output;
1165
+ if (edit.operation === require_protocol.ConfigEditOperation.REMOVE_OUTPUT) return removeIn({
1166
+ source,
1167
+ options: output.object,
1168
+ path: edit.path
1169
+ });
1170
+ if (!isOptionValue(edit.value) || !isOutputValue({
1171
+ key: edit.path[0],
1172
+ value: nest(edit.path.slice(1), edit.value)
1173
+ })) return { reason: `the value is not one output.${edit.path[0]} takes` };
1174
+ return setIn({
1175
+ source,
1176
+ options: output.object,
1177
+ path: edit.path,
1178
+ value: edit.value,
1179
+ quote
1180
+ });
1181
+ }
1095
1182
  /**
1096
1183
  * Deletes the option at `path`, falling the plugin back to its default for it. An option the file
1097
1184
  * does not set already has its default, so removing it changes nothing.
@@ -1100,6 +1187,14 @@ function applyRemove(source, call, path) {
1100
1187
  const options = call.arguments[0];
1101
1188
  if (!options) return { source };
1102
1189
  if (options.type !== "ObjectExpression") return { reason: "the factory was not called with an object literal" };
1190
+ return removeIn({
1191
+ source,
1192
+ options,
1193
+ path
1194
+ });
1195
+ }
1196
+ /** Deletes the member at `path` inside `options`. A path that is not there already holds its default. */
1197
+ function removeIn({ source, options, path }) {
1103
1198
  const target = walk(options, path);
1104
1199
  if ("reason" in target) return target;
1105
1200
  if ("missing" in target) return { source };
@@ -1188,7 +1283,14 @@ function applyEdit({ source, edit, quote, extensions }) {
1188
1283
  const findConfig = (entries) => typeof ref === "string" ? entries.configs.find((entry) => configName(entry) === ref) : entries.configs[ref ?? 0];
1189
1284
  const config = findConfig(parsed);
1190
1285
  if (!config) return { reason: `no config entry found for ${JSON.stringify(ref)}` };
1191
- if (edit.operation === "disable-plugin") {
1286
+ if (edit.operation === require_protocol.ConfigEditOperation.SET_INPUT) return applySetInput(source, config, edit.input, quote);
1287
+ if (edit.operation === require_protocol.ConfigEditOperation.SET_OUTPUT || edit.operation === require_protocol.ConfigEditOperation.REMOVE_OUTPUT) return applyOutputEdit({
1288
+ source,
1289
+ config,
1290
+ edit,
1291
+ quote
1292
+ });
1293
+ if (edit.operation === require_protocol.ConfigEditOperation.DISABLE_PLUGIN) {
1192
1294
  const result = disablePlugin(source, parsed.mod, config, edit.plugin);
1193
1295
  if (!("reason" in result) || !result.reason.includes("shares a line")) return result;
1194
1296
  const expanded = expandPluginsArray(source, config);
@@ -1196,8 +1298,8 @@ function applyEdit({ source, edit, quote, extensions }) {
1196
1298
  const reparsedConfig = reparsed && !("reason" in reparsed) ? findConfig(reparsed) : void 0;
1197
1299
  return expanded !== void 0 && reparsed && !("reason" in reparsed) && reparsedConfig ? disablePlugin(expanded, reparsed.mod, reparsedConfig, edit.plugin) : result;
1198
1300
  }
1199
- if (edit.operation === "enable-plugin") return enablePlugin(source, config, edit.plugin);
1200
- if (edit.operation === "add-plugin") return applyAddPlugin({
1301
+ if (edit.operation === require_protocol.ConfigEditOperation.ENABLE_PLUGIN) return enablePlugin(source, config, edit.plugin);
1302
+ if (edit.operation === require_protocol.ConfigEditOperation.ADD_PLUGIN) return applyAddPlugin({
1201
1303
  source,
1202
1304
  parsed,
1203
1305
  config,
@@ -1213,7 +1315,7 @@ function applyEdit({ source, edit, quote, extensions }) {
1213
1315
  if (kind === "adapter") return { reason: `${edit.plugin} is not the config's adapter` };
1214
1316
  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` };
1215
1317
  }
1216
- return edit.operation === "set" ? applySet(source, call, edit.path, edit.value, quote) : applyRemove(source, call, edit.path);
1318
+ return edit.operation === require_protocol.ConfigEditOperation.SET ? applySet(source, call, edit.path, edit.value, quote) : applyRemove(source, call, edit.path);
1217
1319
  }
1218
1320
  /**
1219
1321
  * Applies edits to a `kubb.config.ts` in place. Each edit rewrites only the text it targets, so
@@ -2151,6 +2253,7 @@ var Agent = class extends capnweb.RpcTarget {
2151
2253
  allowConfigEdit: false,
2152
2254
  allowInstall: false,
2153
2255
  allowUpdate: false,
2256
+ allowSaveInput: false,
2154
2257
  allowRead: true,
2155
2258
  allowReadAst: true
2156
2259
  } : permissions;
@@ -2176,13 +2279,13 @@ var Agent = class extends capnweb.RpcTarget {
2176
2279
  permission
2177
2280
  });
2178
2281
  }
2179
- async #refuse({ message, permission }) {
2282
+ async #refuse({ message, permission, code = _kubb_core.Diagnostics.code.unknown }) {
2180
2283
  await this.#warn({
2181
2284
  message,
2182
2285
  permission
2183
2286
  });
2184
2287
  throw new _kubb_core.Diagnostics.Error({
2185
- code: _kubb_core.Diagnostics.code.unknown,
2288
+ code,
2186
2289
  severity: "error",
2187
2290
  message
2188
2291
  });
@@ -2374,12 +2477,22 @@ var Agent = class extends capnweb.RpcTarget {
2374
2477
  });
2375
2478
  const activePlugins = plugins ?? config.plugins;
2376
2479
  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" });
2377
- 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;
2480
+ const checkout = this.#managed && studioInput !== void 0 && (0, _kubb_core.getInputKind)(studioInput) === "git" ? await (0, _kubb_core.checkoutGitInput)(studioInput, signal, {
2481
+ publicOnly: true,
2482
+ authorization: input.gitAuth?.authorization
2483
+ }).catch((error) => this.#refuse({
2484
+ message: getErrorMessage(error),
2485
+ code: _kubb_core.Diagnostics.code.gitInputFailed
2486
+ })) : void 0;
2378
2487
  const run = () => generate({
2379
2488
  config: {
2380
2489
  ...config,
2381
2490
  root,
2382
2491
  input: checkout?.path ?? studioInput ?? config.input,
2492
+ output: mergeOutput({
2493
+ output: config.output,
2494
+ studio: patch?.output
2495
+ }),
2383
2496
  storage: outputRoot ? stagingStorage({
2384
2497
  root,
2385
2498
  directory: outputRoot
@@ -2448,11 +2561,29 @@ var Agent = class extends capnweb.RpcTarget {
2448
2561
  if (busy) return refuse(busy);
2449
2562
  this.#session.saving = true;
2450
2563
  try {
2451
- const { outcomes, changed } = await writeConfigEdits({
2564
+ const kept = [];
2565
+ for (const edit of edits) kept.push(await this.#keepInput(edit));
2566
+ const written = await writeConfigEdits({
2452
2567
  filePath: configFile,
2453
- edits,
2568
+ edits: kept.flatMap((edit) => "reason" in edit ? [] : [edit]),
2454
2569
  extensions: await this.#installedExtensions()
2455
2570
  });
2571
+ let next = 0;
2572
+ const outcomes = kept.map((edit, index) => "reason" in edit ? {
2573
+ edit: edits[index],
2574
+ applied: false,
2575
+ reason: edit.reason
2576
+ } : {
2577
+ ...written.outcomes[next++],
2578
+ edit: edits[index]
2579
+ });
2580
+ let wroteDocument = false;
2581
+ for (const [index, edit] of kept.entries()) {
2582
+ if (!("document" in edit) || !edit.document || !outcomes[index].applied) continue;
2583
+ await (0, node_fs_promises.writeFile)(node_path.default.join(this.#options.root, edit.document.fileName), edit.document.text, "utf-8");
2584
+ wroteDocument = true;
2585
+ }
2586
+ const changed = written.changed || wroteDocument;
2456
2587
  const applied = outcomes.filter((outcome) => outcome.applied).length;
2457
2588
  await this.#logCommand("saveConfig", `applied ${applied}/${outcomes.length} edits to ${configPath}`, input.requestedBy);
2458
2589
  return {
@@ -2467,6 +2598,33 @@ var Agent = class extends capnweb.RpcTarget {
2467
2598
  }
2468
2599
  }
2469
2600
  /**
2601
+ * Readies a `set-input` edit for the config file: a document is pointed at `openapi.yaml` (`openapi.json`
2602
+ * for JSON) in the project, one file per config entry, for `saveConfig` to write once the edit applies.
2603
+ * A managed agent refuses a file path, as in a generation.
2604
+ */
2605
+ async #keepInput(edit) {
2606
+ if (edit?.operation !== require_protocol.ConfigEditOperation.SET_INPUT || typeof edit.input !== "string" || !edit.input.trim()) return edit;
2607
+ if (!this.#permissions.allowSaveInput) {
2608
+ await this.#warn({
2609
+ message: "Ignored the spec to save: saving the input was not granted",
2610
+ permission: require_protocol.AgentPermission.SAVE_INPUT
2611
+ });
2612
+ return { reason: "the agent was not granted permission to save the input" };
2613
+ }
2614
+ const kind = (0, _kubb_core.getInputKind)(edit.input);
2615
+ if (this.#managed && !MANAGED_INPUT_KINDS.has(kind)) return { reason: require_protocol.AgentMessage.MANAGED_INPUT_KIND };
2616
+ if (kind !== "inline") return edit;
2617
+ const fileName = `openapi${edit.config === void 0 || edit.config === 0 ? "" : `-${String(edit.config).replace(/[^\w.-]/g, "-")}`}.${isJson(edit.input) ? "json" : "yaml"}`;
2618
+ return {
2619
+ ...edit,
2620
+ input: `./${fileName}`,
2621
+ document: {
2622
+ fileName,
2623
+ text: edit.input
2624
+ }
2625
+ };
2626
+ }
2627
+ /**
2470
2628
  * Installs a registry extension into the project with `kubb add <id> --yes`, which also adds a
2471
2629
  * plugin to `kubb.config.ts`. Studio calls `connect()` again afterwards to see the result.
2472
2630
  */
@@ -2767,6 +2925,15 @@ function pickConfig(configs, ref) {
2767
2925
  if (ref !== void 0 && !Number.isSafeInteger(ref)) return void 0;
2768
2926
  return configs[ref ?? 0];
2769
2927
  }
2928
+ /** Whether `text` parses as JSON, so a pasted document is saved under the matching extension. */
2929
+ function isJson(text) {
2930
+ try {
2931
+ JSON.parse(text);
2932
+ return true;
2933
+ } catch {
2934
+ return false;
2935
+ }
2936
+ }
2770
2937
  /** The input path or URL Studio may show: no inline document, and no credentials from a URL. */
2771
2938
  function reportedInput(input) {
2772
2939
  if (typeof input !== "string") return void 0;
@@ -2785,6 +2952,7 @@ function toEntryView(config, extensions) {
2785
2952
  ...config.name !== void 0 && { name: config.name },
2786
2953
  ...input !== void 0 && { input },
2787
2954
  output: config.output.path,
2955
+ outputOptions: toJSONOptions(pickOutput(config.output)),
2788
2956
  plugins: config.plugins.map((plugin) => ({
2789
2957
  name: toStudioName({
2790
2958
  name: (0, _kubb_core.resolveMeta)(plugin).name,
@@ -2822,6 +2990,7 @@ async function runAgent({ options, openRpc }) {
2822
2990
  allowConfigEdit: false,
2823
2991
  allowInstall: false,
2824
2992
  allowUpdate: false,
2993
+ allowSaveInput: false,
2825
2994
  allowExec: false,
2826
2995
  allowRead: false,
2827
2996
  allowReadAst: false,