@kubb/studio 5.3.14 → 5.3.16

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/README.md CHANGED
@@ -40,32 +40,67 @@ npm install @kubb/studio
40
40
  ## Usage
41
41
 
42
42
  ```typescript
43
- import { createClient, createFileStorage } from '@kubb/studio'
44
-
45
- const client = createClient({
46
- token: process.env.KUBB_AGENT_TOKEN!,
47
- configPath: 'kubb.config.ts',
48
- version: '1.0.0',
49
- loadConfig: () => loadMyKubbConfig(),
50
- storage: createFileStorage('./.kubb-cache'),
43
+ import { createFileStorage, runConnection, setStorage } from '@kubb/studio'
44
+
45
+ // The machine identity lives in this storage, so install it before anything pairs or connects.
46
+ setStorage(createFileStorage('./.kubb-cache'))
47
+
48
+ const outcome = await runConnection({
49
+ credentials: { token: process.env.KUBB_AGENT_TOKEN! },
50
+ clientOptions: () => ({
51
+ configPath: 'kubb.config.ts',
52
+ version: '1.0.0',
53
+ loadConfig: () => loadMyKubbConfig(),
54
+ installLogger: (hooks) => {
55
+ hooks.hook('studio:connected', ({ url }) => console.log(`Connected to ${url}`))
56
+ hooks.hook('studio:reconnecting', ({ delayMs }) => console.log(`Retrying in ${delayMs}ms`))
57
+ hooks.hook('studio:warn', ({ message }) => console.warn(message))
58
+ hooks.hook('studio:error', ({ error }) => console.error(error.message))
59
+ },
60
+ }),
61
+ signal: shutdown.signal,
62
+ // Return a new credential to reconnect with, `null` to stop, or throw to fail the run.
63
+ onTokenRejected: async () => null,
51
64
  })
52
-
53
- await client.connect()
54
65
  ```
55
66
 
56
67
  The runtime discovers nothing on its own: the host injects the config loader, the storage, and its
57
68
  own version. That is what lets one runtime serve a CLI running in a developer's project and a
58
69
  container running a fixed plugin set.
59
70
 
71
+ ## Hosts and the runtime
72
+
73
+ Three hosts run this package: `kubb studio`, the `kubblabs/kubb-agent` Docker image, and
74
+ `kubb studio snapshot` in CI. All three follow the steps below. The runtime owns only the
75
+ connection: it doesn't read CI environment variables or print anything, and behaves the same in
76
+ every host.
77
+
78
+ | Step | `kubb studio` | Docker agent | `kubb studio snapshot` |
79
+ | ---------------- | --------------------------------------- | ---------------------------------------- | ---------------------------------------- |
80
+ | Machine identity | `setStorage` under `~/.kubb` | `setStorage` on the Nitro `kubb` mount | `KUBB_AGENT_SECRET` from the CI identity |
81
+ | Credentials | stored, or `pairAgent({ type: 'cli' })` | stored, or `pairAgent({ type: 'user' })` | `createAgent` with the CI API key |
82
+ | Permissions | flags and a per-project prompt | `KUBB_AGENT_ALLOW_*` | flags only |
83
+ | Connection | `runConnection` | `runConnection` | `runConnection` |
84
+ | Token rejected | pair again once | pair again once | fail the run |
85
+ | Output | renders the `studio:*` hooks | renders the `studio:*` hooks | renders the `studio:*` hooks |
86
+
87
+ Everything the runtime has to say goes through hooks on the emitter `installLogger` receives:
88
+ `studio:connecting`, `studio:connected`, `studio:ready`, `studio:reconnecting`,
89
+ `studio:disconnected`, `studio:command:start`, `studio:command:end`, `studio:warn`, and
90
+ `studio:error`. When a request is refused for a missing permission, `studio:warn` carries it as
91
+ `permission`, and the host adds its own remedy, a CLI flag or an environment variable.
92
+
60
93
  ## Permissions
61
94
 
62
95
  Every permission is off by default, and each covers one trust boundary:
63
96
 
