@kubb/studio 0.0.0-canary-20260903193839

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1319 @@
1
+ import { t as __name } from "./rolldown-runtime-CRm0XQPb.js";
2
+ import { n as mergeAdapter, r as mergePlugins } from "./resolveConfig-B9oGiNMi.js";
3
+ import { isCommandMessage, isDisconnectMessage, isStudioPingMessage } from "./protocol.js";
4
+ import { styleText } from "node:util";
5
+ import { readFile, writeFile } from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { spawn } from "node:child_process";
8
+ import { FetchError, ofetch } from "ofetch";
9
+ import { hash, randomBytes } from "node:crypto";
10
+ import process$1 from "node:process";
11
+ import { createStorage } from "unstorage";
12
+ import fsDriver from "unstorage/drivers/fs";
13
+ import { Diagnostics, Hookable, createKubb, fsStorage, memoryStorage } from "@kubb/core";
14
+ import { x } from "tinyexec";
15
+ import WebSocket from "ws";
16
+ import { setTimeout as setTimeout$1 } from "node:timers/promises";
17
+ //#region src/constants.ts
18
+ /**
19
+ * Hosted Kubb Studio URL. Exported so credential stores can bind tokens to the resolved instance,
20
+ * not whatever default the client would pick on its own.
21
+ */
22
+ const defaultStudioUrl = "https://kubb.studio";
23
+ /**
24
+ * Defaults the Studio client uses when a host passes nothing.
25
+ * Config path is left out on purpose: each host discovers that itself.
26
+ */
27
+ const agentDefaults = {
28
+ studioUrl: defaultStudioUrl,
29
+ retryIntervalMs: 3e4,
30
+ /**
31
+ * Maximum heartbeat interval. Studio drops agents from the active list after ~90s without a ping,
32
+ * so a slower override would make a healthy agent look dead.
33
+ */
34
+ heartbeatIntervalMs: 3e4,
35
+ poolSize: 1
36
+ };
37
+ //#endregion
38
+ //#region ../../internals/utils/src/errors.ts
39
+ /**
40
+ * Coerces an unknown thrown value to an `Error` instance.
41
+ * Returns the value as-is when it is already an `Error`; otherwise wraps it with `String(value)`.
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * try { ... } catch(err) {
46
+ * throw new Error('Build failed', { cause: toError(err) })
47
+ * }
48
+ * ```
49
+ */
50
+ function toError(value) {
51
+ return value instanceof Error ? value : new Error(String(value));
52
+ }
53
+ /**
54
+ * Extracts a human-readable message from any thrown value.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * getErrorMessage(new Error('oops')) // 'oops'
59
+ * getErrorMessage('plain string') // 'plain string'
60
+ * ```
61
+ */
62
+ function getErrorMessage(value) {
63
+ return value instanceof Error ? value.message : String(value);
64
+ }
65
+ //#endregion
66
+ //#region ../../internals/utils/src/promise.ts
67
+ /**
68
+ * Wraps `factory` with a keyed cache backed by the provided store.
69
+ *
70
+ * Pass a `WeakMap` for object keys (results are GC-eligible when the key is
71
+ * collected) or a `Map` for primitive keys. For multi-argument functions,
72
+ * nest two `memoize` calls — the outer keyed by the first argument, the
73
+ * inner (created once per outer miss) keyed by the second.
74
+ *
75
+ * Because the cache is owned by the caller, it can be shared, inspected, or
76
+ * cleared independently of the memoized function.
77
+ *
78
+ * @example Single WeakMap key
79
+ * ```ts
80
+ * const cache = new WeakMap<SchemaNode, Set<string>>()
81
+ * const getRefs = memoize(cache, (node) => collectRefs(node))
82
+ * ```
83
+ *
84
+ * @example Single Map key (primitive)
85
+ * ```ts
86
+ * const cache = new Map<string, Resolver>()
87
+ * const getResolver = memoize(cache, (name) => buildResolver(name))
88
+ * ```
89
+ *
90
+ * @example Two-level (object + primitive)
91
+ * ```ts
92
+ * const outer = new WeakMap<Params[], Map<string, Params[]>>()
93
+ * const fn = memoize(outer, (params) => memoize(new Map(), (key) => transform(params, key)))
94
+ * fn(params)('camelcase')
95
+ * ```
96
+ */
97
+ function memoize(store, factory) {
98
+ return (key) => {
99
+ if (store.has(key)) return store.get(key);
100
+ const value = factory(key);
101
+ store.set(key, value);
102
+ return value;
103
+ };
104
+ }
105
+ /**
106
+ * Runs `run` over every item with at most `limit` in flight. Workers share one iterator, so each
107
+ * takes the next item the moment it frees up instead of waiting for a batch to drain.
108
+ *
109
+ * @example
110
+ * ```ts
111
+ * await inParallel({ items: files, limit: 50, run: (file) => storage.writeItem(file.path, file.source) })
112
+ * ```
113
+ */
114
+ async function inParallel({ items, limit, run }) {
115
+ const queue = items.entries();
116
+ const worker = async () => {
117
+ for (const [index, item] of queue) await run(item, index);
118
+ };
119
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
120
+ }
121
+ //#endregion
122
+ //#region ../../internals/utils/src/tools.ts
123
+ /**
124
+ * CLI command descriptors for each supported code formatter.
125
+ */
126
+ const formatters = {
127
+ prettier: {
128
+ command: "prettier",
129
+ args: (outputPath) => [
130
+ "--ignore-unknown",
131
+ "--write",
132
+ outputPath
133
+ ],
134
+ errorMessage: "Prettier not found"
135
+ },
136
+ biome: {
137
+ command: "biome",
138
+ args: (outputPath) => [
139
+ "format",
140
+ "--write",
141
+ outputPath
142
+ ],
143
+ errorMessage: "Biome not found"
144
+ },
145
+ oxfmt: {
146
+ command: "oxfmt",
147
+ args: (outputPath) => [outputPath],
148
+ errorMessage: "Oxfmt not found"
149
+ }
150
+ };
151
+ /**
152
+ * CLI command descriptors for each supported linter.
153
+ */
154
+ const linters = {
155
+ eslint: {
156
+ command: "eslint",
157
+ args: (outputPath) => [outputPath, "--fix"],
158
+ errorMessage: "Eslint not found"
159
+ },
160
+ biome: {
161
+ command: "biome",
162
+ args: (outputPath) => [
163
+ "lint",
164
+ "--fix",
165
+ outputPath
166
+ ],
167
+ errorMessage: "Biome not found"
168
+ },
169
+ oxlint: {
170
+ command: "oxlint",
171
+ args: (outputPath) => [
172
+ "--fix",
173
+ "--no-ignore",
174
+ outputPath
175
+ ],
176
+ errorMessage: "Oxlint not found"
177
+ }
178
+ };
179
+ /**
180
+ * Preference order for `format: 'auto'`, most-preferred first. Spelled out rather than taken from
181
+ * the table's key order, which is arbitrary and would silently change what `auto` picks.
182
+ */
183
+ const FORMATTER_PREFERENCE = [
184
+ "oxfmt",
185
+ "biome",
186
+ "prettier"
187
+ ];
188
+ /**
189
+ * Preference order for `lint: 'auto'`, most-preferred first.
190
+ */
191
+ const LINTER_PREFERENCE = [
192
+ "oxlint",
193
+ "biome",
194
+ "eslint"
195
+ ];
196
+ /**
197
+ * Whether `name` is on PATH and answers `--version` with a zero exit.
198
+ */
199
+ function isToolAvailable(name) {
200
+ return new Promise((resolve) => {
201
+ const child = spawn(name, ["--version"], { stdio: "ignore" });
202
+ child.on("close", (code) => resolve(code === 0));
203
+ child.on("error", () => resolve(false));
204
+ });
205
+ }
206
+ /**
207
+ * Returns the first installed executable from `candidates`, or `null` when none are found.
208
+ *
209
+ * Not memoized: a long-running host that probes repeatedly should cache the result itself, and a
210
+ * `--watch` build should keep noticing a tool installed mid-session.
211
+ */
212
+ async function detectTool$1(candidates) {
213
+ for (const candidate of candidates) if (await isToolAvailable(candidate)) return candidate;
214
+ return null;
215
+ }
216
+ __name(detectTool$1, "detectTool");
217
+ /**
218
+ * Tokenizes a shell command string, respecting single and double quotes.
219
+ *
220
+ * @example
221
+ * ```ts
222
+ * tokenize('git commit -m "initial commit"')
223
+ * // → ['git', 'commit', '-m', 'initial commit']
224
+ * ```
225
+ */
226
+ function tokenize(command) {
227
+ return (command.match(/[^\s"']+|"([^"]*)"|'([^']*)'/g) ?? []).map((token) => token.replace(/^["']|["']$/g, ""));
228
+ }
229
+ //#endregion
230
+ //#region ../../internals/utils/src/time.ts
231
+ /**
232
+ * Calculates elapsed time in milliseconds from a high-resolution `process.hrtime` start time.
233
+ * Rounds to 2 decimal places for sub-millisecond precision without noise.
234
+ *
235
+ * @example
236
+ * ```ts
237
+ * const start = process.hrtime()
238
+ * doWork()
239
+ * getElapsedMs(start) // 42.35
240
+ * ```
241
+ */
242
+ function getElapsedMs(hrStart) {
243
+ const [seconds, nanoseconds] = process.hrtime(hrStart);
244
+ const ms = seconds * 1e3 + nanoseconds / 1e6;
245
+ return Math.round(ms * 100) / 100;
246
+ }
247
+ //#endregion
248
+ //#region src/machine.ts
249
+ /**
250
+ * Key-value storage the runtime uses for its machine secret and the last Studio config.
251
+ *
252
+ * One storage per process, since one process serves one config file. Hosts install their own
253
+ * driver on startup: Nitro passes its `kubb` mount, the CLI an fs driver under `~/.kubb/cache`.
254
+ * The in-memory default keeps the runtime usable without a host, at the cost of a machine
255
+ * identity that changes on every restart.
256
+ */
257
+ let storage = createStorage();
258
+ let hasInstalledStorage = false;
259
+ /**
260
+ * Installs the storage driver the runtime persists to. Call once, before connecting.
261
+ */
262
+ function setStorage(next) {
263
+ storage = next;
264
+ hasInstalledStorage = true;
265
+ }
266
+ /**
267
+ * A storage backed by files under `base`, so the machine secret and the last Studio config
268
+ * survive a restart. Repeated pairings of one machine depend on that secret staying put.
269
+ */
270
+ function createFileStorage(base) {
271
+ return createStorage({ driver: fsDriver({ base }) });
272
+ }
273
+ let fallbackSecretPromise = null;
274
+ /**
275
+ * Loads the fallback machine secret from the runtime storage.
276
+ * On first use it generates a secret and persists it, so the machine identity stays
277
+ * stable across restarts. An identity that changes on every boot breaks session
278
+ * creation with Studio whenever the startup registration call fails.
279
+ */
280
+ async function loadOrCreateFallbackSecret() {
281
+ if (!hasInstalledStorage) console.warn(styleText("yellow", "Deriving the machine token before a storage driver was installed"), "call setStorage() first, or set KUBB_AGENT_SECRET, to keep a stable machine identity across restarts");
282
+ const stored = await storage.getItem("machine-secret").catch(() => null);
283
+ if (typeof stored === "string" && stored) return stored;
284
+ const secret = randomBytes(32).toString("hex");
285
+ await storage.setItem("machine-secret", secret).catch(() => {
286
+ console.warn(styleText("yellow", "Could not persist the generated machine secret"), "set KUBB_AGENT_SECRET to keep a stable machine identity across restarts");
287
+ });
288
+ return secret;
289
+ }
290
+ /**
291
+ * Returns the machine token derived from the `KUBB_AGENT_SECRET` environment variable.
292
+ * Falls back to a generated secret persisted in the runtime storage if the env var is not set.
293
+ * The token is hashed with SHA-256.
294
+ */
295
+ async function getMachineToken() {
296
+ if (process$1.env.KUBB_AGENT_SECRET) return hash("sha256", process$1.env.KUBB_AGENT_SECRET);
297
+ fallbackSecretPromise ??= loadOrCreateFallbackSecret();
298
+ return hash("sha256", await fallbackSecretPromise);
299
+ }
300
+ //#endregion
301
+ //#region src/api.ts
302
+ /**
303
+ * Reads a human-readable message from a Studio JSON error body, when it has one. `FetchError`'s own
304
+ * message stops at the status line, so the detail Studio sends with a failure (an agent limit, a
305
+ * revoked token) would otherwise never reach the user.
306
+ */
307
+ function responseMessage(data) {
308
+ if (!data || typeof data !== "object") return;
309
+ const body = data;
310
+ for (const value of [
311
+ body.error_description,
312
+ body.message,
313
+ body.error
314
+ ]) if (typeof value === "string" && value) return value;
315
+ }
316
+ /**
317
+ * Retries after the first registration attempt, each backing off twice as far as the last.
318
+ */
319
+ const REGISTER_RETRIES = 3;
320
+ /**
321
+ * Shared in-flight registration so concurrent pool sessions trigger one purge, not N.
322
+ */
323
+ let registrationInFlight = null;
324
+ /**
325
+ * Thrown when Studio rejects the agent token itself (401). Retrying cannot help: the token was
326
+ * revoked, or the agent it belonged to was deleted in the Studio UI. Hosts catch this to forget
327
+ * the stored credential and pair again.
328
+ */
329
+ var InvalidAgentTokenError = class extends Error {
330
+ constructor(studioUrl, options) {
331
+ super(`Kubb Studio rejected this agent's token. It was revoked or the agent was deleted in ${studioUrl}.`, options);
332
+ this.name = "InvalidAgentTokenError";
333
+ }
334
+ };
335
+ /**
336
+ * Whether a thrown value carries `statusCode`. Not narrowed to `FetchError`: a host wrapper can
337
+ * throw its own error shape with the same field.
338
+ *
339
+ * A 401 means the agent token itself was rejected. A 403 from the session create endpoint means
340
+ * the machine token stored in Studio no longer matches this agent (missing or mismatched).
341
+ */
342
+ function rejectedWith(error, statusCode) {
343
+ return error?.statusCode === statusCode;
344
+ }
345
+ function sessionError(cause) {
346
+ const detail = (cause instanceof FetchError ? responseMessage(cause.data) : void 0) ?? getErrorMessage(cause);
347
+ return new Error(detail ? `Failed to get agent session from Kubb Studio: ${detail}` : "Failed to get agent session from Kubb Studio", { cause });
348
+ }
349
+ /**
350
+ * Performs the raw session create request against Studio.
351
+ */
352
+ async function requestAgentSession({ token, studioUrl }) {
353
+ const url = `${studioUrl}/api/agent/sessions`;
354
+ const data = await ofetch(url, {
355
+ method: "POST",
356
+ headers: { Authorization: `Bearer ${token}` },
357
+ body: { machineToken: await getMachineToken() }
358
+ });
359
+ if (!data) throw new Error("No data available for agent session");
360
+ return data;
361
+ }
362
+ /**
363
+ * Obtain an agent session token from Kubb Studio via HTTP.
364
+ *
365
+ * When Studio rejects the machine token (403), for example after the agent restarted
366
+ * with a new identity while the startup registration call failed, the agent re-registers
367
+ * and retries once, so a single failed registration can't permanently block session creation.
368
+ */
369
+ async function createAgentSession({ token, studioUrl }) {
370
+ try {
371
+ return await requestAgentSession({
372
+ token,
373
+ studioUrl
374
+ });
375
+ } catch (error) {
376
+ if (rejectedWith(error, 401)) throw new InvalidAgentTokenError(studioUrl, { cause: error });
377
+ if (!rejectedWith(error, 403) || !await registerAgent({
378
+ token,
379
+ studioUrl
380
+ })) throw sessionError(error);
381
+ try {
382
+ return await requestAgentSession({
383
+ token,
384
+ studioUrl
385
+ });
386
+ } catch (retryError) {
387
+ if (rejectedWith(retryError, 401)) throw new InvalidAgentTokenError(studioUrl, { cause: retryError });
388
+ throw sessionError(retryError);
389
+ }
390
+ }
391
+ }
392
+ /**
393
+ * Register this agent with Kubb Studio by sending the machine ID.
394
+ * Called on agent startup before creating a WebSocket session, and again when
395
+ * Studio rejects the machine token during session creation.
396
+ *
397
+ * Retries with backoff because a failed registration leaves Studio with a stale
398
+ * machine token that blocks every subsequent session create call. Registration
399
+ * purges all of the agent's sessions on the Studio side, so concurrent callers
400
+ * (multiple pool sessions hitting a 403 at once) share one in-flight run instead
401
+ * of purging each other's fresh sessions.
402
+ */
403
+ function registerAgent(props) {
404
+ registrationInFlight ??= runRegistration(props).finally(() => {
405
+ registrationInFlight = null;
406
+ });
407
+ return registrationInFlight;
408
+ }
409
+ async function runRegistration({ token, studioUrl, poolSize }) {
410
+ const machineToken = await getMachineToken();
411
+ try {
412
+ await ofetch(`${studioUrl}/api/agent/connect`, {
413
+ method: "POST",
414
+ headers: { Authorization: `Bearer ${token}` },
415
+ body: {
416
+ machineToken,
417
+ poolSize
418
+ },
419
+ retry: REGISTER_RETRIES,
420
+ retryDelay: ({ options }) => 2e3 * 2 ** (REGISTER_RETRIES - Number(options.retry))
421
+ });
422
+ return true;
423
+ } catch (error) {
424
+ if (rejectedWith(error, 401)) throw new InvalidAgentTokenError(studioUrl, { cause: error });
425
+ console.error(styleText("red", `Failed to register agent with Studio after 4 attempts`));
426
+ return false;
427
+ }
428
+ }
429
+ /**
430
+ * Notify Kubb Studio that this agent is disconnecting.
431
+ * Called on process termination or server close. A failed notify is logged and swallowed: the
432
+ * local socket is already gone, and failing teardown must not block shutdown or reconnect.
433
+ */
434
+ async function disconnect({ sessionId, token, studioUrl, slug }) {
435
+ const url = `${studioUrl}/api/agent/sessions/${sessionId}/disconnect`;
436
+ const tag = slug ?? "agent";
437
+ try {
438
+ await ofetch(url, {
439
+ method: "POST",
440
+ headers: { Authorization: `Bearer ${token}` }
441
+ });
442
+ console.log(styleText("green", `[${tag}] Disconnected from Studio`));
443
+ } catch (error) {
444
+ console.warn(styleText("yellow", `[${tag}] Failed to notify Studio of disconnection: ${getErrorMessage(error)}`));
445
+ }
446
+ }
447
+ //#endregion
448
+ //#region package.json
449
+ var version = "5.1.0";
450
+ //#endregion
451
+ //#region src/hooks.ts
452
+ /**
453
+ * Register a `kubb:hook:start` listener that spawns the requested command via tinyexec,
454
+ * streams each stdout line as a `kubb:hook:line` event, and calls `kubb:hook:end` with the result.
455
+ * Streaming the output lets Kubb Studio render live hook progress over the WebSocket connection.
456
+ */
457
+ function setupHookListener(hooks, root) {
458
+ hooks.hook("kubb:hook:start", async (ctx) => {
459
+ const { id, command, args } = ctx;
460
+ if (!id) return;
461
+ const commandWithArgs = args?.length ? `${command} ${args.join(" ")}` : command;
462
+ try {
463
+ const proc = x(command, [...args ?? []], { nodeOptions: {
464
+ cwd: root,
465
+ detached: true
466
+ } });
467
+ for await (const line of proc) await hooks.callHook("kubb:hook:line", {
468
+ id,
469
+ line
470
+ });
471
+ const { exitCode } = await proc;
472
+ if (exitCode !== 0) {
473
+ const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
474
+ await hooks.callHook("kubb:hook:end", {
475
+ id,
476
+ command,
477
+ args,
478
+ success: false,
479
+ error
480
+ });
481
+ await hooks.callHook("kubb:error", { error });
482
+ return;
483
+ }
484
+ await hooks.callHook("kubb:hook:end", {
485
+ id,
486
+ command,
487
+ args,
488
+ success: true,
489
+ error: null
490
+ });
491
+ } catch (caughtError) {
492
+ const error = /* @__PURE__ */ new Error(`Hook execute failed: ${commandWithArgs}`);
493
+ error.cause = caughtError;
494
+ await hooks.callHook("kubb:hook:end", {
495
+ id,
496
+ command,
497
+ args,
498
+ success: false,
499
+ error
500
+ });
501
+ await hooks.callHook("kubb:error", { error });
502
+ }
503
+ });
504
+ }
505
+ /**
506
+ * Waits for the `kubb:hook:end` matching `hookId`. Register this before calling `kubb:hook:start`:
507
+ * `callHook` awaits its listeners, and {@link setupHookListener} calls `kubb:hook:end` from inside
508
+ * that same listener, so a handler added afterward would already have missed it.
509
+ */
510
+ function waitForHookEnd(hooks, hookId) {
511
+ return new Promise((resolve, reject) => {
512
+ const handleHookEnd = (ctx) => {
513
+ if (ctx.id !== hookId) return;
514
+ hooks.removeHook("kubb:hook:end", handleHookEnd);
515
+ if (ctx.success) resolve();
516
+ else reject(ctx.error);
517
+ };
518
+ hooks.hook("kubb:hook:end", handleHookEnd);
519
+ });
520
+ }
521
+ //#endregion
522
+ //#region src/generate.ts
523
+ /**
524
+ * `isToolAvailable` spawns a process, and a long-lived connection generates repeatedly, so each
525
+ * executable is probed once per process. The CLI deliberately does not memoize: a `--watch` build
526
+ * should keep noticing a tool installed mid-session.
527
+ */
528
+ const detectTool = memoize(/* @__PURE__ */ new Map(), detectTool$1);
529
+ /**
530
+ * The two post-build tool steps. Formatting and linting differ only in which tools they look for,
531
+ * so they run through one loop rather than two near-identical blocks.
532
+ *
533
+ * `noun` and `verbing` are spelled out instead of built from `kind`. Concatenating `` `${kind}ter` ``
534
+ * and `` `${kind}ting` `` works for `format`, but doubles the `t` in `lint`, giving "lintter" and
535
+ * "lintting" instead of "linter" and "linting".
536
+ */
537
+ const TOOL_STEPS = [{
538
+ kind: "format",
539
+ noun: "formatter",
540
+ verbing: "Formatting",
541
+ tools: formatters,
542
+ detect: FORMATTER_PREFERENCE
543
+ }, {
544
+ kind: "lint",
545
+ noun: "linter",
546
+ verbing: "Linting",
547
+ tools: linters,
548
+ detect: LINTER_PREFERENCE
549
+ }];
550
+ /**
551
+ * Absolute path of the directory the formatter and linter are pointed at.
552
+ */
553
+ function outputPath(config) {
554
+ return path.isAbsolute(config.output.path) ? config.output.path : path.resolve(process$1.cwd(), config.root, config.output.path);
555
+ }
556
+ /**
557
+ * Emits `kubb:hook:start` and waits for the matching `kubb:hook:end`. The host spawns the process:
558
+ * this only describes what to run and when it finished.
559
+ *
560
+ * @throws whatever the command failed with, so callers can report it their own way.
561
+ */
562
+ async function runHook({ hooks, id, command, args }) {
563
+ const hookId = hash("sha256", id);
564
+ const hookEnd = waitForHookEnd(hooks, hookId);
565
+ await hooks.callHook("kubb:hook:start", {
566
+ id: hookId,
567
+ command,
568
+ args: [...args]
569
+ });
570
+ await hookEnd;
571
+ }
572
+ function isProblemErrorDiagnostic(diagnostic) {
573
+ return (diagnostic.kind ?? "problem") === "problem" && diagnostic.severity === "error";
574
+ }
575
+ /**
576
+ * Folds error-severity diagnostics into one thrown error so logs name the failing plugin.
577
+ */
578
+ function formatGenerationFailure(diagnostics) {
579
+ const reasons = diagnostics.filter(isProblemErrorDiagnostic).map((diagnostic) => diagnostic.plugin ? `${diagnostic.plugin}: ${diagnostic.message}` : diagnostic.message);
580
+ if (!reasons.length) return /* @__PURE__ */ new Error("Generation failed");
581
+ return /* @__PURE__ */ new Error(`Generation failed: ${reasons.length} error${reasons.length === 1 ? "" : "s"}: ${reasons.join("; ")}`);
582
+ }
583
+ /**
584
+ * Runs a full Kubb code-generation cycle for the given config.
585
+ *
586
+ * Emits lifecycle events on the provided `hooks` emitter so callers (e.g. the WebSocket stream)
587
+ * can forward progress to connected clients. After a successful build, auto-formatting and
588
+ * linting are applied when configured, followed by any user-defined `hooks.done` commands.
589
+ */
590
+ async function generate({ config, hooks }) {
591
+ const hrStart = process$1.hrtime();
592
+ await hooks.callHook("kubb:generation:start", { config });
593
+ await hooks.callHook("kubb:info", { message: config.name ? `Setup generation ${config.name}` : "Setup generation" });
594
+ const kubb = createKubb(config, { hooks });
595
+ await kubb.setup();
596
+ await hooks.callHook("kubb:info", { message: config.name ? `Build generation ${config.name}` : "Build generation" });
597
+ const { files, diagnostics, storage } = await kubb.safeBuild();
598
+ await hooks.callHook("kubb:info", { message: "Load summary" });
599
+ for (const diagnostic of diagnostics.filter(isProblemErrorDiagnostic)) await hooks.callHook("kubb:error", { error: new Error(diagnostic.plugin ? `${diagnostic.plugin}: ${diagnostic.message}` : diagnostic.message) });
600
+ const status = Diagnostics.hasError(diagnostics) ? "failed" : "success";
601
+ await hooks.callHook("kubb:generation:end", {
602
+ config,
603
+ storage: {
604
+ ...storage,
605
+ readKeys: async () => [...new Set(files.map((file) => file.path))]
606
+ },
607
+ diagnostics,
608
+ status,
609
+ hrStart,
610
+ filesCreated: files.length
611
+ });
612
+ if (status === "failed") throw formatGenerationFailure(diagnostics);
613
+ await hooks.callHook("kubb:success", { message: "Generation successfully" });
614
+ for (const step of TOOL_STEPS) {
615
+ const setting = config.output[step.kind];
616
+ if (!setting) continue;
617
+ await hooks.callHook(`kubb:${step.kind}:start`);
618
+ const tool = setting === "auto" ? await detectTool(step.detect) : setting;
619
+ if (!tool) await hooks.callHook("kubb:warn", { message: `No ${step.noun} found (${step.detect.join(", ")}). Skipping ${step.verbing.toLowerCase()}.` });
620
+ if (tool && setting === "auto") await hooks.callHook("kubb:info", { message: `Auto-detected ${step.noun}: ${styleText("dim", tool)}` });
621
+ const command = tool ? step.tools[tool] : void 0;
622
+ if (command) try {
623
+ await runHook({
624
+ hooks,
625
+ id: [config.name, tool].filter(Boolean).join("-"),
626
+ command: command.command,
627
+ args: command.args(outputPath(config))
628
+ });
629
+ await hooks.callHook("kubb:success", { message: `${step.verbing} with ${tool} successfully` });
630
+ } catch (caughtError) {
631
+ await hooks.callHook("kubb:error", { error: new Error(command.errorMessage, { cause: caughtError }) });
632
+ }
633
+ await hooks.callHook(`kubb:${step.kind}:end`);
634
+ }
635
+ if (config.output.postGenerate?.length) {
636
+ await hooks.callHook("kubb:hooks:start");
637
+ for (const entry of config.output.postGenerate) {
638
+ const line = typeof entry === "string" ? entry : entry.command;
639
+ const [cmd, ...args] = tokenize(line);
640
+ if (!cmd) continue;
641
+ await runHook({
642
+ hooks,
643
+ id: line,
644
+ command: cmd,
645
+ args
646
+ });
647
+ await hooks.callHook("kubb:success", { message: `${line} successfully executed` });
648
+ }
649
+ await hooks.callHook("kubb:hooks:end");
650
+ }
651
+ }
652
+ //#endregion
653
+ //#region src/ws.ts
654
+ /**
655
+ * How many generated files are read from storage at once when building the
656
+ * `kubb:generation:end` payload. A spec producing thousands of files would otherwise fire one
657
+ * `storage.readItem` per file simultaneously.
658
+ */
659
+ const FILE_READ_CONCURRENCY = 50;
660
+ /**
661
+ * How long the initial handshake may take before the socket is closed and the reconnect loop
662
+ * takes over.
663
+ */
664
+ const CONNECT_TIMEOUT_MS = 5e3;
665
+ /**
666
+ * Per-socket event counter. Every data message carries the next value so Studio can restore the
667
+ * agent's emission order even when the relay delivers frames out of order. Keyed by the socket so
668
+ * the count stays monotonic across every generation run on one connection, and is dropped
669
+ * automatically once the socket is collected.
670
+ */
671
+ const eventSeqCounters = /* @__PURE__ */ new WeakMap();
672
+ function nextEventSeq(ws) {
673
+ const seq = eventSeqCounters.get(ws) ?? 0;
674
+ eventSeqCounters.set(ws, seq + 1);
675
+ return seq;
676
+ }
677
+ /**
678
+ * Opens a Studio WebSocket connection and closes it when the initial handshake exceeds the configured timeout.
679
+ */
680
+ function createWebsocket(url, options) {
681
+ const ws = new WebSocket(url, options);
682
+ const timer = setTimeout(() => {
683
+ if (ws.readyState === WebSocket.CONNECTING) ws.close(3008, "Connection timeout");
684
+ }, CONNECT_TIMEOUT_MS);
685
+ ws.once("open", () => clearTimeout(timer));
686
+ ws.once("close", () => clearTimeout(timer));
687
+ return ws;
688
+ }
689
+ /**
690
+ * Sends a serialized agent message when the Studio socket is ready to accept frames.
691
+ */
692
+ function sendAgentMessage(ws, message) {
693
+ try {
694
+ if (ws.readyState !== WebSocket.OPEN) return;
695
+ ws.send(JSON.stringify(message));
696
+ } catch (error) {
697
+ throw new Error("Failed to send message to Kubb Studio", { cause: error });
698
+ }
699
+ }
700
+ /**
701
+ * Sends a single `kubb:error` payload to Studio, stamped from the same per-socket counter the event stream
702
+ * uses so Studio can still order it against the generation events around it.
703
+ */
704
+ function sendErrorMessage(ws, error) {
705
+ sendAgentMessage(ws, {
706
+ type: "agent:data",
707
+ payload: {
708
+ type: "kubb:error",
709
+ data: [{
710
+ message: error.message,
711
+ stack: error.stack
712
+ }],
713
+ timestamp: Date.now(),
714
+ seq: nextEventSeq(ws)
715
+ }
716
+ });
717
+ }
718
+ /**
719
+ * Forwards selected Kubb lifecycle events to Studio as data messages for the active session.
720
+ */
721
+ function setupEventsStream(ws, hooks) {
722
+ function sendDataMessage(payload) {
723
+ sendAgentMessage(ws, {
724
+ type: "agent:data",
725
+ payload: {
726
+ ...payload,
727
+ timestamp: Date.now(),
728
+ seq: nextEventSeq(ws)
729
+ }
730
+ });
731
+ }
732
+ hooks.hook("kubb:plugin:start", (ctx) => {
733
+ sendDataMessage({
734
+ type: "kubb:plugin:start",
735
+ data: [{ plugin: ctx.plugin }]
736
+ });
737
+ });
738
+ hooks.hook("kubb:plugin:end", (ctx) => {
739
+ sendDataMessage({
740
+ type: "kubb:plugin:end",
741
+ data: [{
742
+ plugin: ctx.plugin,
743
+ duration: ctx.duration,
744
+ success: ctx.success
745
+ }]
746
+ });
747
+ });
748
+ hooks.hook("kubb:build:start", ({ config, adapter }) => {
749
+ sendDataMessage({
750
+ type: "kubb:build:start",
751
+ data: [{
752
+ config: { name: config.name },
753
+ adapter: { name: adapter.name }
754
+ }]
755
+ });
756
+ });
757
+ hooks.hook("kubb:build:end", ({ files, outputDir }) => {
758
+ sendDataMessage({
759
+ type: "kubb:build:end",
760
+ data: [{
761
+ files: files.map((file) => ({
762
+ path: file.path,
763
+ name: file.name
764
+ })),
765
+ outputDir
766
+ }]
767
+ });
768
+ });
769
+ hooks.hook("kubb:files:processing:start", ({ files }) => {
770
+ sendDataMessage({
771
+ type: "kubb:files:processing:start",
772
+ data: [{ total: files.length }]
773
+ });
774
+ });
775
+ hooks.hook("kubb:files:processing:update", ({ files }) => {
776
+ sendDataMessage({
777
+ type: "kubb:files:processing:update",
778
+ data: [{ files: files.map(({ file, processed, total, percentage }) => ({
779
+ file: file.path,
780
+ processed,
781
+ total,
782
+ percentage
783
+ })) }]
784
+ });
785
+ });
786
+ hooks.hook("kubb:files:processing:end", ({ files }) => {
787
+ sendDataMessage({
788
+ type: "kubb:files:processing:end",
789
+ data: [{ total: files.length }]
790
+ });
791
+ });
792
+ for (const type of [
793
+ "kubb:info",
794
+ "kubb:success",
795
+ "kubb:warn"
796
+ ]) hooks.hook(type, ({ message, info }) => {
797
+ sendDataMessage({
798
+ type,
799
+ data: [{
800
+ message,
801
+ info
802
+ }]
803
+ });
804
+ });
805
+ hooks.hook("kubb:generation:start", ({ config }) => {
806
+ sendDataMessage({
807
+ type: "kubb:generation:start",
808
+ data: [{
809
+ name: config.name,
810
+ plugins: config.plugins.length
811
+ }]
812
+ });
813
+ });
814
+ hooks.hook("kubb:generation:end", async ({ config, storage, diagnostics = [], status, hrStart, filesCreated }) => {
815
+ const paths = await storage.readKeys();
816
+ const files = {};
817
+ await inParallel({
818
+ items: paths,
819
+ limit: FILE_READ_CONCURRENCY,
820
+ run: async (path) => {
821
+ const content = await storage.readItem(path);
822
+ if (content !== null) files[path] = content;
823
+ }
824
+ });
825
+ sendDataMessage({
826
+ type: "kubb:generation:end",
827
+ data: [{
828
+ config,
829
+ storage: files
830
+ }]
831
+ });
832
+ if (!hrStart) return;
833
+ sendDataMessage({
834
+ type: "kubb:generation:summary",
835
+ data: [{
836
+ duration: Math.round(getElapsedMs(hrStart)),
837
+ fileCount: filesCreated ?? 0,
838
+ failedPlugins: Diagnostics.failedPlugins(diagnostics).length,
839
+ status: status ?? "success"
840
+ }]
841
+ });
842
+ });
843
+ hooks.hook("kubb:error", ({ error }) => {
844
+ sendDataMessage({
845
+ type: "kubb:error",
846
+ data: [{
847
+ message: error.message,
848
+ stack: error.stack
849
+ }]
850
+ });
851
+ });
852
+ for (const type of [
853
+ "kubb:lifecycle:start",
854
+ "kubb:lifecycle:end",
855
+ "kubb:format:start",
856
+ "kubb:format:end",
857
+ "kubb:lint:start",
858
+ "kubb:lint:end",
859
+ "kubb:hooks:start",
860
+ "kubb:hooks:end"
861
+ ]) hooks.hook(type, () => {
862
+ sendDataMessage({
863
+ type,
864
+ data: []
865
+ });
866
+ });
867
+ hooks.hook("kubb:hook:start", ({ id, command, args }) => {
868
+ sendDataMessage({
869
+ type: "kubb:hook:start",
870
+ data: [{
871
+ id,
872
+ command,
873
+ args: args ? [...args] : void 0
874
+ }]
875
+ });
876
+ });
877
+ hooks.hook("kubb:hook:line", ({ id, line }) => {
878
+ sendDataMessage({
879
+ type: "kubb:hook:line",
880
+ data: [{
881
+ id,
882
+ line
883
+ }]
884
+ });
885
+ });
886
+ hooks.hook("kubb:hook:end", ({ id, command, args, success, error }) => {
887
+ sendDataMessage({
888
+ type: "kubb:hook:end",
889
+ data: [{
890
+ id,
891
+ command,
892
+ args: args ? [...args] : void 0,
893
+ success,
894
+ error: error ? {
895
+ message: error.message,
896
+ stack: error.stack
897
+ } : void 0
898
+ }]
899
+ });
900
+ });
901
+ }
902
+ //#endregion
903
+ //#region src/connectStudio.ts
904
+ /**
905
+ * Schedules another connection attempt.
906
+ *
907
+ * Hoisted out of `connectToStudio` on purpose: a pending retry timer reaches its whole enclosing
908
+ * scope, so keeping it inside would pin the closed socket, the hook emitter, and the session id
909
+ * alive for the length of every retry interval.
910
+ */
911
+ function reconnect(options) {
912
+ const { signal, retryInterval = agentDefaults.retryIntervalMs } = options;
913
+ if (signal?.aborted) return;
914
+ console.info(styleText("dim", `Retrying connection in ${retryInterval}ms to Kubb Studio ...`));
915
+ const cancel = () => clearTimeout(timer);
916
+ const timer = setTimeout(() => {
917
+ signal?.removeEventListener("abort", cancel);
918
+ if (signal?.aborted) return;
919
+ connectToStudio(options).catch((error) => {
920
+ console.error(styleText("red", `Reconnect attempt to Kubb Studio failed: ${getErrorMessage(error)}`));
921
+ if (error instanceof InvalidAgentTokenError) return;
922
+ reconnect(options);
923
+ });
924
+ }, retryInterval);
925
+ signal?.addEventListener("abort", cancel, { once: true });
926
+ }
927
+ async function connectToStudio(options) {
928
+ const { token, studioUrl = agentDefaults.studioUrl, configPath, loadConfig, version: version$1, client, allowWrite = false, allowConfigEdit = false, allowInput = false, allowExec = false, root = process$1.cwd(), heartbeatInterval: requestedHeartbeatInterval = agentDefaults.heartbeatIntervalMs, signal, installLogger } = options;
929
+ const heartbeatInterval = Math.min(requestedHeartbeatInterval, agentDefaults.heartbeatIntervalMs);
930
+ const hooks = new Hookable();
931
+ await installLogger?.(hooks);
932
+ try {
933
+ await hooks.callHook("studio:connecting", { url: studioUrl });
934
+ const { sessionId, slug, wsUrl, isSandbox, version: sessionStudioVersion } = await createAgentSession({
935
+ token,
936
+ studioUrl
937
+ });
938
+ let studioVersion = sessionStudioVersion;
939
+ const ws = createWebsocket(wsUrl, { headers: { Authorization: `Bearer ${token}` } });
940
+ const canWrite = isSandbox ? false : allowWrite;
941
+ const canEditConfig = isSandbox ? false : allowConfigEdit;
942
+ const configFilePath = path.resolve(root, configPath);
943
+ const canUseInput = isSandbox || allowInput;
944
+ let serverDisconnected = false;
945
+ let isGenerating = false;
946
+ let heartbeatTimer;
947
+ let lastPongAt = Date.now();
948
+ const onAbort = () => void teardown({
949
+ reason: "shutdown",
950
+ retry: false
951
+ });
952
+ function cleanup(reason = "cleanup") {
953
+ clearInterval(heartbeatTimer);
954
+ heartbeatTimer = void 0;
955
+ signal?.removeEventListener("abort", onAbort);
956
+ hooks.removeAllHooks();
957
+ try {
958
+ ws.close(1e3, reason);
959
+ } catch {}
960
+ ws.removeEventListener("open", onOpen);
961
+ ws.removeEventListener("close", onClose);
962
+ ws.removeEventListener("error", onError);
963
+ ws.removeEventListener("message", onMessage);
964
+ }
965
+ /**
966
+ * Reads `kubb.config.ts` and reports which plugin options Studio may edit.
967
+ *
968
+ * Skipped when the host did not grant `allowConfigEdit`. The patcher pulls in `magicast`
969
+ * (~25ms, ~55MB RSS), so read-only agents never import it.
970
+ *
971
+ * Not cached: the user can edit the file between two Studio actions.
972
+ */
973
+ async function readConfigFileView(source) {
974
+ if (!canEditConfig) return;
975
+ try {
976
+ const { readConfig } = await import("./configFile-ZnV5tPon.js");
977
+ return readConfig(source ?? await readFile(configFilePath, "utf-8"));
978
+ } catch (error) {
979
+ await hooks.callHook("studio:warn", { message: `Could not read ${configFilePath}: ${getErrorMessage(error)}` });
980
+ return;
981
+ }
982
+ }
983
+ async function sendConnectedPayload() {
984
+ const config = await loadConfig();
985
+ sendAgentMessage(ws, {
986
+ type: "agent:connect",
987
+ payload: {
988
+ versions: {
989
+ kubb: version,
990
+ agent: version$1
991
+ },
992
+ root,
993
+ config: {
994
+ path: configPath,
995
+ file: await readConfigFileView(),
996
+ plugins: config.plugins.map((plugin) => ({
997
+ name: `@kubb/${plugin.name}`,
998
+ options: plugin.options ?? {}
999
+ }))
1000
+ },
1001
+ permissions: {
1002
+ allowWrite: canWrite,
1003
+ allowInput: canUseInput,
1004
+ allowExec,
1005
+ allowConfigEdit: canEditConfig
1006
+ }
1007
+ }
1008
+ });
1009
+ }
1010
+ async function handleOpen() {
1011
+ lastPongAt = Date.now();
1012
+ await hooks.callHook("studio:connected", {
1013
+ url: studioUrl,
1014
+ versions: {
1015
+ studio: studioVersion,
1016
+ kubb: version,
1017
+ agent: version$1
1018
+ }
1019
+ });
1020
+ try {
1021
+ await sendConnectedPayload();
1022
+ } catch (error) {
1023
+ await hooks.callHook("studio:warn", { message: `Failed to send the connect payload: ${getErrorMessage(error)}` });
1024
+ }
1025
+ }
1026
+ const onOpen = () => void handleOpen().catch(() => {});
1027
+ /**
1028
+ * Drops the socket and tells Studio the session is over. `serverDisconnected` guards against
1029
+ * the close event running this a second time, and against a shutdown reconnecting.
1030
+ */
1031
+ async function teardown({ reason, retry }) {
1032
+ if (serverDisconnected) return;
1033
+ serverDisconnected = true;
1034
+ if (reason === "shutdown") sendAgentMessage(ws, {
1035
+ type: "agent:disconnect",
1036
+ reason: "shutdown"
1037
+ });
1038
+ cleanup(reason);
1039
+ await disconnect({
1040
+ sessionId,
1041
+ studioUrl,
1042
+ token,
1043
+ slug
1044
+ }).catch(() => {});
1045
+ if (retry) reconnect(options);
1046
+ }
1047
+ const onClose = () => teardown({ retry: true });
1048
+ const onError = () => {
1049
+ hooks.callHook("studio:error", { error: /* @__PURE__ */ new Error("Failed to connect to Kubb Studio") });
1050
+ return onClose();
1051
+ };
1052
+ ws.addEventListener("open", onOpen);
1053
+ ws.addEventListener("close", onClose);
1054
+ ws.addEventListener("error", onError);
1055
+ signal?.addEventListener("abort", onAbort, { once: true });
1056
+ heartbeatTimer = setInterval(() => {
1057
+ if (Date.now() - lastPongAt > heartbeatInterval * 2) {
1058
+ hooks.callHook("studio:warn", { message: "No reply from Kubb Studio, terminating the stale connection" });
1059
+ clearInterval(heartbeatTimer);
1060
+ heartbeatTimer = void 0;
1061
+ ws.terminate();
1062
+ return;
1063
+ }
1064
+ sendAgentMessage(ws, { type: "agent:ping" });
1065
+ }, heartbeatInterval);
1066
+ hooks.hook("kubb:error", ({ error }) => sendErrorMessage(ws, error));
1067
+ const onMessage = async (message) => {
1068
+ try {
1069
+ const data = JSON.parse(message.data);
1070
+ if (isStudioPingMessage(data)) {
1071
+ lastPongAt = Date.now();
1072
+ return;
1073
+ }
1074
+ if (isDisconnectMessage(data)) {
1075
+ await hooks.callHook("studio:disconnected", { reason: data.reason });
1076
+ if (data.reason === "revoked") {
1077
+ cleanup(`session_${data.reason}`);
1078
+ return;
1079
+ }
1080
+ if (data.reason === "expired") {
1081
+ cleanup();
1082
+ reconnect(options);
1083
+ return;
1084
+ }
1085
+ return;
1086
+ }
1087
+ if (isCommandMessage(data)) {
1088
+ const command = data.type.slice(7);
1089
+ await hooks.callHook("studio:command:start", { command });
1090
+ if (data.type === "studio:generate") {
1091
+ if (isGenerating) {
1092
+ await hooks.callHook("studio:warn", { message: "Ignored generate: a generation is already in progress" });
1093
+ await Promise.resolve(hooks.callHook("kubb:error", { error: /* @__PURE__ */ new Error("A generation is already in progress, please wait for it to finish") })).catch(() => {});
1094
+ return;
1095
+ }
1096
+ isGenerating = true;
1097
+ try {
1098
+ const config = await loadConfig();
1099
+ const patch = data.payload;
1100
+ const plugins = await mergePlugins(config.plugins, patch?.plugins);
1101
+ const adapter = await mergeAdapter(config.adapter, patch?.adapter);
1102
+ const inputOverride = isSandbox ? patch?.input ?? "" : allowInput && patch?.input || void 0;
1103
+ if (allowWrite && isSandbox) await hooks.callHook("studio:warn", { message: "Running in a sandbox, so writing files is disabled" });
1104
+ if (patch?.input && !canUseInput) {
1105
+ const remedy = client?.kind === "cli" ? "--allowInput, or answer yes when kubb studio asks," : "KUBB_AGENT_ALLOW_INPUT=true";
1106
+ await hooks.callHook("studio:warn", { message: `Ignored the spec from Studio; set ${remedy} to generate from it` });
1107
+ }
1108
+ const generationHooks = new Hookable();
1109
+ await installLogger?.(generationHooks);
1110
+ setupHookListener(generationHooks, root);
1111
+ setupEventsStream(ws, generationHooks);
1112
+ const resolvedPlugins = plugins ?? config.plugins;
1113
+ await generate({
1114
+ config: {
1115
+ ...config,
1116
+ root,
1117
+ input: inputOverride ?? config.input,
1118
+ storage: canWrite ? fsStorage() : memoryStorage(),
1119
+ output: allowExec ? { ...config.output } : {
1120
+ ...config.output,
1121
+ format: false,
1122
+ lint: false,
1123
+ postGenerate: []
1124
+ },
1125
+ plugins: resolvedPlugins,
1126
+ adapter
1127
+ },
1128
+ hooks: generationHooks
1129
+ });
1130
+ await hooks.callHook("studio:command:end", {
1131
+ command,
1132
+ info: `${resolvedPlugins.length} plugin${resolvedPlugins.length === 1 ? "" : "s"}, ${canWrite ? "written to disk" : "in memory"}${inputOverride !== void 0 ? ", from a Studio spec" : ""}`
1133
+ });
1134
+ } finally {
1135
+ isGenerating = false;
1136
+ }
1137
+ return;
1138
+ }
1139
+ if (data.type === "studio:connect") {
1140
+ studioVersion = data.version ?? studioVersion;
1141
+ await sendConnectedPayload();
1142
+ await hooks.callHook("studio:command:end", { command });
1143
+ return;
1144
+ }
1145
+ if (data.type === "studio:save") {
1146
+ if (!Array.isArray(data.edits)) {
1147
+ await hooks.callHook("studio:warn", { message: "Ignored save: the message carried no edits" });
1148
+ sendAgentMessage(ws, {
1149
+ type: "agent:save",
1150
+ payload: {
1151
+ outcomes: [],
1152
+ changed: false
1153
+ }
1154
+ });
1155
+ return;
1156
+ }
1157
+ const edits = data.edits;
1158
+ const refuse = (reason) => sendAgentMessage(ws, {
1159
+ type: "agent:save",
1160
+ payload: {
1161
+ outcomes: edits.map((edit) => ({
1162
+ edit,
1163
+ applied: false,
1164
+ reason
1165
+ })),
1166
+ changed: false
1167
+ }
1168
+ });
1169
+ if (!canEditConfig) {
1170
+ await hooks.callHook("studio:warn", { message: "Ignored save: editing kubb.config.ts was not granted" });
1171
+ refuse("the agent was not granted permission to edit kubb.config.ts");
1172
+ return;
1173
+ }
1174
+ if (isGenerating) {
1175
+ refuse("a generation is in progress");
1176
+ return;
1177
+ }
1178
+ try {
1179
+ const { applyConfigEdits } = await import("./configFile-ZnV5tPon.js");
1180
+ const { source: patched, outcomes, changed } = applyConfigEdits(await readFile(configFilePath, "utf-8"), edits);
1181
+ if (changed) await writeFile(configFilePath, patched, "utf-8");
1182
+ sendAgentMessage(ws, {
1183
+ type: "agent:save",
1184
+ payload: {
1185
+ outcomes,
1186
+ changed,
1187
+ file: changed ? await readConfigFileView(patched) : void 0
1188
+ }
1189
+ });
1190
+ const applied = outcomes.filter((outcome) => outcome.applied).length;
1191
+ await hooks.callHook("studio:command:end", {
1192
+ command,
1193
+ info: `applied ${applied}/${outcomes.length} edits to ${configPath}`
1194
+ });
1195
+ } catch (error) {
1196
+ await hooks.callHook("studio:error", { error: toError(error) });
1197
+ refuse(getErrorMessage(error));
1198
+ }
1199
+ return;
1200
+ }
1201
+ return;
1202
+ }
1203
+ await hooks.callHook("studio:warn", { message: `Ignored an unknown message from Kubb Studio: ${data.type}` });
1204
+ } catch (error) {
1205
+ await hooks.callHook("studio:error", { error: toError(error) });
1206
+ await Promise.resolve(hooks.callHook("kubb:error", { error: toError(error) })).catch(() => {});
1207
+ }
1208
+ };
1209
+ ws.addEventListener("message", onMessage);
1210
+ } catch (error) {
1211
+ await hooks.callHook("studio:error", { error: toError(error) });
1212
+ if (error instanceof InvalidAgentTokenError) throw error;
1213
+ reconnect(options);
1214
+ }
1215
+ }
1216
+ //#endregion
1217
+ //#region src/client.ts
1218
+ /**
1219
+ * Creates the Kubb Studio client: the connection, the command loop, and the generation event
1220
+ * stream shared by the `kubb studio` CLI command and the Docker agent.
1221
+ *
1222
+ * Every permission is off by default. A host that wants more grants it explicitly.
1223
+ *
1224
+ * @example
1225
+ * ```ts
1226
+ * const studio = createClient({ token, configPath, version, loadConfig: () => loadMyConfig() })
1227
+ * await studio.connect()
1228
+ * ```
1229
+ */
1230
+ function createClient({ storage, ...options }) {
1231
+ if (storage) setStorage(storage);
1232
+ const controller = new AbortController();
1233
+ const poolSize = options.poolSize ?? agentDefaults.poolSize;
1234
+ return {
1235
+ async connect() {
1236
+ await registerAgent({
1237
+ token: options.token,
1238
+ studioUrl: options.studioUrl ?? agentDefaults.studioUrl,
1239
+ poolSize
1240
+ });
1241
+ await Promise.all(Array.from({ length: poolSize }, () => connectToStudio({
1242
+ ...options,
1243
+ signal: controller.signal
1244
+ })));
1245
+ },
1246
+ disconnect() {
1247
+ controller.abort();
1248
+ }
1249
+ };
1250
+ }
1251
+ //#endregion
1252
+ //#region src/pair.ts
1253
+ /**
1254
+ * Identifies the CLI to Studio's device authorization endpoint. A label, not a secret: what
1255
+ * authorizes a pairing is a signed-in person approving the code in the browser.
1256
+ */
1257
+ const CLIENT_ID = "kubb-cli";
1258
+ /**
1259
+ * Asks Studio for a pairing code. The machine token travels with the request and is stored against
1260
+ * the code, so approval knows which machine it is pairing: the same machine pairing twice rotates
1261
+ * one agent's token instead of creating a second agent.
1262
+ */
1263
+ async function startPairing({ studioUrl = agentDefaults.studioUrl, name, hostname, clientId = CLIENT_ID, agentKind }) {
1264
+ return ofetch(`${studioUrl}/api/auth/device/code`, {
1265
+ method: "POST",
1266
+ body: {
1267
+ client_id: clientId,
1268
+ name,
1269
+ hostname,
1270
+ machine_token: await getMachineToken(),
1271
+ agent_kind: agentKind
1272
+ }
1273
+ });
1274
+ }
1275
+ function isPairingResult(response) {
1276
+ return !!response && typeof response === "object" && "token" in response && typeof response.token === "string";
1277
+ }
1278
+ /**
1279
+ * Polls until the user approves or denies, honoring the server's `slow_down` back-off. A poll that
1280
+ * cannot reach Studio is warned about and retried, since the code stays valid either way.
1281
+ *
1282
+ * Studio's own endpoint is used rather than the auth layer's `/device/token`, because an approved
1283
+ * Kubb pairing is worth an agent bearer token, not a user session.
1284
+ *
1285
+ * @throws when the code expires, the user denies it, or Studio returns an unexpected error.
1286
+ */
1287
+ async function pollForPairingToken({ studioUrl = agentDefaults.studioUrl, session }) {
1288
+ const deadline = Date.now() + (session.expires_in > 0 ? session.expires_in : 600) * 1e3;
1289
+ let intervalMs = (session.interval > 0 ? session.interval : 5) * 1e3;
1290
+ while (Date.now() < deadline) {
1291
+ await setTimeout$1(intervalMs);
1292
+ let response;
1293
+ try {
1294
+ response = await ofetch(`${studioUrl}/api/agent/token`, {
1295
+ method: "POST",
1296
+ body: { device_code: session.device_code },
1297
+ ignoreResponseError: true
1298
+ });
1299
+ } catch (error) {
1300
+ console.warn(styleText("yellow", `Could not reach Kubb Studio while waiting for approval, retrying: ${getErrorMessage(error)}`));
1301
+ continue;
1302
+ }
1303
+ if (isPairingResult(response)) return response;
1304
+ if (!response || typeof response !== "object" || !("error" in response) || typeof response.error !== "string") throw new Error("Kubb Studio returned an empty pairing response, pair again");
1305
+ if (response.error === "authorization_pending") continue;
1306
+ if (response.error === "slow_down") {
1307
+ intervalMs += 5e3;
1308
+ continue;
1309
+ }
1310
+ if (response.error === "access_denied") throw new Error(response.error_description ?? "Pairing was denied in the browser");
1311
+ if (response.error === "expired_token" || response.error === "invalid_grant") throw new Error(response.error_description ?? "The pairing code expired, pair again");
1312
+ throw new Error(response.error_description ?? `Pairing failed (${response.error})`);
1313
+ }
1314
+ throw new Error("The pairing code expired, pair again");
1315
+ }
1316
+ //#endregion
1317
+ export { InvalidAgentTokenError, createClient, createFileStorage, defaultStudioUrl, pollForPairingToken, setStorage, startPairing };
1318
+
1319
+ //# sourceMappingURL=index.js.map