@kubb/studio 5.6.2 → 5.6.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
@@ -216,7 +216,7 @@ function getElapsedMs(hrStart) {
216
216
  }
217
217
  //#endregion
218
218
  //#region package.json
219
- var version = "5.6.2";
219
+ var version = "5.6.3";
220
220
  //#endregion
221
221
  //#region src/resolveConfig.ts
222
222
  /**
@@ -261,6 +261,46 @@ function toPluginName(packageName) {
261
261
  function toPackageName(name) {
262
262
  return name.startsWith("plugin-") ? `@kubb/${name}` : name;
263
263
  }
264
+ function resolvesFrom(base, packageName) {
265
+ try {
266
+ (0, node_module.createRequire)(base).resolve(`${packageName}/package.json`);
267
+ return true;
268
+ } catch {
269
+ return false;
270
+ }
271
+ }
272
+ /**
273
+ * Whether `packageName` resolves from the project at `root`, from this package, or from the `kubb`
274
+ * package the project installs, which brings the plugins Kubb adds on its own such as
275
+ * `@kubb/plugin-barrel`. Only resolves the package, it never runs it.
276
+ */
277
+ function isInstalled({ packageName, root }) {
278
+ const project = (0, node_url.pathToFileURL)(`${root}/`);
279
+ if (resolvesFrom(project, packageName) || resolvesFrom(require("url").pathToFileURL(__filename).href, packageName)) return true;
280
+ try {
281
+ return resolvesFrom((0, node_module.createRequire)(project).resolve("kubb/package.json"), packageName);
282
+ } catch {
283
+ return false;
284
+ }
285
+ }
286
+ /**
287
+ * The name Studio knows a plugin by: its `@kubb/plugin-*` package when one is installed, otherwise
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.
290
+ *
291
+ * @example
292
+ * ```ts
293
+ * toStudioName({ name: 'plugin-ts', root }) // '@kubb/plugin-ts'
294
+ * toStudioName({ name: 'plugin-zod-operations', root }) // 'plugin-zod-operations', a local plugin
295
+ * ```
296
+ */
297
+ function toStudioName({ name, root }) {
298
+ const packageName = toPackageName(name);
299
+ return packageName !== name && isInstalled({
300
+ packageName,
301
+ root
302
+ }) ? packageName : name;
303
+ }
264
304
  /**
265
305
  * Derives the conventional named export for a `@kubb/*` plugin package from its package name.
266
306
  *
@@ -356,6 +396,38 @@ async function resolvePeerDependencies(names) {
356
396
  };
357
397
  }
358
398
  /**
399
+ * Keys that reach an object's prototype when assigned. Never read from or written into options.
400
+ */
401
+ const UNSAFE_KEYS = /* @__PURE__ */ new Set([
402
+ "__proto__",
403
+ "constructor",
404
+ "prototype"
405
+ ]);
406
+ /**
407
+ * Whether `key` can be used as an option key without touching an object's prototype.
408
+ */
409
+ function isSafeKey(key) {
410
+ return !UNSAFE_KEYS.has(key);
411
+ }
412
+ /**
413
+ * A JSON copy of plugin or adapter options: functions and other values JSON cannot hold are left
414
+ * out, so nothing callable crosses the socket. Options that cannot be copied at all, such as a
415
+ * circular structure, come back empty.
416
+ *
417
+ * @example
418
+ * ```ts
419
+ * toJSONOptions({ output: { path: 'types' }, group: { name: () => 'x' } }) // { output: { path: 'types' }, group: {} }
420
+ * ```
421
+ */
422
+ function toJSONOptions(options) {
423
+ try {
424
+ const copy = JSON.parse(JSON.stringify(options ?? {}, (key, value) => isSafeKey(key) && typeof value !== "bigint" ? value : void 0));
425
+ return isPlainObject(copy) ? copy : {};
426
+ } catch {
427
+ return {};
428
+ }
429
+ }
430
+ /**
359
431
  * Whether `value` is an object literal or a null-prototype object, not an array or class instance.
360
432
  */
