@kubb/studio 5.8.0 → 5.8.2

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
@@ -216,7 +216,7 @@ function getElapsedMs(hrStart) {
216
216
  }
217
217
  //#endregion
218
218
  //#region package.json
219
- var version = "5.8.0";
219
+ var version = "5.8.2";
220
220
  //#endregion
221
221
  //#region src/resolveConfig.ts
222
222
  /**
@@ -286,7 +286,7 @@ function isInstalled({ packageName, root }) {
286
286
  /**
287
287
  * The name Studio knows a plugin by: its `@kubb/plugin-*` package when one is installed, otherwise
288
288
  * the plugin's own `name`, so a plugin defined in the user's project is never reported as a Kubb
289
- * package that does not exist.
289
+ * package that does not exist. An installed extension whose manifest `name` matches wins.
290
290
  *
291
291
  * @example
292
292
  * ```ts
@@ -294,7 +294,9 @@ function isInstalled({ packageName, root }) {
294
294
  * toStudioName({ name: 'plugin-zod-operations', root }) // 'plugin-zod-operations', a local plugin
295
295
  * ```
296
296
  */
297
- function toStudioName({ name, root }) {
297
+ function toStudioName({ name, root, extensions = /* @__PURE__ */ new Map() }) {
298
+ const installed = [...extensions.values()].find(({ manifest }) => manifest.kind === "plugin" && manifest.name === name);
299
+ if (installed) return installed.package;
298
300
  const packageName = toPackageName(name);
299
301
  return packageName !== name && isInstalled({
300
302
  packageName,
@@ -313,59 +315,69 @@ function toStudioName({ name, root }) {
313
315
  function toExportName(packageName) {
314
316
  return camelCase(toPluginName(packageName));
315
317
  }
316
- /**
317
- * A `@kubb/plugin-*` package specifier. Nothing else may reach `import()`: only Kubb's own
318
- * plugins are supported, so a payload naming anything else, a third-party package or a path, is
319
- * refused before it can execute.
320
- */
321
318
  const KUBB_PLUGIN_SPECIFIER = /^@kubb\/plugin-[\w.-]+$/;
322
319
  /**
323
- * Whether `name` is a `@kubb/plugin-*` specifier. Exported so `configFile.ts` can refuse the same
324
- * shape before printing a Studio-supplied plugin name into the config file's source text.
320
+ * Whether Studio may import `packageName` as a plugin: a `@kubb/plugin-*` package, or an installed
321
+ * package whose `kubb-extension.json` has `kind: "plugin"`. A path, URL or arbitrary package is
322
+ * refused before it can execute. `configFile.ts` applies the same rule before printing a plugin name.
325
323
  */
326
- function isKubbPluginSpecifier(name) {
327
- return KUBB_PLUGIN_SPECIFIER.test(name);
324
+ function isAllowedPlugin({ packageName, extensions }) {
325
+ if (!(0, _kubb_core.isPackageName)(packageName)) return false;
326
+ return KUBB_PLUGIN_SPECIFIER.test(packageName) || extensions.get(packageName)?.manifest.kind === "plugin";
328
327
  }
329
328
  /**
330
- * Dynamically imports a `@kubb/plugin-*` package and returns its factory function.
329
+ * Dynamically imports a plugin package and returns its factory. Only packages
330
+ * {@link isAllowedPlugin} accepts reach `import()`.
331
331
  *
332
332
  * Packages must be pre-installed in the Docker image at build time via the `KUBB_PACKAGES`
333
333
  * build ARG, no runtime installation is possible in the distroless container.
334
334
  *
335
- * Resolution order: the camelCase named export the package name implies (e.g. `pluginTs`), then
336
- * the default export.
335
+ * Resolution order: the manifest's `export`, else the camelCase named export the package name
336
+ * implies (e.g. `pluginTs`), then the default export.
337
337
  *
338
- * @throws if the package cannot be imported or exports no callable factory.
338
+ * @throws if the package is not allowed, cannot be imported, or exports no callable factory.
339
339
  */
340
- async function loadPluginFactory(packageName) {
341
- if (!isKubbPluginSpecifier(packageName)) throw new Error(`Plugin "${packageName}" is not a @kubb/plugin-* package. Kubb Studio only supports Kubb's own plugins.`);
340
+ async function loadPluginFactory({ packageName, extensions }) {
341
+ if (!isAllowedPlugin({
342
+ packageName,
343
+ extensions
344
+ })) 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".`);
342
345
  let mod;
343
346
  try {
344
347
  mod = await importFromProject(packageName);
345
348
  } catch (cause) {
346
349
  throw new Error(`Plugin "${packageName}" could not be loaded. Make sure it is installed: \`npm install ${packageName}\``, { cause });
347
350
  }
351
+ const manifestExport = extensions.get(packageName)?.manifest.export;
352
+ if (manifestExport) {
353
+ if (typeof mod[manifestExport] === "function") return mod[manifestExport];
354
+ throw new Error(`Plugin "${packageName}" does not export "${manifestExport}", the factory its kubb-extension.json names.`);
355
+ }
348
356
  const exportName = toExportName(packageName);
349
357
  if (typeof mod[exportName] === "function") return mod[exportName];
350
358
  if (typeof mod["default"] === "function") return mod["default"];
351
359
  throw new Error(`Plugin "${packageName}" does not export a callable factory. Tried the named export "${exportName}" and "default".`);
352
360
  }
353
361
  /**
354
- * Resolves each plugin entry by dynamically importing the `@kubb/plugin-*` package and
355
- * calling its factory with the provided options.
356
- *
357
- * Packages must be pre-installed in the Docker image at build time, use the `KUBB_PACKAGES`
358
- * build ARG to control which ones are available at runtime.
362
+ * Resolves each plugin entry by dynamically importing its package and calling its factory with the
363
+ * provided options. See {@link loadPluginFactory} for which packages are allowed.
359
364
  *
360
365
  * @example
361
366
  * ```ts
362
- * { name: '@kubb/plugin-react-query', options: { output: { path: './hooks' } } }
363
- * { name: '@kubb/plugin-ts', options: { output: { path: './types' } } }
367
+ * const plugins = await resolvePlugins({
368
+ * plugins: [
369
+ * { name: '@kubb/plugin-react-query', options: { output: { path: './hooks' } } },
370
+ * { name: '@kubb/plugin-ts', options: { output: { path: './types' } } },
371
+ * ],
372
+ * })
364
373
  * ```
365
374
  */
366
- async function resolvePlugins(plugins) {
375
+ async function resolvePlugins({ plugins, extensions = /* @__PURE__ */ new Map() }) {
367
376
  return Promise.all(plugins.map(async ({ name, options }) => {
368
- return (await loadPluginFactory(name))(options ?? {});
377
+ return (await loadPluginFactory({
378
+ packageName: name,
379
+ extensions
380
+ }))(options ?? {});
369
381
  }));
370
382
  }
371
383
  /** The installed version of each plugin's package, plus the names that could not be resolved. */
@@ -483,14 +495,18 @@ function mergeOptions(disk, studio) {
483
495
  * For plugins present in both configs, the plugin is re-instantiated with merged options
484
496
  * so that all internal closures correctly reference the merged values.
485
497
  */
486
- async function mergePlugins(diskPlugins, studioPlugins) {
487
- const disabledNames = new Set((studioPlugins ?? []).filter((entry) => entry.disabled).map((entry) => toPluginName(entry.name)));
498
+ async function mergePlugins({ diskPlugins, studioPlugins, extensions = /* @__PURE__ */ new Map() }) {
499
+ const pluginNameOf = (packageName) => extensions.get(packageName)?.manifest.name ?? toPluginName(packageName);
500
+ const disabledNames = new Set((studioPlugins ?? []).filter((entry) => entry.disabled).map((entry) => pluginNameOf(entry.name)));
488
501
  const activeDiskPlugins = disabledNames.size ? diskPlugins?.filter((plugin) => !disabledNames.has((0, _kubb_core.resolveMeta)(plugin).name)) : diskPlugins;
489
502
  const activeStudioPlugins = studioPlugins?.filter((entry) => !entry.disabled);
490
503
  if (!activeDiskPlugins && !activeStudioPlugins?.length) return void 0;
491
504
  if (!activeStudioPlugins?.length) return activeDiskPlugins;
492
- if (!activeDiskPlugins) return resolvePlugins(activeStudioPlugins);
493
- const studioEntryByName = new Map(activeStudioPlugins.map((entry) => [toPluginName(entry.name), entry]));
505
+ if (!activeDiskPlugins) return resolvePlugins({
506
+ plugins: activeStudioPlugins,
507
+ extensions
508
+ });
509
+ const studioEntryByName = new Map(activeStudioPlugins.map((entry) => [pluginNameOf(entry.name), entry]));
494
510
  const diskNames = new Set(activeDiskPlugins.map((plugin) => plugin.name));
495
511
  const merged = await Promise.all(activeDiskPlugins.map(async (diskPlugin) => {
496
512
  const studioEntry = studioEntryByName.get((0, _kubb_core.resolveMeta)(diskPlugin).name);
@@ -498,17 +514,23 @@ async function mergePlugins(diskPlugins, studioPlugins) {
498
514
  const diskOptions = diskPlugin.options ?? {};
499
515
  const options = mergeOptions(diskOptions, studioEntry.options ?? {});
500
516
  const unchanged = JSON.stringify(toJSONOptions(options)) === JSON.stringify(toJSONOptions(diskOptions));
501
- const [resolved] = await resolvePlugins([{
502
- name: studioEntry.name,
503
- options
504
- }]).catch((cause) => {
517
+ const [resolved] = await resolvePlugins({
518
+ plugins: [{
519
+ name: studioEntry.name,
520
+ options
521
+ }],
522
+ extensions
523
+ }).catch((cause) => {
505
524
  if (unchanged) return [diskPlugin];
506
- throw new Error(`Plugin "${diskPlugin.name}" is not an installed @kubb/plugin-* package, so Studio cannot change its options. Edit it in the config file, or only enable or disable it from Studio.`, { cause });
525
+ throw new Error(`Plugin "${diskPlugin.name}" is not an installed @kubb/plugin-* package or Kubb extension, so Studio cannot change its options. Edit it in the config file, or only enable or disable it from Studio.`, { cause });
507
526
  });
508
527
  return resolved ?? diskPlugin;
509
528
  }));
510
- const studioOnly = activeStudioPlugins.filter((entry) => !diskNames.has(toPluginName(entry.name)));
511
- return [...merged, ...await resolvePlugins(studioOnly)];
529
+ const studioOnly = activeStudioPlugins.filter((entry) => !diskNames.has(pluginNameOf(entry.name)));
530
+ return [...merged, ...await resolvePlugins({
531
+ plugins: studioOnly,
532
+ extensions
533
+ })];
512
534
  }
513
535
  /**
514
536
  * Merges Studio-provided adapter option overrides into the disk config's adapter.
@@ -875,6 +897,14 @@ function readConfig(source) {
875
897
  })
876
898
  };
877
899
  }
900
+ /** The modules the config file imports, in source order. Empty when the file cannot be parsed. */
901
+ function readImports(source) {
902
+ try {
903
+ return [...new Set(importedFrom((0, magicast.parseModule)(source)).values())];
904
+ } catch {
905
+ return [];
906
+ }
907
+ }
878
908
  /**
879
909
  * Walks `path` through a plugin's options to the literal at its last key. Returns `missing` with
880
910
  * the index of the first key that is not there yet, so `set` can write the rest of the path as one
@@ -948,9 +978,12 @@ function insertImport(source, { mod, kind }, importName, moduleSpecifier) {
948
978
  * Adds a `pluginX(...)` call to a config's plugins array, and its import. Replaying the same add is
949
979
  * a no-op, while an import name collision with an unrelated package remains an error.
950
980
  */
951
- function applyAddPlugin(source, parsed, config, edit, quote) {
952
- if (!isKubbPluginSpecifier(edit.plugin)) return { reason: `"${edit.plugin}" is not a @kubb/plugin-* package` };
953
- const importName = edit.importName ?? toExportName(edit.plugin);
981
+ function applyAddPlugin({ source, parsed, config, edit, quote, extensions }) {
982
+ if (!isAllowedPlugin({
983
+ packageName: edit.plugin,
984
+ extensions
985
+ })) return { reason: `"${edit.plugin}" is not a @kubb/plugin-* package or an installed Kubb plugin` };
986
+ const importName = extensions.get(edit.plugin)?.manifest.export ?? edit.importName ?? toExportName(edit.plugin);
954
987
  if (!IDENTIFIER.test(importName)) return { reason: `"${importName}" is not a valid import name` };
955
988
  if (pluginCalls(parsed.mod, config).some((plugin) => plugin.packageName === edit.plugin)) return { source };
956
989
  const taken = importedFrom(parsed.mod).get(importName);
@@ -985,7 +1018,7 @@ function enablePlugin(source, config, plugin) {
985
1018
  return { source: lines.join("\n") };
986
1019
  }
987
1020
  /** Applies one edit to `source`. Every edit re-parses the file, since the previous one rewrote it. */
988
- function applyEdit(source, edit, quote) {
1021
+ function applyEdit({ source, edit, quote, extensions }) {
989
1022
  const parsed = parseConfigs(source);
990
1023
  if ("reason" in parsed) return parsed;
991
1024
  const ref = edit.config;
@@ -993,7 +1026,14 @@ function applyEdit(source, edit, quote) {
993
1026
  if (!config) return { reason: `no config entry found for ${JSON.stringify(ref)}` };
994
1027
  if (edit.operation === "disable-plugin") return disablePlugin(source, parsed.mod, config, edit.plugin);
995
1028
  if (edit.operation === "enable-plugin") return enablePlugin(source, config, edit.plugin);
996
- if (edit.operation === "add-plugin") return applyAddPlugin(source, parsed, config, edit, quote);
1029
+ if (edit.operation === "add-plugin") return applyAddPlugin({
1030
+ source,
1031
+ parsed,
1032
+ config,
1033
+ edit,
1034
+ quote,
1035
+ extensions
1036
+ });
997
1037
  const call = pluginCalls(parsed.mod, config).find((plugin) => plugin.packageName === edit.plugin)?.call;
998
1038
  if (!call) return { reason: disabledBlocks(source, config).some((block) => block.packageName === edit.plugin) ? `${edit.plugin} is disabled, enable it first` : `${edit.plugin} is not in the plugins array` };
999
1039
  return edit.operation === "set" ? applySet(source, call, edit.path, edit.value, quote) : applyRemove(source, call, edit.path);
@@ -1006,12 +1046,13 @@ function applyEdit(source, edit, quote) {
1006
1046
  *
1007
1047
  * @example
1008
1048
  * ```ts
1009
- * const { source, outcomes } = applyConfigEdits(current, [
1010
- * { operation: 'set', plugin: '@kubb/plugin-ts', path: ['enum', 'type'], value: 'enum' },
1011
- * ])
1049
+ * const { source, outcomes } = applyConfigEdits({
1050
+ * source: current,
1051
+ * edits: [{ operation: 'set', plugin: '@kubb/plugin-ts', path: ['enum', 'type'], value: 'enum' }],
1052
+ * })
1012
1053
  * ```
1013
1054
  */
1014
- function applyConfigEdits(source, edits) {
1055
+ function applyConfigEdits({ source, edits, extensions = /* @__PURE__ */ new Map() }) {
1015
1056
  let current = source;
1016
1057
  const quote = (0, magicast.detectCodeFormat)(source).quote === "double" ? `"` : `'`;
1017
1058
  const outcomes = edits.map((edit) => {
@@ -1022,7 +1063,12 @@ function applyConfigEdits(source, edits) {
1022
1063
  reason: invalid
1023
1064
  };
1024
1065
  try {
1025
- const result = applyEdit(current, edit, quote);
1066
+ const result = applyEdit({
1067
+ source: current,
1068
+ edit,
1069
+ quote,
1070
+ extensions
1071
+ });
1026
1072
  if ("reason" in result) return {
1027
1073
  edit,
1028
1074
  applied: false,
@@ -1047,13 +1093,46 @@ function applyConfigEdits(source, edits) {
1047
1093
  changed: current !== source
1048
1094
  };
1049
1095
  }
1050
- /** Applies `edits` to the config file at `filePath`, writing it only when something changed. */
1051
- async function writeConfigEdits({ filePath, edits }) {
1052
- const result = applyConfigEdits(await read(filePath), edits);
1096
+ /**
1097
+ * Applies `edits` to the config file at `filePath`, writing it only when something changed.
1098
+ * `add-plugin` accepts `@kubb/plugin-*` packages and the plugins in `extensions`.
1099
+ */
1100
+ async function writeConfigEdits({ filePath, edits, extensions }) {
1101
+ const result = applyConfigEdits({
1102
+ source: await read(filePath),
1103
+ edits,
1104
+ extensions
1105
+ });
1053
1106
  if (result.changed) await (0, node_fs_promises.writeFile)(filePath, result.source, "utf-8");
1054
1107
  return result;
1055
1108
  }
1056
1109
  //#endregion
1110
+ //#region src/extensions.ts
1111
+ /** Extensions `kubb` ships with, so they count as installed without being a direct dependency. */
1112
+ const BUILT_IN_PACKAGES = [
1113
+ "@kubb/adapter-oas",
1114
+ "@kubb/parser-ts",
1115
+ "@kubb/parser-md",
1116
+ "@kubb/plugin-barrel"
1117
+ ];
1118
+ /**
1119
+ * Finds the extensions installed in the project at `root`: its `dependencies` and `devDependencies`,
1120
+ * the `packages` its config file imports, and the extensions `kubb` ships with. Keeps each package
1121
+ * that resolves and ships a valid `kubb-extension.json`. Never throws.
1122
+ */
1123
+ async function discoverExtensions({ root, packages = [] }) {
1124
+ const installed = await (0, _kubb_core.readInstalledPackages)({
1125
+ root,
1126
+ packages: [...packages, ...BUILT_IN_PACKAGES],
1127
+ resolveFrom: ["kubb"]
1128
+ });
1129
+ return new Map(installed.flatMap(({ package: name, version, manifest }) => version !== void 0 && manifest ? [[name, {
1130
+ package: name,
1131
+ version,
1132
+ manifest
1133
+ }]] : []));
1134
+ }
1135
+ //#endregion
1057
1136
  //#region src/generations.ts
1058
1137
  const READ_CONCURRENCY = 50;
1059
1138
  const MB = 1048576;
@@ -1568,10 +1647,21 @@ var Agent = class extends capnweb.RpcTarget {
1568
1647
  return;
1569
1648
  }
1570
1649
  }
1650
+ async #installedExtensions() {
1651
+ const source = await read(this.#options.configFile).catch(() => "");
1652
+ return discoverExtensions({
1653
+ root: this.#options.root,
1654
+ packages: readImports(source)
1655
+ });
1656
+ }
1571
1657
  async connect(info) {
1572
1658
  this.#session.info = info;
1573
1659
  const { configPath, root, version: version$1, loadConfig } = this.#options;
1574
- const [config, file] = await Promise.all([loadConfig(), this.#readConfigFileView()]);
1660
+ const [config, file, extensions] = await Promise.all([
1661
+ loadConfig(),
1662
+ this.#readConfigFileView(),
1663
+ this.#installedExtensions()
1664
+ ]);
1575
1665
  this.#session.connected.resolve(info);
1576
1666
  return {
1577
1667
  versions: {
@@ -1585,10 +1675,12 @@ var Agent = class extends capnweb.RpcTarget {
1585
1675
  plugins: config.plugins.map((plugin) => ({
1586
1676
  name: toStudioName({
1587
1677
  name: (0, _kubb_core.resolveMeta)(plugin).name,
1588
- root
1678
+ root,
1679
+ extensions
1589
1680
  }),
1590
1681
  options: toJSONOptions(plugin.options)
1591
- }))
1682
+ })),
1683
+ extensions: [...extensions.values()]
1592
1684
  },
1593
1685
  permissions: this.#permissions
1594
1686
  };
@@ -1646,8 +1738,12 @@ var Agent = class extends capnweb.RpcTarget {
1646
1738
  const { jobId, config: patch } = input;
1647
1739
  const studioInput = this.#sandbox ? patch?.input : void 0;
1648
1740
  if (this.#sandbox && (typeof studioInput !== "string" || !studioInput.trim() || (0, _kubb_core.getInputKind)(studioInput) !== "inline")) return this.#refuse({ message: "A sandbox agent generates only from the OpenAPI document content Studio sends, not from a file path or URL" });
1649
- const config = await this.#options.loadConfig();
1650
- const [plugins, adapter] = await Promise.all([mergePlugins(config.plugins, patch?.plugins), mergeAdapter(config.adapter, patch?.adapter)]).catch(async (error) => {
1741
+ const [config, extensions] = await Promise.all([this.#options.loadConfig(), this.#installedExtensions()]);
1742
+ const [plugins, adapter] = await Promise.all([mergePlugins({
1743
+ diskPlugins: config.plugins,
1744
+ studioPlugins: patch?.plugins,
1745
+ extensions
1746
+ }), mergeAdapter(config.adapter, patch?.adapter)]).catch(async (error) => {
1651
1747
  await emit(this.#options.hooks, "kubb:error", { error: toError(error) });
1652
1748
  throw error;
1653
1749
  });
@@ -1735,7 +1831,8 @@ var Agent = class extends capnweb.RpcTarget {
1735
1831
  try {
1736
1832
  const { source, outcomes, changed } = await writeConfigEdits({
1737
1833
  filePath: configFile,
1738
- edits
1834
+ edits,
1835
+ extensions: await this.#installedExtensions()
1739
1836
  });
1740
1837
  const applied = outcomes.filter((outcome) => outcome.applied).length;
1741
1838
  return {
@@ -2269,5 +2366,6 @@ exports.connectAgent = connectAgent;
2269
2366
  exports.createSnapshot = createSnapshot;
2270
2367
  exports.defaultStudioUrl = defaultStudioUrl;
2271
2368
  exports.pairAgent = pairAgent;
2369
+ exports.writeConfigEdits = writeConfigEdits;
2272
2370
 
2273
2371
  //# sourceMappingURL=index.cjs.map