@kubb/studio 5.6.1 → 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.js CHANGED
@@ -4,14 +4,14 @@ import { createRequire } from "node:module";
4
4
  import { createHash, hash, randomUUID } from "node:crypto";
5
5
  import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
6
6
  import { tmpdir } from "node:os";
7
- import path, { isAbsolute, join, relative, resolve, sep } from "node:path";
7
+ import path, { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
8
8
  import process$1 from "node:process";
9
9
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
10
10
  import { pathToFileURL } from "node:url";
11
11
  import { existsSync } from "node:fs";
12
- import { Diagnostics, Hookable, cacheStorage, createKubb, fsStorage, memoryStorage, resolveCacheDir } from "@kubb/core";
12
+ import { Diagnostics, Hookable, cacheStorage, createKubb, fsStorage, getInputKind, memoryStorage, resolveCacheDir } from "@kubb/core";
13
13
  import { RpcTarget, newWebSocketRpcSession } from "capnweb";
14
- import { builders, detectCodeFormat, generateCode, parseModule } from "magicast";
14
+ import { detectCodeFormat, parseExpression, parseModule } from "magicast";
15
15
  import { promisify, stripVTControlCharacters } from "node:util";
16
16
  import { ofetch } from "ofetch";
17
17
  import { gzip } from "node:zlib";
@@ -192,7 +192,7 @@ function getElapsedMs(hrStart) {
192
192
  }
193
193
  //#endregion
194
194
  //#region package.json
195
- var version = "5.6.1";
195
+ var version = "5.6.3";
196
196
  //#endregion
197
197
  //#region src/resolveConfig.ts
198
198
  /**
@@ -237,6 +237,46 @@ function toPluginName(packageName) {
237
237
  function toPackageName(name) {
238
238
  return name.startsWith("plugin-") ? `@kubb/${name}` : name;
239
239
  }
240
+ function resolvesFrom(base, packageName) {
241
+ try {
242
+ createRequire(base).resolve(`${packageName}/package.json`);
243
+ return true;
244
+ } catch {
245
+ return false;
246
+ }
247
+ }
248
+ /**
249
+ * Whether `packageName` resolves from the project at `root`, from this package, or from the `kubb`
250
+ * package the project installs, which brings the plugins Kubb adds on its own such as
251
+ * `@kubb/plugin-barrel`. Only resolves the package, it never runs it.
252
+ */
253
+ function isInstalled({ packageName, root }) {
254
+ const project = pathToFileURL(`${root}/`);
255
+ if (resolvesFrom(project, packageName) || resolvesFrom(import.meta.url, packageName)) return true;
256
+ try {
257
+ return resolvesFrom(createRequire(project).resolve("kubb/package.json"), packageName);
258
+ } catch {
259
+ return false;
260
+ }
261
+ }
262
+ /**
263
+ * The name Studio knows a plugin by: its `@kubb/plugin-*` package when one is installed, otherwise
264
+ * the plugin's own `name`, so a plugin defined in the user's project is never reported as a Kubb
265
+ * package that does not exist.
266
+ *
267
+ * @example
268
+ * ```ts
269
+ * toStudioName({ name: 'plugin-ts', root }) // '@kubb/plugin-ts'
270
+ * toStudioName({ name: 'plugin-zod-operations', root }) // 'plugin-zod-operations', a local plugin
271
+ * ```
272
+ */
273
+ function toStudioName({ name, root }) {
274
+ const packageName = toPackageName(name);
275
+ return packageName !== name && isInstalled({
276
+ packageName,
277
+ root
278
+ }) ? packageName : name;
279
+ }
240
280
  /**
241
281
  * Derives the conventional named export for a `@kubb/*` plugin package from its package name.
242
282
  *
@@ -332,6 +372,38 @@ async function resolvePeerDependencies(names) {
332
372
  };
333
373
  }
334
374
  /**
375
+ * Keys that reach an object's prototype when assigned. Never read from or written into options.
376
+ */
377
+ const UNSAFE_KEYS = /* @__PURE__ */ new Set([
378
+ "__proto__",
379
+ "constructor",
380
+ "prototype"
381
+ ]);
382
+ /**
383
+ * Whether `key` can be used as an option key without touching an object's prototype.
384
+ */
385
+ function isSafeKey(key) {
386
+ return !UNSAFE_KEYS.has(key);
387
+ }
388
+ /**
389
+ * A JSON copy of plugin or adapter options: functions and other values JSON cannot hold are left
390
+ * out, so nothing callable crosses the socket. Options that cannot be copied at all, such as a
391
+ * circular structure, come back empty.
392
+ *
393
+ * @example
394
+ * ```ts
395
+ * toJSONOptions({ output: { path: 'types' }, group: { name: () => 'x' } }) // { output: { path: 'types' }, group: {} }
396
+ * ```
397
+ */
398
+ function toJSONOptions(options) {
399
+ try {
400
+ const copy = JSON.parse(JSON.stringify(options ?? {}, (key, value) => isSafeKey(key) && typeof value !== "bigint" ? value : void 0));
401
+ return isPlainObject(copy) ? copy : {};
402
+ } catch {
403
+ return {};
404
+ }
405
+ }
406
+ /**
335
407
  * Whether `value` is an object literal or a null-prototype object, not an array or class instance.
336
408
  */
337
409
  function isPlainObject(value) {
@@ -348,7 +420,7 @@ function isOptionValue(value) {
348
420
  if (value === null || typeof value === "string" || typeof value === "boolean") return true;
349
421
  if (typeof value === "number") return Number.isFinite(value);
350
422
  if (Array.isArray(value)) return value.every(isOptionValue);
351
- if (isPlainObject(value)) return Object.values(value).every(isOptionValue);
423
+ if (isPlainObject(value)) return Object.entries(value).every(([key, entry]) => isSafeKey(key) && isOptionValue(entry));
352
424
  return false;
353
425
  }
354
426
  /**
@@ -366,6 +438,7 @@ function isOptionValue(value) {
366
438
  function mergeOptions(disk, studio) {
367
439
  const merged = { ...disk };
368
440
  for (const [key, value] of Object.entries(studio)) {
441
+ if (!isSafeKey(key)) continue;
369
442
  const current = disk[key];
370
443
  if (isPlainObject(current) && isPlainObject(value)) {
371
444
  merged[key] = mergeOptions(current, value);
@@ -398,11 +471,16 @@ async function mergePlugins(diskPlugins, studioPlugins) {
398
471
  const merged = await Promise.all(activeDiskPlugins.map(async (diskPlugin) => {
399
472
  const studioEntry = studioEntryByName.get(diskPlugin.name);
400
473
  if (!studioEntry) return diskPlugin;
401
- const options = mergeOptions(diskPlugin.options ?? {}, studioEntry.options ?? {});
474
+ const diskOptions = diskPlugin.options ?? {};
475
+ const options = mergeOptions(diskOptions, studioEntry.options ?? {});
476
+ const unchanged = JSON.stringify(toJSONOptions(options)) === JSON.stringify(toJSONOptions(diskOptions));
402
477
  const [resolved] = await resolvePlugins([{
403
478
  name: studioEntry.name,
404
479
  options
405
- }]);
480
+ }]).catch((cause) => {
481
+ if (unchanged) return [diskPlugin];
482
+ 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 });
483
+ });
406
484
  return resolved ?? diskPlugin;
407
485
  }));
408
486
  const studioOnly = activeStudioPlugins.filter((entry) => !diskNames.has(toPluginName(entry.name)));
@@ -432,142 +510,130 @@ async function mergeAdapter(diskAdapter, studioOptions) {
432
510
  */
433
511
  const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
434
512
  /**
435
- * `key: value` as an object literal property, in the file's quote and key style.
436
- *
437
- * Uses magicast's literal builder for the key/value nodes, then wraps them as a Babel
438
- * `ObjectProperty`, the type the rest of this file reads.
439
- */
440
- function literalProperty({ key, value }) {
441
- const built = builders.literal({ [key]: value }).properties[0];
513
+ * The marker line a `disable-plugin` writes above the block it comments out. It carries the block's
514
+ * line count, so `enable-plugin` restores exactly those lines and not whatever comments follow.
515
+ */
516
+ const DISABLED_MARKER = /^\/\/ kubb:disabled (.+)\s+(\d+)$/;
517
+ const OPERATIONS = /* @__PURE__ */ new Set([
518
+ "set",
519
+ "remove",
520
+ "add-plugin",
521
+ "disable-plugin",
522
+ "enable-plugin"
523
+ ]);
524
+ /**
525
+ * Why an edit from Studio cannot be applied as sent, checked before anything reads its fields.
526
+ * The edit crossed the wire, so its declared type is a promise the sender may not have kept.
527
+ */
528
+ function invalidEdit(edit) {
529
+ const { operation, plugin, config, path, importName } = typeof edit === "object" && edit !== null ? edit : {};
530
+ if (typeof operation !== "string" || !OPERATIONS.has(operation)) return `unknown operation ${JSON.stringify(operation)}`;
531
+ if (typeof plugin !== "string" || !plugin) return "the edit names no plugin";
532
+ if (config !== void 0 && typeof config !== "string" && !Number.isSafeInteger(config)) return "config must name an entry or give its index";
533
+ if (operation === "set" || operation === "remove") {
534
+ if (!Array.isArray(path) || !path.every((key) => typeof key === "string" && key)) return "path must be a list of option names";
535
+ if (!path.length) return "no option path given";
536
+ const unsafe = path.find((key) => !isSafeKey(key));
537
+ if (unsafe !== void 0) return `${unsafe} cannot be used as an option name`;
538
+ }
539
+ if (operation === "add-plugin" && importName !== void 0 && typeof importName !== "string") return "importName must be a string";
540
+ }
541
+ /** Where a node sits in the source text. */
542
+ function rangeOf(node) {
543
+ if (typeof node.start !== "number" || typeof node.end !== "number") throw new Error("the config file could not be mapped back to its source text");
442
544
  return {
443
- type: "ObjectProperty",
444
- key: built.key,
445
- value: built.value,
446
- computed: false,
447
- shorthand: false
545
+ start: node.start,
546
+ end: node.end
448
547
  };
449
548
  }
450
- /**
451
- * Marks the comment block a `disable-plugin` leaves behind, so `enable-plugin` can find its way
452
- * back to the exact lines it commented out. Carries the block's line count, so `enable-plugin`
453
- * restores exactly those lines instead of scanning forward through whatever comments follow.
454
- */
455
- const DISABLED_MARKER = "kubb:disabled";
456
- /**
457
- * The plugin and comment-block length a marker line names, when `line` is one.
458
- */
459
- function parseMarker(line) {
460
- const trimmed = line.trim();
461
- if (!trimmed.startsWith(`// ${DISABLED_MARKER} `)) return;
462
- const match = trimmed.slice(`// ${DISABLED_MARKER} `.length).match(/^(.+)\s+(\d+)$/);
463
- return match ? {
464
- plugin: match[1],
465
- lineCount: Number(match[2])
466
- } : void 0;
549
+ /** `source` with the text between `start` and `end` replaced by `text`. */
550
+ function splice(source, start, end, text = "") {
551
+ return `${source.slice(0, start)}${text}${source.slice(end)}`;
552
+ }
553
+ function bodyOf(mod) {
554
+ return mod.$ast.type === "Program" ? mod.$ast.body : [];
467
555
  }
468
556
  /**
469
557
  * Steps through a config's wrappers to the object literal underneath: a `satisfies`/`as`
470
558
  * assertion, a `() => ...` factory, or a factory whose block body returns the config.
471
559
  */
472
560
  function unwrap(node) {
473
- if (!node) return;
561
+ if (!node) return void 0;
474
562
  if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression") return unwrap(node.expression);
475
563
  if (node.type !== "ArrowFunctionExpression" && node.type !== "FunctionExpression") return node;
476
564
  if (node.body.type !== "BlockStatement") return unwrap(node.body);
477
565
  return unwrap(node.body.body.find((statement) => statement.type === "ReturnStatement")?.argument);
478
566
  }
567
+ function isModuleExports(node) {
568
+ return node.type === "MemberExpression" && !node.computed && node.object.type === "Identifier" && node.object.name === "module" && node.property.type === "Identifier" && node.property.name === "exports";
569
+ }
479
570
  /**
480
- * Every config object in `export default defineConfig(...)`, or why the file is unmanaged.
481
- *
482
- * An array export gets one entry per element, matching {@link ConfigRef}'s numeric index.
571
+ * Parses `source` and finds every config object in `export default defineConfig(...)` or
572
+ * `module.exports = defineConfig(...)`, or why the file is unmanaged. An array export gets one entry
573
+ * per element, matching a numeric `config` index.
483
574
  *
484
575
  * Walks the parsed AST rather than magicast's proxies, which throw on node types they cannot
485
576
  * cast, most of what an unmanaged config file is made of.
486
577
  */
487
- function findConfigs(mod) {
488
- const exported = unwrap((mod.$ast.type === "Program" ? mod.$ast.body : []).find((node) => node.type === "ExportDefaultDeclaration")?.declaration);
489
- if (!exported) return { reason: "no default export found" };
490
- if (exported.type !== "CallExpression" || exported.callee.type !== "Identifier" || exported.callee.name !== "defineConfig") return { reason: "default export is not a defineConfig(...) call" };
491
- const argument = unwrap(exported.arguments[0]);
492
- if (!argument) return { reason: "defineConfig(...) was called without a config" };
493
- if (argument.type === "ArrayExpression") {
494
- const configs = [];
495
- for (const element of argument.elements) {
496
- const entry = unwrap(element);
497
- if (entry?.type !== "ObjectExpression") return { reason: "config is not an object literal" };
498
- configs.push(entry);
578
+ function parseConfigs(source) {
579
+ let mod;
580
+ try {
581
+ mod = parseModule(source);
582
+ } catch {
583
+ return { reason: "the config file could not be parsed" };
584
+ }
585
+ let exported;
586
+ let kind = "esm";
587
+ for (const node of bodyOf(mod)) {
588
+ if (node.type === "ExportDefaultDeclaration") {
589
+ exported = node.declaration;
590
+ break;
591
+ }
592
+ if (node.type === "ExpressionStatement" && node.expression.type === "AssignmentExpression" && isModuleExports(node.expression.left)) {
593
+ exported = node.expression.right;
594
+ kind = "cjs";
595
+ break;
499
596
  }
500
- return { configs };
501
597
  }
502
- if (argument.type !== "ObjectExpression") return { reason: "config is not an object literal" };
503
- return { configs: [argument] };
598
+ const value = unwrap(exported);
599
+ if (!value) return { reason: "no `export default` or `module.exports` found" };
600
+ if (value.type !== "CallExpression" || value.callee.type !== "Identifier" || value.callee.name !== "defineConfig") return { reason: "the exported config is not a defineConfig(...) call" };
601
+ const argument = unwrap(value.arguments[0]);
602
+ if (!argument) return { reason: "defineConfig(...) was called without a config" };
603
+ const entries = argument.type === "ArrayExpression" ? argument.elements.map((element) => unwrap(element)) : [argument];
604
+ const configs = entries.filter((entry) => entry?.type === "ObjectExpression");
605
+ if (configs.length !== entries.length) return { reason: "config is not an object literal" };
606
+ return {
607
+ mod,
608
+ configs,
609
+ kind
610
+ };
504
611
  }
505
- /**
506
- * The config entry an edit names, defaulting to the first when it names none.
507
- */
508
- function selectConfig(configs, ref) {
509
- if (ref === void 0) return configs[0];
510
- if (typeof ref === "number") return configs[ref];
511
- return configs.find((config) => configName(config) === ref);
612
+ /** The name of a `key` or `'key'` member, `undefined` for a spread or a computed key. */
613
+ function memberKey(entry) {
614
+ if (entry.type === "SpreadElement" || entry.computed) return void 0;
615
+ return entry.key.type === "Identifier" ? entry.key.name : entry.key.type === "StringLiteral" ? entry.key.value : void 0;
616
+ }
617
+ /** The member named `key`. The last one wins when the key repeats, as it does when the object is evaluated. */
618
+ function findMember(node, key) {
619
+ const index = node.properties.findLastIndex((entry) => memberKey(entry) === key);
620
+ return index === -1 ? void 0 : {
621
+ member: node.properties[index],
622
+ index
623
+ };
624
+ }
625
+ /** The value node of a member. A method or accessor is its own value: it is code, never a literal. */
626
+ function property(node, key) {
627
+ const member = findMember(node, key)?.member;
628
+ return member?.type === "ObjectProperty" ? member.value : member;
512
629
  }
513
630
  function configName(config) {
514
631
  const name = property(config, "name");
515
632
  return name?.type === "StringLiteral" ? name.value : void 0;
516
633
  }
517
- /**
518
- * The name of an object literal property, for the two key shapes a config uses: `key: value` and
519
- * `'key': value`. `undefined` for a computed key, which the patcher never touches.
520
- */
521
- function propertyKey(entry) {
522
- if (entry.key.type === "Identifier") return entry.key.name;
523
- if (entry.key.type === "StringLiteral") return entry.key.value;
524
- }
525
- /**
526
- * The index of an object literal's own property named `key`, `-1` when it has none.
527
- */
528
- function entryIndex({ node, key }) {
529
- return node.properties.findIndex((entry) => entry.type === "ObjectProperty" && propertyKey(entry) === key);
530
- }
531
- /**
532
- * The value node of an object literal's own property.
533
- */
534
- function property(node, key) {
535
- const index = entryIndex({
536
- node,
537
- key
538
- });
539
- return index === -1 ? void 0 : node.properties[index].value;
540
- }
541
- /**
542
- * Writes `key: value` on an object literal, replacing the value when the property is already there.
543
- *
544
- * An existing property has its value swapped in place rather than being replaced whole, so recast
545
- * reprints only that value and leaves the object's own layout alone.
546
- */
547
- function setProperty({ node, key, value }) {
548
- const entry = literalProperty({
549
- key,
550
- value
551
- });
552
- const index = entryIndex({
553
- node,
554
- key
555
- });
556
- if (index === -1) {
557
- node.properties.push(entry);
558
- return;
559
- }
560
- node.properties[index].value = entry.value;
561
- }
562
- /**
563
- * Drops `key` from an object literal.
564
- */
565
- function removeProperty({ node, key }) {
566
- const index = entryIndex({
567
- node,
568
- key
569
- });
570
- if (index !== -1) node.properties.splice(index, 1);
634
+ /** Whether a `...spread` after the member at `index` could override it. */
635
+ function isShadowed(node, index) {
636
+ return node.properties.slice(index + 1).some((entry) => entry.type === "SpreadElement");
571
637
  }
572
638
  /**
573
639
  * Reads a literal node's value: a primitive, or an object/array built only from primitives.
@@ -575,42 +641,109 @@ function removeProperty({ node, key }) {
575
641
  * whether a node is a literal at all.
576
642
  */
577
643
  function readLiteral(node) {
578
- if (!node) return;
579
- if (node.type === "StringLiteral" || node.type === "NumericLiteral" || node.type === "BooleanLiteral") return node.value;
580
- if (node.type === "NullLiteral") return null;
581
- if (node.type === "TemplateLiteral") return node.expressions.length === 0 ? node.quasis[0]?.value.cooked ?? "" : void 0;
582
- if (node.type === "UnaryExpression") {
583
- const value = readLiteral(node.argument);
584
- if (typeof value !== "number") return;
585
- if (node.operator === "-") return -value;
586
- if (node.operator === "+") return value;
587
- return;
588
- }
589
- if (node.type === "ArrayExpression") {
590
- const values = node.elements.map((element) => element ? readLiteral(element) : void 0);
591
- return values.every((value) => value !== void 0) ? values : void 0;
592
- }
593
- if (node.type === "ObjectExpression") {
594
- const entries = {};
595
- for (const entry of node.properties) {
596
- if (entry.type !== "ObjectProperty") return;
597
- const key = propertyKey(entry);
598
- const value = readLiteral(entry.value);
599
- if (key === void 0 || value === void 0) return;
600
- entries[key] = value;
644
+ switch (node?.type) {
645
+ case "StringLiteral":
646
+ case "NumericLiteral":
647
+ case "BooleanLiteral": return node.value;
648
+ case "NullLiteral": return null;
649
+ case "TemplateLiteral": return node.expressions.length ? void 0 : node.quasis[0]?.value.cooked ?? "";
650
+ case "UnaryExpression": {
651
+ const value = readLiteral(node.argument);
652
+ if (typeof value !== "number") return void 0;
653
+ return node.operator === "-" ? -value : node.operator === "+" ? value : void 0;
654
+ }
655
+ case "ArrayExpression": {
656
+ const values = node.elements.map((element) => readLiteral(element));
657
+ return values.every((value) => value !== void 0) ? values : void 0;
658
+ }
659
+ case "ObjectExpression": {
660
+ const entries = {};
661
+ for (const entry of node.properties) {
662
+ if (entry.type !== "ObjectProperty") return void 0;
663
+ const key = memberKey(entry);
664
+ const value = readLiteral(entry.value);
665
+ if (key === void 0 || !isSafeKey(key) || value === void 0) return void 0;
666
+ entries[key] = value;
667
+ }
668
+ return entries;
601
669
  }
602
- return entries;
603
670
  }
604
671
  }
605
- /**
606
- * Maps a factory identifier in the file back to the module it was imported from.
607
- */
672
+ /** Writes `value` as a single-line literal with the file's quote style. */
673
+ function printValue(value, quote) {
674
+ if (typeof value === "string") {
675
+ const escaped = JSON.stringify(value).slice(1, -1);
676
+ return quote === `"` ? `"${escaped}"` : `'${escaped.replaceAll(`\\"`, `"`).replaceAll(`'`, `\\'`)}'`;
677
+ }
678
+ if (value === null || typeof value !== "object") return String(value);
679
+ if (Array.isArray(value)) return `[${value.map((item) => printValue(item, quote)).join(", ")}]`;
680
+ const entries = Object.entries(value);
681
+ return entries.length ? `{ ${entries.map(([key, item]) => `${printKey(key, quote)}: ${printValue(item, quote)}`).join(", ")} }` : "{}";
682
+ }
683
+ /** An object key as source text: bare when it is an identifier, quoted otherwise. */
684
+ function printKey(key, quote) {
685
+ return IDENTIFIER.test(key) ? key : printValue(key, quote);
686
+ }
687
+ /** `value` wrapped in one object per key of `path`: `nest(['enum', 'type'], 'enum')` is `{ enum: { type: 'enum' } }`. */
688
+ function nest(path, value) {
689
+ return path.reduceRight((inner, key) => ({ [key]: inner }), value);
690
+ }
691
+ /**
692
+ * Adds `text` as the last item of an object or array literal, following its layout: on a line of
693
+ * its own with the last item's indentation when the literal spans lines, inline otherwise.
694
+ */
695
+ function insertItem(source, container, text) {
696
+ const { start, end } = rangeOf(container);
697
+ const last = (container.type === "ObjectExpression" ? container.properties : container.elements).filter((item) => item !== null).at(-1);
698
+ if (!last) {
699
+ const inner = source.slice(start + 1, end - 1);
700
+ if (inner.trim()) return splice(source, start + 1, start + 1, /^\s/.test(inner) ? `${text},` : `${text}, `);
701
+ return splice(source, start, end, container.type === "ObjectExpression" ? `{ ${text} }` : `[${text}]`);
702
+ }
703
+ const lastRange = rangeOf(last);
704
+ const between = source.slice(lastRange.end, end - 1);
705
+ if (!between.includes("\n")) return splice(source, lastRange.end, lastRange.end, `, ${text}`);
706
+ const lineStart = source.lastIndexOf("\n", lastRange.start - 1) + 1;
707
+ const indent = source.slice(lineStart).match(/^[ \t]*/)?.[0] ?? "";
708
+ const lineEnd = source.indexOf("\n", lastRange.end);
709
+ const comma = /^\s*,/.test(between);
710
+ return `${source.slice(0, lastRange.end)}${comma ? "" : ","}${source.slice(lastRange.end, lineEnd)}\n${indent}${text}${comma ? "," : ""}${source.slice(lineEnd)}`;
711
+ }
712
+ /** Drops the member at `index` from an object literal, with its own lines when it sits on them alone. */
713
+ function removeMember(source, node, index) {
714
+ if (node.properties.length === 1) return splice(source, rangeOf(node).start, rangeOf(node).end, "{}");
715
+ const { start, end } = rangeOf(node.properties[index]);
716
+ const lineStart = source.lastIndexOf("\n", start - 1) + 1;
717
+ const nextNewline = source.indexOf("\n", end);
718
+ const lineEnd = nextNewline === -1 ? source.length : nextNewline;
719
+ if (!source.slice(lineStart, start).trim() && /^\s*,?\s*(\/\/.*)?$/.test(source.slice(end, lineEnd))) return splice(source, lineStart, Math.min(lineEnd + 1, source.length));
720
+ const next = node.properties[index + 1];
721
+ return next ? splice(source, start, rangeOf(next).start) : splice(source, rangeOf(node.properties[index - 1]).end, end);
722
+ }
723
+ /** The module a `require('...')` call loads, when `node` is one. */
724
+ function requiredModule(node) {
725
+ if (node?.type !== "CallExpression" || node.callee.type !== "Identifier" || node.callee.name !== "require") return void 0;
726
+ const [argument] = node.arguments;
727
+ return argument?.type === "StringLiteral" ? argument.value : void 0;
728
+ }
729
+ /** Maps each identifier the file imports, through `import { x } from` or `const { x } = require(...)`, to its module. */
608
730
  function importedFrom(mod) {
609
- return new Map(mod.imports.$items.map((item) => [item.local, item.from]));
731
+ const imports = /* @__PURE__ */ new Map();
732
+ for (const statement of bodyOf(mod)) {
733
+ if (statement.type === "ImportDeclaration") {
734
+ for (const specifier of statement.specifiers) imports.set(specifier.local.name, statement.source.value);
735
+ continue;
736
+ }
737
+ if (statement.type !== "VariableDeclaration") continue;
738
+ for (const declarator of statement.declarations) {
739
+ const from = requiredModule(declarator.init);
740
+ if (from === void 0 || declarator.id.type !== "ObjectPattern") continue;
741
+ for (const entry of declarator.id.properties) if (entry.type === "ObjectProperty" && entry.value.type === "Identifier") imports.set(entry.value.name, from);
742
+ }
743
+ }
744
+ return imports;
610
745
  }
611
- /**
612
- * Every `pluginX(...)` element of a config's plugins array that resolves to an import.
613
- */
746
+ /** Every `pluginX(...)` element of a config's plugins array that resolves to an import. */
614
747
  function pluginCalls(mod, config) {
615
748
  const plugins = property(config, "plugins");
616
749
  if (plugins?.type !== "ArrayExpression") return [];
@@ -626,19 +759,51 @@ function pluginCalls(mod, config) {
626
759
  });
627
760
  }
628
761
  /**
629
- * Plugins a previous `disable-plugin` commented out of this config, keyed by package name.
630
- *
631
- * Read from the marker lines rather than the AST, since a commented-out call is no longer a node.
762
+ * The blocks a previous `disable-plugin` commented out of this config, read from the marker lines
763
+ * since a commented-out call is no longer a node. `code` holds the block's lines as they were.
632
764
  */
633
- function disabledMarkers(source) {
634
- return source.split("\n").flatMap((line, index) => {
635
- const marker = parseMarker(line);
636
- return marker ? [{
637
- packageName: marker.plugin,
638
- line: index + 1
639
- }] : [];
765
+ function disabledBlocks(source, config) {
766
+ const lines = source.split("\n");
767
+ const first = config.loc?.start.line ?? 0;
768
+ const last = config.loc?.end.line ?? Number.POSITIVE_INFINITY;
769
+ return lines.flatMap((text, index) => {
770
+ const match = text.trim().match(DISABLED_MARKER);
771
+ if (!match || index + 1 < first || index + 1 > last) return [];
772
+ const end = index + 1 + Number(match[2]);
773
+ const code = lines.slice(index + 1, end).map((line) => line.replace(/^(\s*)\/\/ ?/, "$1").replace(/^\s+$/, ""));
774
+ return [{
775
+ packageName: match[1],
776
+ line: index + 1,
777
+ end,
778
+ code
779
+ }];
640
780
  });
641
781
  }
782
+ /** The top-level options of a plugin call, each flagged with whether Studio may write it. */
783
+ function readOptions(call) {
784
+ const entries = {};
785
+ const options = call.arguments[0];
786
+ if (options?.type !== "ObjectExpression") return entries;
787
+ for (const [index, entry] of options.properties.entries()) {
788
+ const key = memberKey(entry);
789
+ if (key === void 0 || !isSafeKey(key)) continue;
790
+ const value = entry.type === "ObjectProperty" && !isShadowed(options, index) ? readLiteral(entry.value) : void 0;
791
+ entries[key] = value === void 0 ? { literal: false } : {
792
+ literal: true,
793
+ value
794
+ };
795
+ }
796
+ return entries;
797
+ }
798
+ /** The options a commented-out plugin call holds, so Studio can show them while it is disabled. */
799
+ function readDisabledOptions(code) {
800
+ try {
801
+ const call = parseExpression(code.join("\n").trim().replace(/,$/, "")).$ast;
802
+ return call.type === "CallExpression" ? readOptions(call) : {};
803
+ } catch {
804
+ return {};
805
+ }
806
+ }
642
807
  /**
643
808
  * Reads which plugins the file declares and which of their options Studio may write.
644
809
  *
@@ -658,37 +823,27 @@ function readConfig(source) {
658
823
  };
659
824
  const { mod, configs } = parsed;
660
825
  const importNames = new Map([...importedFrom(mod)].map(([local, from]) => [from, local]));
661
- const disabled = disabledMarkers(source);
662
826
  return {
663
827
  managed: true,
664
828
  configs: configs.map((config) => {
665
- const plugins = pluginCalls(mod, config).map(({ importName, packageName, call }) => {
666
- const entries = {};
667
- const options = call.arguments[0];
668
- if (options?.type === "ObjectExpression") for (const entry of options.properties) {
669
- if (entry.type !== "ObjectProperty") continue;
670
- const key = propertyKey(entry);
671
- if (key === void 0) continue;
672
- const value = readLiteral(entry.value);
673
- entries[key] = value === void 0 ? { literal: false } : {
674
- literal: true,
675
- value
676
- };
677
- }
678
- return {
829
+ const active = pluginCalls(mod, config).map(({ importName, packageName, call }) => ({
830
+ line: call.loc?.start.line ?? 0,
831
+ plugin: {
679
832
  importName,
680
833
  packageName,
681
- options: entries
682
- };
683
- });
684
- const start = config.loc?.start.line ?? 0;
685
- const end = config.loc?.end.line ?? Number.POSITIVE_INFINITY;
686
- for (const { packageName } of disabled.filter((entry) => entry.line >= start && entry.line <= end)) plugins.push({
687
- importName: importNames.get(packageName) ?? toExportName(packageName),
688
- packageName,
689
- options: {},
690
- disabled: true
691
- });
834
+ options: readOptions(call)
835
+ }
836
+ }));
837
+ const disabled = disabledBlocks(source, config).map(({ packageName, line, code }) => ({
838
+ line,
839
+ plugin: {
840
+ importName: importNames.get(packageName) ?? toExportName(packageName),
841
+ packageName,
842
+ options: readDisabledOptions(code),
843
+ disabled: true
844
+ }
845
+ }));
846
+ const plugins = [...active, ...disabled].sort((a, b) => a.line - b.line).map(({ plugin }) => plugin);
692
847
  return {
693
848
  name: configName(config),
694
849
  plugins
@@ -697,173 +852,131 @@ function readConfig(source) {
697
852
  };
698
853
  }
699
854
  /**
700
- * The options object of a plugin call, when it was called with one.
855
+ * Walks `path` through a plugin's options to the literal at its last key. Returns `missing` with
856
+ * the index of the first key that is not there yet, so `set` can write the rest of the path as one
857
+ * nested literal and `remove` can report it as unset. Refuses a value customized in code, so it is
858
+ * never overwritten.
701
859
  */
702
- function getOptions(call) {
703
- const options = call.arguments[0];
704
- return options?.type === "ObjectExpression" ? options : void 0;
705
- }
706
- /**
707
- * The options object of a plugin call, creating an empty one when the plugin was called bare.
708
- */
709
- function ensureOptions(call) {
710
- if (call.arguments.length === 0) call.arguments.push({
711
- type: "ObjectExpression",
712
- properties: []
713
- });
714
- return getOptions(call);
715
- }
716
- /**
717
- * Walks `path` down to the object holding its last key, descending only through object literals.
718
- */
719
- function optionParent(options, path) {
860
+ function walk(options, path) {
720
861
  let object = options;
721
- for (const [index, key] of path.entries()) {
722
- if (index === path.length - 1) return {
862
+ for (const [depth, key] of path.entries()) {
863
+ const found = findMember(object, key);
864
+ if (!found) return {
723
865
  object,
724
- key
866
+ missing: depth
725
867
  };
726
- if (property(object, key) === void 0) setProperty({
727
- node: object,
728
- key,
729
- value: {}
730
- });
731
- const next = property(object, key);
732
- if (next?.type !== "ObjectExpression") return { reason: `${key} is not an object, so ${path.join(".")} cannot be reached` };
733
- object = next;
868
+ 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` };
869
+ const { member, index } = found;
870
+ if (depth === path.length - 1) return member.type === "ObjectProperty" && readLiteral(member.value) !== void 0 ? {
871
+ object,
872
+ index,
873
+ value: member.value
874
+ } : { reason: `${path.join(".")} is customized in code` };
875
+ if (member.type !== "ObjectProperty" || member.value.type !== "ObjectExpression") return { reason: `${key} is not an object, so ${path.join(".")} cannot be reached` };
876
+ object = member.value;
734
877
  }
735
878
  return { reason: "no option path given" };
736
879
  }
737
- /**
738
- * Writes `value` at `path` inside a plugin call's options, creating the options object and any
739
- * intermediate object along the path as needed. Refuses when the current value at `path` is
740
- * something other than a literal, so an option customized in code is never overwritten.
741
- */
742
- function applySet(call, path, value) {
743
- if (!isOptionValue(value)) return "the value is not a literal that can be written to a config file";
744
- const options = ensureOptions(call);
745
- if (!options) return "the plugin was not called with an object literal";
746
- const target = optionParent(options, path);
747
- if ("reason" in target) return target.reason;
748
- const current = property(target.object, target.key);
749
- if (current !== void 0 && readLiteral(current) === void 0) return `${path.join(".")} is customized in code`;
750
- setProperty({
751
- node: target.object,
752
- key: target.key,
753
- value
754
- });
880
+ /** Writes `value` at `path` inside a plugin call's options, adding the options object and any intermediate object it needs. */
881
+ function applySet(source, call, path, value, quote) {
882
+ if (!isOptionValue(value)) return { reason: "the value is not a literal that can be written to a config file" };
883
+ const options = call.arguments[0];
884
+ if (!options) {
885
+ const closing = rangeOf(call).end - 1;
886
+ if (source[closing] !== ")") return { reason: "the plugin call could not be located in the file" };
887
+ return { source: splice(source, closing, closing, printValue(nest(path, value), quote)) };
888
+ }
889
+ if (options.type !== "ObjectExpression") return { reason: "the plugin was not called with an object literal" };
890
+ const target = walk(options, path);
891
+ if ("reason" in target) return target;
892
+ if ("missing" in target) {
893
+ const [key, ...rest] = path.slice(target.missing);
894
+ return { source: insertItem(source, target.object, `${printKey(key, quote)}: ${printValue(nest(rest, value), quote)}`) };
895
+ }
896
+ return { source: splice(source, rangeOf(target.value).start, rangeOf(target.value).end, printValue(value, quote)) };
897
+ }
898
+ /** Deletes the option at `path`, falling the plugin back to its default for it. */
899
+ function applyRemove(source, call, path) {
900
+ const options = call.arguments[0];
901
+ if (options?.type !== "ObjectExpression") return { reason: "the plugin has no options to remove" };
902
+ const target = walk(options, path);
903
+ if ("reason" in target) return target;
904
+ if ("missing" in target) return { reason: `${path.join(".")} is not set` };
905
+ return { source: removeMember(source, target.object, target.index) };
755
906
  }
756
907
  /**
757
- * Deletes the property at `path` inside a plugin call's options, falling the plugin back to its
758
- * default for that option. Refuses when the value at `path` is not a literal, for the same reason
759
- * `applySet` does.
908
+ * Writes an import after the last one already in the file, matching its quote style and whether it
909
+ * ends in a semicolon: `import { x } from '...'`, or `const { x } = require('...')` in a CommonJS file.
910
+ * The last import is read off the AST, so a multi-line `import {\n x,\n} from '...'` counts to its closing line.
760
911
  */
761
- function applyRemove(call, path) {
762
- const options = getOptions(call);
763
- if (!options) return "the plugin has no options to remove";
764
- const target = optionParent(options, path);
765
- if ("reason" in target) return target.reason;
766
- const current = property(target.object, target.key);
767
- if (current === void 0) return `${path.join(".")} is not set`;
768
- if (readLiteral(current) === void 0) return `${path.join(".")} is customized in code`;
769
- removeProperty({
770
- node: target.object,
771
- key: target.key
772
- });
912
+ function insertImport(source, { mod, kind }, importName, moduleSpecifier) {
913
+ 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;
914
+ const lines = source.split("\n");
915
+ const lastImportLine = afterLine > 0 ? lines[afterLine - 1] : void 0;
916
+ const quote = lastImportLine?.includes(`"`) ? `"` : `'`;
917
+ const semicolon = lastImportLine?.trimEnd().endsWith(";") ? ";" : "";
918
+ const specifier = `${quote}${moduleSpecifier}${quote}`;
919
+ lines.splice(afterLine, 0, kind === "esm" ? `import { ${importName} } from ${specifier}${semicolon}` : `const { ${importName} } = require(${specifier})${semicolon}`);
920
+ if (afterLine === 0) lines.splice(1, 0, "");
921
+ return lines.join("\n");
773
922
  }
774
923
  /**
775
- * Adds a `pluginX(...)` call to a config's plugins array. Replaying the same add is a no-op, while
776
- * an import name collision with an unrelated package remains an error.
924
+ * Adds a `pluginX(...)` call to a config's plugins array, and its import. Replaying the same add is
925
+ * a no-op, while an import name collision with an unrelated package remains an error.
777
926
  */
778
- function applyAddPlugin(mod, config, edit) {
927
+ function applyAddPlugin(source, parsed, config, edit, quote) {
779
928
  if (!isKubbPluginSpecifier(edit.plugin)) return { reason: `"${edit.plugin}" is not a @kubb/plugin-* package` };
780
929
  const importName = edit.importName ?? toExportName(edit.plugin);
781
930
  if (!IDENTIFIER.test(importName)) return { reason: `"${importName}" is not a valid import name` };
782
- if (pluginCalls(mod, config).some((plugin) => plugin.packageName === edit.plugin)) return { noop: true };
783
- const taken = importedFrom(mod).get(importName);
931
+ if (pluginCalls(parsed.mod, config).some((plugin) => plugin.packageName === edit.plugin)) return { source };
932
+ const taken = importedFrom(parsed.mod).get(importName);
784
933
  if (taken && taken !== edit.plugin) return { reason: `${importName} is already imported from ${taken}` };
785
934
  const options = edit.options ?? {};
786
935
  if (!isOptionValue(options)) return { reason: "the options are not literals that can be written to a config file" };
787
936
  const plugins = property(config, "plugins");
788
937
  if (plugins?.type !== "ArrayExpression") return { reason: "plugins is not an array literal" };
789
- const call = Object.keys(options).length ? builders.functionCall(importName, options) : builders.functionCall(importName);
790
- plugins.elements.push(call.$ast);
791
- return taken ? {} : { addImport: {
792
- importName,
793
- moduleSpecifier: edit.plugin
794
- } };
938
+ const next = insertItem(source, plugins, `${importName}(${Object.keys(options).length ? printValue(options, quote) : ""})`);
939
+ return { source: taken ? next : insertImport(next, parsed, importName, edit.plugin) };
795
940
  }
796
- /**
797
- * Comments out a plugin call in place, keeping its options on disk so `enable-plugin` can restore
798
- * them exactly. Operates on `source` text rather than the AST: a commented-out call is no longer a
799
- * node magicast can address, and the surrounding array must not reflow when its element count
800
- * never actually changes.
801
- */
941
+ /** Comments out a plugin call in place, keeping its options on disk so `enable-plugin` can restore them exactly. */
802
942
  function disablePlugin(source, mod, config, plugin) {
803
- const target = pluginCalls(mod, config).find((entry) => entry.packageName === plugin);
804
- if (!target) return { reason: `${plugin} is not in the plugins array` };
805
- const loc = target.call.loc;
806
- if (!loc?.start || !loc.end) return { reason: `${plugin} has no source location to comment out` };
943
+ const loc = pluginCalls(mod, config).find((entry) => entry.packageName === plugin)?.call.loc;
944
+ if (!loc) return { reason: `${plugin} is not in the plugins array` };
807
945
  const lines = source.split("\n");
808
946
  const from = loc.start.line - 1;
809
947
  const to = loc.end.line - 1;
810
948
  const firstLine = lines[from] ?? "";
811
- const lastLine = lines[to] ?? "";
812
- 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` };
949
+ 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` };
813
950
  const indent = firstLine.match(/^\s*/)?.[0] ?? "";
814
- const commented = lines.slice(from, to + 1).map((line) => line.trim() ? `${indent}// ${line.slice(indent.length)}` : indent ? `${indent}//` : "//");
815
- lines.splice(from, to - from + 1, `${indent}// ${DISABLED_MARKER} ${plugin} ${commented.length}`, ...commented);
951
+ const commented = lines.slice(from, to + 1).map((line) => line.trim() ? `${indent}// ${line.slice(indent.length)}` : `${indent}//`);
952
+ lines.splice(from, commented.length, `${indent}// kubb:disabled ${plugin} ${commented.length}`, ...commented);
816
953
  return { source: lines.join("\n") };
817
954
  }
818
- /**
819
- * Uncomments the block a previous `disable-plugin` left behind for `plugin`.
820
- */
821
- function enablePlugin(source, plugin) {
955
+ /** Uncomments the block a previous `disable-plugin` left behind for `plugin` in this config. */
956
+ function enablePlugin(source, config, plugin) {
957
+ const block = disabledBlocks(source, config).find((entry) => entry.packageName === plugin);
958
+ if (!block) return { reason: `${plugin} is not disabled` };
822
959
  const lines = source.split("\n");
823
- for (const [index, line] of lines.entries()) {
824
- const marker = parseMarker(line);
825
- if (marker?.plugin !== plugin) continue;
826
- const end = index + 1 + marker.lineCount;
827
- const restored = lines.slice(index + 1, end).map((commented) => commented.replace(/^(\s*)\/\/ ?/, "$1"));
828
- lines.splice(index, end - index, ...restored);
829
- return { source: lines.join("\n") };
830
- }
831
- return { reason: `${plugin} is not disabled` };
832
- }
833
- /**
834
- * Parses `source` and finds the config entries it exports.
835
- */
836
- function parseConfigs(source) {
837
- let mod;
838
- try {
839
- mod = parseModule(source);
840
- } catch {
841
- return { reason: "the config file could not be parsed" };
842
- }
843
- const found = findConfigs(mod);
844
- return "reason" in found ? found : {
845
- mod,
846
- configs: found.configs
847
- };
960
+ lines.splice(block.line - 1, block.end - block.line + 1, ...block.code);
961
+ return { source: lines.join("\n") };
848
962
  }
849
- /**
850
- * Re-parses `source` and resolves the config entry an edit targets. Every edit re-parses rather
851
- * than sharing one module across the batch, since the disable/enable edits rewrite `source` as
852
- * text and would otherwise leave the others working from a stale tree.
853
- */
854
- function parseTarget(source, ref) {
963
+ /** Applies one edit to `source`. Every edit re-parses the file, since the previous one rewrote it. */
964
+ function applyEdit(source, edit, quote) {
855
965
  const parsed = parseConfigs(source);
856
966
  if ("reason" in parsed) return parsed;
857
- const config = selectConfig(parsed.configs, ref);
967
+ const ref = edit.config;
968
+ const config = typeof ref === "string" ? parsed.configs.find((entry) => configName(entry) === ref) : parsed.configs[ref ?? 0];
858
969
  if (!config) return { reason: `no config entry found for ${JSON.stringify(ref)}` };
859
- return {
860
- mod: parsed.mod,
861
- config
862
- };
970
+ if (edit.operation === "disable-plugin") return disablePlugin(source, parsed.mod, config, edit.plugin);
971
+ if (edit.operation === "enable-plugin") return enablePlugin(source, config, edit.plugin);
972
+ if (edit.operation === "add-plugin") return applyAddPlugin(source, parsed, config, edit, quote);
973
+ const call = pluginCalls(parsed.mod, config).find((plugin) => plugin.packageName === edit.plugin)?.call;
974
+ 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` };
975
+ return edit.operation === "set" ? applySet(source, call, edit.path, edit.value, quote) : applyRemove(source, call, edit.path);
863
976
  }
864
977
  /**
865
- * Applies edits to a `kubb.config.ts` in place. Every node the edits do not touch keeps its
866
- * original text, so comments, formatting, and hand-written code around the config survive.
978
+ * Applies edits to a `kubb.config.ts` in place. Each edit rewrites only the text it targets, so
979
+ * comments, formatting, and hand-written code around the config survive.
867
980
  *
868
981
  * Edits are independent: one that cannot be applied is reported in `outcomes` and the rest still run.
869
982
  *
@@ -873,25 +986,19 @@ function parseTarget(source, ref) {
873
986
  * { operation: 'set', plugin: '@kubb/plugin-ts', path: ['enum', 'type'], value: 'enum' },
874
987
  * ])
875
988
  * ```
876
- *
877
- * @note recast always reprints a semicolon on a reprinted statement, so editing a block-body
878
- * `defineConfig` in a semicolon-free file adds one to the `return` line. This is a known gap.
879
- * Strip it when `detectCodeFormat` reports `useSemi: false`, if it turns out to matter in practice.
880
989
  */
881
990
  function applyConfigEdits(source, edits) {
882
991
  let current = source;
883
- const format = detectCodeFormat(source);
884
- const endsWithNewline = source.endsWith("\n");
992
+ const quote = detectCodeFormat(source).quote === "double" ? `"` : `'`;
885
993
  const outcomes = edits.map((edit) => {
886
- const target = parseTarget(current, edit.config);
887
- if ("reason" in target) return {
994
+ const invalid = invalidEdit(edit);
995
+ if (invalid) return {
888
996
  edit,
889
997
  applied: false,
890
- reason: target.reason
998
+ reason: invalid
891
999
  };
892
- const { mod, config } = target;
893
- if (edit.operation === "disable-plugin" || edit.operation === "enable-plugin") {
894
- const result = edit.operation === "disable-plugin" ? disablePlugin(current, mod, config, edit.plugin) : enablePlugin(current, edit.plugin);
1000
+ try {
1001
+ const result = applyEdit(current, edit, quote);
895
1002
  if ("reason" in result) return {
896
1003
  edit,
897
1004
  applied: false,
@@ -902,44 +1009,13 @@ function applyConfigEdits(source, edits) {
902
1009
  edit,
903
1010
  applied: true
904
1011
  };
905
- }
906
- if (edit.operation === "add-plugin") {
907
- const result = applyAddPlugin(mod, config, edit);
908
- if ("reason" in result) return {
909
- edit,
910
- applied: false,
911
- reason: result.reason
912
- };
913
- if ("noop" in result) return {
914
- edit,
915
- applied: true
916
- };
917
- const afterLine = lastImportEndLine(mod);
918
- let next = generateCode(mod, { format }).code;
919
- if (result.addImport) next = insertImportLine({
920
- source: next,
921
- afterLine,
922
- ...result.addImport
923
- });
924
- current = withTrailingNewline(next, endsWithNewline);
1012
+ } catch (error) {
925
1013
  return {
926
1014
  edit,
927
- applied: true
1015
+ applied: false,
1016
+ reason: getErrorMessage(error)
928
1017
  };
929
1018
  }
930
- const pluginCall = pluginCalls(mod, config).find((plugin) => plugin.packageName === edit.plugin);
931
- if (!pluginCall) return {
932
- edit,
933
- applied: false,
934
- reason: `${edit.plugin} is not in the plugins array`
935
- };
936
- const reason = edit.operation === "set" ? applySet(pluginCall.call, edit.path, edit.value) : applyRemove(pluginCall.call, edit.path);
937
- if (!reason) current = withTrailingNewline(generateCode(mod, { format }).code, endsWithNewline);
938
- return {
939
- edit,
940
- applied: !reason,
941
- reason
942
- };
943
1019
  });
944
1020
  return {
945
1021
  source: current,
@@ -947,42 +1023,12 @@ function applyConfigEdits(source, edits) {
947
1023
  changed: current !== source
948
1024
  };
949
1025
  }
1026
+ /** Applies `edits` to the config file at `filePath`, writing it only when something changed. */
950
1027
  async function writeConfigEdits({ filePath, edits }) {
951
1028
  const result = applyConfigEdits(await read(filePath), edits);
952
1029
  if (result.changed) await writeFile(filePath, result.source, "utf-8");
953
1030
  return result;
954
1031
  }
955
- /**
956
- * The 1-based line where the file's last import declaration ends, or `0` when it has none. Read
957
- * off the parsed module, so a multi-line `import {\n x,\n} from '...'` reports its closing line
958
- * rather than the `import` keyword.
959
- */
960
- function lastImportEndLine(mod) {
961
- return (mod.$ast.type === "Program" ? mod.$ast.body : []).filter((node) => node.type === "ImportDeclaration").at(-1)?.loc?.end.line ?? 0;
962
- }
963
- /**
964
- * Writes an import after the last one already in the file, matching its quote style and whether it
965
- * ends in a semicolon. `afterLine` is where that last import ends, `0` for a file with none.
966
- *
967
- * Written as plain text rather than through magicast's import builder, which prints a brand-new
968
- * import declaration with its own default spacing and a semicolon regardless of `format`, since
969
- * that formatting only governs nodes recast can diff against the original source.
970
- */
971
- function insertImportLine({ source, importName, moduleSpecifier, afterLine }) {
972
- const lines = source.split("\n");
973
- const lastImportLine = afterLine > 0 ? lines[afterLine - 1] : void 0;
974
- const quote = lastImportLine?.includes(`"`) ? `"` : `'`;
975
- const line = `import { ${importName} } from ${quote}${moduleSpecifier}${quote}${lastImportLine?.trimEnd().endsWith(";") ? ";" : ""}`;
976
- lines.splice(afterLine, 0, ...afterLine > 0 ? [line] : [line, ""]);
977
- return lines.join("\n");
978
- }
979
- /**
980
- * `generateCode` always drops the file's trailing newline. Restore it when the input had one.
981
- */
982
- function withTrailingNewline(code, hadTrailingNewline) {
983
- if (!hadTrailingNewline || code.endsWith("\n")) return code;
984
- return `${code}\n`;
985
- }
986
1032
  //#endregion
987
1033
  //#region src/generations.ts
988
1034
  const READ_CONCURRENCY = 50;
@@ -1034,19 +1080,21 @@ async function generate({ config, hooks, signal, allowExec }) {
1034
1080
  lint: false,
1035
1081
  postGenerate: []
1036
1082
  };
1037
- const result = await createKubb({
1083
+ const kubb = createKubb({
1038
1084
  ...config,
1039
1085
  output
1040
1086
  }, {
1041
1087
  hooks,
1042
1088
  signal
1043
- }).generate();
1089
+ });
1090
+ const result = await kubb.generate();
1044
1091
  if (!result.success) throw generationFailure(result.diagnostics);
1092
+ const { storage, root } = kubb.config;
1045
1093
  return {
1046
- storage: config.storage,
1047
- root: config.root,
1094
+ storage,
1095
+ root,
1048
1096
  paths: new Set(result.files.map((file) => relativeStoragePath({
1049
- root: config.root,
1097
+ root,
1050
1098
  filePath: file.path
1051
1099
  })))
1052
1100
  };
@@ -1103,7 +1151,8 @@ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now
1103
1151
  if (path.split("/").includes("..")) return;
1104
1152
  const content = await files.storage.readItem(resolve(files.root, path));
1105
1153
  if (content === null) return;
1106
- hashes[path] = hashOf(content);
1154
+ if (source === "output" && !content.trim()) return;
1155
+ hashes[path] = hashOf(source === "output" ? `${content.trim()}\n` : content);
1107
1156
  bytes += Buffer.byteLength(content);
1108
1157
  if (bytes <= maxSetBytes) await storage.writeItem(`${dirOf(jobId)}${source}/${path}`, content);
1109
1158
  }
@@ -1510,8 +1559,11 @@ var Agent = class extends RpcTarget {
1510
1559
  path: configPath,
1511
1560
  file,
1512
1561
  plugins: config.plugins.map((plugin) => ({
1513
- name: toPackageName(plugin.name),
1514
- options: plugin.options ?? {}
1562
+ name: toStudioName({
1563
+ name: plugin.name,
1564
+ root
1565
+ }),
1566
+ options: toJSONOptions(plugin.options)
1515
1567
  }))
1516
1568
  },
1517
1569
  permissions: this.#permissions
@@ -1568,11 +1620,15 @@ var Agent = class extends RpcTarget {
1568
1620
  }
1569
1621
  async #generate({ input, root, signal }) {
1570
1622
  const { jobId, config: patch } = input;
1623
+ const studioInput = this.#sandbox ? patch?.input : void 0;
1624
+ if (this.#sandbox && (typeof studioInput !== "string" || !studioInput.trim() || 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" });
1571
1625
  const config = await this.#options.loadConfig();
1572
- const plugins = await mergePlugins(config.plugins, patch?.plugins) ?? config.plugins;
1573
- const adapter = await mergeAdapter(config.adapter, patch?.adapter);
1574
- const studioInput = this.#sandbox ? patch?.input ?? "" : void 0;
1575
- 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" });
1626
+ const [plugins, adapter] = await Promise.all([mergePlugins(config.plugins, patch?.plugins), mergeAdapter(config.adapter, patch?.adapter)]).catch(async (error) => {
1627
+ await emit(this.#options.hooks, "kubb:error", { error: toError(error) });
1628
+ throw error;
1629
+ });
1630
+ const activePlugins = plugins ?? config.plugins;
1631
+ 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" });
1576
1632
  const diskFiles = !this.#sandbox && this.#permissions.allowRead ? await listDisk({
1577
1633
  root,
1578
1634
  outputPath: config.output.path,
@@ -1590,7 +1646,7 @@ var Agent = class extends RpcTarget {
1590
1646
  root,
1591
1647
  input: studioInput ?? config.input,
1592
1648
  storage: this.#permissions.allowWrite ? fsStorage() : memoryStorage(),
1593
- plugins,
1649
+ plugins: activePlugins,
1594
1650
  adapter
1595
1651
  },
1596
1652
  hooks: this.#options.hooks,
@@ -1620,7 +1676,7 @@ var Agent = class extends RpcTarget {
1620
1676
  hashes: output.hashes,
1621
1677
  disk: disk ? { hashes: disk.hashes } : void 0
1622
1678
  },
1623
- info: `${plugins.length} plugin${plugins.length === 1 ? "" : "s"}, ${this.#permissions.allowWrite ? "written to disk" : "in memory"}${studioInput !== void 0 ? ", from a Studio spec" : ""}`
1679
+ info: `${activePlugins.length} plugin${activePlugins.length === 1 ? "" : "s"}, ${this.#permissions.allowWrite ? "written to disk" : "in memory"}${studioInput !== void 0 ? ", from a Studio spec" : ""}`
1624
1680
  };
1625
1681
  }
1626
1682
  saveConfig(input) {
@@ -1775,8 +1831,12 @@ async function runSession({ options, openRpc, url, instanceId }) {
1775
1831
  clearTimeout(timer);
1776
1832
  signal?.removeEventListener("abort", rpc.close);
1777
1833
  session.connected.reject(/* @__PURE__ */ new Error("The socket closed before Studio called connect()"));
1778
- session.active?.abort();
1779
- await session.active?.settled;
1834
+ const active = session.active;
1835
+ if (active) {
1836
+ active.abort();
1837
+ await emit(hooks, "studio:warn", { message: "Canceled the running generation: the connection to Kubb Studio closed" });
1838
+ }
1839
+ await active?.settled;
1780
1840
  return {
1781
1841
  ...close,
1782
1842
  connected: session.info !== void 0
@@ -1991,29 +2051,24 @@ const gzipAsync = promisify(gzip);
1991
2051
  const UPLOAD_TIMEOUT_MS = 12e4;
1992
2052
  const BUNDLED_DEPENDENCIES = ["@kubb/plugin-barrel"];
1993
2053
  /**
1994
- * Maps a generated file's path to its place inside the tarball, stripping everything before a
1995
- * `src`/`dist` segment and any `..`/empty path segment so a crafted file name cannot escape the
1996
- * `package/` root.
2054
+ * Maps a generated file to its path in the package, from its `src`/`dist` segment on, without `..` parts that could escape it.
1997
2055
  */
1998
2056
  function packagePath(filePath) {
1999
2057
  const normalized = filePath.replaceAll("\\", "/");
2000
- return `package/${(normalized.match(/\/(?:src|dist)\/.*$/)?.[0].slice(1) ?? normalized.replace(/^\/+/, "")).split("/").filter((part) => part && part !== "." && part !== "..").join("/")}`;
2058
+ return (normalized.match(/\/(?:src|dist)\/.*$/)?.[0] ?? normalized).split("/").filter((part) => part && part !== "." && part !== "..").join("/");
2001
2059
  }
2002
2060
  /**
2003
- * Splits a tarball entry path into the legacy 100-byte `name` field and, when the path does not
2004
- * fit, the 155-byte USTAR `prefix` field that extends it. Throws rather than silently truncating
2005
- * a path the format cannot address (max 256 bytes: 100 name + 1 separator + 155 prefix).
2061
+ * Splits a tar path into the 100-byte USTAR `name` and the 155-byte `prefix`, and throws for a path that fits neither.
2006
2062
  */
2007
2063
  function splitEntryPath(path) {
2008
- if (Buffer.byteLength(path, "utf8") <= 100) return {
2064
+ if (Buffer.byteLength(path) <= 100) return {
2009
2065
  name: path,
2010
2066
  prefix: ""
2011
2067
  };
2012
- for (let i = path.length - 1; i >= 0; i--) {
2013
- if (path[i] !== "/") continue;
2068
+ for (let i = path.lastIndexOf("/"); i > 0; i = path.lastIndexOf("/", i - 1)) {
2014
2069
  const prefix = path.slice(0, i);
2015
2070
  const name = path.slice(i + 1);
2016
- if (Buffer.byteLength(prefix, "utf8") <= 155 && Buffer.byteLength(name, "utf8") <= 100) return {
2071
+ if (Buffer.byteLength(prefix) <= 155 && Buffer.byteLength(name) <= 100) return {
2017
2072
  name,
2018
2073
  prefix
2019
2074
  };
@@ -2023,21 +2078,23 @@ function splitEntryPath(path) {
2023
2078
  function header(path, size) {
2024
2079
  const { name, prefix } = splitEntryPath(path);
2025
2080
  const value = Buffer.alloc(512);
2026
- value.write(name, 0, "utf8");
2027
- value.write("0000644\0", 100, "ascii");
2028
- value.write("0000000\0", 108, "ascii");
2029
- value.write("0000000\0", 116, "ascii");
2030
- value.write(`${size.toString(8).padStart(11, "0")}\0`, 124, "ascii");
2031
- value.write("03560116604\0", 136, "ascii");
2081
+ value.write(name);
2082
+ value.write("0000644\0", 100);
2083
+ for (const offset of [
2084
+ 108,
2085
+ 116,
2086
+ 265,
2087
+ 297
2088
+ ]) value.write("0000000\0", offset);
2089
+ value.write(`${size.toString(8).padStart(11, "0")}\0`, 124);
2090
+ value.write("03560116604\0", 136);
2032
2091
  value.fill(32, 148, 156);
2033
- value.write("0", 156, "ascii");
2034
- value.write("ustar\0", 257, "ascii");
2035
- value.write("00", 263, "ascii");
2036
- value.write("0000000\0", 265, "ascii");
2037
- value.write("0000000\0", 297, "ascii");
2038
- value.write(prefix, 345, "utf8");
2039
- const checksum = [...value].reduce((sum, byte) => sum + byte, 0);
2040
- value.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, "ascii");
2092
+ value.write("0", 156);
2093
+ value.write("ustar\0", 257);
2094
+ value.write("00", 263);
2095
+ value.write(prefix, 345);
2096
+ const checksum = value.reduce((sum, byte) => sum + byte, 0);
2097
+ value.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148);
2041
2098
  return value;
2042
2099
  }
2043
2100
  /**
@@ -2045,31 +2102,29 @@ function header(path, size) {
2045
2102
  * generated sources, a `tsdown`-built `dist/` (esm + cjs), and a `package.json` manifest.
2046
2103
  */
2047
2104
  async function createSnapshotPackage({ files, packageInfo }) {
2105
+ const sources = /* @__PURE__ */ new Map();
2106
+ for (const [name, content] of Object.entries(files)) {
2107
+ const path = packagePath(name);
2108
+ const owner = sources.get(path)?.name;
2109
+ if (owner) throw new Error(`Snapshot has two generated files that sanitize to the same path "package/${path}": "${owner}" and "${name}"`);
2110
+ sources.set(path, {
2111
+ name,
2112
+ content
2113
+ });
2114
+ }
2048
2115
  const root = await mkdtemp(join(tmpdir(), "kubb-snapshot-"));
2049
2116
  const dist = join(root, "dist");
2050
- const resolvedPaths = Object.entries(files).map(([name, content]) => ({
2051
- name,
2052
- content,
2053
- target: packagePath(name)
2054
- }));
2055
- const targetOwners = /* @__PURE__ */ new Map();
2056
- for (const { name, target } of resolvedPaths) {
2057
- const owner = targetOwners.get(target);
2058
- if (owner) throw new Error(`Snapshot has two generated files that sanitize to the same path "${target}": "${owner}" and "${name}"`);
2059
- targetOwners.set(target, name);
2060
- }
2061
2117
  try {
2062
2118
  await mkdir(dist);
2063
- await Promise.all(resolvedPaths.map(async ({ content, target }) => {
2064
- const path = join(root, target.slice(8));
2065
- await mkdir(join(path, ".."), { recursive: true });
2066
- await writeFile(path, content);
2119
+ await Promise.all([...sources].map(async ([path, { content }]) => {
2120
+ await mkdir(dirname(join(root, path)), { recursive: true });
2121
+ await writeFile(join(root, path), content);
2067
2122
  }));
2068
- const sourceEntries = resolvedPaths.filter(({ name }) => /\.(?:[cm]?[jt]sx?)$/.test(name)).map(({ target }) => join(root, target.slice(8)));
2069
- if (sourceEntries.length) {
2123
+ const entry = [...sources.keys()].filter((path) => /\.(?:[cm]?[jt]sx?)$/.test(path)).map((path) => join(root, path));
2124
+ if (entry.length) {
2070
2125
  const { build } = await import("tsdown");
2071
2126
  await build({
2072
- entry: sourceEntries,
2127
+ entry,
2073
2128
  outDir: dist,
2074
2129
  format: ["esm", "cjs"],
2075
2130
  dts: false,
@@ -2080,38 +2135,38 @@ async function createSnapshotPackage({ files, packageInfo }) {
2080
2135
  fixedExtension: true
2081
2136
  });
2082
2137
  }
2083
- const builtEntries = await Promise.all((await fsStorage().readKeys(dist)).map(async (key) => [`package/dist/${key}`, await readFile(join(dist, key), "utf8")]));
2084
- const builtPaths = new Set(builtEntries.map(([path]) => path));
2085
- const hasBarrel = builtPaths.has("package/dist/index.mjs") && builtPaths.has("package/dist/index.cjs");
2086
- const barrelFields = hasBarrel ? {
2087
- main: "./dist/index.cjs",
2088
- module: "./dist/index.mjs"
2089
- } : {};
2090
- const barrelExport = hasBarrel ? { ".": {
2091
- import: "./dist/index.mjs",
2092
- require: "./dist/index.cjs"
2093
- } } : {};
2094
- const entries = {
2095
- "package/package.json": JSON.stringify({
2096
- ...packageInfo,
2097
- type: "module",
2098
- ...barrelFields,
2099
- exports: {
2100
- ...barrelExport,
2101
- "./*": {
2102
- import: "./dist/*.mjs",
2103
- require: "./dist/*.cjs"
2104
- }
2138
+ const built = await fsStorage().readKeys(dist);
2139
+ const hasBarrel = built.includes("index.mjs") && built.includes("index.cjs");
2140
+ const manifest = {
2141
+ ...packageInfo,
2142
+ type: "module",
2143
+ ...hasBarrel ? {
2144
+ main: "./dist/index.cjs",
2145
+ module: "./dist/index.mjs"
2146
+ } : {},
2147
+ exports: {
2148
+ ...hasBarrel ? { ".": {
2149
+ import: "./dist/index.mjs",
2150
+ require: "./dist/index.cjs"
2151
+ } } : {},
2152
+ "./*": {
2153
+ import: "./dist/*.mjs",
2154
+ require: "./dist/*.cjs"
2105
2155
  }
2106
- }, null, 2),
2107
- ...Object.fromEntries(resolvedPaths.map(({ content, target }) => [target, content])),
2108
- ...Object.fromEntries(builtEntries)
2156
+ }
2109
2157
  };
2110
- const chunks = [];
2111
- for (const [name, content] of Object.entries(entries)) {
2158
+ const chunks = [...new Map([
2159
+ ["package.json", JSON.stringify(manifest, null, 2)],
2160
+ ...[...sources].map(([path, { content }]) => [path, content]),
2161
+ ...await Promise.all(built.map(async (key) => [`dist/${key}`, await readFile(join(dist, key), "utf8")]))
2162
+ ])].flatMap(([path, content]) => {
2112
2163
  const bytes = Buffer.from(content);
2113
- chunks.push(header(name, bytes.length), bytes, Buffer.alloc((512 - bytes.length % 512) % 512));
2114
- }
2164
+ return [
2165
+ header(`package/${path}`, bytes.length),
2166
+ bytes,
2167
+ Buffer.alloc((512 - bytes.length % 512) % 512)
2168
+ ];
2169
+ });
2115
2170
  return await gzipAsync(Buffer.concat([...chunks, Buffer.alloc(1024)]));
2116
2171
  } finally {
2117
2172
  await rm(root, {
@@ -2120,25 +2175,12 @@ async function createSnapshotPackage({ files, packageInfo }) {
2120
2175
  });
2121
2176
  }
2122
2177
  }
2123
- function failureMessage({ status, _data }) {
2124
- const upgrade = _data?.data?.upgradeUrl ? ` Upgrade at ${_data.data.upgradeUrl}.` : "";
2125
- return `Kubb Studio refused the snapshot (status ${status})${_data?.message ? `: ${_data.message}` : ""}.${upgrade}`;
2126
- }
2127
2178
  /**
2128
- * Generates the project, packs it as an npm tarball, and publishes it to Studio in one
2129
- * `POST /api/snapshots` request.
2179
+ * Generates the project, packs it as an npm tarball, and publishes it to Studio in one `POST /api/snapshots` request.
2130
2180
  *
2131
2181
  * @example
2132
2182
  * ```ts
2133
- * const snapshot = await createSnapshot({
2134
- * apiKey: process.env.KUBB_TOKEN!,
2135
- * config,
2136
- * id: 'gh:123:pr-4',
2137
- * name: 'acme/api#4',
2138
- * packageName: '@acme/api',
2139
- * version: '1.0.0',
2140
- * })
2141
- * console.log(snapshot.url, snapshot.changes)
2183
+ * const { url } = await createSnapshot({ apiKey: process.env.KUBB_TOKEN!, config, id: 'gh:123:pr-4', name: 'acme/api#4', packageName: '@acme/api', version: '1.0.0' })
2142
2184
  * ```
2143
2185
  */
2144
2186
  async function createSnapshot({ studioUrl = defaultStudioUrl, apiKey, config, hooks, signal, ...metadata }) {
@@ -2178,7 +2220,7 @@ async function createSnapshot({ studioUrl = defaultStudioUrl, apiKey, config, ho
2178
2220
  }));
2179
2221
  body.set("tarball", new Blob([new Uint8Array(bytes)], { type: "application/gzip" }), "snapshot.tgz");
2180
2222
  const timeout = AbortSignal.timeout(UPLOAD_TIMEOUT_MS);
2181
- const response = await ofetch.raw(url.href, {
2223
+ const { ok, status, _data } = await ofetch.raw(url.href, {
2182
2224
  method: "POST",
2183
2225
  headers: { "x-api-key": apiKey },
2184
2226
  body,
@@ -2186,10 +2228,12 @@ async function createSnapshot({ studioUrl = defaultStudioUrl, apiKey, config, ho
2186
2228
  signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
2187
2229
  ignoreResponseError: true
2188
2230
  });
2189
- if (!response.ok) throw new Error(failureMessage(response));
2190
- const snapshot = response._data?.snapshot;
2191
- if (!snapshot) throw new Error(`Kubb Studio returned no snapshot (status ${response.status})`);
2192
- return snapshot;
2231
+ if (!ok) {
2232
+ const upgrade = _data?.data?.upgradeUrl ? ` Upgrade at ${_data.data.upgradeUrl}.` : "";
2233
+ throw new Error(`Kubb Studio refused the snapshot (status ${status})${_data?.message ? `: ${_data.message}` : ""}.${upgrade}`);
2234
+ }
2235
+ if (!_data?.snapshot) throw new Error(`Kubb Studio returned no snapshot (status ${status})`);
2236
+ return _data.snapshot;
2193
2237
  }
2194
2238
  //#endregion
2195
2239
  export { IncompatibleAgentError, InvalidAgentTokenError, PairingCanceledError, PairingDeniedError, PairingExpiredError, connectAgent, createSnapshot, defaultStudioUrl, pairAgent };