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