64
- | Option | What it grants |
65
- | ------------ | ---------------------------------------------------------------------------------------------- |
66
- | `allowWrite` | Generated files are written to disk. Off means they exist only in memory and stream to Studio. |
67
- | `allowInput` | An OpenAPI spec sent by Studio replaces the one on disk. |
68
- | `allowExec` | The formatter, the linter, and `output.postGenerate` run as child processes. |
97
+ | Option | What it grants |
98
+ | ----------------- | ---------------------------------------------------------------------------------------------- |
99
+ | `allowWrite` | Generated files are written to disk. Off means they exist only in memory and stream to Studio. |
100
+ | `allowInput` | An OpenAPI spec sent by Studio replaces the one on disk. |
101
+ | `allowExec` | The formatter, the linter, and `output.postGenerate` run as child processes. |
102
+ | `allowConfigEdit` | Studio may change plugin options in `kubb.config.ts`. |
103
+ | `allowRead` | Studio may read back the files a generation produced. |
69
104
 
70
105
  ## Connection flow
71
106
 
@@ -107,9 +142,23 @@ No host starts with a token, so each one pairs over
107
142
  `POST /api/agent/token` until someone approves in the browser. Studio mints the token once and stores only its hash, so
108
143
  nothing can read it back.
109
144
 
110
- `startPairing` defaults to the `kubb-cli` client, which `kubb studio login` uses and any signed-in
111
- member can approve. A host that pairs a shared or tier-limited agent passes `clientId: 'kubb-agent'`
112
- and an `agentKind`, whose codes only an admin can approve.
145
+ `pairAgent` runs that whole flow and hands each code to the host's `onCode` to show. The `type` is
146
+ what Studio registers the machine as: `cli` is a `kubb studio` machine any signed-in member can
147
+ approve, and `user` or `sandbox` is the Docker image, whose codes only an admin can approve. With
148
+ `maxAttempts` above 1 it asks for a fresh code when one expires unapproved. A denial throws
149
+ `PairingDeniedError`, an expired code `PairingExpiredError`, and an aborted `signal`
150
+ `PairingCanceledError`.
151
+
152
+ ```typescript
153
+ import { pairAgent } from '@kubb/studio'
154
+
155
+ const { token, agent } = await pairAgent({
156
+ type: 'cli',
157
+ name: 'my-project',
158
+ hostname: os.hostname(),
159
+ onCode: (session) => console.log(`Approve ${session.user_code} at ${session.verification_uri}`),
160
+ })
161
+ ```
113
162
 
114
163
  ## Asynchronous jobs
115
164
 
@@ -144,6 +193,10 @@ if (finished.status === 'canceled') throw new Error('Studio job was canceled')
144
193
  const snapshot = finished.snapshot
