@kubb/studio 5.5.6 → 5.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,22 +1,47 @@
1
- import { t as __name } from "./rolldown-runtime-CRm0XQPb.js";
2
- import { a as toPackageName, n as mergeAdapter, o as read, r as mergePlugins } from "./resolveConfig-DMIG_MH-.js";
3
- import { AGENT_INSTANCE_HEADER, AgentCloseCode, GENERATION_GONE_MESSAGE, MAX_PAGE_LINES, generationEventTypes } from "./protocol.js";
1
+ import "./rolldown-runtime-CRm0XQPb.js";
2
+ import { AGENT_INSTANCE_HEADER, AGENT_SOCKET_PATH, AgentCloseCode, GENERATION_GONE_MESSAGE, MAX_PAGE_LINES, MAX_SNAPSHOT_BYTES, generationEventTypes } from "./protocol.js";
4
3
  import { createRequire } from "node:module";
5
- import { createHash, hash, randomBytes, randomUUID } from "node:crypto";
6
- import { setTimeout as setTimeout$1 } from "node:timers/promises";
7
- import process$1 from "node:process";
8
- import { spawn } from "node:child_process";
9
- import { mkdtemp, readFile, rm } from "node:fs/promises";
10
- import path, { isAbsolute, relative, resolve, sep } from "node:path";
11
- import { FetchError, ofetch } from "ofetch";
12
- import { styleText } from "node:util";
13
- import { createStorage } from "unstorage";
14
- import fsDriver from "unstorage/drivers/fs";
4
+ import { createHash, hash, randomUUID } from "node:crypto";
5
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
15
6
  import { tmpdir } from "node:os";
7
+ import path, { isAbsolute, join, relative, resolve, sep } from "node:path";
8
+ import process$1 from "node:process";
9
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
10
+ import { pathToFileURL } from "node:url";
11
+ import { existsSync } from "node:fs";
16
12
  import { Diagnostics, Hookable, cacheStorage, createKubb, fsStorage, memoryStorage, resolveCacheDir } from "@kubb/core";
17
13
  import { RpcTarget, newWebSocketRpcSession } from "capnweb";
18
- import WebSocket from "ws";
19
- import { x } from "tinyexec";
14
+ import { builders, detectCodeFormat, generateCode, parseModule } from "magicast";
15
+ import { promisify, stripVTControlCharacters } from "node:util";
16
+ import { ofetch } from "ofetch";
17
+ import { gzip } from "node:zlib";
18
+ //#region ../../internals/utils/src/casing.ts
19
+ /**
20
+ * Shared implementation for camelCase and PascalCase conversion.
21
+ * Splits on common word boundaries (spaces, hyphens, underscores, dots, slashes, colons)
22
+ * and capitalizes each word according to `pascal`.
23
+ *
24
+ * When `pascal` is `true` the first word is also capitalized (PascalCase), otherwise only subsequent words are.
25
+ */
26
+ function toCamelOrPascal(text, pascal) {
27
+ 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) => {
28
+ if (word.length > 1 && word === word.toUpperCase()) return word;
29
+ return (i === 0 && !pascal ? word.charAt(0).toLowerCase() : word.charAt(0).toUpperCase()) + word.slice(1);
30
+ }).join("").replace(/[^a-zA-Z0-9]/g, "");
31
+ }
32
+ /**
33
+ * Converts `text` to camelCase.
34
+ *
35
+ * @example Word boundaries
36
+ * `camelCase('hello-world') // 'helloWorld'`
37
+ *
38
+ * @example With a prefix
39
+ * `camelCase('tag', { prefix: 'create' }) // 'createTag'`
40
+ */
41
+ function camelCase(text, { prefix = "", suffix = "" } = {}) {
42
+ return toCamelOrPascal(`${prefix} ${text} ${suffix}`, false);
43
+ }
44
+ //#endregion
20
45
  //#region ../../internals/utils/src/errors.ts
21
46
  /**
22
47
  * Coerces an unknown thrown value to an `Error` instance.
@@ -45,45 +70,92 @@ function getErrorMessage(value) {
45
70
  return value instanceof Error ? value.message : String(value);
46
71
  }
47
72
  //#endregion
48
- //#region ../../internals/utils/src/promise.ts
73
+ //#region ../../internals/utils/src/runtime.ts
49
74
  /**
50
- * Wraps `factory` with a keyed cache backed by the provided store.
51
- *
52
- * Pass a `WeakMap` for object keys (results are GC-eligible when the key is
53
- * collected) or a `Map` for primitive keys. For multi-argument functions,
54
- * nest two `memoize` calls — the outer keyed by the first argument, the
55
- * inner (created once per outer miss) keyed by the second.
75
+ * Detects the JavaScript runtime executing the current process and exposes its name and version.
56
76
  *
57
- * Because the cache is owned by the caller, it can be shared, inspected, or
58
- * cleared independently of the memoized function.
59
- *
60
- * @example Single WeakMap key
61
- * ```ts
62
- * const cache = new WeakMap<SchemaNode, Set<string>>()
63
- * const getRefs = memoize(cache, (node) => collectRefs(node))
64
- * ```
65
- *
66
- * @example Single Map key (primitive)
67
- * ```ts
68
- * const cache = new Map<string, Resolver>()
69
- * const getResolver = memoize(cache, (name) => buildResolver(name))
70
- * ```
77
+ * Prefer the shared {@link runtime} instance over constructing your own.
78
+ */
79
+ var Runtime = class {
80
+ /**
81
+ * `true` when the current process is running under Bun.
82
+ *
83
+ * Detection keys off the global `Bun` object rather than `process.versions`,
84
+ * because Bun polyfills `process.versions.node` for Node compatibility and would
85
+ * otherwise look like Node.
86
+ *
87
+ * @example
88
+ * ```ts
89
+ * if (runtime.isBun) {
90
+ * await Bun.write(path, data)
91
+ * }
92
+ * ```
93
+ */
94
+ get isBun() {
95
+ return typeof Bun !== "undefined";
96
+ }
97
+ /**
98
+ * `true` when the current process is running under Deno.
99
+ */
100
+ get isDeno() {
101
+ return typeof globalThis.Deno !== "undefined";
102
+ }
103
+ /**
104
+ * `true` when the current process is running under Node.
105
+ *
106
+ * Bun and Deno are excluded first so a polyfilled `process` does not register as Node.
107
+ */
108
+ get isNode() {
109
+ return !this.isBun && !this.isDeno && typeof process !== "undefined" && process.versions?.node != null;
110
+ }
111
+ /**
112
+ * Name of the runtime executing the current process.
113
+ *
114
+ * @example
115
+ * ```ts
116
+ * runtime.name // 'bun' when run with `bun kubb`, 'node' otherwise
117
+ * ```
118
+ */
119
+ get name() {
120
+ if (this.isBun) return "bun";
121
+ if (this.isDeno) return "deno";
122
+ return "node";
123
+ }
124
+ /**
125
+ * Version of the active runtime, or an empty string when it cannot be read.
126
+ *
127
+ * @example
128
+ * ```ts
129
+ * runtime.version // '1.3.11' under Bun, '22.22.2' under Node
130
+ * ```
131
+ */
132
+ get version() {
133
+ if (this.isBun) return process.versions.bun ?? "";
134
+ if (this.isDeno) return globalThis.Deno?.version?.deno ?? "";
135
+ return process.versions?.node ?? "";
136
+ }
137
+ };
138
+ /**
139
+ * Shared {@link Runtime} instance describing the JavaScript runtime executing the current process.
140
+ */
141
+ const runtime = new Runtime();
142
+ //#endregion
143
+ //#region ../../internals/utils/src/fs.ts
144
+ /**
145
+ * Reads the file at `path` as a UTF-8 string.
146
+ * Uses `Bun.file().text()` when running under Bun, `fs.readFile` otherwise.
71
147
  *
72
- * @example Two-level (object + primitive)
148
+ * @example
73
149
  * ```ts
74
- * const outer = new WeakMap<Params[], Map<string, Params[]>>()
75
- * const fn = memoize(outer, (params) => memoize(new Map(), (key) => transform(params, key)))
76
- * fn(params)('camelcase')
150
+ * const source = await read('./src/Pet.ts')
77
151
  * ```
78
152
  */
