@kubb/studio 5.1.0 → 5.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1,6 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_rolldown_runtime = require("./rolldown-runtime-qbf5tadS.cjs");
3
- const require_resolveConfig = require("./resolveConfig-Ci-BVhN_.cjs");
4
3
  const require_protocol = require("./protocol.cjs");
5
4
  let node_util = require("node:util");
6
5
  let node_fs_promises = require("node:fs/promises");
@@ -16,6 +15,11 @@ let unstorage_drivers_fs = require("unstorage/drivers/fs");
16
15
  unstorage_drivers_fs = require_rolldown_runtime.__toESM(unstorage_drivers_fs, 1);
17
16
  let _kubb_core = require("@kubb/core");
18
17
  let tinyexec = require("tinyexec");
18
+ let magicast = require("magicast");
19
+ let node_fs = require("node:fs");
20
+ let node_module = require("node:module");
21
+ let node_url = require("node:url");
22
+ let remeda = require("remeda");
19
23
  let ws = require("ws");
20
24
  ws = require_rolldown_runtime.__toESM(ws, 1);
21
25
  let node_timers_promises = require("node:timers/promises");
@@ -32,14 +36,43 @@ const defaultStudioUrl = "https://kubb.studio";
32
36
  const agentDefaults = {
33
37
  studioUrl: defaultStudioUrl,
34
38
  retryIntervalMs: 3e4,
39
+ heartbeatIntervalMs: 3e4,
35
40
  /**
36
- * Maximum heartbeat interval. Studio drops agents from the active list after ~90s without a ping,
37
- * so a slower override would make a healthy agent look dead.
41
+ * Slowest heartbeat a host may ask for. Studio drops an agent from the active list once its
42
+ * stored ping is older than its liveness window, and it stores a ping at most once a minute, so
43
+ * a slower cadence would make a healthy agent look dead after a single missed ping.
38
44
  */
39
- heartbeatIntervalMs: 3e4,
45
+ maxHeartbeatIntervalMs: 6e4,
40
46
  poolSize: 1
41
47
  };
42
48
  //#endregion
49
+ //#region ../../internals/utils/src/casing.ts
50
+ /**
51
+ * Shared implementation for camelCase and PascalCase conversion.
52
+ * Splits on common word boundaries (spaces, hyphens, underscores, dots, slashes, colons)
53
+ * and capitalizes each word according to `pascal`.
54
+ *
55
+ * When `pascal` is `true` the first word is also capitalized (PascalCase), otherwise only subsequent words are.
56
+ */
57
+ function toCamelOrPascal(text, pascal) {
58
+ return text.trim().replace(/([a-z\d])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").replace(/(\d)([a-z])/g, "$1 $2").split(/[\s\-_./\\:]+/).filter(Boolean).map((word, i) => {
59
+ if (word.length > 1 && word === word.toUpperCase()) return word;
60
+ return (i === 0 && !pascal ? word.charAt(0).toLowerCase() : word.charAt(0).toUpperCase()) + word.slice(1);
61
+ }).join("").replace(/[^a-zA-Z0-9]/g, "");
62
+ }
63
+ /**
64
+ * Converts `text` to camelCase.
65
+ *
66
+ * @example Word boundaries
67
+ * `camelCase('hello-world') // 'helloWorld'`
68
+ *
69
+ * @example With a prefix
70
+ * `camelCase('tag', { prefix: 'create' }) // 'createTag'`
71
+ */
72
+ function camelCase(text, { prefix = "", suffix = "" } = {}) {
73
+ return toCamelOrPascal(`${prefix} ${text} ${suffix}`, false);
74
+ }
75
+ //#endregion
43
76
  //#region ../../internals/utils/src/errors.ts
44
77
  /**
45
78
  * Coerces an unknown thrown value to an `Error` instance.
@@ -68,6 +101,91 @@ function getErrorMessage(value) {
68
101
  return value instanceof Error ? value.message : String(value);
69
102
  }
70
103
  //#endregion
104
+ //#region ../../internals/utils/src/runtime.ts
105
+ /**
106
+ * Detects the JavaScript runtime executing the current process and exposes its name and version.
107
+ *
108
+ * Prefer the shared {@link runtime} instance over constructing your own.
109
+ */
110
+ var Runtime = class {
111
+ /**
112
+ * `true` when the current process is running under Bun.
113
+ *
114
+ * Detection keys off the global `Bun` object rather than `process.versions`,
115
+ * because Bun polyfills `process.versions.node` for Node compatibility and would
116
+ * otherwise look like Node.
117
+ *
118
+ * @example
119
+ * ```ts
120
+ * if (runtime.isBun) {
121
+ * await Bun.write(path, data)
122
+ * }
123
+ * ```
124
+ */
125
+ get isBun() {
126
+ return typeof Bun !== "undefined";
127
+ }
128
+ /**
129
+ * `true` when the current process is running under Deno.
130
+ */
131
+ get isDeno() {
132
+ return typeof globalThis.Deno !== "undefined";
133
+ }
134
+ /**
135
+ * `true` when the current process is running under Node.
136
+ *
137
+ * Bun and Deno are excluded first so a polyfilled `process` does not register as Node.
138
+ */
139
+ get isNode() {
140
+ return !this.isBun && !this.isDeno && typeof process !== "undefined" && process.versions?.node != null;
141
+ }
142
+ /**
143
+ * Name of the runtime executing the current process.
144
+ *
145
+ * @example
146
+ * ```ts
147
+ * runtime.name // 'bun' when run with `bun kubb`, 'node' otherwise
148
+ * ```
149
+ */
150
+ get name() {
151
+ if (this.isBun) return "bun";
152
+ if (this.isDeno) return "deno";
153
+ return "node";
154
+ }
155
+ /**
156
+ * Version of the active runtime, or an empty string when it cannot be read.
157
+ *
158
+ * @example
159
+ * ```ts
160
+ * runtime.version // '1.3.11' under Bun, '22.22.2' under Node
161
+ * ```
162
+ */
163
+ get version() {
164
+ if (this.isBun) return process.versions.bun ?? "";
165
+ if (this.isDeno) return globalThis.Deno?.version?.deno ?? "";
166
+ return process.versions?.node ?? "";
167
+ }
168
+ };
169
+ /**
170
+ * Shared {@link Runtime} instance describing the JavaScript runtime executing the current process.
171
+ */
172
+ const runtime = new Runtime();
173
+ //#endregion
174
+ //#region ../../internals/utils/src/fs.ts
175
+ /**
176
+ * Reads the file at `path` as a UTF-8 string.
177
+ * Uses `Bun.file().text()` when running under Bun, `fs.readFile` otherwise.
178
+ *
179
+ * @example
180
+ * ```ts
181
+ * const source = await read('./src/Pet.ts')
182
+ * ```
183
+ */
184
+ async function read(path) {
185
+ if (runtime.isBun) return Bun.file(path).text();
186
+ return (0, node_fs_promises.readFile)(path, { encoding: "utf8" });
187
+ }
188
+ //#endregion
71
189
  //#region ../../internals/utils/src/promise.ts
72
190
  /**
73
191
  * Wraps `factory` with a keyed cache backed by the provided store.
@@ -451,16 +569,19 @@ async function disconnect({ sessionId, token, studioUrl, slug }) {
451
569
  }
452
570
  //#endregion
453
571
  //#region package.json
454
- var version = "5.1.0";
572
+ var version = "5.2.1";
455
573
  //#endregion
456
574
  //#region src/hooks.ts
457
575
  /**
458
576
  * Register a `kubb:hook:start` listener that spawns the requested command via tinyexec,
459
577
  * streams each stdout line as a `kubb:hook:line` event, and calls `kubb:hook:end` with the result.
460
578
  * Streaming the output lets Kubb Studio render live hook progress over the WebSocket connection.
579
+ *
580
+ * Returns a remover, so a session that runs one generation after another on the same emitter does
581
+ * not stack a listener per run.
461
582
  */