145
194
  ```
146
195
 
196
+ Pass `commit` with a snapshot job, and the finished snapshot carries `changes`: the files added,
197
+ changed, and removed since the previous snapshot of the same package on the same agent, and which
198
+ snapshot (and commit) that was. `base` is `null` on the first one.
199
+
147
200
  A snapshot job packs the tarball on the agent, not on Studio. The agent `PUT`s an empty request to
148
201
  a path Studio provides, gets back a redirect to a short-lived storage URL, and uploads the tarball
149
202
  there. The storage URL never crosses the RPC socket.
package/dist/index.cjs CHANGED
@@ -26,19 +26,19 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
26
26
  }) : target, mod));
27
27
  //#endregion
28
28
  const require_protocol = require("./protocol.cjs");
29
- let node_util = require("node:util");
30
29
  let node_process = require("node:process");
31
30
  node_process = __toESM(node_process, 1);
32
31
  let node_child_process = require("node:child_process");
33
32
  let node_fs_promises = require("node:fs/promises");
34
33
  let node_path = require("node:path");
35
34
  node_path = __toESM(node_path, 1);
36
- let _kubb_core = require("@kubb/core");
37
35
  let ofetch = require("ofetch");
38
36
  let node_crypto = require("node:crypto");
37
+ let node_util = require("node:util");
39
38
  let unstorage = require("unstorage");
40
39
  let unstorage_drivers_fs = require("unstorage/drivers/fs");
41
40
  unstorage_drivers_fs = __toESM(unstorage_drivers_fs, 1);
41
+ let _kubb_core = require("@kubb/core");
42
42
  let tinyexec = require("tinyexec");
43
43
  let magicast = require("magicast");
44
44
  let node_fs = require("node:fs");
@@ -602,29 +602,27 @@ async function runRegistration({ token, studioUrl, poolSize }) {
602
602
  return true;
603
603
  } catch (error) {
604
604
  if (rejectedWith(error, 401)) throw new InvalidAgentTokenError(studioUrl, { cause: error });
605
- console.error((0, node_util.styleText)("red", `Failed to register agent with Studio after 4 attempts`));
606
605
  return false;
607
606
  }
608
607
  }
609
608
  /**
610
609
  * Notify Kubb Studio that this agent is disconnecting.
611
- * Called on process termination or server close. A failed notify is logged and swallowed: the
612
- * local socket is already gone, and failing teardown must not block shutdown or reconnect.
610
+ * Called on process termination or server close. Never throws: the local socket is already gone,
611
+ * and failing teardown must not block shutdown or reconnect.
612
+ *
613
+ * @returns `false` when Studio could not be reached or rate limited the call. Any other 4xx
614
+ * counts as notified, since it means Studio already dropped the session.
613
615
  */
614
- async function disconnect({ sessionId, token, studioUrl, slug, logLevel }) {
615
- const url = `${studioUrl}/api/agent/sessions/${sessionId}/disconnect`;
616
- const tag = slug ?? "agent";
617
- const canLog = logLevel !== void 0 && logLevel > _kubb_core.logLevel.silent;
616
+ async function disconnect({ sessionId, token, studioUrl }) {
618
617
  try {
619
- await (0, ofetch.ofetch)(url, {
618
+ await (0, ofetch.ofetch)(`${studioUrl}/api/agent/sessions/${sessionId}/disconnect`, {
620
619
  method: "POST",
621
620
  headers: { Authorization: `Bearer ${token}` }
622
621
  });
623
- if (canLog) console.error((0, node_util.styleText)("green", `[${tag}] Disconnected from Studio`));
622
+ return true;
624
623
  } catch (error) {
625
624
  const statusCode = error?.statusCode;
626
- if (statusCode !== void 0 && statusCode >= 400 && statusCode < 500) return;
627
- if (canLog) console.warn((0, node_util.styleText)("yellow", `[${tag}] Failed to notify Studio of disconnection: ${getErrorMessage(error)}`));
625
+ return statusCode !== void 0 && statusCode !== 429 && statusCode >= 400 && statusCode < 500;
628
626
  }
629
627
  }
630
628
  /**
@@ -646,7 +644,7 @@ async function disconnect({ sessionId, token, studioUrl, slug, logLevel }) {
646
644
  * const finished = await waitForJob({ studioUrl, token, id: job.id })
647
645
  * ```
648
646
  */
