@kubb/studio 5.6.0 → 5.6.2

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