@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 +71 -18
- package/dist/index.cjs +106 -64
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +77 -39
- package/dist/index.js +104 -65
- package/dist/index.js.map +1 -1
- package/dist/protocol.cjs.map +1 -1
- package/dist/protocol.d.ts +0 -12
- package/dist/protocol.js.map +1 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -40,32 +40,67 @@ npm install @kubb/studio
|
|
|
40
40
|
## Usage
|
|
41
41
|
|
|
42
42
|
```typescript
|
|
43
|
-
import {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
65
|
-
|
|
|
66
|
-
| `allowWrite`
|
|
67
|
-
| `allowInput`
|
|
68
|
-
| `allowExec`
|
|
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
|
-
`
|
|
111
|
-
|
|
112
|
-
and
|
|
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.
|
|
612
|
-
*
|
|
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
|
|
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)(
|
|
618
|
+
await (0, ofetch.ofetch)(`${studioUrl}/api/agent/sessions/${sessionId}/disconnect`, {
|
|
620
619
|
method: "POST",
|
|
621
620
|
headers: { Authorization: `Bearer ${token}` }
|
|
622
621
|
});
|
|
623
|
-
|
|
622
|
+
return true;
|
|
624
623
|
} catch (error) {
|
|
625
624
|
const statusCode = error?.statusCode;
|
|
626
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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(
|
|
2405
|
+
await this.#reconnect();
|
|
2406
2406
|
}
|
|
2407
2407
|
}
|
|
2408
|
-
|
|
2409
|
-
|
|
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
|
|
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
|
|
2522
|
+
if (this.#session && !await disconnect({
|
|
2511
2523
|
sessionId: this.#session.sessionId,
|
|
2512
2524
|
studioUrl,
|
|
2513
|
-
token
|
|
2514
|
-
|
|
2515
|
-
|
|
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
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
|
|
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,
|
|
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:
|
|
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:
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
2959
|
-
if (response.error === "expired_token" || response.error === "invalid_grant") throw new
|
|
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
|
|
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;
|