@kubb/studio 5.12.8 → 5.13.1

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
@@ -21,7 +21,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
21
21
  enumerable: true
22
22
  }) : target, mod));
23
23
  //#endregion
24
- const require_protocol = require("./protocol.cjs");
24
+ const require_protocol = require("./protocol-n7fVO3_7.cjs");
25
25
  let node_crypto = require("node:crypto");
26
26
  let node_fs = require("node:fs");
27
27
  let node_fs_promises = require("node:fs/promises");
@@ -35,10 +35,10 @@ let node_timers_promises = require("node:timers/promises");
35
35
  let tinyexec = require("tinyexec");
36
36
  let _kubb_core = require("@kubb/core");
37
37
  let capnweb = require("capnweb");
38
- let magicast = require("magicast");
39
38
  let node_url = require("node:url");
40
- let diff = require("diff");
41
39
  let node_util = require("node:util");
40
+ let yuku_parser = require("yuku-parser");
41
+ let diff = require("diff");
42
42
  let _kubb_ast = require("@kubb/ast");
43
43
  let node_dns = require("node:dns");
44
44
  node_dns = __toESM(node_dns, 1);
@@ -241,220 +241,112 @@ function getElapsedMs(hrStart) {
241
241
  }
242
242
  //#endregion
243
243
  //#region package.json
244
- var version = "5.12.8";
244
+ var version = "5.13.1";
245
245
  //#endregion
246
- //#region src/resolveConfig.ts
246
+ //#region ../../internals/shared/src/constants.ts
247
247
  /**
248
- * Turns the JSON config Studio sends back into live Kubb objects.
249
- *
250
- * A plugin or adapter instance carries closures (`parse`, `getImports`, ...) that cannot survive
251
- * JSON, so both sides pass options over the wire and the factory is re-invoked here with the merged
252
- * result. Only packages {@link isAllowedPlugin} accepts are resolved this way, never an arbitrary
253
- * module the payload names.
248
+ * Extensions `kubb` ships at its own version, so they count as installed without being a direct
249
+ * dependency. `constants.test.ts` checks it against the `kubb` package's dependencies.
254
250
  */
251
+ const BUNDLED_PACKAGES = [
252
+ "@kubb/adapter-oas",
253
+ "@kubb/parser-ts",
254
+ "@kubb/parser-md",
255
+ "@kubb/plugin-barrel"
256
+ ];
257
+ //#endregion
258
+ //#region ../../internals/shared/src/configFile/ast.ts
259
+ /** Node types the config editor reads from the yuku AST. */
260
+ const nodeTypes = {
261
+ arrayExpression: "ArrayExpression",
262
+ arrowFunctionExpression: "ArrowFunctionExpression",
263
+ assignmentExpression: "AssignmentExpression",
264
+ blockStatement: "BlockStatement",
265
+ callExpression: "CallExpression",
266
+ exportDefaultDeclaration: "ExportDefaultDeclaration",
267
+ expressionStatement: "ExpressionStatement",
268
+ functionExpression: "FunctionExpression",
269
+ identifier: "Identifier",
270
+ importDeclaration: "ImportDeclaration",
271
+ importSpecifier: "ImportSpecifier",
272
+ literal: "Literal",
273
+ memberExpression: "MemberExpression",
274
+ objectExpression: "ObjectExpression",
275
+ objectPattern: "ObjectPattern",
276
+ property: "Property",
277
+ returnStatement: "ReturnStatement",
278
+ spreadElement: "SpreadElement",
279
+ tSAsExpression: "TSAsExpression",
280
+ tSSatisfiesExpression: "TSSatisfiesExpression",
281
+ templateLiteral: "TemplateLiteral",
282
+ unaryExpression: "UnaryExpression",
283
+ variableDeclaration: "VariableDeclaration"
284
+ };
285
+ const commentTypes = { line: "Line" };
286
+ const propertyKinds = { init: "init" };
287
+ const unaryOperators = {
288
+ minus: "-",
289
+ plus: "+"
290
+ };
291
+ const diagnosticSeverities = { error: "error" };
255
292
  /**
256
- * Imports a package, falling back to how the user's project would resolve it.
257
- *
258
- * `import()` resolves from this file, so a linked or globally installed Studio (`pnpm link`,
259
- * `npm i -g`) only sees its own `node_modules` and misses the plugins installed next to the user's
260
- * config. The retry resolves from `process.cwd()` instead.
293
+ * Parses `source` as a TypeScript module. Never throws: a syntax error, or a native parser that
294
+ * cannot load on this platform, comes back as a `reason`.
261
295
  */
