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