@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-BVn89Nw2.d.ts → index-Y-wkaxbL.d.ts} +31 -26
- package/dist/index.cjs +1365 -317
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +136 -35
- package/dist/index.js +1364 -318
- package/dist/index.js.map +1 -1
- package/dist/protocol.cjs.map +1 -1
- package/dist/protocol.d.ts +2 -2
- package/dist/protocol.js.map +1 -1
- package/package.json +12 -8
- package/dist/configFile-DjzP1_Ln.cjs +0 -575
- package/dist/configFile-DjzP1_Ln.cjs.map +0 -1
- package/dist/configFile-ZnV5tPon.js +0 -574
- package/dist/configFile-ZnV5tPon.js.map +0 -1
- package/dist/resolveConfig-B9oGiNMi.js +0 -179
- package/dist/resolveConfig-B9oGiNMi.js.map +0 -1
- package/dist/resolveConfig-Ci-BVhN_.cjs +0 -202
- package/dist/resolveConfig-Ci-BVhN_.cjs.map +0 -1
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
|
-
*
|
|
32
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
])
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
])
|
|
1702
|
+
]) on(type, () => {
|
|
862
1703
|
sendDataMessage({
|
|
863
1704
|
type,
|
|
864
1705
|
data: []
|
|
865
1706
|
});
|
|
866
1707
|
});
|
|
867
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
-
*
|
|
908
|
-
*
|
|
909
|
-
* alive for the length of
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
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
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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
|
-
|
|
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
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
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
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
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
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
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
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
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
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
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
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
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
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
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 }, () =>
|
|
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
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
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
|
-
|
|
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
|