649
- async function createJob({ studioUrl, token, type, agentId, name, version, config }) {
647
+ async function createJob({ studioUrl, token, type, agentId, name, version, commit, config }) {
650
648
  const { job } = await (0, ofetch.ofetch)(`${studioUrl}/api/jobs`, {
651
649
  method: "POST",
652
650
  headers: { "x-api-key": token },
@@ -655,6 +653,7 @@ async function createJob({ studioUrl, token, type, agentId, name, version, confi
655
653
  agentId,
656
654
  name,
657
655
  version,
656
+ commit,
658
657
  config
659
658
  }
660
659
  });
@@ -725,7 +724,7 @@ async function createAgent({ studioUrl, token, name, machineToken }) {
725
724
  }
726
725
  //#endregion
727
726
  //#region package.json
728
- var version = "5.3.14";
727
+ var version = "5.3.16";
729
728
  //#endregion
730
729
  //#region src/hooks.ts
731
730
  /**
@@ -2275,15 +2274,13 @@ function applyStudioDefaults(options) {
2275
2274
  * socket, its hook emitter, or its session id alive for the length of the retry interval.
2276
2275
  */
2277
2276
  function reconnect(options) {
2278
- const { signal, retryInterval, onTokenRejected, logLevel } = options;
2277
+ const { signal, retryInterval, onTokenRejected } = options;
2279
2278
  if (signal?.aborted) return;
2280
- if (logLevel !== void 0 && logLevel > _kubb_core.logLevel.silent) console.error((0, node_util.styleText)("dim", `Retrying connection in ${retryInterval}ms to Kubb Studio ...`));
2281
2279
  const cancel = () => clearTimeout(timer);
2282
2280
  const timer = setTimeout(() => {
2283
2281
  signal?.removeEventListener("abort", cancel);
2284
2282
  if (signal?.aborted) return;
2285
2283
  new StudioSession(options).start().catch((error) => {
2286
- if (logLevel !== void 0 && logLevel > _kubb_core.logLevel.silent) console.error((0, node_util.styleText)("red", `Reconnect attempt to Kubb Studio failed: ${getErrorMessage(error)}`));
2287
2284
  if (error instanceof InvalidAgentTokenError) {
2288
2285
  onTokenRejected?.(error);
2289
2286
  return;
@@ -2323,8 +2320,10 @@ var StudioSession = class {
2323
2320
  * host does not queue jobs before the agent session is registered.
2324
2321
  */
2325
2322
  #connectAck = Promise.withResolvers();
2326
- constructor(options) {
2323
+ #startupWarning;
2324
+ constructor({ startupWarning, ...options }) {
2327
2325
  this.#options = applyStudioDefaults(options);
2326
+ this.#startupWarning = startupWarning;
2328
2327
  this.#connectAck.promise.catch(() => {});
2329
2328
  }
2330
2329
  /**
@@ -2367,6 +2366,7 @@ var StudioSession = class {
2367
2366
  async start() {
2368
2367
  const { token, studioUrl, signal, heartbeatInterval, installLogger } = this.#options;
2369
2368
  await installLogger?.(this.#hooks);
2369
+ if (this.#startupWarning) await this.#warn(this.#startupWarning);
2370
2370
  try {
2371
2371
  await this.#hooks.callHook("studio:connecting", { url: studioUrl });
2372
2372
  const session = await createAgentSession({
@@ -2402,18 +2402,30 @@ var StudioSession = class {
2402
2402
  this.dispose();
2403
2403
  await this.#hooks.callHook("studio:error", { error: toError(error) });
2404
2404
  if (error instanceof InvalidAgentTokenError) throw error;
2405
- reconnect(this.#options);
2405
+ await this.#reconnect();
2406
2406
  }
2407
2407
  }
2408
- #warn(message) {
2409
- return this.#hooks.callHook("studio:warn", { message });
2408
+ /**
2409
+ * Tells the host a retry is coming, then schedules it. The host prints the retry, since the
2410
+ * runtime has no output of its own.
2411
+ */
2412
+ async #reconnect() {
2413
+ if (this.#options.signal?.aborted) return;
2414
+ await this.#hooks.callHook("studio:reconnecting", { delayMs: this.#options.retryInterval });
2415
+ reconnect(this.#options);
2416
+ }
2417
+ #warn(message, permission) {
2418
+ return this.#hooks.callHook("studio:warn", {
2419
+ message,
2420
+ permission
2421
+ });
2410
2422
  }
2411
2423
  /**
2412
2424
  * Declines a request: logs why locally, then tells Studio. The two wordings differ on purpose,
2413
2425
  * since the log names the request that was ignored and the error names what the caller can do.
2414
2426
  */
2415
- async #refuse(reason, message) {
2416
- await this.#warn(reason);
2427
+ async #refuse(reason, message, permission) {
2428
+ await this.#warn(reason, permission);
2417
2429
  throw new Error(message);
2418
2430
  }
2419
2431
  #scheduleHeartbeat(interval) {
@@ -2502,19 +2514,17 @@ var StudioSession = class {
2502
2514
  * `#disposed` keeps the close event from running this twice, and a shutdown from reconnecting.
2503
2515
  */
2504
2516
  async #end({ retry }) {
2505
- const { studioUrl, token, logLevel } = this.#options;
2517
+ const { studioUrl, token } = this.#options;
2506
2518
  if (this.#disposed) return;
2507
2519
  this.#disposed = true;
2508
2520
  this.dispose();
2509
2521
  await this.#hooks.callHook("studio:disconnected", { reason: retry ? "connection closed" : "shutdown" });
2510
- if (this.#session) await disconnect({
2522
+ if (this.#session && !await disconnect({
2511
2523
  sessionId: this.#session.sessionId,
2512
2524
  studioUrl,
2513
- token,
2514
- slug: this.#session.slug,
2515
- logLevel
2516
- }).catch(() => {});
2517
- if (retry) reconnect(this.#options);
2525
+ token
2526
+ })) await this.#warn("Could not notify Kubb Studio of the disconnect");
2527
+ if (retry) await this.#reconnect();
2518
2528
  }
2519
2529
  startGeneration(data) {
2520
2530
  const generationStream = createGenerationStream(this.#hooks, data.jobId, { onGenerationEnd: (result) => {
@@ -2540,7 +2550,7 @@ var StudioSession = class {
2540
2550
  if (this.#isGenerating) return this.#refuse("Ignored generate: a generation is already in progress", "A generation is already in progress, please wait for it to finish");
2541
2551
  this.#isGenerating = true;
2542
2552
  const command = "generate";
2543
- const { root, loadConfig, permissions, client } = this.#options;
2553
+ const { root, loadConfig, permissions } = this.#options;
2544
2554
  try {
2545
2555
  await this.#hooks.callHook("studio:command:start", { command });
2546
2556
  const config = await loadConfig();
@@ -2549,10 +2559,7 @@ var StudioSession = class {
2549
2559
  const adapter = await mergeAdapter(config.adapter, patch?.adapter);
2550
2560
  const inputOverride = this.#isSandbox ? patch?.input ?? "" : permissions.allowInput && patch?.input || void 0;
2551
2561
  if (permissions.allowWrite && this.#isSandbox) await this.#warn("Running in a sandbox, so writing files is disabled");
2552
- if (patch?.input && !this.#canUseInput) {
2553
- const remedy = client?.kind === "cli" ? "--allowInput, or answer yes when kubb studio asks," : "KUBB_AGENT_ALLOW_INPUT=true";
2554
- await this.#warn(`Ignored the spec from Studio; set ${remedy} to generate from it`);
2555
- }
2562
+ if (patch?.input && !this.#canUseInput) await this.#warn("Ignored the spec from Studio: generating from a Studio spec was not granted", "allowInput");
2556
2563
  const resolvedPlugins = plugins ?? config.plugins;
2557
2564
  this.#lastGeneration = void 0;
2558
2565
  const diskFiles = this.#hasProjectOnDisk ? await listDisk({
@@ -2732,12 +2739,7 @@ var StudioSession = class {
2732
2739
  async readFiles(data) {
2733
2740
  const command = "readFiles";
2734
2741
  await this.#hooks.callHook("studio:command:start", { command });
2735
- const { client } = this.#options;
2736
- if (!this.#canRead) {
2737
- await this.#warn("Ignored files: reading generated files was not granted");
2738
- const remedy = client?.kind === "cli" ? "--allow-read, or answer yes when kubb studio asks," : "KUBB_AGENT_ALLOW_READ=true";
2739
- throw new Error(`The agent was not granted permission to read generated files; set ${remedy} to allow it`);
2740
- }
2742
+ if (!this.#canRead) return this.#refuse("Ignored files: reading generated files was not granted", "The agent was not granted permission to read generated files", "allowRead");
2741
2743
  if (!Array.isArray(data.paths)) return this.#refuse("Ignored files: the message carried no paths", "The request carried no paths");
2742
2744
  const { paths } = data;
2743
2745
  if (paths.length > 50) return this.#refuse(`Ignored files: requested ${paths.length} paths, more than the 50 allowed per request`, `At most 50 paths may be requested at once`);
@@ -2764,7 +2766,8 @@ var StudioSession = class {
2764
2766
  * Creates the Kubb Studio client: the connection, the command loop, and the generation event
2765
2767
  * stream shared by the `kubb studio` CLI command and the Docker agent.
2766
2768
  *
2767
- * Every permission is off by default. A host that wants more grants it explicitly.
2769
+ * Every permission is off by default. A host that wants more grants it explicitly. The machine
2770
+ * identity comes from the storage the host installed with `setStorage`, before connecting.
2768
2771
  *
2769
2772
  * @example
2770
2773
  * ```ts
@@ -2772,8 +2775,7 @@ var StudioSession = class {
2772
2775
  * await studio.connect()
2773
2776
  * ```
2774
2777
  */
2775
- function createClient({ storage, onAuthRequired, ...options }) {
2776
- if (storage) setStorage(storage);
2778
+ function createClient({ onAuthRequired, ...options }) {
2777
2779
  const controller = new AbortController();
2778
2780
  const poolSize = options.poolSize ?? agentDefaults.poolSize;
2779
2781
  function notifyAuthRequired(error) {
@@ -2783,15 +2785,18 @@ function createClient({ storage, onAuthRequired, ...options }) {
2783
2785
  }
2784
2786
  return {
2785
2787
  async connect() {
2786
- await registerAgent({
2788
+ const registered = await registerAgent({
2787
2789
  token: options.token,
2788
2790
  studioUrl: options.studioUrl ?? agentDefaults.studioUrl,
2789
2791
  poolSize
2790
2792
  });
2791
- await Promise.all(Array.from({ length: poolSize }, () => new StudioSession({
2793
+ if (controller.signal.aborted) return;
2794
+ const startupWarning = registered ? void 0 : "Could not register with Kubb Studio, continuing";
2795
+ await Promise.all(Array.from({ length: poolSize }, (_, slot) => new StudioSession({
2792
2796
  ...options,
2793
2797
  signal: controller.signal,
2794
- onTokenRejected: notifyAuthRequired
2798
+ onTokenRejected: notifyAuthRequired,
2799
+ startupWarning: slot === 0 ? startupWarning : void 0
2795
2800
  }).start()));
2796
2801
  },
2797
2802
  disconnect() {
@@ -2874,11 +2879,11 @@ async function runConnection({ credentials, clientOptions, onTokenRejected, sign
2874
2879
  }
2875
2880
  //#endregion
2876
2881
  //#region src/pair.ts
2877
- /**
2878
- * Identifies the CLI to Studio's device authorization endpoint. A label, not a secret: what
2879
- * authorizes a pairing is a signed-in person approving the code in the browser.
2880
- */
2881
- const CLIENT_ID = "kubb-cli";
2882
+ /** Labels, not secrets: a person approving the code in the browser is what authorizes a pairing. */
2883
+ const CLIENT_IDS = {
2884
+ cli: "kubb-cli",
2885
+ agent: "kubb-agent"
2886
+ };
2882
2887
  /**
2883
2888
  * Thrown when a caller aborts `startPairing` or `pollForPairingToken` through their `signal`, such
2884
2889
  * as a `kubb studio` shutdown mid-pairing. Distinct from a denial or an expired code, so a host can
@@ -2890,21 +2895,35 @@ var PairingCanceledError = class extends Error {
2890
2895
  this.name = "PairingCanceledError";
2891
2896
  }
2892
2897
  };
2898
+ /** Thrown when the code expired unapproved. A fresh code can still succeed. */
2899
+ var PairingExpiredError = class extends Error {
2900
+ constructor(message = "The pairing code expired, pair again") {
2901
+ super(message);
2902
+ this.name = "PairingExpiredError";
2903
+ }
2904
+ };
2905
+ /** Thrown when the pairing was denied in the browser, including by the organization's agent limit. */
2906
+ var PairingDeniedError = class extends Error {
2907
+ constructor(message = "Pairing was denied in the browser") {
2908
+ super(message);
2909
+ this.name = "PairingDeniedError";
2910
+ }
2911
+ };
2893
2912
  /**
2894
2913
  * Asks Studio for a pairing code. The machine token travels with the request and is stored against
2895
2914
  * the code, so approval knows which machine it is pairing: the same machine pairing twice rotates
2896
2915
  * one agent's token instead of creating a second agent.
2897
2916
  */
2898
- async function startPairing({ studioUrl = agentDefaults.studioUrl, name, hostname, clientId = CLIENT_ID, agentKind, signal }) {
2917
+ async function startPairing({ studioUrl = agentDefaults.studioUrl, type, name, hostname, signal }) {
2899
2918
  try {
2900
2919
  return await (0, ofetch.ofetch)(`${studioUrl}/api/auth/device/code`, {
2901
2920
  method: "POST",
2902
2921
  body: {
2903
- client_id: clientId,
2922
+ client_id: type === "cli" ? CLIENT_IDS.cli : CLIENT_IDS.agent,
2904
2923
  name,
2905
2924
  hostname,
2906
2925
  machine_token: await getMachineToken(),
2907
- agent_kind: agentKind
2926
+ agent_kind: type === "cli" ? void 0 : type
2908
2927
  },
2909
2928
  signal
2910
2929
  });
@@ -2917,15 +2936,16 @@ function isPairingResult(response) {
2917
2936
  return !!response && typeof response === "object" && "token" in response && typeof response.token === "string";
2918
2937
  }
2919
2938
  /**
2920
- * Polls until the user approves or denies, honoring the server's `slow_down` back-off. A poll that
2921
- * cannot reach Studio is warned about and retried, since the code stays valid either way.
2939
+ * Polls until the user approves or denies, honoring the server's `slow_down` back-off.
2922
2940
  *
2923
2941
  * Studio's own endpoint is used rather than the auth layer's `/device/token`, because an approved
2924
2942
  * Kubb pairing is worth an agent bearer token, not a user session.
2925
2943
  *
2926
- * @throws when the code expires, the user denies it, or Studio returns an unexpected error.
2944
+ * @throws {PairingExpiredError} when the code expires before anyone approves it.
2945
+ * @throws {PairingDeniedError} when the pairing is denied in the browser.
2946
+ * @throws {PairingCanceledError} when `signal` aborts.
2927
2947
  */
2928
- async function pollForPairingToken({ studioUrl = agentDefaults.studioUrl, session, signal }) {
2948
+ async function pollForPairingToken({ studioUrl = agentDefaults.studioUrl, session, signal, onRetry }) {
2929
2949
  const deadline = Date.now() + (session.expires_in > 0 ? session.expires_in : 600) * 1e3;
2930
2950
  let intervalMs = (session.interval > 0 ? session.interval : 5) * 1e3;
2931
2951
  while (Date.now() < deadline) {
@@ -2945,7 +2965,7 @@ async function pollForPairingToken({ studioUrl = agentDefaults.studioUrl, sessio
2945
2965
  });
2946
2966
  } catch (error) {
2947
2967
  if (signal?.aborted) throw new PairingCanceledError();
2948
- console.warn((0, node_util.styleText)("yellow", `Could not reach Kubb Studio while waiting for approval, retrying: ${getErrorMessage(error)}`));
2968
+ onRetry?.(toError(error));
2949
2969
  continue;
2950
2970
  }
2951
2971
  if (isPairingResult(response)) return response;
@@ -2955,15 +2975,36 @@ async function pollForPairingToken({ studioUrl = agentDefaults.studioUrl, sessio
2955
2975
  intervalMs += 5e3;
2956
2976
  continue;
2957
2977
  }
2958
- if (response.error === "access_denied") throw new Error(response.error_description ?? "Pairing was denied in the browser");
2959
- if (response.error === "expired_token" || response.error === "invalid_grant") throw new Error(response.error_description ?? "The pairing code expired, pair again");
2978
+ if (response.error === "access_denied") throw new PairingDeniedError(response.error_description);
2979
+ if (response.error === "expired_token" || response.error === "invalid_grant") throw new PairingExpiredError(response.error_description);
2960
2980
  throw new Error(response.error_description ?? `Pairing failed (${response.error})`);
2961
2981
  }
2962
- throw new Error("The pairing code expired, pair again");
2982
+ throw new PairingExpiredError();
2983
+ }
2984
+ /**
2985
+ * Pairs this machine with Studio: asks for a code, hands it to the host to show, and waits for approval.
2986
+ */
2987
+ async function pairAgent({ onCode, onRetry, maxAttempts = 1, ...options }) {
2988
+ for (let attempt = 1;; attempt++) {
2989
+ const session = await startPairing(options);
2990
+ await onCode(session, attempt);
2991
+ try {
2992
+ return await pollForPairingToken({
2993
+ studioUrl: options.studioUrl,
2994
+ session,
2995
+ signal: options.signal,
2996
+ onRetry
2997
+ });
2998
+ } catch (error) {
2999
+ if (!(error instanceof PairingExpiredError) || attempt >= maxAttempts) throw error;
3000
+ }
3001
+ }
2963
3002
  }
2964
3003
  //#endregion
2965
3004
  exports.InvalidAgentTokenError = InvalidAgentTokenError;
2966
3005
  exports.PairingCanceledError = PairingCanceledError;
3006
+ exports.PairingDeniedError = PairingDeniedError;
3007
+ exports.PairingExpiredError = PairingExpiredError;
2967
3008
  exports.connectWebSocketRpc = connectWebSocketRpc;
2968
3009
  exports.createAgent = createAgent;
2969
3010
  exports.createClient = createClient;
@@ -2972,6 +3013,7 @@ exports.createJob = createJob;
2972
3013
  exports.defaultStudioUrl = defaultStudioUrl;
2973
3014
  exports.generationEventTypes = require_protocol.generationEventTypes;
2974
3015
  exports.machineTokenFrom = machineTokenFrom;
3016
+ exports.pairAgent = pairAgent;
2975
3017
  exports.pollForPairingToken = pollForPairingToken;
2976
3018
  exports.runConnection = runConnection;
2977
3019
  exports.setStorage = setStorage;