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