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