361
433
  function isPlainObject(value) {
@@ -372,7 +444,7 @@ function isOptionValue(value) {
372
444
  if (value === null || typeof value === "string" || typeof value === "boolean") return true;
373
445
  if (typeof value === "number") return Number.isFinite(value);
374
446
  if (Array.isArray(value)) return value.every(isOptionValue);
375
- if (isPlainObject(value)) return Object.values(value).every(isOptionValue);
447
+ if (isPlainObject(value)) return Object.entries(value).every(([key, entry]) => isSafeKey(key) && isOptionValue(entry));
376
448
  return false;
377
449
  }
378
450
  /**
@@ -390,6 +462,7 @@ function isOptionValue(value) {
390
462
  function mergeOptions(disk, studio) {
391
463
  const merged = { ...disk };
392
464
  for (const [key, value] of Object.entries(studio)) {
465
+ if (!isSafeKey(key)) continue;
393
466
  const current = disk[key];
394
467
  if (isPlainObject(current) && isPlainObject(value)) {
395
468
  merged[key] = mergeOptions(current, value);
@@ -422,11 +495,16 @@ async function mergePlugins(diskPlugins, studioPlugins) {
422
495
  const merged = await Promise.all(activeDiskPlugins.map(async (diskPlugin) => {
423
496
  const studioEntry = studioEntryByName.get(diskPlugin.name);
424
497
  if (!studioEntry) return diskPlugin;
425
- const options = mergeOptions(diskPlugin.options ?? {}, studioEntry.options ?? {});
498
+ const diskOptions = diskPlugin.options ?? {};
499
+ const options = mergeOptions(diskOptions, studioEntry.options ?? {});
500
+ const unchanged = JSON.stringify(toJSONOptions(options)) === JSON.stringify(toJSONOptions(diskOptions));
426
501
  const [resolved] = await resolvePlugins([{
427
502
  name: studioEntry.name,
428
503
  options
429
- }]);
504
+ }]).catch((cause) => {
505
+ 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 });
507
+ });
430
508
  return resolved ?? diskPlugin;
431
509
  }));
432
510
  const studioOnly = activeStudioPlugins.filter((entry) => !diskNames.has(toPluginName(entry.name)));
@@ -456,142 +534,130 @@ async function mergeAdapter(diskAdapter, studioOptions) {
456
534
  */
457
535
  const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
458
536
  /**
459
- * `key: value` as an object literal property, in the file's quote and key style.
460
- *
461
- * Uses magicast's literal builder for the key/value nodes, then wraps them as a Babel
462
- * `ObjectProperty`, the type the rest of this file reads.
463
- */
464
- function literalProperty({ key, value }) {
465
- const built = magicast.builders.literal({ [key]: value }).properties[0];
537
+ * The marker line a `disable-plugin` writes above the block it comments out. It carries the block's
538
+ * line count, so `enable-plugin` restores exactly those lines and not whatever comments follow.
539
+ */
540
+ const DISABLED_MARKER = /^\/\/ kubb:disabled (.+)\s+(\d+)$/;
541
+ const OPERATIONS = /* @__PURE__ */ new Set([
542
+ "set",
543
+ "remove",
544
+ "add-plugin",
545
+ "disable-plugin",
546
+ "enable-plugin"
547
+ ]);
548
+ /**
549
+ * Why an edit from Studio cannot be applied as sent, checked before anything reads its fields.
550
+ * The edit crossed the wire, so its declared type is a promise the sender may not have kept.
551
+ */
552
+ function invalidEdit(edit) {
553
+ const { operation, plugin, config, path, importName } = typeof edit === "object" && edit !== null ? edit : {};
554
+ if (typeof operation !== "string" || !OPERATIONS.has(operation)) return `unknown operation ${JSON.stringify(operation)}`;
555
+ if (typeof plugin !== "string" || !plugin) return "the edit names no plugin";
556
+ if (config !== void 0 && typeof config !== "string" && !Number.isSafeInteger(config)) return "config must name an entry or give its index";
557
+ if (operation === "set" || operation === "remove") {
558
+ if (!Array.isArray(path) || !path.every((key) => typeof key === "string" && key)) return "path must be a list of option names";
559
+ if (!path.length) return "no option path given";
560
+ const unsafe = path.find((key) => !isSafeKey(key));
561
+ if (unsafe !== void 0) return `${unsafe} cannot be used as an option name`;
562
+ }
563
+ if (operation === "add-plugin" && importName !== void 0 && typeof importName !== "string") return "importName must be a string";
564
+ }
565
+ /** Where a node sits in the source text. */
566
+ function rangeOf(node) {
567
+ if (typeof node.start !== "number" || typeof node.end !== "number") throw new Error("the config file could not be mapped back to its source text");
466
568
  return {
467
- type: "ObjectProperty",
468
- key: built.key,
469
- value: built.value,
470
- computed: false,
471
- shorthand: false
569
+ start: node.start,
570
+ end: node.end
472
571
  };
473
572
  }
474
- /**
475
- * Marks the comment block a `disable-plugin` leaves behind, so `enable-plugin` can find its way
476
- * back to the exact lines it commented out. Carries the block's line count, so `enable-plugin`
477
- * restores exactly those lines instead of scanning forward through whatever comments follow.
478
- */
479
- const DISABLED_MARKER = "kubb:disabled";
480
- /**
481
- * The plugin and comment-block length a marker line names, when `line` is one.
482
- */
483
- function parseMarker(line) {
484
- const trimmed = line.trim();
485
- if (!trimmed.startsWith(`// ${DISABLED_MARKER} `)) return;
486
- const match = trimmed.slice(`// ${DISABLED_MARKER} `.length).match(/^(.+)\s+(\d+)$/);
487
- return match ? {
488
- plugin: match[1],
489
- lineCount: Number(match[2])
490
- } : void 0;
573
+ /** `source` with the text between `start` and `end` replaced by `text`. */
574
+ function splice(source, start, end, text = "") {
575
+ return `${source.slice(0, start)}${text}${source.slice(end)}`;
576
+ }
577
+ function bodyOf(mod) {
578
+ return mod.$ast.type === "Program" ? mod.$ast.body : [];
491
579
  }
492
580
  /**
493
581
  * Steps through a config's wrappers to the object literal underneath: a `satisfies`/`as`
494
582
  * assertion, a `() => ...` factory, or a factory whose block body returns the config.
495
583
  */
496
584
  function unwrap(node) {
497
- if (!node) return;
585
+ if (!node) return void 0;
498
586
  if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression") return unwrap(node.expression);
499
587
  if (node.type !== "ArrowFunctionExpression" && node.type !== "FunctionExpression") return node;
500
588
  if (node.body.type !== "BlockStatement") return unwrap(node.body);
501
589
  return unwrap(node.body.body.find((statement) => statement.type === "ReturnStatement")?.argument);
502
590
  }
591
+ function isModuleExports(node) {
592
+ return node.type === "MemberExpression" && !node.computed && node.object.type === "Identifier" && node.object.name === "module" && node.property.type === "Identifier" && node.property.name === "exports";
593
+ }
503
594
  /**
504
- * Every config object in `export default defineConfig(...)`, or why the file is unmanaged.
505
- *
506
- * An array export gets one entry per element, matching {@link ConfigRef}'s numeric index.
595
+ * Parses `source` and finds every config object in `export default defineConfig(...)` or
596
+ * `module.exports = defineConfig(...)`, or why the file is unmanaged. An array export gets one entry
597
+ * per element, matching a numeric `config` index.
507
598
  *
508
599
  * Walks the parsed AST rather than magicast's proxies, which throw on node types they cannot
509
600
  * cast, most of what an unmanaged config file is made of.
510
601
  */
511
- function findConfigs(mod) {
512
- const exported = unwrap((mod.$ast.type === "Program" ? mod.$ast.body : []).find((node) => node.type === "ExportDefaultDeclaration")?.declaration);
513
- if (!exported) return { reason: "no default export found" };
514
- if (exported.type !== "CallExpression" || exported.callee.type !== "Identifier" || exported.callee.name !== "defineConfig") return { reason: "default export is not a defineConfig(...) call" };
515
- const argument = unwrap(exported.arguments[0]);
516
- if (!argument) return { reason: "defineConfig(...) was called without a config" };
517
- if (argument.type === "ArrayExpression") {
518
- const configs = [];
519
- for (const element of argument.elements) {
520
- const entry = unwrap(element);
521
- if (entry?.type !== "ObjectExpression") return { reason: "config is not an object literal" };
522
- configs.push(entry);
602
+ function parseConfigs(source) {
603
+ let mod;
604
+ try {
605
+ mod = (0, magicast.parseModule)(source);
606
+ } catch {
607
+ return { reason: "the config file could not be parsed" };
608
+ }
609
+ let exported;
610
+ let kind = "esm";
611
+ for (const node of bodyOf(mod)) {
612
+ if (node.type === "ExportDefaultDeclaration") {
613
+ exported = node.declaration;
614
+ break;
615
+ }
616
+ if (node.type === "ExpressionStatement" && node.expression.type === "AssignmentExpression" && isModuleExports(node.expression.left)) {
617
+ exported = node.expression.right;
618
+ kind = "cjs";
619
+ break;
523
620
  }
524
- return { configs };
525
621
  }
526
- if (argument.type !== "ObjectExpression") return { reason: "config is not an object literal" };
527
- return { configs: [argument] };
622
+ const value = unwrap(exported);
623
+ if (!value) return { reason: "no `export default` or `module.exports` found" };
624
+ if (value.type !== "CallExpression" || value.callee.type !== "Identifier" || value.callee.name !== "defineConfig") return { reason: "the exported config is not a defineConfig(...) call" };
625
+ const argument = unwrap(value.arguments[0]);
626
+ if (!argument) return { reason: "defineConfig(...) was called without a config" };
627
+ const entries = argument.type === "ArrayExpression" ? argument.elements.map((element) => unwrap(element)) : [argument];
628
+ const configs = entries.filter((entry) => entry?.type === "ObjectExpression");
629
+ if (configs.length !== entries.length) return { reason: "config is not an object literal" };
630
+ return {
631
+ mod,
632
+ configs,
633
+ kind
634
+ };
528
635
  }
529
- /**
530
- * The config entry an edit names, defaulting to the first when it names none.
531
- */
532
- function selectConfig(configs, ref) {
533
- if (ref === void 0) return configs[0];
534
- if (typeof ref === "number") return configs[ref];
535
- return configs.find((config) => configName(config) === ref);
636
+ /** The name of a `key` or `'key'` member, `undefined` for a spread or a computed key. */
637
+ function memberKey(entry) {
638
+ if (entry.type === "SpreadElement" || entry.computed) return void 0;
639
+ return entry.key.type === "Identifier" ? entry.key.name : entry.key.type === "StringLiteral" ? entry.key.value : void 0;
640
+ }
641
+ /** The member named `key`. The last one wins when the key repeats, as it does when the object is evaluated. */
642
+ function findMember(node, key) {
643
+ const index = node.properties.findLastIndex((entry) => memberKey(entry) === key);
644
+ return index === -1 ? void 0 : {
645
+ member: node.properties[index],
646
+ index
647
+ };
648
+ }
649
+ /** The value node of a member. A method or accessor is its own value: it is code, never a literal. */
650
+ function property(node, key) {
651
+ const member = findMember(node, key)?.member;
652
+ return member?.type === "ObjectProperty" ? member.value : member;
536
653
  }
537
654
  function configName(config) {
538
655
  const name = property(config, "name");
539
656
  return name?.type === "StringLiteral" ? name.value : void 0;
540
657
  }
541
- /**
542
- * The name of an object literal property, for the two key shapes a config uses: `key: value` and
543
- * `'key': value`. `undefined` for a computed key, which the patcher never touches.
544
- */
545
- function propertyKey(entry) {
546
- if (entry.key.type === "Identifier") return entry.key.name;
547
- if (entry.key.type === "StringLiteral") return entry.key.value;
548
- }
549
- /**
550
- * The index of an object literal's own property named `key`, `-1` when it has none.
551
- */
552
- function entryIndex({ node, key }) {
553
- return node.properties.findIndex((entry) => entry.type === "ObjectProperty" && propertyKey(entry) === key);
554
- }
555
- /**
556
- * The value node of an object literal's own property.
557
- */
558
- function property(node, key) {
559
- const index = entryIndex({
560
- node,
561
- key
562
- });
563
- return index === -1 ? void 0 : node.properties[index].value;
564
- }
565
- /**
566
- * Writes `key: value` on an object literal, replacing the value when the property is already there.
567
- *
568
- * An existing property has its value swapped in place rather than being replaced whole, so recast
569
- * reprints only that value and leaves the object's own layout alone.
570
- */
571
- function setProperty({ node, key, value }) {
572
- const entry = literalProperty({
573
- key,
574
- value
575
- });
576
- const index = entryIndex({
577
- node,
578
- key
579
- });
580
- if (index === -1) {
581
- node.properties.push(entry);
582
- return;
583
- }
584
- node.properties[index].value = entry.value;
585
- }
586
- /**
587
- * Drops `key` from an object literal.
588
- */
589
- function removeProperty({ node, key }) {
590
- const index = entryIndex({
591
- node,
592
- key
593
- });
594
- if (index !== -1) node.properties.splice(index, 1);
658
+ /** Whether a `...spread` after the member at `index` could override it. */
659
+ function isShadowed(node, index) {
660
+ return node.properties.slice(index + 1).some((entry) => entry.type === "SpreadElement");
595
661
  }
596
662
  /**
597
663
  * Reads a literal node's value: a primitive, or an object/array built only from primitives.
@@ -599,42 +665,109 @@ function removeProperty({ node, key }) {
599
665
  * whether a node is a literal at all.
600
666
  */
601
667
  function readLiteral(node) {
602
- if (!node) return;
603
- if (node.type === "StringLiteral" || node.type === "NumericLiteral" || node.type === "BooleanLiteral") return node.value;
604
- if (node.type === "NullLiteral") return null;
605
- if (node.type === "TemplateLiteral") return node.expressions.length === 0 ? node.quasis[0]?.value.cooked ?? "" : void 0;
606
- if (node.type === "UnaryExpression") {
607
- const value = readLiteral(node.argument);
608
- if (typeof value !== "number") return;
609
- if (node.operator === "-") return -value;
610
- if (node.operator === "+") return value;
611
- return;
612
- }
613
- if (node.type === "ArrayExpression") {
614
- const values = node.elements.map((element) => element ? readLiteral(element) : void 0);
615
- return values.every((value) => value !== void 0) ? values : void 0;
616
- }
617
- if (node.type === "ObjectExpression") {
618
- const entries = {};
619
- for (const entry of node.properties) {
620
- if (entry.type !== "ObjectProperty") return;
621
- const key = propertyKey(entry);
622
- const value = readLiteral(entry.value);
623
- if (key === void 0 || value === void 0) return;
624
- entries[key] = value;
668
+ switch (node?.type) {
669
+ case "StringLiteral":
670
+ case "NumericLiteral":
671
+ case "BooleanLiteral": return node.value;
672
+ case "NullLiteral": return null;
673
+ case "TemplateLiteral": return node.expressions.length ? void 0 : node.quasis[0]?.value.cooked ?? "";
674
+ case "UnaryExpression": {
675
+ const value = readLiteral(node.argument);
676
+ if (typeof value !== "number") return void 0;
677
+ return node.operator === "-" ? -value : node.operator === "+" ? value : void 0;
678
+ }
679
+ case "ArrayExpression": {
680
+ const values = node.elements.map((element) => readLiteral(element));
681
+ return values.every((value) => value !== void 0) ? values : void 0;
682
+ }
683
+ case "ObjectExpression": {
684
+ const entries = {};
685
+ for (const entry of node.properties) {
686
+ if (entry.type !== "ObjectProperty") return void 0;
687
+ const key = memberKey(entry);
688
+ const value = readLiteral(entry.value);
689
+ if (key === void 0 || !isSafeKey(key) || value === void 0) return void 0;
690
+ entries[key] = value;
691
+ }
692
+ return entries;
625
693
  }
626
- return entries;
627
694
  }
628
695
  }
629
- /**
630
- * Maps a factory identifier in the file back to the module it was imported from.
631
- */
696
+ /** Writes `value` as a single-line literal with the file's quote style. */
697
+ function printValue(value, quote) {
698
+ if (typeof value === "string") {
699
+ const escaped = JSON.stringify(value).slice(1, -1);
700
+ return quote === `"` ? `"${escaped}"` : `'${escaped.replaceAll(`\\"`, `"`).replaceAll(`'`, `\\'`)}'`;
701
+ }
702
+ if (value === null || typeof value !== "object") return String(value);
703
+ if (Array.isArray(value)) return `[${value.map((item) => printValue(item, quote)).join(", ")}]`;
704
+ const entries = Object.entries(value);
705
+ return entries.length ? `{ ${entries.map(([key, item]) => `${printKey(key, quote)}: ${printValue(item, quote)}`).join(", ")} }` : "{}";
706
+ }
707
+ /** An object key as source text: bare when it is an identifier, quoted otherwise. */
708
+ function printKey(key, quote) {
709
+ return IDENTIFIER.test(key) ? key : printValue(key, quote);
710
+ }
711
+ /** `value` wrapped in one object per key of `path`: `nest(['enum', 'type'], 'enum')` is `{ enum: { type: 'enum' } }`. */
712
+ function nest(path, value) {
713
+ return path.reduceRight((inner, key) => ({ [key]: inner }), value);
714
+ }
715
+ /**
716
+ * Adds `text` as the last item of an object or array literal, following its layout: on a line of
717
+ * its own with the last item's indentation when the literal spans lines, inline otherwise.
718
+ */
719
+ function insertItem(source, container, text) {
720
+ const { start, end } = rangeOf(container);
721
+ const last = (container.type === "ObjectExpression" ? container.properties : container.elements).filter((item) => item !== null).at(-1);
722
+ if (!last) {
723
+ const inner = source.slice(start + 1, end - 1);
724
+ if (inner.trim()) return splice(source, start + 1, start + 1, /^\s/.test(inner) ? `${text},` : `${text}, `);
725
+ return splice(source, start, end, container.type === "ObjectExpression" ? `{ ${text} }` : `[${text}]`);
726
+ }
727
+ const lastRange = rangeOf(last);
728
+ const between = source.slice(lastRange.end, end - 1);
729
+ if (!between.includes("\n")) return splice(source, lastRange.end, lastRange.end, `, ${text}`);
730
+ const lineStart = source.lastIndexOf("\n", lastRange.start - 1) + 1;
731
+ const indent = source.slice(lineStart).match(/^[ \t]*/)?.[0] ?? "";
732
+ const lineEnd = source.indexOf("\n", lastRange.end);
733
+ const comma = /^\s*,/.test(between);
734
+ return `${source.slice(0, lastRange.end)}${comma ? "" : ","}${source.slice(lastRange.end, lineEnd)}\n${indent}${text}${comma ? "," : ""}${source.slice(lineEnd)}`;
735
+ }
736
+ /** Drops the member at `index` from an object literal, with its own lines when it sits on them alone. */
737
+ function removeMember(source, node, index) {
738
+ if (node.properties.length === 1) return splice(source, rangeOf(node).start, rangeOf(node).end, "{}");
739
+ const { start, end } = rangeOf(node.properties[index]);
740
+ const lineStart = source.lastIndexOf("\n", start - 1) + 1;
741
+ const nextNewline = source.indexOf("\n", end);
742
+ const lineEnd = nextNewline === -1 ? source.length : nextNewline;
743
+ if (!source.slice(lineStart, start).trim() && /^\s*,?\s*(\/\/.*)?$/.test(source.slice(end, lineEnd))) return splice(source, lineStart, Math.min(lineEnd + 1, source.length));
744
+ const next = node.properties[index + 1];
745
+ return next ? splice(source, start, rangeOf(next).start) : splice(source, rangeOf(node.properties[index - 1]).end, end);
746
+ }
747
+ /** The module a `require('...')` call loads, when `node` is one. */
748
+ function requiredModule(node) {
749
+ if (node?.type !== "CallExpression" || node.callee.type !== "Identifier" || node.callee.name !== "require") return void 0;
750
+ const [argument] = node.arguments;
751
+ return argument?.type === "StringLiteral" ? argument.value : void 0;
752
+ }
753
+ /** Maps each identifier the file imports, through `import { x } from` or `const { x } = require(...)`, to its module. */
632
754
  function importedFrom(mod) {
633
- return new Map(mod.imports.$items.map((item) => [item.local, item.from]));
755
+ const imports = /* @__PURE__ */ new Map();
756
+ for (const statement of bodyOf(mod)) {
757
+ if (statement.type === "ImportDeclaration") {
758
+ for (const specifier of statement.specifiers) imports.set(specifier.local.name, statement.source.value);
759
+ continue;
760
+ }
761
+ if (statement.type !== "VariableDeclaration") continue;
762
+ for (const declarator of statement.declarations) {
763
+ const from = requiredModule(declarator.init);
764
+ if (from === void 0 || declarator.id.type !== "ObjectPattern") continue;
765
+ for (const entry of declarator.id.properties) if (entry.type === "ObjectProperty" && entry.value.type === "Identifier") imports.set(entry.value.name, from);
766
+ }
767
+ }
768
+ return imports;
634
769
  }
635
- /**
636
- * Every `pluginX(...)` element of a config's plugins array that resolves to an import.
637
- */
770
+ /** Every `pluginX(...)` element of a config's plugins array that resolves to an import. */
638
771
  function pluginCalls(mod, config) {
639
772
  const plugins = property(config, "plugins");
640
773
  if (plugins?.type !== "ArrayExpression") return [];
@@ -650,19 +783,51 @@ function pluginCalls(mod, config) {
650
783
  });
651
784
  }
652
785
  /**
653
- * Plugins a previous `disable-plugin` commented out of this config, keyed by package name.
654
- *
655
- * Read from the marker lines rather than the AST, since a commented-out call is no longer a node.
786
+ * The blocks a previous `disable-plugin` commented out of this config, read from the marker lines
787
+ * since a commented-out call is no longer a node. `code` holds the block's lines as they were.
656
788
  */
657
- function disabledMarkers(source) {
658
- return source.split("\n").flatMap((line, index) => {
659
- const marker = parseMarker(line);
660
- return marker ? [{
661
- packageName: marker.plugin,
662
- line: index + 1
663
- }] : [];
789
+ function disabledBlocks(source, config) {
790
+ const lines = source.split("\n");
791
+ const first = config.loc?.start.line ?? 0;
792
+ const last = config.loc?.end.line ?? Number.POSITIVE_INFINITY;
793
+ return lines.flatMap((text, index) => {
794
+ const match = text.trim().match(DISABLED_MARKER);
795
+ if (!match || index + 1 < first || index + 1 > last) return [];
796
+ const end = index + 1 + Number(match[2]);
797
+ const code = lines.slice(index + 1, end).map((line) => line.replace(/^(\s*)\/\/ ?/, "$1").replace(/^\s+$/, ""));
798
+ return [{
799
+ packageName: match[1],
800
+ line: index + 1,
801
+ end,
802
+ code
803
+ }];
664
804
  });
665
805
  }
806
+ /** The top-level options of a plugin call, each flagged with whether Studio may write it. */
807
+ function readOptions(call) {
808
+ const entries = {};
809
+ const options = call.arguments[0];
810
+ if (options?.type !== "ObjectExpression") return entries;
811
+ for (const [index, entry] of options.properties.entries()) {
812
+ const key = memberKey(entry);
813
+ if (key === void 0 || !isSafeKey(key)) continue;
814
+ const value = entry.type === "ObjectProperty" && !isShadowed(options, index) ? readLiteral(entry.value) : void 0;
815
+ entries[key] = value === void 0 ? { literal: false } : {
816
+ literal: true,
817
+ value
818
+ };
819
+ }
820
+ return entries;
821
+ }
822
+ /** The options a commented-out plugin call holds, so Studio can show them while it is disabled. */
823
+ function readDisabledOptions(code) {
824
+ try {
825
+ const call = (0, magicast.parseExpression)(code.join("\n").trim().replace(/,$/, "")).$ast;
826
+ return call.type === "CallExpression" ? readOptions(call) : {};
827
+ } catch {
828
+ return {};
829
+ }
830
+ }
666
831
  /**
667
832
  * Reads which plugins the file declares and which of their options Studio may write.
668
833
  *
@@ -682,37 +847,27 @@ function readConfig(source) {
682
847
  };
683
848
  const { mod, configs } = parsed;
684
849
  const importNames = new Map([...importedFrom(mod)].map(([local, from]) => [from, local]));
685
- const disabled = disabledMarkers(source);
686
850
  return {
687
851
  managed: true,
688
852
  configs: configs.map((config) => {
689
- const plugins = pluginCalls(mod, config).map(({ importName, packageName, call }) => {
690
- const entries = {};
691
- const options = call.arguments[0];
692
- if (options?.type === "ObjectExpression") for (const entry of options.properties) {
693
- if (entry.type !== "ObjectProperty") continue;
694
- const key = propertyKey(entry);
695
- if (key === void 0) continue;
696
- const value = readLiteral(entry.value);
697
- entries[key] = value === void 0 ? { literal: false } : {
698
- literal: true,
699
- value
700
- };
701
- }
702
- return {
853
+ const active = pluginCalls(mod, config).map(({ importName, packageName, call }) => ({
854
+ line: call.loc?.start.line ?? 0,
855
+ plugin: {
703
856
  importName,
704
857
  packageName,
705
- options: entries
706
- };
707
- });
708
- const start = config.loc?.start.line ?? 0;
709
- const end = config.loc?.end.line ?? Number.POSITIVE_INFINITY;
710
- for (const { packageName } of disabled.filter((entry) => entry.line >= start && entry.line <= end)) plugins.push({
711
- importName: importNames.get(packageName) ?? toExportName(packageName),
712
- packageName,
713
- options: {},
714
- disabled: true
715
- });
858
+ options: readOptions(call)
859
+ }
860
+ }));
861
+ const disabled = disabledBlocks(source, config).map(({ packageName, line, code }) => ({
862
+ line,
863
+ plugin: {
864
+ importName: importNames.get(packageName) ?? toExportName(packageName),
865
+ packageName,
866
+ options: readDisabledOptions(code),
867
+ disabled: true
868
+ }
869
+ }));
870
+ const plugins = [...active, ...disabled].sort((a, b) => a.line - b.line).map(({ plugin }) => plugin);
716
871
  return {
717
872
  name: configName(config),
718
873
  plugins
@@ -721,173 +876,131 @@ function readConfig(source) {
721
876
  };
722
877
  }
723
878
  /**
724
- * The options object of a plugin call, when it was called with one.
725
- */
726
- function getOptions(call) {
727
- const options = call.arguments[0];
728
- return options?.type === "ObjectExpression" ? options : void 0;
729
- }
730
- /**
731
- * The options object of a plugin call, creating an empty one when the plugin was called bare.
732
- */
733
- function ensureOptions(call) {
734
- if (call.arguments.length === 0) call.arguments.push({
735
- type: "ObjectExpression",
736
- properties: []
737
- });
738
- return getOptions(call);
739
- }
740
- /**
741
- * Walks `path` down to the object holding its last key, descending only through object literals.
879
+ * Walks `path` through a plugin's options to the literal at its last key. Returns `missing` with
880
+ * the index of the first key that is not there yet, so `set` can write the rest of the path as one
881
+ * nested literal and `remove` can report it as unset. Refuses a value customized in code, so it is
882
+ * never overwritten.
742
883
  */
743
- function optionParent(options, path) {
884
+ function walk(options, path) {
744
885
  let object = options;
745
- for (const [index, key] of path.entries()) {
746
- if (index === path.length - 1) return {
886
+ for (const [depth, key] of path.entries()) {
887
+ const found = findMember(object, key);
888
+ if (!found) return {
747
889
  object,
748
- key
890
+ missing: depth
749
891
  };
750
- if (property(object, key) === void 0) setProperty({
751
- node: object,
752
- key,
753
- value: {}
754
- });
755
- const next = property(object, key);
756
- if (next?.type !== "ObjectExpression") return { reason: `${key} is not an object, so ${path.join(".")} cannot be reached` };
757
- object = next;
892
+ if (isShadowed(object, found.index)) return { reason: `${path.slice(0, depth + 1).join(".")} can be overridden by a spread after it, so it is customized in code` };
893
+ const { member, index } = found;
894
+ if (depth === path.length - 1) return member.type === "ObjectProperty" && readLiteral(member.value) !== void 0 ? {
895
+ object,
896
+ index,
897
+ value: member.value
898
+ } : { reason: `${path.join(".")} is customized in code` };
899
+ if (member.type !== "ObjectProperty" || member.value.type !== "ObjectExpression") return { reason: `${key} is not an object, so ${path.join(".")} cannot be reached` };
900
+ object = member.value;
758
901
  }
759
902
  return { reason: "no option path given" };
760
903
  }
761
- /**
762
- * Writes `value` at `path` inside a plugin call's options, creating the options object and any
763
- * intermediate object along the path as needed. Refuses when the current value at `path` is
764
- * something other than a literal, so an option customized in code is never overwritten.
765
- */
766
- function applySet(call, path, value) {
767
- if (!isOptionValue(value)) return "the value is not a literal that can be written to a config file";
768
- const options = ensureOptions(call);
769
- if (!options) return "the plugin was not called with an object literal";
770
- const target = optionParent(options, path);
771
- if ("reason" in target) return target.reason;
772
- const current = property(target.object, target.key);
773
- if (current !== void 0 && readLiteral(current) === void 0) return `${path.join(".")} is customized in code`;
774
- setProperty({
775
- node: target.object,
776
- key: target.key,
777
- value
778
- });
904
+ /** Writes `value` at `path` inside a plugin call's options, adding the options object and any intermediate object it needs. */
905
+ function applySet(source, call, path, value, quote) {
906
+ if (!isOptionValue(value)) return { reason: "the value is not a literal that can be written to a config file" };
907
+ const options = call.arguments[0];
908
+ if (!options) {
909
+ const closing = rangeOf(call).end - 1;
910
+ if (source[closing] !== ")") return { reason: "the plugin call could not be located in the file" };
911
+ return { source: splice(source, closing, closing, printValue(nest(path, value), quote)) };
912
+ }
913
+ if (options.type !== "ObjectExpression") return { reason: "the plugin was not called with an object literal" };
914
+ const target = walk(options, path);
915
+ if ("reason" in target) return target;
916
+ if ("missing" in target) {
917
+ const [key, ...rest] = path.slice(target.missing);
918
+ return { source: insertItem(source, target.object, `${printKey(key, quote)}: ${printValue(nest(rest, value), quote)}`) };
919
+ }
920
+ return { source: splice(source, rangeOf(target.value).start, rangeOf(target.value).end, printValue(value, quote)) };
921
+ }
922
+ /** Deletes the option at `path`, falling the plugin back to its default for it. */
923
+ function applyRemove(source, call, path) {
924
+ const options = call.arguments[0];
925
+ if (options?.type !== "ObjectExpression") return { reason: "the plugin has no options to remove" };
926
+ const target = walk(options, path);
927
+ if ("reason" in target) return target;
928
+ if ("missing" in target) return { reason: `${path.join(".")} is not set` };
929
+ return { source: removeMember(source, target.object, target.index) };
779
930
  }
780
931
  /**
781
- * Deletes the property at `path` inside a plugin call's options, falling the plugin back to its
782
- * default for that option. Refuses when the value at `path` is not a literal, for the same reason
783
- * `applySet` does.
932
+ * Writes an import after the last one already in the file, matching its quote style and whether it
933
+ * ends in a semicolon: `import { x } from '...'`, or `const { x } = require('...')` in a CommonJS file.
934
+ * The last import is read off the AST, so a multi-line `import {\n x,\n} from '...'` counts to its closing line.
784
935
  */
785
- function applyRemove(call, path) {
786
- const options = getOptions(call);
787
- if (!options) return "the plugin has no options to remove";
788
- const target = optionParent(options, path);
789
- if ("reason" in target) return target.reason;
790
- const current = property(target.object, target.key);
791
- if (current === void 0) return `${path.join(".")} is not set`;
792
- if (readLiteral(current) === void 0) return `${path.join(".")} is customized in code`;
793
- removeProperty({
794
- node: target.object,
795
- key: target.key
796
- });
936
+ function insertImport(source, { mod, kind }, importName, moduleSpecifier) {
937
+ const afterLine = bodyOf(mod).findLast((node) => kind === "esm" ? node.type === "ImportDeclaration" : node.type === "VariableDeclaration" && node.declarations.some((declarator) => requiredModule(declarator.init) !== void 0))?.loc?.end.line ?? 0;
938
+ const lines = source.split("\n");
939
+ const lastImportLine = afterLine > 0 ? lines[afterLine - 1] : void 0;
940
+ const quote = lastImportLine?.includes(`"`) ? `"` : `'`;
941
+ const semicolon = lastImportLine?.trimEnd().endsWith(";") ? ";" : "";
942
+ const specifier = `${quote}${moduleSpecifier}${quote}`;
943
+ lines.splice(afterLine, 0, kind === "esm" ? `import { ${importName} } from ${specifier}${semicolon}` : `const { ${importName} } = require(${specifier})${semicolon}`);
944
+ if (afterLine === 0) lines.splice(1, 0, "");
945
+ return lines.join("\n");
797
946
  }
798
947
  /**
799
- * Adds a `pluginX(...)` call to a config's plugins array. Replaying the same add is a no-op, while
800
- * an import name collision with an unrelated package remains an error.
948
+ * Adds a `pluginX(...)` call to a config's plugins array, and its import. Replaying the same add is
949
+ * a no-op, while an import name collision with an unrelated package remains an error.
801
950
  */
802
- function applyAddPlugin(mod, config, edit) {
951
+ function applyAddPlugin(source, parsed, config, edit, quote) {
803
952
  if (!isKubbPluginSpecifier(edit.plugin)) return { reason: `"${edit.plugin}" is not a @kubb/plugin-* package` };
804
953
  const importName = edit.importName ?? toExportName(edit.plugin);
805
954
  if (!IDENTIFIER.test(importName)) return { reason: `"${importName}" is not a valid import name` };
806
- if (pluginCalls(mod, config).some((plugin) => plugin.packageName === edit.plugin)) return { noop: true };
807
- const taken = importedFrom(mod).get(importName);
955
+ if (pluginCalls(parsed.mod, config).some((plugin) => plugin.packageName === edit.plugin)) return { source };
956
+ const taken = importedFrom(parsed.mod).get(importName);
808
957
  if (taken && taken !== edit.plugin) return { reason: `${importName} is already imported from ${taken}` };
809
958
  const options = edit.options ?? {};
810
959
  if (!isOptionValue(options)) return { reason: "the options are not literals that can be written to a config file" };
811
960
  const plugins = property(config, "plugins");
812
961
  if (plugins?.type !== "ArrayExpression") return { reason: "plugins is not an array literal" };
813
- const call = Object.keys(options).length ? magicast.builders.functionCall(importName, options) : magicast.builders.functionCall(importName);
814
- plugins.elements.push(call.$ast);
815
- return taken ? {} : { addImport: {
816
- importName,
817
- moduleSpecifier: edit.plugin
818
- } };
962
+ const next = insertItem(source, plugins, `${importName}(${Object.keys(options).length ? printValue(options, quote) : ""})`);
963
+ return { source: taken ? next : insertImport(next, parsed, importName, edit.plugin) };
819
964
  }
820
- /**
821
- * Comments out a plugin call in place, keeping its options on disk so `enable-plugin` can restore
822
- * them exactly. Operates on `source` text rather than the AST: a commented-out call is no longer a
823
- * node magicast can address, and the surrounding array must not reflow when its element count
824
- * never actually changes.
825
- */
965
+ /** Comments out a plugin call in place, keeping its options on disk so `enable-plugin` can restore them exactly. */
826
966
  function disablePlugin(source, mod, config, plugin) {
827
- const target = pluginCalls(mod, config).find((entry) => entry.packageName === plugin);
828
- if (!target) return { reason: `${plugin} is not in the plugins array` };
829
- const loc = target.call.loc;
830
- if (!loc?.start || !loc.end) return { reason: `${plugin} has no source location to comment out` };
967
+ const loc = pluginCalls(mod, config).find((entry) => entry.packageName === plugin)?.call.loc;
968
+ if (!loc) return { reason: `${plugin} is not in the plugins array` };
831
969
  const lines = source.split("\n");
832
970
  const from = loc.start.line - 1;
833
971
  const to = loc.end.line - 1;
834
972
  const firstLine = lines[from] ?? "";
835
- const lastLine = lines[to] ?? "";
836
- if (firstLine.slice(0, loc.start.column).trim() !== "" || !/^,?\s*$/.test(lastLine.slice(loc.end.column))) return { reason: `${plugin} shares a line with other code, so it cannot be commented out safely` };
973
+ if (firstLine.slice(0, loc.start.column).trim() || !/^,?\s*$/.test((lines[to] ?? "").slice(loc.end.column))) return { reason: `${plugin} shares a line with other code, so it cannot be commented out safely` };
837
974
  const indent = firstLine.match(/^\s*/)?.[0] ?? "";
838
- const commented = lines.slice(from, to + 1).map((line) => line.trim() ? `${indent}// ${line.slice(indent.length)}` : indent ? `${indent}//` : "//");
839
- lines.splice(from, to - from + 1, `${indent}// ${DISABLED_MARKER} ${plugin} ${commented.length}`, ...commented);
975
+ const commented = lines.slice(from, to + 1).map((line) => line.trim() ? `${indent}// ${line.slice(indent.length)}` : `${indent}//`);
976
+ lines.splice(from, commented.length, `${indent}// kubb:disabled ${plugin} ${commented.length}`, ...commented);
840
977
  return { source: lines.join("\n") };
841
978
  }
842
- /**
843
- * Uncomments the block a previous `disable-plugin` left behind for `plugin`.
844
- */
845
- function enablePlugin(source, plugin) {
979
+ /** Uncomments the block a previous `disable-plugin` left behind for `plugin` in this config. */
980
+ function enablePlugin(source, config, plugin) {
981
+ const block = disabledBlocks(source, config).find((entry) => entry.packageName === plugin);
982
+ if (!block) return { reason: `${plugin} is not disabled` };
846
983
  const lines = source.split("\n");
847
- for (const [index, line] of lines.entries()) {
848
- const marker = parseMarker(line);
849
- if (marker?.plugin !== plugin) continue;
850
- const end = index + 1 + marker.lineCount;
851
- const restored = lines.slice(index + 1, end).map((commented) => commented.replace(/^(\s*)\/\/ ?/, "$1"));
852
- lines.splice(index, end - index, ...restored);
853
- return { source: lines.join("\n") };
854
- }
855
- return { reason: `${plugin} is not disabled` };
856
- }
857
- /**
858
- * Parses `source` and finds the config entries it exports.
859
- */
860
- function parseConfigs(source) {
861
- let mod;
862
- try {
863
- mod = (0, magicast.parseModule)(source);
864
- } catch {
865
- return { reason: "the config file could not be parsed" };
866
- }
867
- const found = findConfigs(mod);
868
- return "reason" in found ? found : {
869
- mod,
870
- configs: found.configs
871
- };
984
+ lines.splice(block.line - 1, block.end - block.line + 1, ...block.code);
985
+ return { source: lines.join("\n") };
872
986
  }
873
- /**
874
- * Re-parses `source` and resolves the config entry an edit targets. Every edit re-parses rather
875
- * than sharing one module across the batch, since the disable/enable edits rewrite `source` as
876
- * text and would otherwise leave the others working from a stale tree.
877
- */
878
- function parseTarget(source, ref) {
987
+ /** Applies one edit to `source`. Every edit re-parses the file, since the previous one rewrote it. */
988
+ function applyEdit(source, edit, quote) {
879
989
  const parsed = parseConfigs(source);
880
990
  if ("reason" in parsed) return parsed;
881
- const config = selectConfig(parsed.configs, ref);
991
+ const ref = edit.config;
992
+ const config = typeof ref === "string" ? parsed.configs.find((entry) => configName(entry) === ref) : parsed.configs[ref ?? 0];
882
993
  if (!config) return { reason: `no config entry found for ${JSON.stringify(ref)}` };
883
- return {
884
- mod: parsed.mod,
885
- config
886
- };
994
+ if (edit.operation === "disable-plugin") return disablePlugin(source, parsed.mod, config, edit.plugin);
995
+ if (edit.operation === "enable-plugin") return enablePlugin(source, config, edit.plugin);
996
+ if (edit.operation === "add-plugin") return applyAddPlugin(source, parsed, config, edit, quote);
997
+ const call = pluginCalls(parsed.mod, config).find((plugin) => plugin.packageName === edit.plugin)?.call;
998
+ 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
+ return edit.operation === "set" ? applySet(source, call, edit.path, edit.value, quote) : applyRemove(source, call, edit.path);
887
1000
  }
888
1001
  /**
889
- * Applies edits to a `kubb.config.ts` in place. Every node the edits do not touch keeps its
890
- * original text, so comments, formatting, and hand-written code around the config survive.
1002
+ * Applies edits to a `kubb.config.ts` in place. Each edit rewrites only the text it targets, so
1003
+ * comments, formatting, and hand-written code around the config survive.
891
1004
  *
892
1005
  * Edits are independent: one that cannot be applied is reported in `outcomes` and the rest still run.
893
1006
  *
@@ -897,25 +1010,19 @@ function parseTarget(source, ref) {
897
1010
  * { operation: 'set', plugin: '@kubb/plugin-ts', path: ['enum', 'type'], value: 'enum' },
898
1011
  * ])
899
1012
  * ```
900
- *
901
- * @note recast always reprints a semicolon on a reprinted statement, so editing a block-body
902
- * `defineConfig` in a semicolon-free file adds one to the `return` line. This is a known gap.
903
- * Strip it when `detectCodeFormat` reports `useSemi: false`, if it turns out to matter in practice.
904
1013
  */
905
1014
  function applyConfigEdits(source, edits) {
906
1015
  let current = source;
907
- const format = (0, magicast.detectCodeFormat)(source);
908
- const endsWithNewline = source.endsWith("\n");
1016
+ const quote = (0, magicast.detectCodeFormat)(source).quote === "double" ? `"` : `'`;
909
1017
  const outcomes = edits.map((edit) => {
910
- const target = parseTarget(current, edit.config);
911
- if ("reason" in target) return {
1018
+ const invalid = invalidEdit(edit);
1019
+ if (invalid) return {
912
1020
  edit,
913
1021
  applied: false,
914
- reason: target.reason
1022
+ reason: invalid
915
1023
  };
916
- const { mod, config } = target;
917
- if (edit.operation === "disable-plugin" || edit.operation === "enable-plugin") {
918
- const result = edit.operation === "disable-plugin" ? disablePlugin(current, mod, config, edit.plugin) : enablePlugin(current, edit.plugin);
1024
+ try {
1025
+ const result = applyEdit(current, edit, quote);
919
1026
  if ("reason" in result) return {
920
1027
  edit,
921
1028
  applied: false,
@@ -926,44 +1033,13 @@ function applyConfigEdits(source, edits) {
926
1033
  edit,
927
1034
  applied: true
928
1035
  };
929
- }
930
- if (edit.operation === "add-plugin") {
931
- const result = applyAddPlugin(mod, config, edit);
932
- if ("reason" in result) return {
933
- edit,
934
- applied: false,
935
- reason: result.reason
936
- };
937
- if ("noop" in result) return {
938
- edit,
939
- applied: true
940
- };
941
- const afterLine = lastImportEndLine(mod);
942
- let next = (0, magicast.generateCode)(mod, { format }).code;
943
- if (result.addImport) next = insertImportLine({
944
- source: next,
945
- afterLine,
946
- ...result.addImport
947
- });
948
- current = withTrailingNewline(next, endsWithNewline);
1036
+ } catch (error) {
949
1037
  return {
950
1038
  edit,
951
- applied: true
1039
+ applied: false,
1040
+ reason: getErrorMessage(error)
952
1041
  };
953
1042
  }
954
- const pluginCall = pluginCalls(mod, config).find((plugin) => plugin.packageName === edit.plugin);
955
- if (!pluginCall) return {
956
- edit,
957
- applied: false,
958
- reason: `${edit.plugin} is not in the plugins array`
959
- };
960
- const reason = edit.operation === "set" ? applySet(pluginCall.call, edit.path, edit.value) : applyRemove(pluginCall.call, edit.path);
961
- if (!reason) current = withTrailingNewline((0, magicast.generateCode)(mod, { format }).code, endsWithNewline);
962
- return {
963
- edit,
964
- applied: !reason,
965
- reason
966
- };
967
1043
  });
968
1044
  return {
969
1045
  source: current,
@@ -971,42 +1047,12 @@ function applyConfigEdits(source, edits) {
971
1047
  changed: current !== source
972
1048
  };
973
1049
  }
1050
+ /** Applies `edits` to the config file at `filePath`, writing it only when something changed. */
974
1051
  async function writeConfigEdits({ filePath, edits }) {
975
1052
  const result = applyConfigEdits(await read(filePath), edits);
976
1053
  if (result.changed) await (0, node_fs_promises.writeFile)(filePath, result.source, "utf-8");
977
1054
  return result;
978
1055
  }
979
- /**
980
- * The 1-based line where the file's last import declaration ends, or `0` when it has none. Read
981
- * off the parsed module, so a multi-line `import {\n x,\n} from '...'` reports its closing line
982
- * rather than the `import` keyword.
983
- */
984
- function lastImportEndLine(mod) {
985
- return (mod.$ast.type === "Program" ? mod.$ast.body : []).filter((node) => node.type === "ImportDeclaration").at(-1)?.loc?.end.line ?? 0;
986
- }
987
- /**
988
- * Writes an import after the last one already in the file, matching its quote style and whether it
989
- * ends in a semicolon. `afterLine` is where that last import ends, `0` for a file with none.
990
- *
991
- * Written as plain text rather than through magicast's import builder, which prints a brand-new
992
- * import declaration with its own default spacing and a semicolon regardless of `format`, since
993
- * that formatting only governs nodes recast can diff against the original source.
994
- */
995
- function insertImportLine({ source, importName, moduleSpecifier, afterLine }) {
996
- const lines = source.split("\n");
997
- const lastImportLine = afterLine > 0 ? lines[afterLine - 1] : void 0;
998
- const quote = lastImportLine?.includes(`"`) ? `"` : `'`;
999
- const line = `import { ${importName} } from ${quote}${moduleSpecifier}${quote}${lastImportLine?.trimEnd().endsWith(";") ? ";" : ""}`;
1000
- lines.splice(afterLine, 0, ...afterLine > 0 ? [line] : [line, ""]);
1001
- return lines.join("\n");
1002
- }
1003
- /**
1004
- * `generateCode` always drops the file's trailing newline. Restore it when the input had one.
1005
- */
1006
- function withTrailingNewline(code, hadTrailingNewline) {
1007
- if (!hadTrailingNewline || code.endsWith("\n")) return code;
1008
- return `${code}\n`;
1009
- }
1010
1056
  //#endregion
1011
1057
  //#region src/generations.ts
1012
1058
  const READ_CONCURRENCY = 50;
@@ -1129,7 +1175,8 @@ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now
1129
1175
  if (path.split("/").includes("..")) return;
1130
1176
  const content = await files.storage.readItem((0, node_path.resolve)(files.root, path));
1131
1177
  if (content === null) return;
1132
- hashes[path] = hashOf(content);
1178
+ if (source === "output" && !content.trim()) return;
1179
+ hashes[path] = hashOf(source === "output" ? `${content.trim()}\n` : content);
1133
1180
  bytes += Buffer.byteLength(content);
1134
1181
  if (bytes <= maxSetBytes) await storage.writeItem(`${dirOf(jobId)}${source}/${path}`, content);
1135
1182
  }
@@ -1536,8 +1583,11 @@ var Agent = class extends capnweb.RpcTarget {
1536
1583
  path: configPath,
1537
1584
  file,
1538
1585
  plugins: config.plugins.map((plugin) => ({
1539
- name: toPackageName(plugin.name),
1540
- options: plugin.options ?? {}
1586
+ name: toStudioName({
1587
+ name: plugin.name,
1588
+ root
1589
+ }),
1590
+ options: toJSONOptions(plugin.options)
1541
1591
  }))
1542
1592
  },
1543
1593
  permissions: this.#permissions
@@ -1594,11 +1644,15 @@ var Agent = class extends capnweb.RpcTarget {
1594
1644
  }
1595
1645
  async #generate({ input, root, signal }) {
1596
1646
  const { jobId, config: patch } = input;
1647
+ const studioInput = this.#sandbox ? patch?.input : void 0;
1648
+ 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" });
1597
1649
  const config = await this.#options.loadConfig();
1598
- const plugins = await mergePlugins(config.plugins, patch?.plugins) ?? config.plugins;
1599
- const adapter = await mergeAdapter(config.adapter, patch?.adapter);
1600
- const studioInput = this.#sandbox ? patch?.input ?? "" : void 0;
1601
- if (patch?.input && !this.#sandbox) await this.#warn({ message: "Ignored the spec from Studio: generating from a Studio spec is only available to a sandbox agent" });
1650
+ const [plugins, adapter] = await Promise.all([mergePlugins(config.plugins, patch?.plugins), mergeAdapter(config.adapter, patch?.adapter)]).catch(async (error) => {
1651
+ await emit(this.#options.hooks, "kubb:error", { error: toError(error) });
1652
+ throw error;
1653
+ });
1654
+ const activePlugins = plugins ?? config.plugins;
1655
+ 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" });
1602
1656
  const diskFiles = !this.#sandbox && this.#permissions.allowRead ? await listDisk({
1603
1657
  root,
1604
1658
  outputPath: config.output.path,
@@ -1616,7 +1670,7 @@ var Agent = class extends capnweb.RpcTarget {
1616
1670
  root,
1617
1671
  input: studioInput ?? config.input,
1618
1672
  storage: this.#permissions.allowWrite ? (0, _kubb_core.fsStorage)() : (0, _kubb_core.memoryStorage)(),
1619
- plugins,
1673
+ plugins: activePlugins,
1620
1674
  adapter
1621
1675
  },
1622
1676
  hooks: this.#options.hooks,
@@ -1646,7 +1700,7 @@ var Agent = class extends capnweb.RpcTarget {
1646
1700
  hashes: output.hashes,
1647
1701
  disk: disk ? { hashes: disk.hashes } : void 0
1648
1702
  },
1649
- info: `${plugins.length} plugin${plugins.length === 1 ? "" : "s"}, ${this.#permissions.allowWrite ? "written to disk" : "in memory"}${studioInput !== void 0 ? ", from a Studio spec" : ""}`
1703
+ info: `${activePlugins.length} plugin${activePlugins.length === 1 ? "" : "s"}, ${this.#permissions.allowWrite ? "written to disk" : "in memory"}${studioInput !== void 0 ? ", from a Studio spec" : ""}`
1650
1704
  };
1651
1705
  }
1652
1706
  saveConfig(input) {
@@ -1801,8 +1855,12 @@ async function runSession({ options, openRpc, url, instanceId }) {
1801
1855
  clearTimeout(timer);
1802
1856
  signal?.removeEventListener("abort", rpc.close);
1803
1857
  session.connected.reject(/* @__PURE__ */ new Error("The socket closed before Studio called connect()"));
1804
- session.active?.abort();
1805
- await session.active?.settled;
1858
+ const active = session.active;
1859
+ if (active) {
1860
+ active.abort();
1861
+ await emit(hooks, "studio:warn", { message: "Canceled the running generation: the connection to Kubb Studio closed" });
1862
+ }
1863
+ await active?.settled;
1806
1864
  return {
1807
1865
  ...close,
1808
1866
  connected: session.info !== void 0