462
583
  function setupHookListener(hooks, root) {
463
- hooks.hook("kubb:hook:start", async (ctx) => {
584
+ return hooks.hook("kubb:hook:start", async (ctx) => {
464
585
  const { id, command, args } = ctx;
465
586
  if (!id) return;
466
587
  const commandWithArgs = args?.length ? `${command} ${args.join(" ")}` : command;
@@ -524,6 +645,718 @@ function waitForHookEnd(hooks, hookId) {
524
645
  });
525
646
  }
526
647
  //#endregion
648
+ //#region src/resolveConfig.ts
649
+ /**
650
+ * Imports a package, falling back to how the user's project would resolve it.
651
+ *
652
+ * `import()` resolves from this file, so a linked or globally installed Studio (`pnpm link`,
653
+ * `npm i -g`) only sees its own `node_modules` and misses the plugins installed next to the user's
654
+ * config. The retry resolves from `process.cwd()` instead.
655
+ */
656
+ async function importFromProject(packageName) {
657
+ try {
658
+ return await import(packageName);
659
+ } catch {
660
+ const resolved = (0, node_module.createRequire)((0, node_url.pathToFileURL)(`${process.cwd()}/`)).resolve(packageName);
661
+ const esm = resolved.replace(/\.cjs$/, ".js");
662
+ return await import((0, node_url.pathToFileURL)(esm !== resolved && (0, node_fs.existsSync)(esm) ? esm : resolved).href);
663
+ }
664
+ }
665
+ /**
666
+ * Strips the `@kubb/` scope from a plugin package name, matching the `name` convention Kubb
667
+ * plugin factories use internally.
668
+ *
669
+ * @example
670
+ * ```ts
671
+ * toPluginName('@kubb/plugin-ts') // 'plugin-ts'
672
+ * ```
673
+ */
674
+ function toPluginName(packageName) {
675
+ return packageName.split("/").pop() ?? packageName;
676
+ }
677
+ /**
678
+ * Derives the conventional named export for a `@kubb/*` plugin package from its package name.
679
+ *
680
+ * @example
681
+ * ```ts
682
+ * toExportName('@kubb/plugin-react-query') // 'pluginReactQuery'
683
+ * toExportName('@kubb/plugin-ts') // 'pluginTs'
684
+ * ```
685
+ */
686
+ function toExportName(packageName) {
687
+ return camelCase(toPluginName(packageName));
688
+ }
689
+ /**
690
+ * A `@kubb/plugin-*` package specifier. Nothing else may reach `import()`: only Kubb's own
691
+ * plugins are supported, so a payload naming anything else, a third-party package or a path, is
692
+ * refused before it can execute.
693
+ */
694
+ const KUBB_PLUGIN_SPECIFIER = /^@kubb\/plugin-[\w.-]+$/;
695
+ /**
696
+ * Whether `name` is a `@kubb/plugin-*` specifier. Exported so `configFile.ts` can refuse the same
697
+ * shape before printing a Studio-supplied plugin name into the config file's source text.
698
+ */
699
+ function isKubbPluginSpecifier(name) {
700
+ return KUBB_PLUGIN_SPECIFIER.test(name);
701
+ }
702
+ /**
703
+ * Dynamically imports a `@kubb/plugin-*` package and returns its factory function.
704
+ *
705
+ * Packages must be pre-installed in the Docker image at build time via the `KUBB_PACKAGES`
706
+ * build ARG, no runtime installation is possible in the distroless container.
707
+ *
708
+ * Resolution order: the camelCase named export the package name implies (e.g. `pluginTs`), then
709
+ * the default export.
710
+ *
711
+ * @throws if the package cannot be imported or exports no callable factory.
712
+ */
713
+ async function loadPluginFactory(packageName) {
714
+ if (!isKubbPluginSpecifier(packageName)) throw new Error(`Plugin "${packageName}" is not a @kubb/plugin-* package. Kubb Studio only supports Kubb's own plugins.`);
715
+ let mod;
716
+ try {
717
+ mod = await importFromProject(packageName);
718
+ } catch (cause) {
719
+ throw new Error(`Plugin "${packageName}" could not be loaded. Make sure it is installed: \`npm install ${packageName}\``, { cause });
720
+ }
721
+ const exportName = toExportName(packageName);
722
+ if (typeof mod[exportName] === "function") return mod[exportName];
723
+ if (typeof mod["default"] === "function") return mod["default"];
724
+ throw new Error(`Plugin "${packageName}" does not export a callable factory. Tried the named export "${exportName}" and "default".`);
725
+ }
726
+ /**
727
+ * Resolves each plugin entry by dynamically importing the `@kubb/plugin-*` package and
728
+ * calling its factory with the provided options.
729
+ *
730
+ * Packages must be pre-installed in the Docker image at build time, use the `KUBB_PACKAGES`
731
+ * build ARG to control which ones are available at runtime.
732
+ *
733
+ * @example
734
+ * ```ts
735
+ * { name: '@kubb/plugin-react-query', options: { output: { path: './hooks' } } }
736
+ * { name: '@kubb/plugin-ts', options: { output: { path: './types' } } }
737
+ * ```
738
+ */
739
+ async function resolvePlugins(plugins) {
740
+ return Promise.all(plugins.map(async ({ name, options }) => {
741
+ return (await loadPluginFactory(name))(options ?? {});
742
+ }));
743
+ }
744
+ /**
745
+ * Merges studio plugin options with disk config plugins.
746
+ * Studio takes priority: options from studio win over disk, and a plugin Studio explicitly
747
+ * disabled is dropped even when the disk config still lists it. Disk plugins without a studio
748
+ * counterpart are kept as-is. Studio plugins not present on disk are appended.
749
+ *
750
+ * For plugins present in both configs, the plugin is re-instantiated with merged options
751
+ * so that all internal closures correctly reference the merged values.
752
+ */
753
+ async function mergePlugins(diskPlugins, studioPlugins) {
754
+ const disabledNames = new Set((studioPlugins ?? []).filter((entry) => entry.disabled).map((entry) => toPluginName(entry.name)));
755
+ const activeDiskPlugins = disabledNames.size ? diskPlugins?.filter((plugin) => !disabledNames.has(plugin.name)) : diskPlugins;
756
+ const activeStudioPlugins = studioPlugins?.filter((entry) => !entry.disabled);
757
+ if (!activeDiskPlugins && !activeStudioPlugins?.length) return void 0;
758
+ if (!activeStudioPlugins?.length) return activeDiskPlugins;
759
+ if (!activeDiskPlugins) return resolvePlugins(activeStudioPlugins);
760
+ const studioEntryByName = new Map(activeStudioPlugins.map((entry) => [toPluginName(entry.name), entry]));
761
+ const diskNames = new Set(activeDiskPlugins.map((plugin) => plugin.name));
762
+ const merged = await Promise.all(activeDiskPlugins.map(async (diskPlugin) => {
763
+ const studioEntry = studioEntryByName.get(diskPlugin.name);
764
+ if (!studioEntry) return diskPlugin;
765
+ const options = (0, remeda.mergeDeep)(diskPlugin.options ?? {}, studioEntry.options ?? {});
766
+ const [resolved] = await resolvePlugins([{
767
+ name: studioEntry.name,
768
+ options
769
+ }]);
770
+ return resolved ?? diskPlugin;
771
+ }));
772
+ const studioOnly = activeStudioPlugins.filter((entry) => !diskNames.has(toPluginName(entry.name)));
773
+ return [...merged, ...await resolvePlugins(studioOnly)];
774
+ }
775
+ /**
776
+ * Merges Studio-provided adapter option overrides into the disk config's adapter.
777
+ *
778
+ * Adapter instances carry live functions (`parse`, `getImports`, ...) that can't survive
779
+ * JSON serialization over the WebSocket, so `studioOptions` is treated as an options patch
780
+ * rather than a replacement adapter. Re-invokes the same `@kubb/adapter-<name>` factory the
781
+ * disk config used, with the merged options, so the resulting instance has fresh closures
782
+ * over the merged values instead of a plain object missing `parse`.
783
+ */
784
+ async function mergeAdapter(diskAdapter, studioOptions) {
785
+ if (!studioOptions || !diskAdapter) return diskAdapter;
786
+ const packageName = `@kubb/adapter-${diskAdapter.name}`;
787
+ const factory = (await importFromProject(packageName))[toExportName(packageName)];
788
+ if (typeof factory !== "function") return diskAdapter;
789
+ return factory((0, remeda.mergeDeep)(diskAdapter.options ?? {}, studioOptions));
790
+ }
791
+ //#endregion
792
+ //#region src/configFile.ts
793
+ /**
794
+ * A valid JavaScript identifier, so an import name can only ever print as `import { name } from`,
795
+ * never as source that breaks out of the import statement.
796
+ */
797
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
798
+ /**
799
+ * `key: value` as an object literal property, in the file's quote and key style.
800
+ *
801
+ * Uses magicast's literal builder for the key/value nodes, then wraps them as a Babel
802
+ * `ObjectProperty`, the type the rest of this file reads.
803
+ */
804
+ function literalProperty({ key, value }) {
805
+ const built = magicast.builders.literal({ [key]: value }).properties[0];
806
+ return {
807
+ type: "ObjectProperty",
808
+ key: built.key,
809
+ value: built.value,
810
+ computed: false,
811
+ shorthand: false
812
+ };
813
+ }
814
+ /**
815
+ * Marks the comment block a `disable-plugin` leaves behind, so `enable-plugin` can find its way
816
+ * back to the exact lines it commented out. Carries the block's line count, so `enable-plugin`
817
+ * restores exactly those lines instead of scanning forward through whatever comments follow.
818
+ */
819
+ const DISABLED_MARKER = "kubb:disabled";
820
+ /**
821
+ * The one line `disable-plugin` writes above the comment block it produces for `plugin`.
822
+ */
823
+ function formatMarker(plugin, lineCount, indent = "") {
824
+ return `${indent}// ${DISABLED_MARKER} ${plugin} ${lineCount}`;
825
+ }
826
+ /**
827
+ * The plugin and comment-block length a marker line names, when `line` is one.
828
+ */
829
+ function parseMarker(line) {
830
+ const trimmed = line.trim();
831
+ if (!trimmed.startsWith(`// ${DISABLED_MARKER} `)) return;
832
+ const match = trimmed.slice(`// ${DISABLED_MARKER} `.length).match(/^(.+)\s+(\d+)$/);
833
+ return match ? {
834
+ plugin: match[1],
835
+ lineCount: Number(match[2])
836
+ } : void 0;
837
+ }
838
+ /**
839
+ * Steps through a config's wrappers to the object literal underneath: a `satisfies`/`as`
840
+ * assertion, a `() => ...` factory, or a factory whose block body returns the config.
841
+ */
842
+ function unwrap(node) {
843
+ if (!node) return;
844
+ if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression") return unwrap(node.expression);
845
+ if (node.type !== "ArrowFunctionExpression" && node.type !== "FunctionExpression") return node;
846
+ if (node.body.type !== "BlockStatement") return unwrap(node.body);
847
+ return unwrap(node.body.body.find((statement) => statement.type === "ReturnStatement")?.argument);
848
+ }
849
+ /**
850
+ * Every config object in `export default defineConfig(...)`, or why the file is unmanaged.
851
+ *
852
+ * An array export gets one entry per element, matching {@link ConfigRef}'s numeric index.
853
+ *
854
+ * Walks the parsed AST rather than magicast's proxies, which throw on node types they cannot
855
+ * cast, most of what an unmanaged config file is made of.
856
+ */
857
+ function findConfigs(mod) {
858
+ const exported = unwrap((mod.$ast.type === "Program" ? mod.$ast.body : []).find((node) => node.type === "ExportDefaultDeclaration")?.declaration);
859
+ if (!exported) return { reason: "no default export found" };
860
+ if (exported.type !== "CallExpression" || exported.callee.type !== "Identifier" || exported.callee.name !== "defineConfig") return { reason: "default export is not a defineConfig(...) call" };
861
+ const argument = unwrap(exported.arguments[0]);
862
+ if (!argument) return { reason: "defineConfig(...) was called without a config" };
863
+ if (argument.type === "ArrayExpression") {
864
+ const configs = [];
865
+ for (const element of argument.elements) {
866
+ const entry = unwrap(element);
867
+ if (entry?.type !== "ObjectExpression") return { reason: "config is not an object literal" };
868
+ configs.push(entry);
869
+ }
870
+ return { configs };
871
+ }
872
+ if (argument.type !== "ObjectExpression") return { reason: "config is not an object literal" };
873
+ return { configs: [argument] };
874
+ }
875
+ /**
876
+ * The config entry an edit names, defaulting to the first when it names none.
877
+ */
878
+ function selectConfig(configs, ref) {
879
+ if (ref === void 0) return configs[0];
880
+ if (typeof ref === "number") return configs[ref];
881
+ return configs.find((config) => configName(config) === ref);
882
+ }
883
+ function configName(config) {
884
+ const name = property(config, "name");
885
+ return name?.type === "StringLiteral" ? name.value : void 0;
886
+ }
887
+ /**
888
+ * The name of an object literal property, for the two key shapes a config uses: `key: value` and
889
+ * `'key': value`. `undefined` for a computed key, which the patcher never touches.
890
+ */
891
+ function propertyKey(entry) {
892
+ if (entry.key.type === "Identifier") return entry.key.name;
893
+ if (entry.key.type === "StringLiteral") return entry.key.value;
894
+ }
895
+ /**
896
+ * The index of an object literal's own property named `key`, `-1` when it has none.
897
+ */
898
+ function entryIndex({ node, key }) {
899
+ return node.properties.findIndex((entry) => entry.type === "ObjectProperty" && propertyKey(entry) === key);
900
+ }
901
+ /**
902
+ * The value node of an object literal's own property.
903
+ */
904
+ function property(node, key) {
905
+ const index = entryIndex({
906
+ node,
907
+ key
908
+ });
909
+ return index === -1 ? void 0 : node.properties[index].value;
910
+ }
911
+ /**
912
+ * Writes `key: value` on an object literal, replacing the value when the property is already there.
913
+ *
914
+ * An existing property has its value swapped in place rather than being replaced whole, so recast
915
+ * reprints only that value and leaves the object's own layout alone.
916
+ */
917
+ function setProperty({ node, key, value }) {
918
+ const entry = literalProperty({
919
+ key,
920
+ value
921
+ });
922
+ const index = entryIndex({
923
+ node,
924
+ key
925
+ });
926
+ if (index === -1) {
927
+ node.properties.push(entry);
928
+ return;
929
+ }
930
+ node.properties[index].value = entry.value;
931
+ }
932
+ /**
933
+ * Drops `key` from an object literal.
934
+ */
935
+ function removeProperty({ node, key }) {
936
+ const index = entryIndex({
937
+ node,
938
+ key
939
+ });
940
+ if (index !== -1) node.properties.splice(index, 1);
941
+ }
942
+ /**
943
+ * Reads a literal node's value: a primitive, or an object/array built only from primitives.
944
+ * `undefined` for anything else, so a caller can use this both to read a value and to check
945
+ * whether a node is a literal at all.
946
+ */
947
+ function readLiteral(node) {
948
+ if (!node) return;
949
+ if (node.type === "StringLiteral" || node.type === "NumericLiteral" || node.type === "BooleanLiteral") return node.value;
950
+ if (node.type === "NullLiteral") return null;
951
+ if (node.type === "TemplateLiteral") return node.expressions.length === 0 ? node.quasis[0]?.value.cooked ?? "" : void 0;
952
+ if (node.type === "UnaryExpression") {
953
+ const value = readLiteral(node.argument);
954
+ if (typeof value !== "number") return;
955
+ if (node.operator === "-") return -value;
956
+ if (node.operator === "+") return value;
957
+ return;
958
+ }
959
+ if (node.type === "ArrayExpression") {
960
+ const values = node.elements.map((element) => element ? readLiteral(element) : void 0);
961
+ return values.every((value) => value !== void 0) ? values : void 0;
962
+ }
963
+ if (node.type === "ObjectExpression") {
964
+ const entries = {};
965
+ for (const entry of node.properties) {
966
+ if (entry.type !== "ObjectProperty") return;
967
+ const key = propertyKey(entry);
968
+ const value = readLiteral(entry.value);
969
+ if (key === void 0 || value === void 0) return;
970
+ entries[key] = value;
971
+ }
972
+ return entries;
973
+ }
974
+ }
975
+ /**
976
+ * Maps a factory identifier in the file back to the module it was imported from.
977
+ */
978
+ function importedFrom(mod) {
979
+ return new Map(mod.imports.$items.map((item) => [item.local, item.from]));
980
+ }
981
+ /**
982
+ * Every `pluginX(...)` element of a config's plugins array that resolves to an import.
983
+ */
984
+ function pluginCalls(mod, config) {
985
+ const plugins = property(config, "plugins");
986
+ if (plugins?.type !== "ArrayExpression") return [];
987
+ const imports = importedFrom(mod);
988
+ return plugins.elements.flatMap((element) => {
989
+ if (element?.type !== "CallExpression" || element.callee.type !== "Identifier") return [];
990
+ const packageName = imports.get(element.callee.name);
991
+ return packageName ? [{
992
+ importName: element.callee.name,
993
+ packageName,
994
+ call: element
995
+ }] : [];
996
+ });
997
+ }
998
+ /**
999
+ * Plugins a previous `disable-plugin` commented out of this config, keyed by package name.
1000
+ *
1001
+ * Read from the marker lines rather than the AST, since a commented-out call is no longer a node.
1002
+ */
1003
+ function disabledMarkers(source) {
1004
+ return source.split("\n").flatMap((line, index) => {
1005
+ const marker = parseMarker(line);
1006
+ return marker ? [{
1007
+ packageName: marker.plugin,
1008
+ line: index + 1
1009
+ }] : [];
1010
+ });
1011
+ }
1012
+ /**
1013
+ * Reads which plugins the file declares and which of their options Studio may write.
1014
+ *
1015
+ * @example
1016
+ * ```ts
1017
+ * const view = readConfig(await readFile('kubb.config.ts', 'utf8'))
1018
+ * if (view.managed) {
1019
+ * view.configs.forEach((config) => console.log(config.name, config.plugins.length))
1020
+ * }
1021
+ * ```
1022
+ */
1023
+ function readConfig(source) {
1024
+ let mod;
1025
+ try {
1026
+ mod = (0, magicast.parseModule)(source);
1027
+ } catch {
1028
+ return {
1029
+ managed: false,
1030
+ reason: "the config file could not be parsed"
1031
+ };
1032
+ }
1033
+ const found = findConfigs(mod);
1034
+ if ("reason" in found) return {
1035
+ managed: false,
1036
+ reason: found.reason
1037
+ };
1038
+ const importNames = new Map([...importedFrom(mod)].map(([local, from]) => [from, local]));
1039
+ const disabled = disabledMarkers(source);
1040
+ return {
1041
+ managed: true,
1042
+ configs: found.configs.map((config) => {
1043
+ const plugins = pluginCalls(mod, config).map(({ importName, packageName, call }) => {
1044
+ const entries = {};
1045
+ const options = call.arguments[0];
1046
+ if (options?.type === "ObjectExpression") for (const entry of options.properties) {
1047
+ if (entry.type !== "ObjectProperty") continue;
1048
+ const key = propertyKey(entry);
1049
+ if (key === void 0) continue;
1050
+ const value = readLiteral(entry.value);
1051
+ entries[key] = value === void 0 ? { literal: false } : {
1052
+ literal: true,
1053
+ value
1054
+ };
1055
+ }
1056
+ return {
1057
+ importName,
1058
+ packageName,
1059
+ options: entries
1060
+ };
1061
+ });
1062
+ const start = config.loc?.start.line ?? 0;
1063
+ const end = config.loc?.end.line ?? Number.POSITIVE_INFINITY;
1064
+ for (const { packageName } of disabled.filter((entry) => entry.line >= start && entry.line <= end)) plugins.push({
1065
+ importName: importNames.get(packageName) ?? toExportName(packageName),
1066
+ packageName,
1067
+ options: {},
1068
+ disabled: true
1069
+ });
1070
+ return {
1071
+ name: configName(config),
1072
+ plugins
1073
+ };
1074
+ })
1075
+ };
1076
+ }
1077
+ /**
1078
+ * Whether a value can be written into a config file as a literal.
1079
+ *
1080
+ * This is the trust boundary for edits that arrive over the agent WebSocket: a function, `undefined`,
1081
+ * or a non-finite number is refused rather than printed into the user's source.
1082
+ */
1083
+ function isOptionValue(value) {
1084
+ if (value === null) return true;
1085
+ if (typeof value === "string" || typeof value === "boolean") return true;
1086
+ if (typeof value === "number") return Number.isFinite(value);
1087
+ if (Array.isArray(value)) return value.every(isOptionValue);
1088
+ if (typeof value === "object") return Object.values(value).every(isOptionValue);
1089
+ return false;
1090
+ }
1091
+ /**
1092
+ * The options object of a plugin call, when it was called with one.
1093
+ */
1094
+ function getOptions(call) {
1095
+ const options = call.arguments[0];
1096
+ return options?.type === "ObjectExpression" ? options : void 0;
1097
+ }
1098
+ /**
1099
+ * The options object of a plugin call, creating an empty one when the plugin was called bare.
1100
+ */
1101
+ function ensureOptions(call) {
1102
+ if (call.arguments.length === 0) call.arguments.push({
1103
+ type: "ObjectExpression",
1104
+ properties: []
1105
+ });
1106
+ return getOptions(call);
1107
+ }
1108
+ /**
1109
+ * Walks `path` down to the object holding its last key, descending only through object literals.
1110
+ */
1111
+ function optionParent(options, path) {
1112
+ let object = options;
1113
+ for (const [index, key] of path.entries()) {
1114
+ if (index === path.length - 1) return {
1115
+ object,
1116
+ key
1117
+ };
1118
+ if (property(object, key) === void 0) setProperty({
1119
+ node: object,
1120
+ key,
1121
+ value: {}
1122
+ });
1123
+ const next = property(object, key);
1124
+ if (next?.type !== "ObjectExpression") return { reason: `${key} is not an object, so ${path.join(".")} cannot be reached` };
1125
+ object = next;
1126
+ }
1127
+ return { reason: "no option path given" };
1128
+ }
1129
+ /**
1130
+ * Writes `value` at `path` inside a plugin call's options, creating the options object and any
1131
+ * intermediate object along the path as needed. Refuses when the current value at `path` is
1132
+ * something other than a literal, so an option customized in code is never overwritten.
1133
+ */
1134
+ function applySet(call, path, value) {
1135
+ if (!isOptionValue(value)) return "the value is not a literal that can be written to a config file";
1136
+ const options = ensureOptions(call);
1137
+ if (!options) return "the plugin was not called with an object literal";
1138
+ const target = optionParent(options, path);
1139
+ if ("reason" in target) return target.reason;
1140
+ const current = property(target.object, target.key);
1141
+ if (current !== void 0 && readLiteral(current) === void 0) return `${path.join(".")} is customized in code`;
1142
+ setProperty({
1143
+ node: target.object,
1144
+ key: target.key,
1145
+ value
1146
+ });
1147
+ }
1148
+ /**
1149
+ * Deletes the property at `path` inside a plugin call's options, falling the plugin back to its
1150
+ * default for that option. Refuses when the value at `path` is not a literal, for the same reason
1151
+ * `applySet` does.
1152
+ */
1153
+ function applyRemove(call, path) {
1154
+ const options = getOptions(call);
1155
+ if (!options) return "the plugin has no options to remove";
1156
+ const target = optionParent(options, path);
1157
+ if ("reason" in target) return target.reason;
1158
+ const current = property(target.object, target.key);
1159
+ if (current === void 0) return `${path.join(".")} is not set`;
1160
+ if (readLiteral(current) === void 0) return `${path.join(".")} is customized in code`;
1161
+ removeProperty({
1162
+ node: target.object,
1163
+ key: target.key
1164
+ });
1165
+ }
1166
+ /**
1167
+ * Adds a `pluginX(...)` call to a config's plugins array. Refuses when the plugin is already
1168
+ * present, or when its import name collides with an unrelated existing import.
1169
+ */
1170
+ function applyAddPlugin(mod, config, edit) {
1171
+ if (!isKubbPluginSpecifier(edit.plugin)) return { reason: `"${edit.plugin}" is not a @kubb/plugin-* package` };
1172
+ const importName = edit.importName ?? toExportName(edit.plugin);
1173
+ if (!IDENTIFIER.test(importName)) return { reason: `"${importName}" is not a valid import name` };
1174
+ if (pluginCalls(mod, config).some((plugin) => plugin.packageName === edit.plugin)) return { reason: `${edit.plugin} is already in the plugins array` };
1175
+ const taken = importedFrom(mod).get(importName);
1176
+ if (taken && taken !== edit.plugin) return { reason: `${importName} is already imported from ${taken}` };
1177
+ const options = edit.options ?? {};
1178
+ if (!isOptionValue(options)) return { reason: "the options are not literals that can be written to a config file" };
1179
+ const plugins = property(config, "plugins");
1180
+ if (plugins?.type !== "ArrayExpression") return { reason: "plugins is not an array literal" };
1181
+ const call = Object.keys(options).length ? magicast.builders.functionCall(importName, options) : magicast.builders.functionCall(importName);
1182
+ plugins.elements.push(call.$ast);
1183
+ return taken ? {} : { addImport: {
1184
+ importName,
1185
+ moduleSpecifier: edit.plugin
1186
+ } };
1187
+ }
1188
+ /**
1189
+ * Comments out a plugin call in place, keeping its options on disk so `enable-plugin` can restore
1190
+ * them exactly. Operates on `source` text rather than the AST: a commented-out call is no longer a
1191
+ * node magicast can address, and the surrounding array must not reflow when its element count
1192
+ * never actually changes.
1193
+ */
1194
+ function disablePlugin(source, mod, config, plugin) {
1195
+ const target = pluginCalls(mod, config).find((entry) => entry.packageName === plugin);
1196
+ if (!target) return { reason: `${plugin} is not in the plugins array` };
1197
+ const loc = target.call.loc;
1198
+ if (!loc?.start || !loc.end) return { reason: `${plugin} has no source location to comment out` };
1199
+ const lines = source.split("\n");
1200
+ const from = loc.start.line - 1;
1201
+ const to = loc.end.line - 1;
1202
+ const firstLine = lines[from] ?? "";
1203
+ const lastLine = lines[to] ?? "";
1204
+ if (firstLine.slice(0, loc.start.column).trim() !== "" || !/^,?\s*$/.test(lastLine.slice(loc.end.column))) return { reason: `${plugin} shares a line with other code, so it cannot be commented out safely` };
1205
+ const indent = firstLine.match(/^\s*/)?.[0] ?? "";
1206
+ const commented = lines.slice(from, to + 1).map((line) => line.trim() ? `${indent}// ${line.slice(indent.length)}` : indent ? `${indent}//` : "//");
1207
+ lines.splice(from, to - from + 1, formatMarker(plugin, commented.length, indent), ...commented);
1208
+ return { source: lines.join("\n") };
1209
+ }
1210
+ /**
1211
+ * Uncomments the block a previous `disable-plugin` left behind for `plugin`.
1212
+ */
1213
+ function enablePlugin(source, plugin) {
1214
+ const lines = source.split("\n");
1215
+ for (const [index, line] of lines.entries()) {
1216
+ const marker = parseMarker(line);
1217
+ if (marker?.plugin !== plugin) continue;
1218
+ const end = index + 1 + marker.lineCount;
1219
+ const restored = lines.slice(index + 1, end).map((commented) => commented.replace(/^(\s*)\/\/ ?/, "$1"));
1220
+ lines.splice(index, end - index, ...restored);
1221
+ return { source: lines.join("\n") };
1222
+ }
1223
+ return { reason: `${plugin} is not disabled` };
1224
+ }
1225
+ /**
1226
+ * Re-parses `source` and resolves the config entry an edit targets. Every edit re-parses rather
1227
+ * than sharing one module across the batch, since the disable/enable edits rewrite `source` as
1228
+ * text and would otherwise leave the others working from a stale tree.
1229
+ */
1230
+ function parseTarget(source, ref) {
1231
+ let mod;
1232
+ try {
1233
+ mod = (0, magicast.parseModule)(source);
1234
+ } catch {
1235
+ return { reason: "the config file could not be parsed" };
1236
+ }
1237
+ const found = findConfigs(mod);
1238
+ if ("reason" in found) return { reason: found.reason };
1239
+ const config = selectConfig(found.configs, ref);
1240
+ if (!config) return { reason: `no config entry found for ${JSON.stringify(ref)}` };
1241
+ return {
1242
+ mod,
1243
+ config
1244
+ };
1245
+ }
1246
+ /**
1247
+ * Applies edits to a `kubb.config.ts` in place. Every node the edits do not touch keeps its
1248
+ * original text, so comments, formatting, and hand-written code around the config survive.
1249
+ *
1250
+ * Edits are independent: one that cannot be applied is reported in `outcomes` and the rest still run.
1251
+ *
1252
+ * @example
1253
+ * ```ts
1254
+ * const { source, outcomes } = applyConfigEdits(current, [
1255
+ * { operation: 'set', plugin: '@kubb/plugin-ts', path: ['enum', 'type'], value: 'enum' },
1256
+ * ])
1257
+ * ```
1258
+ *
1259
+ * @note recast always reprints a semicolon on a reprinted statement, so editing a block-body
1260
+ * `defineConfig` in a semicolon-free file adds one to the `return` line. This is a known gap.
1261
+ * Strip it when `detectCodeFormat` reports `useSemi: false`, if it turns out to matter in practice.
1262
+ */
1263
+ function applyConfigEdits(source, edits) {
1264
+ let current = source;
1265
+ const format = (0, magicast.detectCodeFormat)(source);
1266
+ const endsWithNewline = source.endsWith("\n");
1267
+ const outcomes = edits.map((edit) => {
1268
+ const target = parseTarget(current, edit.config);
1269
+ if ("reason" in target) return {
1270
+ edit,
1271
+ applied: false,
1272
+ reason: target.reason
1273
+ };
1274
+ const { mod, config } = target;
1275
+ if (edit.operation === "disable-plugin" || edit.operation === "enable-plugin") {
1276
+ const result = edit.operation === "disable-plugin" ? disablePlugin(current, mod, config, edit.plugin) : enablePlugin(current, edit.plugin);
1277
+ if ("reason" in result) return {
1278
+ edit,
1279
+ applied: false,
1280
+ reason: result.reason
1281
+ };
1282
+ current = result.source;
1283
+ return {
1284
+ edit,
1285
+ applied: true
1286
+ };
1287
+ }
1288
+ if (edit.operation === "add-plugin") {
1289
+ const result = applyAddPlugin(mod, config, edit);
1290
+ if ("reason" in result) return {
1291
+ edit,
1292
+ applied: false,
1293
+ reason: result.reason
1294
+ };
1295
+ const afterLine = lastImportEndLine(mod);
1296
+ let next = (0, magicast.generateCode)(mod, { format }).code;
1297
+ if (result.addImport) next = insertImportLine({
1298
+ source: next,
1299
+ afterLine,
1300
+ ...result.addImport
1301
+ });
1302
+ current = withTrailingNewline(next, endsWithNewline);
1303
+ return {
1304
+ edit,
1305
+ applied: true
1306
+ };
1307
+ }
1308
+ const pluginCall = pluginCalls(mod, config).find((plugin) => plugin.packageName === edit.plugin);
1309
+ if (!pluginCall) return {
1310
+ edit,
1311
+ applied: false,
1312
+ reason: `${edit.plugin} is not in the plugins array`
1313
+ };
1314
+ const reason = edit.operation === "set" ? applySet(pluginCall.call, edit.path, edit.value) : applyRemove(pluginCall.call, edit.path);
1315
+ if (!reason) current = withTrailingNewline((0, magicast.generateCode)(mod, { format }).code, endsWithNewline);
1316
+ return {
1317
+ edit,
1318
+ applied: !reason,
1319
+ reason
1320
+ };
1321
+ });
1322
+ return {
1323
+ source: current,
1324
+ outcomes,
1325
+ changed: current !== source
1326
+ };
1327
+ }
1328
+ /**
1329
+ * The 1-based line where the file's last import declaration ends, or `0` when it has none. Read
1330
+ * off the parsed module, so a multi-line `import {\n x,\n} from '...'` reports its closing line
1331
+ * rather than the `import` keyword.
1332
+ */
1333
+ function lastImportEndLine(mod) {
1334
+ return (mod.$ast.type === "Program" ? mod.$ast.body : []).filter((node) => node.type === "ImportDeclaration").at(-1)?.loc?.end.line ?? 0;
1335
+ }
1336
+ /**
1337
+ * Writes an import after the last one already in the file, matching its quote style and whether it
1338
+ * ends in a semicolon. `afterLine` is where that last import ends, `0` for a file with none.
1339
+ *
1340
+ * Written as plain text rather than through magicast's import builder, which prints a brand-new
1341
+ * import declaration with its own default spacing and a semicolon regardless of `format`, since
1342
+ * that formatting only governs nodes recast can diff against the original source.
1343
+ */
1344
+ function insertImportLine({ source, importName, moduleSpecifier, afterLine }) {
1345
+ const lines = source.split("\n");
1346
+ const lastImportLine = afterLine > 0 ? lines[afterLine - 1] : void 0;
1347
+ const quote = lastImportLine?.includes(`"`) ? `"` : `'`;
1348
+ const line = `import { ${importName} } from ${quote}${moduleSpecifier}${quote}${lastImportLine?.trimEnd().endsWith(";") ? ";" : ""}`;
1349
+ lines.splice(afterLine, 0, ...afterLine > 0 ? [line] : [line, ""]);
1350
+ return lines.join("\n");
1351
+ }
1352
+ /**
1353
+ * `generateCode` always drops the file's trailing newline. Restore it when the input had one.
1354
+ */
1355
+ function withTrailingNewline(code, hadTrailingNewline) {
1356
+ if (!hadTrailingNewline || code.endsWith("\n")) return code;
1357
+ return `${code}\n`;
1358
+ }
1359
+ //#endregion
527
1360
  //#region src/generate.ts
528
1361
  /**
529
1362
  * `isToolAvailable` spawns a process, and a long-lived connection generates repeatedly, so each
@@ -724,6 +1557,14 @@ function sendErrorMessage(ws$4, error) {
724
1557
  * Forwards selected Kubb lifecycle events to Studio as data messages for the active session.
725
1558
  */
726
1559
  function setupEventsStream(ws$5, hooks) {
1560
+ const unhooks = [];
1561
+ /**
1562
+ * Registers a listener and keeps its remover, so one generation's listeners come off the session
1563
+ * emitter again when that generation ends.
1564
+ */
1565
+ function on(name, handler) {
1566
+ unhooks.push(hooks.hook(name, handler));
1567
+ }
727
1568
  function sendDataMessage(payload) {
728
1569
  sendAgentMessage(ws$5, {
729
1570
  type: "agent:data",
@@ -734,13 +1575,13 @@ function setupEventsStream(ws$5, hooks) {
734
1575
  }
735
1576
  });
736
1577
  }
737
- hooks.hook("kubb:plugin:start", (ctx) => {
1578
+ on("kubb:plugin:start", (ctx) => {
738
1579
  sendDataMessage({
739
1580
  type: "kubb:plugin:start",
740
1581
  data: [{ plugin: ctx.plugin }]
741
1582
  });
742
1583
  });
743
- hooks.hook("kubb:plugin:end", (ctx) => {
1584
+ on("kubb:plugin:end", (ctx) => {
744
1585
  sendDataMessage({
745
1586
  type: "kubb:plugin:end",
746
1587
  data: [{
@@ -750,7 +1591,7 @@ function setupEventsStream(ws$5, hooks) {
750
1591
  }]
751
1592
  });
752
1593
  });
753
- hooks.hook("kubb:build:start", ({ config, adapter }) => {
1594
+ on("kubb:build:start", ({ config, adapter }) => {
754
1595
  sendDataMessage({
755
1596
  type: "kubb:build:start",
756
1597
  data: [{
@@ -759,7 +1600,7 @@ function setupEventsStream(ws$5, hooks) {
759
1600
  }]
760
1601
  });
761
1602
  });
762
- hooks.hook("kubb:build:end", ({ files, outputDir }) => {
1603
+ on("kubb:build:end", ({ files, outputDir }) => {
763
1604
  sendDataMessage({
764
1605
  type: "kubb:build:end",
765
1606
  data: [{
@@ -771,13 +1612,13 @@ function setupEventsStream(ws$5, hooks) {
771
1612
  }]
772
1613
  });
773
1614
  });
774
- hooks.hook("kubb:files:processing:start", ({ files }) => {
1615
+ on("kubb:files:processing:start", ({ files }) => {
775
1616
  sendDataMessage({
776
1617
  type: "kubb:files:processing:start",
777
1618
  data: [{ total: files.length }]
778
1619
  });
779
1620
  });
780
- hooks.hook("kubb:files:processing:update", ({ files }) => {
1621
+ on("kubb:files:processing:update", ({ files }) => {
781
1622
  sendDataMessage({
782
1623
  type: "kubb:files:processing:update",
783
1624
  data: [{ files: files.map(({ file, processed, total, percentage }) => ({
@@ -788,7 +1629,7 @@ function setupEventsStream(ws$5, hooks) {
788
1629
  })) }]
789
1630
  });
790
1631
  });
791
- hooks.hook("kubb:files:processing:end", ({ files }) => {
1632
+ on("kubb:files:processing:end", ({ files }) => {
792
1633
  sendDataMessage({
793
1634
  type: "kubb:files:processing:end",
794
1635
  data: [{ total: files.length }]
@@ -798,7 +1639,7 @@ function setupEventsStream(ws$5, hooks) {
798
1639
  "kubb:info",
799
1640
  "kubb:success",
800
1641
  "kubb:warn"
801
- ]) hooks.hook(type, ({ message, info }) => {
1642
+ ]) on(type, ({ message, info }) => {
802
1643
  sendDataMessage({
803
1644
  type,
804
1645
  data: [{
@@ -807,7 +1648,7 @@ function setupEventsStream(ws$5, hooks) {
807
1648
  }]
808
1649
  });
809
1650
  });
810
- hooks.hook("kubb:generation:start", ({ config }) => {
1651
+ on("kubb:generation:start", ({ config }) => {
811
1652
  sendDataMessage({
812
1653
  type: "kubb:generation:start",
813
1654
  data: [{
@@ -816,7 +1657,7 @@ function setupEventsStream(ws$5, hooks) {
816
1657
  }]
817
1658
  });
818
1659
  });
819
- hooks.hook("kubb:generation:end", async ({ config, storage, diagnostics = [], status, hrStart, filesCreated }) => {
1660
+ on("kubb:generation:end", async ({ config, storage, diagnostics = [], status, hrStart, filesCreated }) => {
820
1661
  const paths = await storage.readKeys();
821
1662
  const files = {};
822
1663
  await inParallel({
@@ -845,7 +1686,7 @@ function setupEventsStream(ws$5, hooks) {
845
1686
  }]
846
1687
  });
847
1688
  });
848
- hooks.hook("kubb:error", ({ error }) => {
1689
+ on("kubb:error", ({ error }) => {
849
1690
  sendDataMessage({
850
1691
  type: "kubb:error",
851
1692
  data: [{
@@ -863,13 +1704,13 @@ function setupEventsStream(ws$5, hooks) {
863
1704
  "kubb:lint:end",
864
1705
  "kubb:hooks:start",
865
1706
  "kubb:hooks:end"
866
- ]) hooks.hook(type, () => {
1707
+ ]) on(type, () => {
867
1708
  sendDataMessage({
868
1709
  type,
869
1710
  data: []
870
1711
  });
871
1712
  });
872
- hooks.hook("kubb:hook:start", ({ id, command, args }) => {
1713
+ on("kubb:hook:start", ({ id, command, args }) => {
873
1714
  sendDataMessage({
874
1715
  type: "kubb:hook:start",
875
1716
  data: [{
@@ -879,7 +1720,7 @@ function setupEventsStream(ws$5, hooks) {
879
1720
  }]
880
1721
  });
881
1722
  });
882
- hooks.hook("kubb:hook:line", ({ id, line }) => {
1723
+ on("kubb:hook:line", ({ id, line }) => {
883
1724
  sendDataMessage({
884
1725
  type: "kubb:hook:line",
885
1726
  data: [{
@@ -888,7 +1729,7 @@ function setupEventsStream(ws$5, hooks) {
888
1729
  }]
889
1730
  });
890
1731
  });
891
- hooks.hook("kubb:hook:end", ({ id, command, args, success, error }) => {
1732
+ on("kubb:hook:end", ({ id, command, args, success, error }) => {
892
1733
  sendDataMessage({
893
1734
  type: "kubb:hook:end",
894
1735
  data: [{
@@ -903,321 +1744,423 @@ function setupEventsStream(ws$5, hooks) {
903
1744
  }]
904
1745
  });
905
1746
  });
1747
+ return () => {
1748
+ for (const unhook of unhooks) unhook();
1749
+ };
906
1750
  }
907
1751
  //#endregion
908
- //#region src/connectStudio.ts
1752
+ //#region src/StudioSession.ts
1753
+ /**
1754
+ * Fills in a host's options: the hosted Studio URL, the current working directory, and every
1755
+ * permission off unless granted. Idempotent, so a reconnect can pass an already-resolved bag
1756
+ * back in.
1757
+ */
1758
+ function applyStudioDefaults(options) {
1759
+ const root = options.root ?? node_process.default.cwd();
1760
+ return {
1761
+ ...options,
1762
+ studioUrl: options.studioUrl ?? agentDefaults.studioUrl,
1763
+ root,
1764
+ configFile: node_path.default.resolve(root, options.configPath),
1765
+ permissions: {
1766
+ allowWrite: false,
1767
+ allowConfigEdit: false,
1768
+ allowInput: false,
1769
+ allowExec: false,
1770
+ ...options.permissions
1771
+ },
1772
+ retryInterval: options.retryInterval ?? agentDefaults.retryIntervalMs,
1773
+ heartbeatInterval: Math.min(options.heartbeatInterval ?? agentDefaults.heartbeatIntervalMs, agentDefaults.maxHeartbeatIntervalMs)
1774
+ };
1775
+ }
909
1776
  /**
910
1777
  * Schedules another connection attempt.
911
1778
  *
912
- * Hoisted out of `connectToStudio` on purpose: a pending retry timer reaches its whole enclosing
913
- * scope, so keeping it inside would pin the closed socket, the hook emitter, and the session id
914
- * alive for the length of every retry interval.
1779
+ * A free function rather than a method: a pending retry timer reaches whatever it closes over, so
1780
+ * closing only over `options` (not a `StudioSession`) keeps a queued retry from pinning a closed
1781
+ * socket, its hook emitter, or its session id alive for the length of the retry interval.
915
1782
  */
916
1783
  function reconnect(options) {
917
- const { signal, retryInterval = agentDefaults.retryIntervalMs } = options;
1784
+ const { signal, retryInterval, onTokenRejected } = options;
918
1785
  if (signal?.aborted) return;
919
1786
  console.info((0, node_util.styleText)("dim", `Retrying connection in ${retryInterval}ms to Kubb Studio ...`));
920
1787
  const cancel = () => clearTimeout(timer);
921
1788
  const timer = setTimeout(() => {
922
1789
  signal?.removeEventListener("abort", cancel);
923
1790
  if (signal?.aborted) return;
924
- connectToStudio(options).catch((error) => {
1791
+ new StudioSession(options).connect().catch((error) => {
925
1792
  console.error((0, node_util.styleText)("red", `Reconnect attempt to Kubb Studio failed: ${getErrorMessage(error)}`));
926
- if (error instanceof InvalidAgentTokenError) return;
1793
+ if (error instanceof InvalidAgentTokenError) {
1794
+ onTokenRejected?.(error);
1795
+ return;
1796
+ }
927
1797
  reconnect(options);
928
1798
  });
929
1799
  }, retryInterval);
930
1800
  signal?.addEventListener("abort", cancel, { once: true });
931
1801
  }
932
- async function connectToStudio(options) {
933
- const { token, studioUrl = agentDefaults.studioUrl, configPath, loadConfig, version: version$1, client, allowWrite = false, allowConfigEdit = false, allowInput = false, allowExec = false, root = node_process.default.cwd(), heartbeatInterval: requestedHeartbeatInterval = agentDefaults.heartbeatIntervalMs, signal, installLogger } = options;
934
- const heartbeatInterval = Math.min(requestedHeartbeatInterval, agentDefaults.heartbeatIntervalMs);
935
- const hooks = new _kubb_core.Hookable();
936
- await installLogger?.(hooks);
937
- try {
938
- await hooks.callHook("studio:connecting", { url: studioUrl });
939
- const { sessionId, slug, wsUrl, isSandbox, version: sessionStudioVersion } = await createAgentSession({
940
- token,
941
- studioUrl
1802
+ /**
1803
+ * One WebSocket session with Studio: opening it, keeping it alive, and running the commands it
1804
+ * sends. `createClient` opens one per pool slot and is the only caller.
1805
+ */
1806
+ var StudioSession = class {
1807
+ #options;
1808
+ #hooks = new _kubb_core.Hookable();
1809
+ /**
1810
+ * Removers for every listener this session added (socket, shutdown signal, hooks) so `dispose`
1811
+ * detaches them in one pass. Listeners a host attached itself through `installLogger` survive.
1812
+ */
1813
+ #unhooks = [];
1814
+ /**
1815
+ * What `createAgentSession` handed back, and the marker for whether a session exists at all.
1816
+ * Before it resolves there is nothing to disconnect and no sandbox flag to read.
1817
+ */
1818
+ #session;
1819
+ #ws;
1820
+ #studioVersion;
1821
+ #disposed = false;
1822
+ #isGenerating = false;
1823
+ #heartbeatTimer;
1824
+ #lastPongAt = Date.now();
1825
+ constructor(options) {
1826
+ this.#options = applyStudioDefaults(options);
1827
+ }
1828
+ /**
1829
+ * A sandbox agent runs on Studio's own infrastructure, so it has no user project to touch.
1830
+ */
1831
+ get #isSandbox() {
1832
+ return this.#session?.isSandbox === true;
1833
+ }
1834
+ get #canWrite() {
1835
+ return !this.#isSandbox && this.#options.permissions.allowWrite;
1836
+ }
1837
+ get #canEditConfig() {
1838
+ return !this.#isSandbox && this.#options.permissions.allowConfigEdit;
1839
+ }
1840
+ /**
1841
+ * A sandbox agent always generates from the spec Studio supplies. A local agent only when the
1842
+ * host opted in.
1843
+ */
1844
+ get #canUseInput() {
1845
+ return this.#isSandbox || this.#options.permissions.allowInput;
1846
+ }
1847
+ async connect() {
1848
+ const { token, studioUrl, signal, heartbeatInterval, installLogger } = this.#options;
1849
+ await installLogger?.(this.#hooks);
1850
+ try {
1851
+ await this.#hooks.callHook("studio:connecting", { url: studioUrl });
1852
+ const session = await createAgentSession({
1853
+ token,
1854
+ studioUrl
1855
+ });
1856
+ this.#session = session;
1857
+ this.#studioVersion = session.version;
1858
+ const ws = createWebsocket(session.wsUrl, { headers: { Authorization: `Bearer ${token}` } });
1859
+ this.#ws = ws;
1860
+ this.#listen(ws, "open", this.#onOpen);
1861
+ this.#listen(ws, "close", this.#onClose);
1862
+ this.#listen(ws, "error", this.#onError);
1863
+ this.#listen(ws, "message", this.#onMessage);
1864
+ signal?.addEventListener("abort", this.#onAbort, { once: true });
1865
+ this.#unhooks.push(() => signal?.removeEventListener("abort", this.#onAbort));
1866
+ this.#heartbeatTimer = setInterval(() => this.#sendHeartbeat(), heartbeatInterval);
1867
+ this.#unhooks.push(this.#hooks.hook("kubb:error", ({ error }) => sendErrorMessage(ws, error)));
1868
+ } catch (error) {
1869
+ await this.#hooks.callHook("studio:error", { error: toError(error) });
1870
+ if (error instanceof InvalidAgentTokenError) throw error;
1871
+ reconnect(this.#options);
1872
+ }
1873
+ }
1874
+ /**
1875
+ * Adds a socket listener and tracks its remover, so `dispose` detaches every listener at once.
1876
+ */
1877
+ #listen(ws, event, listener) {
1878
+ ws.addEventListener(event, listener);
1879
+ this.#unhooks.push(() => ws.removeEventListener(event, listener));
1880
+ }
1881
+ #warn(message) {
1882
+ return this.#hooks.callHook("studio:warn", { message });
1883
+ }
1884
+ /**
1885
+ * Forwards a failure to Studio over the connection emitter, which `connect` wired to this
1886
+ * socket. Swallows a listener's own failure, since this is already the error path.
1887
+ */
1888
+ #emitError(error) {
1889
+ return Promise.resolve(this.#hooks.callHook("kubb:error", { error })).catch(() => {});
1890
+ }
1891
+ #sendHeartbeat() {
1892
+ if (Date.now() - this.#lastPongAt > this.#options.heartbeatInterval * 2) {
1893
+ this.#warn("No reply from Kubb Studio, terminating the stale connection");
1894
+ clearInterval(this.#heartbeatTimer);
1895
+ this.#heartbeatTimer = void 0;
1896
+ this.#ws?.terminate();
1897
+ return;
1898
+ }
1899
+ if (this.#ws) sendAgentMessage(this.#ws, { type: "agent:ping" });
1900
+ }
1901
+ /**
1902
+ * Reads `kubb.config.ts` and reports which plugin options Studio may edit.
1903
+ *
1904
+ * Skipped when the host did not grant `allowConfigEdit`. Not cached: the user can edit the file
1905
+ * between two Studio actions.
1906
+ */
1907
+ async #readConfigFileView(source) {
1908
+ if (!this.#canEditConfig) return;
1909
+ try {
1910
+ return readConfig(source ?? await read(this.#options.configFile));
1911
+ } catch (error) {
1912
+ await this.#warn(`Could not read ${this.#options.configFile}: ${getErrorMessage(error)}`);
1913
+ return;
1914
+ }
1915
+ }
1916
+ async #sendConnectedPayload() {
1917
+ const { configPath, root, version: version$1, loadConfig, permissions } = this.#options;
1918
+ if (!this.#ws) return;
1919
+ const config = await loadConfig();
1920
+ sendAgentMessage(this.#ws, {
1921
+ type: "agent:connect",
1922
+ payload: {
1923
+ versions: {
1924
+ kubb: version,
1925
+ agent: version$1
1926
+ },
1927
+ root,
1928
+ config: {
1929
+ path: configPath,
1930
+ file: await this.#readConfigFileView(),
1931
+ plugins: config.plugins.map((plugin) => ({
1932
+ name: `@kubb/${plugin.name}`,
1933
+ options: plugin.options ?? {}
1934
+ }))
1935
+ },
1936
+ permissions: {
1937
+ ...permissions,
1938
+ allowWrite: this.#canWrite,
1939
+ allowInput: this.#canUseInput,
1940
+ allowConfigEdit: this.#canEditConfig
1941
+ }
1942
+ }
942
1943
  });
943
- let studioVersion = sessionStudioVersion;
944
- const ws = createWebsocket(wsUrl, { headers: { Authorization: `Bearer ${token}` } });
945
- const canWrite = isSandbox ? false : allowWrite;
946
- const canEditConfig = isSandbox ? false : allowConfigEdit;
947
- const configFilePath = node_path.default.resolve(root, configPath);
948
- const canUseInput = isSandbox || allowInput;
949
- let serverDisconnected = false;
950
- let isGenerating = false;
951
- let heartbeatTimer;
952
- let lastPongAt = Date.now();
953
- const onAbort = () => void teardown({
954
- reason: "shutdown",
955
- retry: false
1944
+ }
1945
+ async #handleOpen() {
1946
+ this.#lastPongAt = Date.now();
1947
+ await this.#hooks.callHook("studio:connected", {
1948
+ url: this.#options.studioUrl,
1949
+ versions: {
1950
+ studio: this.#studioVersion,
1951
+ kubb: version,
1952
+ agent: this.#options.version
1953
+ }
956
1954
  });
957
- function cleanup(reason = "cleanup") {
958
- clearInterval(heartbeatTimer);
959
- heartbeatTimer = void 0;
960
- signal?.removeEventListener("abort", onAbort);
961
- hooks.removeAllHooks();
962
- try {
963
- ws.close(1e3, reason);
964
- } catch {}
965
- ws.removeEventListener("open", onOpen);
966
- ws.removeEventListener("close", onClose);
967
- ws.removeEventListener("error", onError);
968
- ws.removeEventListener("message", onMessage);
1955
+ try {
1956
+ await this.#sendConnectedPayload();
1957
+ } catch (error) {
1958
+ await this.#warn(`Failed to send the connect payload: ${getErrorMessage(error)}`);
969
1959
  }
970
- /**
971
- * Reads `kubb.config.ts` and reports which plugin options Studio may edit.
972
- *
973
- * Skipped when the host did not grant `allowConfigEdit`. The patcher pulls in `magicast`
974
- * (~25ms, ~55MB RSS), so read-only agents never import it.
975
- *
976
- * Not cached: the user can edit the file between two Studio actions.
977
- */
978
- async function readConfigFileView(source) {
979
- if (!canEditConfig) return;
980
- try {
981
- const { readConfig } = await Promise.resolve().then(() => require("./configFile-DjzP1_Ln.cjs"));
982
- return readConfig(source ?? await (0, node_fs_promises.readFile)(configFilePath, "utf-8"));
983
- } catch (error) {
984
- await hooks.callHook("studio:warn", { message: `Could not read ${configFilePath}: ${getErrorMessage(error)}` });
1960
+ }
1961
+ #onOpen = () => void this.#handleOpen().catch(() => {});
1962
+ #onAbort = () => void this.#end({
1963
+ reason: "shutdown",
1964
+ retry: false
1965
+ });
1966
+ #onClose = () => void this.#end({ retry: true });
1967
+ #onError = () => {
1968
+ this.#hooks.callHook("studio:error", { error: /* @__PURE__ */ new Error("Failed to connect to Kubb Studio") });
1969
+ this.#onClose();
1970
+ };
1971
+ /**
1972
+ * Drops the socket and detaches every listener and timer this session added. Idempotent, and
1973
+ * safe before `connect` opened anything.
1974
+ *
1975
+ * @internal
1976
+ */
1977
+ dispose(reason = "cleanup") {
1978
+ clearInterval(this.#heartbeatTimer);
1979
+ this.#heartbeatTimer = void 0;
1980
+ try {
1981
+ this.#ws?.close(1e3, reason);
1982
+ } catch {}
1983
+ for (const unhook of this.#unhooks) unhook();
1984
+ this.#unhooks.length = 0;
1985
+ }
1986
+ /**
1987
+ * Ends the session: tells Studio it is over, drops the socket, and optionally reconnects.
1988
+ * `#disposed` keeps the close event from running this twice, and a shutdown from reconnecting.
1989
+ */
1990
+ async #end({ reason, retry }) {
1991
+ const { studioUrl, token } = this.#options;
1992
+ if (this.#disposed) return;
1993
+ this.#disposed = true;
1994
+ if (reason === "shutdown" && this.#ws) sendAgentMessage(this.#ws, {
1995
+ type: "agent:disconnect",
1996
+ reason: "shutdown"
1997
+ });
1998
+ this.dispose(reason);
1999
+ if (this.#session) await disconnect({
2000
+ sessionId: this.#session.sessionId,
2001
+ studioUrl,
2002
+ token,
2003
+ slug: this.#session.slug
2004
+ }).catch(() => {});
2005
+ if (retry) reconnect(this.#options);
2006
+ }
2007
+ #onMessage = async (message) => {
2008
+ try {
2009
+ const data = JSON.parse(message.data);
2010
+ if (require_protocol.isStudioPingMessage(data)) {
2011
+ this.#lastPongAt = Date.now();
985
2012
  return;
986
2013
  }
2014
+ if (require_protocol.isDisconnectMessage(data)) {
2015
+ await this.#handleDisconnect(data.reason);
2016
+ return;
2017
+ }
2018
+ if (require_protocol.isCommandMessage(data)) {
2019
+ await this.#handleCommand(data);
2020
+ return;
2021
+ }
2022
+ await this.#warn(`Ignored an unknown message from Kubb Studio: ${data.type}`);
2023
+ } catch (error) {
2024
+ await this.#hooks.callHook("studio:error", { error: toError(error) });
2025
+ await this.#emitError(toError(error));
987
2026
  }
988
- async function sendConnectedPayload() {
2027
+ };
2028
+ /**
2029
+ * Studio ended the session itself. A revoked one stays ended, an expired one gets a fresh
2030
+ * session, and anything else is left to the socket's own close event.
2031
+ */
2032
+ async #handleDisconnect(reason) {
2033
+ await this.#hooks.callHook("studio:disconnected", { reason });
2034
+ if (reason !== "revoked" && reason !== "expired") return;
2035
+ this.#disposed = true;
2036
+ this.dispose(`session_${reason}`);
2037
+ if (reason === "expired") reconnect(this.#options);
2038
+ }
2039
+ async #handleCommand(data) {
2040
+ const ws = this.#ws;
2041
+ if (!ws) return;
2042
+ const command = data.type.slice(7);
2043
+ await this.#hooks.callHook("studio:command:start", { command });
2044
+ switch (data.type) {
2045
+ case "studio:generate":
2046
+ await this.#handleGenerate(ws, data, command);
2047
+ return;
2048
+ case "studio:connect":
2049
+ this.#studioVersion = data.version ?? this.#studioVersion;
2050
+ await this.#sendConnectedPayload();
2051
+ await this.#hooks.callHook("studio:command:end", { command });
2052
+ return;
2053
+ case "studio:save":
2054
+ await this.#handleSave(ws, data, command);
2055
+ return;
2056
+ }
2057
+ }
2058
+ async #handleGenerate(ws, data, command) {
2059
+ const { root, loadConfig, permissions, client } = this.#options;
2060
+ if (this.#isGenerating) {
2061
+ await this.#warn("Ignored generate: a generation is already in progress");
2062
+ await this.#emitError(/* @__PURE__ */ new Error("A generation is already in progress, please wait for it to finish"));
2063
+ return;
2064
+ }
2065
+ this.#isGenerating = true;
2066
+ try {
989
2067
  const config = await loadConfig();
990
- sendAgentMessage(ws, {
991
- type: "agent:connect",
992
- payload: {
993
- versions: {
994
- kubb: version,
995
- agent: version$1
996
- },
997
- root,
2068
+ const patch = data.payload;
2069
+ const plugins = await mergePlugins(config.plugins, patch?.plugins);
2070
+ const adapter = await mergeAdapter(config.adapter, patch?.adapter);
2071
+ const inputOverride = this.#isSandbox ? patch?.input ?? "" : permissions.allowInput && patch?.input || void 0;
2072
+ if (permissions.allowWrite && this.#isSandbox) await this.#warn("Running in a sandbox, so writing files is disabled");
2073
+ if (patch?.input && !this.#canUseInput) {
2074
+ const remedy = client?.kind === "cli" ? "--allowInput, or answer yes when kubb studio asks," : "KUBB_AGENT_ALLOW_INPUT=true";
2075
+ await this.#warn(`Ignored the spec from Studio; set ${remedy} to generate from it`);
2076
+ }
2077
+ const resolvedPlugins = plugins ?? config.plugins;
2078
+ const detach = [setupHookListener(this.#hooks, root), setupEventsStream(ws, this.#hooks)];
2079
+ try {
2080
+ await generate({
998
2081
  config: {
999
- path: configPath,
1000
- file: await readConfigFileView(),
1001
- plugins: config.plugins.map((plugin) => ({
1002
- name: `@kubb/${plugin.name}`,
1003
- options: plugin.options ?? {}
1004
- }))
2082
+ ...config,
2083
+ root,
2084
+ input: inputOverride ?? config.input,
2085
+ storage: this.#canWrite ? (0, _kubb_core.fsStorage)() : (0, _kubb_core.memoryStorage)(),
2086
+ output: permissions.allowExec ? { ...config.output } : {
2087
+ ...config.output,
2088
+ format: false,
2089
+ lint: false,
2090
+ postGenerate: []
2091
+ },
2092
+ plugins: resolvedPlugins,
2093
+ adapter
1005
2094
  },
1006
- permissions: {
1007
- allowWrite: canWrite,
1008
- allowInput: canUseInput,
1009
- allowExec,
1010
- allowConfigEdit: canEditConfig
1011
- }
1012
- }
2095
+ hooks: this.#hooks
2096
+ });
2097
+ } finally {
2098
+ for (const remove of detach) remove();
2099
+ }
2100
+ await this.#hooks.callHook("studio:command:end", {
2101
+ command,
2102
+ info: `${resolvedPlugins.length} plugin${resolvedPlugins.length === 1 ? "" : "s"}, ${this.#canWrite ? "written to disk" : "in memory"}${inputOverride !== void 0 ? ", from a Studio spec" : ""}`
1013
2103
  });
2104
+ } finally {
2105
+ this.#isGenerating = false;
1014
2106
  }
1015
- async function handleOpen() {
1016
- lastPongAt = Date.now();
1017
- await hooks.callHook("studio:connected", {
1018
- url: studioUrl,
1019
- versions: {
1020
- studio: studioVersion,
1021
- kubb: version,
1022
- agent: version$1
2107
+ }
2108
+ async #handleSave(ws, data, command) {
2109
+ const { configPath, configFile } = this.#options;
2110
+ if (!Array.isArray(data.edits)) {
2111
+ await this.#warn("Ignored save: the message carried no edits");
2112
+ sendAgentMessage(ws, {
2113
+ type: "agent:save",
2114
+ payload: {
2115
+ outcomes: [],
2116
+ changed: false
1023
2117
  }
1024
2118
  });
1025
- try {
1026
- await sendConnectedPayload();
1027
- } catch (error) {
1028
- await hooks.callHook("studio:warn", { message: `Failed to send the connect payload: ${getErrorMessage(error)}` });
2119
+ return;
2120
+ }
2121
+ const edits = data.edits;
2122
+ const refuse = (reason) => sendAgentMessage(ws, {
2123
+ type: "agent:save",
2124
+ payload: {
2125
+ outcomes: edits.map((edit) => ({
2126
+ edit,
2127
+ applied: false,
2128
+ reason
2129
+ })),
2130
+ changed: false
1029
2131
  }
2132
+ });
2133
+ if (!this.#canEditConfig) {
2134
+ await this.#warn("Ignored save: editing kubb.config.ts was not granted");
2135
+ refuse("the agent was not granted permission to edit kubb.config.ts");
2136
+ return;
1030
2137
  }
1031
- const onOpen = () => void handleOpen().catch(() => {});
1032
- /**
1033
- * Drops the socket and tells Studio the session is over. `serverDisconnected` guards against
1034
- * the close event running this a second time, and against a shutdown reconnecting.
1035
- */
1036
- async function teardown({ reason, retry }) {
1037
- if (serverDisconnected) return;
1038
- serverDisconnected = true;
1039
- if (reason === "shutdown") sendAgentMessage(ws, {
1040
- type: "agent:disconnect",
1041
- reason: "shutdown"
1042
- });
1043
- cleanup(reason);
1044
- await disconnect({
1045
- sessionId,
1046
- studioUrl,
1047
- token,
1048
- slug
1049
- }).catch(() => {});
1050
- if (retry) reconnect(options);
2138
+ if (this.#isGenerating) {
2139
+ refuse("a generation is in progress");
2140
+ return;
1051
2141
  }
1052
- const onClose = () => teardown({ retry: true });
1053
- const onError = () => {
1054
- hooks.callHook("studio:error", { error: /* @__PURE__ */ new Error("Failed to connect to Kubb Studio") });
1055
- return onClose();
1056
- };
1057
- ws.addEventListener("open", onOpen);
1058
- ws.addEventListener("close", onClose);
1059
- ws.addEventListener("error", onError);
1060
- signal?.addEventListener("abort", onAbort, { once: true });
1061
- heartbeatTimer = setInterval(() => {
1062
- if (Date.now() - lastPongAt > heartbeatInterval * 2) {
1063
- hooks.callHook("studio:warn", { message: "No reply from Kubb Studio, terminating the stale connection" });
1064
- clearInterval(heartbeatTimer);
1065
- heartbeatTimer = void 0;
1066
- ws.terminate();
1067
- return;
1068
- }
1069
- sendAgentMessage(ws, { type: "agent:ping" });
1070
- }, heartbeatInterval);
1071
- hooks.hook("kubb:error", ({ error }) => sendErrorMessage(ws, error));
1072
- const onMessage = async (message) => {
1073
- try {
1074
- const data = JSON.parse(message.data);
1075
- if (require_protocol.isStudioPingMessage(data)) {
1076
- lastPongAt = Date.now();
1077
- return;
1078
- }
1079
- if (require_protocol.isDisconnectMessage(data)) {
1080
- await hooks.callHook("studio:disconnected", { reason: data.reason });
1081
- if (data.reason === "revoked") {
1082
- cleanup(`session_${data.reason}`);
1083
- return;
1084
- }
1085
- if (data.reason === "expired") {
1086
- cleanup();
1087
- reconnect(options);
1088
- return;
1089
- }
1090
- return;
1091
- }
1092
- if (require_protocol.isCommandMessage(data)) {
1093
- const command = data.type.slice(7);
1094
- await hooks.callHook("studio:command:start", { command });
1095
- if (data.type === "studio:generate") {
1096
- if (isGenerating) {
1097
- await hooks.callHook("studio:warn", { message: "Ignored generate: a generation is already in progress" });
1098
- await Promise.resolve(hooks.callHook("kubb:error", { error: /* @__PURE__ */ new Error("A generation is already in progress, please wait for it to finish") })).catch(() => {});
1099
- return;
1100
- }
1101
- isGenerating = true;
1102
- try {
1103
- const config = await loadConfig();
1104
- const patch = data.payload;
1105
- const plugins = await require_resolveConfig.mergePlugins(config.plugins, patch?.plugins);
1106
- const adapter = await require_resolveConfig.mergeAdapter(config.adapter, patch?.adapter);
1107
- const inputOverride = isSandbox ? patch?.input ?? "" : allowInput && patch?.input || void 0;
1108
- if (allowWrite && isSandbox) await hooks.callHook("studio:warn", { message: "Running in a sandbox, so writing files is disabled" });
1109
- if (patch?.input && !canUseInput) {
1110
- const remedy = client?.kind === "cli" ? "--allowInput, or answer yes when kubb studio asks," : "KUBB_AGENT_ALLOW_INPUT=true";
1111
- await hooks.callHook("studio:warn", { message: `Ignored the spec from Studio; set ${remedy} to generate from it` });
1112
- }
1113
- const generationHooks = new _kubb_core.Hookable();
1114
- await installLogger?.(generationHooks);
1115
- setupHookListener(generationHooks, root);
1116
- setupEventsStream(ws, generationHooks);
1117
- const resolvedPlugins = plugins ?? config.plugins;
1118
- await generate({
1119
- config: {
1120
- ...config,
1121
- root,
1122
- input: inputOverride ?? config.input,
1123
- storage: canWrite ? (0, _kubb_core.fsStorage)() : (0, _kubb_core.memoryStorage)(),
1124
- output: allowExec ? { ...config.output } : {
1125
- ...config.output,
1126
- format: false,
1127
- lint: false,
1128
- postGenerate: []
1129
- },
1130
- plugins: resolvedPlugins,
1131
- adapter
1132
- },
1133
- hooks: generationHooks
1134
- });
1135
- await hooks.callHook("studio:command:end", {
1136
- command,
1137
- info: `${resolvedPlugins.length} plugin${resolvedPlugins.length === 1 ? "" : "s"}, ${canWrite ? "written to disk" : "in memory"}${inputOverride !== void 0 ? ", from a Studio spec" : ""}`
1138
- });
1139
- } finally {
1140
- isGenerating = false;
1141
- }
1142
- return;
1143
- }
1144
- if (data.type === "studio:connect") {
1145
- studioVersion = data.version ?? studioVersion;
1146
- await sendConnectedPayload();
1147
- await hooks.callHook("studio:command:end", { command });
1148
- return;
1149
- }
1150
- if (data.type === "studio:save") {
1151
- if (!Array.isArray(data.edits)) {
1152
- await hooks.callHook("studio:warn", { message: "Ignored save: the message carried no edits" });
1153
- sendAgentMessage(ws, {
1154
- type: "agent:save",
1155
- payload: {
1156
- outcomes: [],
1157
- changed: false
1158
- }
1159
- });
1160
- return;
1161
- }
1162
- const edits = data.edits;
1163
- const refuse = (reason) => sendAgentMessage(ws, {
1164
- type: "agent:save",
1165
- payload: {
1166
- outcomes: edits.map((edit) => ({
1167
- edit,
1168
- applied: false,
1169
- reason
1170
- })),
1171
- changed: false
1172
- }
1173
- });
1174
- if (!canEditConfig) {
1175
- await hooks.callHook("studio:warn", { message: "Ignored save: editing kubb.config.ts was not granted" });
1176
- refuse("the agent was not granted permission to edit kubb.config.ts");
1177
- return;
1178
- }
1179
- if (isGenerating) {
1180
- refuse("a generation is in progress");
1181
- return;
1182
- }
1183
- try {
1184
- const { applyConfigEdits } = await Promise.resolve().then(() => require("./configFile-DjzP1_Ln.cjs"));
1185
- const { source: patched, outcomes, changed } = applyConfigEdits(await (0, node_fs_promises.readFile)(configFilePath, "utf-8"), edits);
1186
- if (changed) await (0, node_fs_promises.writeFile)(configFilePath, patched, "utf-8");
1187
- sendAgentMessage(ws, {
1188
- type: "agent:save",
1189
- payload: {
1190
- outcomes,
1191
- changed,
1192
- file: changed ? await readConfigFileView(patched) : void 0
1193
- }
1194
- });
1195
- const applied = outcomes.filter((outcome) => outcome.applied).length;
1196
- await hooks.callHook("studio:command:end", {
1197
- command,
1198
- info: `applied ${applied}/${outcomes.length} edits to ${configPath}`
1199
- });
1200
- } catch (error) {
1201
- await hooks.callHook("studio:error", { error: toError(error) });
1202
- refuse(getErrorMessage(error));
1203
- }
1204
- return;
1205
- }
1206
- return;
2142
+ try {
2143
+ const { source: patched, outcomes, changed } = applyConfigEdits(await read(configFile), edits);
2144
+ if (changed) await (0, node_fs_promises.writeFile)(configFile, patched, "utf-8");
2145
+ sendAgentMessage(ws, {
2146
+ type: "agent:save",
2147
+ payload: {
2148
+ outcomes,
2149
+ changed,
2150
+ file: changed ? await this.#readConfigFileView(patched) : void 0
1207
2151
  }
1208
- await hooks.callHook("studio:warn", { message: `Ignored an unknown message from Kubb Studio: ${data.type}` });
1209
- } catch (error) {
1210
- await hooks.callHook("studio:error", { error: toError(error) });
1211
- await Promise.resolve(hooks.callHook("kubb:error", { error: toError(error) })).catch(() => {});
1212
- }
1213
- };
1214
- ws.addEventListener("message", onMessage);
1215
- } catch (error) {
1216
- await hooks.callHook("studio:error", { error: toError(error) });
1217
- if (error instanceof InvalidAgentTokenError) throw error;
1218
- reconnect(options);
2152
+ });
2153
+ const applied = outcomes.filter((outcome) => outcome.applied).length;
2154
+ await this.#hooks.callHook("studio:command:end", {
2155
+ command,
2156
+ info: `applied ${applied}/${outcomes.length} edits to ${configPath}`
2157
+ });
2158
+ } catch (error) {
2159
+ await this.#hooks.callHook("studio:error", { error: toError(error) });
2160
+ refuse(getErrorMessage(error));
2161
+ }
1219
2162
  }
1220
- }
2163
+ };
1221
2164
  //#endregion
1222
2165
  //#region src/client.ts
1223
2166
  /**
@@ -1232,10 +2175,15 @@ async function connectToStudio(options) {
1232
2175
  * await studio.connect()
1233
2176
  * ```
1234
2177
  */
1235
- function createClient({ storage, ...options }) {
2178
+ function createClient({ storage, onAuthRequired, ...options }) {
1236
2179
  if (storage) setStorage(storage);
1237
2180
  const controller = new AbortController();
1238
2181
  const poolSize = options.poolSize ?? agentDefaults.poolSize;
2182
+ function notifyAuthRequired(error) {
2183
+ if (controller.signal.aborted) return;
2184
+ controller.abort();
2185
+ onAuthRequired?.(error);
2186
+ }
1239
2187
  return {
1240
2188
  async connect() {
1241
2189
  await registerAgent({
@@ -1243,10 +2191,11 @@ function createClient({ storage, ...options }) {
1243
2191
  studioUrl: options.studioUrl ?? agentDefaults.studioUrl,
1244
2192
  poolSize
1245
2193
  });
1246
- await Promise.all(Array.from({ length: poolSize }, () => connectToStudio({
2194
+ await Promise.all(Array.from({ length: poolSize }, () => new StudioSession({
1247
2195
  ...options,
1248
- signal: controller.signal
1249
- })));
2196
+ signal: controller.signal,
2197
+ onTokenRejected: notifyAuthRequired
2198
+ }).connect()));
1250
2199
  },
1251
2200
  disconnect() {
1252
2201
  controller.abort();
@@ -1254,6 +2203,79 @@ function createClient({ storage, ...options }) {
1254
2203
  };
1255
2204
  }
1256
2205
  //#endregion
2206
+ //#region src/runConnection.ts
2207
+ /**
2208
+ * Waits for whichever comes first: the shutdown signal, or Studio rejecting the token during a
2209
+ * background reconnect. Resolves with the rejection, or nothing when the run is being shut down.
2210
+ */
2211
+ function waitForRejection(authRequired, signal) {
2212
+ if (!signal) return authRequired;
2213
+ const settled = new AbortController();
2214
+ const shutdown = new Promise((resolve) => {
2215
+ if (signal.aborted) {
2216
+ resolve(void 0);
2217
+ return;
2218
+ }
2219
+ signal.addEventListener("abort", () => resolve(void 0), {
2220
+ once: true,
2221
+ signal: settled.signal
2222
+ });
2223
+ });
2224
+ return Promise.race([shutdown, authRequired]).finally(() => settled.abort());
2225
+ }
2226
+ /**
2227
+ * Keeps a host connected to Studio across token changes: it opens a client, waits until the run
2228
+ * ends or Studio rejects the token, and reconnects with whatever credential the host hands back.
2229
+ *
2230
+ * The host owns everything around that. Where credentials live, whether a rejected token may be
2231
+ * replaced, and how any of it is reported are all decisions `onTokenRejected` makes.
2232
+ *
2233
+ * @example
2234
+ * ```ts
2235
+ * const outcome = await runConnection({
2236
+ * credentials,
2237
+ * clientOptions: () => ({ studioUrl, configPath, version, loadConfig }),
2238
+ * signal: shutdown.signal,
2239
+ * onTokenRejected: ({ error, live }) => pairAgain(error, live),
2240
+ * })
2241
+ * ```
2242
+ */
2243
+ async function runConnection({ credentials, clientOptions, onTokenRejected, signal }) {
2244
+ let current = credentials;
2245
+ while (true) {
2246
+ if (signal?.aborted) return "shutdown";
2247
+ const { promise: authRequired, resolve: notifyAuthRequired } = Promise.withResolvers();
2248
+ const client = createClient({
2249
+ ...clientOptions(current),
2250
+ token: current.token,
2251
+ onAuthRequired: notifyAuthRequired
2252
+ });
2253
+ let rejection;
2254
+ try {
2255
+ await client.connect();
2256
+ const error = await waitForRejection(authRequired, signal);
2257
+ if (!error) return "shutdown";
2258
+ rejection = {
2259
+ error,
2260
+ credentials: current,
2261
+ live: true
2262
+ };
2263
+ } catch (error) {
2264
+ if (!(error instanceof InvalidAgentTokenError)) throw error;
2265
+ rejection = {
2266
+ error,
2267
+ credentials: current,
2268
+ live: false
2269
+ };
2270
+ } finally {
2271
+ client.disconnect();
2272
+ }
2273
+ const next = await onTokenRejected(rejection);
2274
+ if (!next) return "stopped";
2275
+ current = next;
2276
+ }
2277
+ }
2278
+ //#endregion
1257
2279
  //#region src/pair.ts
1258
2280
  /**
1259
2281
  * Identifies the CLI to Studio's device authorization endpoint. A label, not a secret: what
@@ -1261,21 +2283,38 @@ function createClient({ storage, ...options }) {
1261
2283
  */
1262
2284
  const CLIENT_ID = "kubb-cli";
1263
2285
  /**
2286
+ * Thrown when a caller aborts `startPairing` or `pollForPairingToken` through their `signal`, such
2287
+ * as a `kubb studio` shutdown mid-pairing. Distinct from a denial or an expired code, so a host can
2288
+ * exit quietly instead of reporting a pairing failure.
2289
+ */
2290
+ var PairingCanceledError = class extends Error {
2291
+ constructor() {
2292
+ super("Pairing was canceled");
2293
+ this.name = "PairingCanceledError";
2294
+ }
2295
+ };
2296
+ /**
1264
2297
  * Asks Studio for a pairing code. The machine token travels with the request and is stored against
1265
2298
  * the code, so approval knows which machine it is pairing: the same machine pairing twice rotates
1266
2299
  * one agent's token instead of creating a second agent.
1267
2300
  */
1268
- async function startPairing({ studioUrl = agentDefaults.studioUrl, name, hostname, clientId = CLIENT_ID, agentKind }) {
1269
- return (0, ofetch.ofetch)(`${studioUrl}/api/auth/device/code`, {
1270
- method: "POST",
1271
- body: {
1272
- client_id: clientId,
1273
- name,
1274
- hostname,
1275
- machine_token: await getMachineToken(),
1276
- agent_kind: agentKind
1277
- }
1278
- });
2301
+ async function startPairing({ studioUrl = agentDefaults.studioUrl, name, hostname, clientId = CLIENT_ID, agentKind, signal }) {
2302
+ try {
2303
+ return await (0, ofetch.ofetch)(`${studioUrl}/api/auth/device/code`, {
2304
+ method: "POST",
2305
+ body: {
2306
+ client_id: clientId,
2307
+ name,
2308
+ hostname,
2309
+ machine_token: await getMachineToken(),
2310
+ agent_kind: agentKind
2311
+ },
2312
+ signal
2313
+ });
2314
+ } catch (error) {
2315
+ if (signal?.aborted) throw new PairingCanceledError();
2316
+ throw error;
2317
+ }
1279
2318
  }
1280
2319
  function isPairingResult(response) {
1281
2320
  return !!response && typeof response === "object" && "token" in response && typeof response.token === "string";
@@ -1289,19 +2328,26 @@ function isPairingResult(response) {
1289
2328
  *
1290
2329
  * @throws when the code expires, the user denies it, or Studio returns an unexpected error.
1291
2330
  */
1292
- async function pollForPairingToken({ studioUrl = agentDefaults.studioUrl, session }) {
2331
+ async function pollForPairingToken({ studioUrl = agentDefaults.studioUrl, session, signal }) {
1293
2332
  const deadline = Date.now() + (session.expires_in > 0 ? session.expires_in : 600) * 1e3;
1294
2333
  let intervalMs = (session.interval > 0 ? session.interval : 5) * 1e3;
1295
2334
  while (Date.now() < deadline) {
1296
- await (0, node_timers_promises.setTimeout)(intervalMs);
2335
+ if (signal?.aborted) throw new PairingCanceledError();
2336
+ try {
2337
+ await (0, node_timers_promises.setTimeout)(intervalMs, void 0, { signal });
2338
+ } catch {
2339
+ throw new PairingCanceledError();
2340
+ }
1297
2341
  let response;
1298
2342
  try {
1299
2343
  response = await (0, ofetch.ofetch)(`${studioUrl}/api/agent/token`, {
1300
2344
  method: "POST",
1301
2345
  body: { device_code: session.device_code },
1302
- ignoreResponseError: true
2346
+ ignoreResponseError: true,
2347
+ signal
1303
2348
  });
1304
2349
  } catch (error) {
2350
+ if (signal?.aborted) throw new PairingCanceledError();
1305
2351
  console.warn((0, node_util.styleText)("yellow", `Could not reach Kubb Studio while waiting for approval, retrying: ${getErrorMessage(error)}`));
1306
2352
  continue;
1307
2353
  }
@@ -1320,10 +2366,12 @@ async function pollForPairingToken({ studioUrl = agentDefaults.studioUrl, sessio
1320
2366
  }
1321
2367
  //#endregion
1322
2368
  exports.InvalidAgentTokenError = InvalidAgentTokenError;
2369
+ exports.PairingCanceledError = PairingCanceledError;
1323
2370
  exports.createClient = createClient;
1324
2371
  exports.createFileStorage = createFileStorage;
1325
2372
  exports.defaultStudioUrl = defaultStudioUrl;
1326
2373
  exports.pollForPairingToken = pollForPairingToken;
2374
+ exports.runConnection = runConnection;
1327
2375
  exports.setStorage = setStorage;
1328
2376
  exports.startPairing = startPairing;
1329
2377