262
- async function importFromProject(packageName) {
296
+ function parseSource(source) {
263
297
  try {
264
- return await import(packageName);
265
- } catch {
266
- const resolved = (0, node_module.createRequire)((0, node_url.pathToFileURL)(`${process.cwd()}/`)).resolve(packageName);
267
- const esm = resolved.replace(/\.cjs$/, ".js");
268
- return await import((0, node_url.pathToFileURL)(esm !== resolved && (0, node_fs.existsSync)(esm) ? esm : resolved).href);
298
+ const { program, comments, diagnostics } = (0, yuku_parser.parse)(source, {
299
+ lang: "ts",
300
+ sourceType: "module",
301
+ preserveParens: false
302
+ });
303
+ if (diagnostics.some((diagnostic) => diagnostic.severity === diagnosticSeverities.error)) return { reason: "the config file could not be parsed" };
304
+ return {
305
+ program,
306
+ comments
307
+ };
308
+ } catch (error) {
309
+ return { reason: `the config parser could not load on this platform: ${getErrorMessage(error)}` };
269
310
  }
270
311
  }
271
- /**
272
- * Strips the `@kubb/` scope from a plugin package name, matching the `name` convention Kubb
273
- * plugin factories use internally.
274
- *
275
- * @example
276
- * ```ts
277
- * toPluginName('@kubb/plugin-ts') // 'plugin-ts'
278
- * ```
279
- */
312
+ /** Parses `source` as one expression, such as a commented-out `pluginTs({ ... })` call. */
313
+ function parseExpression(source) {
314
+ const parsed = parseSource(source);
315
+ if ("reason" in parsed) return void 0;
316
+ const [statement] = parsed.program.body;
317
+ return parsed.program.body.length === 1 && statement?.type === nodeTypes.expressionStatement ? statement.expression : void 0;
318
+ }
319
+ //#endregion
320
+ //#region ../../internals/shared/src/configFile/rules.ts
321
+ /** `@kubb/plugin-ts` becomes `plugin-ts`, the `name` Kubb plugin factories use. */
280
322
  function toPluginName(packageName) {
281
323
  return packageName.split("/").pop() ?? packageName;
282
324
  }
283
- /**
284
- * Adds the `@kubb/` scope a plugin's package carries but its `name` does not, the inverse of
285
- * {@link toPluginName}. A name outside the `plugin-` convention is left alone, so a third-party
286
- * plugin is not reported as one of Kubb's.
287
- *
288
- * @example
289
- * ```ts
290
- * toPackageName('plugin-ts') // '@kubb/plugin-ts'
291
- * toPackageName('@acme/my-plugin') // '@acme/my-plugin'
292
- * ```
293
- */
294
- function toPackageName(name) {
295
- return name.startsWith("plugin-") ? `@kubb/${name}` : name;
296
- }
297
- /**
298
- * The name Studio knows an extension by: the package of the installed extension whose manifest
299
- * `name` matches, otherwise its own `name`, so a plugin defined in the user's project is never
300
- * reported as a Kubb package that does not exist.
301
- *
302
- * @example
303
- * ```ts
304
- * toStudioName({ name: 'plugin-ts', extensions }) // '@kubb/plugin-ts'
305
- * toStudioName({ name: 'plugin-zod-operations', extensions }) // 'plugin-zod-operations', a local plugin
306
- * ```
307
- */
308
- function toStudioName({ name, extensions = /* @__PURE__ */ new Map(), kind = "plugin" }) {
309
- return [...extensions.values()].find(({ manifest }) => manifest.kind === kind && manifest.name === name)?.package ?? name;
310
- }
311
- /**
312
- * Derives the conventional named export for a `@kubb/*` plugin package from its package name.
313
- *
314
- * @example
315
- * ```ts
316
- * toExportName('@kubb/plugin-react-query') // 'pluginReactQuery'
317
- * toExportName('@kubb/plugin-ts') // 'pluginTs'
318
- * ```
319
- */
325
+ /** `@kubb/plugin-react-query` becomes `pluginReactQuery`, the export a plugin package uses by convention. */
320
326
  function toExportName(packageName) {
321
327
  return camelCase(toPluginName(packageName));
322
328
  }
323
329
  const KUBB_PLUGIN_SPECIFIER = /^@kubb\/plugin-[\w.-]+$/;
324
- /**
325
- * Whether Studio may import `packageName` as a plugin: a `@kubb/plugin-*` package, or an installed
326
- * package whose `kubb-extension.json` has `kind: "plugin"`. A path, URL or arbitrary package is
327
- * refused before it can execute. `configFile.ts` applies the same rule before printing a plugin name.
328
- */
329
- function isAllowedPlugin({ packageName, extensions }) {
330
+ /** Only a `@kubb/plugin-*` package or an installed extension of `kind` may be imported, never a path or URL. */
331
+ function isAllowedPlugin({ packageName, extensions, kind = require_protocol.extensionKinds.plugin }) {
330
332
  if (!(0, _kubb_core.isPackageName)(packageName)) return false;
331
- return KUBB_PLUGIN_SPECIFIER.test(packageName) || extensions.get(packageName)?.manifest.kind === "plugin";
332
- }
333
- /**
334
- * Dynamically imports an extension package and returns its factory. Callers check
335
- * {@link isAllowedPlugin} or derive the package from a disk instance first, so an arbitrary module
336
- * never reaches `import()`.
337
- *
338
- * Packages must be pre-installed in the Docker image at build time via the `KUBB_PACKAGES`
339
- * build ARG, no runtime installation is possible in the distroless container.
340
- *
341
- * Resolution order: the manifest's `export`, else the camelCase named export the package name
342
- * implies (e.g. `pluginTs`), then the default export.
343
- *
344
- * @throws if the package cannot be imported or exports no callable factory.
345
- */
346
- async function loadFactory({ packageName, extensions, kind = "plugin" }) {
347
- const label = {
348
- plugin: "Plugin",
349
- adapter: "Adapter",
350
- parser: "Parser"
351
- }[kind];
352
- let mod;
353
- try {
354
- mod = await importFromProject(packageName);
355
- } catch (cause) {
356
- throw new _kubb_core.Diagnostics.Error({
357
- code: kind === "plugin" ? _kubb_core.Diagnostics.code.pluginNotFound : _kubb_core.Diagnostics.code.unknown,
358
- severity: "error",
359
- message: `${label} "${packageName}" could not be loaded. Make sure it is installed: \`npm install ${packageName}\``,
360
- cause: toError(cause)
361
- });
362
- }
363
- const manifestExport = extensions.get(packageName)?.manifest.export;
364
- if (manifestExport) {
365
- if (typeof mod[manifestExport] === "function") return mod[manifestExport];
366
- throw new _kubb_core.Diagnostics.Error({
367
- code: _kubb_core.Diagnostics.code.incompatibleExtension,
368
- severity: "error",
369
- message: `${label} "${packageName}" does not export "${manifestExport}", the factory its kubb-extension.json names.`,
370
- help: "Install a version of the plugin whose exports match its kubb-extension.json."
371
- });
372
- }
373
- const exportName = toExportName(packageName);
374
- if (typeof mod[exportName] === "function") return mod[exportName];
375
- if (typeof mod["default"] === "function") return mod["default"];
376
- throw new _kubb_core.Diagnostics.Error({
377
- code: _kubb_core.Diagnostics.code.incompatibleExtension,
378
- severity: "error",
379
- message: `${label} "${packageName}" does not export a callable factory. Tried the named export "${exportName}" and "default".`,
380
- help: "Install a Kubb plugin package that exports its factory."
381
- });
382
- }
383
- /**
384
- * Resolves each plugin entry by dynamically importing its package and calling its factory with the
385
- * provided options. Only packages {@link isAllowedPlugin} accepts are loaded.
386
- *
387
- * @example
388
- * ```ts
389
- * const plugins = await resolvePlugins({
390
- * plugins: [
391
- * { name: '@kubb/plugin-react-query', options: { output: { path: './hooks' } } },
392
- * { name: '@kubb/plugin-ts', options: { output: { path: './types' } } },
393
- * ],
394
- * })
395
- * ```
396
- */
397
- async function resolvePlugins({ plugins, extensions = /* @__PURE__ */ new Map() }) {
398
- return Promise.all(plugins.map(async ({ name, options }) => {
399
- if (!isAllowedPlugin({
400
- packageName: name,
401
- extensions
402
- })) throw new _kubb_core.Diagnostics.Error({
403
- code: _kubb_core.Diagnostics.code.pluginNotFound,
404
- severity: "error",
405
- message: `Plugin "${name}" is not an installed Kubb plugin. Studio loads @kubb/plugin-* packages and installed packages that ship a kubb-extension.json with kind "plugin".`
406
- });
407
- return (await loadFactory({
408
- packageName: name,
409
- extensions
410
- }))(options ?? {});
411
- }));
333
+ return kind === require_protocol.extensionKinds.plugin && KUBB_PLUGIN_SPECIFIER.test(packageName) || extensions.get(packageName)?.manifest.kind === kind;
412
334
  }
413
- /**
414
- * Keys that reach an object's prototype when assigned. Never read from or written into options.
415
- */
416
335
  const UNSAFE_KEYS = /* @__PURE__ */ new Set([
417
336
  "__proto__",
418
337
  "constructor",
419
338
  "prototype"
420
339
  ]);
421
- /**
422
- * Whether `key` can be used as an option key without touching an object's prototype.
423
- */
340
+ /** Whether `key` can be an option key without reaching the object's prototype. */
424
341
  function isSafeKey(key) {
425
342
  return !UNSAFE_KEYS.has(key);
426
343
  }
427
- /**
428
- * A JSON copy of plugin or adapter options: functions and other values JSON cannot hold are left
429
- * out, so nothing callable crosses the socket. Options that cannot be copied at all, such as a
430
- * circular structure, come back empty.
431
- *
432
- * @example
433
- * ```ts
434
- * toJSONOptions({ output: { path: 'types' }, group: { name: () => 'x' } }) // { output: { path: 'types' }, group: {} }
435
- * ```
436
- */
437
- function toJSONOptions(options) {
438
- try {
439
- const copy = JSON.parse(JSON.stringify(options ?? {}, (key, value) => isSafeKey(key) && typeof value !== "bigint" ? value : void 0));
440
- return isPlainObject(copy) ? copy : {};
441
- } catch {
442
- return {};
443
- }
444
- }
445
- /**
446
- * Whether `value` is an object literal or a null-prototype object, not an array or class instance.
447
- */
448
344
  function isPlainObject(value) {
449
345
  if (typeof value !== "object" || value === null) return false;
450
346
  const prototype = Object.getPrototypeOf(value);
451
347
  return prototype === null || prototype === Object.prototype;
452
348
  }
453
- /**
454
- * Whether `value` survives a JSON round trip, so it can be printed into a config file as a literal.
455
- * A function, `undefined`, a non-finite number or a class instance comes back from Studio as a
456
- * lossy copy, so an edit carrying one is refused rather than written into the user's source.
457
- */
349
+ /** Whether `value` survives a JSON round trip, so an edit never writes a lossy copy into the file. */
458
350
  function isOptionValue(value) {
459
351
  if (value === null || typeof value === "string" || typeof value === "boolean") return true;
460
352
  if (typeof value === "number") return Number.isFinite(value);
@@ -462,375 +354,250 @@ function isOptionValue(value) {
462
354
  if (isPlainObject(value)) return Object.entries(value).every(([key, entry]) => isSafeKey(key) && isOptionValue(entry));
463
355
  return false;
464
356
  }
357
+ /** The root `output` members Studio may set, with the values each takes. */
358
+ const OUTPUT_VALUES = {
359
+ barrel: (value) => value === false || isPlainObject(value) && Object.keys(value).length === 1 && (value.type === "named" || value.type === "all"),
360
+ format: (value) => value === false || [
361
+ "auto",
362
+ "prettier",
363
+ "biome",
364
+ "oxfmt"
365
+ ].includes(value),
366
+ lint: (value) => value === false || [
367
+ "auto",
368
+ "eslint",
369
+ "biome",
370
+ "oxlint"
371
+ ].includes(value),
372
+ clean: (value) => typeof value === "boolean",
373
+ defaultBanner: (value) => value === false || value === "simple" || value === "full"
374
+ };
375
+ function isOutputKey(key) {
376
+ return Object.hasOwn(OUTPUT_VALUES, key);
377
+ }
378
+ function isOutputValue({ key, value }) {
379
+ return isOutputKey(key) && OUTPUT_VALUES[key](value);
380
+ }
381
+ function pickOutput(output) {
382
+ return Object.fromEntries(Object.entries(output).filter(([key]) => isOutputKey(key)));
383
+ }
384
+ //#endregion
385
+ //#region ../../internals/shared/src/configFile/text.ts
465
386
  /**
466
- * Deep-merges Studio's options over the disk options, with Studio taking priority, except where the
467
- * disk value cannot survive JSON. Studio only ever saw a lossy copy of such a value, so the disk
468
- * value is kept rather than replaced by what Studio echoes back. Plain objects merge key by key, so
469
- * Studio can still change a literal that sits next to a function (e.g. `group.type` beside `group.name`).
470
- *
471
- * @example
472
- * ```ts
473
- * mergeOptions({ macros: [camelCaseMacro], syntaxType: 'type' }, { macros: [{ name: 'camelCase' }], syntaxType: 'interface' })
474
- * // { macros: [camelCaseMacro], syntaxType: 'interface' }
475
- * mergeOptions({ emptySchemaType: 'any' }, { emptySchemaType: null }) // {}
476
- * ```
387
+ * A valid JavaScript identifier, so an import name can only ever print as `import { name } from`,
388
+ * never as source that breaks out of the import statement.
477
389
  */
478
- function mergeOptions(disk, studio) {
479
- const merged = { ...disk };
480
- for (const [key, value] of Object.entries(studio)) {
481
- if (!isSafeKey(key)) continue;
482
- const current = disk[key];
483
- if (value === null) {
484
- if (current === void 0 || isOptionValue(current)) delete merged[key];
485
- continue;
486
- }
487
- if (isPlainObject(current) && isPlainObject(value)) {
488
- merged[key] = mergeOptions(current, value);
489
- continue;
490
- }
491
- if (current !== void 0 && !isOptionValue(current)) continue;
492
- merged[key] = value;
390
+ const IDENTIFIER_PATTERN = /^[A-Za-z_$][\w$]*$/;
391
+ const quotes = {
392
+ single: `'`,
393
+ double: `"`
394
+ };
395
+ /** `source` with the text between `start` and `end` replaced by `text`. */
396
+ function replaceText(source, start, end, text = "") {
397
+ return `${source.slice(0, start)}${text}${source.slice(end)}`;
398
+ }
399
+ /** The 1-based line and 0-based column of `offset` in `source`. */
400
+ function getPosition(source, offset) {
401
+ return {
402
+ line: source.slice(0, offset).split("\n").length,
403
+ column: offset - source.lastIndexOf("\n", offset - 1) - 1
404
+ };
405
+ }
406
+ /** Writes `value` as a single-line literal with the file's quote style. */
407
+ function printValue(value, quote) {
408
+ if (typeof value === "string") {
409
+ const escaped = JSON.stringify(value).slice(1, -1);
410
+ return quote === quotes.double ? `"${escaped}"` : `'${escaped.replaceAll(`\\"`, `"`).replaceAll(`'`, `\\'`)}'`;
493
411
  }
494
- return merged;
412
+ if (value === null || typeof value !== "object") return String(value);
413
+ if (Array.isArray(value)) return `[${value.map((item) => printValue(item, quote)).join(", ")}]`;
414
+ const entries = Object.entries(value);
415
+ return entries.length ? `{ ${entries.map(([key, item]) => `${printKey(key, quote)}: ${printValue(item, quote)}`).join(", ")} }` : "{}";
495
416
  }
496
- /**
497
- * Merges studio plugin options with disk config plugins.
498
- * Studio takes priority: options from studio win over disk (except values only the disk config
499
- * can express, see {@link mergeOptions}), and a plugin Studio explicitly
500
- * disabled is dropped even when the disk config still lists it. Disk plugins without a studio
501
- * counterpart are kept as-is. Studio plugins not present on disk are appended.
502
- *
503
- * For plugins present in both configs, the plugin is re-instantiated with merged options
504
- * so that all internal closures correctly reference the merged values.
505
- */
506
- async function mergePlugins({ diskPlugins, studioPlugins, extensions = /* @__PURE__ */ new Map() }) {
507
- const pluginNameOf = (packageName) => extensions.get(packageName)?.manifest.name ?? toPluginName(packageName);
508
- const disabledNames = new Set((studioPlugins ?? []).filter((entry) => entry.disabled).map((entry) => pluginNameOf(entry.name)));
509
- const activeDiskPlugins = disabledNames.size ? diskPlugins?.filter((plugin) => !disabledNames.has((0, _kubb_core.resolveMeta)(plugin).name)) : diskPlugins;
510
- const activeStudioPlugins = studioPlugins?.filter((entry) => !entry.disabled);
511
- if (!activeDiskPlugins && !activeStudioPlugins?.length) return void 0;
512
- if (!activeStudioPlugins?.length) return activeDiskPlugins;
513
- if (!activeDiskPlugins) return resolvePlugins({
514
- plugins: activeStudioPlugins,
515
- extensions
516
- });
517
- const studioEntryByName = new Map(activeStudioPlugins.map((entry) => [pluginNameOf(entry.name), entry]));
518
- const diskNames = new Set(activeDiskPlugins.map((plugin) => plugin.name));
519
- const merged = await Promise.all(activeDiskPlugins.map(async (diskPlugin) => {
520
- const studioEntry = studioEntryByName.get((0, _kubb_core.resolveMeta)(diskPlugin).name);
521
- if (!studioEntry) return diskPlugin;
522
- const diskOptions = diskPlugin.options ?? {};
523
- const options = mergeOptions(diskOptions, studioEntry.options ?? {});
524
- const unchanged = JSON.stringify(toJSONOptions(options)) === JSON.stringify(toJSONOptions(diskOptions));
525
- const [resolved] = await resolvePlugins({
526
- plugins: [{
527
- name: studioEntry.name,
528
- options
529
- }],
530
- extensions
531
- }).catch((cause) => {
532
- if (unchanged) return [diskPlugin];
533
- throw new _kubb_core.Diagnostics.Error({
534
- code: _kubb_core.Diagnostics.code.pluginNotFound,
535
- severity: "error",
536
- message: `Plugin "${diskPlugin.name}" is not an installed @kubb/plugin-* package or Kubb extension, so Studio cannot change its options. Edit it in the config file, or only enable or disable it from Studio.`,
537
- cause: toError(cause)
538
- });
539
- });
540
- return resolved ?? diskPlugin;
541
- }));
542
- const studioOnly = activeStudioPlugins.filter((entry) => !diskNames.has(pluginNameOf(entry.name)));
543
- return [...merged, ...await resolvePlugins({
544
- plugins: studioOnly,
545
- extensions
546
- })];
547
- }
548
- /**
549
- * Throws `KUBB_PATH_TRAVERSAL` when a plugin would write outside the config's output folder to a path
550
- * the disk config does not set. Studio can change `output.path`, also inside `override`, and add
551
- * plugins, so without this check any member of the Studio organization could write any file the agent
552
- * can reach, including `node_modules` code the next run imports. A path from the config file stays
553
- * allowed, since a monorepo may write outside its root.
554
- */
555
- function assertOutputPaths({ plugins, diskPlugins, root, outputPath }) {
556
- const base = node_path.default.resolve(root, outputPath);
557
- const targetsOf = (plugin) => {
558
- const { output, override } = plugin.options ?? {};
559
- return [output, ...Array.isArray(override) ? override.map((entry) => entry?.options?.output) : []].flatMap((entry) => typeof entry?.path === "string" ? [node_path.default.resolve(base, entry.path)] : []);
560
- };
561
- const diskTargets = new Set(diskPlugins.flatMap(targetsOf));
562
- for (const plugin of plugins) {
563
- const target = targetsOf(plugin).find((resolved) => !diskTargets.has(resolved) && !isPathInside(resolved, base));
564
- if (!target) continue;
565
- throw new _kubb_core.Diagnostics.Error({
566
- code: _kubb_core.Diagnostics.code.pathTraversal,
567
- severity: "error",
568
- message: `Plugin "${plugin.name}" would write to "${target}", outside the output folder "${base}". Studio can only set an output path inside it.`,
569
- help: "Choose an output path inside the output folder, or set this path in the config file.",
570
- location: { kind: "config" },
571
- plugin: plugin.name
572
- });
573
- }
574
- }
575
- /**
576
- * Re-invokes the factory behind a disk adapter or parser with Studio's options merged over its own.
577
- * An instance carries live functions (`parse`, `print`, ...) that cannot survive JSON, so Studio's
578
- * options are a patch, not a replacement, and the factory is found from the disk instance itself: the
579
- * installed extension whose manifest names it, or the `@kubb/<kind>-<name>` package for a built-in
580
- * one. Throws when no factory resolves, as `mergePlugins` does, so an override Studio sent is never
581
- * dropped without a word. An extension without a manifest, such as `parserTsx`, has no options in
582
- * Studio, so Studio never sends one for it.
583
- */
584
- async function rebuildExtension({ disk, studioOptions, kind, extensions }) {
585
- if (!studioOptions || !Object.keys(studioOptions).length) return disk;
586
- const { name } = (0, _kubb_core.resolveMeta)(disk);
587
- return (await loadFactory({
588
- packageName: [...extensions.values()].find(({ manifest }) => manifest.kind === kind && manifest.name === name)?.package ?? `@kubb/${kind}-${name}`,
589
- extensions,
590
- kind
591
- }).catch((cause) => {
592
- throw new _kubb_core.Diagnostics.Error({
593
- code: kind === "plugin" ? _kubb_core.Diagnostics.code.pluginNotFound : _kubb_core.Diagnostics.code.unknown,
594
- severity: "error",
595
- message: `The ${kind} "${name}" is not an installed Kubb extension, so Studio cannot change its options. Edit it in the config file.`,
596
- cause: toError(cause)
597
- });
598
- }))(mergeOptions(disk.options ?? {}, studioOptions));
599
- }
600
- /** The root `output` members Studio may set, with the values each takes. The rest, `path` and `postGenerate` among them, stay with the config file. */
601
- const OUTPUT_VALUES = {
602
- barrel: (value) => value === false || isPlainObject(value) && Object.keys(value).length === 1 && (value.type === "named" || value.type === "all"),
603
- format: (value) => value === false || [
604
- "auto",
605
- "prettier",
606
- "biome",
607
- "oxfmt"
608
- ].includes(value),
609
- lint: (value) => value === false || [
610
- "auto",
611
- "eslint",
612
- "biome",
613
- "oxlint"
614
- ].includes(value),
615
- clean: (value) => typeof value === "boolean",
616
- defaultBanner: (value) => value === false || value === "simple" || value === "full"
617
- };
618
- /** Whether Studio may set the root `output` member `key`. */
619
- function isOutputKey(key) {
620
- return Object.hasOwn(OUTPUT_VALUES, key);
621
- }
622
- /** Whether Studio may set the root `output` member `key` to `value`. */
623
- function isOutputValue({ key, value }) {
624
- return isOutputKey(key) && OUTPUT_VALUES[key](value);
625
- }
626
- /** The members of `output` Studio may see and set. */
627
- function pickOutput(output) {
628
- return Object.fromEntries(Object.entries(output).filter(([key]) => isOutputKey(key)));
417
+ /** An object key as source text: bare when it is an identifier, quoted otherwise. */
418
+ function printKey(key, quote) {
419
+ return IDENTIFIER_PATTERN.test(key) ? key : printValue(key, quote);
629
420
  }
630
- /** Merges Studio's valid `output` overrides, with `null` unsetting a member. */
631
- function mergeOutput({ output, studio }) {
632
- if (!isPlainObject(studio)) return output;
633
- return mergeOptions(output, Object.fromEntries(Object.entries(studio).filter(([key, value]) => value === null ? isOutputKey(key) : isOutputValue({
634
- key,
635
- value
636
- }))));
421
+ /** `value` wrapped in one object per key of `path`: `wrapPath(['enum', 'type'], 'enum')` is `{ enum: { type: 'enum' } }`. */
422
+ function wrapPath(path, value) {
423
+ return path.reduceRight((inner, key) => ({ [key]: inner }), value);
637
424
  }
638
425
  /**
639
- * Merges Studio-provided adapter option overrides into the disk config's adapter, see {@link rebuildExtension}.
426
+ * Adds `text` as the last item of an object or array literal, following its layout: on a line of
427
+ * its own with the last item's indentation when the literal spans lines, inline otherwise.
640
428
  */
641
- async function mergeAdapter(diskAdapter, studioOptions, extensions = /* @__PURE__ */ new Map()) {
642
- if (!diskAdapter) return diskAdapter;
643
- return rebuildExtension({
644
- disk: diskAdapter,
645
- studioOptions,
646
- kind: "adapter",
647
- extensions
648
- });
429
+ function appendItem(source, container, text) {
430
+ const { start, end } = container;
431
+ const last = (container.type === nodeTypes.objectExpression ? container.properties : container.elements).filter((item) => item !== null).at(-1);
432
+ if (!last) {
433
+ const inner = source.slice(start + 1, end - 1);
434
+ if (inner.trim()) return replaceText(source, start + 1, start + 1, /^\s/.test(inner) ? `${text},` : `${text}, `);
435
+ return replaceText(source, start, end, container.type === nodeTypes.objectExpression ? `{ ${text} }` : `[${text}]`);
436
+ }
437
+ const between = source.slice(last.end, end - 1);
438
+ if (!between.includes("\n")) return replaceText(source, last.end, last.end, `, ${text}`);
439
+ const lineStart = source.lastIndexOf("\n", last.start - 1) + 1;
440
+ const indent = source.slice(lineStart).match(/^[ \t]*/)?.[0] ?? "";
441
+ const lineEnd = source.indexOf("\n", last.end);
442
+ const comma = /^\s*,/.test(between);
443
+ return `${source.slice(0, last.end)}${comma ? "" : ","}${source.slice(last.end, lineEnd)}\n${indent}${text}${comma ? "," : ""}${source.slice(lineEnd)}`;
649
444
  }
650
- /**
651
- * Merges Studio-provided parser option overrides into the disk config's parsers, matched by the name
652
- * Studio knows each parser by, see {@link rebuildExtension}. Parsers Studio does not name are kept as is.
653
- */
654
- async function mergeParsers({ diskParsers, studioParsers, extensions = /* @__PURE__ */ new Map() }) {
655
- if (!studioParsers?.length) return diskParsers;
656
- const studioOptions = new Map(studioParsers.map((entry) => [entry.name, entry.options]));
657
- return Promise.all(diskParsers.map((parser) => rebuildExtension({
658
- disk: parser,
659
- studioOptions: studioOptions.get(toStudioName({
660
- name: (0, _kubb_core.resolveMeta)(parser).name,
661
- extensions,
662
- kind: "parser"
663
- })),
664
- kind: "parser",
665
- extensions
666
- })));
445
+ /** Drops the item at `index` from an object or array literal, with its own lines when it sits on them alone. */
446
+ function removeItem(source, node, index) {
447
+ const items = node.type === nodeTypes.objectExpression ? node.properties : node.elements.filter((element) => element !== null);
448
+ if (items.length === 1) return replaceText(source, node.start, node.end, node.type === nodeTypes.objectExpression ? "{}" : "[]");
449
+ const { start, end } = items[index];
450
+ const lineStart = source.lastIndexOf("\n", start - 1) + 1;
451
+ const nextNewline = source.indexOf("\n", end);
452
+ const lineEnd = nextNewline === -1 ? source.length : nextNewline;
453
+ if (!source.slice(lineStart, start).trim() && /^\s*,?\s*(\/\/.*)?$/.test(source.slice(end, lineEnd))) return replaceText(source, lineStart, Math.min(lineEnd + 1, source.length));
454
+ const next = items[index + 1];
455
+ return next ? replaceText(source, start, next.start) : replaceText(source, items[index - 1].end, end);
667
456
  }
668
457
  //#endregion
669
- //#region src/configFile.ts
670
- /**
671
- * A valid JavaScript identifier, so an import name can only ever print as `import { name } from`,
672
- * never as source that breaks out of the import statement.
673
- */
674
- const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
675
- /**
676
- * The marker line a `disable-plugin` writes above the block it comments out. It carries the block's
677
- * line count, so `enable-plugin` restores exactly those lines and not whatever comments follow.
678
- */
679
- const DISABLED_MARKER = /^\/\/ kubb:disabled (.+)\s+(\d+)$/;
458
+ //#region ../../internals/shared/src/configFile/configFile.ts
459
+ /** The marker `disable-plugin` writes, with the block's line count so `enable-plugin` restores exactly those lines. */
460
+ const DISABLED_MARKER_PATTERN = /^kubb:disabled (.+)\s+(\d+)$/;
680
461
  const OPERATIONS = new Set(Object.values(require_protocol.ConfigEditOperation));
681
- const KINDS = /* @__PURE__ */ new Set([
682
- "plugin",
683
- "adapter",
684
- "parser"
685
- ]);
686
- /**
687
- * Why an edit from Studio cannot be applied as sent, checked before anything reads its fields.
688
- * The edit crossed the wire, so its declared type is a promise the sender may not have kept.
689
- */
690
- function invalidEdit(edit) {
462
+ const OPTION_OPERATIONS = /* @__PURE__ */ new Set([require_protocol.ConfigEditOperation.SET_OPTION, require_protocol.ConfigEditOperation.REMOVE_OPTION]);
463
+ const OUTPUT_OPERATIONS = /* @__PURE__ */ new Set([require_protocol.ConfigEditOperation.SET_OUTPUT, require_protocol.ConfigEditOperation.REMOVE_OUTPUT]);
464
+ const KINDS = new Set(Object.values(require_protocol.extensionKinds));
465
+ /** Identifiers the editor matches by name. */
466
+ const identifiers = {
467
+ defineConfig: "defineConfig",
468
+ require: "require",
469
+ module: "module",
470
+ exports: "exports"
471
+ };
472
+ const moduleKinds = {
473
+ esm: "esm",
474
+ cjs: "cjs"
475
+ };
476
+ function isNonEmptyString(value) {
477
+ return typeof value === "string" && value !== "";
478
+ }
479
+ /** Edits cross the wire, so their shape is checked before anything reads them. */
480
+ function validateEdit(edit) {
691
481
  const { operation, plugin, config, path, kind, input } = typeof edit === "object" && edit !== null ? edit : {};
692
482
  if (typeof operation !== "string" || !OPERATIONS.has(operation)) return `unknown operation ${JSON.stringify(operation)}`;
693
483
  if (config !== void 0 && typeof config !== "string" && !Number.isSafeInteger(config)) return "config must name an entry or give its index";
694
- if (operation === require_protocol.ConfigEditOperation.SET_INPUT) return typeof input === "string" && input.trim() ? void 0 : "the edit names no input";
695
- const isOutput = operation === require_protocol.ConfigEditOperation.SET_OUTPUT || operation === require_protocol.ConfigEditOperation.REMOVE_OUTPUT;
696
- if (!isOutput && (typeof plugin !== "string" || !plugin)) return "the edit names no plugin";
697
- if (kind !== void 0 && (typeof kind !== "string" || !KINDS.has(kind))) return `unknown kind ${JSON.stringify(kind)}`;
698
- if (kind !== void 0 && operation !== require_protocol.ConfigEditOperation.SET && operation !== require_protocol.ConfigEditOperation.REMOVE) return `${operation} edits the plugins array, so it takes no kind`;
699
- if (isOutput || operation === require_protocol.ConfigEditOperation.SET || operation === require_protocol.ConfigEditOperation.REMOVE) {
700
- if (!Array.isArray(path) || !path.every((key) => typeof key === "string" && key)) return "path must be a list of option names";
701
- if (!path.length) return "no option path given";
702
- const unsafe = path.find((key) => !isSafeKey(key));
703
- if (unsafe !== void 0) return `${unsafe} cannot be used as an option name`;
704
- if (isOutput && !isOutputKey(path[0])) return `output.${path[0]} can only be changed in the config file`;
705
- }
706
- }
707
- /** Where a node sits in the source text. */
708
- function rangeOf(node) {
709
- if (typeof node.start !== "number" || typeof node.end !== "number") throw new _kubb_core.Diagnostics.Error({
710
- code: _kubb_core.Diagnostics.code.internalError,
711
- severity: "error",
712
- message: "the config file could not be mapped back to its source text"
713
- });
714
- return {
715
- start: node.start,
716
- end: node.end
717
- };
718
- }
719
- /** `source` with the text between `start` and `end` replaced by `text`. */
720
- function splice(source, start, end, text = "") {
721
- return `${source.slice(0, start)}${text}${source.slice(end)}`;
722
- }
723
- function bodyOf(mod) {
724
- return mod.$ast.type === "Program" ? mod.$ast.body : [];
725
- }
726
- /**
727
- * Steps through a config's wrappers to the object literal underneath: a `satisfies`/`as`
728
- * assertion, a `() => ...` factory, or a factory whose block body returns the config.
729
- */
730
- function unwrap(node) {
484
+ if (operation === require_protocol.ConfigEditOperation.SET_INPUT) return typeof input === "string" && input.trim() !== "" ? void 0 : "the edit names no input";
485
+ const isOption = OPTION_OPERATIONS.has(operation);
486
+ const isOutput = OUTPUT_OPERATIONS.has(operation);
487
+ if (!isOutput && !isNonEmptyString(plugin)) return "the edit names no plugin";
488
+ if (kind !== void 0) {
489
+ if (typeof kind !== "string" || !KINDS.has(kind)) return `unknown kind ${JSON.stringify(kind)}`;
490
+ const takesKind = operation === require_protocol.ConfigEditOperation.ADD_PLUGIN || operation === require_protocol.ConfigEditOperation.REMOVE_EXTENSION;
491
+ if (takesKind && kind === require_protocol.extensionKinds.adapter) return `${operation} takes a plugin or a parser, since a config has one adapter`;
492
+ if (!isOption && !takesKind) return `${operation} edits the plugins array, so it takes no kind`;
493
+ }
494
+ if (!isOption && !isOutput) return void 0;
495
+ if (!Array.isArray(path) || !path.every(isNonEmptyString)) return "path must be a list of option names";
496
+ if (path.length === 0) return "no option path given";
497
+ const unsafe = path.find((key) => !isSafeKey(key));
498
+ if (unsafe !== void 0) return `${unsafe} cannot be used as an option name`;
499
+ if (isOutput && !isOutputKey(path[0])) return `output.${path[0]} can only be changed in the config file`;
500
+ }
501
+ /** Steps through `as`/`satisfies` and `() => ...` wrappers to the config object. */
502
+ function unwrapValue(node) {
731
503
  if (!node) return void 0;
732
- if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression") return unwrap(node.expression);
733
- if (node.type !== "ArrowFunctionExpression" && node.type !== "FunctionExpression") return node;
734
- if (node.body.type !== "BlockStatement") return unwrap(node.body);
735
- return unwrap(node.body.body.find((statement) => statement.type === "ReturnStatement")?.argument);
504
+ if (node.type === nodeTypes.tSAsExpression || node.type === nodeTypes.tSSatisfiesExpression) return unwrapValue(node.expression);
505
+ if (node.type !== nodeTypes.arrowFunctionExpression && node.type !== nodeTypes.functionExpression) return node;
506
+ if (node.body?.type !== nodeTypes.blockStatement) return unwrapValue(node.body);
507
+ const returned = node.body.body.find((statement) => statement.type === nodeTypes.returnStatement);
508
+ return unwrapValue(returned?.type === nodeTypes.returnStatement ? returned.argument : void 0);
736
509
  }
737
510
  function isModuleExports(node) {
738
- return node.type === "MemberExpression" && !node.computed && node.object.type === "Identifier" && node.object.name === "module" && node.property.type === "Identifier" && node.property.name === "exports";
739
- }
740
- /**
741
- * Parses `source` and finds every config object in `export default defineConfig(...)` or
742
- * `module.exports = defineConfig(...)`, or why the file is unmanaged. An array export gets one entry
743
- * per element, matching a numeric `config` index.
744
- *
745
- * Walks the parsed AST rather than magicast's proxies, which throw on node types they cannot
746
- * cast, most of what an unmanaged config file is made of.
747
- */
748
- function parseConfigs(source) {
749
- let mod;
750
- try {
751
- mod = (0, magicast.parseModule)(source);
752
- } catch {
753
- return { reason: "the config file could not be parsed" };
754
- }
755
- let exported;
756
- let kind = "esm";
757
- for (const node of bodyOf(mod)) {
758
- if (node.type === "ExportDefaultDeclaration") {
759
- exported = node.declaration;
760
- break;
761
- }
762
- if (node.type === "ExpressionStatement" && node.expression.type === "AssignmentExpression" && isModuleExports(node.expression.left)) {
763
- exported = node.expression.right;
764
- kind = "cjs";
765
- break;
766
- }
511
+ return node.type === nodeTypes.memberExpression && !node.computed && node.object.type === nodeTypes.identifier && node.object.name === identifiers.module && node.property.type === nodeTypes.identifier && node.property.name === identifiers.exports;
512
+ }
513
+ /** The exported config value and how the file exports it. */
514
+ function findExport(program) {
515
+ for (const node of program.body) {
516
+ if (node.type === nodeTypes.exportDefaultDeclaration) return {
517
+ exported: node.declaration,
518
+ kind: moduleKinds.esm
519
+ };
520
+ if (node.type === nodeTypes.expressionStatement && node.expression.type === nodeTypes.assignmentExpression && isModuleExports(node.expression.left)) return {
521
+ exported: node.expression.right,
522
+ kind: moduleKinds.cjs
523
+ };
767
524
  }
768
- const value = unwrap(exported);
525
+ return {
526
+ exported: void 0,
527
+ kind: moduleKinds.esm
528
+ };
529
+ }
530
+ /** Every config object in `export default` or `module.exports = defineConfig(...)`, or why the file is unmanaged. */
531
+ function findConfigs(source) {
532
+ const parsed = parseSource(source);
533
+ if ("reason" in parsed) return parsed;
534
+ const { exported, kind } = findExport(parsed.program);
535
+ const value = unwrapValue(exported);
769
536
  if (!value) return { reason: "no `export default` or `module.exports` found" };
770
- if (value.type !== "CallExpression" || value.callee.type !== "Identifier" || value.callee.name !== "defineConfig") return { reason: "the exported config is not a defineConfig(...) call" };
771
- const argument = unwrap(value.arguments[0]);
537
+ if (value.type !== nodeTypes.callExpression || value.callee.type !== nodeTypes.identifier || value.callee.name !== identifiers.defineConfig) return { reason: "the exported config is not a defineConfig(...) call" };
538
+ const argument = unwrapValue(value.arguments[0]);
772
539
  if (!argument) return { reason: "defineConfig(...) was called without a config" };
773
- const entries = argument.type === "ArrayExpression" ? argument.elements.map((element) => unwrap(element)) : [argument];
774
- const configs = entries.filter((entry) => entry?.type === "ObjectExpression");
540
+ const entries = argument.type === nodeTypes.arrayExpression ? argument.elements.map((element) => unwrapValue(element)) : [argument];
541
+ const configs = entries.filter((entry) => entry?.type === nodeTypes.objectExpression);
775
542
  if (configs.length !== entries.length) return { reason: "config is not an object literal" };
776
543
  return {
777
- mod,
544
+ ...parsed,
778
545
  configs,
779
546
  kind
780
547
  };
781
548
  }
782
- /** The name of a `key` or `'key'` member, `undefined` for a spread or a computed key. */
783
- function memberKey(entry) {
784
- if (entry.type === "SpreadElement" || entry.computed) return void 0;
785
- return entry.key.type === "Identifier" ? entry.key.name : entry.key.type === "StringLiteral" ? entry.key.value : void 0;
549
+ function findConfigEntry(configs, ref) {
550
+ return typeof ref === "string" ? configs.find((entry) => getConfigName(entry) === ref) : configs[ref ?? 0];
786
551
  }
787
- /** The member named `key`. The last one wins when the key repeats, as it does when the object is evaluated. */
788
- function findMember(node, key) {
789
- const index = node.properties.findLastIndex((entry) => memberKey(entry) === key);
552
+ function getKey(entry) {
553
+ if (entry.type !== nodeTypes.property || entry.computed) return void 0;
554
+ if (entry.key.type === nodeTypes.identifier) return entry.key.name;
555
+ return entry.key.type === nodeTypes.literal && typeof entry.key.value === "string" ? entry.key.value : void 0;
556
+ }
557
+ /** The last one wins when the key repeats, as at runtime. */
558
+ function findProperty(node, key) {
559
+ const index = node.properties.findLastIndex((entry) => getKey(entry) === key);
790
560
  return index === -1 ? void 0 : {
791
561
  member: node.properties[index],
792
562
  index
793
563
  };
794
564
  }
795
- /** The value node of a member. A method or accessor is its own value: it is code, never a literal. */
796
- function property(node, key) {
797
- const member = findMember(node, key)?.member;
798
- return member?.type === "ObjectProperty" ? member.value : member;
565
+ function getValue(node, key) {
566
+ const member = findProperty(node, key)?.member;
567
+ return member?.type === nodeTypes.property ? member.value : member;
799
568
  }
800
- function configName(config) {
801
- const name = property(config, "name");
802
- return name?.type === "StringLiteral" ? name.value : void 0;
569
+ function getConfigName(config) {
570
+ const name = getValue(config, "name");
571
+ return name?.type === nodeTypes.literal && typeof name.value === "string" ? name.value : void 0;
803
572
  }
804
- /** Whether a `...spread` after the member at `index` could override it. */
805
- function isShadowed(node, index) {
806
- return node.properties.slice(index + 1).some((entry) => entry.type === "SpreadElement");
573
+ function isOverridden(node, index) {
574
+ return node.properties.slice(index + 1).some((entry) => entry.type === nodeTypes.spreadElement);
807
575
  }
808
- /**
809
- * Reads a literal node's value: a primitive, or an object/array built only from primitives.
810
- * `undefined` for anything else, so a caller can use this both to read a value and to check
811
- * whether a node is a literal at all.
812
- */
576
+ /** `key: value`, not a method, getter, setter, or `{ key }` shorthand. */
577
+ function isPlainProperty(member) {
578
+ return !member.method && !member.shorthand && member.kind === propertyKinds.init;
579
+ }
580
+ /** A literal's value, `undefined` for code. */
813
581
  function readLiteral(node) {
814
582
  switch (node?.type) {
815
- case "StringLiteral":
816
- case "NumericLiteral":
817
- case "BooleanLiteral": return node.value;
818
- case "NullLiteral": return null;
819
- case "TemplateLiteral": return node.expressions.length ? void 0 : node.quasis[0]?.value.cooked ?? "";
820
- case "UnaryExpression": {
583
+ case nodeTypes.literal: return "regex" in node || "bigint" in node ? void 0 : node.value;
584
+ case nodeTypes.templateLiteral: return node.expressions.length ? void 0 : node.quasis[0]?.value.cooked ?? "";
585
+ case nodeTypes.unaryExpression: {
821
586
  const value = readLiteral(node.argument);
822
587
  if (typeof value !== "number") return void 0;
823
- return node.operator === "-" ? -value : node.operator === "+" ? value : void 0;
588
+ if (node.operator === unaryOperators.minus) return -value;
589
+ if (node.operator === unaryOperators.plus) return value;
590
+ return;
824
591
  }
825
- case "ArrayExpression": {
592
+ case nodeTypes.arrayExpression: {
826
593
  const values = node.elements.map((element) => readLiteral(element));
827
594
  return values.every((value) => value !== void 0) ? values : void 0;
828
595
  }
829
- case "ObjectExpression": {
596
+ case nodeTypes.objectExpression: {
830
597
  const entries = {};
831
598
  for (const entry of node.properties) {
832
- if (entry.type !== "ObjectProperty") return void 0;
833
- const key = memberKey(entry);
599
+ if (entry.type !== nodeTypes.property) return void 0;
600
+ const key = getKey(entry);
834
601
  const value = readLiteral(entry.value);
835
602
  if (key === void 0 || !isSafeKey(key) || value === void 0) return void 0;
836
603
  entries[key] = value;
@@ -839,158 +606,123 @@ function readLiteral(node) {
839
606
  }
840
607
  }
841
608
  }
842
- /** Writes `value` as a single-line literal with the file's quote style. */
843
- function printValue(value, quote) {
844
- if (typeof value === "string") {
845
- const escaped = JSON.stringify(value).slice(1, -1);
846
- return quote === `"` ? `"${escaped}"` : `'${escaped.replaceAll(`\\"`, `"`).replaceAll(`'`, `\\'`)}'`;
847
- }
848
- if (value === null || typeof value !== "object") return String(value);
849
- if (Array.isArray(value)) return `[${value.map((item) => printValue(item, quote)).join(", ")}]`;
850
- const entries = Object.entries(value);
851
- return entries.length ? `{ ${entries.map(([key, item]) => `${printKey(key, quote)}: ${printValue(item, quote)}`).join(", ")} }` : "{}";
609
+ function getRequirePath(node) {
610
+ if (node?.type !== nodeTypes.callExpression || node.callee.type !== nodeTypes.identifier || node.callee.name !== identifiers.require) return void 0;
611
+ const [argument] = node.arguments;
612
+ return argument?.type === nodeTypes.literal && typeof argument.value === "string" ? argument.value : void 0;
613
+ }
614
+ /** `import { exported as local } from '...'`. A default or namespace import has no exported name, so its local name stands in. */
615
+ function importBindings(statement) {
616
+ return statement.specifiers.map((specifier) => {
617
+ const imported = specifier.type === nodeTypes.importSpecifier ? specifier.imported : specifier.local;
618
+ const exported = imported.type === nodeTypes.identifier ? imported.name : imported.value;
619
+ return [specifier.local.name, {
620
+ from: statement.source.value,
621
+ exported
622
+ }];
623
+ });
852
624
  }
853
- /** An object key as source text: bare when it is an identifier, quoted otherwise. */
854
- function printKey(key, quote) {
855
- return IDENTIFIER.test(key) ? key : printValue(key, quote);
625
+ /** `const { exported: local } = require('...')`. */
626
+ function requireBindings(statement) {
627
+ return statement.declarations.flatMap((declarator) => {
628
+ const from = getRequirePath(declarator.init);
629
+ if (from === void 0 || declarator.id.type !== nodeTypes.objectPattern) return [];
630
+ return declarator.id.properties.flatMap((entry) => {
631
+ if (entry.type !== nodeTypes.property || entry.value.type !== nodeTypes.identifier) return [];
632
+ return [[entry.value.name, {
633
+ from,
634
+ exported: getKey(entry) ?? entry.value.name
635
+ }]];
636
+ });
637
+ });
856
638
  }
857
- /** `value` wrapped in one object per key of `path`: `nest(['enum', 'type'], 'enum')` is `{ enum: { type: 'enum' } }`. */
858
- function nest(path, value) {
859
- return path.reduceRight((inner, key) => ({ [key]: inner }), value);
639
+ function getImports(program) {
640
+ return new Map(program.body.flatMap((statement) => {
641
+ if (statement.type === nodeTypes.importDeclaration) return importBindings(statement);
642
+ if (statement.type === nodeTypes.variableDeclaration) return requireBindings(statement);
643
+ return [];
644
+ }));
860
645
  }
861
- /**
862
- * Adds `text` as the last item of an object or array literal, following its layout: on a line of
863
- * its own with the last item's indentation when the literal spans lines, inline otherwise.
864
- */
865
- function insertItem(source, container, text) {
866
- const { start, end } = rangeOf(container);
867
- const last = (container.type === "ObjectExpression" ? container.properties : container.elements).filter((item) => item !== null).at(-1);
868
- if (!last) {
869
- const inner = source.slice(start + 1, end - 1);
870
- if (inner.trim()) return splice(source, start + 1, start + 1, /^\s/.test(inner) ? `${text},` : `${text}, `);
871
- return splice(source, start, end, container.type === "ObjectExpression" ? `{ ${text} }` : `[${text}]`);
872
- }
873
- const lastRange = rangeOf(last);
874
- const between = source.slice(lastRange.end, end - 1);
875
- if (!between.includes("\n")) return splice(source, lastRange.end, lastRange.end, `, ${text}`);
876
- const lineStart = source.lastIndexOf("\n", lastRange.start - 1) + 1;
877
- const indent = source.slice(lineStart).match(/^[ \t]*/)?.[0] ?? "";
878
- const lineEnd = source.indexOf("\n", lastRange.end);
879
- const comma = /^\s*,/.test(between);
880
- return `${source.slice(0, lastRange.end)}${comma ? "" : ","}${source.slice(lastRange.end, lineEnd)}\n${indent}${text}${comma ? "," : ""}${source.slice(lineEnd)}`;
646
+ function isImportStatement(node, kind) {
647
+ if (kind === moduleKinds.esm) return node.type === nodeTypes.importDeclaration;
648
+ return node.type === nodeTypes.variableDeclaration && node.declarations.some((declarator) => getRequirePath(declarator.init) !== void 0);
649
+ }
650
+ /** The quote of the first string literal in the file, so new code matches it. */
651
+ function detectQuote(node) {
652
+ if (!node || typeof node !== "object") return void 0;
653
+ const { type, raw } = node;
654
+ if (type === nodeTypes.literal && typeof raw === "string" && (raw[0] === quotes.single || raw[0] === quotes.double)) return raw[0];
655
+ for (const child of Object.values(node)) {
656
+ const quote = detectQuote(child);
657
+ if (quote) return quote;
658
+ }
659
+ }
660
+ /** The `adapter` value, or the elements of the `plugins` or `parsers` array. */
661
+ function factoryNodes(config, kind) {
662
+ if (kind === require_protocol.extensionKinds.adapter) return [getValue(config, "adapter")];
663
+ const array = getValue(config, `${kind}s`);
664
+ return array?.type === nodeTypes.arrayExpression ? array.elements : [];
665
+ }
666
+ /** The factory calls of one kind whose callee the file imports. */
667
+ function findCalls(program, config, kind = require_protocol.extensionKinds.plugin) {
668
+ const imports = getImports(program);
669
+ return factoryNodes(config, kind).flatMap((node) => {
670
+ if (node?.type !== nodeTypes.callExpression || node.callee.type !== nodeTypes.identifier) return [];
671
+ const imported = imports.get(node.callee.name);
672
+ return imported ? [{
673
+ importName: node.callee.name,
674
+ exportName: imported.exported,
675
+ packageName: imported.from,
676
+ call: node
677
+ }] : [];
678
+ });
881
679
  }
882
- /** Drops the member at `index` from an object literal, with its own lines when it sits on them alone. */
883
- function removeMember(source, node, index) {
884
- if (node.properties.length === 1) return splice(source, rangeOf(node).start, rangeOf(node).end, "{}");
885
- const { start, end } = rangeOf(node.properties[index]);
886
- const lineStart = source.lastIndexOf("\n", start - 1) + 1;
887
- const nextNewline = source.indexOf("\n", end);
888
- const lineEnd = nextNewline === -1 ? source.length : nextNewline;
889
- if (!source.slice(lineStart, start).trim() && /^\s*,?\s*(\/\/.*)?$/.test(source.slice(end, lineEnd))) return splice(source, lineStart, Math.min(lineEnd + 1, source.length));
890
- const next = node.properties[index + 1];
891
- return next ? splice(source, start, rangeOf(next).start) : splice(source, rangeOf(node.properties[index - 1]).end, end);
680
+ /** Whether nothing but whitespace precedes `offset` on its line. */
681
+ function startsLine(source, offset) {
682
+ return source.slice(source.lastIndexOf("\n", offset - 1) + 1, offset).trim() === "";
892
683
  }
893
- /** The module a `require('...')` call loads, when `node` is one. */
894
- function requiredModule(node) {
895
- if (node?.type !== "CallExpression" || node.callee.type !== "Identifier" || node.callee.name !== "require") return void 0;
896
- const [argument] = node.arguments;
897
- return argument?.type === "StringLiteral" ? argument.value : void 0;
898
- }
899
- /** Maps each identifier the file imports, through `import { x } from` or `const { x } = require(...)`, to its module. */
900
- function importedFrom(mod) {
901
- const imports = /* @__PURE__ */ new Map();
902
- for (const statement of bodyOf(mod)) {
903
- if (statement.type === "ImportDeclaration") {
904
- for (const specifier of statement.specifiers) {
905
- const imported = specifier.type === "ImportSpecifier" ? specifier.imported : specifier.local;
906
- imports.set(specifier.local.name, {
907
- from: statement.source.value,
908
- exported: imported.type === "Identifier" ? imported.name : imported.value
909
- });
910
- }
911
- continue;
912
- }
913
- if (statement.type !== "VariableDeclaration") continue;
914
- for (const declarator of statement.declarations) {
915
- const from = requiredModule(declarator.init);
916
- if (from === void 0 || declarator.id.type !== "ObjectPattern") continue;
917
- for (const entry of declarator.id.properties) if (entry.type === "ObjectProperty" && entry.value.type === "Identifier") imports.set(entry.value.name, {
918
- from,
919
- exported: memberKey(entry) ?? entry.value.name
920
- });
921
- }
922
- }
923
- return imports;
924
- }
925
- /**
926
- * Every factory call of one kind that resolves to an import: the `pluginX(...)` elements of the
927
- * `plugins` array, the `adapter` value, or the elements of the `parsers` array.
928
- */
929
- function factoryCalls(mod, config, kind = "plugin") {
930
- const node = property(config, kind === "adapter" ? "adapter" : `${kind}s`);
931
- const elements = kind === "adapter" ? [node] : node?.type === "ArrayExpression" ? node.elements : [];
932
- const imports = importedFrom(mod);
933
- return elements.flatMap((element) => {
934
- if (element?.type !== "CallExpression" || element.callee.type !== "Identifier") return [];
935
- const imported = imports.get(element.callee.name);
936
- return imported ? [{
937
- importName: element.callee.name,
938
- exportName: imported.exported,
939
- packageName: imported.from,
940
- call: element
941
- }] : [];
942
- });
943
- }
944
- /**
945
- * The blocks a previous `disable-plugin` commented out of this config, read from the marker lines
946
- * since a commented-out call is no longer a node. `code` holds the block's lines as they were.
947
- */
948
- function disabledBlocks(source, config) {
684
+ /** The `disable-plugin` blocks in this config, read from marker comments since the call is no longer a node. */
685
+ function findDisabled(source, comments, config) {
949
686
  const lines = source.split("\n");
950
- const first = config.loc?.start.line ?? 0;
951
- const last = config.loc?.end.line ?? Number.POSITIVE_INFINITY;
952
- return lines.flatMap((text, index) => {
953
- const match = text.trim().match(DISABLED_MARKER);
954
- if (!match || index + 1 < first || index + 1 > last) return [];
955
- const end = index + 1 + Number(match[2]);
956
- const code = lines.slice(index + 1, end).map((line) => line.replace(/^(\s*)\/\/ ?/, "$1").replace(/^\s+$/, ""));
687
+ return comments.flatMap((comment) => {
688
+ const match = comment.type === commentTypes.line ? comment.value.trim().match(DISABLED_MARKER_PATTERN) : null;
689
+ if (!match || comment.start < config.start || comment.end > config.end || !startsLine(source, comment.start)) return [];
690
+ const line = getPosition(source, comment.start).line;
691
+ const end = line + Number(match[2]);
692
+ const code = lines.slice(line, end).map((text) => text.replace(/^(\s*)\/\/ ?/, "$1").replace(/^\s+$/, ""));
957
693
  return [{
958
694
  packageName: match[1],
959
- line: index + 1,
695
+ line,
960
696
  end,
961
697
  code
962
698
  }];
963
699
  });
964
700
  }
965
- /**
966
- * Reads an object option that holds some code: its literal members as `value`, and the paths of
967
- * the members that are code as `locked`. `set` can write any path outside `locked`, so Studio
968
- * edits those fields. `undefined` for a computed key, since it could set any path.
969
- */
970
- function readPartial(node, prefix = []) {
701
+ /** Literal members as `value` and code paths as `locked`, `undefined` for a computed key. */
702
+ function readEditable(node, prefix = []) {
971
703
  const value = {};
972
704
  const locked = [];
973
705
  for (const [index, entry] of node.properties.entries()) {
974
- if (entry.type === "SpreadElement") continue;
975
- const key = memberKey(entry);
706
+ if (entry.type === nodeTypes.spreadElement) continue;
707
+ const key = getKey(entry);
976
708
  if (key === void 0) return void 0;
977
- if (findMember(node, key)?.index !== index) continue;
709
+ if (findProperty(node, key)?.index !== index) continue;
978
710
  const path = [...prefix, key];
979
711
  if (!isSafeKey(key)) {
980
712
  locked.push(path);
981
713
  continue;
982
714
  }
983
- const member = entry.type === "ObjectProperty" && !isShadowed(node, index) ? entry.value : void 0;
715
+ const member = isOverridden(node, index) ? void 0 : entry.value;
984
716
  const literal = readLiteral(member);
985
717
  if (literal !== void 0) {
986
718
  value[key] = literal;
987
719
  continue;
988
720
  }
989
- if (member?.type !== "ObjectExpression") {
721
+ if (member?.type !== nodeTypes.objectExpression) {
990
722
  locked.push(path);
991
723
  continue;
992
724
  }
993
- const nested = readPartial(member, path);
725
+ const nested = readEditable(member, path);
994
726
  if (!nested) return void 0;
995
727
  value[key] = nested.value;
996
728
  locked.push(...nested.locked);
@@ -1000,142 +732,144 @@ function readPartial(node, prefix = []) {
1000
732
  locked
1001
733
  };
1002
734
  }
1003
- /** The top-level options of a plugin call, each flagged with whether Studio may write it. */
1004
- function readOptions(call) {
1005
- const options = call.arguments[0];
1006
- return options?.type === "ObjectExpression" ? readObject(options) : {};
1007
- }
1008
- /** The members of an object literal, each flagged with whether Studio may write it. */
1009
- function readObject(options) {
735
+ function readObjectOptions(options) {
1010
736
  const entries = {};
1011
737
  for (const [index, entry] of options.properties.entries()) {
1012
- const key = memberKey(entry);
738
+ const key = getKey(entry);
1013
739
  if (key === void 0 || !isSafeKey(key)) continue;
1014
- const member = entry.type === "ObjectProperty" && !isShadowed(options, index) ? entry.value : void 0;
740
+ const member = entry.type === nodeTypes.property && !isOverridden(options, index) ? entry.value : void 0;
1015
741
  const value = readLiteral(member);
1016
- const partial = value === void 0 && member?.type === "ObjectExpression" ? readPartial(member) : void 0;
1017
- entries[key] = value !== void 0 ? {
1018
- literal: true,
1019
- value
1020
- } : {
742
+ if (value !== void 0) {
743
+ entries[key] = {
744
+ literal: true,
745
+ value
746
+ };
747
+ continue;
748
+ }
749
+ entries[key] = {
1021
750
  literal: false,
1022
- ...partial
751
+ ...member?.type === nodeTypes.objectExpression ? readEditable(member) : void 0
1023
752
  };
1024
753
  }
1025
754
  return entries;
1026
755
  }
1027
- /** The options a commented-out plugin call holds, so Studio can show them while it is disabled. */
756
+ function readCallOptions(call) {
757
+ const options = call.arguments[0];
758
+ return options?.type === nodeTypes.objectExpression ? readObjectOptions(options) : {};
759
+ }
1028
760
  function readDisabledOptions(code) {
1029
- try {
1030
- const call = (0, magicast.parseExpression)(code.join("\n").trim().replace(/,$/, "")).$ast;
1031
- return call.type === "CallExpression" ? readOptions(call) : {};
1032
- } catch {
1033
- return {};
1034
- }
761
+ const call = parseExpression(code.join("\n").trim().replace(/,$/, ""));
762
+ return call?.type === nodeTypes.callExpression ? readCallOptions(call) : {};
1035
763
  }
1036
- /**
1037
- * Reads which plugins the file declares and which of their options Studio may write.
1038
- *
1039
- * @example
1040
- * ```ts
1041
- * const view = readConfig(await readFile('kubb.config.ts', 'utf8'))
1042
- * if (view.managed) {
1043
- * view.configs.forEach((config) => console.log(config.name, config.plugins.length))
1044
- * }
1045
- * ```
1046
- */
764
+ function toPluginView({ importName, packageName, call }) {
765
+ return {
766
+ importName,
767
+ packageName,
768
+ options: readCallOptions(call)
769
+ };
770
+ }
771
+ /** Active and disabled plugins, in source order so a disabled plugin keeps its place. */
772
+ function readPlugins({ source, parsed, config }) {
773
+ const importNames = new Map([...getImports(parsed.program)].map(([local, { from }]) => [from, local]));
774
+ const active = findCalls(parsed.program, config).map((call) => ({
775
+ line: getPosition(source, call.call.start).line,
776
+ plugin: toPluginView(call)
777
+ }));
778
+ const disabled = findDisabled(source, parsed.comments, config).map(({ packageName, line, code }) => ({
779
+ line,
780
+ plugin: {
781
+ importName: importNames.get(packageName) ?? toExportName(packageName),
782
+ packageName,
783
+ options: readDisabledOptions(code),
784
+ disabled: true
785
+ }
786
+ }));
787
+ return [...active, ...disabled].sort((a, b) => a.line - b.line).map(({ plugin }) => plugin);
788
+ }
789
+ function readConfigView({ source, parsed, config }) {
790
+ const [adapter] = findCalls(parsed.program, config, require_protocol.extensionKinds.adapter).map(toPluginView);
791
+ const parsers = findCalls(parsed.program, config, require_protocol.extensionKinds.parser).map(toPluginView);
792
+ const output = getOutput(config);
793
+ return {
794
+ name: getConfigName(config),
795
+ plugins: readPlugins({
796
+ source,
797
+ parsed,
798
+ config
799
+ }),
800
+ ...adapter && { adapter },
801
+ ...parsers.length ? { parsers } : {},
802
+ ...!("reason" in output) && { output: pickOutput(readObjectOptions(output.object)) }
803
+ };
804
+ }
805
+ /** Which plugins the file declares and which options Studio may write. */
1047
806
  function readConfig(source) {
1048
- const parsed = parseConfigs(source);
807
+ const parsed = findConfigs(source);
1049
808
  if ("reason" in parsed) return {
1050
809
  managed: false,
1051
810
  reason: parsed.reason
1052
811
  };
1053
- const { mod, configs } = parsed;
1054
- const importNames = new Map([...importedFrom(mod)].map(([local, { from }]) => [from, local]));
1055
812
  return {
1056
813
  managed: true,
1057
- configs: configs.map((config) => {
1058
- const active = factoryCalls(mod, config).map(({ importName, packageName, call }) => ({
1059
- line: call.loc?.start.line ?? 0,
1060
- plugin: {
1061
- importName,
1062
- packageName,
1063
- options: readOptions(call)
1064
- }
1065
- }));
1066
- const disabled = disabledBlocks(source, config).map(({ packageName, line, code }) => ({
1067
- line,
1068
- plugin: {
1069
- importName: importNames.get(packageName) ?? toExportName(packageName),
1070
- packageName,
1071
- options: readDisabledOptions(code),
1072
- disabled: true
1073
- }
1074
- }));
1075
- const plugins = [...active, ...disabled].sort((a, b) => a.line - b.line).map(({ plugin }) => plugin);
1076
- const toView = ({ importName, packageName, call }) => ({
1077
- importName,
1078
- packageName,
1079
- options: readOptions(call)
1080
- });
1081
- const [adapter] = factoryCalls(mod, config, "adapter").map(toView);
1082
- const parsers = factoryCalls(mod, config, "parser").map(toView);
1083
- const output = outputObject(config);
1084
- return {
1085
- name: configName(config),
1086
- plugins,
1087
- ...adapter && { adapter },
1088
- ...parsers.length ? { parsers } : {},
1089
- ...!("reason" in output) && { output: pickOutput(readObject(output.object)) }
1090
- };
1091
- })
814
+ configs: parsed.configs.map((config) => readConfigView({
815
+ source,
816
+ parsed,
817
+ config
818
+ }))
1092
819
  };
1093
820
  }
1094
- /** The modules the config file imports, in source order. Empty when the file cannot be parsed. */
821
+ /** The modules the file imports, empty when it cannot be parsed. */
1095
822
  function readImports(source) {
1096
- try {
1097
- return [...new Set([...importedFrom((0, magicast.parseModule)(source)).values()].map(({ from }) => from))];
1098
- } catch {
1099
- return [];
1100
- }
823
+ const parsed = parseSource(source);
824
+ if ("reason" in parsed) return [];
825
+ return [...new Set([...getImports(parsed.program).values()].map(({ from }) => from))];
1101
826
  }
1102
- /**
1103
- * Walks `path` through a plugin's options to the literal at its last key. Returns `missing` with
1104
- * the index of the first key that is not there yet, so `set` can write the rest of the path as one
1105
- * nested literal and `remove` can report it as unset. Refuses a value customized in code, so it is
1106
- * never overwritten.
1107
- */
1108
- function walk(options, path) {
827
+ /** Follows `path` to its literal, or to the first missing key. Refuses a value set in code. */
828
+ function findPath(options, path) {
1109
829
  let object = options;
1110
830
  for (const [depth, key] of path.entries()) {
1111
- const found = findMember(object, key);
831
+ const found = findProperty(object, key);
1112
832
  if (!found) return {
1113
833
  object,
1114
834
  missing: depth
1115
835
  };
1116
- 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` };
836
+ if (isOverridden(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` };
1117
837
  const { member, index } = found;
1118
- if (depth === path.length - 1) return member.type === "ObjectProperty" && readLiteral(member.value) !== void 0 ? {
838
+ if (depth === path.length - 1) return member.type === nodeTypes.property && readLiteral(member.value) !== void 0 ? {
1119
839
  object,
1120
840
  index,
1121
841
  value: member.value
1122
842
  } : { reason: `${path.join(".")} is customized in code` };
1123
- if (member.type !== "ObjectProperty" || member.value.type !== "ObjectExpression") return { reason: `${key} is not an object, so ${path.join(".")} cannot be reached` };
843
+ if (member.type !== nodeTypes.property || member.value.type !== nodeTypes.objectExpression) return { reason: `${key} is not an object, so ${path.join(".")} cannot be reached` };
1124
844
  object = member.value;
1125
845
  }
1126
846
  return { reason: "no option path given" };
1127
847
  }
1128
- /** Writes `value` at `path` inside a plugin call's options, adding the options object and any intermediate object it needs. */
1129
- function applySet(source, call, path, value, quote) {
848
+ function setAtPath({ source, options, path, value, quote }) {
849
+ const target = findPath(options, path);
850
+ if ("reason" in target) return target;
851
+ if ("missing" in target) {
852
+ const [key, ...rest] = path.slice(target.missing);
853
+ return { source: appendItem(source, target.object, `${printKey(key, quote)}: ${printValue(wrapPath(rest, value), quote)}`) };
854
+ }
855
+ return { source: replaceText(source, target.value.start, target.value.end, printValue(value, quote)) };
856
+ }
857
+ function removeAtPath({ source, options, path }) {
858
+ const target = findPath(options, path);
859
+ if ("reason" in target) return target;
860
+ if ("missing" in target) return { source };
861
+ return { source: removeItem(source, target.object, target.index) };
862
+ }
863
+ function setOption(source, call, path, value, quote) {
1130
864
  if (!isOptionValue(value)) return { reason: "the value is not a literal that can be written to a config file" };
1131
865
  const options = call.arguments[0];
1132
866
  if (!options) {
1133
- const closing = rangeOf(call).end - 1;
867
+ const closing = call.end - 1;
1134
868
  if (source[closing] !== ")") return { reason: "the factory call could not be located in the file" };
1135
- return { source: splice(source, closing, closing, printValue(nest(path, value), quote)) };
869
+ return { source: replaceText(source, closing, closing, printValue(wrapPath(path, value), quote)) };
1136
870
  }
1137
- if (options.type !== "ObjectExpression") return { reason: "the factory was not called with an object literal" };
1138
- return setIn({
871
+ if (options.type !== nodeTypes.objectExpression) return { reason: "the factory was not called with an object literal" };
872
+ return setAtPath({
1139
873
  source,
1140
874
  options,
1141
875
  path,
@@ -1143,46 +877,56 @@ function applySet(source, call, path, value, quote) {
1143
877
  quote
1144
878
  });
1145
879
  }
1146
- /** Writes the literal `value` at `path` inside `options`, adding any intermediate object it needs. */
1147
- function setIn({ source, options, path, value, quote }) {
1148
- const target = walk(options, path);
1149
- if ("reason" in target) return target;
1150
- if ("missing" in target) {
1151
- const [key, ...rest] = path.slice(target.missing);
1152
- return { source: insertItem(source, target.object, `${printKey(key, quote)}: ${printValue(nest(rest, value), quote)}`) };
880
+ function removeOption(source, call, path) {
881
+ const options = call.arguments[0];
882
+ if (!options) return { source };
883
+ if (options.type !== nodeTypes.objectExpression) return { reason: "the factory was not called with an object literal" };
884
+ return removeAtPath({
885
+ source,
886
+ options,
887
+ path
888
+ });
889
+ }
890
+ function editOption({ source, parsed, config, edit, quote, extensions }) {
891
+ const kind = edit.kind ?? require_protocol.extensionKinds.plugin;
892
+ const calls = findCalls(parsed.program, config, kind).filter((entry) => entry.packageName === edit.plugin);
893
+ const exportName = extensions.get(edit.plugin)?.manifest.export ?? toExportName(edit.plugin);
894
+ const call = (calls.find((entry) => entry.exportName === exportName) ?? calls[0])?.call;
895
+ if (!call) {
896
+ if (kind === require_protocol.extensionKinds.adapter) return { reason: `${edit.plugin} is not the config's adapter` };
897
+ return { reason: kind === require_protocol.extensionKinds.plugin && findDisabled(source, parsed.comments, config).some((block) => block.packageName === edit.plugin) ? `${edit.plugin} is disabled, enable it first` : `${edit.plugin} is not in the ${kind}s array` };
1153
898
  }
1154
- return { source: splice(source, rangeOf(target.value).start, rangeOf(target.value).end, printValue(value, quote)) };
899
+ if (edit.operation === require_protocol.ConfigEditOperation.SET_OPTION) return setOption(source, call, edit.path, edit.value, quote);
900
+ return removeOption(source, call, edit.path);
1155
901
  }
1156
- /** Writes the config's top-level `input` as a string literal, replacing what it held. */
1157
- function applySetInput(source, config, input, quote) {
902
+ function setInput(source, config, input, quote) {
1158
903
  const value = printValue(input, quote);
1159
- const found = findMember(config, "input");
1160
- if (!found) return { source: insertItem(source, config, `input: ${value}`) };
1161
- if (found.member.type !== "ObjectProperty") return { reason: "input is not a plain property" };
1162
- if (isShadowed(config, found.index)) return { reason: "a spread after input could override it" };
1163
- return { source: splice(source, rangeOf(found.member.value).start, rangeOf(found.member.value).end, value) };
1164
- }
1165
- /** The config's root `output` object literal, or why Studio cannot edit inside it. */
1166
- function outputObject(config) {
1167
- const found = findMember(config, "output");
1168
- if (found?.member.type !== "ObjectProperty" || found.member.value.type !== "ObjectExpression") return { reason: "output is not an object literal" };
1169
- if (isShadowed(config, found.index)) return { reason: "a spread after output could override it" };
904
+ const found = findProperty(config, "input");
905
+ if (!found) return { source: appendItem(source, config, `input: ${value}`) };
906
+ if (found.member.type !== nodeTypes.property || !isPlainProperty(found.member)) return { reason: "input is not a plain property" };
907
+ if (isOverridden(config, found.index)) return { reason: "a spread after input could override it" };
908
+ return { source: replaceText(source, found.member.value.start, found.member.value.end, value) };
909
+ }
910
+ function getOutput(config) {
911
+ const found = findProperty(config, "output");
912
+ if (found?.member.type !== nodeTypes.property || found.member.value.type !== nodeTypes.objectExpression) return { reason: "output is not an object literal" };
913
+ if (isOverridden(config, found.index)) return { reason: "a spread after output could override it" };
1170
914
  return { object: found.member.value };
1171
915
  }
1172
- /** Writes or drops a member of the config's root `output` object. */
1173
- function applyOutputEdit({ source, config, edit, quote }) {
1174
- const output = outputObject(config);
916
+ function editOutput({ source, config, edit, quote }) {
917
+ const output = getOutput(config);
1175
918
  if ("reason" in output) return output;
1176
- if (edit.operation === require_protocol.ConfigEditOperation.REMOVE_OUTPUT) return removeIn({
919
+ if (edit.operation === require_protocol.ConfigEditOperation.REMOVE_OUTPUT) return removeAtPath({
1177
920
  source,
1178
921
  options: output.object,
1179
922
  path: edit.path
1180
923
  });
924
+ const [key, ...rest] = edit.path;
1181
925
  if (!isOptionValue(edit.value) || !isOutputValue({
1182
- key: edit.path[0],
1183
- value: nest(edit.path.slice(1), edit.value)
1184
- })) return { reason: `the value is not one output.${edit.path[0]} takes` };
1185
- return setIn({
926
+ key,
927
+ value: wrapPath(rest, edit.value)
928
+ })) return { reason: `the value is not one output.${key} takes` };
929
+ return setAtPath({
1186
930
  source,
1187
931
  options: output.object,
1188
932
  path: edit.path,
@@ -1190,163 +934,216 @@ function applyOutputEdit({ source, config, edit, quote }) {
1190
934
  quote
1191
935
  });
1192
936
  }
1193
- /**
1194
- * Deletes the option at `path`, falling the plugin back to its default for it. An option the file
1195
- * does not set already has its default, so removing it changes nothing.
1196
- */
1197
- function applyRemove(source, call, path) {
1198
- const options = call.arguments[0];
1199
- if (!options) return { source };
1200
- if (options.type !== "ObjectExpression") return { reason: "the factory was not called with an object literal" };
1201
- return removeIn({
1202
- source,
1203
- options,
1204
- path
1205
- });
1206
- }
1207
- /** Deletes the member at `path` inside `options`. A path that is not there already holds its default. */
1208
- function removeIn({ source, options, path }) {
1209
- const target = walk(options, path);
1210
- if ("reason" in target) return target;
1211
- if ("missing" in target) return { source };
1212
- return { source: removeMember(source, target.object, target.index) };
1213
- }
1214
- /**
1215
- * Writes an import after the last one already in the file, matching its quote style and whether it
1216
- * ends in a semicolon: `import { x } from '...'`, or `const { x } = require('...')` in a CommonJS file.
1217
- * The last import is read off the AST, so a multi-line `import {\n x,\n} from '...'` counts to its closing line.
1218
- */
1219
- function insertImport(source, { mod, kind }, importName, moduleSpecifier) {
1220
- 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;
937
+ /** Adds the import after the last one, matching its semicolon style. */
938
+ function addImport({ source, parsed, importNames, moduleSpecifier, quote }) {
939
+ const lastImport = parsed.program.body.findLast((node) => isImportStatement(node, parsed.kind));
940
+ const afterLine = lastImport ? getPosition(source, lastImport.end).line : 0;
1221
941
  const lines = source.split("\n");
1222
- const lastImportLine = afterLine > 0 ? lines[afterLine - 1] : void 0;
1223
- const quote = lastImportLine?.includes(`"`) ? `"` : `'`;
1224
- const semicolon = lastImportLine?.trimEnd().endsWith(";") ? ";" : "";
942
+ const semicolon = afterLine > 0 && lines[afterLine - 1]?.trimEnd().endsWith(";") ? ";" : "";
1225
943
  const specifier = `${quote}${moduleSpecifier}${quote}`;
1226
- lines.splice(afterLine, 0, kind === "esm" ? `import { ${importName} } from ${specifier}${semicolon}` : `const { ${importName} } = require(${specifier})${semicolon}`);
944
+ const statement = parsed.kind === moduleKinds.esm ? `import { ${importNames.join(", ")} } from ${specifier}` : `const { ${importNames.join(", ")} } = require(${specifier})`;
945
+ lines.splice(afterLine, 0, `${statement}${semicolon}`);
1227
946
  if (afterLine === 0) lines.splice(1, 0, "");
1228
947
  return lines.join("\n");
1229
948
  }
1230
949
  /**
1231
- * Adds a `pluginX(...)` call to a config's plugins array, and its import. Replaying the same add is
1232
- * a no-op, while an import name collision with an unrelated package remains an error.
950
+ * The parsers `defineConfig` applies when a config sets no `parsers`, by import name. Adding a parser
951
+ * to such a config writes these too, or the new array would replace them.
1233
952
  */
1234
- function applyAddPlugin({ source, parsed, config, edit, quote, extensions }) {
953
+ const DEFAULT_PARSERS = [
954
+ {
955
+ importName: "parserTs",
956
+ packageName: "@kubb/parser-ts"
957
+ },
958
+ {
959
+ importName: "parserTsx",
960
+ packageName: "@kubb/parser-ts"
961
+ },
962
+ {
963
+ importName: "parserMd",
964
+ packageName: "@kubb/parser-md"
965
+ }
966
+ ];
967
+ /**
968
+ * Adds a plugin to `plugins`, or with `kind: 'parser'` a parser to `parsers`. Replaying the same add
969
+ * is a no-op, while an import name taken by another package is an error.
970
+ */
971
+ function addPlugin({ source, parsed, config, edit, quote, extensions }) {
972
+ const kind = edit.kind ?? require_protocol.extensionKinds.plugin;
973
+ const isParser = kind === require_protocol.extensionKinds.parser;
1235
974
  if (!isAllowedPlugin({
1236
975
  packageName: edit.plugin,
1237
- extensions
1238
- })) return { reason: `"${edit.plugin}" is not a @kubb/plugin-* package or an installed Kubb plugin` };
976
+ extensions,
977
+ kind
978
+ })) return { reason: isParser ? `"${edit.plugin}" is not an installed Kubb parser` : `"${edit.plugin}" is not a @kubb/plugin-* package or an installed Kubb plugin` };
1239
979
  const importName = extensions.get(edit.plugin)?.manifest.export ?? toExportName(edit.plugin);
1240
- if (!IDENTIFIER.test(importName)) return { reason: `"${importName}" is not a valid import name` };
1241
- if (factoryCalls(parsed.mod, config).some((plugin) => plugin.packageName === edit.plugin)) return { source };
1242
- const taken = importedFrom(parsed.mod).get(importName)?.from;
1243
- if (taken && taken !== edit.plugin) return { reason: `${importName} is already imported from ${taken}` };
980
+ if (!IDENTIFIER_PATTERN.test(importName)) return { reason: `"${importName}" is not a valid import name` };
981
+ if (findCalls(parsed.program, config, kind).some((entry) => entry.packageName === edit.plugin && (!isParser || entry.exportName === importName))) return { source };
1244
982
  const options = edit.options ?? {};
1245
983
  if (!isOptionValue(options)) return { reason: "the options are not literals that can be written to a config file" };
1246
- const plugins = property(config, "plugins");
1247
- if (plugins?.type !== "ArrayExpression") return { reason: "plugins is not an array literal" };
1248
- const next = insertItem(source, plugins, `${importName}(${Object.keys(options).length ? printValue(options, quote) : ""})`);
1249
- return { source: taken ? next : insertImport(next, parsed, importName, edit.plugin) };
984
+ const key = `${kind}s`;
985
+ const array = getValue(config, key);
986
+ if (array ? array.type !== nodeTypes.arrayExpression : !isParser) return { reason: `${key} is not an array literal` };
987
+ const defaults = isParser && !array ? DEFAULT_PARSERS.filter((parser) => parser.importName !== importName) : [];
988
+ const imports = getImports(parsed.program);
989
+ const missing = /* @__PURE__ */ new Map();
990
+ for (const item of [...defaults, {
991
+ importName,
992
+ packageName: edit.plugin
993
+ }]) {
994
+ const from = imports.get(item.importName)?.from;
995
+ if (from && from !== item.packageName) return { reason: `${item.importName} is already imported from ${from}` };
996
+ if (!from) missing.set(item.packageName, [...missing.get(item.packageName) ?? [], item.importName]);
997
+ }
998
+ const call = `${importName}(${Object.keys(options).length ? printValue(options, quote) : ""})`;
999
+ const next = array?.type === nodeTypes.arrayExpression ? appendItem(source, array, call) : appendItem(source, config, `parsers: [${[...defaults.map((parser) => `${parser.importName}()`), call].join(", ")}]`);
1000
+ return { source: [...missing].reduceRight((current, [moduleSpecifier, importNames]) => addImport({
1001
+ source: current,
1002
+ parsed,
1003
+ importNames,
1004
+ moduleSpecifier,
1005
+ quote
1006
+ }), next) };
1250
1007
  }
1251
- /**
1252
- * Rewrites the config's `plugins` array with one item per line, so a call can be commented out without
1253
- * taking its neighbors with it. Not done when a comment sits between the items, which would have to move.
1254
- */
1255
- function expandPluginsArray(source, config) {
1256
- const array = property(config, "plugins");
1257
- if (array?.type !== "ArrayExpression") return void 0;
1008
+ /** One item per line, so a call can be commented out alone. Skipped when comments sit between items. */
1009
+ function splitPlugins(source, config) {
1010
+ const array = getValue(config, "plugins");
1011
+ if (array?.type !== nodeTypes.arrayExpression) return void 0;
1258
1012
  const items = array.elements.flatMap((element) => element ? [element] : []);
1259
- const { start, end } = rangeOf(array);
1260
- const ranges = items.map(rangeOf);
1261
- const gaps = [source.slice(start + 1, ranges[0]?.start ?? end - 1), ...ranges.map((range, index) => source.slice(range.end, ranges[index + 1]?.start ?? end - 1))];
1013
+ const { start, end } = array;
1014
+ const gaps = [source.slice(start + 1, items[0]?.start ?? end - 1), ...items.map((item, index) => source.slice(item.end, items[index + 1]?.start ?? end - 1))];
1262
1015
  if (!items.length || gaps.some((gap) => !/^[\s,]*$/.test(gap))) return void 0;
1263
1016
  const lineStart = source.lastIndexOf("\n", start - 1) + 1;
1264
1017
  const indent = source.slice(lineStart).match(/^[ \t]*/)?.[0] ?? "";
1265
- return splice(source, start, end, `[\n${ranges.map((range) => `${indent} ${source.slice(range.start, range.end)},`).join("\n")}\n${indent}]`);
1018
+ return replaceText(source, start, end, `[\n${items.map((item) => `${indent} ${source.slice(item.start, item.end)},`).join("\n")}\n${indent}]`);
1266
1019
  }
1267
- /** Comments out a plugin call in place, keeping its options on disk so `enable-plugin` can restore them exactly. */
1268
- function disablePlugin(source, mod, config, plugin) {
1269
- const loc = factoryCalls(mod, config).find((entry) => entry.packageName === plugin)?.call.loc;
1270
- if (!loc) return { reason: `${plugin} is not in the plugins array` };
1020
+ function disablePlugin(source, program, config, plugin) {
1021
+ const call = findCalls(program, config).find((entry) => entry.packageName === plugin)?.call;
1022
+ if (!call) return { reason: `${plugin} is not in the plugins array` };
1023
+ const start = getPosition(source, call.start);
1024
+ const end = getPosition(source, call.end);
1271
1025
  const lines = source.split("\n");
1272
- const from = loc.start.line - 1;
1273
- const to = loc.end.line - 1;
1026
+ const from = start.line - 1;
1027
+ const to = end.line - 1;
1274
1028
  const firstLine = lines[from] ?? "";
1275
- 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` };
1029
+ if (firstLine.slice(0, start.column).trim() || !/^,?\s*$/.test((lines[to] ?? "").slice(end.column))) return { reason: `${plugin} shares a line with other code, so it cannot be commented out safely` };
1276
1030
  const indent = firstLine.match(/^\s*/)?.[0] ?? "";
1277
1031
  const commented = lines.slice(from, to + 1).map((line) => line.trim() ? `${indent}// ${line.slice(indent.length)}` : `${indent}//`);
1278
1032
  lines.splice(from, commented.length, `${indent}// kubb:disabled ${plugin} ${commented.length}`, ...commented);
1279
1033
  return { source: lines.join("\n") };
1280
1034
  }
1281
- /** Uncomments the block a previous `disable-plugin` left behind for `plugin` in this config. */
1282
- function enablePlugin(source, config, plugin) {
1283
- const block = disabledBlocks(source, config).find((entry) => entry.packageName === plugin);
1035
+ /** Disables the plugin, first putting a one-line `plugins: [a(), b()]` (what `add-plugin` writes) on separate lines. */
1036
+ function disablePluginInConfig({ source, parsed, config, edit }) {
1037
+ const result = disablePlugin(source, parsed.program, config, edit.plugin);
1038
+ if (!("reason" in result) || !result.reason.includes("shares a line")) return result;
1039
+ const split = splitPlugins(source, config);
1040
+ if (split === void 0) return result;
1041
+ const reparsed = findConfigs(split);
1042
+ const reparsedConfig = "reason" in reparsed ? void 0 : findConfigEntry(reparsed.configs, edit.config);
1043
+ if ("reason" in reparsed || !reparsedConfig) return result;
1044
+ return disablePlugin(split, reparsed.program, reparsedConfig, edit.plugin);
1045
+ }
1046
+ function enablePlugin(source, comments, config, plugin) {
1047
+ const block = findDisabled(source, comments, config).find((entry) => entry.packageName === plugin);
1284
1048
  if (!block) return { reason: `${plugin} is not disabled` };
1285
1049
  const lines = source.split("\n");
1286
1050
  lines.splice(block.line - 1, block.end - block.line + 1, ...block.code);
1287
1051
  return { source: lines.join("\n") };
1288
1052
  }
1289
- /** Applies one edit to `source`. Every edit re-parses the file, since the previous one rewrote it. */
1290
- function applyEdit({ source, edit, quote, extensions }) {
1291
- const parsed = parseConfigs(source);
1053
+ /** Drops the import statements of `packageName` once nothing else in the file uses their names. */
1054
+ function removeUnusedImports(source, packageName) {
1055
+ const parsed = parseSource(source);
1056
+ if ("reason" in parsed) return source;
1057
+ return parsed.program.body.reduceRight((current, statement) => {
1058
+ const bindings = statement.type === nodeTypes.importDeclaration ? importBindings(statement) : statement.type === nodeTypes.variableDeclaration ? requireBindings(statement) : [];
1059
+ if (!bindings.length || bindings.some(([, { from }]) => from !== packageName)) return current;
1060
+ const rest = replaceText(current, statement.start, statement.end);
1061
+ if (bindings.some(([local]) => new RegExp(`(?<![\\w$])${local.replaceAll("$", "\\$")}(?![\\w$])`).test(rest))) return current;
1062
+ const lineStart = current.lastIndexOf("\n", statement.start - 1) + 1;
1063
+ const lineEnd = current.indexOf("\n", statement.end);
1064
+ const after = lineEnd === -1 ? current.length : lineEnd + 1;
1065
+ return replaceText(current, lineStart, lineStart === 0 && current[after] === "\n" ? after + 1 : after);
1066
+ }, source);
1067
+ }
1068
+ /**
1069
+ * Removes every call of a plugin or parser package, and the block `disable-plugin` commented out,
1070
+ * then its unused imports. Removing what the config does not hold is a no-op. Refuses to empty
1071
+ * `parsers`, since a config with no parser cannot print files.
1072
+ */
1073
+ function removeExtension(source, edit) {
1074
+ const kind = edit.kind ?? require_protocol.extensionKinds.plugin;
1075
+ let current = source;
1076
+ for (;;) {
1077
+ const parsed = findConfigs(current);
1078
+ if ("reason" in parsed) return parsed;
1079
+ const config = findConfigEntry(parsed.configs, edit.config);
1080
+ if (!config) return { reason: `no config entry found for ${JSON.stringify(edit.config)}` };
1081
+ const array = getValue(config, `${kind}s`);
1082
+ const calls = findCalls(parsed.program, config, kind).filter((entry) => entry.packageName === edit.plugin);
1083
+ const block = findDisabled(current, parsed.comments, config).find((entry) => entry.packageName === edit.plugin);
1084
+ if (calls[0] && array?.type === nodeTypes.arrayExpression) {
1085
+ const elements = array.elements.filter((element) => element !== null);
1086
+ if (kind === require_protocol.extensionKinds.parser && elements.length === calls.length) return { reason: `${edit.plugin} is the last parser in the parsers array` };
1087
+ current = removeItem(current, array, elements.indexOf(calls[0].call));
1088
+ continue;
1089
+ }
1090
+ if (block && kind === require_protocol.extensionKinds.plugin) {
1091
+ const lines = current.split("\n");
1092
+ lines.splice(block.line - 1, block.end - block.line + 1);
1093
+ current = lines.join("\n");
1094
+ continue;
1095
+ }
1096
+ return { source: current === source ? source : removeUnusedImports(current, edit.plugin) };
1097
+ }
1098
+ }
1099
+ /** Every edit re-parses the file, since the previous one rewrote it. */
1100
+ function applyEdit({ source, edit, extensions }) {
1101
+ const parsed = findConfigs(source);
1292
1102
  if ("reason" in parsed) return parsed;
1293
- const ref = edit.config;
1294
- const findConfig = (entries) => typeof ref === "string" ? entries.configs.find((entry) => configName(entry) === ref) : entries.configs[ref ?? 0];
1295
- const config = findConfig(parsed);
1296
- if (!config) return { reason: `no config entry found for ${JSON.stringify(ref)}` };
1297
- if (edit.operation === require_protocol.ConfigEditOperation.SET_INPUT) return applySetInput(source, config, edit.input, quote);
1298
- if (edit.operation === require_protocol.ConfigEditOperation.SET_OUTPUT || edit.operation === require_protocol.ConfigEditOperation.REMOVE_OUTPUT) return applyOutputEdit({
1299
- source,
1300
- config,
1301
- edit,
1302
- quote
1303
- });
1304
- if (edit.operation === require_protocol.ConfigEditOperation.DISABLE_PLUGIN) {
1305
- const result = disablePlugin(source, parsed.mod, config, edit.plugin);
1306
- if (!("reason" in result) || !result.reason.includes("shares a line")) return result;
1307
- const expanded = expandPluginsArray(source, config);
1308
- const reparsed = expanded === void 0 ? void 0 : parseConfigs(expanded);
1309
- const reparsedConfig = reparsed && !("reason" in reparsed) ? findConfig(reparsed) : void 0;
1310
- return expanded !== void 0 && reparsed && !("reason" in reparsed) && reparsedConfig ? disablePlugin(expanded, reparsed.mod, reparsedConfig, edit.plugin) : result;
1311
- }
1312
- if (edit.operation === require_protocol.ConfigEditOperation.ENABLE_PLUGIN) return enablePlugin(source, config, edit.plugin);
1313
- if (edit.operation === require_protocol.ConfigEditOperation.ADD_PLUGIN) return applyAddPlugin({
1314
- source,
1315
- parsed,
1316
- config,
1317
- edit,
1318
- quote,
1319
- extensions
1320
- });
1321
- const kind = edit.kind ?? "plugin";
1322
- const calls = factoryCalls(parsed.mod, config, kind).filter((entry) => entry.packageName === edit.plugin);
1323
- const exportName = extensions.get(edit.plugin)?.manifest.export ?? toExportName(edit.plugin);
1324
- const call = (calls.find((entry) => entry.exportName === exportName) ?? calls[0])?.call;
1325
- if (!call) {
1326
- if (kind === "adapter") return { reason: `${edit.plugin} is not the config's adapter` };
1327
- return { reason: kind === "plugin" && disabledBlocks(source, config).some((block) => block.packageName === edit.plugin) ? `${edit.plugin} is disabled, enable it first` : `${edit.plugin} is not in the ${kind}s array` };
1103
+ const config = findConfigEntry(parsed.configs, edit.config);
1104
+ if (!config) return { reason: `no config entry found for ${JSON.stringify(edit.config)}` };
1105
+ const quote = detectQuote(parsed.program) ?? quotes.single;
1106
+ switch (edit.operation) {
1107
+ case require_protocol.ConfigEditOperation.SET_INPUT: return setInput(source, config, edit.input, quote);
1108
+ case require_protocol.ConfigEditOperation.SET_OUTPUT:
1109
+ case require_protocol.ConfigEditOperation.REMOVE_OUTPUT: return editOutput({
1110
+ source,
1111
+ config,
1112
+ edit,
1113
+ quote
1114
+ });
1115
+ case require_protocol.ConfigEditOperation.DISABLE_PLUGIN: return disablePluginInConfig({
1116
+ source,
1117
+ parsed,
1118
+ config,
1119
+ edit
1120
+ });
1121
+ case require_protocol.ConfigEditOperation.ENABLE_PLUGIN: return enablePlugin(source, parsed.comments, config, edit.plugin);
1122
+ case require_protocol.ConfigEditOperation.ADD_PLUGIN: return addPlugin({
1123
+ source,
1124
+ parsed,
1125
+ config,
1126
+ edit,
1127
+ quote,
1128
+ extensions
1129
+ });
1130
+ case require_protocol.ConfigEditOperation.REMOVE_EXTENSION: return removeExtension(source, edit);
1131
+ case require_protocol.ConfigEditOperation.SET_OPTION:
1132
+ case require_protocol.ConfigEditOperation.REMOVE_OPTION: return editOption({
1133
+ source,
1134
+ parsed,
1135
+ config,
1136
+ edit,
1137
+ quote,
1138
+ extensions
1139
+ });
1328
1140
  }
1329
- return edit.operation === require_protocol.ConfigEditOperation.SET ? applySet(source, call, edit.path, edit.value, quote) : applyRemove(source, call, edit.path);
1330
1141
  }
1331
- /**
1332
- * Applies edits to a `kubb.config.ts` in place. Each edit rewrites only the text it targets, so
1333
- * comments, formatting, and hand-written code around the config survive.
1334
- *
1335
- * Edits are independent: one that cannot be applied is reported in `outcomes` and the rest still run.
1336
- *
1337
- * @example
1338
- * ```ts
1339
- * const { source, outcomes } = applyConfigEdits({
1340
- * source: current,
1341
- * edits: [{ operation: 'set', plugin: '@kubb/plugin-ts', path: ['enum', 'type'], value: 'enum' }],
1342
- * })
1343
- * ```
1344
- */
1345
- function applyConfigEdits({ source, edits, extensions = /* @__PURE__ */ new Map() }) {
1142
+ /** Applies edits in place, touching only the text each targets. A refused edit is reported and the rest still run. */
1143
+ function editConfig({ source, edits, extensions = /* @__PURE__ */ new Map() }) {
1346
1144
  let current = source;
1347
- const quote = (0, magicast.detectCodeFormat)(source).quote === "double" ? `"` : `'`;
1348
1145
  const outcomes = edits.map((edit) => {
1349
- const invalid = invalidEdit(edit);
1146
+ const invalid = validateEdit(edit);
1350
1147
  if (invalid) return {
1351
1148
  edit,
1352
1149
  applied: false,
@@ -1356,7 +1153,6 @@ function applyConfigEdits({ source, edits, extensions = /* @__PURE__ */ new Map(
1356
1153
  const result = applyEdit({
1357
1154
  source: current,
1358
1155
  edit,
1359
- quote,
1360
1156
  extensions
1361
1157
  });
1362
1158
  if ("reason" in result) return {
@@ -1383,17 +1179,14 @@ function applyConfigEdits({ source, edits, extensions = /* @__PURE__ */ new Map(
1383
1179
  changed: current !== source
1384
1180
  };
1385
1181
  }
1386
- /**
1387
- * Applies `edits` to the config file at `filePath`, writing it only when something changed.
1388
- * `add-plugin` accepts `@kubb/plugin-*` packages and the plugins in `extensions`.
1389
- */
1390
- async function writeConfigEdits({ filePath, edits, extensions }) {
1391
- const result = applyConfigEdits({
1182
+ /** Applies `edits` to `filePath`, writing only when something changed. */
1183
+ async function editConfigFile({ filePath, edits, extensions }) {
1184
+ const result = editConfig({
1392
1185
  source: await read(filePath),
1393
1186
  edits,
1394
1187
  extensions
1395
1188
  });
1396
- if (result.changed) await (0, node_fs_promises.writeFile)(filePath, result.source, "utf-8");
1189
+ if (result.changed) await writeRaw(filePath, result.source);
1397
1190
  return result;
1398
1191
  }
1399
1192
  //#endregion
@@ -1944,149 +1737,474 @@ function createGenerationStream(hooks, jobId) {
1944
1737
  } : void 0
1945
1738
  }]
1946
1739
  };
1947
- function forward(type) {
1948
- on(type, (...args) => {
1949
- emitEvent(type, projections[type](...args));
1950
- if (!isDiscardable(type)) return new Promise((resolve) => setImmediate(resolve));
1740
+ function forward(type) {
1741
+ on(type, (...args) => {
1742
+ emitEvent(type, projections[type](...args));
1743
+ if (!isDiscardable(type)) return new Promise((resolve) => setImmediate(resolve));
1744
+ });
1745
+ }
1746
+ on("kubb:build:start", ({ config }) => {
1747
+ root = config.root;
1748
+ });
1749
+ for (const type of require_protocol.generationEventTypes) forward(type);
1750
+ on("kubb:generation:end", ({ diagnostics = [], status, hrStart, filesCreated }) => {
1751
+ if (!hrStart) return;
1752
+ emitEvent("kubb:generation:summary", [{
1753
+ duration: Math.round(getElapsedMs(hrStart)),
1754
+ fileCount: filesCreated ?? 0,
1755
+ failedPlugins: _kubb_core.Diagnostics.failedPlugins(diagnostics).length,
1756
+ status: status ?? "success"
1757
+ }]);
1758
+ });
1759
+ /**
1760
+ * Takes this generation's listeners off the session emitter. Safe to call twice.
1761
+ */
1762
+ function detach() {
1763
+ for (const unhook of unhooks) unhook();
1764
+ unhooks.length = 0;
1765
+ }
1766
+ async function close() {
1767
+ if (closed) return;
1768
+ closed = true;
1769
+ detach();
1770
+ controller.close();
1771
+ }
1772
+ function fail(error) {
1773
+ detach();
1774
+ if (closed) return;
1775
+ closed = true;
1776
+ controller.error(error);
1777
+ }
1778
+ return {
1779
+ stream,
1780
+ close,
1781
+ fail
1782
+ };
1783
+ }
1784
+ //#endregion
1785
+ //#region src/publicFetch.ts
1786
+ const MAX_REDIRECTS = 5;
1787
+ const MAX_BYTES = 52428800;
1788
+ const REDIRECTS = /* @__PURE__ */ new Set([
1789
+ 301,
1790
+ 302,
1791
+ 303,
1792
+ 307,
1793
+ 308
1794
+ ]);
1795
+ const NO_BODY = /* @__PURE__ */ new Set([
1796
+ 204,
1797
+ 205,
1798
+ 304
1799
+ ]);
1800
+ function notPublic(host) {
1801
+ return /* @__PURE__ */ new Error(`${host} is not a public address, so a managed agent does not fetch from it`);
1802
+ }
1803
+ function createLookup(blocked) {
1804
+ return (hostname, options, callback) => {
1805
+ node_dns.default.lookup(hostname, {
1806
+ ...options,
1807
+ all: true
1808
+ }, (error, addresses) => {
1809
+ if (error) return callback(error, "", 0);
1810
+ const found = addresses;
1811
+ if (!found.length || found.some(({ address }) => !(0, _kubb_core.isPublicAddress)(address, blocked))) return callback(notPublic(hostname), "", 0);
1812
+ return options.all ? callback(null, found) : callback(null, found[0].address, found[0].family);
1813
+ });
1814
+ };
1815
+ }
1816
+ function request({ url, blocked, signal }) {
1817
+ if (url.protocol !== "http:" && url.protocol !== "https:") return Promise.reject(/* @__PURE__ */ new Error(`Only http and https URLs are fetched, not ${url.protocol}`));
1818
+ const host = url.hostname.replace(/^\[|\]$/g, "");
1819
+ if (node_net.default.isIP(host) && !(0, _kubb_core.isPublicAddress)(host, blocked)) return Promise.reject(notPublic(host));
1820
+ return new Promise((resolve, reject) => {
1821
+ const req = (url.protocol === "https:" ? node_https.default.request : node_http.default.request)(url, {
1822
+ method: "GET",
1823
+ lookup: createLookup(blocked),
1824
+ signal: signal ?? void 0,
1825
+ headers: { "accept-encoding": "identity" }
1826
+ }, (res) => {
1827
+ const headers = new Headers();
1828
+ for (const [key, value] of Object.entries(res.headers)) for (const item of [value].flat()) if (item !== void 0) headers.append(key, item);
1829
+ let total = 0;
1830
+ const limit = new node_stream.Transform({ transform(chunk, _encoding, done) {
1831
+ total += chunk.length;
1832
+ done(total > MAX_BYTES ? /* @__PURE__ */ new Error("The response is larger than 50 MB") : null, chunk);
1833
+ } });
1834
+ res.on("error", (error) => limit.destroy(error));
1835
+ res.pipe(limit);
1836
+ const body = NO_BODY.has(res.statusCode ?? 0) ? null : node_stream.Readable.toWeb(limit);
1837
+ resolve(new Response(body, {
1838
+ status: res.statusCode,
1839
+ statusText: res.statusMessage,
1840
+ headers
1841
+ }));
1842
+ });
1843
+ req.on("error", reject);
1844
+ req.end();
1845
+ });
1846
+ }
1847
+ /**
1848
+ * Builds a `fetch` that only reaches addresses `blocked` does not list. It follows redirects itself
1849
+ * and checks every hop, and takes GET requests only, since that is all a spec load needs.
1850
+ */
1851
+ function createPublicFetch(blocked) {
1852
+ const publicFetch = async (input, init) => {
1853
+ if (typeof input !== "string" && !(input instanceof URL)) throw new TypeError("A managed agent fetches by URL only");
1854
+ if (init?.method && init.method.toUpperCase() !== "GET") throw new TypeError("A managed agent only sends GET requests");
1855
+ let url = new URL(input);
1856
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
1857
+ const response = await request({
1858
+ url,
1859
+ blocked,
1860
+ signal: init?.signal
1861
+ });
1862
+ const location = response.headers.get("location");
1863
+ if (!REDIRECTS.has(response.status) || !location) return response;
1864
+ await response.body?.cancel();
1865
+ url = new URL(location, url);
1866
+ }
1867
+ throw new Error(`Stopped after ${MAX_REDIRECTS} redirects from ${String(input)}`);
1868
+ };
1869
+ return publicFetch;
1870
+ }
1871
+ /**
1872
+ * Runs `run` with the global `fetch` limited to public addresses, then puts the original back.
1873
+ * A managed agent runs one job at a time, so nothing else shares the window.
1874
+ */
1875
+ async function withPublicFetch(run) {
1876
+ const original = globalThis.fetch;
1877
+ globalThis.fetch = createPublicFetch();
1878
+ try {
1879
+ return await run();
1880
+ } finally {
1881
+ globalThis.fetch = original;
1882
+ }
1883
+ }
1884
+ //#endregion
1885
+ //#region src/resolveConfig.ts
1886
+ /**
1887
+ * Turns the JSON config Studio sends back into live Kubb objects.
1888
+ *
1889
+ * A plugin or adapter instance carries closures (`parse`, `getImports`, ...) that cannot survive
1890
+ * JSON, so both sides pass options over the wire and the factory is re-invoked here with the merged
1891
+ * result. Only packages {@link isAllowedPlugin} accepts are resolved this way, never an arbitrary
1892
+ * module the payload names.
1893
+ */
1894
+ /**
1895
+ * Imports a package, falling back to how the user's project would resolve it.
1896
+ *
1897
+ * `import()` resolves from this file, so a linked or globally installed Studio (`pnpm link`,
1898
+ * `npm i -g`) only sees its own `node_modules` and misses the plugins installed next to the user's
1899
+ * config. The retry resolves from `process.cwd()` instead.
1900
+ */
1901
+ async function importFromProject(packageName) {
1902
+ try {
1903
+ return await import(packageName);
1904
+ } catch {
1905
+ const resolved = (0, node_module.createRequire)((0, node_url.pathToFileURL)(`${process.cwd()}/`)).resolve(packageName);
1906
+ const esm = resolved.replace(/\.cjs$/, ".js");
1907
+ return await import((0, node_url.pathToFileURL)(esm !== resolved && (0, node_fs.existsSync)(esm) ? esm : resolved).href);
1908
+ }
1909
+ }
1910
+ /**
1911
+ * Adds the `@kubb/` scope a plugin's package carries but its `name` does not, the inverse of
1912
+ * {@link toPluginName}. A name outside the `plugin-` convention is left alone, so a third-party
1913
+ * plugin is not reported as one of Kubb's.
1914
+ *
1915
+ * @example
1916
+ * ```ts
1917
+ * toPackageName('plugin-ts') // '@kubb/plugin-ts'
1918
+ * toPackageName('@acme/my-plugin') // '@acme/my-plugin'
1919
+ * ```
1920
+ */
1921
+ function toPackageName(name) {
1922
+ return name.startsWith("plugin-") ? `@kubb/${name}` : name;
1923
+ }
1924
+ /**
1925
+ * The name Studio knows an extension by: the package of the installed extension whose manifest
1926
+ * `name` matches, otherwise its own `name`, so a plugin defined in the user's project is never
1927
+ * reported as a Kubb package that does not exist.
1928
+ *
1929
+ * @example
1930
+ * ```ts
1931
+ * toStudioName({ name: 'plugin-ts', extensions }) // '@kubb/plugin-ts'
1932
+ * toStudioName({ name: 'plugin-zod-operations', extensions }) // 'plugin-zod-operations', a local plugin
1933
+ * ```
1934
+ */
1935
+ function toStudioName({ name, extensions = /* @__PURE__ */ new Map(), kind = "plugin" }) {
1936
+ return [...extensions.values()].find(({ manifest }) => manifest.kind === kind && manifest.name === name)?.package ?? name;
1937
+ }
1938
+ /**
1939
+ * Dynamically imports an extension package and returns its factory. Callers check
1940
+ * {@link isAllowedPlugin} or derive the package from a disk instance first, so an arbitrary module
1941
+ * never reaches `import()`.
1942
+ *
1943
+ * Packages must be pre-installed in the Docker image at build time via the `KUBB_PACKAGES`
1944
+ * build ARG, no runtime installation is possible in the distroless container.
1945
+ *
1946
+ * Resolution order: the manifest's `export`, else the camelCase named export the package name
1947
+ * implies (e.g. `pluginTs`), then the default export.
1948
+ *
1949
+ * @throws if the package cannot be imported or exports no callable factory.
1950
+ */
1951
+ async function loadFactory({ packageName, extensions, kind = "plugin" }) {
1952
+ const label = {
1953
+ plugin: "Plugin",
1954
+ adapter: "Adapter",
1955
+ parser: "Parser"
1956
+ }[kind];
1957
+ let mod;
1958
+ try {
1959
+ mod = await importFromProject(packageName);
1960
+ } catch (cause) {
1961
+ throw new _kubb_core.Diagnostics.Error({
1962
+ code: kind === "plugin" ? _kubb_core.Diagnostics.code.pluginNotFound : _kubb_core.Diagnostics.code.unknown,
1963
+ severity: "error",
1964
+ message: `${label} "${packageName}" could not be loaded. Make sure it is installed: \`npm install ${packageName}\``,
1965
+ cause: toError(cause)
1966
+ });
1967
+ }
1968
+ const manifestExport = extensions.get(packageName)?.manifest.export;
1969
+ if (manifestExport) {
1970
+ if (typeof mod[manifestExport] === "function") return mod[manifestExport];
1971
+ throw new _kubb_core.Diagnostics.Error({
1972
+ code: _kubb_core.Diagnostics.code.incompatibleExtension,
1973
+ severity: "error",
1974
+ message: `${label} "${packageName}" does not export "${manifestExport}", the factory its kubb-extension.json names.`,
1975
+ help: "Install a version of the plugin whose exports match its kubb-extension.json."
1976
+ });
1977
+ }
1978
+ const exportName = toExportName(packageName);
1979
+ if (typeof mod[exportName] === "function") return mod[exportName];
1980
+ if (typeof mod["default"] === "function") return mod["default"];
1981
+ throw new _kubb_core.Diagnostics.Error({
1982
+ code: _kubb_core.Diagnostics.code.incompatibleExtension,
1983
+ severity: "error",
1984
+ message: `${label} "${packageName}" does not export a callable factory. Tried the named export "${exportName}" and "default".`,
1985
+ help: "Install a Kubb plugin package that exports its factory."
1986
+ });
1987
+ }
1988
+ /**
1989
+ * Resolves each plugin entry by dynamically importing its package and calling its factory with the
1990
+ * provided options. Only packages {@link isAllowedPlugin} accepts are loaded.
1991
+ *
1992
+ * @example
1993
+ * ```ts
1994
+ * const plugins = await resolvePlugins({
1995
+ * plugins: [
1996
+ * { name: '@kubb/plugin-react-query', options: { output: { path: './hooks' } } },
1997
+ * { name: '@kubb/plugin-ts', options: { output: { path: './types' } } },
1998
+ * ],
1999
+ * })
2000
+ * ```
2001
+ */
2002
+ async function resolvePlugins({ plugins, extensions = /* @__PURE__ */ new Map() }) {
2003
+ return Promise.all(plugins.map(async ({ name, options }) => {
2004
+ if (!isAllowedPlugin({
2005
+ packageName: name,
2006
+ extensions
2007
+ })) throw new _kubb_core.Diagnostics.Error({
2008
+ code: _kubb_core.Diagnostics.code.pluginNotFound,
2009
+ severity: "error",
2010
+ message: `Plugin "${name}" is not an installed Kubb plugin. Studio loads @kubb/plugin-* packages and installed packages that ship a kubb-extension.json with kind "plugin".`
2011
+ });
2012
+ return (await loadFactory({
2013
+ packageName: name,
2014
+ extensions
2015
+ }))(options ?? {});
2016
+ }));
2017
+ }
2018
+ /**
2019
+ * A JSON copy of plugin or adapter options: functions and other values JSON cannot hold are left
2020
+ * out, so nothing callable crosses the socket. Options that cannot be copied at all, such as a
2021
+ * circular structure, come back empty.
2022
+ *
2023
+ * @example
2024
+ * ```ts
2025
+ * toJSONOptions({ output: { path: 'types' }, group: { name: () => 'x' } }) // { output: { path: 'types' }, group: {} }
2026
+ * ```
2027
+ */
2028
+ function toJSONOptions(options) {
2029
+ try {
2030
+ const copy = JSON.parse(JSON.stringify(options ?? {}, (key, value) => isSafeKey(key) && typeof value !== "bigint" ? value : void 0));
2031
+ return isPlainObject(copy) ? copy : {};
2032
+ } catch {
2033
+ return {};
2034
+ }
2035
+ }
2036
+ /**
2037
+ * Deep-merges Studio's options over the disk options, with Studio taking priority, except where the
2038
+ * disk value cannot survive JSON. Studio only ever saw a lossy copy of such a value, so the disk
2039
+ * value is kept rather than replaced by what Studio echoes back. Plain objects merge key by key, so
2040
+ * Studio can still change a literal that sits next to a function (e.g. `group.type` beside `group.name`).
2041
+ *
2042
+ * @example
2043
+ * ```ts
2044
+ * mergeOptions({ macros: [camelCaseMacro], syntaxType: 'type' }, { macros: [{ name: 'camelCase' }], syntaxType: 'interface' })
2045
+ * // { macros: [camelCaseMacro], syntaxType: 'interface' }
2046
+ * mergeOptions({ emptySchemaType: 'any' }, { emptySchemaType: null }) // {}
2047
+ * ```
2048
+ */
2049
+ function mergeOptions(disk, studio) {
2050
+ const merged = { ...disk };
2051
+ for (const [key, value] of Object.entries(studio)) {
2052
+ if (!isSafeKey(key)) continue;
2053
+ const current = disk[key];
2054
+ if (value === null) {
2055
+ if (current === void 0 || isOptionValue(current)) delete merged[key];
2056
+ continue;
2057
+ }
2058
+ if (isPlainObject(current) && isPlainObject(value)) {
2059
+ merged[key] = mergeOptions(current, value);
2060
+ continue;
2061
+ }
2062
+ if (current !== void 0 && !isOptionValue(current)) continue;
2063
+ merged[key] = value;
2064
+ }
2065
+ return merged;
2066
+ }
2067
+ /**
2068
+ * Merges studio plugin options with disk config plugins.
2069
+ * Studio takes priority: options from studio win over disk (except values only the disk config
2070
+ * can express, see {@link mergeOptions}), and a plugin Studio explicitly
2071
+ * disabled is dropped even when the disk config still lists it. Disk plugins without a studio
2072
+ * counterpart are kept as-is. Studio plugins not present on disk are appended.
2073
+ *
2074
+ * For plugins present in both configs, the plugin is re-instantiated with merged options
2075
+ * so that all internal closures correctly reference the merged values.
2076
+ */
2077
+ async function mergePlugins({ diskPlugins, studioPlugins, extensions = /* @__PURE__ */ new Map() }) {
2078
+ const pluginNameOf = (packageName) => extensions.get(packageName)?.manifest.name ?? toPluginName(packageName);
2079
+ const disabledNames = new Set((studioPlugins ?? []).filter((entry) => entry.disabled).map((entry) => pluginNameOf(entry.name)));
2080
+ const activeDiskPlugins = disabledNames.size ? diskPlugins?.filter((plugin) => !disabledNames.has((0, _kubb_core.resolveMeta)(plugin).name)) : diskPlugins;
2081
+ const activeStudioPlugins = studioPlugins?.filter((entry) => !entry.disabled);
2082
+ if (!activeDiskPlugins && !activeStudioPlugins?.length) return void 0;
2083
+ if (!activeStudioPlugins?.length) return activeDiskPlugins;
2084
+ if (!activeDiskPlugins) return resolvePlugins({
2085
+ plugins: activeStudioPlugins,
2086
+ extensions
2087
+ });
2088
+ const studioEntryByName = new Map(activeStudioPlugins.map((entry) => [pluginNameOf(entry.name), entry]));
2089
+ const diskNames = new Set(activeDiskPlugins.map((plugin) => plugin.name));
2090
+ const merged = await Promise.all(activeDiskPlugins.map(async (diskPlugin) => {
2091
+ const studioEntry = studioEntryByName.get((0, _kubb_core.resolveMeta)(diskPlugin).name);
2092
+ if (!studioEntry) return diskPlugin;
2093
+ const diskOptions = diskPlugin.options ?? {};
2094
+ const options = mergeOptions(diskOptions, studioEntry.options ?? {});
2095
+ const unchanged = JSON.stringify(toJSONOptions(options)) === JSON.stringify(toJSONOptions(diskOptions));
2096
+ const [resolved] = await resolvePlugins({
2097
+ plugins: [{
2098
+ name: studioEntry.name,
2099
+ options
2100
+ }],
2101
+ extensions
2102
+ }).catch((cause) => {
2103
+ if (unchanged) return [diskPlugin];
2104
+ throw new _kubb_core.Diagnostics.Error({
2105
+ code: _kubb_core.Diagnostics.code.pluginNotFound,
2106
+ severity: "error",
2107
+ message: `Plugin "${diskPlugin.name}" is not an installed @kubb/plugin-* package or Kubb extension, so Studio cannot change its options. Edit it in the config file, or only enable or disable it from Studio.`,
2108
+ cause: toError(cause)
2109
+ });
2110
+ });
2111
+ return resolved ?? diskPlugin;
2112
+ }));
2113
+ const studioOnly = activeStudioPlugins.filter((entry) => !diskNames.has(pluginNameOf(entry.name)));
2114
+ return [...merged, ...await resolvePlugins({
2115
+ plugins: studioOnly,
2116
+ extensions
2117
+ })];
2118
+ }
2119
+ /**
2120
+ * Throws `KUBB_PATH_TRAVERSAL` when a plugin would write outside the config's output folder to a path
2121
+ * the disk config does not set. Studio can change `output.path`, also inside `override`, and add
2122
+ * plugins, so without this check any member of the Studio organization could write any file the agent
2123
+ * can reach, including `node_modules` code the next run imports. A path from the config file stays
2124
+ * allowed, since a monorepo may write outside its root.
2125
+ */
2126
+ function assertOutputPaths({ plugins, diskPlugins, root, outputPath }) {
2127
+ const base = node_path.default.resolve(root, outputPath);
2128
+ const targetsOf = (plugin) => {
2129
+ const { output, override } = plugin.options ?? {};
2130
+ return [output, ...Array.isArray(override) ? override.map((entry) => entry?.options?.output) : []].flatMap((entry) => typeof entry?.path === "string" ? [node_path.default.resolve(base, entry.path)] : []);
2131
+ };
2132
+ const diskTargets = new Set(diskPlugins.flatMap(targetsOf));
2133
+ for (const plugin of plugins) {
2134
+ const target = targetsOf(plugin).find((resolved) => !diskTargets.has(resolved) && !isPathInside(resolved, base));
2135
+ if (!target) continue;
2136
+ throw new _kubb_core.Diagnostics.Error({
2137
+ code: _kubb_core.Diagnostics.code.pathTraversal,
2138
+ severity: "error",
2139
+ message: `Plugin "${plugin.name}" would write to "${target}", outside the output folder "${base}". Studio can only set an output path inside it.`,
2140
+ help: "Choose an output path inside the output folder, or set this path in the config file.",
2141
+ location: { kind: "config" },
2142
+ plugin: plugin.name
1951
2143
  });
1952
2144
  }
1953
- on("kubb:build:start", ({ config }) => {
1954
- root = config.root;
1955
- });
1956
- for (const type of require_protocol.generationEventTypes) forward(type);
1957
- on("kubb:generation:end", ({ diagnostics = [], status, hrStart, filesCreated }) => {
1958
- if (!hrStart) return;
1959
- emitEvent("kubb:generation:summary", [{
1960
- duration: Math.round(getElapsedMs(hrStart)),
1961
- fileCount: filesCreated ?? 0,
1962
- failedPlugins: _kubb_core.Diagnostics.failedPlugins(diagnostics).length,
1963
- status: status ?? "success"
1964
- }]);
1965
- });
1966
- /**
1967
- * Takes this generation's listeners off the session emitter. Safe to call twice.
1968
- */
1969
- function detach() {
1970
- for (const unhook of unhooks) unhook();
1971
- unhooks.length = 0;
1972
- }
1973
- async function close() {
1974
- if (closed) return;
1975
- closed = true;
1976
- detach();
1977
- controller.close();
1978
- }
1979
- function fail(error) {
1980
- detach();
1981
- if (closed) return;
1982
- closed = true;
1983
- controller.error(error);
1984
- }
1985
- return {
1986
- stream,
1987
- close,
1988
- fail
1989
- };
1990
- }
1991
- //#endregion
1992
- //#region src/publicFetch.ts
1993
- const MAX_REDIRECTS = 5;
1994
- const MAX_BYTES = 52428800;
1995
- const REDIRECTS = /* @__PURE__ */ new Set([
1996
- 301,
1997
- 302,
1998
- 303,
1999
- 307,
2000
- 308
2001
- ]);
2002
- const NO_BODY = /* @__PURE__ */ new Set([
2003
- 204,
2004
- 205,
2005
- 304
2006
- ]);
2007
- function notPublic(host) {
2008
- return /* @__PURE__ */ new Error(`${host} is not a public address, so a managed agent does not fetch from it`);
2009
2145
  }
2010
- function createLookup(blocked) {
2011
- return (hostname, options, callback) => {
2012
- node_dns.default.lookup(hostname, {
2013
- ...options,
2014
- all: true
2015
- }, (error, addresses) => {
2016
- if (error) return callback(error, "", 0);
2017
- const found = addresses;
2018
- if (!found.length || found.some(({ address }) => !(0, _kubb_core.isPublicAddress)(address, blocked))) return callback(notPublic(hostname), "", 0);
2019
- return options.all ? callback(null, found) : callback(null, found[0].address, found[0].family);
2146
+ /**
2147
+ * Re-invokes the factory behind a disk adapter or parser with Studio's options merged over its own.
2148
+ * An instance carries live functions (`parse`, `print`, ...) that cannot survive JSON, so Studio's
2149
+ * options are a patch, not a replacement, and the factory is found from the disk instance itself: the
2150
+ * installed extension whose manifest names it, or the `@kubb/<kind>-<name>` package for a built-in
2151
+ * one. Throws when no factory resolves, as `mergePlugins` does, so an override Studio sent is never
2152
+ * dropped without a word. An extension without a manifest, such as `parserTsx`, has no options in
2153
+ * Studio, so Studio never sends one for it.
2154
+ */
2155
+ async function rebuildExtension({ disk, studioOptions, kind, extensions }) {
2156
+ if (!studioOptions || !Object.keys(studioOptions).length) return disk;
2157
+ const { name } = (0, _kubb_core.resolveMeta)(disk);
2158
+ return (await loadFactory({
2159
+ packageName: [...extensions.values()].find(({ manifest }) => manifest.kind === kind && manifest.name === name)?.package ?? `@kubb/${kind}-${name}`,
2160
+ extensions,
2161
+ kind
2162
+ }).catch((cause) => {
2163
+ throw new _kubb_core.Diagnostics.Error({
2164
+ code: kind === "plugin" ? _kubb_core.Diagnostics.code.pluginNotFound : _kubb_core.Diagnostics.code.unknown,
2165
+ severity: "error",
2166
+ message: `The ${kind} "${name}" is not an installed Kubb extension, so Studio cannot change its options. Edit it in the config file.`,
2167
+ cause: toError(cause)
2020
2168
  });
2021
- };
2169
+ }))(mergeOptions(disk.options ?? {}, studioOptions));
2022
2170
  }
2023
- function request({ url, blocked, signal }) {
2024
- if (url.protocol !== "http:" && url.protocol !== "https:") return Promise.reject(/* @__PURE__ */ new Error(`Only http and https URLs are fetched, not ${url.protocol}`));
2025
- const host = url.hostname.replace(/^\[|\]$/g, "");
2026
- if (node_net.default.isIP(host) && !(0, _kubb_core.isPublicAddress)(host, blocked)) return Promise.reject(notPublic(host));
2027
- return new Promise((resolve, reject) => {
2028
- const req = (url.protocol === "https:" ? node_https.default.request : node_http.default.request)(url, {
2029
- method: "GET",
2030
- lookup: createLookup(blocked),
2031
- signal: signal ?? void 0,
2032
- headers: { "accept-encoding": "identity" }
2033
- }, (res) => {
2034
- const headers = new Headers();
2035
- for (const [key, value] of Object.entries(res.headers)) for (const item of [value].flat()) if (item !== void 0) headers.append(key, item);
2036
- let total = 0;
2037
- const limit = new node_stream.Transform({ transform(chunk, _encoding, done) {
2038
- total += chunk.length;
2039
- done(total > MAX_BYTES ? /* @__PURE__ */ new Error("The response is larger than 50 MB") : null, chunk);
2040
- } });
2041
- res.on("error", (error) => limit.destroy(error));
2042
- res.pipe(limit);
2043
- const body = NO_BODY.has(res.statusCode ?? 0) ? null : node_stream.Readable.toWeb(limit);
2044
- resolve(new Response(body, {
2045
- status: res.statusCode,
2046
- statusText: res.statusMessage,
2047
- headers
2048
- }));
2049
- });
2050
- req.on("error", reject);
2051
- req.end();
2052
- });
2171
+ /** Merges Studio's valid `output` overrides, with `null` unsetting a member. */
2172
+ function mergeOutput({ output, studio }) {
2173
+ if (!isPlainObject(studio)) return output;
2174
+ return mergeOptions(output, Object.fromEntries(Object.entries(studio).filter(([key, value]) => value === null ? isOutputKey(key) : isOutputValue({
2175
+ key,
2176
+ value
2177
+ }))));
2053
2178
  }
2054
2179
  /**
2055
- * Builds a `fetch` that only reaches addresses `blocked` does not list. It follows redirects itself
2056
- * and checks every hop, and takes GET requests only, since that is all a spec load needs.
2180
+ * Merges Studio-provided adapter option overrides into the disk config's adapter, see {@link rebuildExtension}.
2057
2181
  */
2058
- function createPublicFetch(blocked) {
2059
- const publicFetch = async (input, init) => {
2060
- if (typeof input !== "string" && !(input instanceof URL)) throw new TypeError("A managed agent fetches by URL only");
2061
- if (init?.method && init.method.toUpperCase() !== "GET") throw new TypeError("A managed agent only sends GET requests");
2062
- let url = new URL(input);
2063
- for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
2064
- const response = await request({
2065
- url,
2066
- blocked,
2067
- signal: init?.signal
2068
- });
2069
- const location = response.headers.get("location");
2070
- if (!REDIRECTS.has(response.status) || !location) return response;
2071
- await response.body?.cancel();
2072
- url = new URL(location, url);
2073
- }
2074
- throw new Error(`Stopped after ${MAX_REDIRECTS} redirects from ${String(input)}`);
2075
- };
2076
- return publicFetch;
2182
+ async function mergeAdapter(diskAdapter, studioOptions, extensions = /* @__PURE__ */ new Map()) {
2183
+ if (!diskAdapter) return diskAdapter;
2184
+ return rebuildExtension({
2185
+ disk: diskAdapter,
2186
+ studioOptions,
2187
+ kind: "adapter",
2188
+ extensions
2189
+ });
2077
2190
  }
2078
2191
  /**
2079
- * Runs `run` with the global `fetch` limited to public addresses, then puts the original back.
2080
- * A managed agent runs one job at a time, so nothing else shares the window.
2192
+ * Merges Studio-provided parser option overrides into the disk config's parsers, matched by the name
2193
+ * Studio knows each parser by, see {@link rebuildExtension}. Parsers Studio does not name are kept as is.
2081
2194
  */
2082
- async function withPublicFetch(run) {
2083
- const original = globalThis.fetch;
2084
- globalThis.fetch = createPublicFetch();
2085
- try {
2086
- return await run();
2087
- } finally {
2088
- globalThis.fetch = original;
2089
- }
2195
+ async function mergeParsers({ diskParsers, studioParsers, extensions = /* @__PURE__ */ new Map() }) {
2196
+ if (!studioParsers?.length) return diskParsers;
2197
+ const studioOptions = new Map(studioParsers.map((entry) => [entry.name, entry.options]));
2198
+ return Promise.all(diskParsers.map((parser) => rebuildExtension({
2199
+ disk: parser,
2200
+ studioOptions: studioOptions.get(toStudioName({
2201
+ name: (0, _kubb_core.resolveMeta)(parser).name,
2202
+ extensions,
2203
+ kind: "parser"
2204
+ })),
2205
+ kind: "parser",
2206
+ extensions
2207
+ })));
2090
2208
  }
2091
2209
  //#endregion
2092
2210
  //#region src/url.ts
@@ -2130,13 +2248,6 @@ const MANAGED_INPUT_KINDS = /* @__PURE__ */ new Set([
2130
2248
  "url",
2131
2249
  "git"
2132
2250
  ]);
2133
- /** Extensions `kubb` ships with, so they count as installed without being a direct dependency. */
2134
- const BUILT_IN_PACKAGES = [
2135
- "@kubb/adapter-oas",
2136
- "@kubb/parser-ts",
2137
- "@kubb/parser-md",
2138
- "@kubb/plugin-barrel"
2139
- ];
2140
2251
  /**
2141
2252
  * Studio closed with {@link AgentCloseCode.UNAUTHORIZED}. Hosts forget the token and pair again.
2142
2253
  */
@@ -2264,6 +2375,7 @@ var Agent = class extends capnweb.RpcTarget {
2264
2375
  allowConfigEdit: false,
2265
2376
  allowInstall: false,
2266
2377
  allowUpdate: false,
2378
+ allowRemove: false,
2267
2379
  allowSaveInput: false,
2268
2380
  allowRead: true,
2269
2381
  allowReadAst: true
@@ -2329,13 +2441,14 @@ var Agent = class extends capnweb.RpcTarget {
2329
2441
  const imports = await source.then(readImports, () => []);
2330
2442
  const installed = await (0, _kubb_core.readInstalledPackages)({
2331
2443
  root: this.#options.root,
2332
- packages: [...imports, ...BUILT_IN_PACKAGES],
2444
+ packages: [...imports, ...BUNDLED_PACKAGES],
2333
2445
  resolveFrom: ["kubb"]
2334
2446
  });
2335
2447
  return new Map(installed.flatMap(({ package: name, version, manifest }) => version !== void 0 && manifest ? [[name, {
2336
2448
  package: name,
2337
2449
  version,
2338
- manifest
2450
+ manifest,
2451
+ ...BUNDLED_PACKAGES.includes(name) ? { bundled: true } : {}
2339
2452
  }]] : []));
2340
2453
  }
2341
2454
  async connect(info) {
@@ -2574,7 +2687,7 @@ var Agent = class extends capnweb.RpcTarget {
2574
2687
  try {
2575
2688
  const kept = [];
2576
2689
  for (const edit of edits) kept.push(await this.#keepInput(edit));
2577
- const written = await writeConfigEdits({
2690
+ const written = await editConfigFile({
2578
2691
  filePath: configFile,
2579
2692
  edits: kept.flatMap((edit) => "reason" in edit ? [] : [edit]),
2580
2693
  extensions: await this.#installedExtensions()
@@ -2711,10 +2824,49 @@ var Agent = class extends capnweb.RpcTarget {
2711
2824
  reason: output
2712
2825
  } : { updated: true };
2713
2826
  }
2827
+ /**
2828
+ * Removes an extension with `kubb remove <id> --yes`, which deletes it from `kubb.config.ts` and
2829
+ * uninstalls its package. Studio calls `connect()` again afterwards.
2830
+ */
2831
+ async removeExtension(input) {
2832
+ this.#assertConnected();
2833
+ const { root, configFile, kubbBin = findKubbBin(root) } = this.#options;
2834
+ const refuse = async (reason, permission) => {
2835
+ await this.#warn({
2836
+ message: `Ignored remove: ${reason}`,
2837
+ permission
2838
+ });
2839
+ return {
2840
+ removed: false,
2841
+ reason
2842
+ };
2843
+ };
2844
+ if (typeof input?.id !== "string" || !REGISTRY_ID.test(input.id)) return refuse("the message carried no registry id");
2845
+ if (!this.#permissions.allowRemove) return refuse("removing extensions was not granted", require_protocol.AgentPermission.REMOVE);
2846
+ if (!this.#permissions.allowConfigEdit) return refuse("editing kubb.config.ts was not granted", require_protocol.AgentPermission.CONFIG_EDIT);
2847
+ const busy = this.#busy();
2848
+ if (busy) return refuse(busy);
2849
+ if (!kubbBin) return refuse("kubb is not installed in the project");
2850
+ const { failed, output } = await this.#runKubb({
2851
+ bin: kubbBin,
2852
+ args: [
2853
+ "remove",
2854
+ input.id,
2855
+ "--yes",
2856
+ "--config",
2857
+ configFile
2858
+ ]
2859
+ });
2860
+ await this.#logCommand("removeExtension", `${failed ? "failed to remove" : "removed"} ${input.id}`, input.requestedBy, failed);
2861
+ return failed ? {
2862
+ removed: false,
2863
+ reason: output
2864
+ } : { removed: true };
2865
+ }
2714
2866
  /** Why a command that touches the project cannot start right now. */
2715
2867
  #busy() {
2716
2868
  if (this.#session.active) return "a generation is in progress";
2717
- if (this.#session.install) return "an install or update is in progress";
2869
+ if (this.#session.install) return "an install, update, or remove is in progress";
2718
2870
  if (this.#session.saving) return "a save is in progress";
2719
2871
  }
2720
2872
  /** Runs `kubb` in the project while holding off saves and generations, and ends it when the socket closes. */
@@ -3001,6 +3153,7 @@ async function runAgent({ options, openRpc }) {
3001
3153
  allowConfigEdit: false,
3002
3154
  allowInstall: false,
3003
3155
  allowUpdate: false,
3156
+ allowRemove: false,
3004
3157
  allowSaveInput: false,
3005
3158
  allowExec: false,
3006
3159
  allowRead: false,
@@ -3269,6 +3422,22 @@ function header(path, size) {
3269
3422
  return value;
3270
3423
  }
3271
3424
  /**
3425
+ * Loads `tsdown` on first use. It is an optional peer dependency: it is large, and only snapshots need it.
3426
+ */
3427
+ async function loadTsdown(load = () => import("tsdown")) {
3428
+ try {
3429
+ return await load();
3430
+ } catch (error) {
3431
+ if (error.code !== "ERR_MODULE_NOT_FOUND" || !error.message.includes("'tsdown'")) throw error;
3432
+ throw new _kubb_core.Diagnostics.Error({
3433
+ code: _kubb_core.Diagnostics.code.missingDependency,
3434
+ severity: "error",
3435
+ message: "Missing dependency: tsdown. A snapshot builds its dist files with tsdown, which Kubb does not install by default.",
3436
+ help: "Run `npm i -D tsdown` in the project, then try again."
3437
+ });
3438
+ }
3439
+ }
3440
+ /**
3272
3441
  * Packs a generation's files into a gzipped, npm-installable tarball: a `package/` root with the
3273
3442
  * generated sources, a `tsdown`-built `dist/` (esm + cjs), and a `package.json` manifest.
3274
3443
  */
@@ -3297,7 +3466,7 @@ async function createSnapshotPackage({ files, packageInfo }) {
3297
3466
  }));
3298
3467
  const entry = [...sources.keys()].filter((path) => /\.(?:[cm]?[jt]sx?)$/.test(path)).map((path) => (0, node_path.join)(root, path));
3299
3468
  if (entry.length) {
3300
- const { build } = await import("tsdown");
3469
+ const { build } = await loadTsdown();
3301
3470
  await build({
3302
3471
  entry,
3303
3472
  outDir: dist,
@@ -3451,11 +3620,11 @@ exports.InvalidAgentTokenError = InvalidAgentTokenError;
3451
3620
  exports.PairingCanceledError = PairingCanceledError;
3452
3621
  exports.PairingDeniedError = PairingDeniedError;
3453
3622
  exports.PairingExpiredError = PairingExpiredError;
3454
- exports.applyConfigEdits = applyConfigEdits;
3455
3623
  exports.connectAgent = connectAgent;
3456
3624
  exports.createSnapshot = createSnapshot;
3457
3625
  exports.defaultStudioUrl = defaultStudioUrl;
3626
+ exports.editConfig = editConfig;
3627
+ exports.editConfigFile = editConfigFile;
3458
3628
  exports.pairAgent = pairAgent;
3459
- exports.writeConfigEdits = writeConfigEdits;
3460
3629
 
3461
3630
  //# sourceMappingURL=index.cjs.map