79
- function memoize(store, factory) {
80
- return (key) => {
81
- if (store.has(key)) return store.get(key);
82
- const value = factory(key);
83
- store.set(key, value);
84
- return value;
85
- };
153
+ async function read(path) {
154
+ if (runtime.isBun) return Bun.file(path).text();
155
+ return readFile(path, { encoding: "utf8" });
86
156
  }
157
+ //#endregion
158
+ //#region ../../internals/utils/src/promise.ts
87
159
  /**
88
160
  * Runs `run` over every item with at most `limit` in flight. Workers share one iterator, so each
89
161
  * takes the next item the moment it frees up instead of waiting for a batch to drain.
@@ -101,458 +173,832 @@ async function inParallel({ items, limit, run }) {
101
173
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
102
174
  }
103
175
  //#endregion
104
- //#region ../../internals/utils/src/tools.ts
105
- /**
106
- * CLI command descriptors for each supported code formatter.
107
- */
108
- const formatters = {
109
- prettier: {
110
- command: "prettier",
111
- args: (outputPath) => [
112
- "--ignore-unknown",
113
- "--write",
114
- outputPath
115
- ],
116
- errorMessage: "Prettier not found"
117
- },
118
- biome: {
119
- command: "biome",
120
- args: (outputPath) => [
121
- "format",
122
- "--write",
123
- outputPath
124
- ],
125
- errorMessage: "Biome not found"
126
- },
127
- oxfmt: {
128
- command: "oxfmt",
129
- args: (outputPath) => [outputPath],
130
- errorMessage: "Oxfmt not found"
131
- }
132
- };
176
+ //#region ../../internals/utils/src/time.ts
177
+ /**
178
+ * Calculates elapsed time in milliseconds from a high-resolution `process.hrtime` start time.
179
+ * Rounds to 2 decimal places for sub-millisecond precision without noise.
180
+ *
181
+ * @example
182
+ * ```ts
183
+ * const start = process.hrtime()
184
+ * doWork()
185
+ * getElapsedMs(start) // 42.35
186
+ * ```
187
+ */
188
+ function getElapsedMs(hrStart) {
189
+ const [seconds, nanoseconds] = process.hrtime(hrStart);
190
+ const ms = seconds * 1e3 + nanoseconds / 1e6;
191
+ return Math.round(ms * 100) / 100;
192
+ }
193
+ //#endregion
194
+ //#region package.json
195
+ var version = "5.6.1";
196
+ //#endregion
197
+ //#region src/resolveConfig.ts
133
198
  /**
134
- * CLI command descriptors for each supported linter.
135
- */
136
- const linters = {
137
- eslint: {
138
- command: "eslint",
139
- args: (outputPath) => [outputPath, "--fix"],
140
- errorMessage: "Eslint not found"
141
- },
142
- biome: {
143
- command: "biome",
144
- args: (outputPath) => [
145
- "lint",
146
- "--fix",
147
- outputPath
148
- ],
149
- errorMessage: "Biome not found"
150
- },
151
- oxlint: {
152
- command: "oxlint",
153
- args: (outputPath) => [
154
- "--fix",
155
- "--no-ignore",
156
- outputPath
157
- ],
158
- errorMessage: "Oxlint not found"
199
+ * Imports a package, falling back to how the user's project would resolve it.
200
+ *
201
+ * `import()` resolves from this file, so a linked or globally installed Studio (`pnpm link`,
202
+ * `npm i -g`) only sees its own `node_modules` and misses the plugins installed next to the user's
203
+ * config. The retry resolves from `process.cwd()` instead.
204
+ */
205
+ async function importFromProject(packageName) {
206
+ try {
207
+ return await import(packageName);
208
+ } catch {
209
+ const resolved = createRequire(pathToFileURL(`${process.cwd()}/`)).resolve(packageName);
210
+ const esm = resolved.replace(/\.cjs$/, ".js");
211
+ return await import(pathToFileURL(esm !== resolved && existsSync(esm) ? esm : resolved).href);
159
212
  }
160
- };
213
+ }
161
214
  /**
162
- * Preference order for `format: 'auto'`, most-preferred first. Spelled out rather than taken from
163
- * the table's key order, which is arbitrary and would silently change what `auto` picks.
215
+ * Strips the `@kubb/` scope from a plugin package name, matching the `name` convention Kubb
216
+ * plugin factories use internally.
217
+ *
218
+ * @example
219
+ * ```ts
220
+ * toPluginName('@kubb/plugin-ts') // 'plugin-ts'
221
+ * ```
164
222
  */
165
- const FORMATTER_PREFERENCE = [
166
- "oxfmt",
167
- "biome",
168
- "prettier"
169
- ];
223
+ function toPluginName(packageName) {
224
+ return packageName.split("/").pop() ?? packageName;
225
+ }
170
226
  /**
171
- * Preference order for `lint: 'auto'`, most-preferred first.
227
+ * Adds the `@kubb/` scope a plugin's package carries but its `name` does not, the inverse of
228
+ * {@link toPluginName}. A name outside the `plugin-` convention is left alone, so a third-party
229
+ * plugin is not reported as one of Kubb's.
230
+ *
231
+ * @example
232
+ * ```ts
233
+ * toPackageName('plugin-ts') // '@kubb/plugin-ts'
234
+ * toPackageName('@acme/my-plugin') // '@acme/my-plugin'
235
+ * ```
172
236
  */
173
- const LINTER_PREFERENCE = [
174
- "oxlint",
175
- "biome",
176
- "eslint"
177
- ];
237
+ function toPackageName(name) {
238
+ return name.startsWith("plugin-") ? `@kubb/${name}` : name;
239
+ }
178
240
  /**
179
- * Whether `name` is on PATH and answers `--version` with a zero exit.
241
+ * Derives the conventional named export for a `@kubb/*` plugin package from its package name.
242
+ *
243
+ * @example
244
+ * ```ts
245
+ * toExportName('@kubb/plugin-react-query') // 'pluginReactQuery'
246
+ * toExportName('@kubb/plugin-ts') // 'pluginTs'
247
+ * ```
180
248
  */
181
- function isToolAvailable(name) {
182
- return new Promise((resolve) => {
183
- const child = spawn(name, ["--version"], { stdio: "ignore" });
184
- child.on("close", (code) => resolve(code === 0));
185
- child.on("error", () => resolve(false));
186
- });
249
+ function toExportName(packageName) {
250
+ return camelCase(toPluginName(packageName));
187
251
  }
188
252
  /**
189
- * Returns the first installed executable from `candidates`, or `null` when none are found.
253
+ * A `@kubb/plugin-*` package specifier. Nothing else may reach `import()`: only Kubb's own
254
+ * plugins are supported, so a payload naming anything else, a third-party package or a path, is
255
+ * refused before it can execute.
256
+ */
257
+ const KUBB_PLUGIN_SPECIFIER = /^@kubb\/plugin-[\w.-]+$/;
258
+ /**
259
+ * Whether `name` is a `@kubb/plugin-*` specifier. Exported so `configFile.ts` can refuse the same
260
+ * shape before printing a Studio-supplied plugin name into the config file's source text.
261
+ */
262
+ function isKubbPluginSpecifier(name) {
263
+ return KUBB_PLUGIN_SPECIFIER.test(name);
264
+ }
265
+ /**
266
+ * Dynamically imports a `@kubb/plugin-*` package and returns its factory function.
190
267
  *
191
- * Not memoized: a long-running host that probes repeatedly should cache the result itself, and a
192
- * `--watch` build should keep noticing a tool installed mid-session.
268
+ * Packages must be pre-installed in the Docker image at build time via the `KUBB_PACKAGES`
269
+ * build ARG, no runtime installation is possible in the distroless container.
270
+ *
271
+ * Resolution order: the camelCase named export the package name implies (e.g. `pluginTs`), then
272
+ * the default export.
273
+ *
274
+ * @throws if the package cannot be imported or exports no callable factory.
193
275
  */
194
- async function detectTool$1(candidates) {
195
- for (const candidate of candidates) if (await isToolAvailable(candidate)) return candidate;
196
- return null;
276
+ async function loadPluginFactory(packageName) {
277
+ if (!isKubbPluginSpecifier(packageName)) throw new Error(`Plugin "${packageName}" is not a @kubb/plugin-* package. Kubb Studio only supports Kubb's own plugins.`);
278
+ let mod;
279
+ try {
280
+ mod = await importFromProject(packageName);
281
+ } catch (cause) {
282
+ throw new Error(`Plugin "${packageName}" could not be loaded. Make sure it is installed: \`npm install ${packageName}\``, { cause });
283
+ }
284
+ const exportName = toExportName(packageName);
285
+ if (typeof mod[exportName] === "function") return mod[exportName];
286
+ if (typeof mod["default"] === "function") return mod["default"];
287
+ throw new Error(`Plugin "${packageName}" does not export a callable factory. Tried the named export "${exportName}" and "default".`);
197
288
  }
198
- __name(detectTool$1, "detectTool");
199
289
  /**
200
- * Tokenizes a shell command string, respecting single and double quotes.
290
+ * Resolves each plugin entry by dynamically importing the `@kubb/plugin-*` package and
291
+ * calling its factory with the provided options.
292
+ *
293
+ * Packages must be pre-installed in the Docker image at build time, use the `KUBB_PACKAGES`
294
+ * build ARG to control which ones are available at runtime.
201
295
  *
202
296
  * @example
203
297
  * ```ts
204
- * tokenize('git commit -m "initial commit"')
205
- * // → ['git', 'commit', '-m', 'initial commit']
298
+ * { name: '@kubb/plugin-react-query', options: { output: { path: './hooks' } } }
299
+ * { name: '@kubb/plugin-ts', options: { output: { path: './types' } } }
206
300
  * ```
207
301
  */
208
- function tokenize(command) {
209
- return (command.match(/[^\s"']+|"([^"]*)"|'([^']*)'/g) ?? []).map((token) => token.replace(/^["']|["']$/g, ""));
302
+ async function resolvePlugins(plugins) {
303
+ return Promise.all(plugins.map(async ({ name, options }) => {
304
+ return (await loadPluginFactory(name))(options ?? {});
305
+ }));
306
+ }
307
+ /** The installed version of each plugin's package, plus the names that could not be resolved. */
308
+ async function resolvePeerDependencies(names) {
309
+ const require = createRequire(import.meta.url);
310
+ const uniqueNames = [...new Set(names.map(toPackageName))];
311
+ const peerDependencies = {};
312
+ const missingDependencies = [];
313
+ const versions = await Promise.all(uniqueNames.map(async (name) => {
314
+ try {
315
+ const path = require.resolve(`${name}/package.json`);
316
+ return JSON.parse(await readFile(path, "utf8")).version;
317
+ } catch {
318
+ return;
319
+ }
320
+ }));
321
+ for (const [index, name] of uniqueNames.entries()) {
322
+ const version = versions[index];
323
+ if (version) {
324
+ peerDependencies[name] = version;
325
+ continue;
326
+ }
327
+ missingDependencies.push(name);
328
+ }
329
+ return {
330
+ peerDependencies,
331
+ missingDependencies
332
+ };
210
333
  }
211
- //#endregion
212
- //#region ../../internals/utils/src/time.ts
213
334
  /**
214
- * Calculates elapsed time in milliseconds from a high-resolution `process.hrtime` start time.
215
- * Rounds to 2 decimal places for sub-millisecond precision without noise.
335
+ * Whether `value` is an object literal or a null-prototype object, not an array or class instance.
336
+ */
337
+ function isPlainObject(value) {
338
+ if (typeof value !== "object" || value === null) return false;
339
+ const prototype = Object.getPrototypeOf(value);
340
+ return prototype === null || prototype === Object.prototype;
341
+ }
342
+ /**
343
+ * Whether `value` survives a JSON round trip, so it can be printed into a config file as a literal.
344
+ * A function, `undefined`, a non-finite number or a class instance comes back from Studio as a
345
+ * lossy copy, so an edit carrying one is refused rather than written into the user's source.
346
+ */
347
+ function isOptionValue(value) {
348
+ if (value === null || typeof value === "string" || typeof value === "boolean") return true;
349
+ if (typeof value === "number") return Number.isFinite(value);
350
+ if (Array.isArray(value)) return value.every(isOptionValue);
351
+ if (isPlainObject(value)) return Object.values(value).every(isOptionValue);
352
+ return false;
353
+ }
354
+ /**
355
+ * Deep-merges Studio's options over the disk options, with Studio taking priority, except where the
356
+ * disk value cannot survive JSON. Studio only ever saw a lossy copy of such a value, so the disk
357
+ * value is kept rather than replaced by what Studio echoes back. Plain objects merge key by key, so
358
+ * Studio can still change a literal that sits next to a function (e.g. `group.type` beside `group.name`).
216
359
  *
217
360
  * @example
218
361
  * ```ts
219
- * const start = process.hrtime()
220
- * doWork()
221
- * getElapsedMs(start) // 42.35
362
+ * mergeOptions({ macros: [camelCaseMacro], syntaxType: 'type' }, { macros: [{ name: 'camelCase' }], syntaxType: 'interface' })
363
+ * // { macros: [camelCaseMacro], syntaxType: 'interface' }
222
364
  * ```
223
365
  */
224
- function getElapsedMs(hrStart) {
225
- const [seconds, nanoseconds] = process.hrtime(hrStart);
226
- const ms = seconds * 1e3 + nanoseconds / 1e6;
227
- return Math.round(ms * 100) / 100;
366
+ function mergeOptions(disk, studio) {
367
+ const merged = { ...disk };
368
+ for (const [key, value] of Object.entries(studio)) {
369
+ const current = disk[key];
370
+ if (isPlainObject(current) && isPlainObject(value)) {
371
+ merged[key] = mergeOptions(current, value);
372
+ continue;
373
+ }
374
+ if (current !== void 0 && !isOptionValue(current)) continue;
375
+ merged[key] = value;
376
+ }
377
+ return merged;
378
+ }
379
+ /**
380
+ * Merges studio plugin options with disk config plugins.
381
+ * Studio takes priority: options from studio win over disk (except values only the disk config
382
+ * can express, see {@link mergeOptions}), and a plugin Studio explicitly
383
+ * disabled is dropped even when the disk config still lists it. Disk plugins without a studio
384
+ * counterpart are kept as-is. Studio plugins not present on disk are appended.
385
+ *
386
+ * For plugins present in both configs, the plugin is re-instantiated with merged options
387
+ * so that all internal closures correctly reference the merged values.
388
+ */
389
+ async function mergePlugins(diskPlugins, studioPlugins) {
390
+ const disabledNames = new Set((studioPlugins ?? []).filter((entry) => entry.disabled).map((entry) => toPluginName(entry.name)));
391
+ const activeDiskPlugins = disabledNames.size ? diskPlugins?.filter((plugin) => !disabledNames.has(plugin.name)) : diskPlugins;
392
+ const activeStudioPlugins = studioPlugins?.filter((entry) => !entry.disabled);
393
+ if (!activeDiskPlugins && !activeStudioPlugins?.length) return void 0;
394
+ if (!activeStudioPlugins?.length) return activeDiskPlugins;
395
+ if (!activeDiskPlugins) return resolvePlugins(activeStudioPlugins);
396
+ const studioEntryByName = new Map(activeStudioPlugins.map((entry) => [toPluginName(entry.name), entry]));
397
+ const diskNames = new Set(activeDiskPlugins.map((plugin) => plugin.name));
398
+ const merged = await Promise.all(activeDiskPlugins.map(async (diskPlugin) => {
399
+ const studioEntry = studioEntryByName.get(diskPlugin.name);
400
+ if (!studioEntry) return diskPlugin;
401
+ const options = mergeOptions(diskPlugin.options ?? {}, studioEntry.options ?? {});
402
+ const [resolved] = await resolvePlugins([{
403
+ name: studioEntry.name,
404
+ options
405
+ }]);
406
+ return resolved ?? diskPlugin;
407
+ }));
408
+ const studioOnly = activeStudioPlugins.filter((entry) => !diskNames.has(toPluginName(entry.name)));
409
+ return [...merged, ...await resolvePlugins(studioOnly)];
410
+ }
411
+ /**
412
+ * Merges Studio-provided adapter option overrides into the disk config's adapter.
413
+ *
414
+ * Adapter instances carry live functions (`parse`, `getImports`, ...) that can't survive
415
+ * JSON serialization over the WebSocket, so `studioOptions` is treated as an options patch
416
+ * rather than a replacement adapter. Re-invokes the same `@kubb/adapter-<name>` factory the
417
+ * disk config used, with the merged options, so the resulting instance has fresh closures
418
+ * over the merged values instead of a plain object missing `parse`.
419
+ */
420
+ async function mergeAdapter(diskAdapter, studioOptions) {
421
+ if (!studioOptions || !diskAdapter) return diskAdapter;
422
+ const packageName = `@kubb/adapter-${diskAdapter.name}`;
423
+ const factory = (await importFromProject(packageName))[toExportName(packageName)];
424
+ if (typeof factory !== "function") return diskAdapter;
425
+ return factory(mergeOptions(diskAdapter.options ?? {}, studioOptions));
228
426
  }
229
427
  //#endregion
230
- //#region src/operations/machine.ts
428
+ //#region src/configFile.ts
231
429
  /**
232
- * Key-value storage the runtime uses for its machine secret and the last Studio config.
430
+ * A valid JavaScript identifier, so an import name can only ever print as `import { name } from`,
431
+ * never as source that breaks out of the import statement.
432
+ */
433
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
434
+ /**
435
+ * `key: value` as an object literal property, in the file's quote and key style.
233
436
  *
234
- * One storage per process, since one process serves one config file. Hosts install their own
235
- * driver on startup: Nitro passes its `kubb` mount, the CLI an fs driver under `~/.kubb/cache`.
236
- * The in-memory default keeps the runtime usable without a host, at the cost of a machine
237
- * identity that changes on every restart.
437
+ * Uses magicast's literal builder for the key/value nodes, then wraps them as a Babel
438
+ * `ObjectProperty`, the type the rest of this file reads.
238
439
  */
239
- let storage = createStorage();
240
- let hasInstalledStorage = false;
440
+ function literalProperty({ key, value }) {
441
+ const built = builders.literal({ [key]: value }).properties[0];
442
+ return {
443
+ type: "ObjectProperty",
444
+ key: built.key,
445
+ value: built.value,
446
+ computed: false,
447
+ shorthand: false
448
+ };
449
+ }
450
+ /**
451
+ * Marks the comment block a `disable-plugin` leaves behind, so `enable-plugin` can find its way
452
+ * back to the exact lines it commented out. Carries the block's line count, so `enable-plugin`
453
+ * restores exactly those lines instead of scanning forward through whatever comments follow.
454
+ */
455
+ const DISABLED_MARKER = "kubb:disabled";
241
456
  /**
242
- * Installs the storage driver the runtime persists to. Call once, before connecting.
457
+ * The plugin and comment-block length a marker line names, when `line` is one.
243
458
  */
244
- function setStorage(next) {
245
- storage = next;
246
- hasInstalledStorage = true;
459
+ function parseMarker(line) {
460
+ const trimmed = line.trim();
461
+ if (!trimmed.startsWith(`// ${DISABLED_MARKER} `)) return;
462
+ const match = trimmed.slice(`// ${DISABLED_MARKER} `.length).match(/^(.+)\s+(\d+)$/);
463
+ return match ? {
464
+ plugin: match[1],
465
+ lineCount: Number(match[2])
466
+ } : void 0;
247
467
  }
248
468
  /**
249
- * A storage backed by files under `base`, so the machine secret and the last Studio config
250
- * survive a restart. Repeated pairings of one machine depend on that secret staying put.
469
+ * Steps through a config's wrappers to the object literal underneath: a `satisfies`/`as`
470
+ * assertion, a `() => ...` factory, or a factory whose block body returns the config.
251
471
  */
252
- function createFileStorage(base) {
253
- return createStorage({ driver: fsDriver({ base }) });
472
+ function unwrap(node) {
473
+ if (!node) return;
474
+ if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression") return unwrap(node.expression);
475
+ if (node.type !== "ArrowFunctionExpression" && node.type !== "FunctionExpression") return node;
476
+ if (node.body.type !== "BlockStatement") return unwrap(node.body);
477
+ return unwrap(node.body.body.find((statement) => statement.type === "ReturnStatement")?.argument);
254
478
  }
255
- let fallbackSecretPromise = null;
256
479
  /**
257
- * Loads the fallback machine secret from the runtime storage.
258
- * On first use it generates a secret and persists it, so the machine identity stays
259
- * stable across restarts. An identity that changes on every boot breaks session
260
- * creation with Studio whenever the startup registration call fails.
480
+ * Every config object in `export default defineConfig(...)`, or why the file is unmanaged.
481
+ *
482
+ * An array export gets one entry per element, matching {@link ConfigRef}'s numeric index.
483
+ *
484
+ * Walks the parsed AST rather than magicast's proxies, which throw on node types they cannot
485
+ * cast, most of what an unmanaged config file is made of.
261
486
  */
262
- async function loadOrCreateFallbackSecret() {
263
- if (!hasInstalledStorage) console.warn(styleText("yellow", "Deriving the machine token before a storage driver was installed"), "call setStorage() first, or set KUBB_AGENT_SECRET, to keep a stable machine identity across restarts");
264
- const stored = await storage.getItem("machine-secret").catch(() => null);
265
- if (typeof stored === "string" && stored) return stored;
266
- const secret = randomBytes(32).toString("hex");
267
- await storage.setItem("machine-secret", secret).catch(() => {
268
- console.warn(styleText("yellow", "Could not persist the generated machine secret"), "set KUBB_AGENT_SECRET to keep a stable machine identity across restarts");
269
- });
270
- return secret;
487
+ function findConfigs(mod) {
488
+ const exported = unwrap((mod.$ast.type === "Program" ? mod.$ast.body : []).find((node) => node.type === "ExportDefaultDeclaration")?.declaration);
489
+ if (!exported) return { reason: "no default export found" };
490
+ if (exported.type !== "CallExpression" || exported.callee.type !== "Identifier" || exported.callee.name !== "defineConfig") return { reason: "default export is not a defineConfig(...) call" };
491
+ const argument = unwrap(exported.arguments[0]);
492
+ if (!argument) return { reason: "defineConfig(...) was called without a config" };
493
+ if (argument.type === "ArrayExpression") {
494
+ const configs = [];
495
+ for (const element of argument.elements) {
496
+ const entry = unwrap(element);
497
+ if (entry?.type !== "ObjectExpression") return { reason: "config is not an object literal" };
498
+ configs.push(entry);
499
+ }
500
+ return { configs };
501
+ }
502
+ if (argument.type !== "ObjectExpression") return { reason: "config is not an object literal" };
503
+ return { configs: [argument] };
271
504
  }
272
505
  /**
273
- * Hashes a stable secret into the machine token Studio expects, so a host can derive one from its
274
- * own identity without duplicating `getMachineToken`'s SHA-256 step.
506
+ * The config entry an edit names, defaulting to the first when it names none.
275
507
  */
276
- function machineTokenFrom(secret) {
277
- return hash("sha256", secret);
508
+ function selectConfig(configs, ref) {
509
+ if (ref === void 0) return configs[0];
510
+ if (typeof ref === "number") return configs[ref];
511
+ return configs.find((config) => configName(config) === ref);
512
+ }
513
+ function configName(config) {
514
+ const name = property(config, "name");
515
+ return name?.type === "StringLiteral" ? name.value : void 0;
278
516
  }
279
517
  /**
280
- * Returns the machine token derived from the `KUBB_AGENT_SECRET` environment variable.
281
- * Falls back to a generated secret persisted in the runtime storage if the env var is not set.
282
- * The token is hashed with SHA-256.
518
+ * The name of an object literal property, for the two key shapes a config uses: `key: value` and
519
+ * `'key': value`. `undefined` for a computed key, which the patcher never touches.
283
520
  */
284
- async function getMachineToken() {
285
- if (process$1.env.KUBB_AGENT_SECRET) return machineTokenFrom(process$1.env.KUBB_AGENT_SECRET);
286
- fallbackSecretPromise ??= loadOrCreateFallbackSecret();
287
- return machineTokenFrom(await fallbackSecretPromise);
521
+ function propertyKey(entry) {
522
+ if (entry.key.type === "Identifier") return entry.key.name;
523
+ if (entry.key.type === "StringLiteral") return entry.key.value;
288
524
  }
289
- //#endregion
290
- //#region src/operations/api.ts
291
525
  /**
292
- * Reads a human-readable message from a Studio JSON error body, when it has one. `FetchError`'s own
293
- * message stops at the status line, so the detail Studio sends with a failure (an agent limit, a
294
- * revoked token) would otherwise never reach the user.
526
+ * The index of an object literal's own property named `key`, `-1` when it has none.
295
527
  */
296
- function responseMessage(data) {
297
- if (!data || typeof data !== "object") return;
298
- const body = data;
299
- for (const value of [
300
- body.error_description,
301
- body.message,
302
- body.error
303
- ]) if (typeof value === "string" && value) return value;
528
+ function entryIndex({ node, key }) {
529
+ return node.properties.findIndex((entry) => entry.type === "ObjectProperty" && propertyKey(entry) === key);
304
530
  }
305
531
  /**
306
- * Thrown when Studio rejects the agent token itself (401). Retrying cannot help: the token was
307
- * revoked, or the agent it belonged to was deleted in the Studio UI. Hosts catch this to forget
308
- * the stored credential and pair again.
532
+ * The value node of an object literal's own property.
309
533
  */
310
- var InvalidAgentTokenError = class extends Error {
311
- constructor(studioUrl, options) {
312
- super(`Kubb Studio rejected this agent's token. It was revoked or the agent was deleted in ${studioUrl}.`, options);
313
- this.name = "InvalidAgentTokenError";
534
+ function property(node, key) {
535
+ const index = entryIndex({
536
+ node,
537
+ key
538
+ });
539
+ return index === -1 ? void 0 : node.properties[index].value;
540
+ }
541
+ /**
542
+ * Writes `key: value` on an object literal, replacing the value when the property is already there.
543
+ *
544
+ * An existing property has its value swapped in place rather than being replaced whole, so recast
545
+ * reprints only that value and leaves the object's own layout alone.
546
+ */
547
+ function setProperty({ node, key, value }) {
548
+ const entry = literalProperty({
549
+ key,
550
+ value
551
+ });
552
+ const index = entryIndex({
553
+ node,
554
+ key
555
+ });
556
+ if (index === -1) {
557
+ node.properties.push(entry);
558
+ return;
314
559
  }
315
- };
560
+ node.properties[index].value = entry.value;
561
+ }
316
562
  /**
317
- * Thrown when Studio refuses this agent's protocol version (426). Retrying cannot help until the
318
- * agent is upgraded, so hosts stop instead of reconnecting.
563
+ * Drops `key` from an object literal.
319
564
  */
320
- var IncompatibleAgentError = class extends Error {
321
- constructor(studioUrl, detail, options) {
322
- super(`Kubb Studio at ${studioUrl} requires a newer agent${detail ? `: ${detail}` : ""}. Upgrade @kubb/studio or the Kubb agent image.`, options);
323
- this.name = "IncompatibleAgentError";
565
+ function removeProperty({ node, key }) {
566
+ const index = entryIndex({
567
+ node,
568
+ key
569
+ });
570
+ if (index !== -1) node.properties.splice(index, 1);
571
+ }
572
+ /**
573
+ * Reads a literal node's value: a primitive, or an object/array built only from primitives.
574
+ * `undefined` for anything else, so a caller can use this both to read a value and to check
575
+ * whether a node is a literal at all.
576
+ */
577
+ function readLiteral(node) {
578
+ if (!node) return;
579
+ if (node.type === "StringLiteral" || node.type === "NumericLiteral" || node.type === "BooleanLiteral") return node.value;
580
+ if (node.type === "NullLiteral") return null;
581
+ if (node.type === "TemplateLiteral") return node.expressions.length === 0 ? node.quasis[0]?.value.cooked ?? "" : void 0;
582
+ if (node.type === "UnaryExpression") {
583
+ const value = readLiteral(node.argument);
584
+ if (typeof value !== "number") return;
585
+ if (node.operator === "-") return -value;
586
+ if (node.operator === "+") return value;
587
+ return;
324
588
  }
325
- };
589
+ if (node.type === "ArrayExpression") {
590
+ const values = node.elements.map((element) => element ? readLiteral(element) : void 0);
591
+ return values.every((value) => value !== void 0) ? values : void 0;
592
+ }
593
+ if (node.type === "ObjectExpression") {
594
+ const entries = {};
595
+ for (const entry of node.properties) {
596
+ if (entry.type !== "ObjectProperty") return;
597
+ const key = propertyKey(entry);
598
+ const value = readLiteral(entry.value);
599
+ if (key === void 0 || value === void 0) return;
600
+ entries[key] = value;
601
+ }
602
+ return entries;
603
+ }
604
+ }
326
605
  /**
327
- * Whether a thrown value carries `statusCode`. Not narrowed to `FetchError`: a host wrapper can
328
- * throw its own error shape with the same field.
606
+ * Maps a factory identifier in the file back to the module it was imported from.
329
607
  */
330
- function rejectedWith(error, statusCode) {
331
- return error?.statusCode === statusCode;
608
+ function importedFrom(mod) {
609
+ return new Map(mod.imports.$items.map((item) => [item.local, item.from]));
332
610
  }
333
- function registrationError(cause) {
334
- const detail = (cause instanceof FetchError ? responseMessage(cause.data) : void 0) ?? getErrorMessage(cause);
335
- return new Error(detail ? `Failed to register with Kubb Studio: ${detail}` : "Failed to register with Kubb Studio", { cause });
611
+ /**
612
+ * Every `pluginX(...)` element of a config's plugins array that resolves to an import.
613
+ */
614
+ function pluginCalls(mod, config) {
615
+ const plugins = property(config, "plugins");
616
+ if (plugins?.type !== "ArrayExpression") return [];
617
+ const imports = importedFrom(mod);
618
+ return plugins.elements.flatMap((element) => {
619
+ if (element?.type !== "CallExpression" || element.callee.type !== "Identifier") return [];
620
+ const packageName = imports.get(element.callee.name);
621
+ return packageName ? [{
622
+ importName: element.callee.name,
623
+ packageName,
624
+ call: element
625
+ }] : [];
626
+ });
336
627
  }
337
628
  /**
338
- * Registers this agent process with Kubb Studio (`POST /api/agent/connect`): binds the machine
339
- * identity to the token, reports what the process can take on, and gets back the URL of the one
340
- * socket it keeps open.
629
+ * Plugins a previous `disable-plugin` commented out of this config, keyed by package name.
341
630
  *
342
- * Makes one bounded attempt; `runConnection` owns retries. A rejected token (401) throws
343
- * {@link InvalidAgentTokenError} and an unsupported agent version (426) throws
344
- * {@link IncompatibleAgentError}, since retrying either cannot help.
631
+ * Read from the marker lines rather than the AST, since a commented-out call is no longer a node.
345
632
  */
346
- async function registerAgent({ token, studioUrl, instanceId, capacity, signal }) {
347
- const body = {
348
- machineToken: await getMachineToken(),
349
- instanceId,
350
- capacity
633
+ function disabledMarkers(source) {
634
+ return source.split("\n").flatMap((line, index) => {
635
+ const marker = parseMarker(line);
636
+ return marker ? [{
637
+ packageName: marker.plugin,
638
+ line: index + 1
639
+ }] : [];
640
+ });
641
+ }
642
+ /**
643
+ * Reads which plugins the file declares and which of their options Studio may write.
644
+ *
645
+ * @example
646
+ * ```ts
647
+ * const view = readConfig(await readFile('kubb.config.ts', 'utf8'))
648
+ * if (view.managed) {
649
+ * view.configs.forEach((config) => console.log(config.name, config.plugins.length))
650
+ * }
651
+ * ```
652
+ */
653
+ function readConfig(source) {
654
+ const parsed = parseConfigs(source);
655
+ if ("reason" in parsed) return {
656
+ managed: false,
657
+ reason: parsed.reason
351
658
  };
352
- try {
353
- return await ofetch(`${studioUrl}/api/agent/connect`, {
354
- method: "POST",
355
- headers: { Authorization: `Bearer ${token}` },
356
- body,
357
- retry: 0,
358
- timeout: 1e4,
359
- signal
659
+ const { mod, configs } = parsed;
660
+ const importNames = new Map([...importedFrom(mod)].map(([local, from]) => [from, local]));
661
+ const disabled = disabledMarkers(source);
662
+ return {
663
+ managed: true,
664
+ configs: configs.map((config) => {
665
+ const plugins = pluginCalls(mod, config).map(({ importName, packageName, call }) => {
666
+ const entries = {};
667
+ const options = call.arguments[0];
668
+ if (options?.type === "ObjectExpression") for (const entry of options.properties) {
669
+ if (entry.type !== "ObjectProperty") continue;
670
+ const key = propertyKey(entry);
671
+ if (key === void 0) continue;
672
+ const value = readLiteral(entry.value);
673
+ entries[key] = value === void 0 ? { literal: false } : {
674
+ literal: true,
675
+ value
676
+ };
677
+ }
678
+ return {
679
+ importName,
680
+ packageName,
681
+ options: entries
682
+ };
683
+ });
684
+ const start = config.loc?.start.line ?? 0;
685
+ const end = config.loc?.end.line ?? Number.POSITIVE_INFINITY;
686
+ for (const { packageName } of disabled.filter((entry) => entry.line >= start && entry.line <= end)) plugins.push({
687
+ importName: importNames.get(packageName) ?? toExportName(packageName),
688
+ packageName,
689
+ options: {},
690
+ disabled: true
691
+ });
692
+ return {
693
+ name: configName(config),
694
+ plugins
695
+ };
696
+ })
697
+ };
698
+ }
699
+ /**
700
+ * The options object of a plugin call, when it was called with one.
701
+ */
702
+ function getOptions(call) {
703
+ const options = call.arguments[0];
704
+ return options?.type === "ObjectExpression" ? options : void 0;
705
+ }
706
+ /**
707
+ * The options object of a plugin call, creating an empty one when the plugin was called bare.
708
+ */
709
+ function ensureOptions(call) {
710
+ if (call.arguments.length === 0) call.arguments.push({
711
+ type: "ObjectExpression",
712
+ properties: []
713
+ });
714
+ return getOptions(call);
715
+ }
716
+ /**
717
+ * Walks `path` down to the object holding its last key, descending only through object literals.
718
+ */
719
+ function optionParent(options, path) {
720
+ let object = options;
721
+ for (const [index, key] of path.entries()) {
722
+ if (index === path.length - 1) return {
723
+ object,
724
+ key
725
+ };
726
+ if (property(object, key) === void 0) setProperty({
727
+ node: object,
728
+ key,
729
+ value: {}
360
730
  });
361
- } catch (error) {
362
- if (rejectedWith(error, 401)) throw new InvalidAgentTokenError(studioUrl, { cause: error });
363
- if (rejectedWith(error, 426)) throw new IncompatibleAgentError(studioUrl, error instanceof FetchError ? responseMessage(error.data) : void 0, { cause: error });
364
- throw registrationError(error);
731
+ const next = property(object, key);
732
+ if (next?.type !== "ObjectExpression") return { reason: `${key} is not an object, so ${path.join(".")} cannot be reached` };
733
+ object = next;
365
734
  }
735
+ return { reason: "no option path given" };
366
736
  }
367
737
  /**
368
- * First wait before `createJob` retries a busy or queue-full response, absent a `Retry-After` hint.
738
+ * Writes `value` at `path` inside a plugin call's options, creating the options object and any
739
+ * intermediate object along the path as needed. Refuses when the current value at `path` is
740
+ * something other than a literal, so an option customized in code is never overwritten.
369
741
  */
370
- const CREATE_JOB_INITIAL_DELAY_MS = 1e3;
742
+ function applySet(call, path, value) {
743
+ if (!isOptionValue(value)) return "the value is not a literal that can be written to a config file";
744
+ const options = ensureOptions(call);
745
+ if (!options) return "the plugin was not called with an object literal";
746
+ const target = optionParent(options, path);
747
+ if ("reason" in target) return target.reason;
748
+ const current = property(target.object, target.key);
749
+ if (current !== void 0 && readLiteral(current) === void 0) return `${path.join(".")} is customized in code`;
750
+ setProperty({
751
+ node: target.object,
752
+ key: target.key,
753
+ value
754
+ });
755
+ }
371
756
  /**
372
- * Slowest `createJob` backs off to between retries.
757
+ * Deletes the property at `path` inside a plugin call's options, falling the plugin back to its
758
+ * default for that option. Refuses when the value at `path` is not a literal, for the same reason
759
+ * `applySet` does.
373
760
  */
374
- const CREATE_JOB_MAX_INTERVAL_MS = 1e4;
761
+ function applyRemove(call, path) {
762
+ const options = getOptions(call);
763
+ if (!options) return "the plugin has no options to remove";
764
+ const target = optionParent(options, path);
765
+ if ("reason" in target) return target.reason;
766
+ const current = property(target.object, target.key);
767
+ if (current === void 0) return `${path.join(".")} is not set`;
768
+ if (readLiteral(current) === void 0) return `${path.join(".")} is customized in code`;
769
+ removeProperty({
770
+ node: target.object,
771
+ key: target.key
772
+ });
773
+ }
375
774
  /**
376
- * Statuses worth retrying: the agent has no free connection yet (409, a stale conflict a moment
377
- * later resolves), its queue is momentarily full (429), or it has no live connection at all yet
378
- * (503, an agent process that is mid-reconnect). Anything else (404 agent not found, 401/403 auth)
379
- * is thrown straight away, since retrying cannot change the outcome.
775
+ * Adds a `pluginX(...)` call to a config's plugins array. Replaying the same add is a no-op, while
776
+ * an import name collision with an unrelated package remains an error.
380
777
  */
381
- const CREATE_JOB_RETRYABLE_STATUSES = /* @__PURE__ */ new Set([
382
- 409,
383
- 429,
384
- 503
385
- ]);
778
+ function applyAddPlugin(mod, config, edit) {
779
+ if (!isKubbPluginSpecifier(edit.plugin)) return { reason: `"${edit.plugin}" is not a @kubb/plugin-* package` };
780
+ const importName = edit.importName ?? toExportName(edit.plugin);
781
+ if (!IDENTIFIER.test(importName)) return { reason: `"${importName}" is not a valid import name` };
782
+ if (pluginCalls(mod, config).some((plugin) => plugin.packageName === edit.plugin)) return { noop: true };
783
+ const taken = importedFrom(mod).get(importName);
784
+ if (taken && taken !== edit.plugin) return { reason: `${importName} is already imported from ${taken}` };
785
+ const options = edit.options ?? {};
786
+ if (!isOptionValue(options)) return { reason: "the options are not literals that can be written to a config file" };
787
+ const plugins = property(config, "plugins");
788
+ if (plugins?.type !== "ArrayExpression") return { reason: "plugins is not an array literal" };
789
+ const call = Object.keys(options).length ? builders.functionCall(importName, options) : builders.functionCall(importName);
790
+ plugins.elements.push(call.$ast);
791
+ return taken ? {} : { addImport: {
792
+ importName,
793
+ moduleSpecifier: edit.plugin
794
+ } };
795
+ }
386
796
  /**
387
- * Reads Studio's `Retry-After` header (seconds) off a thrown `ofetch` error, when present.
797
+ * Comments out a plugin call in place, keeping its options on disk so `enable-plugin` can restore
798
+ * them exactly. Operates on `source` text rather than the AST: a commented-out call is no longer a
799
+ * node magicast can address, and the surrounding array must not reflow when its element count
800
+ * never actually changes.
388
801
  */
389
- function retryAfterMs(error) {
390
- const seconds = Number(error.response?.headers.get("retry-after"));
391
- return Number.isFinite(seconds) && seconds > 0 ? seconds * 1e3 : void 0;
802
+ function disablePlugin(source, mod, config, plugin) {
803
+ const target = pluginCalls(mod, config).find((entry) => entry.packageName === plugin);
804
+ if (!target) return { reason: `${plugin} is not in the plugins array` };
805
+ const loc = target.call.loc;
806
+ if (!loc?.start || !loc.end) return { reason: `${plugin} has no source location to comment out` };
807
+ const lines = source.split("\n");
808
+ const from = loc.start.line - 1;
809
+ const to = loc.end.line - 1;
810
+ const firstLine = lines[from] ?? "";
811
+ const lastLine = lines[to] ?? "";
812
+ 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` };
813
+ const indent = firstLine.match(/^\s*/)?.[0] ?? "";
814
+ const commented = lines.slice(from, to + 1).map((line) => line.trim() ? `${indent}// ${line.slice(indent.length)}` : indent ? `${indent}//` : "//");
815
+ lines.splice(from, to - from + 1, `${indent}// ${DISABLED_MARKER} ${plugin} ${commented.length}`, ...commented);
816
+ return { source: lines.join("\n") };
392
817
  }
393
818
  /**
394
- * Adds up to 30% jitter, so every CI run queued behind the same busy agent does not retry in
395
- * lockstep.
819
+ * Uncomments the block a previous `disable-plugin` left behind for `plugin`.
396
820
  */
397
- function withJitter(ms) {
398
- return ms + Math.random() * ms * .3;
821
+ function enablePlugin(source, plugin) {
822
+ const lines = source.split("\n");
823
+ for (const [index, line] of lines.entries()) {
824
+ const marker = parseMarker(line);
825
+ if (marker?.plugin !== plugin) continue;
826
+ const end = index + 1 + marker.lineCount;
827
+ const restored = lines.slice(index + 1, end).map((commented) => commented.replace(/^(\s*)\/\/ ?/, "$1"));
828
+ lines.splice(index, end - index, ...restored);
829
+ return { source: lines.join("\n") };
830
+ }
831
+ return { reason: `${plugin} is not disabled` };
399
832
  }
400
833
  /**
401
- * Queues a generation or snapshot job on Studio (`POST /api/jobs`).
402
- *
403
- * Returns as soon as Studio accepts the job (`202`). Poll with {@link waitForJob} until it finishes.
404
- * Authenticates with the organization CI API key via `x-api-key`.
834
+ * Parses `source` and finds the config entries it exports.
835
+ */
836
+ function parseConfigs(source) {
837
+ let mod;
838
+ try {
839
+ mod = parseModule(source);
840
+ } catch {
841
+ return { reason: "the config file could not be parsed" };
842
+ }
843
+ const found = findConfigs(mod);
844
+ return "reason" in found ? found : {
845
+ mod,
846
+ configs: found.configs
847
+ };
848
+ }
849
+ /**
850
+ * Re-parses `source` and resolves the config entry an edit targets. Every edit re-parses rather
851
+ * than sharing one module across the batch, since the disable/enable edits rewrite `source` as
852
+ * text and would otherwise leave the others working from a stale tree.
853
+ */
854
+ function parseTarget(source, ref) {
855
+ const parsed = parseConfigs(source);
856
+ if ("reason" in parsed) return parsed;
857
+ const config = selectConfig(parsed.configs, ref);
858
+ if (!config) return { reason: `no config entry found for ${JSON.stringify(ref)}` };
859
+ return {
860
+ mod: parsed.mod,
861
+ config
862
+ };
863
+ }
864
+ /**
865
+ * Applies edits to a `kubb.config.ts` in place. Every node the edits do not touch keeps its
866
+ * original text, so comments, formatting, and hand-written code around the config survive.
405
867
  *
406
- * A busy agent, a full queue, or a momentary lack of a live connection (409, 429, 503) retries with
407
- * exponential backoff and jitter, honoring Studio's `Retry-After` header when it sends one, up to
408
- * `timeoutMs`. Every other failure, including a missing agent (404), throws immediately.
868
+ * Edits are independent: one that cannot be applied is reported in `outcomes` and the rest still run.
409
869
  *
410
- * @example Snapshot job
870
+ * @example
411
871
  * ```ts
412
- * const job = await createJob({
413
- * studioUrl: 'https://kubb.studio',
414
- * token: process.env.KUBB_TOKEN!,
415
- * type: 'snapshot',
416
- * agentId: agent.id,
417
- * name: '@kubb/demo',
418
- * version: '1.0.0',
419
- * })
420
- * const finished = await waitForJob({ studioUrl, token, id: job.id })
872
+ * const { source, outcomes } = applyConfigEdits(current, [
873
+ * { operation: 'set', plugin: '@kubb/plugin-ts', path: ['enum', 'type'], value: 'enum' },
874
+ * ])
421
875
  * ```
876
+ *
877
+ * @note recast always reprints a semicolon on a reprinted statement, so editing a block-body
878
+ * `defineConfig` in a semicolon-free file adds one to the `return` line. This is a known gap.
879
+ * Strip it when `detectCodeFormat` reports `useSemi: false`, if it turns out to matter in practice.
422
880
  */
423
- async function createJob({ studioUrl, token, type, agentId, name, version, commit, baseId, config, instanceId, timeoutMs = 6e4, signal }) {
424
- const deadline = Date.now() + timeoutMs;
425
- let interval = CREATE_JOB_INITIAL_DELAY_MS;
426
- for (;;) {
427
- signal?.throwIfAborted();
428
- try {
429
- const { job } = await ofetch(`${studioUrl}/api/jobs`, {
430
- method: "POST",
431
- headers: { "x-api-key": token },
432
- body: {
433
- type,
434
- agentId,
435
- name,
436
- version,
437
- commit,
438
- baseId,
439
- config,
440
- instanceId
441
- },
442
- retry: false,
443
- timeout: Math.max(deadline - Date.now(), 1),
444
- signal
881
+ function applyConfigEdits(source, edits) {
882
+ let current = source;
883
+ const format = detectCodeFormat(source);
884
+ const endsWithNewline = source.endsWith("\n");
885
+ const outcomes = edits.map((edit) => {
886
+ const target = parseTarget(current, edit.config);
887
+ if ("reason" in target) return {
888
+ edit,
889
+ applied: false,
890
+ reason: target.reason
891
+ };
892
+ const { mod, config } = target;
893
+ if (edit.operation === "disable-plugin" || edit.operation === "enable-plugin") {
894
+ const result = edit.operation === "disable-plugin" ? disablePlugin(current, mod, config, edit.plugin) : enablePlugin(current, edit.plugin);
895
+ if ("reason" in result) return {
896
+ edit,
897
+ applied: false,
898
+ reason: result.reason
899
+ };
900
+ current = result.source;
901
+ return {
902
+ edit,
903
+ applied: true
904
+ };
905
+ }
906
+ if (edit.operation === "add-plugin") {
907
+ const result = applyAddPlugin(mod, config, edit);
908
+ if ("reason" in result) return {
909
+ edit,
910
+ applied: false,
911
+ reason: result.reason
912
+ };
913
+ if ("noop" in result) return {
914
+ edit,
915
+ applied: true
916
+ };
917
+ const afterLine = lastImportEndLine(mod);
918
+ let next = generateCode(mod, { format }).code;
919
+ if (result.addImport) next = insertImportLine({
920
+ source: next,
921
+ afterLine,
922
+ ...result.addImport
445
923
  });
446
- return job;
447
- } catch (error) {
448
- signal?.throwIfAborted();
449
- const status = error.response?.status;
450
- if (!status || !CREATE_JOB_RETRYABLE_STATUSES.has(status) || Date.now() >= deadline) throw error;
451
- const wait = Math.min(retryAfterMs(error) ?? withJitter(interval), Math.max(deadline - Date.now(), 0));
452
- if (signal) await setTimeout$1(wait, void 0, { signal });
453
- else await new Promise((resolve) => setTimeout(resolve, wait));
454
- if (Date.now() >= deadline) throw error;
455
- interval = Math.min(interval * 2, CREATE_JOB_MAX_INTERVAL_MS);
924
+ current = withTrailingNewline(next, endsWithNewline);
925
+ return {
926
+ edit,
927
+ applied: true
928
+ };
456
929
  }
457
- }
930
+ const pluginCall = pluginCalls(mod, config).find((plugin) => plugin.packageName === edit.plugin);
931
+ if (!pluginCall) return {
932
+ edit,
933
+ applied: false,
934
+ reason: `${edit.plugin} is not in the plugins array`
935
+ };
936
+ const reason = edit.operation === "set" ? applySet(pluginCall.call, edit.path, edit.value) : applyRemove(pluginCall.call, edit.path);
937
+ if (!reason) current = withTrailingNewline(generateCode(mod, { format }).code, endsWithNewline);
938
+ return {
939
+ edit,
940
+ applied: !reason,
941
+ reason
942
+ };
943
+ });
944
+ return {
945
+ source: current,
946
+ outcomes,
947
+ changed: current !== source
948
+ };
949
+ }
950
+ async function writeConfigEdits({ filePath, edits }) {
951
+ const result = applyConfigEdits(await read(filePath), edits);
952
+ if (result.changed) await writeFile(filePath, result.source, "utf-8");
953
+ return result;
458
954
  }
459
955
  /**
460
- * A job runs a generation and packs a tarball, so it is never done the instant it is queued.
461
- */
462
- const INITIAL_POLL_DELAY_MS = 2e3;
463
- /**
464
- * Slowest the poll backs off to. Requests per run are roughly `timeoutMs` divided by this, and
465
- * every concurrent run on the same organization key draws on one budget.
956
+ * The 1-based line where the file's last import declaration ends, or `0` when it has none. Read
957
+ * off the parsed module, so a multi-line `import {\n x,\n} from '...'` reports its closing line
958
+ * rather than the `import` keyword.
466
959
  */
467
- const MAX_POLL_INTERVAL_MS = 3e4;
960
+ function lastImportEndLine(mod) {
961
+ return (mod.$ast.type === "Program" ? mod.$ast.body : []).filter((node) => node.type === "ImportDeclaration").at(-1)?.loc?.end.line ?? 0;
962
+ }
468
963
  /**
469
- * Polls `GET /api/jobs/{id}` until the job reaches a terminal status, waiting
470
- * {@link INITIAL_POLL_DELAY_MS} first and doubling up to {@link MAX_POLL_INTERVAL_MS} so a long
471
- * job stays inside the API key's rate limit.
964
+ * Writes an import after the last one already in the file, matching its quote style and whether it
965
+ * ends in a semicolon. `afterLine` is where that last import ends, `0` for a file with none.
472
966
  *
473
- * A `failed` job resolves normally. Check `job.status` and `job.error`. Throws only when the
474
- * deadline passes before Studio finishes.
475
- */
476
- async function waitForJob({ studioUrl, token, id, timeoutMs = 6e4, signal }) {
477
- const deadline = Date.now() + timeoutMs;
478
- let interval = INITIAL_POLL_DELAY_MS;
479
- for (;;) {
480
- signal?.throwIfAborted();
481
- const wait = Math.max(Math.min(interval, deadline - Date.now()), 0);
482
- if (signal) await setTimeout$1(wait, void 0, { signal });
483
- else await new Promise((resolve) => setTimeout(resolve, wait));
484
- if (Date.now() >= deadline) throw new Error("Timed out waiting for the Studio job");
485
- interval = Math.min(interval * 2, MAX_POLL_INTERVAL_MS);
486
- try {
487
- const { job } = await ofetch(`${studioUrl}/api/jobs/${id}`, {
488
- headers: { "x-api-key": token },
489
- retry: false,
490
- timeout: Math.max(deadline - Date.now(), 1),
491
- signal
492
- });
493
- if (job.status === "success" || job.status === "failed" || job.status === "canceled") return job;
494
- } catch (error) {
495
- signal?.throwIfAborted();
496
- const response = error.response;
497
- if (response?.status !== 429) throw error;
498
- const retryAfter = response._data?.data?.tryAgainIn;
499
- interval = Math.max(interval, typeof retryAfter === "number" && Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : MAX_POLL_INTERVAL_MS);
500
- }
501
- }
967
+ * Written as plain text rather than through magicast's import builder, which prints a brand-new
968
+ * import declaration with its own default spacing and a semicolon regardless of `format`, since
969
+ * that formatting only governs nodes recast can diff against the original source.
970
+ */
971
+ function insertImportLine({ source, importName, moduleSpecifier, afterLine }) {
972
+ const lines = source.split("\n");
973
+ const lastImportLine = afterLine > 0 ? lines[afterLine - 1] : void 0;
974
+ const quote = lastImportLine?.includes(`"`) ? `"` : `'`;
975
+ const line = `import { ${importName} } from ${quote}${moduleSpecifier}${quote}${lastImportLine?.trimEnd().endsWith(";") ? ";" : ""}`;
976
+ lines.splice(afterLine, 0, ...afterLine > 0 ? [line] : [line, ""]);
977
+ return lines.join("\n");
502
978
  }
503
979
  /**
504
- * Creates or reuses a CI agent (`POST /api/agents`), keyed by `(organization, machineToken)`.
505
- * Authenticates via `x-api-key`. Reusing the same `machineToken` reuses the same agent instead of
506
- * consuming a new one from the organization's agent limit.
980
+ * `generateCode` always drops the file's trailing newline. Restore it when the input had one.
507
981
  */
508
- async function createAgent({ studioUrl, token, name, machineToken }) {
509
- try {
510
- return await ofetch(`${studioUrl}/api/agents`, {
511
- method: "POST",
512
- headers: { "x-api-key": token },
513
- body: {
514
- name,
515
- machineToken
516
- }
517
- });
518
- } catch (error) {
519
- if (error instanceof FetchError) {
520
- const upgradeUrl = error.data?.data?.upgradeUrl;
521
- const detail = responseMessage(error.data) ?? getErrorMessage(error);
522
- const hint = upgradeUrl ? ` Agent limit reached; upgrade at ${upgradeUrl}.` : "";
523
- throw new Error(`Failed to create a Kubb Studio agent: ${detail}${hint}`, { cause: error });
524
- }
525
- throw error;
526
- }
982
+ function withTrailingNewline(code, hadTrailingNewline) {
983
+ if (!hadTrailingNewline || code.endsWith("\n")) return code;
984
+ return `${code}\n`;
527
985
  }
528
986
  //#endregion
529
- //#region src/operations/constants.ts
987
+ //#region src/generations.ts
988
+ const READ_CONCURRENCY = 50;
989
+ const MB = 1048576;
990
+ const INDEX_KEY = "studio/generations.json";
530
991
  /**
531
- * Hosted Kubb Studio URL. Exported so credential stores can bind tokens to the resolved instance,
532
- * not whatever default the client would pick on its own.
992
+ * The path form {@link SourceFiles} keys on: relative to `root`, with forward slashes.
533
993
  */
534
- const defaultStudioUrl = "https://kubb.studio";
994
+ function relativeStoragePath({ root, filePath }) {
995
+ return (isAbsolute(filePath) ? relative(resolve(root), filePath) : filePath).replaceAll("\\", "/");
996
+ }
535
997
  /**
536
- * Defaults the Studio client uses when a host passes nothing.
537
- * Config path is left out on purpose: each host discovers that itself.
998
+ * The content fingerprint Studio compares snapshots by. Agent runs and CI uploads must agree on it.
538
999
  */
539
- const agentDefaults = {
540
- studioUrl: defaultStudioUrl,
541
- retryIntervalMs: 3e4,
542
- heartbeatIntervalMs: 3e4,
543
- /**
544
- * Slowest heartbeat a host may ask for. Studio drops an agent from the active list once its
545
- * stored ping is older than its liveness window, and it stores a ping at most once a minute, so
546
- * a slower cadence would make a healthy agent look dead after a single missed ping.
547
- */
548
- maxHeartbeatIntervalMs: 6e4,
549
- /** How long a heartbeat ping may take before the session is treated as dead. */
550
- heartbeatTimeoutMs: 1e4,
551
- maxConcurrent: 1,
552
- maxGenerations: 8,
553
- maxGenerationsMb: 100,
554
- maxSnapshotMb: 50
555
- };
1000
+ const hashOf = (content) => createHash("sha1").update(content).digest("hex").slice(0, 16);
1001
+ const totalBytes = (entries) => entries.reduce((sum, { output, disk }) => sum + output.bytes + (disk?.bytes ?? 0), 0);
556
1002
  function positiveNumber(value) {
557
1003
  const parsed = Number(value);
558
1004
  return value && Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
@@ -562,29 +1008,49 @@ function positiveNumber(value) {
562
1008
  * `KUBB_AGENT_MAX_GENERATIONS`, `KUBB_AGENT_MAX_GENERATIONS_MB` and `KUBB_AGENT_MAX_SNAPSHOT_MB`.
563
1009
  * An unset or invalid value keeps the default.
564
1010
  */
565
- function resolveGenerationLimits(env = process.env) {
1011
+ function resolveGenerationLimits() {
566
1012
  return {
567
- maxCount: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS) ?? agentDefaults.maxGenerations)),
568
- maxMb: positiveNumber(env.KUBB_AGENT_MAX_GENERATIONS_MB) ?? agentDefaults.maxGenerationsMb,
569
- maxSnapshotMb: positiveNumber(env.KUBB_AGENT_MAX_SNAPSHOT_MB) ?? agentDefaults.maxSnapshotMb
1013
+ maxCount: Math.max(1, Math.floor(positiveNumber(process$1.env.KUBB_AGENT_MAX_GENERATIONS) ?? 8)),
1014
+ maxMb: positiveNumber(process$1.env.KUBB_AGENT_MAX_GENERATIONS_MB) ?? 100,
1015
+ maxSnapshotMb: positiveNumber(process$1.env.KUBB_AGENT_MAX_SNAPSHOT_MB) ?? 50
570
1016
  };
571
1017
  }
572
1018
  /**
573
- * An agent's capacity read from `KUBB_AGENT_MAX_CONCURRENT`. An unset or invalid value keeps the
574
- * default: one job at a time.
1019
+ * Folds error-severity diagnostics into one thrown error so logs name the failing plugin or pass.
575
1020
  */
576
- function resolveAgentCapacity(env = process.env) {
577
- return { maxConcurrent: Math.max(1, Math.floor(positiveNumber(env.KUBB_AGENT_MAX_CONCURRENT) ?? agentDefaults.maxConcurrent)) };
1021
+ function generationFailure(diagnostics) {
1022
+ const reasons = diagnostics.filter(Diagnostics.isProblem).filter((diagnostic) => diagnostic.severity === "error").map((diagnostic) => diagnostic.plugin ? `${diagnostic.plugin}: ${diagnostic.message}` : diagnostic.message);
1023
+ if (!reasons.length) return /* @__PURE__ */ new Error("Generation failed");
1024
+ return /* @__PURE__ */ new Error(`Generation failed: ${reasons.length} error${reasons.length === 1 ? "" : "s"}: ${reasons.join("; ")}`);
1025
+ }
1026
+ /**
1027
+ * Runs one generation and returns the files it produced. The formatter, the linter, and
1028
+ * `output.postGenerate` only run with `allowExec`. Throws when any plugin reports an error.
1029
+ */
1030
+ async function generate({ config, hooks, signal, allowExec }) {
1031
+ const output = allowExec ? config.output : {
1032
+ ...config.output,
1033
+ format: false,
1034
+ lint: false,
1035
+ postGenerate: []
1036
+ };
1037
+ const result = await createKubb({
1038
+ ...config,
1039
+ output
1040
+ }, {
1041
+ hooks,
1042
+ signal
1043
+ }).generate();
1044
+ if (!result.success) throw generationFailure(result.diagnostics);
1045
+ return {
1046
+ storage: config.storage,
1047
+ root: config.root,
1048
+ paths: new Set(result.files.map((file) => relativeStoragePath({
1049
+ root: config.root,
1050
+ filePath: file.path
1051
+ })))
1052
+ };
578
1053
  }
579
- //#endregion
580
- //#region package.json
581
- var version = "5.5.6";
582
- //#endregion
583
- //#region src/operations/generations.ts
584
- const READ_CONCURRENCY = 50;
585
- const MB$1 = 1048576;
586
- const INDEX_KEY = "studio/generations.json";
587
- const hashOf = (content) => createHash("sha1").update(content).digest("hex").slice(0, 16);
588
1054
  /**
589
1055
  * What the output directory holds on disk before a run. `undefined` when `outputPath` is not a real
590
1056
  * subdirectory of `root` (listing the root would take in every source file) or holds more than `maxFiles`.
@@ -627,7 +1093,7 @@ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now
627
1093
  * Copies `files` into the store as one set of `jobId`. Above `maxSetMb` only the hashes are kept.
628
1094
  */
629
1095
  async function keep({ jobId, source, files, maxSetMb }) {
630
- const maxSetBytes = maxSetMb * MB$1;
1096
+ const maxSetBytes = maxSetMb * MB;
631
1097
  const hashes = {};
632
1098
  let bytes = 0;
633
1099
  await inParallel({
@@ -663,9 +1129,6 @@ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now
663
1129
  keep,
664
1130
  drop,
665
1131
  get: async (jobId) => (await load()).find((generation) => generation.jobId === jobId && isLive(generation)),
666
- latest: async () => (await load()).filter(isLive).at(-1),
667
- /** Total bytes of every set the store holds, expired ones too until the next add drops them. */
668
- bytes: async () => (await load()).reduce((sum, { output, disk }) => sum + output.bytes + (disk?.bytes ?? 0), 0),
669
1132
  async add(generation) {
670
1133
  const current = await load();
671
1134
  for (const expired of current.filter((entry) => !isLive(entry))) await drop(expired.jobId);
@@ -674,8 +1137,7 @@ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now
674
1137
  ...generation,
675
1138
  keptAt: now()
676
1139
  });
677
- const weight = () => entries.reduce((sum, { output, disk }) => sum + output.bytes + (disk?.bytes ?? 0), 0);
678
- while (entries.length > 1 && (entries.length > maxCount || weight() > maxMb * MB$1)) await drop(entries.shift().jobId);
1140
+ while (entries.length > 1 && (entries.length > maxCount || totalBytes(entries) > maxMb * MB)) await drop(entries.shift().jobId);
679
1141
  index = entries;
680
1142
  await storage.writeItem(INDEX_KEY, JSON.stringify(entries));
681
1143
  },
@@ -713,41 +1175,12 @@ function createGenerationStore({ storage, maxCount, maxMb, ttlMs, now = Date.now
713
1175
  };
714
1176
  }
715
1177
  //#endregion
716
- //#region src/operations/generationEvents.ts
717
- const require = createRequire(import.meta.url);
718
- function relativeStoragePath(root, filePath) {
719
- return (isAbsolute(filePath) ? relative(resolve(root), filePath) : filePath).replaceAll("\\", "/");
720
- }
721
- async function resolvePeerDependencies(names) {
722
- const uniqueNames = [...new Set(names.map(toPackageName))];
723
- const peerDependencies = {};
724
- const missingDependencies = [];
725
- const versions = await Promise.all(uniqueNames.map(async (name) => {
726
- try {
727
- const path = require.resolve(`${name}/package.json`);
728
- return JSON.parse(await readFile(path, "utf8")).version;
729
- } catch {
730
- return;
731
- }
732
- }));
733
- for (const [index, name] of uniqueNames.entries()) {
734
- const version = versions[index];
735
- if (version) {
736
- peerDependencies[name] = version;
737
- continue;
738
- }
739
- missingDependencies.push(name);
740
- }
741
- return {
742
- peerDependencies,
743
- missingDependencies
744
- };
745
- }
1178
+ //#region src/generationEvents.ts
746
1179
  const MAX_QUEUED_EVENTS = 1024;
747
1180
  const RESERVED_EVENTS = 64;
748
1181
  const isDiscardable = (event) => event.type === "kubb:files:processing:update" || event.type === "kubb:info" || event.type === "kubb:success";
749
1182
  /** Forwards selected Kubb lifecycle events to a native Cap'n Web stream. */
750
- function createGenerationStream(hooks, jobId, options = {}) {
1183
+ function createGenerationStream(hooks, jobId) {
751
1184
  const unhooks = [];
752
1185
  let root = "";
753
1186
  let controller;
@@ -784,129 +1217,92 @@ function createGenerationStream(hooks, jobId, options = {}) {
784
1217
  }
785
1218
  controller.enqueue(event);
786
1219
  }
787
- on("kubb:plugin:start", (ctx) => {
788
- emitEvent("kubb:plugin:start", [{ plugin: { name: ctx.plugin.name } }]);
789
- });
790
- on("kubb:plugin:end", (ctx) => {
791
- emitEvent("kubb:plugin:end", [{
792
- plugin: { name: ctx.plugin.name },
793
- duration: ctx.duration,
794
- success: ctx.success
795
- }]);
796
- });
797
- on("kubb:build:start", ({ config, adapter }) => {
798
- root = config.root;
799
- emitEvent("kubb:build:start", [{
1220
+ const projections = {
1221
+ "kubb:plugin:start": ({ plugin }) => [{ plugin: { name: plugin.name } }],
1222
+ "kubb:plugin:end": ({ plugin, duration, success }) => [{
1223
+ plugin: { name: plugin.name },
1224
+ duration,
1225
+ success
1226
+ }],
1227
+ "kubb:build:start": ({ config, adapter }) => [{
800
1228
  config: { name: config.name },
801
1229
  adapter: { name: adapter.name }
802
- }]);
803
- });
804
- on("kubb:build:end", ({ files, config, outputDir }) => {
805
- emitEvent("kubb:build:end", [{
1230
+ }],
1231
+ "kubb:build:end": ({ files, config, outputDir }) => [{
806
1232
  files: files.map((file) => ({
807
- path: relativeStoragePath(config.root, file.path),
1233
+ path: relativeStoragePath({
1234
+ root: config.root,
1235
+ filePath: file.path
1236
+ }),
808
1237
  name: file.name
809
1238
  })),
810
1239
  outputDir
811
- }]);
812
- });
813
- on("kubb:files:processing:start", ({ files }) => {
814
- emitEvent("kubb:files:processing:start", [{ total: files.length }]);
815
- });
816
- on("kubb:files:processing:update", ({ files }) => {
817
- emitEvent("kubb:files:processing:update", [{ files: files.map(({ file, processed, total, percentage }) => ({
818
- file: relativeStoragePath(root, file.path),
1240
+ }],
1241
+ "kubb:files:processing:start": ({ files }) => [{ total: files.length }],
1242
+ "kubb:files:processing:update": ({ files }) => [{ files: files.map(({ file, processed, total, percentage }) => ({
1243
+ file: relativeStoragePath({
1244
+ root,
1245
+ filePath: file.path
1246
+ }),
819
1247
  processed,
820
1248
  total,
821
1249
  percentage
822
- })) }]);
823
- });
824
- on("kubb:files:processing:end", ({ files }) => {
825
- emitEvent("kubb:files:processing:end", [{ total: files.length }]);
826
- });
827
- for (const type of [
828
- "kubb:info",
829
- "kubb:success",
830
- "kubb:warn"
831
- ]) on(type, ({ message, info }) => {
832
- emitEvent(type, [{
833
- message,
1250
+ })) }],
1251
+ "kubb:files:processing:end": ({ files }) => [{ total: files.length }],
1252
+ "kubb:info": ({ message, info }) => [{
1253
+ message: stripVTControlCharacters(message),
834
1254
  info
835
- }]);
836
- });
837
- on("kubb:generation:start", ({ config }) => {
838
- emitEvent("kubb:generation:start", [{
839
- name: config.name,
840
- plugins: config.plugins.length
841
- }]);
842
- });
843
- on("kubb:generation:end", async ({ config, storage, diagnostics = [], status, hrStart, filesCreated }) => {
844
- const { peerDependencies, missingDependencies } = await resolvePeerDependencies(config.plugins.map(({ name }) => name));
845
- const keys = await storage.readKeys();
846
- const paths = new Set(keys.map((key) => relativeStoragePath(config.root, key)));
847
- options.onGenerationEnd?.({
848
- output: {
849
- storage,
850
- root: config.root,
851
- paths
852
- },
853
- peerDependencies,
854
- missingDependencies
855
- });
856
- emitEvent("kubb:generation:end", []);
857
- if (!hrStart) return;
858
- emitEvent("kubb:generation:summary", [{
859
- duration: Math.round(getElapsedMs(hrStart)),
860
- fileCount: filesCreated ?? 0,
861
- failedPlugins: Diagnostics.failedPlugins(diagnostics).length,
862
- status: status ?? "success"
863
- }]);
864
- });
865
- on("kubb:error", ({ error }) => {
866
- emitEvent("kubb:error", [{
1255
+ }],
1256
+ "kubb:success": ({ message, info }) => [{
1257
+ message: stripVTControlCharacters(message),
1258
+ info
1259
+ }],
1260
+ "kubb:warn": ({ message, info }) => [{
1261
+ message: stripVTControlCharacters(message),
1262
+ info
1263
+ }],
1264
+ "kubb:error": ({ error }) => [{
867
1265
  message: error.message,
868
1266
  stack: error.stack
869
- }]);
870
- });
871
- on("kubb:diagnostic", ({ diagnostic }) => {
872
- const cause = "cause" in diagnostic ? diagnostic.cause : void 0;
873
- emitEvent("kubb:diagnostic", [{
1267
+ }],
1268
+ "kubb:diagnostic": ({ diagnostic }) => [{
874
1269
  code: diagnostic.code,
875
1270
  message: diagnostic.message,
876
1271
  severity: diagnostic.severity,
877
1272
  location: "location" in diagnostic ? diagnostic.location : void 0,
878
1273
  help: "help" in diagnostic ? diagnostic.help : void 0,
879
1274
  plugin: "plugin" in diagnostic ? diagnostic.plugin : void 0,
880
- stack: cause?.stack
881
- }]);
882
- });
883
- for (const type of [
884
- "kubb:lifecycle:start",
885
- "kubb:lifecycle:end",
886
- "kubb:format:start",
887
- "kubb:format:end",
888
- "kubb:lint:start",
889
- "kubb:lint:end",
890
- "kubb:hooks:start",
891
- "kubb:hooks:end"
892
- ]) on(type, () => {
893
- emitEvent(type, []);
894
- });
895
- on("kubb:hook:start", ({ id, command, args }) => {
896
- emitEvent("kubb:hook:start", [{
1275
+ stack: "cause" in diagnostic ? diagnostic.cause?.stack : void 0
1276
+ }],
1277
+ "kubb:generation:start": ({ config }) => [{
1278
+ name: config.name,
1279
+ plugins: config.plugins.length
1280
+ }],
1281
+ "kubb:generation:end": () => [],
1282
+ "kubb:generation:summary": ({ duration, fileCount, failedPlugins, status }) => [{
1283
+ duration,
1284
+ fileCount,
1285
+ failedPlugins,
1286
+ status
1287
+ }],
1288
+ "kubb:lifecycle:start": () => [],
1289
+ "kubb:lifecycle:end": () => [],
1290
+ "kubb:format:start": () => [],
1291
+ "kubb:format:end": () => [],
1292
+ "kubb:lint:start": () => [],
1293
+ "kubb:lint:end": () => [],
1294
+ "kubb:hooks:start": () => [],
1295
+ "kubb:hooks:end": () => [],
1296
+ "kubb:hook:start": ({ id, command, args }) => [{
897
1297
  id,
898
1298
  command,
899
1299
  args: args ? [...args] : void 0
900
- }]);
901
- });
902
- on("kubb:hook:line", ({ id, line }) => {
903
- emitEvent("kubb:hook:line", [{
1300
+ }],
1301
+ "kubb:hook:line": ({ id, line }) => [{
904
1302
  id,
905
1303
  line
906
- }]);
907
- });
908
- on("kubb:hook:end", ({ id, command, args, success, error }) => {
909
- emitEvent("kubb:hook:end", [{
1304
+ }],
1305
+ "kubb:hook:end": ({ id, command, args, success, error }) => [{
910
1306
  id,
911
1307
  command,
912
1308
  args: args ? [...args] : void 0,
@@ -915,6 +1311,22 @@ function createGenerationStream(hooks, jobId, options = {}) {
915
1311
  message: error.message,
916
1312
  stack: error.stack
917
1313
  } : void 0
1314
+ }]
1315
+ };
1316
+ function forward(type) {
1317
+ on(type, (...args) => emitEvent(type, projections[type](...args)));
1318
+ }
1319
+ on("kubb:build:start", ({ config }) => {
1320
+ root = config.root;
1321
+ });
1322
+ for (const type of generationEventTypes) forward(type);
1323
+ on("kubb:generation:end", ({ diagnostics = [], status, hrStart, filesCreated }) => {
1324
+ if (!hrStart) return;
1325
+ emitEvent("kubb:generation:summary", [{
1326
+ duration: Math.round(getElapsedMs(hrStart)),
1327
+ fileCount: filesCreated ?? 0,
1328
+ failedPlugins: Diagnostics.failedPlugins(diagnostics).length,
1329
+ status: status ?? "success"
918
1330
  }]);
919
1331
  });
920
1332
  /**
@@ -944,638 +1356,151 @@ function createGenerationStream(hooks, jobId, options = {}) {
944
1356
  };
945
1357
  }
946
1358
  //#endregion
947
- //#region src/operations/websocket.ts
948
- const CONNECT_TIMEOUT_MS = 5e3;
1359
+ //#region src/url.ts
949
1360
  /**
950
- * Opens a Studio WebSocket connection and closes it when the initial handshake exceeds the configured timeout.
1361
+ * Hosted Kubb Studio URL, used when a host passes none.
951
1362
  */
952
- function createWebsocket(url, options) {
953
- const ws = new WebSocket(url, options);
954
- const timer = setTimeout(() => {
955
- if (ws.readyState === WebSocket.CONNECTING) ws.close(3008, "Connection timeout");
956
- }, CONNECT_TIMEOUT_MS);
957
- ws.once("open", () => clearTimeout(timer));
958
- ws.once("close", () => clearTimeout(timer));
959
- return ws;
1363
+ const defaultStudioUrl = "https://kubb.studio";
1364
+ function isLoopbackHost(hostname) {
1365
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]";
960
1366
  }
961
- //#endregion
962
- //#region src/operations/rpc.ts
963
- /**
964
- * The only methods Studio may call on an agent. A `StudioSession` carries far more than
965
- * {@link AgentApi}, so it is wrapped rather than exposed: what Cap'n Web can reach is exactly what
966
- * this class re-declares.
967
- */
968
- var AgentRpcTarget = class extends RpcTarget {
969
- api;
970
- constructor(api) {
971
- super();
972
- this.api = api;
973
- }
974
- connect() {
975
- return this.api.connect();
976
- }
977
- startGeneration(input) {
978
- return this.api.startGeneration(input);
979
- }
980
- saveConfig(input) {
981
- return this.api.saveConfig(input);
982
- }
983
- publishSnapshot(input) {
984
- return this.api.publishSnapshot(input);
985
- }
986
- readFiles(input) {
987
- return this.api.readFiles(input);
988
- }
989
- cancel(jobId) {
990
- return this.api.cancel(jobId);
991
- }
992
- };
993
1367
  /**
994
- * Opens an authenticated Cap'n Web session to Studio over a WebSocket. Rejects an unencrypted URL
995
- * before opening the socket, so a bearer token never reaches a plaintext host.
1368
+ * Appends `path` to the Studio URL and refuses plaintext to anything but this machine, so a token
1369
+ * or API key never crosses the network unencrypted. `socket` switches the scheme to `ws:`/`wss:`.
996
1370
  *
997
1371
  * @example
998
1372
  * ```ts
999
- * const rpc = await connectWebSocketRpc({ url: 'wss://studio.kubb.dev/s/1', token, local: session })
1000
- * await rpc.studio.ping()
1373
+ * secureUrl({ studioUrl: 'https://kubb.studio', path: '/api/agent/socket', socket: true }).href // 'wss://kubb.studio/api/agent/socket'
1001
1374
  * ```
1002
1375
  */
1003
- const connectWebSocketRpc = async ({ url, token, instanceId, local }) => {
1004
- const { protocol, hostname, host } = new URL(url);
1005
- if (protocol !== "wss:" && !(protocol === "ws:" && (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"))) throw new Error(`Refusing unencrypted WebSocket to ${host}`);
1006
- const socket = createWebsocket(url, { headers: {
1007
- Authorization: `Bearer ${token}`,
1008
- [AGENT_INSTANCE_HEADER]: instanceId
1009
- } });
1010
- const closed = new Promise((resolve) => socket.once("close", (code, reason) => resolve({
1011
- code,
1012
- reason: reason.toString()
1013
- })));
1014
- const studio = newWebSocketRpcSession(socket, new AgentRpcTarget(local));
1015
- studio.onRpcBroken(() => socket.close());
1016
- return {
1017
- studio,
1018
- closed,
1019
- close: () => studio[Symbol.dispose]()
1020
- };
1021
- };
1022
- //#endregion
1023
- //#region src/operations/hooks.ts
1024
- /**
1025
- * Register a `kubb:hook:start` listener that spawns the requested command via tinyexec,
1026
- * streams each stdout line as a `kubb:hook:line` event, and calls `kubb:hook:end` with the result.
1027
- * A failure travels on `kubb:hook:end` only: the caller waiting on it reports `kubb:error` once.
1028
- * Streaming the output lets Kubb Studio render live hook progress over the WebSocket connection.
1029
- *
1030
- * Returns a remover, so a session that runs one generation after another on the same emitter does
1031
- * not stack a listener per run.
1032
- */
1033
- function setupHookListener(hooks, root, signal) {
1034
- return hooks.hook("kubb:hook:start", async (ctx) => {
1035
- const { id, command, args } = ctx;
1036
- if (!id) return;
1037
- const commandWithArgs = args?.length ? `${command} ${args.join(" ")}` : command;
1038
- try {
1039
- const proc = x(command, [...args ?? []], {
1040
- signal,
1041
- nodeOptions: {
1042
- cwd: root,
1043
- detached: true
1044
- }
1045
- });
1046
- for await (const line of proc) await hooks.callHook("kubb:hook:line", {
1047
- id,
1048
- line
1049
- });
1050
- const { exitCode } = await proc;
1051
- if (exitCode !== 0) {
1052
- const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
1053
- await hooks.callHook("kubb:hook:end", {
1054
- id,
1055
- command,
1056
- args,
1057
- success: false,
1058
- error
1059
- });
1060
- return;
1061
- }
1062
- await hooks.callHook("kubb:hook:end", {
1063
- id,
1064
- command,
1065
- args,
1066
- success: true,
1067
- error: null
1068
- });
1069
- } catch (caughtError) {
1070
- const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
1071
- error.cause = caughtError;
1072
- await hooks.callHook("kubb:hook:end", {
1073
- id,
1074
- command,
1075
- args,
1076
- success: false,
1077
- error
1078
- });
1079
- }
1080
- });
1081
- }
1082
- /**
1083
- * Waits for the `kubb:hook:end` matching `hookId`. Register this before calling `kubb:hook:start`:
1084
- * `callHook` awaits its listeners, and {@link setupHookListener} calls `kubb:hook:end` from inside
1085
- * that same listener, so a handler added afterward would already have missed it.
1086
- */
1087
- function waitForHookEnd(hooks, hookId) {
1088
- return new Promise((resolve, reject) => {
1089
- const handleHookEnd = (ctx) => {
1090
- if (ctx.id !== hookId) return;
1091
- hooks.removeHook("kubb:hook:end", handleHookEnd);
1092
- if (ctx.success) {
1093
- resolve();
1094
- return;
1095
- }
1096
- reject(ctx.error);
1097
- };
1098
- hooks.hook("kubb:hook:end", handleHookEnd);
1099
- });
1376
+ function secureUrl({ studioUrl, path, socket = false }) {
1377
+ const url = new URL(`${studioUrl.replace(/\/+$/, "")}${path}`);
1378
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHost(url.hostname))) throw new Error(`Refusing to reach Kubb Studio at ${url.origin}: use an https:// URL, or a loopback host for local development`);
1379
+ if (socket) url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
1380
+ return url;
1100
1381
  }
1101
1382
  //#endregion
1102
- //#region src/operations/generate.ts
1383
+ //#region src/connect.ts
1384
+ const HEARTBEAT_INTERVAL_MS = 3e4;
1385
+ const MAX_HEARTBEAT_INTERVAL_MS = 6e4;
1386
+ const PING_TIMEOUT_MS = 1e4;
1387
+ const OPEN_TIMEOUT_MS = 5e3;
1388
+ const MAX_RETRY_DELAY_MS = 3e4;
1103
1389
  const DISK_SNAPSHOT_MAX_FILES = 1e4;
1104
- /**
1105
- * `isToolAvailable` spawns a process, and a long-lived connection generates repeatedly, so each
1106
- * executable is probed once per process. The CLI deliberately does not memoize: a `--watch` build
1107
- * should keep noticing a tool installed mid-session.
1108
- */
1109
- const detectTool = memoize(/* @__PURE__ */ new Map(), detectTool$1);
1110
- /**
1111
- * The two post-build tool steps. Formatting and linting differ only in which tools they look for,
1112
- * so they run through one loop rather than two near-identical blocks.
1113
- *
1114
- * `noun` and `verbing` are spelled out instead of built from `kind`. Concatenating `` `${kind}ter` ``
1115
- * and `` `${kind}ting` `` works for `format`, but doubles the `t` in `lint`, giving "lintter" and
1116
- * "lintting" instead of "linter" and "linting".
1117
- */
1118
- const TOOL_STEPS = [{
1119
- kind: "format",
1120
- noun: "formatter",
1121
- verbing: "Formatting",
1122
- tools: formatters,
1123
- detect: FORMATTER_PREFERENCE
1124
- }, {
1125
- kind: "lint",
1126
- noun: "linter",
1127
- verbing: "Linting",
1128
- tools: linters,
1129
- detect: LINTER_PREFERENCE
1130
- }];
1131
- /**
1132
- * Absolute path of the directory the formatter and linter are pointed at.
1133
- */
1134
- function outputPath(config) {
1135
- return path.isAbsolute(config.output.path) ? config.output.path : path.resolve(process$1.cwd(), config.root, config.output.path);
1136
- }
1137
- /**
1138
- * Emits `kubb:hook:start` and waits for the matching `kubb:hook:end`. The host spawns the process:
1139
- * this only describes what to run and when it finished.
1140
- *
1141
- * @throws whatever the command failed with, so callers can report it their own way.
1142
- */
1143
- async function runHook({ hooks, id, command, args }) {
1144
- const hookId = hash("sha256", id);
1145
- const hookEnd = waitForHookEnd(hooks, hookId);
1146
- await hooks.callHook("kubb:hook:start", {
1147
- id: hookId,
1148
- command,
1149
- args: [...args]
1150
- });
1151
- await hookEnd;
1152
- }
1153
- function isProblemErrorDiagnostic(diagnostic) {
1154
- return (diagnostic.kind ?? "problem") === "problem" && diagnostic.severity === "error";
1155
- }
1156
- /**
1157
- * Folds error-severity diagnostics into one thrown error so logs name the failing plugin.
1158
- */
1159
- function formatGenerationFailure(diagnostics) {
1160
- const reasons = diagnostics.filter(isProblemErrorDiagnostic).map((diagnostic) => diagnostic.plugin ? `${diagnostic.plugin}: ${diagnostic.message}` : diagnostic.message);
1161
- if (!reasons.length) return /* @__PURE__ */ new Error("Generation failed");
1162
- return /* @__PURE__ */ new Error(`Generation failed: ${reasons.length} error${reasons.length === 1 ? "" : "s"}: ${reasons.join("; ")}`);
1163
- }
1164
- /**
1165
- * Runs a full Kubb code-generation cycle for the given config.
1166
- *
1167
- * Emits lifecycle events on the provided `hooks` emitter so callers (e.g. the WebSocket stream)
1168
- * can forward progress to connected clients. After a successful build, auto-formatting and
1169
- * linting are applied when configured, followed by any user-defined `hooks.done` commands.
1170
- */
1171
- async function generate({ config, hooks, signal }) {
1172
- signal?.throwIfAborted();
1173
- const hrStart = process$1.hrtime();
1174
- await hooks.callHook("kubb:generation:start", { config });
1175
- await hooks.callHook("kubb:info", { message: config.name ? `Setup generation ${config.name}` : "Setup generation" });
1176
- const kubb = createKubb(config, {
1177
- hooks,
1178
- signal
1179
- });
1180
- await kubb.setup();
1181
- await hooks.callHook("kubb:info", { message: config.name ? `Build generation ${config.name}` : "Build generation" });
1182
- const { files, diagnostics, storage } = await kubb.safeBuild();
1183
- signal?.throwIfAborted();
1184
- await hooks.callHook("kubb:info", { message: "Load summary" });
1185
- for (const diagnostic of diagnostics.filter(isProblemErrorDiagnostic)) await hooks.callHook("kubb:error", { error: new Error(diagnostic.plugin ? `${diagnostic.plugin}: ${diagnostic.message}` : diagnostic.message) });
1186
- const status = Diagnostics.hasError(diagnostics) ? "failed" : "success";
1187
- await hooks.callHook("kubb:generation:end", {
1188
- config,
1189
- storage: {
1190
- ...storage,
1191
- readKeys: async () => [...new Set(files.map((file) => file.path))]
1192
- },
1193
- diagnostics,
1194
- status,
1195
- hrStart,
1196
- filesCreated: files.length
1197
- });
1198
- if (status === "failed") throw formatGenerationFailure(diagnostics);
1199
- await hooks.callHook("kubb:success", { message: "Generation successfully" });
1200
- for (const step of TOOL_STEPS) {
1201
- const setting = config.output[step.kind];
1202
- if (!setting) continue;
1203
- await hooks.callHook(`kubb:${step.kind}:start`);
1204
- const tool = setting === "auto" ? await detectTool(step.detect) : setting;
1205
- if (!tool) await hooks.callHook("kubb:warn", { message: `No ${step.noun} found (${step.detect.join(", ")}). Skipping ${step.verbing.toLowerCase()}.` });
1206
- if (tool && setting === "auto") await hooks.callHook("kubb:info", { message: `Auto-detected ${step.noun}: ${styleText("dim", tool)}` });
1207
- const command = tool ? step.tools[tool] : void 0;
1208
- if (command) try {
1209
- await runHook({
1210
- hooks,
1211
- id: [config.name, tool].filter(Boolean).join("-"),
1212
- command: command.command,
1213
- args: command.args(outputPath(config))
1214
- });
1215
- await hooks.callHook("kubb:success", { message: `${step.verbing} with ${tool} successfully` });
1216
- } catch (caughtError) {
1217
- await hooks.callHook("kubb:error", { error: new Error(command.errorMessage, { cause: caughtError }) });
1218
- signal?.throwIfAborted();
1219
- }
1220
- await hooks.callHook(`kubb:${step.kind}:end`);
1221
- }
1222
- if (config.output.postGenerate?.length) {
1223
- await hooks.callHook("kubb:hooks:start");
1224
- for (const entry of config.output.postGenerate) {
1225
- const line = typeof entry === "string" ? entry : entry.command;
1226
- const [cmd, ...args] = tokenize(line);
1227
- if (!cmd) continue;
1228
- try {
1229
- await runHook({
1230
- hooks,
1231
- id: line,
1232
- command: cmd,
1233
- args
1234
- });
1235
- } catch (error) {
1236
- await hooks.callHook("kubb:error", { error: toError(error) });
1237
- throw error;
1238
- }
1239
- await hooks.callHook("kubb:success", { message: `${line} successfully executed` });
1240
- }
1241
- await hooks.callHook("kubb:hooks:end");
1242
- }
1243
- }
1244
- async function runGenerationOperation({ config, hooks, signal, jobId, store, snapshotRoot, maxSnapshotMb }) {
1245
- const diskFiles = snapshotRoot ? await listDisk({
1246
- root: snapshotRoot,
1247
- outputPath: config.output.path,
1248
- maxFiles: DISK_SNAPSHOT_MAX_FILES
1249
- }) : void 0;
1250
- const disk = diskFiles ? await store.keep({
1251
- jobId,
1252
- source: "disk",
1253
- files: diskFiles,
1254
- maxSetMb: maxSnapshotMb
1255
- }) : void 0;
1256
- const removeHookListener = setupHookListener(hooks, config.root, signal);
1257
- try {
1258
- await generate({
1259
- config,
1260
- hooks,
1261
- signal
1262
- });
1263
- } catch (error) {
1264
- await store.drop(jobId);
1265
- throw error;
1266
- } finally {
1267
- removeHookListener();
1268
- }
1269
- return disk;
1270
- }
1271
- //#endregion
1272
- //#region src/runtime/StudioSession.ts
1273
- /** A sandbox shares one in-memory generation store across tenants, so old generations expire. */
1274
1390
  const SANDBOX_GENERATION_TTL_MS = 9e5;
1275
1391
  /**
1276
- * A fresh root for one sandbox job. Kubb keys its output manifest cache by root, so tenants that
1277
- * shared the agent's own root would read each other's manifest.
1278
- */
1279
- function createJobRoot() {
1280
- return mkdtemp(path.join(tmpdir(), "kubb-job-"));
1281
- }
1282
- /**
1283
- * Removes a job root and the manifest cache Kubb derived from it. Best effort: a leftover temp
1284
- * directory must not fail a job that already finished.
1392
+ * Studio closed with {@link AgentCloseCode.UNAUTHORIZED}. Hosts forget the token and pair again.
1285
1393
  */
1286
- async function removeJobRoot(jobRoot) {
1287
- await Promise.all([rm(jobRoot, {
1288
- recursive: true,
1289
- force: true
1290
- }), rm(resolveCacheDir(jobRoot), {
1291
- recursive: true,
1292
- force: true
1293
- })]).catch(() => {});
1294
- }
1295
- var GenerationRunTarget = class extends RpcTarget {
1296
- generationStream;
1297
- generationResult;
1298
- cancelGeneration;
1299
- stopGeneration;
1300
- constructor(generationStream, generationResult, cancelGeneration, stopGeneration) {
1301
- super();
1302
- this.generationStream = generationStream;
1303
- this.generationResult = generationResult;
1304
- this.cancelGeneration = cancelGeneration;
1305
- this.stopGeneration = stopGeneration;
1306
- }
1307
- async events() {
1308
- return this.generationStream;
1309
- }
1310
- result() {
1311
- return this.generationResult;
1312
- }
1313
- cancel() {
1314
- return this.cancelGeneration();
1315
- }
1316
- [Symbol.dispose]() {
1317
- this.stopGeneration();
1394
+ var InvalidAgentTokenError = class extends Error {
1395
+ constructor(studioUrl) {
1396
+ super(`Kubb Studio rejected this agent's token. It expired or was revoked, or the agent was deleted in ${studioUrl}.`);
1397
+ this.name = "InvalidAgentTokenError";
1318
1398
  }
1319
1399
  };
1320
- /**
1321
- * Fills in a host's options: the hosted Studio URL, the current working directory, and every
1322
- * permission off unless granted. Idempotent, so a reconnect can pass an already-resolved bag
1323
- * back in.
1324
- */
1325
- function applyStudioDefaults(options) {
1326
- const root = options.root ?? process$1.cwd();
1327
- return {
1328
- ...options,
1329
- studioUrl: options.studioUrl ?? agentDefaults.studioUrl,
1330
- root,
1331
- configFile: path.resolve(root, options.configPath),
1332
- permissions: {
1333
- allowWrite: false,
1334
- allowConfigEdit: false,
1335
- allowExec: false,
1336
- allowRead: false,
1337
- ...options.permissions
1338
- },
1339
- retryInterval: options.retryInterval ?? agentDefaults.retryIntervalMs,
1340
- heartbeatInterval: Math.min(options.heartbeatInterval ?? agentDefaults.heartbeatIntervalMs, agentDefaults.maxHeartbeatIntervalMs),
1341
- capacity: {
1342
- ...resolveAgentCapacity(),
1343
- ...options.capacity
1344
- },
1345
- instanceId: options.instanceId ?? randomUUID()
1346
- };
1347
- }
1348
- /**
1349
- * Jobs one agent process can run at once today. Two runs would share this session's hook emitter,
1350
- * and with it each other's events, so the connection keeps this limit.
1351
- */
1352
- const RUNTIME_MAX_CONCURRENT = 1;
1353
- const MB = 1048576;
1354
- function rssMb() {
1355
- return process$1.memoryUsage().rss / MB;
1356
- }
1357
- /**
1358
- * Reads what Studio meant by closing the connection. A code Studio did not send on purpose is an
1359
- * ordinary drop, and the agent reconnects as it always has.
1360
- */
1361
- function planEnd(close) {
1362
- const code = close?.code;
1363
- if (code === AgentCloseCode.REAUTHENTICATE) return {
1364
- reason: "Kubb Studio asked the agent to register again",
1365
- retry: true
1366
- };
1367
- if (code === AgentCloseCode.SUPERSEDED) return {
1368
- reason: "another instance of this agent took over",
1369
- retry: false
1370
- };
1371
- if (code === AgentCloseCode.INCOMPATIBLE) return {
1372
- reason: "this agent is too old for Kubb Studio, or was deleted",
1373
- retry: false,
1374
- error: /* @__PURE__ */ new Error("Kubb Studio closed the connection: this agent is too old for it, or was deleted. Upgrade the agent, or pair it again.")
1375
- };
1376
- return {
1377
- reason: "connection closed",
1378
- retry: true
1379
- };
1400
+ /**
1401
+ * Studio closed with {@link AgentCloseCode.INCOMPATIBLE}. Only an upgrade helps.
1402
+ */
1403
+ var IncompatibleAgentError = class extends Error {
1404
+ constructor({ studioUrl, detail }) {
1405
+ super(`Kubb Studio at ${studioUrl} requires a newer agent${detail ? `: ${detail}` : ""}. Upgrade kubb or the Kubb agent image.`);
1406
+ this.name = "IncompatibleAgentError";
1407
+ }
1408
+ };
1409
+ function emit(hooks, name, ...args) {
1410
+ return Promise.resolve(hooks.callHook(name, ...args)).catch(() => {});
1380
1411
  }
1381
1412
  function isValidPage({ cursor, limit }) {
1382
1413
  if (limit === void 0) return cursor === void 0;
1383
1414
  if (cursor !== void 0 && (!Number.isSafeInteger(cursor) || cursor < 0)) return false;
1384
1415
  return Number.isSafeInteger(limit) && limit >= 1 && limit <= 2e3;
1385
1416
  }
1386
- /**
1387
- * One agent-to-Studio RPC transport: opening it, keeping it alive, and serving remote methods.
1388
- * `runConnection` owns the lifetime and retry of each attempt.
1389
- */
1390
- var StudioSession = class {
1417
+ var GenerationRunTarget = class extends RpcTarget {
1418
+ #run;
1419
+ constructor(run) {
1420
+ super();
1421
+ this.#run = run;
1422
+ }
1423
+ async events() {
1424
+ return this.#run.stream;
1425
+ }
1426
+ result() {
1427
+ return this.#run.result;
1428
+ }
1429
+ async cancel() {
1430
+ this.#run.stop();
1431
+ }
1432
+ [Symbol.dispose]() {
1433
+ this.#run.stop();
1434
+ }
1435
+ };
1436
+ var Agent = class extends RpcTarget {
1391
1437
  #options;
1392
- #hooks = new Hookable();
1393
- /**
1394
- * Removers for every listener this session added (socket, shutdown signal, hooks) so `dispose`
1395
- * detaches them in one pass. Listeners a host attached itself through `installLogger` survive.
1396
- */
1397
- #unhooks = [];
1398
- /**
1399
- * What registration handed back, and the marker for whether the agent registered at all.
1400
- * Before it resolves there is no sandbox flag to read.
1401
- */
1402
- #registration;
1403
- #rpc;
1404
- #studioVersion;
1405
- #disposed = false;
1406
- #isGenerating = false;
1407
- #activeJob;
1408
- #heartbeatTimer;
1409
- #lastGeneration;
1410
- #store;
1438
+ #session;
1411
1439
  #limits = resolveGenerationLimits();
1412
- /**
1413
- * Resolves when Studio calls {@link StudioSession.connect}. `studio:ready` waits on this so the
1414
- * host does not queue jobs before the agent session is registered.
1415
- */
1416
- #connectAck = Promise.withResolvers();
1417
- #ended = Promise.withResolvers();
1418
- closed = this.#ended.promise;
1419
- #generationResult;
1420
- constructor(options) {
1421
- this.#options = applyStudioDefaults(options);
1422
- this.#connectAck.promise.catch(() => {});
1440
+ #store;
1441
+ constructor({ options, session }) {
1442
+ super();
1443
+ this.#options = options;
1444
+ this.#session = session;
1423
1445
  }
1424
- /**
1425
- * A sandbox agent runs on Studio's own infrastructure, so it has no user project to touch.
1426
- */
1427
- get #isSandbox() {
1428
- return this.#registration?.isSandbox === true;
1446
+ get #sandbox() {
1447
+ return this.#session.info?.sandbox === true;
1448
+ }
1449
+ get #permissions() {
1450
+ const { permissions } = this.#options;
1451
+ return this.#sandbox ? {
1452
+ ...permissions,
1453
+ allowWrite: false,
1454
+ allowConfigEdit: false,
1455
+ allowRead: true
1456
+ } : permissions;
1429
1457
  }
1430
- /**
1431
- * Kept in the project's cache directory, so it survives a restart, except on a sandbox: its pool
1432
- * sessions run every tenant's jobs, so it keeps them in memory. Looked up by job id only.
1433
- */
1434
1458
  get #generations() {
1435
1459
  this.#store ??= createGenerationStore({
1436
- storage: this.#isSandbox ? memoryStorage() : cacheStorage({ root: this.#options.root }),
1460
+ storage: this.#sandbox ? memoryStorage() : cacheStorage({ root: this.#options.root }),
1437
1461
  maxCount: this.#limits.maxCount,
1438
1462
  maxMb: this.#limits.maxMb,
1439
- ttlMs: this.#isSandbox ? SANDBOX_GENERATION_TTL_MS : void 0
1463
+ ttlMs: this.#sandbox ? SANDBOX_GENERATION_TTL_MS : void 0
1440
1464
  });
1441
1465
  return this.#store;
1442
1466
  }
1443
- get #canWrite() {
1444
- return !this.#isSandbox && this.#options.permissions.allowWrite;
1445
- }
1446
- get #canEditConfig() {
1447
- return !this.#isSandbox && this.#options.permissions.allowConfigEdit;
1448
- }
1449
- /**
1450
- * A sandbox agent always allows reading its output back; a local agent only when opted in.
1451
- */
1452
- get #canRead() {
1453
- return this.#isSandbox || this.#options.permissions.allowRead;
1454
- }
1455
- async start() {
1456
- const { token, studioUrl, signal, heartbeatInterval, installLogger, instanceId, capacity } = this.#options;
1457
- await installLogger?.(this.#hooks);
1458
- signal?.addEventListener("abort", this.#onAbort, { once: true });
1459
- this.#unhooks.push(() => signal?.removeEventListener("abort", this.#onAbort));
1460
- try {
1461
- signal?.throwIfAborted();
1462
- await this.#hooks.callHook("studio:connecting", { url: studioUrl });
1463
- if (capacity.maxConcurrent > RUNTIME_MAX_CONCURRENT) await this.#warn(`Running ${RUNTIME_MAX_CONCURRENT} job at a time: KUBB_AGENT_MAX_CONCURRENT=${capacity.maxConcurrent} needs per-job workers, which this agent does not have yet`);
1464
- const registration = await registerAgent({
1465
- signal,
1466
- token,
1467
- studioUrl,
1468
- instanceId,
1469
- capacity: {
1470
- ...capacity,
1471
- maxConcurrent: Math.min(capacity.maxConcurrent, RUNTIME_MAX_CONCURRENT)
1472
- }
1473
- });
1474
- signal?.throwIfAborted();
1475
- this.#registration = registration;
1476
- this.#studioVersion = registration.version;
1477
- const rpc = await (this.#options.connector ?? connectWebSocketRpc)({
1478
- url: registration.socketUrl,
1479
- token,
1480
- instanceId,
1481
- local: this
1482
- });
1483
- this.#rpc = rpc;
1484
- rpc.closed.then(this.#onClose);
1485
- if (signal?.aborted || this.#disposed) {
1486
- rpc.close();
1487
- throw new Error("Connection stopped during startup");
1488
- }
1489
- this.#scheduleHeartbeat(heartbeatInterval);
1490
- await this.#hooks.callHook("studio:connected", {
1491
- url: studioUrl,
1492
- versions: {
1493
- studio: this.#studioVersion,
1494
- kubb: version,
1495
- agent: this.#options.version
1496
- },
1497
- agentSlug: registration.agentSlug,
1498
- organizationSlug: registration.organizationSlug
1499
- });
1500
- await this.#connectAck.promise;
1501
- await this.#hooks.callHook("studio:ready", {});
1502
- } catch (error) {
1503
- await this.dispose();
1504
- if (!signal?.aborted) await this.#hooks.callHook("studio:error", { error: toError(error) });
1505
- throw error;
1506
- }
1507
- }
1508
- reportRetry(delayMs) {
1509
- return this.#hooks.callHook("studio:reconnecting", { delayMs });
1510
- }
1511
- #warn(message, permission) {
1512
- return this.#hooks.callHook("studio:warn", {
1467
+ #warn({ message, permission }) {
1468
+ return emit(this.#options.hooks, "studio:warn", {
1513
1469
  message,
1514
1470
  permission
1515
1471
  });
1516
1472
  }
1517
- /**
1518
- * Declines a request: logs why locally, then tells Studio. The two wordings differ on purpose,
1519
- * since the log names the request that was ignored and the error names what the caller can do.
1520
- */
1521
- async #refuse(reason, message, permission) {
1522
- await this.#warn(reason, permission);
1473
+ async #refuse({ message, permission }) {
1474
+ await this.#warn({
1475
+ message,
1476
+ permission
1477
+ });
1523
1478
  throw new Error(message);
1524
1479
  }
1525
- #scheduleHeartbeat(interval) {
1526
- const rpc = this.#rpc;
1527
- if (!rpc) return;
1528
- this.#heartbeatTimer = setTimeout(async () => {
1529
- try {
1530
- await this.#ping(rpc);
1531
- } catch {
1532
- if (this.#rpc === rpc) rpc.close();
1533
- return;
1534
- }
1535
- if (this.#rpc === rpc) this.#scheduleHeartbeat(interval);
1536
- }, interval);
1537
- }
1538
- /**
1539
- * Races `studio.ping()` against a deadline, so a half-open socket can't hang it forever.
1540
- * */
1541
- async #ping(rpc) {
1542
- const { promise: timedOut, reject: onTimeout } = Promise.withResolvers();
1543
- const timer = setTimeout(() => onTimeout(/* @__PURE__ */ new Error("Heartbeat ping timed out")), agentDefaults.heartbeatTimeoutMs);
1544
- try {
1545
- const load = await Promise.race([this.#load().catch(() => void 0), timedOut]);
1546
- await Promise.race([rpc.studio.ping(load), timedOut]);
1547
- } finally {
1548
- clearTimeout(timer);
1549
- }
1550
- }
1551
- async #load() {
1552
- return {
1553
- running: this.#isGenerating ? 1 : 0,
1554
- rssMb: Math.round(rssMb()),
1555
- storeBytes: await this.#generations.bytes(),
1556
- accepting: true
1557
- };
1480
+ async #command({ command, run }) {
1481
+ if (!this.#session.info) throw new Error("Studio must call connect() before any other command");
1482
+ const { value, info } = await run();
1483
+ if (info !== void 0) await emit(this.#options.hooks, "studio:command", {
1484
+ command,
1485
+ info
1486
+ });
1487
+ return value;
1558
1488
  }
1559
- /**
1560
- * Reads `kubb.config.ts` and reports which plugin options Studio may edit.
1561
- *
1562
- * Skipped when the host did not grant `allowConfigEdit`. Not cached: the user can edit the file
1563
- * between two Studio actions.
1564
- */
1565
1489
  async #readConfigFileView(source) {
1566
- if (!this.#canEditConfig) return;
1490
+ if (!this.#permissions.allowConfigEdit) return void 0;
1567
1491
  try {
1568
- const { readConfig } = await import("./configFile-BymXUi9P.js");
1569
1492
  return readConfig(source ?? await read(this.#options.configFile));
1570
1493
  } catch (error) {
1571
- await this.#warn(`Could not read ${this.#options.configFile}: ${getErrorMessage(error)}`);
1494
+ await this.#warn({ message: `Could not read ${this.#options.configFile}: ${getErrorMessage(error)}` });
1572
1495
  return;
1573
1496
  }
1574
1497
  }
1575
- async connect() {
1576
- const { configPath, root, version: version$1, loadConfig, permissions } = this.#options;
1498
+ async connect(info) {
1499
+ this.#session.info = info;
1500
+ const { configPath, root, version: version$1, loadConfig } = this.#options;
1577
1501
  const [config, file] = await Promise.all([loadConfig(), this.#readConfigFileView()]);
1578
- const payload = {
1502
+ this.#session.connected.resolve(info);
1503
+ return {
1579
1504
  versions: {
1580
1505
  kubb: version,
1581
1506
  agent: version$1
@@ -1589,172 +1514,131 @@ var StudioSession = class {
1589
1514
  options: plugin.options ?? {}
1590
1515
  }))
1591
1516
  },
1592
- permissions: {
1593
- ...permissions,
1594
- allowWrite: this.#canWrite,
1595
- allowConfigEdit: this.#canEditConfig,
1596
- allowRead: this.#canRead
1597
- }
1517
+ permissions: this.#permissions
1598
1518
  };
1599
- this.#connectAck.resolve();
1600
- return payload;
1601
- }
1602
- #onAbort = () => void this.#end({
1603
- reason: "shutdown",
1604
- retry: false
1605
- }).catch(() => {});
1606
- #onClose = (close) => void this.#end(planEnd(close)).catch(() => {});
1607
- /** Closes this attempt and waits for its generation to stop before another can start. */
1608
- dispose() {
1609
- return this.#end({
1610
- reason: "shutdown",
1611
- retry: false
1612
- });
1613
- }
1614
- async #end(plan) {
1615
- if (this.#disposed) {
1616
- await this.closed;
1617
- return;
1618
- }
1619
- this.#disposed = true;
1620
- clearTimeout(this.#heartbeatTimer);
1621
- this.#rpc?.close();
1622
- this.#rpc = void 0;
1623
- this.#connectAck.reject(/* @__PURE__ */ new Error("Session ended before Studio called connect()"));
1624
- for (const unhook of this.#unhooks) unhook();
1625
- this.#unhooks.length = 0;
1626
- try {
1627
- await this.#activeJob?.cancel();
1628
- await this.#generationResult?.catch(() => {});
1629
- await this.#hooks.callHook("studio:disconnected", { reason: plan.reason });
1630
- if (plan.error) await this.#hooks.callHook("studio:error", { error: plan.error });
1631
- } finally {
1632
- this.#ended.resolve(plan);
1633
- }
1634
1519
  }
1635
- startGeneration(data) {
1636
- const generationStream = createGenerationStream(this.#hooks, data.jobId, { onGenerationEnd: (result) => {
1637
- this.#lastGeneration = result;
1638
- } });
1520
+ startGeneration(input) {
1521
+ const stream = createGenerationStream(this.#options.hooks, input.jobId);
1639
1522
  const controller = new AbortController();
1640
- const cancelRun = async () => {
1641
- controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
1642
- };
1643
- const result = this.#runGeneration(data, controller, cancelRun).then(async (value) => {
1644
- await generationStream.close();
1645
- return value;
1646
- }).catch((error) => {
1647
- generationStream.fail(error);
1523
+ const result = this.#runGeneration(input, controller).then((value) => stream.close().then(() => value), (error) => {
1524
+ stream.fail(error);
1648
1525
  throw error;
1649
- }).finally(() => {
1650
- if (this.#activeJob?.cancel === cancelRun) this.#activeJob = void 0;
1651
1526
  });
1652
- if (this.#activeJob?.cancel === cancelRun) this.#generationResult = result;
1653
1527
  result.catch(() => {});
1654
- return new GenerationRunTarget(generationStream.stream, result, cancelRun, () => {
1655
- controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
1656
- generationStream.dispose();
1528
+ return new GenerationRunTarget({
1529
+ stream: stream.stream,
1530
+ result,
1531
+ stop: () => {
1532
+ controller.abort(/* @__PURE__ */ new Error("Generation canceled"));
1533
+ stream.dispose();
1534
+ }
1657
1535
  });
1658
1536
  }
1659
- /**
1660
- * Cancels the currently running job when its id matches `jobId`.
1661
- */
1662
- async cancel(jobId) {
1663
- if (this.#activeJob?.jobId === jobId) await this.#activeJob.cancel();
1664
- }
1665
- async #runGeneration(data, controller, cancelRun) {
1666
- if (this.#disposed) throw new Error("Connection is closed");
1667
- if (this.#isGenerating) return this.#refuse("Ignored generate: a generation is already in progress", "A generation is already in progress, please wait for it to finish");
1668
- this.#isGenerating = true;
1669
- this.#activeJob = {
1670
- jobId: data.jobId,
1671
- cancel: cancelRun
1537
+ async #runGeneration(input, controller) {
1538
+ if (this.#session.closed) throw new Error("Connection is closed");
1539
+ if (this.#session.active) return this.#refuse({ message: "A generation is already in progress, please wait for it to finish" });
1540
+ const settled = Promise.withResolvers();
1541
+ this.#session.active = {
1542
+ jobId: input.jobId,
1543
+ abort: () => controller.abort(/* @__PURE__ */ new Error("Generation canceled")),
1544
+ settled: settled.promise
1672
1545
  };
1673
- const command = "generate";
1674
- const { loadConfig, permissions } = this.#options;
1675
1546
  let root = this.#options.root;
1676
1547
  try {
1677
- if (this.#isSandbox) root = await createJobRoot();
1678
- await this.#hooks.callHook("studio:command:start", { command });
1679
- const config = await loadConfig();
1680
- const patch = data.config;
1681
- const plugins = await mergePlugins(config.plugins, patch?.plugins);
1682
- const adapter = await mergeAdapter(config.adapter, patch?.adapter);
1683
- const inputOverride = this.#isSandbox ? patch?.input ?? "" : void 0;
1684
- if (permissions.allowWrite && this.#isSandbox) await this.#warn("Running in a sandbox, so writing files is disabled");
1685
- if (patch?.input && !this.#isSandbox) await this.#warn("Ignored the spec from Studio: generating from a Studio spec is only available to a sandbox agent");
1686
- const resolvedPlugins = plugins ?? config.plugins;
1687
- this.#lastGeneration = void 0;
1688
- const disk = await runGenerationOperation({
1689
- config: {
1690
- ...config,
1548
+ if (this.#sandbox) root = await mkdtemp(path.join(tmpdir(), "kubb-job-"));
1549
+ return await this.#command({
1550
+ command: "generate",
1551
+ run: () => this.#generate({
1552
+ input,
1691
1553
  root,
1692
- input: inputOverride ?? config.input,
1693
- storage: this.#canWrite ? fsStorage() : memoryStorage(),
1694
- output: permissions.allowExec ? { ...config.output } : {
1695
- ...config.output,
1696
- format: false,
1697
- lint: false,
1698
- postGenerate: []
1699
- },
1700
- plugins: resolvedPlugins,
1701
- adapter
1702
- },
1703
- hooks: this.#hooks,
1704
- signal: controller.signal,
1705
- jobId: data.jobId,
1706
- store: this.#generations,
1707
- snapshotRoot: this.#hasProjectOnDisk ? root : void 0,
1708
- maxSnapshotMb: this.#limits.maxSnapshotMb
1554
+ signal: controller.signal
1555
+ })
1709
1556
  });
1710
- await this.#hooks.callHook("studio:command:end", {
1711
- command,
1712
- info: `${resolvedPlugins.length} plugin${resolvedPlugins.length === 1 ? "" : "s"}, ${this.#canWrite ? "written to disk" : "in memory"}${inputOverride !== void 0 ? ", from a Studio spec" : ""}`
1713
- });
1714
- const generation = this.#lastGeneration;
1715
- const output = generation ? await this.#generations.keep({
1716
- jobId: data.jobId,
1717
- source: "output",
1718
- files: generation.output,
1719
- maxSetMb: this.#limits.maxMb
1720
- }) : void 0;
1721
- if (generation && output) {
1722
- if (!output.paths.length && Object.keys(output.hashes).length) await this.#warn("Kept only the hashes of this generation: its output is too large to keep");
1723
- const { peerDependencies, missingDependencies } = generation;
1724
- await this.#generations.add({
1725
- jobId: data.jobId,
1726
- output,
1727
- disk,
1728
- peerDependencies,
1729
- missingDependencies
1730
- });
1731
- }
1732
- const files = [...generation?.output.paths ?? []];
1733
- return {
1734
- status: "success",
1735
- files,
1736
- fileCount: files.length,
1737
- hashes: output?.hashes ?? {},
1738
- disk: disk ? { hashes: disk.hashes } : void 0
1739
- };
1740
1557
  } finally {
1741
- this.#lastGeneration = void 0;
1742
- if (root !== this.#options.root) await removeJobRoot(root);
1743
- this.#isGenerating = false;
1558
+ if (root !== this.#options.root) await Promise.all([rm(root, {
1559
+ recursive: true,
1560
+ force: true
1561
+ }), rm(resolveCacheDir(root), {
1562
+ recursive: true,
1563
+ force: true
1564
+ })]).catch(() => {});
1565
+ this.#session.active = void 0;
1566
+ settled.resolve();
1744
1567
  }
1745
1568
  }
1746
- async saveConfig(data) {
1747
- const command = "saveConfig";
1748
- await this.#hooks.callHook("studio:command:start", { command });
1569
+ async #generate({ input, root, signal }) {
1570
+ const { jobId, config: patch } = input;
1571
+ const config = await this.#options.loadConfig();
1572
+ const plugins = await mergePlugins(config.plugins, patch?.plugins) ?? config.plugins;
1573
+ const adapter = await mergeAdapter(config.adapter, patch?.adapter);
1574
+ const studioInput = this.#sandbox ? patch?.input ?? "" : void 0;
1575
+ if (patch?.input && !this.#sandbox) await this.#warn({ message: "Ignored the spec from Studio: generating from a Studio spec is only available to a sandbox agent" });
1576
+ const diskFiles = !this.#sandbox && this.#permissions.allowRead ? await listDisk({
1577
+ root,
1578
+ outputPath: config.output.path,
1579
+ maxFiles: DISK_SNAPSHOT_MAX_FILES
1580
+ }) : void 0;
1581
+ const disk = diskFiles ? await this.#generations.keep({
1582
+ jobId,
1583
+ source: "disk",
1584
+ files: diskFiles,
1585
+ maxSetMb: this.#limits.maxSnapshotMb
1586
+ }) : void 0;
1587
+ const files = await generate({
1588
+ config: {
1589
+ ...config,
1590
+ root,
1591
+ input: studioInput ?? config.input,
1592
+ storage: this.#permissions.allowWrite ? fsStorage() : memoryStorage(),
1593
+ plugins,
1594
+ adapter
1595
+ },
1596
+ hooks: this.#options.hooks,
1597
+ signal,
1598
+ allowExec: this.#permissions.allowExec
1599
+ }).catch(async (error) => {
1600
+ await this.#generations.drop(jobId);
1601
+ throw error;
1602
+ });
1603
+ const output = await this.#generations.keep({
1604
+ jobId,
1605
+ source: "output",
1606
+ files,
1607
+ maxSetMb: this.#limits.maxMb
1608
+ });
1609
+ if (!output.paths.length && Object.keys(output.hashes).length) await this.#warn({ message: "Kept only the hashes of this generation: its output is too large to keep" });
1610
+ await this.#generations.add({
1611
+ jobId,
1612
+ output,
1613
+ disk
1614
+ });
1615
+ return {
1616
+ value: {
1617
+ status: "success",
1618
+ files: [...files.paths],
1619
+ fileCount: files.paths.size,
1620
+ hashes: output.hashes,
1621
+ disk: disk ? { hashes: disk.hashes } : void 0
1622
+ },
1623
+ info: `${plugins.length} plugin${plugins.length === 1 ? "" : "s"}, ${this.#permissions.allowWrite ? "written to disk" : "in memory"}${studioInput !== void 0 ? ", from a Studio spec" : ""}`
1624
+ };
1625
+ }
1626
+ saveConfig(input) {
1627
+ return this.#command({
1628
+ command: "saveConfig",
1629
+ run: () => this.#saveConfig(input)
1630
+ });
1631
+ }
1632
+ async #saveConfig(input) {
1749
1633
  const { configPath, configFile } = this.#options;
1750
- if (!Array.isArray(data.edits)) {
1751
- await this.#warn("Ignored save: the message carried no edits");
1752
- return {
1634
+ if (!Array.isArray(input.edits)) {
1635
+ await this.#warn({ message: "Ignored save: the message carried no edits" });
1636
+ return { value: {
1753
1637
  outcomes: [],
1754
1638
  changed: false
1755
- };
1639
+ } };
1756
1640
  }
1757
- const edits = data.edits;
1641
+ const edits = input.edits;
1758
1642
  const refuse = (reason) => ({
1759
1643
  outcomes: edits.map((edit) => ({
1760
1644
  edit,
@@ -1763,197 +1647,215 @@ var StudioSession = class {
1763
1647
  })),
1764
1648
  changed: false
1765
1649
  });
1766
- if (!this.#canEditConfig) {
1767
- await this.#warn("Ignored save: editing kubb.config.ts was not granted");
1768
- return refuse("the agent was not granted permission to edit kubb.config.ts");
1650
+ if (!this.#permissions.allowConfigEdit) {
1651
+ await this.#warn({ message: "Ignored save: editing kubb.config.ts was not granted" });
1652
+ return { value: refuse("the agent was not granted permission to edit kubb.config.ts") };
1769
1653
  }
1770
- if (this.#isGenerating) return refuse("a generation is in progress");
1654
+ if (this.#session.active) return { value: refuse("a generation is in progress") };
1771
1655
  try {
1772
- const { writeConfigEdits } = await import("./configFile-BymXUi9P.js");
1773
- const { source: patched, outcomes, changed } = await writeConfigEdits({
1656
+ const { source, outcomes, changed } = await writeConfigEdits({
1774
1657
  filePath: configFile,
1775
1658
  edits
1776
1659
  });
1777
1660
  const applied = outcomes.filter((outcome) => outcome.applied).length;
1778
- await this.#hooks.callHook("studio:command:end", {
1779
- command,
1780
- info: `applied ${applied}/${outcomes.length} edits to ${configPath}`
1781
- });
1782
- return {
1783
- outcomes,
1784
- changed,
1785
- file: changed ? await this.#readConfigFileView(patched) : void 0
1786
- };
1787
- } catch (error) {
1788
- await this.#hooks.callHook("studio:error", { error: toError(error) });
1789
- return refuse(getErrorMessage(error));
1790
- }
1791
- }
1792
- async publishSnapshot(data) {
1793
- const command = "snapshot";
1794
- await this.#hooks.callHook("studio:command:start", { command });
1795
- if (this.#isSandbox) return this.#refuse("Ignored snapshot: a sandbox agent has no project to build a package from", "A sandbox agent has no project to build a package from");
1796
- const { name, version, bundledDependencies, uploadPath } = data;
1797
- if (!name || !version || !uploadPath) return this.#refuse("Ignored snapshot: the message was missing required fields", "The request was missing required fields");
1798
- const generation = await this.#generations.latest();
1799
- if (!generation) return this.#refuse("Ignored snapshot: no prior generation to pack", "No prior generation exists to pack, run a generation first");
1800
- const bundled = new Set(bundledDependencies ?? []);
1801
- const missing = generation.missingDependencies.filter((dependency) => !bundled.has(dependency));
1802
- if (missing.length) return this.#refuse(`Ignored snapshot: missing dependencies: ${missing.join(", ")}`, `Missing dependencies: ${missing.join(", ")}`);
1803
- try {
1804
- const { createSnapshotPackage, uploadSnapshot } = await import("./snapshotPackage-I8bt8KTR.js");
1805
- const { files } = await this.#generations.read({
1806
- generation,
1807
- source: "output",
1808
- paths: generation.output.paths
1809
- });
1810
- const { bytes, integrity } = await createSnapshotPackage(files, {
1811
- name,
1812
- version,
1813
- peerDependencies: generation.peerDependencies
1814
- });
1815
- const { token, studioUrl, signal } = this.#options;
1816
- await uploadSnapshot({
1817
- bytes,
1818
- uploadPath,
1819
- studioUrl,
1820
- token,
1821
- shutdown: signal
1822
- });
1823
- await this.#hooks.callHook("studio:command:end", {
1824
- command,
1825
- info: `packed ${Object.keys(files).length} file${Object.keys(files).length === 1 ? "" : "s"}`
1826
- });
1827
1661
  return {
1828
- integrity,
1829
- peerDependencies: generation.peerDependencies
1662
+ value: {
1663
+ outcomes,
1664
+ changed,
1665
+ file: changed ? await this.#readConfigFileView(source) : void 0
1666
+ },
1667
+ info: `applied ${applied}/${outcomes.length} edits to ${configPath}`
1830
1668
  };
1831
1669
  } catch (error) {
1832
- await this.#hooks.callHook("studio:error", { error: toError(error) });
1833
- throw error;
1670
+ await emit(this.#options.hooks, "studio:error", { error: toError(error) });
1671
+ return { value: refuse(getErrorMessage(error)) };
1834
1672
  }
1835
1673
  }
1836
- /** A sandbox has no project directory to snapshot. */
1837
- get #hasProjectOnDisk() {
1838
- return !this.#isSandbox && this.#canRead;
1674
+ readFiles(input) {
1675
+ return this.#command({
1676
+ command: "readFiles",
1677
+ run: () => this.#readFiles(input)
1678
+ });
1839
1679
  }
1840
- async readFiles(data) {
1841
- const command = "readFiles";
1842
- await this.#hooks.callHook("studio:command:start", { command });
1843
- if (!this.#canRead) return this.#refuse("Ignored files: reading generated files was not granted", "The agent was not granted permission to read generated files", "allowRead");
1844
- if (!Array.isArray(data.paths)) return this.#refuse("Ignored files: the message carried no paths", "The request carried no paths");
1845
- const { paths } = data;
1846
- if (paths.length > 50) return this.#refuse(`Ignored files: requested ${paths.length} paths, more than the 50 allowed per request`, `At most 50 paths may be requested at once`);
1847
- if (typeof data.jobId !== "string" || !data.jobId) return this.#refuse("Ignored files: the message named no job", "The request named no generation job");
1848
- const generation = await this.#generations.get(data.jobId);
1849
- if (!generation) return this.#refuse(`Ignored files: job ${data.jobId} is not kept on this agent`, GENERATION_GONE_MESSAGE);
1850
- if (!isValidPage(data)) return this.#refuse("Ignored files: the message carried an invalid page", `The cursor must be a whole line from 0 and come with a limit of 1 to ${MAX_PAGE_LINES} lines`);
1851
- const source = data.source === "disk" ? "disk" : "output";
1852
- if (!generation[source]) return this.#refuse("Ignored files: that job has no snapshot of the files on disk", "This agent kept no snapshot of the files on disk for that job");
1680
+ async #readFiles(input) {
1681
+ if (!this.#permissions.allowRead) return this.#refuse({
1682
+ message: "The agent was not granted permission to read generated files",
1683
+ permission: "allowRead"
1684
+ });
1685
+ if (!Array.isArray(input.paths) || typeof input.jobId !== "string" || !input.jobId) return this.#refuse({ message: "The request must name a job and carry paths" });
1686
+ const { paths } = input;
1687
+ if (paths.length > 50) return this.#refuse({ message: `At most 50 paths may be requested at once` });
1688
+ const generation = await this.#generations.get(input.jobId);
1689
+ if (!generation) return this.#refuse({ message: GENERATION_GONE_MESSAGE });
1690
+ if (!isValidPage(input)) return this.#refuse({ message: `The cursor must be a whole line from 0 and come with a limit of 1 to ${MAX_PAGE_LINES} lines` });
1691
+ const source = input.source === "disk" ? "disk" : "output";
1692
+ if (!generation[source]) return this.#refuse({ message: "This agent kept no snapshot of the files on disk for that job" });
1853
1693
  const result = await this.#generations.read({
1854
1694
  generation,
1855
1695
  source,
1856
1696
  paths,
1857
- cursor: data.cursor,
1858
- limit: data.limit
1697
+ cursor: input.cursor,
1698
+ limit: input.limit
1859
1699
  });
1860
- await this.#hooks.callHook("studio:command:end", {
1861
- command,
1700
+ return {
1701
+ value: result,
1862
1702
  info: `read ${Object.keys(result.files).length}/${paths.length} requested file${paths.length === 1 ? "" : "s"}`
1863
- });
1864
- return result;
1703
+ };
1865
1704
  }
1866
1705
  };
1867
- //#endregion
1868
- //#region src/runtime/runConnection.ts
1869
- /** Runs the process's one connection loop, including retries and credential replacement. */
1870
- async function runConnection({ credentials, clientOptions, onTokenRejected, signal, onStarted }) {
1871
- let current = credentials;
1872
- const initialOptions = clientOptions(current);
1873
- const instanceId = initialOptions.instanceId ?? randomUUID();
1874
- let options = initialOptions;
1875
- let attempt = 0;
1876
- let live = false;
1877
- while (!signal?.aborted) {
1878
- const session = new StudioSession({
1879
- ...options,
1880
- instanceId,
1881
- token: current.token,
1882
- signal
1706
+ const openWebSocketRpc = ({ url, token, instanceId, agent }) => {
1707
+ const socket = new WebSocket(url, { headers: {
1708
+ Authorization: `Bearer ${token}`,
1709
+ [AGENT_INSTANCE_HEADER]: instanceId
1710
+ } });
1711
+ const timer = setTimeout(() => socket.close(3008, "Connection timeout"), OPEN_TIMEOUT_MS);
1712
+ socket.addEventListener("open", () => clearTimeout(timer), { once: true });
1713
+ const closed = new Promise((resolve) => {
1714
+ socket.addEventListener("close", ({ code, reason }) => resolve({
1715
+ code,
1716
+ reason
1717
+ }), { once: true });
1718
+ socket.addEventListener("error", () => {
1719
+ if (socket.readyState !== WebSocket.OPEN) resolve({
1720
+ code: 1006,
1721
+ reason: ""
1722
+ });
1883
1723
  });
1884
- try {
1885
- await session.start();
1886
- live = true;
1887
- attempt = 0;
1888
- onStarted?.();
1889
- if (!(await session.closed).retry) return signal?.aborted ? "shutdown" : "stopped";
1890
- } catch (error) {
1891
- if (signal?.aborted) return "shutdown";
1892
- if (error instanceof IncompatibleAgentError) throw error;
1893
- await session.dispose();
1894
- const end = await session.closed;
1895
- if (!end.retry && end.reason !== "shutdown") return "stopped";
1896
- if (error instanceof InvalidAgentTokenError) {
1897
- const next = await onTokenRejected({
1898
- error,
1899
- credentials: current,
1900
- live
1901
- });
1902
- if (!next) return "stopped";
1903
- current = next;
1904
- options = clientOptions(current);
1905
- live = false;
1906
- attempt = 0;
1907
- continue;
1908
- }
1909
- } finally {
1910
- await session.dispose();
1911
- }
1912
- onStarted?.();
1913
- const cap = Math.min(1e3 * 2 ** Math.min(attempt++, 16), options.retryInterval ?? agentDefaults.retryIntervalMs);
1914
- const delayMs = Math.random() * cap;
1915
- await session.reportRetry(delayMs);
1916
- try {
1917
- await setTimeout$1(delayMs, void 0, { signal });
1918
- } catch (error) {
1919
- if (!signal?.aborted) throw error;
1724
+ }).finally(() => clearTimeout(timer));
1725
+ const studio = newWebSocketRpcSession(socket, agent);
1726
+ studio.onRpcBroken(() => socket.close());
1727
+ return {
1728
+ studio,
1729
+ closed,
1730
+ close: () => {
1731
+ studio[Symbol.dispose]();
1732
+ socket.close();
1920
1733
  }
1921
- }
1922
- return "shutdown";
1923
- }
1924
- //#endregion
1925
- //#region src/runtime/client.ts
1926
- /** Compatibility facade over the shared connection loop. */
1927
- function createClient({ onAuthRequired, ...options }) {
1928
- const shutdown = new AbortController();
1929
- const started = Promise.withResolvers();
1930
- let resolved = false;
1931
- let running;
1734
+ };
1735
+ };
1736
+ async function runSession({ options, openRpc, url, instanceId }) {
1737
+ const { hooks, signal, heartbeatInterval, token, studioUrl, version: version$2 } = options;
1738
+ const session = {
1739
+ connected: Promise.withResolvers(),
1740
+ closed: false
1741
+ };
1742
+ const rpc = openRpc({
1743
+ url,
1744
+ token,
1745
+ instanceId,
1746
+ agent: new Agent({
1747
+ options,
1748
+ session
1749
+ })
1750
+ });
1751
+ signal?.addEventListener("abort", rpc.close, { once: true });
1752
+ let timer;
1753
+ const heartbeat = async () => {
1754
+ let deadline;
1755
+ const timedOut = new Promise((resolve) => deadline = setTimeout(resolve, PING_TIMEOUT_MS, true));
1756
+ const failed = await Promise.race([Promise.resolve(rpc.studio.ping()).then(() => false, () => true), timedOut]);
1757
+ clearTimeout(deadline);
1758
+ if (failed) return rpc.close();
1759
+ if (!session.closed) timer = setTimeout(heartbeat, heartbeatInterval);
1760
+ };
1761
+ session.connected.promise.then(async (info) => {
1762
+ await emit(hooks, "studio:connected", {
1763
+ ...info,
1764
+ url: studioUrl,
1765
+ versions: {
1766
+ studio: info.studioVersion,
1767
+ kubb: version,
1768
+ agent: version$2
1769
+ }
1770
+ });
1771
+ if (!session.closed) timer = setTimeout(heartbeat, heartbeatInterval);
1772
+ }, () => {});
1773
+ const close = await rpc.closed;
1774
+ session.closed = true;
1775
+ clearTimeout(timer);
1776
+ signal?.removeEventListener("abort", rpc.close);
1777
+ session.connected.reject(/* @__PURE__ */ new Error("The socket closed before Studio called connect()"));
1778
+ session.active?.abort();
1779
+ await session.active?.settled;
1932
1780
  return {
1933
- connect() {
1934
- running ??= runConnection({
1935
- credentials: { token: options.token },
1936
- clientOptions: () => options,
1937
- signal: shutdown.signal,
1938
- onStarted: () => {
1939
- resolved = true;
1940
- started.resolve();
1941
- },
1942
- onTokenRejected: async ({ error }) => {
1943
- if (!resolved) throw error;
1944
- onAuthRequired?.(error);
1945
- return null;
1946
- }
1947
- }).then(() => started.resolve(), (error) => started.reject(error));
1948
- return started.promise;
1781
+ ...close,
1782
+ connected: session.info !== void 0
1783
+ };
1784
+ }
1785
+ /**
1786
+ * The connection loop with a swappable transport. {@link connectAgent} passes a real WebSocket.
1787
+ */
1788
+ async function runAgent({ options, openRpc }) {
1789
+ const root = options.root ?? process$1.cwd();
1790
+ const resolved = {
1791
+ ...options,
1792
+ studioUrl: options.studioUrl ?? "https://kubb.studio",
1793
+ root,
1794
+ configFile: path.resolve(root, options.configPath),
1795
+ permissions: {
1796
+ allowWrite: false,
1797
+ allowConfigEdit: false,
1798
+ allowExec: false,
1799
+ allowRead: false,
1800
+ ...options.permissions
1949
1801
  },
1950
- disconnect() {
1951
- shutdown.abort();
1952
- }
1802
+ hooks: options.hooks ?? new Hookable(),
1803
+ maxRetryDelay: options.maxRetryDelay ?? MAX_RETRY_DELAY_MS,
1804
+ heartbeatInterval: Math.min(options.heartbeatInterval ?? HEARTBEAT_INTERVAL_MS, MAX_HEARTBEAT_INTERVAL_MS)
1953
1805
  };
1806
+ const { studioUrl, hooks, signal, maxRetryDelay } = resolved;
1807
+ const url = secureUrl({
1808
+ studioUrl,
1809
+ path: AGENT_SOCKET_PATH,
1810
+ socket: true
1811
+ });
1812
+ const instanceId = randomUUID();
1813
+ let attempt = 0;
1814
+ while (!signal?.aborted) {
1815
+ const { code, reason, connected } = await runSession({
1816
+ options: resolved,
1817
+ openRpc,
1818
+ url,
1819
+ instanceId
1820
+ });
1821
+ if (signal?.aborted) return;
1822
+ await emit(hooks, "studio:disconnected", {
1823
+ code,
1824
+ reason
1825
+ });
1826
+ if (code === AgentCloseCode.UNAUTHORIZED) throw new InvalidAgentTokenError(studioUrl);
1827
+ if (code === AgentCloseCode.INCOMPATIBLE) throw new IncompatibleAgentError({
1828
+ studioUrl,
1829
+ detail: reason
1830
+ });
1831
+ if (code === AgentCloseCode.SUPERSEDED) return;
1832
+ if (connected) attempt = 0;
1833
+ const delayMs = Math.random() * Math.min(1e3 * 2 ** Math.min(attempt++, 16), maxRetryDelay);
1834
+ await emit(hooks, "studio:reconnecting", { delayMs });
1835
+ await setTimeout$1(delayMs, void 0, { signal }).catch(() => {});
1836
+ }
1837
+ }
1838
+ /**
1839
+ * Connects this project to Kubb Studio and serves its commands until `signal` aborts. Reconnects
1840
+ * with jittered backoff, and resolves when another process of the agent takes over. Rejects with
1841
+ * {@link InvalidAgentTokenError} so the host can pair again, or {@link IncompatibleAgentError}.
1842
+ *
1843
+ * @example
1844
+ * ```ts
1845
+ * const hooks = new Hookable<KubbHooks>()
1846
+ * hooks.hook('studio:connected', ({ agentSlug }) => console.log(`Connected as ${agentSlug}`))
1847
+ *
1848
+ * await connectAgent({ token, configPath: 'kubb.config.ts', loadConfig, version: '1.0.0', hooks, signal })
1849
+ * ```
1850
+ */
1851
+ function connectAgent(options) {
1852
+ return runAgent({
1853
+ options,
1854
+ openRpc: openWebSocketRpc
1855
+ });
1954
1856
  }
1955
1857
  //#endregion
1956
- //#region src/operations/pair.ts
1858
+ //#region src/pair.ts
1957
1859
  /** Labels, not secrets: a person approving the code in the browser is what authorizes a pairing. */
1958
1860
  const CLIENT_IDS = {
1959
1861
  cli: "kubb-cli",
@@ -1985,30 +1887,33 @@ var PairingDeniedError = class extends Error {
1985
1887
  }
1986
1888
  };
1987
1889
  /**
1988
- * Asks Studio for a pairing code. The machine token travels with the request and is stored against
1989
- * the code, so approval knows which machine it is pairing: the same machine pairing twice rotates
1990
- * one agent's token instead of creating a second agent.
1890
+ * Asks Studio for a pairing code, sending the hashed machine id with it.
1991
1891
  */
1992
- async function startPairing({ studioUrl = agentDefaults.studioUrl, type, name, hostname, signal }) {
1892
+ async function startPairing({ studioUrl = defaultStudioUrl, type, name, hostname, machineId, signal }) {
1993
1893
  try {
1994
- return await ofetch(`${studioUrl}/api/auth/device/code`, {
1894
+ const pairing = await ofetch(secureUrl({
1895
+ studioUrl,
1896
+ path: "/api/auth/device/code"
1897
+ }).href, {
1995
1898
  method: "POST",
1996
1899
  body: {
1997
1900
  client_id: type === "cli" ? CLIENT_IDS.cli : CLIENT_IDS.agent,
1998
1901
  name,
1999
1902
  hostname,
2000
- machine_token: await getMachineToken(),
1903
+ machine_token: hash("sha256", machineId),
2001
1904
  agent_kind: type === "cli" ? void 0 : type
2002
1905
  },
2003
1906
  signal
2004
1907
  });
1908
+ if (typeof pairing?.device_code !== "string") throw new Error("Kubb Studio returned no pairing code");
1909
+ return pairing;
2005
1910
  } catch (error) {
2006
1911
  if (signal?.aborted) throw new PairingCanceledError();
2007
1912
  throw error;
2008
1913
  }
2009
1914
  }
2010
1915
  function isPairingResult(response) {
2011
- return !!response && typeof response === "object" && "token" in response && typeof response.token === "string";
1916
+ return "token" in response && typeof response.token === "string";
2012
1917
  }
2013
1918
  /**
2014
1919
  * Polls until the user approves or denies, honoring the server's `slow_down` back-off.
@@ -2020,7 +1925,7 @@ function isPairingResult(response) {
2020
1925
  * @throws {PairingDeniedError} when the pairing is denied in the browser.
2021
1926
  * @throws {PairingCanceledError} when `signal` aborts.
2022
1927
  */
2023
- async function pollForPairingToken({ studioUrl = agentDefaults.studioUrl, session, signal, onRetry }) {
1928
+ async function pollForPairingToken({ studioUrl = defaultStudioUrl, session, signal, onRetry }) {
2024
1929
  const deadline = Date.now() + (session.expires_in > 0 ? session.expires_in : 600) * 1e3;
2025
1930
  let intervalMs = (session.interval > 0 ? session.interval : 5) * 1e3;
2026
1931
  while (Date.now() < deadline) {
@@ -2032,19 +1937,24 @@ async function pollForPairingToken({ studioUrl = agentDefaults.studioUrl, sessio
2032
1937
  }
2033
1938
  let response;
2034
1939
  try {
2035
- response = await ofetch(`${studioUrl}/api/agent/token`, {
1940
+ const { status, _data } = await ofetch.raw(secureUrl({
1941
+ studioUrl,
1942
+ path: "/api/agent/token"
1943
+ }).href, {
2036
1944
  method: "POST",
2037
1945
  body: { device_code: session.device_code },
2038
- ignoreResponseError: true,
2039
- signal
1946
+ signal,
1947
+ ignoreResponseError: true
2040
1948
  });
1949
+ if (status >= 500 || !_data || typeof _data !== "object") throw new Error(`Kubb Studio answered with status ${status}`);
1950
+ response = _data;
2041
1951
  } catch (error) {
2042
1952
  if (signal?.aborted) throw new PairingCanceledError();
2043
1953
  onRetry?.(toError(error));
2044
1954
  continue;
2045
1955
  }
2046
1956
  if (isPairingResult(response)) return response;
2047
- if (!response || typeof response !== "object" || !("error" in response) || typeof response.error !== "string") throw new Error("Kubb Studio returned an empty pairing response, pair again");
1957
+ if (!("error" in response) || typeof response.error !== "string") throw new Error("Kubb Studio returned an empty pairing response, pair again");
2048
1958
  if (response.error === "authorization_pending") continue;
2049
1959
  if (response.error === "slow_down") {
2050
1960
  intervalMs += 5e3;
@@ -2076,6 +1986,212 @@ async function pairAgent({ onCode, onRetry, maxAttempts = 1, ...options }) {
2076
1986
  }
2077
1987
  }
2078
1988
  //#endregion
2079
- export { AGENT_INSTANCE_HEADER, AgentCloseCode, IncompatibleAgentError, InvalidAgentTokenError, PairingCanceledError, PairingDeniedError, PairingExpiredError, connectWebSocketRpc, createAgent, createClient, createFileStorage, createJob, defaultStudioUrl, generationEventTypes, machineTokenFrom, pairAgent, pollForPairingToken, runConnection, setStorage, startPairing, waitForJob };
1989
+ //#region src/snapshot.ts
1990
+ const gzipAsync = promisify(gzip);
1991
+ const UPLOAD_TIMEOUT_MS = 12e4;
1992
+ const BUNDLED_DEPENDENCIES = ["@kubb/plugin-barrel"];
1993
+ /**
1994
+ * Maps a generated file's path to its place inside the tarball, stripping everything before a
1995
+ * `src`/`dist` segment and any `..`/empty path segment so a crafted file name cannot escape the
1996
+ * `package/` root.
1997
+ */
1998
+ function packagePath(filePath) {
1999
+ const normalized = filePath.replaceAll("\\", "/");
2000
+ return `package/${(normalized.match(/\/(?:src|dist)\/.*$/)?.[0].slice(1) ?? normalized.replace(/^\/+/, "")).split("/").filter((part) => part && part !== "." && part !== "..").join("/")}`;
2001
+ }
2002
+ /**
2003
+ * Splits a tarball entry path into the legacy 100-byte `name` field and, when the path does not
2004
+ * fit, the 155-byte USTAR `prefix` field that extends it. Throws rather than silently truncating
2005
+ * a path the format cannot address (max 256 bytes: 100 name + 1 separator + 155 prefix).
2006
+ */
2007
+ function splitEntryPath(path) {
2008
+ if (Buffer.byteLength(path, "utf8") <= 100) return {
2009
+ name: path,
2010
+ prefix: ""
2011
+ };
2012
+ for (let i = path.length - 1; i >= 0; i--) {
2013
+ if (path[i] !== "/") continue;
2014
+ const prefix = path.slice(0, i);
2015
+ const name = path.slice(i + 1);
2016
+ if (Buffer.byteLength(prefix, "utf8") <= 155 && Buffer.byteLength(name, "utf8") <= 100) return {
2017
+ name,
2018
+ prefix
2019
+ };
2020
+ }
2021
+ throw new Error(`Snapshot path is too long for a tar entry: ${path}`);
2022
+ }
2023
+ function header(path, size) {
2024
+ const { name, prefix } = splitEntryPath(path);
2025
+ const value = Buffer.alloc(512);
2026
+ value.write(name, 0, "utf8");
2027
+ value.write("0000644\0", 100, "ascii");
2028
+ value.write("0000000\0", 108, "ascii");
2029
+ value.write("0000000\0", 116, "ascii");
2030
+ value.write(`${size.toString(8).padStart(11, "0")}\0`, 124, "ascii");
2031
+ value.write("03560116604\0", 136, "ascii");
2032
+ value.fill(32, 148, 156);
2033
+ value.write("0", 156, "ascii");
2034
+ value.write("ustar\0", 257, "ascii");
2035
+ value.write("00", 263, "ascii");
2036
+ value.write("0000000\0", 265, "ascii");
2037
+ value.write("0000000\0", 297, "ascii");
2038
+ value.write(prefix, 345, "utf8");
2039
+ const checksum = [...value].reduce((sum, byte) => sum + byte, 0);
2040
+ value.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, "ascii");
2041
+ return value;
2042
+ }
2043
+ /**
2044
+ * Packs a generation's files into a gzipped, npm-installable tarball: a `package/` root with the
2045
+ * generated sources, a `tsdown`-built `dist/` (esm + cjs), and a `package.json` manifest.
2046
+ */
2047
+ async function createSnapshotPackage({ files, packageInfo }) {
2048
+ const root = await mkdtemp(join(tmpdir(), "kubb-snapshot-"));
2049
+ const dist = join(root, "dist");
2050
+ const resolvedPaths = Object.entries(files).map(([name, content]) => ({
2051
+ name,
2052
+ content,
2053
+ target: packagePath(name)
2054
+ }));
2055
+ const targetOwners = /* @__PURE__ */ new Map();
2056
+ for (const { name, target } of resolvedPaths) {
2057
+ const owner = targetOwners.get(target);
2058
+ if (owner) throw new Error(`Snapshot has two generated files that sanitize to the same path "${target}": "${owner}" and "${name}"`);
2059
+ targetOwners.set(target, name);
2060
+ }
2061
+ try {
2062
+ await mkdir(dist);
2063
+ await Promise.all(resolvedPaths.map(async ({ content, target }) => {
2064
+ const path = join(root, target.slice(8));
2065
+ await mkdir(join(path, ".."), { recursive: true });
2066
+ await writeFile(path, content);
2067
+ }));
2068
+ const sourceEntries = resolvedPaths.filter(({ name }) => /\.(?:[cm]?[jt]sx?)$/.test(name)).map(({ target }) => join(root, target.slice(8)));
2069
+ if (sourceEntries.length) {
2070
+ const { build } = await import("tsdown");
2071
+ await build({
2072
+ entry: sourceEntries,
2073
+ outDir: dist,
2074
+ format: ["esm", "cjs"],
2075
+ dts: false,
2076
+ sourcemap: false,
2077
+ unbundle: true,
2078
+ report: false,
2079
+ logLevel: "silent",
2080
+ fixedExtension: true
2081
+ });
2082
+ }
2083
+ const builtEntries = await Promise.all((await fsStorage().readKeys(dist)).map(async (key) => [`package/dist/${key}`, await readFile(join(dist, key), "utf8")]));
2084
+ const builtPaths = new Set(builtEntries.map(([path]) => path));
2085
+ const hasBarrel = builtPaths.has("package/dist/index.mjs") && builtPaths.has("package/dist/index.cjs");
2086
+ const barrelFields = hasBarrel ? {
2087
+ main: "./dist/index.cjs",
2088
+ module: "./dist/index.mjs"
2089
+ } : {};
2090
+ const barrelExport = hasBarrel ? { ".": {
2091
+ import: "./dist/index.mjs",
2092
+ require: "./dist/index.cjs"
2093
+ } } : {};
2094
+ const entries = {
2095
+ "package/package.json": JSON.stringify({
2096
+ ...packageInfo,
2097
+ type: "module",
2098
+ ...barrelFields,
2099
+ exports: {
2100
+ ...barrelExport,
2101
+ "./*": {
2102
+ import: "./dist/*.mjs",
2103
+ require: "./dist/*.cjs"
2104
+ }
2105
+ }
2106
+ }, null, 2),
2107
+ ...Object.fromEntries(resolvedPaths.map(({ content, target }) => [target, content])),
2108
+ ...Object.fromEntries(builtEntries)
2109
+ };
2110
+ const chunks = [];
2111
+ for (const [name, content] of Object.entries(entries)) {
2112
+ const bytes = Buffer.from(content);
2113
+ chunks.push(header(name, bytes.length), bytes, Buffer.alloc((512 - bytes.length % 512) % 512));
2114
+ }
2115
+ return await gzipAsync(Buffer.concat([...chunks, Buffer.alloc(1024)]));
2116
+ } finally {
2117
+ await rm(root, {
2118
+ recursive: true,
2119
+ force: true
2120
+ });
2121
+ }
2122
+ }
2123
+ function failureMessage({ status, _data }) {
2124
+ const upgrade = _data?.data?.upgradeUrl ? ` Upgrade at ${_data.data.upgradeUrl}.` : "";
2125
+ return `Kubb Studio refused the snapshot (status ${status})${_data?.message ? `: ${_data.message}` : ""}.${upgrade}`;
2126
+ }
2127
+ /**
2128
+ * Generates the project, packs it as an npm tarball, and publishes it to Studio in one
2129
+ * `POST /api/snapshots` request.
2130
+ *
2131
+ * @example
2132
+ * ```ts
2133
+ * const snapshot = await createSnapshot({
2134
+ * apiKey: process.env.KUBB_TOKEN!,
2135
+ * config,
2136
+ * id: 'gh:123:pr-4',
2137
+ * name: 'acme/api#4',
2138
+ * packageName: '@acme/api',
2139
+ * version: '1.0.0',
2140
+ * })
2141
+ * console.log(snapshot.url, snapshot.changes)
2142
+ * ```
2143
+ */
2144
+ async function createSnapshot({ studioUrl = defaultStudioUrl, apiKey, config, hooks, signal, ...metadata }) {
2145
+ const url = secureUrl({
2146
+ studioUrl,
2147
+ path: "/api/snapshots"
2148
+ });
2149
+ const generated = await generate({
2150
+ config,
2151
+ hooks,
2152
+ signal,
2153
+ allowExec: true
2154
+ });
2155
+ const files = Object.fromEntries(await Promise.all([...generated.paths].map(async (path) => {
2156
+ const content = await generated.storage.readItem(resolve(generated.root, path));
2157
+ if (content === null) throw new Error(`The generation listed ${path} but did not keep its content`);
2158
+ return [path, content];
2159
+ })));
2160
+ const { peerDependencies, missingDependencies } = await resolvePeerDependencies(config.plugins.map(({ name }) => name));
2161
+ const missing = missingDependencies.filter((dependency) => !BUNDLED_DEPENDENCIES.includes(dependency));
2162
+ if (missing.length) throw new Error(`Missing dependencies: ${missing.join(", ")}`);
2163
+ const bytes = await createSnapshotPackage({
2164
+ files,
2165
+ packageInfo: {
2166
+ name: metadata.packageName,
2167
+ version: metadata.version,
2168
+ peerDependencies
2169
+ }
2170
+ });
2171
+ if (bytes.length > 52428800) throw new Error(`The snapshot is ${Math.ceil(bytes.length / 1024 / 1024)} MB, over the ${MAX_SNAPSHOT_BYTES / 1024 / 1024} MB limit`);
2172
+ const hashes = Object.fromEntries(Object.entries(files).map(([path, content]) => [path, hashOf(content)]));
2173
+ const body = new FormData();
2174
+ body.set("metadata", JSON.stringify({
2175
+ ...metadata,
2176
+ hashes,
2177
+ peerDependencies
2178
+ }));
2179
+ body.set("tarball", new Blob([new Uint8Array(bytes)], { type: "application/gzip" }), "snapshot.tgz");
2180
+ const timeout = AbortSignal.timeout(UPLOAD_TIMEOUT_MS);
2181
+ const response = await ofetch.raw(url.href, {
2182
+ method: "POST",
2183
+ headers: { "x-api-key": apiKey },
2184
+ body,
2185
+ redirect: "error",
2186
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
2187
+ ignoreResponseError: true
2188
+ });
2189
+ if (!response.ok) throw new Error(failureMessage(response));
2190
+ const snapshot = response._data?.snapshot;
2191
+ if (!snapshot) throw new Error(`Kubb Studio returned no snapshot (status ${response.status})`);
2192
+ return snapshot;
2193
+ }
2194
+ //#endregion
2195
+ export { IncompatibleAgentError, InvalidAgentTokenError, PairingCanceledError, PairingDeniedError, PairingExpiredError, connectAgent, createSnapshot, defaultStudioUrl, pairAgent };
2080
2196
 
2081
2197
  //# sourceMappingURL=index.js.map