@oya-ai/cli 1.0.97 → 1.0.101

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.
Files changed (2) hide show
  1. package/dist/index.js +2352 -1762
  2. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -1,1546 +1,2060 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/index.ts
4
- import { spawn as spawn2 } from "child_process";
5
-
6
- // ../sdk/dist/index.js
7
- var OyaError = class extends Error {
8
- status;
9
- body;
10
- constructor(message, status, body) {
11
- super(message);
12
- this.name = "OyaError";
13
- this.status = status;
14
- this.body = body;
3
+ // src/args.ts
4
+ function parse(argv) {
5
+ const [command2 = "help", ...rest] = argv;
6
+ const args2 = [];
7
+ const flags2 = {};
8
+ for (let i = 0; i < rest.length; i++) {
9
+ if (!rest[i].startsWith("--")) args2.push(rest[i]);
10
+ else if (takeFlag(rest[i].slice("--".length), rest[i + 1], flags2)) i++;
15
11
  }
12
+ return { command: command2, args: args2, flags: flags2 };
13
+ }
14
+ function takeFlag(name, next, flags2) {
15
+ const bare = next === void 0 || next.startsWith("--");
16
+ flags2[name] = bare ? true : next;
17
+ return !bare;
18
+ }
19
+ var flagStr = (flags2, name) => typeof flags2[name] === "string" ? flags2[name] : void 0;
20
+ var flagNum = (flags2, name) => flagStr(flags2, name) === void 0 ? void 0 : Number(flagStr(flags2, name));
21
+
22
+ // src/install.ts
23
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
24
+ import { join as join5 } from "path";
25
+
26
+ // src/prompt/style.ts
27
+ import { stdout } from "process";
28
+
29
+ // src/prompt/constants.ts
30
+ var DEFAULT_COLUMNS = 80;
31
+ var DEFAULT_ROWS = 24;
32
+ var MAX_RULE_WIDTH = 74;
33
+ var SPINNER_FRAME_MS = 80;
34
+
35
+ // src/prompt/style.ts
36
+ var ESC = "\x1B";
37
+ var plain = !stdout.isTTY || !!process.env.NO_COLOR || process.env.TERM === "dumb";
38
+ var wrap = (open) => (s) => plain ? s : `${ESC}[${open}m${s}${ESC}[0m`;
39
+ var style = {
40
+ bold: wrap("1"),
41
+ dim: wrap("2"),
42
+ green: wrap("32"),
43
+ cyan: wrap("36"),
44
+ yellow: wrap("33"),
45
+ red: wrap("31"),
46
+ grey: wrap("90")
16
47
  };
17
- var Http = class {
18
- constructor(baseUrl, apiKey, timeoutMs, fetchImpl) {
19
- this.baseUrl = baseUrl;
20
- this.apiKey = apiKey;
21
- this.timeoutMs = timeoutMs;
22
- this.fetchImpl = fetchImpl;
23
- }
24
- baseUrl;
25
- apiKey;
26
- timeoutMs;
27
- fetchImpl;
28
- async request(method, path, body, timeoutMs = this.timeoutMs, headers = {}) {
29
- const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
30
- method,
31
- headers: {
32
- Authorization: `Bearer ${this.apiKey}`,
33
- ...headers,
34
- ...body === void 0 ? {} : { "Content-Type": "application/json" }
35
- },
36
- body: body === void 0 ? void 0 : JSON.stringify(body),
37
- signal: AbortSignal.timeout(timeoutMs)
38
- });
39
- const text = await res.text();
40
- let payload;
41
- try {
42
- payload = text ? JSON.parse(text) : null;
43
- } catch {
44
- payload = text;
45
- }
46
- if (!res.ok) {
47
- let message = payload?.error || `${method} ${path} failed (${res.status})`;
48
- if (message === "Invalid API key") message += ` for ${this.baseUrl}. Check OYA_API_KEY: a value exported in your shell beats .env.`;
49
- throw new OyaError(message, res.status, payload);
50
- }
51
- return payload;
52
- }
48
+ var icon = {
49
+ ok: style.green("\u2714"),
50
+ fail: style.red("\u2717"),
51
+ warn: style.yellow("!"),
52
+ arrow: style.green("\u276F")
53
53
  };
54
- var NAVIGATE_TIMEOUT_MS = 12e4;
55
- var Browser = class {
56
- constructor(http, info, autoCaptcha) {
57
- this.http = http;
58
- this.autoCaptcha = autoCaptcha;
59
- this.id = info.id;
60
- this.provider = info.provider;
61
- this.persona = info.persona;
62
- this.cdpUrl = info.cdpUrl;
63
- }
64
- http;
65
- autoCaptcha;
66
- id;
67
- provider;
68
- persona;
69
- /** Point Playwright, Puppeteer or browser-use here. */
70
- cdpUrl;
71
- async command(action, params = {}, timeoutMs) {
72
- const result = await this.http.request(
73
- "POST",
74
- `/api/browsers/${this.id}/command`,
75
- { action, params },
76
- timeoutMs
77
- );
78
- if (result.ok === false) throw new OyaError(result.error || `${action} failed`, 422, result);
79
- return result.data;
80
- }
81
- async goto(url) {
82
- await this.command("navigate", { url }, NAVIGATE_TIMEOUT_MS);
83
- if (this.autoCaptcha) {
84
- const result = await this.solveCaptcha();
85
- if (result.present && !result.solved && !result.invisible) throw new OyaError(result.error || "CAPTCHA needs attention. Call solveCaptcha() again or open the live view.", 409, result);
86
- }
87
- }
88
- /** The page as markdown plus numbered elements to act on. */
89
- async analyze() {
90
- return this.command("analyze");
91
- }
92
- /** Only the visible elements, which is what an agent almost always wants. */
93
- async elements() {
94
- return (await this.analyze()).elements.filter((e) => e.visible);
95
- }
96
- async click(elementId) {
97
- const id = this.elementId(elementId);
98
- await this.command("click", { element_id: id, selector: `[data-ac-id="${id}"]` });
99
- }
100
- async type(elementId, text) {
101
- const id = this.elementId(elementId);
102
- return this.command("type", { element_id: id, selector: `[data-ac-id="${id}"]`, text });
103
- }
104
- elementId(value) {
105
- const id = Number(value);
106
- if (typeof value !== "number" && typeof value !== "string" || value === "" || !Number.isInteger(id) || id < 0) {
107
- throw new OyaError("Use a numeric element id from browser.analyze().", 400, null);
108
- }
109
- return id;
110
- }
111
- async pressKey(key) {
112
- await this.command("press_key", { key });
113
- }
114
- /**
115
- * Answer a native dialog holding the page. alert() and beforeunload are
116
- * answered for you; a confirm() or prompt() waits for this, and every other
117
- * command fails fast with the dialog's text until it is answered.
118
- */
119
- async handleDialog(accept, promptText) {
120
- return this.command("handle_dialog", { accept, prompt_text: promptText });
121
- }
122
- /** `at` aims the wheel at an inner scroller (a results panel, a chat pane) instead of the page. */
123
- async scroll(direction, amount, at) {
124
- await this.command("scroll", at ? { direction, amount: amount ?? 500, ...at, smooth: false } : { direction, amount });
125
- }
126
- async waitFor(selector, timeout = 3e4) {
127
- await this.command("wait", { selector, timeout }, timeout + 5e3);
128
- }
129
- /** A `data:image/…;base64,` URL. PNG or JPEG depending on the driver. */
130
- async screenshot() {
131
- const data = await this.command("screenshot");
132
- return data.screenshot;
133
- }
134
- async url() {
135
- const tabs = await this.tabs();
136
- return tabs.find((t) => t.active)?.url || "";
137
- }
138
- async tabs() {
139
- const data = await this.command("list_tabs");
140
- return data.tabs || [];
141
- }
142
- async openTab(url) {
143
- const data = await this.command("open_tab", { url }, NAVIGATE_TIMEOUT_MS);
144
- return data.tab_id;
145
- }
146
- async switchTab(tabId) {
147
- await this.command("switch_tab", { tab_id: tabId });
148
- }
149
- async closeTab(tabId) {
150
- await this.command("close_tab", { tab_id: tabId });
151
- }
152
- /**
153
- x * Detect and clear a CAPTCHA. Providers that solve natively are left to do
154
- * it; everything else goes to the configured solver.
155
- */
156
- solveCaptcha() {
157
- return this.http.request("POST", `/api/browsers/${this.id}/captcha`, {}, 18e4);
54
+ var clearLine = plain ? "" : `${ESC}[K`;
55
+ var up = (n) => `${ESC}[${n}A`;
56
+ var width = () => Math.min(stdout.columns || DEFAULT_COLUMNS, MAX_RULE_WIDTH);
57
+ var COLOUR_CODE = new RegExp(`${ESC}\\[[0-9;]*m`, "g");
58
+ var visible = (text) => text.replace(COLOUR_CODE, "").length;
59
+
60
+ // src/prompt/frame.ts
61
+ import { stdout as stdout2 } from "process";
62
+ var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
63
+ var counter = { number: 0, total: 0 };
64
+ function banner(title, subtitle) {
65
+ const line = "\u2500".repeat(width());
66
+ stdout2.write(`
67
+ ${style.grey(line)}
68
+ ${style.bold(title)}
69
+ `);
70
+ if (subtitle) stdout2.write(` ${style.grey(subtitle)}
71
+ `);
72
+ stdout2.write(`${style.grey(line)}
73
+ `);
74
+ }
75
+ function steps(total) {
76
+ counter.total = total;
77
+ counter.number = 0;
78
+ }
79
+ function step(title, hint2) {
80
+ counter.number += 1;
81
+ const shown = counter.total ? style.grey(`${counter.number}/${counter.total}`) : "";
82
+ stdout2.write(`
83
+ ${style.cyan("\u25C6")} ${style.bold(title)} ${shown}
84
+ `);
85
+ if (hint2) stdout2.write(`${style.grey(" " + hint2)}
86
+ `);
87
+ }
88
+ function note(text) {
89
+ stdout2.write(`${style.grey(" " + text)}
90
+ `);
91
+ }
92
+ function success(text) {
93
+ stdout2.write(`${icon.ok} ${text}
94
+ `);
95
+ }
96
+ function warn(text) {
97
+ stdout2.write(`${icon.warn} ${style.yellow(text)}
98
+ `);
99
+ }
100
+ function animate(label) {
101
+ let i = 0;
102
+ const timer = setInterval(() => {
103
+ stdout2.write(`\r ${style.cyan(FRAMES[i++ % FRAMES.length])} ${label}\u2026 `);
104
+ }, SPINNER_FRAME_MS);
105
+ return () => clearInterval(timer);
106
+ }
107
+ function spinner(label) {
108
+ if (plain) return plainSpinner(label);
109
+ const halt = animate(label);
110
+ const stop = (mark, msg) => {
111
+ halt();
112
+ stdout2.write(`\r ${mark} ${label} ${style.grey(msg)}${clearLine}
113
+ `);
114
+ };
115
+ return { done: (msg = "") => stop(icon.ok, msg), fail: (msg = "") => stop(icon.fail, msg) };
116
+ }
117
+ function plainSpinner(label) {
118
+ stdout2.write(` ${label}\u2026 `);
119
+ return {
120
+ done: (msg = "ok") => stdout2.write(`${msg}
121
+ `),
122
+ fail: (msg = "failed") => stdout2.write(`${msg}
123
+ `)
124
+ };
125
+ }
126
+
127
+ // src/prompt/lines.ts
128
+ import { createInterface } from "readline";
129
+ import { stdin, stdout as stdout3 } from "process";
130
+ var rl = null;
131
+ var masked = false;
132
+ var pending = [];
133
+ var waiter = null;
134
+ var ended = false;
135
+ function deliver(line) {
136
+ if (waiter) {
137
+ const w = waiter;
138
+ waiter = null;
139
+ w(line);
140
+ } else pending.push(line);
141
+ }
142
+ function close() {
143
+ ended = true;
144
+ if (waiter) deliver("");
145
+ }
146
+ function maskEcho(line) {
147
+ const asAny = line;
148
+ asAny._writeToOutput = (s) => {
149
+ if (masked) asAny.output.write(s.includes("\n") ? "\n" : "*");
150
+ else asAny.output.write(s);
151
+ };
152
+ }
153
+ function iface() {
154
+ if (rl) return rl;
155
+ rl = createInterface({ input: stdin, output: stdout3, terminal: !!stdin.isTTY });
156
+ rl.on("line", deliver);
157
+ rl.on("close", close);
158
+ maskEcho(rl);
159
+ return rl;
160
+ }
161
+ function nextLine() {
162
+ const queued = pending.shift();
163
+ if (queued !== void 0) return Promise.resolve(queued);
164
+ if (ended) return Promise.resolve("");
165
+ iface();
166
+ return new Promise((resolve) => {
167
+ waiter = resolve;
168
+ });
169
+ }
170
+ function setMasked(on) {
171
+ masked = on;
172
+ }
173
+ function inputExhausted() {
174
+ return ended && !pending.length;
175
+ }
176
+ function pauseLines() {
177
+ rl?.pause();
178
+ }
179
+ function resumeLines() {
180
+ rl?.resume();
181
+ }
182
+ function closePrompts() {
183
+ if (stdin.isTTY && stdin.isRaw) stdin.setRawMode(false);
184
+ rl?.close();
185
+ rl = null;
186
+ }
187
+
188
+ // src/prompt/ask.ts
189
+ import { stdin as stdin2, stdout as stdout4 } from "process";
190
+ var InputError = class extends Error {
191
+ };
192
+ function unmask() {
193
+ setMasked(false);
194
+ stdout4.write("\n");
195
+ }
196
+ async function readAnswer(fallback, secret) {
197
+ setMasked(secret);
198
+ try {
199
+ return (await nextLine()).trim() || fallback;
200
+ } finally {
201
+ if (secret) unmask();
158
202
  }
159
- /**
160
- * Answer an MFA prompt with the persona's configured factor. When nothing can
161
- * answer it, `liveViewUrl` is where a person finishes by hand.
162
- */
163
- async completeMfa() {
164
- const result = await this.http.request("POST", `/api/browsers/${this.id}/mfa`, {}, 18e4);
165
- if (result.liveViewUrl) result.liveViewUrl = new URL(result.liveViewUrl, this.http.baseUrl).href;
166
- return result;
203
+ }
204
+ function check(answer, options) {
205
+ if (!stdin2.isTTY && !options.secret) stdout4.write(`${answer}
206
+ `);
207
+ const problem = options.validate?.(answer);
208
+ if (!problem) return answer;
209
+ stdout4.write(` ${icon.fail} ${style.red(problem)}
210
+ `);
211
+ if (inputExhausted()) throw new InputError(problem);
212
+ return void 0;
213
+ }
214
+ async function readValidated(render, fallback, options) {
215
+ for (; ; ) {
216
+ render();
217
+ const accepted = check(await readAnswer(fallback, !!options.secret), options);
218
+ if (accepted !== void 0) return accepted;
167
219
  }
168
- /**
169
- * Natural-language control, using this key's configured model. Refer to values as
170
- * `{{name}}` in the prompt. `data` the agent can read, so it can split a name or pick
171
- * the matching option; `secrets` it never sees. It types both through placeholders,
172
- * with filters like `{{name|first}}`, so a playbook saved from the run stores no values.
173
- */
174
- async ask(prompt, { data, secrets } = {}) {
175
- const res = await this.http.request(
176
- "POST",
177
- `/api/browsers/${this.id}/chat`,
178
- { messages: [{ role: "user", content: prompt }], data, secrets },
179
- 6e5
180
- );
181
- if (res.error) throw new OyaError(res.error, res.status ?? 500, res);
182
- return res.text;
220
+ }
221
+ function ask(question, fallback = "", options = {}) {
222
+ return readValidated(
223
+ () => stdout4.write(` ${style.green("?")} ${question}${fallback ? style.grey(` (${fallback})`) : ""} `),
224
+ fallback,
225
+ options
226
+ );
227
+ }
228
+ function askSecret(question, options = {}) {
229
+ return readValidated(() => stdout4.write(` ${style.green("?")} ${question} `), "", { ...options, secret: true });
230
+ }
231
+ async function confirm(question, fallback = false) {
232
+ const answer = await ask(`${question} ${style.grey(fallback ? "[Y/n]" : "[y/N]")}`, fallback ? "y" : "n");
233
+ return /^y/i.test(answer);
234
+ }
235
+
236
+ // src/prompt/menu.ts
237
+ import { stdin as stdin3, stdout as stdout5 } from "process";
238
+ var hint = () => style.grey(" \u2191\u2193 move \xB7 enter select");
239
+ function optionLine(o, i, chosen, interactive) {
240
+ const bullet = chosen ? icon.arrow : " ";
241
+ const number = interactive ? "" : style.grey(`${i + 1}) `);
242
+ const note2 = o.disabled ? `\u2014 ${o.disabled}` : o.note ? `\u2014 ${o.note}` : "";
243
+ const label = o.disabled ? style.grey(o.label) : chosen ? style.green(o.label) : o.label;
244
+ return ` ${bullet} ${number}${label}${note2 ? style.grey(` ${note2}`) : ""}`;
245
+ }
246
+ function renderOptions(options, active, interactive) {
247
+ const columns = stdout5.columns || DEFAULT_COLUMNS;
248
+ let rows = 0;
249
+ options.forEach((o, i) => {
250
+ const line = optionLine(o, i, interactive && i === active, interactive);
251
+ stdout5.write(`${line}${clearLine}
252
+ `);
253
+ rows += Math.max(1, Math.ceil(visible(line) / columns));
254
+ });
255
+ return rows;
256
+ }
257
+ function redraw(menu) {
258
+ if (!menu.canRedraw) return;
259
+ stdout5.write(up(menu.rows));
260
+ menu.rows = renderOptions(menu.options, menu.active, true) + 1;
261
+ stdout5.write(`${hint()}
262
+ `);
263
+ }
264
+ var move = (delta) => (menu) => {
265
+ const here = menu.pickable.indexOf(menu.active);
266
+ menu.active = menu.pickable[(here + delta + menu.pickable.length) % menu.pickable.length];
267
+ redraw(menu);
268
+ };
269
+ var jump = (key) => (menu) => {
270
+ const target = Number(key) - 1;
271
+ if (menu.options[target] && !menu.options[target].disabled) {
272
+ menu.active = target;
273
+ redraw(menu);
183
274
  }
184
- /**
185
- * Save the last `ask()` on this browser as a named playbook. Every value that was
186
- * typed, picked or clicked becomes a variable, with what the run used kept in
187
- * `defaults`, so `play()` with nothing repeats the run and any one value can be
188
- * swapped. `code` is the same flow as a Playwright module, to read or run yourself.
189
- */
190
- toPlaybook(name) {
191
- return this.http.request("POST", `/api/browsers/${this.id}/playbooks`, { name }, 12e4);
275
+ };
276
+ var select = (menu) => ({ id: menu.options[menu.active].id });
277
+ var KEYS = {
278
+ "": () => ({ id: null }),
279
+ [`${ESC}[A`]: move(-1),
280
+ k: move(-1),
281
+ [`${ESC}[B`]: move(1),
282
+ j: move(1),
283
+ "\r": select,
284
+ "\n": select
285
+ };
286
+ function actionFor(key) {
287
+ if (Object.hasOwn(KEYS, key)) return KEYS[key];
288
+ return /^[1-9]$/.test(key) ? jump(key) : void 0;
289
+ }
290
+ function collapse(menu, chosen) {
291
+ stdout5.write(up(menu.rows));
292
+ for (let i = 0; i < menu.rows; i++) stdout5.write(`${clearLine}
293
+ `);
294
+ stdout5.write(up(menu.rows));
295
+ stdout5.write(`${chosen}${clearLine}
296
+ `);
297
+ }
298
+ function recordChoice(menu) {
299
+ const chosen = ` ${icon.arrow} ${style.green(menu.options[menu.active].label)}`;
300
+ if (menu.canRedraw) collapse(menu, chosen);
301
+ else stdout5.write(`${chosen}
302
+ `);
303
+ }
304
+ var findOption = (options, v) => options[Number(v) - 1] || options.find((o) => o.id === v);
305
+ function pipedProblem(options, v) {
306
+ const picked = findOption(options, v);
307
+ if (!picked) return "Pick one of the numbers above.";
308
+ if (picked.disabled) return `${picked.label} is ${picked.disabled}. Pick another.`;
309
+ }
310
+ async function choosePiped(options, pickable) {
311
+ renderOptions(options, -1, false);
312
+ const answer = await ask("choice:", String(pickable[0] + 1), { validate: (v) => pipedProblem(options, v) });
313
+ return findOption(options, answer).id;
314
+ }
315
+ function openMenu(options, pickable) {
316
+ const rows = renderOptions(options, pickable[0], true) + 1;
317
+ const canRedraw = rows < (stdout5.rows || DEFAULT_ROWS) - 1;
318
+ return { options, pickable, active: pickable[0], rows, canRedraw };
319
+ }
320
+ function finish(menu, outcome, resolve, reject) {
321
+ if (outcome.id === null) {
322
+ stdout5.write("\n");
323
+ reject(new InputError("Cancelled."));
324
+ } else {
325
+ recordChoice(menu);
326
+ resolve(outcome.id);
192
327
  }
193
- /**
194
- * Replay a playbook with no LLM in the loop. Variables left out reuse the
195
- * recorded values where there are any. If a step no longer fits the page and
196
- * `autoHeal` is on (the default), the agent finishes the task and its fix is
197
- * saved as a draft (`healed`, `draft`); off, the step's error is thrown.
198
- * Play `'<name>:draft'` to try a draft before promoting it.
199
- */
200
- async play(name, data = {}, { autoHeal = true } = {}) {
201
- const res = await this.http.request(
202
- "POST",
203
- `/api/browsers/${this.id}/playbooks/${encodeURIComponent(name)}/play`,
204
- { variables: data, autoHeal },
205
- 6e5
206
- );
207
- if (res.error) throw new OyaError(res.error, res.status ?? 500, res);
208
- return res;
328
+ }
329
+ function release(onData, wasRaw) {
330
+ stdin3.off("data", onData);
331
+ if (!wasRaw) stdin3.setRawMode(false);
332
+ resumeLines();
333
+ }
334
+ function listen(menu, wasRaw, resolve, reject) {
335
+ const onData = (buf) => {
336
+ const outcome = actionFor(buf.toString())?.(menu);
337
+ if (!outcome) return;
338
+ release(onData, wasRaw);
339
+ finish(menu, outcome, resolve, reject);
340
+ };
341
+ stdin3.on("data", onData);
342
+ }
343
+ var readKeys = (menu, wasRaw) => new Promise((resolve, reject) => listen(menu, wasRaw, resolve, reject));
344
+ function enterRaw() {
345
+ pauseLines();
346
+ const wasRaw = !!stdin3.isRaw;
347
+ stdin3.setRawMode(true);
348
+ stdin3.resume();
349
+ return wasRaw;
350
+ }
351
+ async function chooseInteractive(options, pickable) {
352
+ const menu = openMenu(options, pickable);
353
+ const wasRaw = enterRaw();
354
+ stdout5.write(`${hint()}
355
+ `);
356
+ try {
357
+ return await readKeys(menu, wasRaw);
358
+ } finally {
359
+ if (!wasRaw && stdin3.isTTY && stdin3.isRaw) stdin3.setRawMode(false);
209
360
  }
210
- /**
211
- * Start a prompt or playbook in the background and hear back through callbacks.
212
- * `onHumanAttention` fires for an unsolved CAPTCHA, an unfinished MFA, the agent
213
- * asking for help, or a replay the agent could not heal; the run waits (up to 30
214
- * minutes) until you call `respond()`.
215
- */
216
- async submit(task, options = {}) {
217
- const { onSuccess, onFailure, onHumanAttention, onHealed, pollMs, ...body } = options;
218
- const started = await this.http.request("POST", `/api/browsers/${this.id}/runs`, { ...task, ...body });
219
- return new Run(this.http, started.id, { onSuccess, onFailure, onHumanAttention, onHealed }, pollMs);
361
+ }
362
+ async function choose(question, options) {
363
+ const pickable = options.map((o, i) => o.disabled ? -1 : i).filter((i) => i >= 0);
364
+ if (!pickable.length) throw new Error(`No option available for: ${question}`);
365
+ stdout5.write(` ${style.green("?")} ${style.bold(question)}
366
+ `);
367
+ return stdin3.isTTY ? chooseInteractive(options, pickable) : choosePiped(options, pickable);
368
+ }
369
+
370
+ // src/install/repo.ts
371
+ import { existsSync } from "fs";
372
+ import { join, dirname } from "path";
373
+
374
+ // src/install/shell.ts
375
+ import { spawn } from "child_process";
376
+ import { createConnection } from "net";
377
+
378
+ // src/install/constants.ts
379
+ var TOKEN_BYTES = 32;
380
+ var PORT_PROBE_MS = 700;
381
+ var REPO_SEARCH_DEPTH = 8;
382
+ var ENV_FILE_MODE = 384;
383
+ var PERMISSION_BITS = 511;
384
+ var OCTAL = 8;
385
+ var DEFAULT_PORT = 3100;
386
+ var EGRESS_PORT = "3128";
387
+ var DNS_PORT = 53;
388
+ var READY_TIMEOUT_MS = 18e4;
389
+ var READY_POLL_MS = 2e3;
390
+ var DIGEST_PREVIEW = 19;
391
+ var INTERVIEW_STEPS = 6;
392
+ var PLAN_LABEL_WIDTH = 12;
393
+ var JSON_INDENT = 2;
394
+ var COMPOSE_MIN = { major: 2, minor: 24 };
395
+ var NODE_MIN = { major: 22, minor: 13 };
396
+ var Status = {
397
+ /** The key is wrong. */
398
+ UNAUTHORIZED: 401,
399
+ /** The key may not use this endpoint. */
400
+ FORBIDDEN: 403
401
+ };
402
+
403
+ // src/install/shell.ts
404
+ function settle(cmd, args2, resolve, reject) {
405
+ return (code) => code === 0 ? resolve() : reject(new Error(`${cmd} ${args2.join(" ")} exited ${code}`));
406
+ }
407
+ function run(cmd, args2, opts = { cwd: process.cwd() }) {
408
+ return new Promise((resolve, reject) => {
409
+ const child = spawn(cmd, args2, { cwd: opts.cwd, stdio: opts.quiet ? "ignore" : "inherit" });
410
+ child.on("error", reject);
411
+ child.on("close", settle(cmd, args2, resolve, reject));
412
+ });
413
+ }
414
+ function pipeTo(cmd, args2, stdin4, cwd) {
415
+ return new Promise((resolve, reject) => {
416
+ const child = spawn(cmd, args2, { cwd, stdio: ["pipe", "inherit", "inherit"] });
417
+ child.on("error", reject);
418
+ child.on("close", settle(cmd, args2, resolve, reject));
419
+ child.stdin.end(stdin4);
420
+ });
421
+ }
422
+ async function capture(cmd, args2, cwd = process.cwd()) {
423
+ return new Promise((resolve) => {
424
+ const child = spawn(cmd, args2, { cwd, stdio: ["ignore", "pipe", "ignore"] });
425
+ const out2 = [];
426
+ child.stdout.on("data", (d) => out2.push(String(d)));
427
+ child.on("error", () => resolve(null));
428
+ child.on("close", (code) => resolve(code === 0 ? out2.join("").trim() : null));
429
+ });
430
+ }
431
+ var has = async (cmd) => await capture(cmd, ["--version"]) !== null;
432
+ function atLeast(version, major, minor) {
433
+ const m = /(\d+)\.(\d+)/.exec(version || "");
434
+ if (!m) return false;
435
+ return Number(m[1]) > major || Number(m[1]) === major && Number(m[2]) >= minor;
436
+ }
437
+ function watchProbe(socket, resolve) {
438
+ const settle2 = (free) => {
439
+ socket.destroy();
440
+ resolve(free);
441
+ };
442
+ socket.on("connect", () => settle2(false)).on("error", () => resolve(true));
443
+ setTimeout(() => settle2(true), PORT_PROBE_MS);
444
+ }
445
+ function portFree(port) {
446
+ return new Promise((resolve) => watchProbe(createConnection({ port, host: "127.0.0.1" }), resolve));
447
+ }
448
+
449
+ // src/install/repo.ts
450
+ var SERVER_ENTRIES = ["index.ts", "index.js"];
451
+ var isRepoRoot = (dir) => existsSync(join(dir, "docker-compose.yml")) && SERVER_ENTRIES.some((entry) => existsSync(join(dir, "server", "src", entry)));
452
+ function findRepoRoot(from = process.cwd()) {
453
+ let dir = from;
454
+ for (let i = 0; i < REPO_SEARCH_DEPTH; i++) {
455
+ if (isRepoRoot(dir)) return dir;
456
+ const up2 = dirname(dir);
457
+ if (up2 === dir) break;
458
+ dir = up2;
220
459
  }
221
- /**
222
- * Watch it work: the console, opened on this browser.
223
- *
224
- * This used to return the raw frame stream with the project's API key in the
225
- * query string — a permanent credential in browser history, Referer headers
226
- * and every proxy log on the way, and a URL that renders as a wall of
227
- * text/event-stream if a person actually opens it. It is the console deep
228
- * link now, the same one the server hands back from `completeMfa()`, and it
229
- * carries no credential at all.
230
- *
231
- * For the frames themselves, use `liveStreamUrl()`.
232
- */
233
- liveViewUrl() {
234
- return `${this.http.baseUrl}/dashboard/?browser=${encodeURIComponent(this.id)}`;
460
+ return null;
461
+ }
462
+ async function locateRepo() {
463
+ const found = findRepoRoot();
464
+ if (found) return found;
465
+ console.log("\nThis does not look like an oya-browser checkout.");
466
+ const where = await ask("Clone it to:", join(process.cwd(), "oya-browser"));
467
+ if (existsSync(where)) throw new Error(`${where} already exists`);
468
+ const repo = "https://github.com/OyadotAI/oya-browser.git";
469
+ await run("git", ["clone", "--depth", "1", repo, where], { cwd: process.cwd() });
470
+ return where;
471
+ }
472
+
473
+ // src/install/llm.ts
474
+ var LLM_PRESETS = {
475
+ anthropic: { base: "https://api.anthropic.com/v1", model: "claude-sonnet-4-5", label: "Anthropic (Claude)" },
476
+ openai: { base: "https://api.openai.com/v1", model: "gpt-4o-mini", label: "OpenAI" },
477
+ gemini: {
478
+ base: "https://generativelanguage.googleapis.com/v1beta/openai",
479
+ model: "gemini-3.8-flash",
480
+ label: "Gemini (Google)"
481
+ },
482
+ // Express mode: a global endpoint, no GCP project or location needed.
483
+ vertex: {
484
+ base: "https://aiplatform.googleapis.com/v1/publishers/google",
485
+ model: "gemini-2.5-flash",
486
+ label: "Gemini Enterprise (Vertex AI)"
235
487
  }
236
- /**
237
- * The SSE stream of JPEG frames, for embedding in your own UI. EventSource
238
- * cannot set headers, so the URL carries a connection ticket: single use,
239
- * 60 seconds. Mint one per viewer — the first connection spends it.
240
- */
241
- async liveStreamUrl() {
242
- const { ticket } = await this.http.request(
243
- "POST",
244
- `/api/control/sessions/${encodeURIComponent(this.id)}/ticket`,
245
- {}
246
- );
247
- return `${this.http.baseUrl}/api/live/${this.id}?ticket=${encodeURIComponent(ticket)}`;
488
+ };
489
+ var LLM_OPTIONS = [
490
+ { id: "anthropic", label: "Anthropic (Claude)" },
491
+ { id: "openai", label: "OpenAI" },
492
+ { id: "gemini", label: "Gemini (Google)" },
493
+ { id: "vertex", label: "Gemini Enterprise (Vertex AI)", note: "express-mode API key" },
494
+ { id: "compatible", label: "An OpenAI-compatible endpoint", note: "OpenRouter, Together, Groq, Azure" },
495
+ { id: "local", label: "A local model", note: "Ollama, vLLM, LM Studio" },
496
+ { id: "skip", label: "Skip", note: "no agent control; add it later with `oya config`" }
497
+ ];
498
+ var SKIPPED = { answers: { provider: "skip", baseUrl: "", model: "" }, key: "" };
499
+ async function endpointFor(provider) {
500
+ const preset = LLM_PRESETS[provider];
501
+ if (preset) return preset.base;
502
+ const fallback = provider === "local" ? "http://localhost:11434/v1" : "https://openrouter.ai/api/v1";
503
+ return (await ask("Base URL:", fallback)).replace(/\/+$/, "");
504
+ }
505
+ async function askLlm() {
506
+ step("Agent LLM", "Drives `oya ask` and the chat API.");
507
+ note("CDP, the SDK and MCP all work without one.");
508
+ const provider = await choose("Which LLM should agents use?", LLM_OPTIONS);
509
+ if (provider === "skip") return { answers: { provider, baseUrl: "", model: "" }, key: "" };
510
+ const baseUrl = await endpointFor(provider);
511
+ const model = await ask("Model:", LLM_PRESETS[provider]?.model || (provider === "local" ? "qwen2.5" : ""));
512
+ return askKey({ provider, baseUrl, model });
513
+ }
514
+ async function askKey(answers) {
515
+ for (; ; ) {
516
+ const key = await askSecret(answers.provider === "local" ? "API key (blank if none):" : "API key:");
517
+ if (!key) return { answers, key: "" };
518
+ const problem = await verifyLlm(answers.baseUrl, key, answers.model);
519
+ if (!problem) return { answers, key };
520
+ warn(problem);
521
+ if (await retryOrSkip() === "skip") return SKIPPED;
248
522
  }
249
- /**
250
- * A shareable link to this browser's live view, for handing to a person or
251
- * embedding in your own app. The token rides in the URL fragment, so it never
252
- * reaches a server log or Referer header. `control: true` lets whoever opens
253
- * it take over and act in the browser; otherwise it is view-only. The link
254
- * expires (default one hour) and is revocable with `revokeShare(id)`.
255
- *
256
- * The credential is scoped to this one browser: it cannot see or touch the
257
- * rest of your project. Anyone holding the link has that access until it
258
- * expires or you revoke it, so treat it like a password.
259
- */
260
- async shareUrl({ control = false, expiresInSeconds = 3600 } = {}) {
261
- const c = await this.http.request(
262
- "POST",
263
- `/api/control/sessions/${encodeURIComponent(this.id)}/share`,
264
- { control, expiresIn: expiresInSeconds }
265
- );
266
- return { url: `${this.http.baseUrl}/live/${encodeURIComponent(this.id)}#t=${encodeURIComponent(c.token)}`, id: c.id, expiresAt: c.expiresAt };
523
+ }
524
+ function retryOrSkip() {
525
+ return choose("What now?", [
526
+ { id: "retry", label: "Enter the key again" },
527
+ { id: "skip", label: "Continue without an LLM", note: "add it later with `oya config`" }
528
+ ]);
529
+ }
530
+ function authHeaders(baseUrl, key) {
531
+ const anthropic = /(^|\.)anthropic\.com$/i.test(new URL(baseUrl).hostname);
532
+ return anthropic ? { "x-api-key": key, "anthropic-version": "2023-06-01" } : { Authorization: `Bearer ${key}` };
533
+ }
534
+ function generateOnce(baseUrl, key, model) {
535
+ const name = encodeURIComponent(model || "gemini-2.5-flash");
536
+ const body = { contents: [{ role: "user", parts: [{ text: "hi" }] }], generationConfig: { maxOutputTokens: 1 } };
537
+ const url = `${baseUrl.replace(/\/+$/, "")}/models/${name}:generateContent?key=${encodeURIComponent(key)}`;
538
+ return fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
539
+ }
540
+ function probe(baseUrl, key, model) {
541
+ const headers = authHeaders(baseUrl, key);
542
+ if (/\/publishers\/google\/?$/.test(baseUrl)) return generateOnce(baseUrl, key, model);
543
+ return fetch(`${baseUrl}/models`, { headers });
544
+ }
545
+ async function judge(res, spin, baseUrl, model) {
546
+ if (res.status === Status.UNAUTHORIZED || res.status === Status.FORBIDDEN) {
547
+ spin.fail();
548
+ return `${baseUrl} rejected that key (HTTP ${res.status}).`;
267
549
  }
268
- /** Revoke a link from `shareUrl()` before it expires, by the id it returned. */
269
- async revokeShare(id) {
270
- await this.http.request("DELETE", `/api/control/credentials/${encodeURIComponent(id)}`);
550
+ if (res.ok) await confirmModel(res, spin, model);
551
+ else spin.fail(`could not verify (HTTP ${res.status}) \u2014 continuing anyway`);
552
+ return null;
553
+ }
554
+ async function confirmModel(res, spin, model) {
555
+ const body = await res.json().catch(() => null);
556
+ const ids = (body?.data || []).map((m) => m.id);
557
+ if (model && ids.length && !ids.includes(model)) {
558
+ spin.done(`key works, though "${model}" is not in this endpoint's list`);
559
+ } else {
560
+ spin.done("ok");
271
561
  }
272
- /** Counters, health and the last 50 things this browser did. */
273
- status() {
274
- return this.http.request("GET", `/api/browsers/${this.id}`);
562
+ }
563
+ async function verifyLlm(baseUrl, key, model) {
564
+ const spin = spinner("checking the key");
565
+ try {
566
+ return await judge(await probe(baseUrl, key, model), spin, baseUrl, model);
567
+ } catch (err) {
568
+ spin.fail(`could not reach it (${err.message}) \u2014 continuing anyway`);
569
+ return null;
275
570
  }
276
- /**
277
- * Stop it, whatever it is: a cloud sandbox is destroyed so billing ends, a
278
- * CDP session is handed back to its provider, a desktop browser disconnects.
279
- */
280
- stop() {
281
- return this.http.request("POST", `/api/browsers/${this.id}/stop`, {}, 6e4);
571
+ }
572
+
573
+ // src/install/interview.ts
574
+ var HOST_OPTIONS = [
575
+ { id: "docker", label: "Docker on this machine", note: "one command, no cluster" },
576
+ { id: "k8s", label: "Kubernetes", disabled: "not in this build yet" },
577
+ { id: "ecs", label: "Amazon ECS", disabled: "not in this build yet" },
578
+ { id: "fly", label: "Fly.io", disabled: "coming soon" },
579
+ { id: "exe", label: "exe.dev", disabled: "coming soon" }
580
+ ];
581
+ var DB_OPTIONS = [
582
+ { id: "sqlite", label: "SQLite", note: "zero config, one replica, API keys only" },
583
+ { id: "supabase", label: "Supabase", note: "adds email sign-in and the dashboard" },
584
+ { id: "postgres", label: "Postgres", note: "many replicas; needs psql for migrations" }
585
+ ];
586
+ var FLEET_OPTIONS = [
587
+ { id: "docker-workers", label: "Docker browser workers here", note: "always-on containers, no daemon access needed" },
588
+ {
589
+ id: "oya-selfhosted",
590
+ label: "Governed Docker, one container per session",
591
+ note: "policies and budgets; needs Docker socket access"
592
+ },
593
+ { id: "oya-cloud", label: "Oya Cloud", note: "managed sandboxes, nothing to run yourself" },
594
+ { id: "browserbase", label: "Browserbase" },
595
+ { id: "steel", label: "Steel" },
596
+ { id: "anchor", label: "Anchor" },
597
+ { id: "browseruse", label: "Browser Use Cloud" },
598
+ { id: "cdp", label: "Your own Chrome over CDP" },
599
+ { id: "k8s", label: "Kubernetes fleet, one pod per session", note: "needs kubectl and a cluster" },
600
+ { id: "ecs", label: "ECS fleet, one task per session", disabled: "not in this build yet" }
601
+ ];
602
+ var VENDOR_KEYS = {
603
+ browserbase: ["BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID"],
604
+ steel: ["STEEL_API_KEY"],
605
+ anchor: ["ANCHOR_API_KEY"],
606
+ browseruse: ["BROWSERUSE_API_KEY"]
607
+ };
608
+ var HEADINGS = {
609
+ host: ["Control plane", "Where the server itself runs."],
610
+ database: ["Database", "What holds sessions, keys and personas."],
611
+ fleet: ["Browsers", "What actually runs Chrome."]
612
+ };
613
+ var MORE_OPTIONS = [
614
+ { id: "no", label: "No", note: "change any of it later" },
615
+ { id: "yes", label: "Yes", note: "CAPTCHA solver, recording storage, metrics" }
616
+ ];
617
+ var CAPTCHA_OPTIONS = [
618
+ { id: "", label: "No", note: "hosted vendors still solve natively" },
619
+ { id: "capsolver", label: "CapSolver" },
620
+ { id: "2captcha", label: "2Captcha" }
621
+ ];
622
+ var METRICS_OPTIONS = [
623
+ { id: "no", label: "No" },
624
+ { id: "yes", label: "Yes", note: "generates a scrape token" }
625
+ ];
626
+ var K8S_QUESTIONS = [
627
+ ["namespace", "Namespace for browser pods:", "oya-browsers"],
628
+ ["image", "Browser image:", "ghcr.io/oyadotai/oya-browser:latest"],
629
+ [
630
+ "controlUrl",
631
+ "Control-plane WebSocket URL, as reachable from the cluster:",
632
+ "ws://oya-server.oya-browser.svc:3100/ws"
633
+ ],
634
+ ["proxyUrl", "Egress proxy URL, as reachable from the cluster:", "http://oya-server.oya-browser.svc:3128"]
635
+ ];
636
+ var FLEET_SECRETS = { "oya-cloud": "OYA_CLOUD_API_KEY", cdp: "OYA_CDP_WS_URL" };
637
+ function secretFor(s, field, hidden = true) {
638
+ if (s.replay) return Promise.resolve(process.env[field] || "");
639
+ return hidden ? askSecret(`${field}:`) : ask(`${field}:`);
640
+ }
641
+ function isPostgresUrl(v) {
642
+ if (!v) return;
643
+ try {
644
+ const url = new URL(v);
645
+ if (!["postgres:", "postgresql:"].includes(url.protocol)) return "Must start with postgres://";
646
+ } catch {
647
+ return "That is not a connection string \u2014 postgres://user:pass@host:5432/db";
282
648
  }
283
- /** `await using browser = await oya.browser.start()` stops it however the block exits, errors included. */
284
- async [Symbol.asyncDispose]() {
285
- await this.stop();
649
+ }
650
+ async function pick(preset, heading, question, options) {
651
+ if (preset) return preset;
652
+ step(...heading);
653
+ return choose(question, options);
654
+ }
655
+ async function askSupabase(s) {
656
+ s.secrets.SUPABASE_URL = s.replay ? process.env.SUPABASE_URL || "" : await ask("Supabase project URL:");
657
+ s.secrets.SUPABASE_SERVICE_KEY = await secretFor(s, "SUPABASE_SERVICE_KEY");
658
+ if (s.replay) return;
659
+ note("Migrations need the Postgres connection string, not the API URL.");
660
+ note("Leave it blank to apply them yourself later with `make migrate`.");
661
+ s.migrateUrl = await askSecret("Postgres connection string (optional):", { validate: isPostgresUrl });
662
+ }
663
+ async function askPostgres(s) {
664
+ s.secrets.DATABASE_URL = s.replay ? process.env.DATABASE_URL || "" : await askSecret("Postgres connection string:", {
665
+ validate: (v) => !v ? "Required for a Postgres deployment." : isPostgresUrl(v)
666
+ });
667
+ s.migrateUrl = s.secrets.DATABASE_URL;
668
+ }
669
+ function sqliteNotes() {
670
+ note("SQLite means API-key auth only \u2014 email sign-in needs Supabase.");
671
+ note("The dashboard still works: sign in with the API key printed at the end.");
672
+ }
673
+ async function askDatabase(s) {
674
+ const database = await pick(s.preset.database, HEADINGS.database, "Which database?", DB_OPTIONS);
675
+ if (database === "sqlite") sqliteNotes();
676
+ if (database === "supabase") await askSupabase(s);
677
+ if (database === "postgres") await askPostgres(s);
678
+ return database;
679
+ }
680
+ async function askWorkers(s, fleet) {
681
+ if (fleet !== "docker-workers" || s.preset.workers !== void 0) return s.preset.workers ?? 0;
682
+ const answer = await ask("How many browser workers?", "2", {
683
+ validate: (v) => /^[1-9][0-9]*$/.test(v) ? void 0 : "Enter a whole number of 1 or more."
684
+ });
685
+ return Number(answer);
686
+ }
687
+ async function askFleetSecrets(s, fleet) {
688
+ for (const field of VENDOR_KEYS[fleet] || []) {
689
+ s.secrets[field] = await secretFor(s, field, !field.endsWith("PROJECT_ID"));
286
690
  }
287
- /** @deprecated use stop() — close() only dropped the socket, and a cloud browser redialled. */
288
- async close() {
289
- await this.stop();
691
+ if (Object.hasOwn(FLEET_SECRETS, fleet)) {
692
+ s.secrets[FLEET_SECRETS[fleet]] = await secretFor(s, FLEET_SECRETS[fleet]);
290
693
  }
291
- };
292
- var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
293
- var Run = class {
294
- constructor(http, id, callbacks, pollMs = 2e3) {
295
- this.http = http;
296
- this.id = id;
297
- this.done = this.watch(callbacks, pollMs);
298
- this.done.catch(() => {
299
- });
694
+ }
695
+ async function askK8s(s, fleet) {
696
+ if (fleet !== "k8s" || s.preset.k8sFleet) return s.preset.k8sFleet;
697
+ step("Kubernetes fleet", "One pod per session.");
698
+ note("The image must be digest-pinned: a tag can move between verification and scheduling.");
699
+ const answers = {};
700
+ for (const [field, question, fallback] of K8S_QUESTIONS) answers[field] = await ask(question, fallback);
701
+ return answers;
702
+ }
703
+ async function llmFor(s) {
704
+ const llm = s.preset.llm ? { answers: s.preset.llm, key: s.replay ? process.env.OPENAI_API_KEY || "" : "" } : await askLlm();
705
+ if (llm.key) s.secrets.OPENAI_API_KEY = llm.key;
706
+ return llm.answers;
707
+ }
708
+ function checkPublicUrl(v) {
709
+ try {
710
+ const url = new URL(v);
711
+ if (!["http:", "https:"].includes(url.protocol)) return "Must be http:// or https://";
712
+ } catch {
713
+ return "That is not a URL \u2014 try http://localhost:3100";
300
714
  }
301
- http;
302
- id;
303
- done;
304
- status() {
305
- return this.http.request("GET", `/api/runs/${encodeURIComponent(this.id)}`);
715
+ }
716
+ async function askPublicUrl(s) {
717
+ step("Address", "Where clients and browsers reach this control plane.");
718
+ const url = s.preset.publicUrl || await ask("Public URL of this control plane:", "http://localhost:3100", { validate: checkPublicUrl });
719
+ return url.replace(/\/+$/, "");
720
+ }
721
+ async function askOptional(s, database) {
722
+ const optional = s.preset.optional || { captcha: "", recordingBucket: "", metrics: false };
723
+ if (s.preset.optional) return optional;
724
+ step("Optional services", "Everything here has a sensible default.");
725
+ const more = await choose("Configure optional services now?", MORE_OPTIONS);
726
+ if (more === "yes") await askServices(s, database, optional);
727
+ return optional;
728
+ }
729
+ async function askServices(s, database, optional) {
730
+ optional.captcha = await choose("Solve CAPTCHAs automatically?", CAPTCHA_OPTIONS);
731
+ if (optional.captcha) s.secrets.OYA_CAPTCHA_API_KEY = await secretFor(s, "OYA_CAPTCHA_API_KEY");
732
+ if (database === "supabase") {
733
+ optional.recordingBucket = await ask("Private Storage bucket for recordings (blank to keep them on disk):", "");
306
734
  }
307
- /** Answer the open attention request: `'done'` after handling it by hand, or your reply to the agent. */
308
- async respond(response = "done") {
309
- await this.http.request("POST", `/api/runs/${encodeURIComponent(this.id)}/respond`, { response });
735
+ optional.metrics = await choose("Expose /metrics to a Prometheus scraper?", METRICS_OPTIONS) === "yes";
736
+ }
737
+ async function askFleet(s) {
738
+ const fleet = await pick(s.preset.fleet, HEADINGS.fleet, "Where should browsers run?", FLEET_OPTIONS);
739
+ const workers = await askWorkers(s, fleet);
740
+ await askFleetSecrets(s, fleet);
741
+ return { fleet, workers, k8sFleet: await askK8s(s, fleet) };
742
+ }
743
+ async function interview(preset, replay) {
744
+ const s = { preset, replay, secrets: {}, migrateUrl: "" };
745
+ const host = await pick(preset.host, HEADINGS.host, "Where should the control plane run?", HOST_OPTIONS);
746
+ const database = await askDatabase(s);
747
+ const { fleet, workers, k8sFleet } = await askFleet(s);
748
+ const [llm, publicUrl] = [await llmFor(s), await askPublicUrl(s)];
749
+ const optional = await askOptional(s, database);
750
+ const answers = { version: 1, host, database, fleet, workers, k8sFleet, llm, publicUrl, optional };
751
+ return { answers, secrets: s.secrets, migrateUrl: s.migrateUrl };
752
+ }
753
+
754
+ // src/install/preflight.ts
755
+ import { existsSync as existsSync2, statSync } from "fs";
756
+ import { join as join2 } from "path";
757
+ function composeCheck(compose) {
758
+ const ok = atLeast(compose, COMPOSE_MIN.major, COMPOSE_MIN.minor);
759
+ return { ok, label: "docker compose \u2265 2.24", detail: compose || "not found", fatal: true };
760
+ }
761
+ async function dockerChecks() {
762
+ const version = await capture("docker", ["--version"]);
763
+ const daemon = await capture("docker", ["info", "--format", "{{.ID}}"]);
764
+ const compose = await capture("docker", ["compose", "version", "--short"]);
765
+ return [
766
+ { ok: !!version, label: "docker", detail: version || "not found", fatal: true },
767
+ { ok: !!daemon, label: "docker daemon reachable", detail: daemon ? "ok" : "not running", fatal: true },
768
+ composeCheck(compose)
769
+ ];
770
+ }
771
+ function nodeCheck() {
772
+ const ok = atLeast(process.versions.node, NODE_MIN.major, NODE_MIN.minor);
773
+ return { ok, label: "node \u2265 22.13 (node:sqlite)", detail: process.versions.node, fatal: true };
774
+ }
775
+ async function kubeChecks() {
776
+ const version = await capture("kubectl", ["version", "--client=true", "-o", "json"]);
777
+ const cluster = await capture("kubectl", ["get", "namespace", "kube-system", "-o", "name"]);
778
+ return [
779
+ { ok: !!version, label: "kubectl", detail: version ? "ok" : "not found", fatal: true },
780
+ { ok: !!cluster, label: "cluster reachable", detail: cluster || "kubectl cannot reach a cluster", fatal: true }
781
+ ];
782
+ }
783
+ async function portCheck(publicUrl) {
784
+ const port = Number(new URL(publicUrl).port || DEFAULT_PORT);
785
+ const free = await portFree(port);
786
+ return { ok: free, label: `port ${port} free`, detail: free ? "ok" : "something is already listening" };
787
+ }
788
+ function envCheck(root) {
789
+ const envPath = join2(root, ".env");
790
+ if (!existsSync2(envPath)) return [];
791
+ const mode = (statSync(envPath).mode & PERMISSION_BITS).toString(OCTAL);
792
+ return [{ ok: true, label: ".env exists \u2014 keeping its OYA_PROFILE_SECRET and API keys", detail: `mode ${mode}` }];
793
+ }
794
+ var psqlCheck = async () => ({
795
+ ok: await has("psql"),
796
+ label: "psql (to apply migrations)",
797
+ detail: "from libpq/postgresql-client",
798
+ fatal: true
799
+ });
800
+ async function toolChecks(a) {
801
+ const checks = [];
802
+ const needsDocker = a.host === "docker" || a.fleet === "docker-workers" || a.fleet === "oya-selfhosted";
803
+ if (needsDocker) checks.push(...await dockerChecks());
804
+ if (a.host !== "docker") checks.push(nodeCheck());
805
+ if (a.fleet === "k8s" || a.host === "k8s") checks.push(...await kubeChecks());
806
+ if (a.database === "postgres" || a.database === "supabase") checks.push(await psqlCheck());
807
+ return checks;
808
+ }
809
+ async function preflight(root, a) {
810
+ return [...await toolChecks(a), await portCheck(a.publicUrl), ...envCheck(root)];
811
+ }
812
+
813
+ // src/install/env-file.ts
814
+ import { existsSync as existsSync3, readFileSync, writeFileSync, chmodSync } from "fs";
815
+ var SECRET_KEYS = /* @__PURE__ */ new Set([
816
+ "OYA_PROFILE_SECRET",
817
+ "OYA_OPERATOR_TOKEN",
818
+ "OYA_METRICS_TOKEN",
819
+ "API_KEYS",
820
+ "FLEET_TOKEN",
821
+ "OYA_CLUSTER_SECRET",
822
+ "DATABASE_URL",
823
+ "SUPABASE_SERVICE_KEY",
824
+ "OPENAI_API_KEY",
825
+ "OYA_CLOUD_API_KEY",
826
+ "ANCHOR_API_KEY",
827
+ "BROWSERBASE_API_KEY",
828
+ "STEEL_API_KEY",
829
+ "BROWSERUSE_API_KEY",
830
+ "OYA_CAPTCHA_API_KEY",
831
+ "OYA_CDP_WS_URL"
832
+ ]);
833
+ var SECTIONS = [
834
+ ["Server", ["PORT", "OYA_UI_MODE", "OYA_DATA_DIR"]],
835
+ ["Tenant keys \u2014 each is an identity; add more from the dashboard", ["API_KEYS"]],
836
+ ["Host operations: /metrics, drain, fleet provision", ["OYA_OPERATOR_TOKEN", "OYA_METRICS_TOKEN"]],
837
+ ["Credentials at rest. Lose this and every stored cookie, proxy and TOTP seed is unreadable", ["OYA_PROFILE_SECRET"]],
838
+ ["Database", ["SUPABASE_URL", "SUPABASE_SERVICE_KEY", "DATABASE_URL", "OYA_RECORDING_BUCKET"]],
839
+ [
840
+ "Agent LLM. A private or http base URL is operator-only: the dashboard and\n# POST /config/host reject one, on purpose \u2014 tenants can set that field too",
841
+ ["OPENAI_API_KEY", "OPENAI_BASE_URL", "CHAT_MODEL"]
842
+ ],
843
+ ["Browser fleet", ["OYA_BROWSER_PROVIDER", "OYA_FLEET_RUNTIME", "OYA_PUBLIC_WS_URL", "OYA_CDP_WS_URL"]],
844
+ [
845
+ "Governed Docker runtime",
846
+ [
847
+ "OYA_MANAGED_NETWORK",
848
+ "OYA_MANAGED_IMAGE",
849
+ "OYA_MANAGED_CONTROL_URL",
850
+ "OYA_MANAGED_PROXY_URL",
851
+ "OYA_MANAGED_REGION",
852
+ "OYA_EGRESS_PORT",
853
+ "OYA_EGRESS_HOST"
854
+ ]
855
+ ],
856
+ ["Oya Cloud", ["OYA_CLOUD_API_KEY", "OYA_CLOUD_SNAPSHOT", "OYA_CLOUD_TARGET"]],
857
+ [
858
+ "Hosted CDP vendors",
859
+ ["BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID", "STEEL_API_KEY", "ANCHOR_API_KEY", "BROWSERUSE_API_KEY"]
860
+ ],
861
+ ["CAPTCHA", ["OYA_CAPTCHA_PROVIDER", "OYA_CAPTCHA_API_KEY"]],
862
+ ["Multi-replica. One SQLite control database allows exactly one writer", ["OYA_INSTANCE_URL", "OYA_CLUSTER_SECRET"]]
863
+ ];
864
+ function readEnv(path) {
865
+ if (!existsSync3(path)) return {};
866
+ const out2 = {};
867
+ for (const line of readFileSync(path, "utf8").split("\n")) {
868
+ const m = /^([A-Z0-9_]+)=(.*)$/.exec(line.trim());
869
+ if (m) out2[m[1]] = m[2];
310
870
  }
311
- async watch(cb, pollMs) {
312
- const call = async (fn) => {
313
- try {
314
- await fn();
315
- } catch (err) {
316
- console.error("[oya] run callback threw:", err);
317
- }
318
- };
319
- let seen;
320
- for (let errors = 0; ; ) {
321
- let run2;
322
- try {
323
- run2 = await this.status();
324
- errors = 0;
325
- } catch (err) {
326
- if (++errors < 5) {
327
- await sleep(pollMs);
328
- continue;
329
- }
330
- const failure = err instanceof OyaError ? err : new OyaError(String(err), 0, null);
331
- await call(() => cb.onFailure?.(failure));
332
- throw failure;
333
- }
334
- if (run2.status === "needs_attention" && run2.attention && run2.attention.id !== seen) {
335
- seen = run2.attention.id;
336
- const request = {
337
- ...run2.attention,
338
- liveViewUrl: run2.attention.liveViewUrl && new URL(run2.attention.liveViewUrl, this.http.baseUrl).href,
339
- respond: (response) => this.respond(response)
340
- };
341
- void call(() => cb.onHumanAttention?.(request));
342
- }
343
- if (run2.status === "succeeded") {
344
- const result = run2.result || {};
345
- if (result.healed) await call(() => cb.onHealed?.(result));
346
- await call(() => cb.onSuccess?.(result));
347
- return result;
348
- }
349
- if (run2.status === "failed") {
350
- const failure = new OyaError(run2.error || "Run failed", run2.errorStatus ?? 500, run2);
351
- await call(() => cb.onFailure?.(failure));
352
- throw failure;
353
- }
354
- await sleep(pollMs);
355
- }
871
+ return out2;
872
+ }
873
+ function block(title, keys, values) {
874
+ return [`# \u2500\u2500 ${title} \u2500\u2500`, ...keys.map((k) => `${k}=${values[k]}`), ""];
875
+ }
876
+ function sectionLines(values, placed) {
877
+ const lines = [];
878
+ for (const [title, keys] of SECTIONS) {
879
+ const present = keys.filter((k) => values[k] !== void 0 && values[k] !== "");
880
+ present.forEach((k) => placed.add(k));
881
+ if (present.length) lines.push(...block(title, present, values));
356
882
  }
883
+ return lines;
884
+ }
885
+ function renderEnv(values, extra) {
886
+ const lines = ["# Written by `oya install`. Re-run it, or edit by hand \u2014 both are fine.", ""];
887
+ const placed = /* @__PURE__ */ new Set();
888
+ lines.push(...sectionLines(values, placed));
889
+ const leftover = Object.keys(values).filter((k) => !placed.has(k) && values[k]);
890
+ if (leftover.length) lines.push(...block("Other", leftover, values));
891
+ if (extra.length) lines.push(...extra, "");
892
+ return lines.join("\n");
893
+ }
894
+ function writeEnv(path, body) {
895
+ writeFileSync(path, body, { mode: ENV_FILE_MODE });
896
+ chmodSync(path, ENV_FILE_MODE);
897
+ }
898
+
899
+ // src/install/build-env.ts
900
+ import { randomBytes } from "crypto";
901
+ var token = () => randomBytes(TOKEN_BYTES).toString("base64url");
902
+ var egress = (v) => Object.assign(v, { OYA_EGRESS_PORT: EGRESS_PORT, OYA_EGRESS_HOST: "0.0.0.0" });
903
+ var oyaCloud = (v, { existing, wsUrl }) => {
904
+ v.OYA_BROWSER_PROVIDER = "oya-cloud";
905
+ v.OYA_PUBLIC_WS_URL = wsUrl;
906
+ v.OYA_CLOUD_SNAPSHOT = existing.OYA_CLOUD_SNAPSHOT || existing.DAYTONA_SNAPSHOT || "oya-browser";
357
907
  };
358
- var MAX_FILE_BYTES = 10 * 1024 * 1024;
359
- var DEFAULT_BASE_URL = "https://browser.getoya.ai";
360
- var READY_POLL_MS = 2e3;
361
- var env = (name) => globalThis.process?.env?.[name];
362
- var Oya = class {
363
- http;
364
- constructor(options = {}) {
365
- const apiKey = options.apiKey || env("OYA_API_KEY");
366
- if (!apiKey) {
367
- throw new Error("No API key. Pass { apiKey } or set OYA_API_KEY \u2014 run `oya login` to get one.");
368
- }
369
- const baseUrl = (options.baseUrl || env("OYA_BASE_URL") || DEFAULT_BASE_URL).replace(/\/+$/, "");
370
- const fetchImpl = options.fetch || globalThis.fetch;
371
- if (!fetchImpl) throw new Error("No fetch available \u2014 pass { fetch } or use Node 18+.");
372
- this.http = new Http(baseUrl, apiKey, options.timeoutMs ?? 6e4, fetchImpl.bind(globalThis));
373
- }
374
- browser = {
375
- /** Start a browser and wait until it can take commands. */
376
- start: async (options = {}) => {
377
- const started = await this.http.request("POST", "/api/browsers/start", {
378
- profile: options.profile || options.persona,
379
- provider: options.provider,
380
- wsUrl: options.wsUrl,
381
- name: options.name,
382
- queueMs: options.queueMs,
383
- priority: options.priority,
384
- budgetUsd: options.budgetUsd,
385
- governed: options.governed,
386
- policy: options.policy
387
- }, 12e4, { "Idempotency-Key": options.idempotencyKey || globalThis.crypto.randomUUID() });
388
- if (started.status === "starting") {
389
- await this.waitUntilConnected(started.id, options.readyTimeoutMs ?? 12e4 + (options.queueMs || 0));
390
- const connected = await this.http.request("GET", `/api/browsers/${encodeURIComponent(started.id)}`);
391
- started.cdpUrl = connected.cdpUrl;
392
- }
393
- return new Browser(this.http, started, options.captcha === "auto");
394
- },
395
- /** Reattach to a browser that is already running. */
396
- get: async (id) => {
397
- const found = await this.http.request("GET", `/api/browsers/${encodeURIComponent(id)}`);
398
- return new Browser(this.http, {
399
- id: found.id,
400
- provider: found.provider || "cdp",
401
- persona: found.persona || "default",
402
- status: "ready",
403
- cdpUrl: found.cdpUrl
404
- }, false);
405
- },
406
- list: () => this.http.request("GET", "/api/browsers"),
407
- /** Stop some (`ids`) or every browser on this key. Each reports separately. */
408
- stop: (ids) => this.http.request("POST", "/api/browsers/stop", ids === "all" ? { all: true } : { ids }, 12e4),
409
- stopAll: async () => (await this.browser.stop("all")).stopped
410
- };
411
- /** Durable operational controls, including disconnected and cleanup-pending sessions. */
412
- control = {
413
- overview: () => this.http.request("GET", "/api/control"),
414
- sessions: () => this.http.request("GET", "/api/control/sessions"),
415
- session: (id) => this.http.request("GET", `/api/control/sessions/${encodeURIComponent(id)}`),
416
- settings: (changes) => this.http.request("PATCH", "/api/control/project", changes),
417
- cancel: (id) => this.http.request("POST", `/api/control/sessions/${encodeURIComponent(id)}/cancel`, {}),
418
- stop: (id, force = false) => this.http.request("POST", `/api/control/sessions/${encodeURIComponent(id)}/stop`, { force }),
419
- takeover: (id, action) => this.http.request("POST", `/api/control/sessions/${encodeURIComponent(id)}/control`, { action }),
420
- input: (id, action, params) => this.http.request("POST", `/api/control/sessions/${encodeURIComponent(id)}/input`, { action, params }),
421
- recover: (id, replace = false) => this.http.request("POST", `/api/control/sessions/${encodeURIComponent(id)}/recover`, { replace }),
422
- ticket: (id) => this.http.request("POST", `/api/control/sessions/${encodeURIComponent(id)}/ticket`, {}),
423
- events: (after = 0) => this.http.request("GET", `/api/control/events?after=${after}`),
424
- createCredential: (options) => this.http.request("POST", "/api/control/credentials", options),
425
- revokeCredential: (id) => this.http.request("DELETE", `/api/control/credentials/${encodeURIComponent(id)}`),
426
- members: () => this.http.request("GET", "/api/control/members"),
427
- inviteMember: (role = "operator") => this.http.request("POST", "/api/control/members/invite", { role }),
428
- removeMember: (userId) => this.http.request("DELETE", `/api/control/members/${encodeURIComponent(userId)}`),
429
- createWebhook: (url, types = []) => this.http.request("POST", "/api/control/webhooks", { url, types }),
430
- removeWebhook: (id) => this.http.request("DELETE", `/api/control/webhooks/${encodeURIComponent(id)}`),
431
- replayDelivery: (id) => this.http.request("POST", `/api/control/deliveries/${encodeURIComponent(id)}/replay`, {})
432
- };
433
- /** Playbooks saved with `browser.toPlaybook()`. */
434
- playbooks = {
435
- list: async () => (await this.http.request("GET", "/api/playbooks")).playbooks,
436
- /** Delete a playbook and its draft, or only the draft with `'<name>:draft'`. */
437
- remove: async (name) => {
438
- await this.http.request("DELETE", `/api/playbooks/${encodeURIComponent(name)}`);
439
- },
440
- /** Replace a playbook with the draft a healed replay saved. Try it first with `browser.play('<name>:draft')`. */
441
- promote: (name) => this.http.request("POST", `/api/playbooks/${encodeURIComponent(name)}/promote`, {})
442
- };
443
- /**
444
- * Proxy exits for your personas. A persona takes one at first connect (by its
445
- * geo hint) or by `personas.pinProxy`, and keeps it.
446
- */
447
- proxies = {
448
- list: async () => (await this.http.request("GET", "/api/proxies")).proxies,
449
- create: (proxy) => this.http.request("POST", "/api/proxies", proxy),
450
- remove: async (id) => {
451
- await this.http.request("DELETE", `/api/proxies/${encodeURIComponent(id)}`);
452
- },
453
- /** Dial each proxy and learn its real exit IP. Failing ones cool down and are skipped. */
454
- check: async () => (await this.http.request("POST", "/api/proxies/check", {})).results
455
- };
456
- personas = {
457
- list: async () => (await this.http.request("GET", "/api/personas")).personas,
458
- get: (id) => this.http.request("GET", `/api/personas/${id}`),
459
- /**
460
- * Create an identity. The device — platform, timezone, locale — is chosen
461
- * here and fixed for its life; `preview()` shows what a choice produces.
462
- */
463
- create: (options = {}) => this.http.request("POST", "/api/personas", options),
464
- /** Name, concurrency cap and proxy hint. Never the device — clone for that. */
465
- update: (id, changes) => this.http.request("PUT", `/api/personas/${id}`, changes),
466
- /** A new persona of the same kind of device: same choices, fresh identity, empty jar. */
467
- clone: (id, options = {}) => this.http.request("POST", `/api/personas/${id}/clone`, options),
468
- /** The fingerprint these choices would produce. Persists nothing. */
469
- preview: async (prefs = {}) => (await this.http.request("POST", "/api/personas/preview", { prefs })).fingerprint,
470
- /** Platforms, and the timezones and locales each may coherently claim. */
471
- options: () => this.http.request("GET", "/api/personas/options"),
472
- /** Pin the persona to one of your proxies, or `null` to let assignment happen at connect. */
473
- pinProxy: (id, proxyId) => this.http.request("PUT", `/api/personas/${id}/proxy`, { proxyId }),
474
- remove: async (id) => {
475
- await this.http.request("DELETE", `/api/personas/${id}`);
476
- },
477
- /** Store the second factor for this identity. Sealed at rest, never read back. */
478
- setMfa: (id, config) => this.http.request("PUT", `/api/personas/${id}/mfa`, config),
479
- clearMfa: async (id, domain) => {
480
- await this.http.request("DELETE", `/api/personas/${id}/mfa${domain ? `?domain=${encodeURIComponent(domain)}` : ""}`);
481
- },
482
- /**
483
- * Store a site login for this identity. Sealed at rest, never read back.
484
- *
485
- * Signing in once on the desktop and inheriting the cookies is still the
486
- * better path. This is for portals that expire a session server-side
487
- * between runs, where an unattended run has nothing else to recover with.
488
- */
489
- setCredentials: (id, config) => this.http.request("PUT", `/api/personas/${id}/credentials`, config),
490
- /** Which sites this identity can sign in to. Usernames only. */
491
- credentials: (id) => this.http.request("GET", `/api/personas/${id}/credentials`),
492
- clearCredentials: async (id, domain) => {
493
- await this.http.request("DELETE", `/api/personas/${id}/credentials?domain=${encodeURIComponent(domain)}`);
494
- }
495
- };
496
- /**
497
- * This key's settings: LLM credentials, browser provider, solver.
498
- *
499
- * Bring your own LLM key (it pays for its own tokens, so no hourly chat quota applies):
500
- * await oya.config.set({ llm_provider: 'gemini', openai_api_key: process.env.GEMINI_API_KEY });
501
- * `llm_provider` is the {@link LlmProvider} union, so an editor offers the choices and a
502
- * typo is a compile error; `chat_model` overrides its default model. 'vertex' is Gemini
503
- * Enterprise (ex-Vertex AI) in express mode, which needs no GCP project; for a
504
- * project-scoped endpoint, set `openai_base_url` to `.../endpoints/openapi` and pass an
505
- * OAuth access token as `openai_api_key`. See {@link ConfigUpdate} for every field.
506
- */
507
- config = {
508
- get: () => this.http.request("GET", "/api/config"),
509
- set: (values) => this.http.request("POST", "/api/config", values)
510
- };
511
- /** Saved profiles. `personas` is retained as an alias for existing clients. */
512
- profiles = this.personas;
513
- usage() {
514
- return this.http.request("GET", "/api/usage");
908
+ var governedDocker = (v, { a, wsUrl }) => {
909
+ const inCompose = a.host === "docker";
910
+ Object.assign(v, { OYA_BROWSER_PROVIDER: "oya-selfhosted", OYA_FLEET_RUNTIME: "docker" });
911
+ Object.assign(v, { OYA_MANAGED_NETWORK: "oya-browsers", OYA_MANAGED_IMAGE: "oya-browser:local" });
912
+ v.OYA_MANAGED_CONTROL_URL = inCompose ? `ws://server:${DEFAULT_PORT}/ws` : wsUrl;
913
+ egress(v);
914
+ v.OYA_MANAGED_PROXY_URL = inCompose ? `http://server:${EGRESS_PORT}` : `http://127.0.0.1:${EGRESS_PORT}`;
915
+ v.OYA_MANAGED_REGION = "local";
916
+ };
917
+ function kubeSettings(v, k) {
918
+ const runtime = { OYA_BROWSER_PROVIDER: "oya-selfhosted", OYA_FLEET_RUNTIME: "k8s", OYA_K8S_NAMESPACE: k.namespace };
919
+ Object.assign(v, runtime);
920
+ v.OYA_MANAGED_NETWORK = "oya-governed-egress";
921
+ v.OYA_MANAGED_IMAGE = k.image;
922
+ Object.assign(v, { OYA_MANAGED_CONTROL_URL: k.controlUrl, OYA_MANAGED_PROXY_URL: k.proxyUrl });
923
+ v.OYA_MANAGED_REGION = k.namespace;
924
+ egress(v);
925
+ }
926
+ var kubernetes = (v, { a }) => {
927
+ if (a.k8sFleet) kubeSettings(v, a.k8sFleet);
928
+ else v.OYA_BROWSER_PROVIDER = a.fleet;
929
+ };
930
+ var FLEETS = {
931
+ "oya-cloud": oyaCloud,
932
+ "oya-selfhosted": governedDocker,
933
+ k8s: kubernetes,
934
+ "docker-workers": (v) => {
935
+ v.OYA_BROWSER_PROVIDER = "oya-selfhosted";
936
+ }
937
+ };
938
+ function fleetSettings(v, input) {
939
+ if (Object.hasOwn(FLEETS, input.a.fleet)) FLEETS[input.a.fleet](v, input);
940
+ else v.OYA_BROWSER_PROVIDER = input.a.fleet;
941
+ }
942
+ function baseSecrets(a, existing) {
943
+ const v = { OYA_PROFILE_SECRET: existing.OYA_PROFILE_SECRET || token() };
944
+ const apiKey = existing.API_KEYS?.split(",")[0] || `oya_${token()}`;
945
+ v.API_KEYS = existing.API_KEYS || apiKey;
946
+ v.OYA_OPERATOR_TOKEN = existing.OYA_OPERATOR_TOKEN || token();
947
+ if (a.host !== "docker") v.PORT = String(Number(new URL(a.publicUrl).port || DEFAULT_PORT));
948
+ return { v, apiKey };
949
+ }
950
+ function optionalSettings(v, a, existing) {
951
+ if (a.optional.captcha) v.OYA_CAPTCHA_PROVIDER = a.optional.captcha;
952
+ if (a.optional.recordingBucket) v.OYA_RECORDING_BUCKET = a.optional.recordingBucket;
953
+ if (a.optional.metrics) v.OYA_METRICS_TOKEN = existing.OYA_METRICS_TOKEN || token();
954
+ }
955
+ var fleetTokenLines = () => [
956
+ "# \u2500\u2500 Shared fleet enrolment \u2500\u2500",
957
+ "# Uncomment to let any browser presenting this token enrol itself.",
958
+ `# FLEET_TOKEN=${token()}`
959
+ ];
960
+ function buildEnv(a, secrets, existing) {
961
+ const { v, apiKey } = baseSecrets(a, existing);
962
+ Object.assign(v, secrets);
963
+ if (a.llm.provider !== "skip") Object.assign(v, { OPENAI_BASE_URL: a.llm.baseUrl, CHAT_MODEL: a.llm.model });
964
+ fleetSettings(v, { a, existing, wsUrl: a.publicUrl.replace(/^http/, "ws") + "/ws" });
965
+ optionalSettings(v, a, existing);
966
+ return { values: v, extra: fleetTokenLines(), apiKey };
967
+ }
968
+
969
+ // src/install/kek.ts
970
+ var PROBE_IMAGE = "alpine@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce";
971
+ var SEALED = /cookies\.json|personas\.json|profiles|\.secret/;
972
+ async function isSealed(name) {
973
+ const listing = await capture("docker", ["run", "--rm", "-v", `${name}:/d`, PROBE_IMAGE, "ls", "/d"]);
974
+ return listing === null || SEALED.test(listing);
975
+ }
976
+ async function priorState() {
977
+ const volumes = await capture("docker", ["volume", "ls", "-q", "--filter", "name=oya-data"]) || "";
978
+ for (const name of volumes.split("\n").map((v) => v.trim()).filter(Boolean)) {
979
+ if (await isSealed(name)) return name;
515
980
  }
516
- async waitUntilConnected(id, timeoutMs) {
517
- const deadline = Date.now() + timeoutMs;
518
- while (Date.now() < deadline) {
519
- const all = await this.browser.list();
520
- if (all.some((b) => b.id === id && b.health !== "dead")) return;
521
- try {
522
- const session = await this.control.session(id);
523
- if (["failed", "stopped", "unknown_outcome"].includes(session.state)) throw new OyaError(`Browser creation ended in ${session.state}`, 409, session);
524
- } catch (e) {
525
- if (!(e instanceof OyaError) || e.status !== 404) throw e;
526
- }
527
- await new Promise((r) => setTimeout(r, READY_POLL_MS));
528
- }
529
- throw new OyaError(`Browser ${id} did not come up within ${Math.round(timeoutMs / 1e3)}s`, 504, null);
981
+ return null;
982
+ }
983
+ var RESOLUTIONS = [
984
+ { id: "paste", label: "I have the previous OYA_PROFILE_SECRET", note: "reuse it and keep the data" },
985
+ { id: "reset", label: "Delete the old data and start clean", note: "personas, cookies and sessions are lost" },
986
+ { id: "abort", label: "Stop so I can go and find it" }
987
+ ];
988
+ function askResolution(volume) {
989
+ warn(`${volume} already holds encrypted data from an earlier install.`);
990
+ note("Cookies, proxy credentials and TOTP seeds in it were sealed with that");
991
+ note("install's OYA_PROFILE_SECRET. A new secret cannot open them, and the");
992
+ note("server refuses to start rather than silently losing them.");
993
+ return choose("How should that be handled?", RESOLUTIONS);
994
+ }
995
+ async function deleteVolume(volume, root) {
996
+ if (!await confirm(`Delete ${volume} and everything stored in it?`, false)) {
997
+ throw new InputError("Stopped without changing anything.");
530
998
  }
531
- };
999
+ await run("docker", ["compose", "down", "-v"], { cwd: root, quiet: true }).catch(() => {
1000
+ });
1001
+ await run("docker", ["volume", "rm", "-f", volume], { cwd: root, quiet: true });
1002
+ success(`removed ${volume}`);
1003
+ }
1004
+ var askPrevious = () => askSecret("Previous OYA_PROFILE_SECRET:", {
1005
+ validate: (v) => v ? void 0 : "Paste the secret, or pick another option."
1006
+ });
1007
+ async function resolveKekConflict(volume, root) {
1008
+ const choice = await askResolution(volume);
1009
+ if (choice === "paste") return askPrevious();
1010
+ if (choice === "abort") throw new InputError("Stopped without changing anything.");
1011
+ await deleteVolume(volume, root);
1012
+ return null;
1013
+ }
532
1014
 
533
- // src/config.ts
534
- import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "fs";
535
- import { homedir } from "os";
536
- import { join, dirname } from "path";
537
- var FILE = join(process.env.OYA_CONFIG_HOME || join(homedir(), ".oya"), "config.json");
538
- function load() {
1015
+ // src/install/migrate.ts
1016
+ import { spawn as spawn2 } from "child_process";
1017
+ import { join as join3 } from "path";
1018
+ var startMigrations = (root, databaseUrl) => spawn2(process.execPath, [join3(root, "server", "migrations", "run.mjs")], {
1019
+ cwd: root,
1020
+ stdio: ["ignore", "pipe", "pipe"],
1021
+ env: { ...process.env, DATABASE_URL: databaseUrl }
1022
+ });
1023
+ function runMigrations(root, databaseUrl) {
1024
+ return new Promise((resolve, reject) => {
1025
+ const child = startMigrations(root, databaseUrl);
1026
+ let err = "";
1027
+ child.stderr.on("data", (d) => err += d);
1028
+ child.on("error", reject);
1029
+ const failure2 = (code) => new Error(err.trim() || `migrations exited ${code}`);
1030
+ child.on("close", (code) => code === 0 ? resolve() : reject(failure2(code)));
1031
+ });
1032
+ }
1033
+ var RETRY = "Fix the database, then re-run: DATABASE_URL=\u2026 make migrate";
1034
+ async function migrate(root, databaseUrl) {
1035
+ const spin = spinner("applying migrations");
539
1036
  try {
540
- return JSON.parse(readFileSync(FILE, "utf8"));
541
- } catch {
542
- return {};
1037
+ await runMigrations(root, databaseUrl);
1038
+ spin.done();
1039
+ } catch (e) {
1040
+ spin.fail();
1041
+ throw new Error(`Migrations failed: ${e.message}
1042
+ ${RETRY}`);
543
1043
  }
544
1044
  }
545
- function save(values) {
546
- const merged = { ...load(), ...values };
547
- mkdirSync(dirname(FILE), { recursive: true });
548
- writeFileSync(FILE, JSON.stringify(merged, null, 2), { mode: 384 });
549
- chmodSync(FILE, 384);
1045
+
1046
+ // src/install/k8s.ts
1047
+ import { mkdirSync, writeFileSync as writeFileSync2 } from "fs";
1048
+ import { join as join4 } from "path";
1049
+ var POLICY_NAME = "oya-governed-egress";
1050
+ function unpinnable(reference) {
1051
+ return new Error(
1052
+ `Kubernetes browsers must run a digest-pinned image, and ${reference} is a tag.
1053
+ Get the digest with: docker manifest inspect --verbose ${reference}
1054
+ then re-run with the image written as ${reference.split(":")[0]}@sha256:<digest>`
1055
+ );
550
1056
  }
551
- var configPath = FILE;
552
- function resolved() {
553
- const saved = load();
554
- return {
555
- apiKey: process.env.OYA_API_KEY || saved.apiKey || "",
556
- baseUrl: (process.env.OYA_BASE_URL || saved.baseUrl || "https://browser.getoya.ai").replace(/\/+$/, "")
1057
+ async function resolveDigest(reference) {
1058
+ const raw = await capture("docker", ["manifest", "inspect", "--verbose", reference]);
1059
+ return raw && /"digest":\s*"(sha256:[0-9a-f]{64})"/.exec(raw)?.[1];
1060
+ }
1061
+ async function pinImage(reference) {
1062
+ if (/@sha256:[0-9a-f]{64}$/.test(reference)) return reference;
1063
+ process.stdout.write(` resolving ${reference} to a digest\u2026 `);
1064
+ const digest = await resolveDigest(reference);
1065
+ console.log(digest ? digest.slice(0, DIGEST_PREVIEW) + "\u2026" : "could not");
1066
+ if (!digest) throw unpinnable(reference);
1067
+ return `${reference.split("@")[0].replace(/:[^:/]+$/, "")}@${digest}`;
1068
+ }
1069
+ var EGRESS = [
1070
+ // DNS only, to the cluster resolver.
1071
+ {
1072
+ ports: [
1073
+ { protocol: "UDP", port: DNS_PORT },
1074
+ { protocol: "TCP", port: DNS_PORT }
1075
+ ]
1076
+ },
1077
+ // Everything else must go through the control plane's egress proxy, which
1078
+ // is what makes per-session egress policy and budgets enforceable.
1079
+ // `app: server` is the label k8s/base/server.yaml gives the control plane;
1080
+ // the empty namespaceSelector lets the browsers live in their own namespace.
1081
+ // A control plane OUTSIDE the cluster is not matched by any pod selector —
1082
+ // add an ipBlock rule here for that case, or the pods will resolve DNS and
1083
+ // then fail to enrol.
1084
+ { to: [{ namespaceSelector: {}, podSelector: { matchLabels: { app: "server" } } }] }
1085
+ ];
1086
+ function networkPolicy(namespace, name) {
1087
+ const spec = {
1088
+ podSelector: { matchLabels: { app: "oya-managed-browser" } },
1089
+ policyTypes: ["Egress"],
1090
+ egress: EGRESS
557
1091
  };
1092
+ const policy = { apiVersion: "networking.k8s.io/v1", kind: "NetworkPolicy", metadata: { name, namespace }, spec };
1093
+ return JSON.stringify(policy, null, JSON_INDENT);
558
1094
  }
559
-
560
- // src/prompt.ts
561
- import { createInterface } from "readline";
562
- import { stdin, stdout } from "process";
563
- var ESC = "\x1B";
564
- var plain = !stdout.isTTY || !!process.env.NO_COLOR || process.env.TERM === "dumb";
565
- var wrap = (open) => (s) => plain ? s : `${ESC}[${open}m${s}${ESC}[0m`;
566
- var style = {
567
- bold: wrap("1"),
568
- dim: wrap("2"),
569
- green: wrap("32"),
570
- cyan: wrap("36"),
571
- yellow: wrap("33"),
572
- red: wrap("31"),
573
- grey: wrap("90")
574
- };
575
- var icon = {
576
- ok: style.green("\u2714"),
577
- fail: style.red("\u2717"),
578
- warn: style.yellow("!"),
579
- arrow: style.green("\u276F")
580
- };
581
- var clearLine = plain ? "" : `${ESC}[K`;
582
- var up = (n) => `${ESC}[${n}A`;
583
- var width = () => Math.min(stdout.columns || 80, 74);
584
- function banner(title, subtitle) {
585
- const line = "\u2500".repeat(width());
586
- stdout.write(`
587
- ${style.grey(line)}
588
- ${style.bold(title)}
589
- `);
590
- if (subtitle) stdout.write(` ${style.grey(subtitle)}
591
- `);
592
- stdout.write(`${style.grey(line)}
593
- `);
1095
+ function savePolicy(root, namespace) {
1096
+ const policyPath = join4(root, "k8s", "fleet", "networkpolicy.json");
1097
+ mkdirSync(join4(root, "k8s", "fleet"), { recursive: true });
1098
+ writeFileSync2(policyPath, networkPolicy(namespace, POLICY_NAME) + "\n");
1099
+ return policyPath;
594
1100
  }
595
- var stepNumber = 0;
596
- var stepTotal = 0;
597
- function steps(total) {
598
- stepTotal = total;
599
- stepNumber = 0;
600
- }
601
- function step(title, hint) {
602
- stepNumber += 1;
603
- const counter = stepTotal ? style.grey(`${stepNumber}/${stepTotal}`) : "";
604
- stdout.write(`
605
- ${style.cyan("\u25C6")} ${style.bold(title)} ${counter}
606
- `);
607
- if (hint) stdout.write(`${style.grey(" " + hint)}
608
- `);
1101
+ var apply = (manifest, root) => pipeTo("kubectl", ["apply", "-f", "-"], manifest, root);
1102
+ async function provisionK8sFleet(root, a) {
1103
+ const fleet = a.k8sFleet;
1104
+ const pinned = await pinImage(fleet.image);
1105
+ await apply(JSON.stringify({ apiVersion: "v1", kind: "Namespace", metadata: { name: fleet.namespace } }), root);
1106
+ const policyPath = savePolicy(root, fleet.namespace);
1107
+ await apply(networkPolicy(fleet.namespace, POLICY_NAME), root);
1108
+ console.log(` applied namespace ${fleet.namespace} and NetworkPolicy ${POLICY_NAME}`);
1109
+ console.log(` policy written to ${policyPath} \u2014 review it; it is your egress boundary`);
1110
+ return pinned;
609
1111
  }
610
- function note(text) {
611
- stdout.write(`${style.grey(" " + text)}
612
- `);
1112
+
1113
+ // src/install/docker.ts
1114
+ async function provisionGoverned(root) {
1115
+ const exists = await capture("docker", ["network", "inspect", "oya-browsers"]);
1116
+ if (!exists)
1117
+ await run("docker", ["network", "create", "--internal", "--driver", "bridge", "oya-browsers"], { cwd: root });
1118
+ console.log(" building the governed browser image\u2026");
1119
+ await run("docker", ["build", "-t", "oya-browser:local", "browser"], { cwd: root });
1120
+ console.log("\n Governed browsers need Docker daemon access from the server container.");
1121
+ console.log(" Add this to the server service in docker-compose.yml, then re-run `docker compose up -d`:");
1122
+ console.log(" volumes:\n - /var/run/docker.sock:/var/run/docker.sock");
613
1123
  }
614
- function success(text) {
615
- stdout.write(`${icon.ok} ${text}
616
- `);
1124
+ async function provisionDocker(root, a) {
1125
+ if (a.fleet === "oya-selfhosted") await provisionGoverned(root);
1126
+ const scale = a.fleet === "docker-workers" ? ["--scale", `browser=${a.workers}`] : ["--scale", "browser=0"];
1127
+ await run("docker", ["compose", "up", "-d", "--build", ...scale], { cwd: root });
617
1128
  }
618
- function warn(text) {
619
- stdout.write(`${icon.warn} ${style.yellow(text)}
620
- `);
1129
+ async function isReady(url) {
1130
+ try {
1131
+ return (await fetch(`${url}/readyz`)).ok;
1132
+ } catch {
1133
+ return false;
1134
+ }
621
1135
  }
622
- function spinner(label) {
623
- if (plain) {
624
- stdout.write(` ${label}\u2026 `);
625
- return {
626
- done: (msg = "ok") => stdout.write(`${msg}
627
- `),
628
- fail: (msg = "failed") => stdout.write(`${msg}
629
- `)
630
- };
1136
+ async function pollReady(url, deadline) {
1137
+ while (Date.now() < deadline) {
1138
+ if (await isReady(url)) return true;
1139
+ await new Promise((r) => setTimeout(r, READY_POLL_MS));
631
1140
  }
632
- const frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
633
- let i = 0;
634
- const timer = setInterval(() => {
635
- stdout.write(`\r ${style.cyan(frames[i++ % frames.length])} ${label}\u2026 `);
636
- }, 80);
637
- const stop = (mark, msg) => {
638
- clearInterval(timer);
639
- stdout.write(`\r ${mark} ${label} ${style.grey(msg)}${clearLine}
640
- `);
641
- };
642
- return { done: (msg = "") => stop(icon.ok, msg), fail: (msg = "") => stop(icon.fail, msg) };
1141
+ return false;
643
1142
  }
644
- var rl = null;
645
- var masked = false;
646
- var pending = [];
647
- var waiter = null;
648
- var ended = false;
649
- function iface() {
650
- if (rl) return rl;
651
- rl = createInterface({ input: stdin, output: stdout, terminal: !!stdin.isTTY });
652
- const deliver = (line) => {
653
- if (waiter) {
654
- const w = waiter;
655
- waiter = null;
656
- w(line);
657
- } else pending.push(line);
658
- };
659
- rl.on("line", deliver);
660
- rl.on("close", () => {
661
- ended = true;
662
- if (waiter) {
663
- const w = waiter;
664
- waiter = null;
665
- w("");
666
- }
667
- });
668
- const asAny = rl;
669
- asAny._writeToOutput = (s) => {
670
- if (!masked) {
671
- asAny.output.write(s);
672
- return;
673
- }
674
- asAny.output.write(s.includes("\n") ? "\n" : "*");
675
- };
676
- return rl;
1143
+ async function waitReady(url, timeoutMs = READY_TIMEOUT_MS) {
1144
+ const deadline = Date.now() + timeoutMs;
1145
+ process.stdout.write(" waiting for /readyz\u2026 ");
1146
+ if (await pollReady(url, deadline)) {
1147
+ console.log("ready");
1148
+ return;
1149
+ }
1150
+ console.log("timed out");
1151
+ throw new Error(`${url}/readyz did not become ready. Check logs with: docker compose logs -f server`);
677
1152
  }
678
- function nextLine() {
679
- const queued = pending.shift();
680
- if (queued !== void 0) return Promise.resolve(queued);
681
- if (ended) return Promise.resolve("");
682
- iface();
683
- return new Promise((resolve) => {
684
- waiter = resolve;
685
- });
1153
+
1154
+ // src/install/report.ts
1155
+ function printPreflight(checks) {
1156
+ console.log(`
1157
+ ${style.bold(" Preflight")}`);
1158
+ for (const c of checks) {
1159
+ const mark = c.ok ? icon.ok : c.fatal ? icon.fail : icon.warn;
1160
+ console.log(` ${mark} ${c.label}${c.detail ? style.grey(` \u2014 ${c.detail}`) : ""}`);
1161
+ }
686
1162
  }
687
- function closePrompts() {
688
- if (stdin.isTTY && stdin.isRaw) stdin.setRawMode(false);
689
- rl?.close();
690
- rl = null;
1163
+ var row = (k, v) => console.log(` ${style.grey(k.padEnd(PLAN_LABEL_WIDTH))}${v}`);
1164
+ function printPlan(answers, envPath) {
1165
+ console.log(`
1166
+ ${style.bold(" Plan")}`);
1167
+ const { llm } = answers;
1168
+ row("control", `${answers.host} \xB7 ${answers.database}`);
1169
+ row("browsers", `${answers.fleet}${answers.workers ? ` \xD7${answers.workers}` : ""}`);
1170
+ row("llm", llm.provider === "skip" ? style.grey("none") : `${llm.model} ${style.grey(`@ ${llm.baseUrl}`)}`);
1171
+ row("writes", `${envPath} ${style.grey("(0600)")}`);
1172
+ row("then", `docker compose up -d, wait for ${answers.publicUrl}/readyz`);
691
1173
  }
692
- var InputError = class extends Error {
693
- };
694
- async function readValidated(render, fallback, options) {
695
- for (; ; ) {
696
- render();
697
- masked = !!options.secret;
698
- let answer;
699
- try {
700
- answer = (await nextLine()).trim() || fallback;
701
- } finally {
702
- if (options.secret) {
703
- masked = false;
704
- stdout.write("\n");
705
- }
706
- }
707
- if (!stdin.isTTY && !options.secret) stdout.write(`${answer}
1174
+ function printDryRun(values) {
1175
+ console.log("\n--dry-run: nothing was written. Settings that would be applied:");
1176
+ for (const k of Object.keys(values)) {
1177
+ console.log(` ${k}=${SECRET_KEYS.has(k) ? style.grey("<hidden>") : values[k]}`);
1178
+ }
1179
+ }
1180
+ function printDone(publicUrl, apiKey) {
1181
+ banner(`${publicUrl} is up`, "Sign in to the dashboard with the key below.");
1182
+ console.log(`
1183
+ ${style.bold("API key")} ${style.green(apiKey)}
708
1184
  `);
709
- const problem = options.validate?.(answer);
710
- if (!problem) return answer;
711
- stdout.write(` ${icon.fail} ${style.red(problem)}
1185
+ console.log(` ${style.grey("# your shell may export OYA_API_KEY for the hosted service")}`);
1186
+ console.log(` unset OYA_API_KEY`);
1187
+ console.log(` oya login --url ${publicUrl} --key ${apiKey}`);
1188
+ console.log(` oya ls && oya goto https://example.com
712
1189
  `);
713
- if (ended && !pending.length) throw new InputError(problem);
1190
+ }
1191
+
1192
+ // src/install.ts
1193
+ var ANSWERS_FILE = "oya-install.json";
1194
+ async function gather(configPath2) {
1195
+ const root = await locateRepo();
1196
+ const preset = configPath2 ? JSON.parse(readFileSync2(configPath2, "utf8")) : {};
1197
+ banner("Oya Browser \u2014 self-host install", root);
1198
+ if (configPath2) note(`replaying ${configPath2}`);
1199
+ steps(configPath2 ? 0 : INTERVIEW_STEPS);
1200
+ return { root, ...await interview(preset, !!configPath2) };
1201
+ }
1202
+ async function checkMachine(root, answers, dryRun) {
1203
+ const checks = await preflight(root, answers);
1204
+ printPreflight(checks);
1205
+ const blocking = checks.filter((c) => !c.ok && c.fatal);
1206
+ if (blocking.length && !dryRun) {
1207
+ throw new Error(`Install cannot continue: ${blocking.map((c) => c.label).join(", ")}`);
714
1208
  }
715
1209
  }
716
- function ask(question, fallback = "", options = {}) {
717
- return readValidated(
718
- () => stdout.write(` ${style.green("?")} ${question}${fallback ? style.grey(` (${fallback})`) : ""} `),
719
- fallback,
720
- options
721
- );
1210
+ function warnLocalDatabase(answers, databaseUrl = "") {
1211
+ if (answers.host !== "docker" || !/(localhost|127\.0\.0\.1)/.test(databaseUrl)) return;
1212
+ warn("DATABASE_URL points at localhost, which inside a container means the container itself.");
1213
+ note("Use host.docker.internal (macOS, Windows) or the host IP so the server can reach it.");
722
1214
  }
723
- function askSecret(question, options = {}) {
724
- return readValidated(
725
- () => stdout.write(` ${style.green("?")} ${question} `),
726
- "",
727
- { ...options, secret: true }
728
- );
1215
+ async function guardKek(existing, answers, root, dryRun) {
1216
+ if (existing.OYA_PROFILE_SECRET || answers.host !== "docker" || dryRun) return;
1217
+ const volume = await priorState();
1218
+ if (!volume) return;
1219
+ const reused = await resolveKekConflict(volume, root);
1220
+ if (reused) existing.OYA_PROFILE_SECRET = reused;
729
1221
  }
730
- async function confirm(question, fallback = false) {
731
- const answer = await ask(`${question} ${style.grey(fallback ? "[Y/n]" : "[y/N]")}`, fallback ? "y" : "n");
732
- return /^y/i.test(answer);
1222
+ async function plan(flags2, dryRun) {
1223
+ const interviewed = await gather(typeof flags2.config === "string" ? flags2.config : null);
1224
+ const { root, answers, secrets } = interviewed;
1225
+ await checkMachine(root, answers, dryRun);
1226
+ warnLocalDatabase(answers, secrets.DATABASE_URL);
1227
+ const envPath = join5(root, ".env");
1228
+ const existing = readEnv(envPath);
1229
+ await guardKek(existing, answers, root, dryRun);
1230
+ return { ...interviewed, envPath, existing, env: buildEnv(answers, secrets, existing) };
733
1231
  }
734
- var visible = (text) => text.replace(/\u001b\[[0-9;]*m/g, "").length;
735
- function renderOptions(options, active, interactive) {
736
- const columns = stdout.columns || 80;
737
- let rows = 0;
738
- options.forEach((o, i) => {
739
- const chosen = interactive && i === active;
740
- const bullet = chosen ? icon.arrow : " ";
741
- const number = interactive ? "" : style.grey(`${i + 1}) `);
742
- const hint = o.disabled ? `\u2014 ${o.disabled}` : o.note ? `\u2014 ${o.note}` : "";
743
- const label = o.disabled ? style.grey(o.label) : chosen ? style.green(o.label) : o.label;
744
- const line = ` ${bullet} ${number}${label}${hint ? style.grey(` ${hint}`) : ""}`;
745
- stdout.write(`${line}${clearLine}
746
- `);
747
- rows += Math.max(1, Math.ceil(visible(line) / columns));
748
- });
749
- return rows;
1232
+ function writeFiles(p) {
1233
+ writeEnv(p.envPath, renderEnv(p.env.values, p.env.extra));
1234
+ writeFileSync3(join5(p.root, ANSWERS_FILE), JSON.stringify(p.answers, null, JSON_INDENT) + "\n");
1235
+ success(`wrote ${p.envPath}`);
1236
+ if (p.existing.OYA_PROFILE_SECRET) return;
1237
+ warn("OYA_PROFILE_SECRET was generated \u2014 back it up.");
1238
+ note("Without it, stored cookies, proxy credentials and TOTP seeds cannot be decrypted.");
750
1239
  }
751
- async function choose(question, options) {
752
- const pickable = options.map((o, i) => o.disabled ? -1 : i).filter((i) => i >= 0);
753
- if (!pickable.length) throw new Error(`No option available for: ${question}`);
754
- stdout.write(` ${style.green("?")} ${style.bold(question)}
755
- `);
756
- if (!stdin.isTTY) {
757
- renderOptions(options, -1, false);
758
- const answer = await ask("choice:", String(pickable[0] + 1), {
759
- validate: (v) => {
760
- const picked = options[Number(v) - 1] || options.find((o) => o.id === v);
761
- if (!picked) return "Pick one of the numbers above.";
762
- if (picked.disabled) return `${picked.label} is ${picked.disabled}. Pick another.`;
763
- }
764
- });
765
- return (options[Number(answer) - 1] || options.find((o) => o.id === answer)).id;
766
- }
767
- const hint = style.grey(" \u2191\u2193 move \xB7 enter select");
768
- let active = pickable[0];
769
- let rows = renderOptions(options, active, true) + 1;
770
- const canRedraw = rows < (stdout.rows || 24) - 1;
771
- rl?.pause();
772
- const wasRaw = !!stdin.isRaw;
773
- stdin.setRawMode(true);
774
- stdin.resume();
775
- stdout.write(`${hint}
776
- `);
777
- const redraw = () => {
778
- if (!canRedraw) return;
779
- stdout.write(up(rows));
780
- rows = renderOptions(options, active, true) + 1;
781
- stdout.write(`${hint}
782
- `);
783
- };
784
- const move = (delta) => {
785
- const here = pickable.indexOf(active);
786
- active = pickable[(here + delta + pickable.length) % pickable.length];
787
- redraw();
788
- };
1240
+ async function provisionK8s(p) {
1241
+ const pinned = await provisionK8sFleet(p.root, p.answers);
1242
+ if (pinned === p.env.values.OYA_MANAGED_IMAGE) return;
1243
+ p.env.values.OYA_MANAGED_IMAGE = pinned;
1244
+ writeEnv(p.envPath, renderEnv(p.env.values, p.env.extra));
1245
+ console.log(" .env updated with the pinned image digest");
1246
+ }
1247
+ async function apply2(p) {
1248
+ writeFiles(p);
1249
+ if (p.migrateUrl) await migrate(p.root, p.migrateUrl);
1250
+ if (p.answers.fleet === "k8s") await provisionK8s(p);
1251
+ if (p.answers.host === "docker") await provisionDocker(p.root, p.answers);
1252
+ await waitReady(p.answers.publicUrl);
1253
+ printDone(p.answers.publicUrl, p.env.apiKey);
1254
+ }
1255
+ async function cmdInstall(flags2) {
1256
+ const dryRun = flags2["dry-run"] === true;
1257
+ const p = await plan(flags2, dryRun);
1258
+ printPlan(p.answers, p.envPath);
1259
+ if (dryRun) return printDryRun(p.env.values);
1260
+ await apply2(p);
1261
+ }
1262
+
1263
+ // src/config.ts
1264
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync4, mkdirSync as mkdirSync2, chmodSync as chmodSync2 } from "fs";
1265
+ import { homedir } from "os";
1266
+ import { join as join6, dirname as dirname2 } from "path";
1267
+
1268
+ // src/constants.ts
1269
+ var ARGV_SKIP = 2;
1270
+ var JSON_INDENT2 = 2;
1271
+ var DEFAULT_BASE_URL = "https://browser.getoya.ai";
1272
+ var CONFIG_FILE_MODE = 384;
1273
+ var LsColumns = {
1274
+ /** Provider name. */
1275
+ PROVIDER: 14,
1276
+ /** Persona name. */
1277
+ PERSONA: 14,
1278
+ /** Browser name. */
1279
+ NAME: 18,
1280
+ /** Current URL, after the scheme is dropped. */
1281
+ URL: 40
1282
+ };
1283
+ var StatusColumns = {
1284
+ /** Recent actions shown. */
1285
+ RECENT: 10,
1286
+ /** Action name column. */
1287
+ ACTION: 18
1288
+ };
1289
+ var PERSONA_NAME_WIDTH = 20;
1290
+ var Status2 = {
1291
+ /** The API key was rejected. */
1292
+ UNAUTHORIZED: 401,
1293
+ /** A quota or a persona concurrency cap. */
1294
+ TOO_MANY_REQUESTS: 429
1295
+ };
1296
+
1297
+ // src/config.ts
1298
+ var FILE = join6(process.env.OYA_CONFIG_HOME || join6(homedir(), ".oya"), "config.json");
1299
+ function load() {
789
1300
  try {
790
- return await new Promise((resolve, reject) => {
791
- const cleanup = () => {
792
- stdin.off("data", onData);
793
- if (!wasRaw) stdin.setRawMode(false);
794
- rl?.resume();
795
- };
796
- const onData = (buf) => {
797
- const key = buf.toString();
798
- if (key === "") {
799
- cleanup();
800
- stdout.write("\n");
801
- reject(new InputError("Cancelled."));
802
- return;
803
- }
804
- if (key === `${ESC}[A` || key === "k") return move(-1);
805
- if (key === `${ESC}[B` || key === "j") return move(1);
806
- if (/^[1-9]$/.test(key)) {
807
- const target = Number(key) - 1;
808
- if (options[target] && !options[target].disabled) {
809
- active = target;
810
- redraw();
811
- }
812
- return;
813
- }
814
- if (key === "\r" || key === "\n") {
815
- cleanup();
816
- if (canRedraw) {
817
- stdout.write(up(rows));
818
- for (let i = 0; i < rows; i++) stdout.write(`${clearLine}
819
- `);
820
- stdout.write(up(rows));
821
- stdout.write(` ${icon.arrow} ${style.green(options[active].label)}${clearLine}
822
- `);
823
- } else {
824
- stdout.write(` ${icon.arrow} ${style.green(options[active].label)}
825
- `);
826
- }
827
- resolve(options[active].id);
828
- }
829
- };
830
- stdin.on("data", onData);
831
- });
832
- } finally {
833
- if (!wasRaw && stdin.isTTY && stdin.isRaw) stdin.setRawMode(false);
1301
+ return JSON.parse(readFileSync3(FILE, "utf8"));
1302
+ } catch {
1303
+ return {};
834
1304
  }
835
1305
  }
1306
+ function save(values) {
1307
+ const merged = { ...load(), ...values };
1308
+ mkdirSync2(dirname2(FILE), { recursive: true });
1309
+ writeFileSync4(FILE, JSON.stringify(merged, null, JSON_INDENT2), { mode: CONFIG_FILE_MODE });
1310
+ chmodSync2(FILE, CONFIG_FILE_MODE);
1311
+ }
1312
+ var configPath = FILE;
1313
+ function resolved() {
1314
+ const saved = load();
1315
+ return {
1316
+ apiKey: process.env.OYA_API_KEY || saved.apiKey || "",
1317
+ baseUrl: (process.env.OYA_BASE_URL || saved.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "")
1318
+ };
1319
+ }
836
1320
 
837
- // src/install.ts
838
- import { randomBytes } from "crypto";
839
- import { existsSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2, chmodSync as chmodSync2, statSync } from "fs";
840
- import { join as join2, dirname as dirname2 } from "path";
841
- import { spawn } from "child_process";
842
- import { createConnection } from "net";
843
- var ANSWERS_FILE = "oya-install.json";
844
- var token = () => randomBytes(32).toString("base64url");
845
- var SECRET_KEYS = /* @__PURE__ */ new Set([
846
- "OYA_PROFILE_SECRET",
847
- "OYA_OPERATOR_TOKEN",
848
- "OYA_METRICS_TOKEN",
849
- "API_KEYS",
850
- "FLEET_TOKEN",
851
- "OYA_CLUSTER_SECRET",
852
- "DATABASE_URL",
853
- "SUPABASE_SERVICE_KEY",
854
- "OPENAI_API_KEY",
855
- "OYA_CLOUD_API_KEY",
856
- "ANCHOR_API_KEY",
857
- "BROWSERBASE_API_KEY",
858
- "STEEL_API_KEY",
859
- "BROWSERUSE_API_KEY",
860
- "OYA_CAPTCHA_API_KEY",
861
- "OYA_CDP_WS_URL"
862
- ]);
863
- function run(cmd, args2, opts = { cwd: process.cwd() }) {
864
- return new Promise((resolve, reject) => {
865
- const child = spawn(cmd, args2, { cwd: opts.cwd, stdio: opts.quiet ? "ignore" : "inherit" });
866
- child.on("error", reject);
867
- child.on("close", (code) => code === 0 ? resolve() : reject(new Error(`${cmd} ${args2.join(" ")} exited ${code}`)));
868
- });
1321
+ // src/commands/login.ts
1322
+ async function postJson(url, body, token2) {
1323
+ const headers = { ...token2 ? { Authorization: `Bearer ${token2}` } : {}, "Content-Type": "application/json" };
1324
+ const res = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) });
1325
+ return { ok: res.ok, body: await res.json() };
869
1326
  }
870
- async function capture(cmd, args2, cwd = process.cwd()) {
871
- return new Promise((resolve) => {
872
- const child = spawn(cmd, args2, { cwd, stdio: ["ignore", "pipe", "ignore"] });
873
- let out2 = "";
874
- child.stdout.on("data", (d) => {
875
- out2 += d;
876
- });
877
- child.on("error", () => resolve(null));
878
- child.on("close", (code) => resolve(code === 0 ? out2.trim() : null));
879
- });
1327
+ async function mintKey(baseUrl) {
1328
+ const email = await ask("Email:");
1329
+ const password = await askSecret("Password:");
1330
+ const session2 = await postJson(`${baseUrl}/api/auth/login`, { email, password });
1331
+ if (!session2.ok || !session2.body.access_token) throw new Error(session2.body.error || "Sign-in failed");
1332
+ const minted = await postJson(`${baseUrl}/api/auth/keys`, { label: "CLI" }, session2.body.access_token);
1333
+ if (!minted.ok || !minted.body.key) throw new Error(minted.body.error || "Could not create an API key");
1334
+ console.log(' Created a new API key labelled "CLI".');
1335
+ return minted.body.key;
880
1336
  }
881
- var has = async (cmd) => await capture(cmd, ["--version"]) !== null;
882
- function atLeast(version, major, minor) {
883
- const m = /(\d+)\.(\d+)/.exec(version || "");
884
- if (!m) return false;
885
- return Number(m[1]) > major || Number(m[1]) === major && Number(m[2]) >= minor;
1337
+ function chooseMethod() {
1338
+ return choose("How do you want to authenticate?", [
1339
+ { id: "paste", label: "Paste an API key", note: "from the dashboard, or a self-hosted key" },
1340
+ { id: "password", label: "Sign in with email and password", note: "mints a new key for this machine" }
1341
+ ]);
886
1342
  }
887
- function portFree(port) {
888
- return new Promise((resolve) => {
889
- const socket = createConnection({ port, host: "127.0.0.1" }).on("connect", () => {
890
- socket.destroy();
891
- resolve(false);
892
- }).on("error", () => resolve(true));
893
- setTimeout(() => {
894
- socket.destroy();
895
- resolve(true);
896
- }, 700);
897
- });
1343
+ async function obtainKey(flags2, baseUrl) {
1344
+ const given = flagStr(flags2, "key") || "";
1345
+ if (given) return given;
1346
+ const how = await chooseMethod();
1347
+ if (how === "paste") return askSecret("API key:");
1348
+ return mintKey(baseUrl);
898
1349
  }
899
- function findRepoRoot(from = process.cwd()) {
900
- let dir = from;
901
- for (let i = 0; i < 8; i++) {
902
- if (existsSync(join2(dir, "docker-compose.yml")) && existsSync(join2(dir, "server", "src", "index.js"))) return dir;
903
- const up2 = dirname2(dir);
904
- if (up2 === dir) break;
905
- dir = up2;
1350
+ async function verifyKey(baseUrl, apiKey) {
1351
+ const check2 = await fetch(`${baseUrl}/api/config`, { headers: { Authorization: `Bearer ${apiKey}` } });
1352
+ if (!check2.ok) throw new Error(`That key was rejected by ${baseUrl} (${check2.status})`);
1353
+ }
1354
+ async function loginUrl(flags2) {
1355
+ const current = resolved().baseUrl;
1356
+ const url = flagStr(flags2, "url") || (flagStr(flags2, "key") ? current : await ask("Control plane URL:", current));
1357
+ return url.replace(/\/+$/, "");
1358
+ }
1359
+ async function cmdLogin(flags2) {
1360
+ const baseUrl = await loginUrl(flags2);
1361
+ const apiKey = await obtainKey(flags2, baseUrl);
1362
+ if (!apiKey) throw new Error("No API key given");
1363
+ await verifyKey(baseUrl, apiKey);
1364
+ save({ apiKey, baseUrl });
1365
+ console.log(`
1366
+ \u2705 Signed in to ${baseUrl}. Saved to ${configPath}`);
1367
+ console.log(" Next: `oya init` to pick your model and browser provider.");
1368
+ }
1369
+
1370
+ // ../sdk/dist/index.js
1371
+ var OyaError = class extends Error {
1372
+ /** The HTTP status, or the SDK's own status for errors it raises itself. */
1373
+ status;
1374
+ /** The server's response body, parsed when it was JSON. */
1375
+ body;
1376
+ /** Records the message, status and body. */
1377
+ constructor(message, status, body) {
1378
+ super(message);
1379
+ this.name = "OyaError";
1380
+ this.status = status;
1381
+ this.body = body;
906
1382
  }
907
- return null;
1383
+ };
1384
+ var DEFAULT_BASE_URL2 = "https://browser.getoya.ai";
1385
+ var DEFAULT_TIMEOUT_MS = 6e4;
1386
+ var START_TIMEOUT_MS = 12e4;
1387
+ var READY_TIMEOUT_MS2 = 12e4;
1388
+ var READY_POLL_MS2 = 2e3;
1389
+ var NAVIGATE_TIMEOUT_MS = 12e4;
1390
+ var CHALLENGE_TIMEOUT_MS = 18e4;
1391
+ var AGENT_TIMEOUT_MS = 6e5;
1392
+ var PLAYBOOK_TIMEOUT_MS = 12e4;
1393
+ var STOP_TIMEOUT_MS = 6e4;
1394
+ var WAIT_FOR_DEFAULT_MS = 3e4;
1395
+ var WAIT_FOR_GRACE_MS = 5e3;
1396
+ var AIMED_SCROLL_AMOUNT = 500;
1397
+ var RUN_POLL_MS = 2e3;
1398
+ var MAX_RUN_POLL_ERRORS = 5;
1399
+ var BYTES_PER_MB = 1048576;
1400
+ var MAX_FILE_MB = 10;
1401
+ var MS_PER_SECOND = 1e3;
1402
+ var Status3 = {
1403
+ /** The caller passed something unusable, such as a non-numeric element id. */
1404
+ BAD_REQUEST: 400,
1405
+ /** The server does not know that session. */
1406
+ NOT_FOUND: 404,
1407
+ /** The browser is in a state that needs attention first. */
1408
+ CONFLICT: 409,
1409
+ /** A browser command ran and failed. */
1410
+ UNPROCESSABLE: 422,
1411
+ /** A run failed without saying why. */
1412
+ SERVER_ERROR: 500,
1413
+ /** A browser did not come up in time. */
1414
+ GATEWAY_TIMEOUT: 504
1415
+ };
1416
+ var Http = class {
1417
+ /** Stores where to call, with which key, how long to wait and which fetch to use. */
1418
+ constructor(baseUrl, apiKey, timeoutMs, fetchImpl) {
1419
+ this.baseUrl = baseUrl;
1420
+ this.apiKey = apiKey;
1421
+ this.timeoutMs = timeoutMs;
1422
+ this.fetchImpl = fetchImpl;
1423
+ }
1424
+ baseUrl;
1425
+ apiKey;
1426
+ timeoutMs;
1427
+ fetchImpl;
1428
+ /** Sends one request and returns the parsed answer, or throws an OyaError when it failed. */
1429
+ async request(method, path, body, timeoutMs = this.timeoutMs, headers = {}) {
1430
+ const init = requestInit(method, body, timeoutMs, { Authorization: `Bearer ${this.apiKey}`, ...headers });
1431
+ return readAnswer2(await this.fetchImpl(`${this.baseUrl}${path}`, init), `${method} ${path}`, this.baseUrl);
1432
+ }
1433
+ };
1434
+ function requestInit(method, body, timeoutMs, headers) {
1435
+ const json = body === void 0 ? {} : { "Content-Type": "application/json" };
1436
+ const payload = body === void 0 ? void 0 : JSON.stringify(body);
1437
+ return { method, headers: { ...headers, ...json }, body: payload, signal: AbortSignal.timeout(timeoutMs) };
908
1438
  }
909
- async function locateRepo() {
910
- const found = findRepoRoot();
911
- if (found) return found;
912
- console.log("\nThis does not look like an oya-browser checkout.");
913
- const where = await ask("Clone it to:", join2(process.cwd(), "oya-browser"));
914
- if (existsSync(where)) throw new Error(`${where} already exists`);
915
- await run("git", ["clone", "--depth", "1", "https://github.com/OyadotAI/oya-browser.git", where], { cwd: process.cwd() });
916
- return where;
1439
+ async function readAnswer2(res, call2, baseUrl) {
1440
+ const payload = parseBody(await res.text());
1441
+ if (!res.ok) throw failure(call2, res.status, payload, baseUrl);
1442
+ return payload;
917
1443
  }
918
- function readEnv(path) {
919
- if (!existsSync(path)) return {};
920
- const out2 = {};
921
- for (const line of readFileSync2(path, "utf8").split("\n")) {
922
- const m = /^([A-Z0-9_]+)=(.*)$/.exec(line.trim());
923
- if (m) out2[m[1]] = m[2];
1444
+ function parseBody(text) {
1445
+ try {
1446
+ return text ? JSON.parse(text) : null;
1447
+ } catch {
1448
+ return text;
924
1449
  }
925
- return out2;
926
1450
  }
927
- var SECTIONS = [
928
- ["Server", ["PORT", "OYA_UI_MODE", "OYA_DATA_DIR"]],
929
- ["Tenant keys \u2014 each is an identity; add more from the dashboard", ["API_KEYS"]],
930
- ["Host operations: /metrics, drain, fleet provision", ["OYA_OPERATOR_TOKEN", "OYA_METRICS_TOKEN"]],
931
- ["Credentials at rest. Lose this and every stored cookie, proxy and TOTP seed is unreadable", ["OYA_PROFILE_SECRET"]],
932
- ["Database", ["SUPABASE_URL", "SUPABASE_SERVICE_KEY", "DATABASE_URL", "OYA_RECORDING_BUCKET"]],
933
- ["Agent LLM. A private or http base URL is operator-only: the dashboard and\n# POST /config/host reject one, on purpose \u2014 tenants can set that field too", ["OPENAI_API_KEY", "OPENAI_BASE_URL", "CHAT_MODEL"]],
934
- ["Browser fleet", ["OYA_BROWSER_PROVIDER", "OYA_FLEET_RUNTIME", "OYA_PUBLIC_WS_URL", "OYA_CDP_WS_URL"]],
935
- ["Governed Docker runtime", ["OYA_MANAGED_NETWORK", "OYA_MANAGED_IMAGE", "OYA_MANAGED_CONTROL_URL", "OYA_MANAGED_PROXY_URL", "OYA_MANAGED_REGION", "OYA_EGRESS_PORT", "OYA_EGRESS_HOST"]],
936
- ["Oya Cloud", ["OYA_CLOUD_API_KEY", "OYA_CLOUD_SNAPSHOT", "OYA_CLOUD_TARGET"]],
937
- ["Hosted CDP vendors", ["BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID", "STEEL_API_KEY", "ANCHOR_API_KEY", "BROWSERUSE_API_KEY"]],
938
- ["CAPTCHA", ["OYA_CAPTCHA_PROVIDER", "OYA_CAPTCHA_API_KEY"]],
939
- ["Multi-replica. One SQLite control database allows exactly one writer", ["OYA_INSTANCE_URL", "OYA_CLUSTER_SECRET"]]
940
- ];
941
- function renderEnv(values, extra) {
942
- const lines = ["# Written by `oya install`. Re-run it, or edit by hand \u2014 both are fine.", ""];
943
- const placed = /* @__PURE__ */ new Set();
944
- for (const [title, keys] of SECTIONS) {
945
- const present = keys.filter((k) => values[k] !== void 0 && values[k] !== "");
946
- if (!present.length) continue;
947
- lines.push(`# \u2500\u2500 ${title} \u2500\u2500`);
948
- for (const k of present) {
949
- lines.push(`${k}=${values[k]}`);
950
- placed.add(k);
951
- }
952
- lines.push("");
1451
+ function failure(call2, status, payload, baseUrl) {
1452
+ let message = payload?.error || `${call2} failed (${status})`;
1453
+ if (message === "Invalid API key") {
1454
+ message += ` for ${baseUrl}. Check OYA_API_KEY: a value exported in your shell beats .env.`;
953
1455
  }
954
- const leftover = Object.keys(values).filter((k) => !placed.has(k) && values[k]);
955
- if (leftover.length) {
956
- lines.push("# \u2500\u2500 Other \u2500\u2500");
957
- for (const k of leftover) lines.push(`${k}=${values[k]}`);
958
- lines.push("");
1456
+ return new OyaError(message, status, payload);
1457
+ }
1458
+ var env = (name) => globalThis.process?.env?.[name];
1459
+ function createHttp(options) {
1460
+ const apiKey = options.apiKey || env("OYA_API_KEY");
1461
+ if (!apiKey) {
1462
+ throw new Error("No API key. Pass { apiKey } or set OYA_API_KEY \u2014 run `oya login` to get one.");
959
1463
  }
960
- if (extra.length) lines.push(...extra, "");
961
- return lines.join("\n");
1464
+ const baseUrl = (options.baseUrl || env("OYA_BASE_URL") || DEFAULT_BASE_URL2).replace(/\/+$/, "");
1465
+ const fetchImpl = options.fetch || globalThis.fetch;
1466
+ if (!fetchImpl) throw new Error("No fetch available \u2014 pass { fetch } or use Node 18+.");
1467
+ return new Http(baseUrl, apiKey, options.timeoutMs ?? DEFAULT_TIMEOUT_MS, fetchImpl.bind(globalThis));
962
1468
  }
963
- function writeEnv(path, body) {
964
- writeFileSync2(path, body, { mode: 384 });
965
- chmodSync2(path, 384);
1469
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1470
+ async function call(fn) {
1471
+ try {
1472
+ await fn();
1473
+ } catch (err) {
1474
+ console.error("[oya] run callback threw:", err);
1475
+ }
966
1476
  }
967
- var LLM_PRESETS = {
968
- anthropic: { base: "https://api.anthropic.com/v1", model: "claude-sonnet-4-5", label: "Anthropic (Claude)" },
969
- openai: { base: "https://api.openai.com/v1", model: "gpt-4o-mini", label: "OpenAI" },
970
- gemini: { base: "https://generativelanguage.googleapis.com/v1beta/openai", model: "gemini-3.8-flash", label: "Gemini (Google)" },
971
- // Express mode: a global endpoint, no GCP project or location needed.
972
- vertex: { base: "https://aiplatform.googleapis.com/v1/publishers/google", model: "gemini-2.5-flash", label: "Gemini Enterprise (Vertex AI)" }
973
- };
974
- async function askLlm() {
975
- step("Agent LLM", "Drives `oya ask` and the chat API.");
976
- note("CDP, the SDK and MCP all work without one.");
977
- const provider = await choose("Which LLM should agents use?", [
978
- { id: "anthropic", label: "Anthropic (Claude)" },
979
- { id: "openai", label: "OpenAI" },
980
- { id: "gemini", label: "Gemini (Google)" },
981
- { id: "vertex", label: "Gemini Enterprise (Vertex AI)", note: "express-mode API key" },
982
- { id: "compatible", label: "An OpenAI-compatible endpoint", note: "OpenRouter, Together, Groq, Azure" },
983
- { id: "local", label: "A local model", note: "Ollama, vLLM, LM Studio" },
984
- { id: "skip", label: "Skip", note: "no agent control; add it later with `oya config`" }
985
- ]);
986
- if (provider === "skip") return { answers: { provider, baseUrl: "", model: "" }, key: "" };
987
- const preset = LLM_PRESETS[provider];
988
- const baseUrl = preset ? preset.base : (await ask("Base URL:", provider === "local" ? "http://localhost:11434/v1" : "https://openrouter.ai/api/v1")).replace(/\/+$/, "");
989
- const model = await ask("Model:", preset?.model || (provider === "local" ? "qwen2.5" : ""));
1477
+ async function watchRun(run2, baseUrl, cb, pollMs) {
1478
+ const watch = { run: run2, baseUrl, cb, errors: 0 };
990
1479
  for (; ; ) {
991
- const key = await askSecret(provider === "local" ? "API key (blank if none):" : "API key:");
992
- if (!key) return { answers: { provider, baseUrl, model }, key: "" };
993
- const problem = await verifyLlm(baseUrl, key, model);
994
- if (!problem) return { answers: { provider, baseUrl, model }, key };
995
- warn(problem);
996
- const what = await choose("What now?", [
997
- { id: "retry", label: "Enter the key again" },
998
- { id: "skip", label: "Continue without an LLM", note: "add it later with `oya config`" }
999
- ]);
1000
- if (what === "skip") return { answers: { provider: "skip", baseUrl: "", model: "" }, key: "" };
1480
+ const result = await step2(watch);
1481
+ if (result) return result;
1482
+ await sleep(pollMs);
1001
1483
  }
1002
1484
  }
1003
- async function verifyLlm(baseUrl, key, model) {
1004
- const spin = spinner("checking the key");
1485
+ async function step2(watch) {
1486
+ const info = await poll(watch);
1487
+ if (!info) return void 0;
1488
+ noticeAttention(watch, info);
1489
+ if (info.status === "succeeded") return succeed(watch.cb, info);
1490
+ if (info.status === "failed") return fail(watch.cb, info);
1491
+ return void 0;
1492
+ }
1493
+ async function poll(watch) {
1005
1494
  try {
1006
- const anthropic = /(^|\.)anthropic\.com$/i.test(new URL(baseUrl).hostname);
1007
- const headers = anthropic ? { "x-api-key": key, "anthropic-version": "2023-06-01" } : { Authorization: `Bearer ${key}` };
1008
- const res = /\/publishers\/google\/?$/.test(baseUrl) ? await fetch(`${baseUrl.replace(/\/+$/, "")}/models/${encodeURIComponent(model || "gemini-2.5-flash")}:generateContent?key=${encodeURIComponent(key)}`, {
1009
- method: "POST",
1010
- headers: { "Content-Type": "application/json" },
1011
- body: JSON.stringify({ contents: [{ role: "user", parts: [{ text: "hi" }] }], generationConfig: { maxOutputTokens: 1 } })
1012
- }) : await fetch(`${baseUrl}/models`, { headers });
1013
- if (res.status === 401 || res.status === 403) {
1014
- spin.fail();
1015
- return `${baseUrl} rejected that key (HTTP ${res.status}).`;
1016
- }
1017
- if (!res.ok) {
1018
- spin.fail(`could not verify (HTTP ${res.status}) \u2014 continuing anyway`);
1019
- return null;
1020
- }
1021
- const body = await res.json().catch(() => null);
1022
- const ids = (body?.data || []).map((m) => m.id);
1023
- if (model && ids.length && !ids.includes(model)) {
1024
- spin.done(`key works, though "${model}" is not in this endpoint's list`);
1025
- return null;
1026
- }
1027
- spin.done("ok");
1028
- return null;
1495
+ const info = await watch.run.status();
1496
+ watch.errors = 0;
1497
+ return info;
1029
1498
  } catch (err) {
1030
- spin.fail(`could not reach it (${err.message}) \u2014 continuing anyway`);
1031
- return null;
1499
+ if (++watch.errors < MAX_RUN_POLL_ERRORS) return null;
1500
+ return giveUp(watch.cb, err);
1032
1501
  }
1033
1502
  }
1034
- var hostOptions = [
1035
- { id: "docker", label: "Docker on this machine", note: "one command, no cluster" },
1036
- { id: "k8s", label: "Kubernetes", disabled: "not in this build yet" },
1037
- { id: "ecs", label: "Amazon ECS", disabled: "not in this build yet" },
1038
- { id: "fly", label: "Fly.io", disabled: "coming soon" },
1039
- { id: "exe", label: "exe.dev", disabled: "coming soon" }
1040
- ];
1041
- var dbOptions = (host) => [
1042
- { id: "sqlite", label: "SQLite", note: "zero config, one replica, API keys only" },
1043
- { id: "supabase", label: "Supabase", note: "adds email sign-in and the dashboard" },
1044
- { id: "postgres", label: "Postgres", note: "many replicas; needs psql for migrations" }
1045
- ];
1046
- var fleetOptions = [
1047
- { id: "docker-workers", label: "Docker browser workers here", note: "always-on containers, no daemon access needed" },
1048
- { id: "oya-selfhosted", label: "Governed Docker, one container per session", note: "policies and budgets; needs Docker socket access" },
1049
- { id: "oya-cloud", label: "Oya Cloud", note: "managed sandboxes, nothing to run yourself" },
1050
- { id: "browserbase", label: "Browserbase" },
1051
- { id: "steel", label: "Steel" },
1052
- { id: "anchor", label: "Anchor" },
1053
- { id: "browseruse", label: "Browser Use Cloud" },
1054
- { id: "cdp", label: "Your own Chrome over CDP" },
1055
- { id: "k8s", label: "Kubernetes fleet, one pod per session", note: "needs kubectl and a cluster" },
1056
- { id: "ecs", label: "ECS fleet, one task per session", disabled: "not in this build yet" }
1057
- ];
1058
- var VENDOR_KEYS = {
1059
- browserbase: ["BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID"],
1060
- steel: ["STEEL_API_KEY"],
1061
- anchor: ["ANCHOR_API_KEY"],
1062
- browseruse: ["BROWSERUSE_API_KEY"]
1063
- };
1064
- async function interview(preset, replay) {
1065
- const secrets = {};
1066
- let migrateUrl = "";
1067
- const secretFor = async (field, hidden = true) => {
1068
- if (replay) return process.env[field] || "";
1069
- return hidden ? askSecret(`${field}:`) : ask(`${field}:`);
1070
- };
1071
- if (!preset.host) step("Control plane", "Where the server itself runs.");
1072
- const host = preset.host || await choose("Where should the control plane run?", hostOptions);
1073
- if (!preset.database) step("Database", "What holds sessions, keys and personas.");
1074
- const database = preset.database || await choose("Which database?", dbOptions(host));
1075
- if (database === "sqlite") {
1076
- note("SQLite means API-key auth only \u2014 email sign-in needs Supabase.");
1077
- note("The dashboard still works: sign in with the API key printed at the end.");
1078
- }
1079
- const isPostgresUrl = (v) => {
1080
- if (!v) return;
1081
- try {
1082
- const url = new URL(v);
1083
- if (!["postgres:", "postgresql:"].includes(url.protocol)) return "Must start with postgres://";
1084
- } catch {
1085
- return "That is not a connection string \u2014 postgres://user:pass@host:5432/db";
1086
- }
1503
+ async function giveUp(cb, err) {
1504
+ const failure2 = err instanceof OyaError ? err : new OyaError(String(err), 0, null);
1505
+ await call(() => cb.onFailure?.(failure2));
1506
+ throw failure2;
1507
+ }
1508
+ function noticeAttention(watch, info) {
1509
+ if (info.status !== "needs_attention" || !info.attention || info.attention.id === watch.seen) return;
1510
+ watch.seen = info.attention.id;
1511
+ const request = {
1512
+ ...info.attention,
1513
+ liveViewUrl: info.attention.liveViewUrl && new URL(info.attention.liveViewUrl, watch.baseUrl).href,
1514
+ respond: (response) => watch.run.respond(response)
1087
1515
  };
1088
- if (database === "supabase") {
1089
- secrets.SUPABASE_URL = replay ? process.env.SUPABASE_URL || "" : await ask("Supabase project URL:");
1090
- secrets.SUPABASE_SERVICE_KEY = await secretFor("SUPABASE_SERVICE_KEY");
1091
- if (!replay) {
1092
- note("Migrations need the Postgres connection string, not the API URL.");
1093
- note("Leave it blank to apply them yourself later with `make migrate`.");
1094
- migrateUrl = await askSecret("Postgres connection string (optional):", { validate: isPostgresUrl });
1095
- }
1516
+ void call(() => watch.cb.onHumanAttention?.(request));
1517
+ }
1518
+ async function succeed(cb, info) {
1519
+ const result = info.result || {};
1520
+ if (result.healed) await call(() => cb.onHealed?.(result));
1521
+ await call(() => cb.onSuccess?.(result));
1522
+ return result;
1523
+ }
1524
+ async function fail(cb, info) {
1525
+ const failure2 = new OyaError(info.error || "Run failed", info.errorStatus ?? Status3.SERVER_ERROR, info);
1526
+ await call(() => cb.onFailure?.(failure2));
1527
+ throw failure2;
1528
+ }
1529
+ var Run = class {
1530
+ /** Starts watching the run straight away. */
1531
+ constructor(http, id, callbacks, pollMs = RUN_POLL_MS) {
1532
+ this.http = http;
1533
+ this.id = id;
1534
+ this.done = this.watch(callbacks, pollMs);
1535
+ this.done.catch(() => {
1536
+ });
1537
+ }
1538
+ http;
1539
+ id;
1540
+ /** Settles with the result when the run succeeds, or rejects when it fails. */
1541
+ done;
1542
+ /** The run's current state. */
1543
+ status() {
1544
+ return this.http.request("GET", `/api/runs/${encodeURIComponent(this.id)}`);
1545
+ }
1546
+ /** Answer the open attention request: `'done'` after handling it by hand, or your reply to the agent. */
1547
+ async respond(response = "done") {
1548
+ await this.http.request("POST", `/api/runs/${encodeURIComponent(this.id)}/respond`, { response });
1549
+ }
1550
+ /** Polls until the run ends, firing callbacks on the way. */
1551
+ watch(cb, pollMs) {
1552
+ return watchRun(this, this.http.baseUrl, cb, pollMs);
1553
+ }
1554
+ };
1555
+ var Browser = class {
1556
+ /** Wraps a started browser; `autoCaptcha` solves CAPTCHAs after every `goto()`. */
1557
+ constructor(http, info, autoCaptcha) {
1558
+ this.http = http;
1559
+ this.autoCaptcha = autoCaptcha;
1560
+ this.id = info.id;
1561
+ this.provider = info.provider;
1562
+ this.persona = info.persona;
1563
+ this.cdpUrl = info.cdpUrl;
1564
+ }
1565
+ http;
1566
+ autoCaptcha;
1567
+ /** The browser's id. */
1568
+ id;
1569
+ /** Which provider runs it. */
1570
+ provider;
1571
+ /** Which persona it runs as. */
1572
+ persona;
1573
+ /** Point Playwright, Puppeteer or browser-use here. */
1574
+ cdpUrl;
1575
+ /** Runs one browser command; a command that ran and failed throws. */
1576
+ async command(action, params = {}, timeoutMs) {
1577
+ const path = `/api/browsers/${this.id}/command`;
1578
+ const result = await this.http.request("POST", path, { action, params }, timeoutMs);
1579
+ if (result.ok === false) throw new OyaError(result.error || `${action} failed`, Status3.UNPROCESSABLE, result);
1580
+ return result.data;
1581
+ }
1582
+ /** Navigates, then clears any CAPTCHA when `captcha: 'auto'` was asked for. */
1583
+ async goto(url) {
1584
+ await this.command("navigate", { url }, NAVIGATE_TIMEOUT_MS);
1585
+ if (this.autoCaptcha) assertCaptchaCleared(await this.solveCaptcha());
1586
+ }
1587
+ /**
1588
+ * The page, written as markdown (the default), TOON or JSONL, plus its blocks and
1589
+ * the numbered elements to act on.
1590
+ */
1591
+ async analyze(options = {}) {
1592
+ return this.command("analyze", options.format ? { format: options.format } : {});
1593
+ }
1594
+ /** Only the visible elements, which is what an agent almost always wants. */
1595
+ async elements() {
1596
+ return (await this.analyze()).elements.filter((e) => e.visible);
1597
+ }
1598
+ /** Clicks an element by its id from `analyze()`. */
1599
+ async click(elementId) {
1600
+ const id = this.elementId(elementId);
1601
+ await this.command("click", { element_id: id, selector: `[data-ac-id="${id}"]` });
1602
+ }
1603
+ /** Types into an element by its id from `analyze()`. */
1604
+ async type(elementId, text) {
1605
+ const id = this.elementId(elementId);
1606
+ return this.command("type", { element_id: id, selector: `[data-ac-id="${id}"]`, text });
1607
+ }
1608
+ /** A valid element id, or an OyaError saying where ids come from. */
1609
+ elementId(value) {
1610
+ return toElementId(value);
1611
+ }
1612
+ /** Presses one key, such as Enter or Escape. */
1613
+ async pressKey(key) {
1614
+ await this.command("press_key", { key });
1615
+ }
1616
+ /**
1617
+ * Answer a native dialog holding the page. alert() and beforeunload are
1618
+ * answered for you; a confirm() or prompt() waits for this, and every other
1619
+ * command fails fast with the dialog's text until it is answered.
1620
+ */
1621
+ async handleDialog(accept, promptText) {
1622
+ return this.command("handle_dialog", { accept, prompt_text: promptText });
1096
1623
  }
1097
- if (database === "postgres") {
1098
- secrets.DATABASE_URL = replay ? process.env.DATABASE_URL || "" : await askSecret("Postgres connection string:", {
1099
- validate: (v) => !v ? "Required for a Postgres deployment." : isPostgresUrl(v)
1100
- });
1101
- migrateUrl = secrets.DATABASE_URL;
1624
+ /** `at` aims the wheel at an inner scroller (a results panel, a chat pane) instead of the page. */
1625
+ async scroll(direction, amount, at) {
1626
+ await this.command("scroll", scrollParams(direction, amount, at));
1102
1627
  }
1103
- if (!preset.fleet) step("Browsers", "What actually runs Chrome.");
1104
- const fleet = preset.fleet || await choose("Where should browsers run?", fleetOptions);
1105
- let workers = preset.workers ?? 0;
1106
- if (fleet === "docker-workers" && preset.workers === void 0) {
1107
- workers = Number(await ask("How many browser workers?", "2", {
1108
- validate: (v) => /^[1-9][0-9]*$/.test(v) ? void 0 : "Enter a whole number of 1 or more."
1109
- }));
1628
+ /** Waits until `selector` matches, up to `timeout` milliseconds. */
1629
+ async waitFor(selector, timeout = WAIT_FOR_DEFAULT_MS) {
1630
+ await this.command("wait", { selector, timeout }, timeout + WAIT_FOR_GRACE_MS);
1110
1631
  }
1111
- for (const field of VENDOR_KEYS[fleet] || []) {
1112
- secrets[field] = await secretFor(field, !field.endsWith("PROJECT_ID"));
1113
- }
1114
- if (fleet === "oya-cloud") secrets.OYA_CLOUD_API_KEY = await secretFor("OYA_CLOUD_API_KEY");
1115
- if (fleet === "cdp") secrets.OYA_CDP_WS_URL = await secretFor("OYA_CDP_WS_URL");
1116
- let k8sFleet = preset.k8sFleet;
1117
- if (fleet === "k8s" && !k8sFleet) {
1118
- step("Kubernetes fleet", "One pod per session.");
1119
- note("The image must be digest-pinned: a tag can move between verification and scheduling.");
1120
- k8sFleet = {
1121
- namespace: await ask("Namespace for browser pods:", "oya-browsers"),
1122
- image: await ask("Browser image:", "ghcr.io/oyadotai/oya-browser:latest"),
1123
- controlUrl: await ask("Control-plane WebSocket URL, as reachable from the cluster:", "ws://oya-server.oya-browser.svc:3100/ws"),
1124
- proxyUrl: await ask("Egress proxy URL, as reachable from the cluster:", "http://oya-server.oya-browser.svc:3128")
1125
- };
1126
- }
1127
- const llm = preset.llm ? { answers: preset.llm, key: replay ? process.env.OPENAI_API_KEY || "" : "" } : await askLlm();
1128
- if (llm.key) secrets.OPENAI_API_KEY = llm.key;
1129
- step("Address", "Where clients and browsers reach this control plane.");
1130
- const publicUrl = (preset.publicUrl || await ask("Public URL of this control plane:", "http://localhost:3100", {
1131
- validate: (v) => {
1132
- try {
1133
- const url = new URL(v);
1134
- if (!["http:", "https:"].includes(url.protocol)) return "Must be http:// or https://";
1135
- } catch {
1136
- return "That is not a URL \u2014 try http://localhost:3100";
1137
- }
1138
- }
1139
- })).replace(/\/+$/, "");
1140
- const optional = preset.optional || { captcha: "", recordingBucket: "", metrics: false };
1141
- if (!preset.optional) {
1142
- step("Optional services", "Everything here has a sensible default.");
1143
- const more = await choose("Configure optional services now?", [
1144
- { id: "no", label: "No", note: "change any of it later" },
1145
- { id: "yes", label: "Yes", note: "CAPTCHA solver, recording storage, metrics" }
1146
- ]);
1147
- if (more === "yes") {
1148
- optional.captcha = await choose("Solve CAPTCHAs automatically?", [
1149
- { id: "", label: "No", note: "hosted vendors still solve natively" },
1150
- { id: "capsolver", label: "CapSolver" },
1151
- { id: "2captcha", label: "2Captcha" }
1152
- ]);
1153
- if (optional.captcha) secrets.OYA_CAPTCHA_API_KEY = await secretFor("OYA_CAPTCHA_API_KEY");
1154
- if (database === "supabase") optional.recordingBucket = await ask("Private Storage bucket for recordings (blank to keep them on disk):", "");
1155
- optional.metrics = await choose("Expose /metrics to a Prometheus scraper?", [
1156
- { id: "no", label: "No" },
1157
- { id: "yes", label: "Yes", note: "generates a scrape token" }
1158
- ]) === "yes";
1159
- }
1632
+ /** A `data:image/…;base64,` URL. PNG or JPEG depending on the driver. */
1633
+ async screenshot() {
1634
+ return (await this.command("screenshot")).screenshot;
1160
1635
  }
1161
- return {
1162
- answers: { version: 1, host, database, fleet, workers, k8sFleet, llm: llm.answers, publicUrl, optional },
1163
- secrets,
1164
- migrateUrl
1165
- };
1166
- }
1167
- async function preflight(root, a) {
1168
- const checks = [];
1169
- const needsDocker = a.host === "docker" || a.fleet === "docker-workers" || a.fleet === "oya-selfhosted";
1170
- if (needsDocker) {
1171
- const version = await capture("docker", ["--version"]);
1172
- checks.push({ ok: !!version, label: "docker", detail: version || "not found", fatal: true });
1173
- const daemon = await capture("docker", ["info", "--format", "{{.ID}}"]);
1174
- checks.push({ ok: !!daemon, label: "docker daemon reachable", detail: daemon ? "ok" : "not running", fatal: true });
1175
- const compose = await capture("docker", ["compose", "version", "--short"]);
1176
- checks.push({ ok: atLeast(compose, 2, 24), label: "docker compose \u2265 2.24", detail: compose || "not found", fatal: true });
1177
- }
1178
- if (a.host !== "docker") {
1179
- checks.push({ ok: atLeast(process.versions.node, 22, 13), label: "node \u2265 22.13 (node:sqlite)", detail: process.versions.node, fatal: true });
1180
- }
1181
- if (a.fleet === "k8s" || a.host === "k8s") {
1182
- const version = await capture("kubectl", ["version", "--client=true", "-o", "json"]);
1183
- checks.push({ ok: !!version, label: "kubectl", detail: version ? "ok" : "not found", fatal: true });
1184
- const cluster = await capture("kubectl", ["get", "namespace", "kube-system", "-o", "name"]);
1185
- checks.push({ ok: !!cluster, label: "cluster reachable", detail: cluster || "kubectl cannot reach a cluster", fatal: true });
1186
- }
1187
- if (a.database === "postgres" || a.database === "supabase") {
1188
- checks.push({ ok: await has("psql"), label: "psql (to apply migrations)", detail: "from libpq/postgresql-client", fatal: true });
1189
- }
1190
- const port = Number(new URL(a.publicUrl).port || 3100);
1191
- const free = await portFree(port);
1192
- checks.push({ ok: free, label: `port ${port} free`, detail: free ? "ok" : "something is already listening" });
1193
- const envPath = join2(root, ".env");
1194
- if (existsSync(envPath)) {
1195
- const mode = (statSync(envPath).mode & 511).toString(8);
1196
- checks.push({ ok: true, label: ".env exists \u2014 keeping its OYA_PROFILE_SECRET and API keys", detail: `mode ${mode}` });
1636
+ /** The active tab's URL, or empty when there is none. */
1637
+ async url() {
1638
+ return (await this.tabs()).find((t) => t.active)?.url || "";
1197
1639
  }
1198
- return checks;
1199
- }
1200
- function buildEnv(a, secrets, existing) {
1201
- const v = {};
1202
- const port = String(Number(new URL(a.publicUrl).port || 3100));
1203
- v.OYA_PROFILE_SECRET = existing.OYA_PROFILE_SECRET || token();
1204
- const apiKey = existing.API_KEYS?.split(",")[0] || `oya_${token()}`;
1205
- v.API_KEYS = existing.API_KEYS || apiKey;
1206
- v.OYA_OPERATOR_TOKEN = existing.OYA_OPERATOR_TOKEN || token();
1207
- if (a.host !== "docker") v.PORT = port;
1208
- Object.assign(v, secrets);
1209
- if (a.llm.provider !== "skip") {
1210
- v.OPENAI_BASE_URL = a.llm.baseUrl;
1211
- v.CHAT_MODEL = a.llm.model;
1212
- }
1213
- const wsUrl = a.publicUrl.replace(/^http/, "ws") + "/ws";
1214
- if (a.fleet === "oya-cloud") {
1215
- v.OYA_BROWSER_PROVIDER = "oya-cloud";
1216
- v.OYA_PUBLIC_WS_URL = wsUrl;
1217
- v.OYA_CLOUD_SNAPSHOT = existing.OYA_CLOUD_SNAPSHOT || existing.DAYTONA_SNAPSHOT || "oya-browser";
1218
- } else if (a.fleet === "oya-selfhosted") {
1219
- v.OYA_BROWSER_PROVIDER = "oya-selfhosted";
1220
- v.OYA_FLEET_RUNTIME = "docker";
1221
- v.OYA_MANAGED_NETWORK = "oya-browsers";
1222
- v.OYA_MANAGED_IMAGE = "oya-browser:local";
1223
- v.OYA_MANAGED_CONTROL_URL = a.host === "docker" ? "ws://server:3100/ws" : wsUrl;
1224
- v.OYA_EGRESS_PORT = "3128";
1225
- v.OYA_EGRESS_HOST = "0.0.0.0";
1226
- v.OYA_MANAGED_PROXY_URL = a.host === "docker" ? "http://server:3128" : "http://127.0.0.1:3128";
1227
- v.OYA_MANAGED_REGION = "local";
1228
- } else if (a.fleet === "k8s" && a.k8sFleet) {
1229
- v.OYA_BROWSER_PROVIDER = "oya-selfhosted";
1230
- v.OYA_FLEET_RUNTIME = "k8s";
1231
- v.OYA_K8S_NAMESPACE = a.k8sFleet.namespace;
1232
- v.OYA_MANAGED_NETWORK = "oya-governed-egress";
1233
- v.OYA_MANAGED_IMAGE = a.k8sFleet.image;
1234
- v.OYA_MANAGED_CONTROL_URL = a.k8sFleet.controlUrl;
1235
- v.OYA_MANAGED_PROXY_URL = a.k8sFleet.proxyUrl;
1236
- v.OYA_MANAGED_REGION = a.k8sFleet.namespace;
1237
- v.OYA_EGRESS_PORT = "3128";
1238
- v.OYA_EGRESS_HOST = "0.0.0.0";
1239
- } else if (a.fleet === "docker-workers") {
1240
- v.OYA_BROWSER_PROVIDER = "oya-selfhosted";
1241
- } else {
1242
- v.OYA_BROWSER_PROVIDER = a.fleet;
1640
+ /** Every open tab. */
1641
+ async tabs() {
1642
+ return (await this.command("list_tabs")).tabs || [];
1243
1643
  }
1244
- if (a.optional.captcha) v.OYA_CAPTCHA_PROVIDER = a.optional.captcha;
1245
- if (a.optional.recordingBucket) v.OYA_RECORDING_BUCKET = a.optional.recordingBucket;
1246
- if (a.optional.metrics) v.OYA_METRICS_TOKEN = existing.OYA_METRICS_TOKEN || token();
1247
- const extra = [
1248
- "# \u2500\u2500 Shared fleet enrolment \u2500\u2500",
1249
- "# Uncomment to let any browser presenting this token enrol itself.",
1250
- `# FLEET_TOKEN=${token()}`
1251
- ];
1252
- return { values: v, extra, apiKey };
1253
- }
1254
- var PROBE_IMAGE = "alpine@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce";
1255
- async function priorState() {
1256
- const volumes = await capture("docker", ["volume", "ls", "-q", "--filter", "name=oya-data"]) || "";
1257
- for (const name of volumes.split("\n").map((v) => v.trim()).filter(Boolean)) {
1258
- const listing = await capture("docker", ["run", "--rm", "-v", `${name}:/d`, PROBE_IMAGE, "ls", "/d"]);
1259
- if (listing === null || /cookies\.json|personas\.json|profiles|\.secret/.test(listing)) return name;
1644
+ /** Opens a tab, optionally at `url`, and returns its id. */
1645
+ async openTab(url) {
1646
+ return (await this.command("open_tab", { url }, NAVIGATE_TIMEOUT_MS)).tab_id;
1260
1647
  }
1261
- return null;
1262
- }
1263
- async function resolveKekConflict(volume, root) {
1264
- warn(`${volume} already holds encrypted data from an earlier install.`);
1265
- note("Cookies, proxy credentials and TOTP seeds in it were sealed with that");
1266
- note("install's OYA_PROFILE_SECRET. A new secret cannot open them, and the");
1267
- note("server refuses to start rather than silently losing them.");
1268
- const choice = await choose("How should that be handled?", [
1269
- { id: "paste", label: "I have the previous OYA_PROFILE_SECRET", note: "reuse it and keep the data" },
1270
- { id: "reset", label: "Delete the old data and start clean", note: "personas, cookies and sessions are lost" },
1271
- { id: "abort", label: "Stop so I can go and find it" }
1272
- ]);
1273
- if (choice === "paste") {
1274
- return askSecret("Previous OYA_PROFILE_SECRET:", {
1275
- validate: (v) => v ? void 0 : "Paste the secret, or pick another option."
1276
- });
1648
+ /** Makes a tab the one commands act on. */
1649
+ async switchTab(tabId) {
1650
+ await this.command("switch_tab", { tab_id: tabId });
1277
1651
  }
1278
- if (choice === "abort") throw new InputError("Stopped without changing anything.");
1279
- if (!await confirm(`Delete ${volume} and everything stored in it?`, false)) {
1280
- throw new InputError("Stopped without changing anything.");
1652
+ /** Closes a tab. */
1653
+ async closeTab(tabId) {
1654
+ await this.command("close_tab", { tab_id: tabId });
1281
1655
  }
1282
- await run("docker", ["compose", "down", "-v"], { cwd: root, quiet: true }).catch(() => {
1283
- });
1284
- await run("docker", ["volume", "rm", "-f", volume], { cwd: root, quiet: true });
1285
- success(`removed ${volume}`);
1286
- return null;
1287
- }
1288
- async function waitReady(url, timeoutMs = 18e4) {
1289
- const deadline = Date.now() + timeoutMs;
1290
- process.stdout.write(" waiting for /readyz\u2026 ");
1291
- while (Date.now() < deadline) {
1292
- try {
1293
- const res = await fetch(`${url}/readyz`);
1294
- if (res.ok) {
1295
- console.log("ready");
1296
- return;
1297
- }
1298
- } catch {
1299
- }
1300
- await new Promise((r) => setTimeout(r, 2e3));
1656
+ /**
1657
+ * Detect and clear a CAPTCHA. Providers that solve natively are left to do
1658
+ * it; everything else goes to the configured solver.
1659
+ */
1660
+ solveCaptcha() {
1661
+ return this.http.request("POST", `/api/browsers/${this.id}/captcha`, {}, CHALLENGE_TIMEOUT_MS);
1301
1662
  }
1302
- console.log("timed out");
1303
- throw new Error(`${url}/readyz did not become ready. Check logs with: docker compose logs -f server`);
1304
- }
1305
- async function pinImage(reference) {
1306
- if (/@sha256:[0-9a-f]{64}$/.test(reference)) return reference;
1307
- process.stdout.write(` resolving ${reference} to a digest\u2026 `);
1308
- const raw = await capture("docker", ["manifest", "inspect", "--verbose", reference]);
1309
- const digest = raw && /"digest":\s*"(sha256:[0-9a-f]{64})"/.exec(raw)?.[1];
1310
- if (!digest) {
1311
- console.log("could not");
1312
- throw new Error(
1313
- `Kubernetes browsers must run a digest-pinned image, and ${reference} is a tag.
1314
- Get the digest with: docker manifest inspect --verbose ${reference}
1315
- then re-run with the image written as ${reference.split(":")[0]}@sha256:<digest>`
1316
- );
1663
+ /**
1664
+ * Answer an MFA prompt with the persona's configured factor. When nothing can
1665
+ * answer it, `liveViewUrl` is where a person finishes by hand.
1666
+ */
1667
+ async completeMfa() {
1668
+ const result = await this.http.request("POST", `/api/browsers/${this.id}/mfa`, {}, CHALLENGE_TIMEOUT_MS);
1669
+ if (result.liveViewUrl) result.liveViewUrl = new URL(result.liveViewUrl, this.http.baseUrl).href;
1670
+ return result;
1317
1671
  }
1318
- const pinned = `${reference.split("@")[0].replace(/:[^:/]+$/, "")}@${digest}`;
1319
- console.log(digest.slice(0, 19) + "\u2026");
1320
- return pinned;
1321
- }
1322
- function networkPolicy(namespace, name) {
1323
- return JSON.stringify({
1324
- apiVersion: "networking.k8s.io/v1",
1325
- kind: "NetworkPolicy",
1326
- metadata: { name, namespace },
1327
- spec: {
1328
- podSelector: { matchLabels: { app: "oya-managed-browser" } },
1329
- policyTypes: ["Egress"],
1330
- egress: [
1331
- // DNS only, to the cluster resolver.
1332
- { ports: [{ protocol: "UDP", port: 53 }, { protocol: "TCP", port: 53 }] },
1333
- // Everything else must go through the control plane's egress proxy, which
1334
- // is what makes per-session egress policy and budgets enforceable.
1335
- // `app: server` is the label k8s/base/server.yaml gives the control plane;
1336
- // the empty namespaceSelector lets the browsers live in their own namespace.
1337
- // A control plane OUTSIDE the cluster is not matched by any pod selector —
1338
- // add an ipBlock rule here for that case, or the pods will resolve DNS and
1339
- // then fail to enrol.
1340
- { to: [{ namespaceSelector: {}, podSelector: { matchLabels: { app: "server" } } }] }
1341
- ]
1342
- }
1343
- }, null, 2);
1344
- }
1345
- async function provisionK8sFleet(root, a) {
1346
- const fleet = a.k8sFleet;
1347
- const policyName = "oya-governed-egress";
1348
- const pinned = await pinImage(fleet.image);
1349
- const nsDoc = JSON.stringify({ apiVersion: "v1", kind: "Namespace", metadata: { name: fleet.namespace } });
1350
- await pipeTo("kubectl", ["apply", "-f", "-"], nsDoc, root);
1351
- const { writeFileSync: writeFileSync3, mkdirSync: mkdirSync2 } = await import("fs");
1352
- mkdirSync2(join2(root, "k8s", "fleet"), { recursive: true });
1353
- const policyPath = join2(root, "k8s", "fleet", "networkpolicy.json");
1354
- writeFileSync3(policyPath, networkPolicy(fleet.namespace, policyName) + "\n");
1355
- await pipeTo("kubectl", ["apply", "-f", "-"], networkPolicy(fleet.namespace, policyName), root);
1356
- console.log(` applied namespace ${fleet.namespace} and NetworkPolicy ${policyName}`);
1357
- console.log(` policy written to ${policyPath} \u2014 review it; it is your egress boundary`);
1358
- return pinned;
1359
- }
1360
- function pipeTo(cmd, args2, stdin2, cwd) {
1361
- return new Promise((resolve, reject) => {
1362
- const child = spawn(cmd, args2, { cwd, stdio: ["pipe", "inherit", "inherit"] });
1363
- child.on("error", reject);
1364
- child.on("close", (code) => code === 0 ? resolve() : reject(new Error(`${cmd} ${args2.join(" ")} exited ${code}`)));
1365
- child.stdin.end(stdin2);
1366
- });
1367
- }
1368
- async function provisionDocker(root, a) {
1369
- if (a.fleet === "oya-selfhosted") {
1370
- const exists = await capture("docker", ["network", "inspect", "oya-browsers"]);
1371
- if (!exists) await run("docker", ["network", "create", "--internal", "--driver", "bridge", "oya-browsers"], { cwd: root });
1372
- console.log(" building the governed browser image\u2026");
1373
- await run("docker", ["build", "-t", "oya-browser:local", "browser"], { cwd: root });
1374
- console.log("\n Governed browsers need Docker daemon access from the server container.");
1375
- console.log(" Add this to the server service in docker-compose.yml, then re-run `docker compose up -d`:");
1376
- console.log(" volumes:\n - /var/run/docker.sock:/var/run/docker.sock");
1672
+ /**
1673
+ * Natural-language control, using this key's configured model. Refer to values as
1674
+ * `{{name}}` in the prompt. `data` the agent can read, so it can split a name or pick
1675
+ * the matching option; `secrets` it never sees. It types both through placeholders,
1676
+ * with filters like `{{name|first}}`, so a playbook saved from the run stores no values.
1677
+ */
1678
+ async ask(prompt, { data, secrets } = {}) {
1679
+ const body = { messages: [{ role: "user", content: prompt }], data, secrets };
1680
+ const path = `/api/browsers/${this.id}/chat`;
1681
+ return agentAnswer(await this.http.request("POST", path, body, AGENT_TIMEOUT_MS)).text;
1377
1682
  }
1378
- const scale = a.fleet === "docker-workers" ? ["--scale", `browser=${a.workers}`] : ["--scale", "browser=0"];
1379
- await run("docker", ["compose", "up", "-d", "--build", ...scale], { cwd: root });
1380
- }
1381
- async function cmdInstall(flags2) {
1382
- const dryRun = flags2["dry-run"] === true;
1383
- const root = await locateRepo();
1384
- const configPath2 = typeof flags2.config === "string" ? flags2.config : null;
1385
- const preset = configPath2 ? JSON.parse(readFileSync2(configPath2, "utf8")) : {};
1386
- banner("Oya Browser \u2014 self-host install", root);
1387
- if (configPath2) note(`replaying ${configPath2}`);
1388
- steps(configPath2 ? 0 : 6);
1389
- const { answers, secrets, migrateUrl } = await interview(preset, !!configPath2);
1390
- const checks = await preflight(root, answers);
1391
- console.log(`
1392
- ${style.bold(" Preflight")}`);
1393
- for (const c of checks) {
1394
- const mark = c.ok ? icon.ok : c.fatal ? icon.fail : icon.warn;
1395
- console.log(` ${mark} ${c.label}${c.detail ? style.grey(` \u2014 ${c.detail}`) : ""}`);
1683
+ /**
1684
+ * Save the last `ask()` on this browser as a named playbook. Every value that was
1685
+ * typed, picked or clicked becomes a variable, with what the run used kept in
1686
+ * `defaults`, so `play()` with nothing repeats the run and any one value can be
1687
+ * swapped. `code` is the same flow as a Playwright module, to read or run yourself.
1688
+ */
1689
+ toPlaybook(name) {
1690
+ return this.http.request("POST", `/api/browsers/${this.id}/playbooks`, { name }, PLAYBOOK_TIMEOUT_MS);
1396
1691
  }
1397
- const blocking = checks.filter((c) => !c.ok && c.fatal);
1398
- if (blocking.length && !dryRun) throw new Error(`Install cannot continue: ${blocking.map((c) => c.label).join(", ")}`);
1399
- if (answers.host === "docker" && /(localhost|127\.0\.0\.1)/.test(secrets.DATABASE_URL || "")) {
1400
- warn("DATABASE_URL points at localhost, which inside a container means the container itself.");
1401
- note("Use host.docker.internal (macOS, Windows) or the host IP so the server can reach it.");
1692
+ /**
1693
+ * Replay a playbook with no LLM in the loop. Variables left out reuse the
1694
+ * recorded values where there are any. If a step no longer fits the page and
1695
+ * `autoHeal` is on (the default), the agent finishes the task and its fix is
1696
+ * saved as a draft (`healed`, `draft`); off, the step's error is thrown.
1697
+ * Play `'<name>:draft'` to try a draft before promoting it.
1698
+ */
1699
+ async play(name, data = {}, { autoHeal = true } = {}) {
1700
+ const path = `/api/browsers/${this.id}/playbooks/${encodeURIComponent(name)}/play`;
1701
+ return agentAnswer(
1702
+ await this.http.request("POST", path, { variables: data, autoHeal }, AGENT_TIMEOUT_MS)
1703
+ );
1402
1704
  }
1403
- const envPath = join2(root, ".env");
1404
- const existing = readEnv(envPath);
1405
- if (!existing.OYA_PROFILE_SECRET && answers.host === "docker" && !dryRun) {
1406
- const volume = await priorState();
1407
- if (volume) {
1408
- const reused = await resolveKekConflict(volume, root);
1409
- if (reused) existing.OYA_PROFILE_SECRET = reused;
1410
- }
1705
+ /**
1706
+ * Start a prompt or playbook in the background and hear back through callbacks.
1707
+ * `onHumanAttention` fires for an unsolved CAPTCHA, an unfinished MFA, the agent
1708
+ * asking for help, or a replay the agent could not heal; the run waits (up to 30
1709
+ * minutes) until you call `respond()`.
1710
+ */
1711
+ async submit(task, options = {}) {
1712
+ const { onSuccess, onFailure, onHumanAttention, onHealed, pollMs, ...body } = options;
1713
+ const started = await this.http.request("POST", `/api/browsers/${this.id}/runs`, { ...task, ...body });
1714
+ return new Run(this.http, started.id, { onSuccess, onFailure, onHumanAttention, onHealed }, pollMs);
1411
1715
  }
1412
- const { values, extra, apiKey } = buildEnv(answers, secrets, existing);
1413
- const body = renderEnv(values, extra);
1414
- console.log(`
1415
- ${style.bold(" Plan")}`);
1416
- const row = (k, v) => console.log(` ${style.grey(k.padEnd(12))}${v}`);
1417
- row("control", `${answers.host} \xB7 ${answers.database}`);
1418
- row("browsers", `${answers.fleet}${answers.workers ? ` \xD7${answers.workers}` : ""}`);
1419
- row("llm", answers.llm.provider === "skip" ? style.grey("none") : `${answers.llm.model} ${style.grey(`@ ${answers.llm.baseUrl}`)}`);
1420
- row("writes", `${envPath} ${style.grey("(0600)")}`);
1421
- row("then", `docker compose up -d, wait for ${answers.publicUrl}/readyz`);
1422
- if (dryRun) {
1423
- console.log("\n--dry-run: nothing was written. Settings that would be applied:");
1424
- for (const k of Object.keys(values)) {
1425
- console.log(` ${k}=${SECRET_KEYS.has(k) ? style.grey("<hidden>") : values[k]}`);
1426
- }
1427
- return;
1716
+ /**
1717
+ * Watch it work: the console, opened on this browser. It carries no
1718
+ * credential. For the frames themselves, use `liveStreamUrl()`.
1719
+ */
1720
+ liveViewUrl() {
1721
+ return consoleLink(this.http.baseUrl, this.id);
1428
1722
  }
1429
- writeEnv(envPath, body);
1430
- writeFileSync2(join2(root, ANSWERS_FILE), JSON.stringify(answers, null, 2) + "\n");
1431
- success(`wrote ${envPath}`);
1432
- if (!existing.OYA_PROFILE_SECRET) {
1433
- warn("OYA_PROFILE_SECRET was generated \u2014 back it up.");
1434
- note("Without it, stored cookies, proxy credentials and TOTP seeds cannot be decrypted.");
1435
- }
1436
- if (migrateUrl) {
1437
- const spin = spinner("applying migrations");
1438
- try {
1439
- await new Promise((resolve, reject) => {
1440
- const child = spawn(process.execPath, [join2(root, "server", "migrations", "run.mjs")], {
1441
- cwd: root,
1442
- stdio: ["ignore", "pipe", "pipe"],
1443
- env: { ...process.env, DATABASE_URL: migrateUrl }
1444
- });
1445
- let err = "";
1446
- child.stderr.on("data", (d) => {
1447
- err += d;
1448
- });
1449
- child.on("error", reject);
1450
- child.on("close", (code) => code === 0 ? resolve() : reject(new Error(err.trim() || `migrations exited ${code}`)));
1451
- });
1452
- spin.done();
1453
- } catch (e) {
1454
- spin.fail();
1455
- throw new Error(`Migrations failed: ${e.message}
1456
- Fix the database, then re-run: DATABASE_URL=\u2026 make migrate`);
1457
- }
1723
+ /**
1724
+ * The SSE stream of JPEG frames, for embedding in your own UI. EventSource
1725
+ * cannot set headers, so the URL carries a connection ticket: single use,
1726
+ * 60 seconds. Mint one per viewer — the first connection spends it.
1727
+ */
1728
+ async liveStreamUrl() {
1729
+ const path = `/api/control/sessions/${encodeURIComponent(this.id)}/ticket`;
1730
+ const { ticket } = await this.http.request("POST", path, {});
1731
+ return `${this.http.baseUrl}/api/live/${this.id}?ticket=${encodeURIComponent(ticket)}`;
1458
1732
  }
1459
- if (answers.fleet === "k8s") {
1460
- const pinned = await provisionK8sFleet(root, answers);
1461
- if (pinned !== values.OYA_MANAGED_IMAGE) {
1462
- values.OYA_MANAGED_IMAGE = pinned;
1463
- writeEnv(envPath, renderEnv(values, extra));
1464
- console.log(" .env updated with the pinned image digest");
1465
- }
1733
+ /**
1734
+ * A shareable link to this browser's live view, for handing to a person or
1735
+ * embedding in your own app. The token rides in the URL fragment, so it never
1736
+ * reaches a server log or Referer header. `control: true` lets whoever opens
1737
+ * it take over and act in the browser; otherwise it is view-only. The link
1738
+ * expires (default one hour) and is revocable with `revokeShare(id)`.
1739
+ *
1740
+ * The credential is scoped to this one browser: it cannot see or touch the
1741
+ * rest of your project. Anyone holding the link has that access until it
1742
+ * expires or you revoke it, so treat it like a password.
1743
+ */
1744
+ async shareUrl({ control = false, expiresInSeconds = 3600 } = {}) {
1745
+ const path = `/api/control/sessions/${encodeURIComponent(this.id)}/share`;
1746
+ const c = await this.http.request("POST", path, { control, expiresIn: expiresInSeconds });
1747
+ return { url: shareLink(this.http.baseUrl, this.id, c.token), id: c.id, expiresAt: c.expiresAt };
1466
1748
  }
1467
- if (answers.host === "docker") await provisionDocker(root, answers);
1468
- await waitReady(answers.publicUrl);
1469
- banner(`${answers.publicUrl} is up`, "Sign in to the dashboard with the key below.");
1470
- console.log(`
1471
- ${style.bold("API key")} ${style.green(apiKey)}
1472
- `);
1473
- console.log(` ${style.grey("# your shell may export OYA_API_KEY for the hosted service")}`);
1474
- console.log(` unset OYA_API_KEY`);
1475
- console.log(` oya login --url ${answers.publicUrl} --key ${apiKey}`);
1476
- console.log(` oya ls && oya goto https://example.com
1477
- `);
1478
- }
1479
-
1480
- // src/index.ts
1481
- var HELP = `oya \u2014 thousands of browsers, one API
1482
-
1483
- oya install Stand up a self-hosted control plane
1484
- [--dry-run] [--config oya-install.json]
1485
- oya login Save an API key for this machine
1486
- oya init Set your model, browser provider and sign-ins
1487
- oya start [--persona auto] Start a browser and print its id
1488
- oya goto <url> [--id <id>] Navigate (defaults to the newest browser)
1489
- oya ask "<prompt>" [--id <id>] Drive it in plain language
1490
- oya ls List running browsers
1491
- oya rm <id> | --all Stop browsers
1492
- oya personas Identities: fingerprint + cookies + proxy
1493
- oya personas new [name] --platform Win32|MacIntel|Linux --tz <zone> --locale <l> --max <n>
1494
- oya personas edit <id> --name <n> --max <n> --geo <cc>
1495
- oya personas clone|rm <id> A new device of the same kind \xB7 delete
1496
- oya status [--id <id>] Health, counters and what it has been doing
1497
- oya open [--id <id>] Open the live view in your browser
1498
- oya config [key=value ...] Show or change this key's settings
1499
- oya control Durable project overview
1500
- oya sessions [id] All sessions, including pending cleanup
1501
- oya stop <id> --force Stop despite a profile-save error, or reconcile
1502
- oya takeover <id> Acquire human control
1503
- oya release <id> Release human control, leaving the agent paused
1504
- oya resume <id> Acknowledge agent resume
1505
- oya events [--after <cursor>] Read durable lifecycle events
1506
- oya project <settings-json> Update limits, rate cards and retention
1507
- oya start --governed --provider oya-selfhosted [--queue-ms 30000]
1508
- [--budget-usd 5] [--policy JSON] [--idempotency-key ID]
1509
- oya cancel <id> Cancel queued or provisioning work
1510
- oya recover <id> [--replace] Explicitly recover or replace a session
1511
- oya members [invite|remove] List members, invite, or remove a user
1512
- oya credential new [--role viewer|operator|administrator]
1513
- oya credential revoke <id> Revoke a service credential
1514
- oya webhook new <https-url> Register a signed event webhook
1515
- oya webhook remove <id> Disable a webhook
1516
- oya webhook replay <id> Replay a delivery
1517
- oya usage What this key has spent
1518
- oya stealth-test [--live] Score this deployment against bot detectors
1519
-
1520
- Options: --url <control plane> --key <api key> --json
1521
- Config: ${configPath}
1522
- `;
1523
- function parse(argv) {
1524
- const [command2 = "help", ...rest] = argv;
1525
- const args2 = [];
1526
- const flags2 = {};
1527
- for (let i = 0; i < rest.length; i++) {
1528
- const token2 = rest[i];
1529
- if (!token2.startsWith("--")) {
1530
- args2.push(token2);
1531
- continue;
1532
- }
1533
- const name = token2.slice(2);
1534
- const next = rest[i + 1];
1535
- if (next === void 0 || next.startsWith("--")) flags2[name] = true;
1536
- else {
1537
- flags2[name] = next;
1538
- i++;
1749
+ /** Revoke a link from `shareUrl()` before it expires, by the id it returned. */
1750
+ async revokeShare(id) {
1751
+ await this.http.request("DELETE", `/api/control/credentials/${encodeURIComponent(id)}`);
1752
+ }
1753
+ /** Counters, health and the last 50 things this browser did. */
1754
+ status() {
1755
+ return this.http.request("GET", `/api/browsers/${this.id}`);
1756
+ }
1757
+ /**
1758
+ * Stop it, whatever it is: a cloud sandbox is destroyed so billing ends, a
1759
+ * CDP session is handed back to its provider, a desktop browser disconnects.
1760
+ */
1761
+ stop() {
1762
+ return this.http.request("POST", `/api/browsers/${this.id}/stop`, {}, STOP_TIMEOUT_MS);
1763
+ }
1764
+ /** `await using browser = await oya.browser.start()` stops it however the block exits, errors included. */
1765
+ async [Symbol.asyncDispose]() {
1766
+ await this.stop();
1767
+ }
1768
+ /** @deprecated use stop() — close() only dropped the socket, and a cloud browser redialled. */
1769
+ async close() {
1770
+ await this.stop();
1771
+ }
1772
+ };
1773
+ function assertCaptchaCleared(result) {
1774
+ if (!result.present || result.solved || result.invisible) return;
1775
+ const message = result.error || "CAPTCHA needs attention. Call solveCaptcha() again or open the live view.";
1776
+ throw new OyaError(message, Status3.CONFLICT, result);
1777
+ }
1778
+ function toElementId(value) {
1779
+ const id = Number(value);
1780
+ const wrongType = typeof value !== "number" && typeof value !== "string";
1781
+ if (wrongType || value === "" || !Number.isInteger(id) || id < 0) {
1782
+ throw new OyaError("Use a numeric element id from browser.analyze().", Status3.BAD_REQUEST, null);
1783
+ }
1784
+ return id;
1785
+ }
1786
+ function scrollParams(direction, amount, at) {
1787
+ if (!at) return { direction, amount };
1788
+ return { direction, amount: amount ?? AIMED_SCROLL_AMOUNT, ...at, smooth: false };
1789
+ }
1790
+ function agentAnswer(res) {
1791
+ if (res.error) throw new OyaError(res.error, res.status ?? Status3.SERVER_ERROR, res);
1792
+ return res;
1793
+ }
1794
+ function consoleLink(baseUrl, id) {
1795
+ return `${baseUrl}/dashboard/?browser=${encodeURIComponent(id)}`;
1796
+ }
1797
+ function shareLink(baseUrl, id, token2) {
1798
+ return `${baseUrl}/live/${encodeURIComponent(id)}#t=${encodeURIComponent(token2)}`;
1799
+ }
1800
+ function startBody(options) {
1801
+ const { profile, persona, provider, wsUrl, name, queueMs, priority, budgetUsd, governed, policy } = options;
1802
+ return { profile: profile || persona, provider, wsUrl, name, queueMs, priority, budgetUsd, governed, policy };
1803
+ }
1804
+ async function start(http, wait, options) {
1805
+ const headers = { "Idempotency-Key": options.idempotencyKey || globalThis.crypto.randomUUID() };
1806
+ const path = "/api/browsers/start";
1807
+ const started = await http().request("POST", path, startBody(options), START_TIMEOUT_MS, headers);
1808
+ if (started.status === "starting") {
1809
+ await wait(started.id, options.readyTimeoutMs ?? READY_TIMEOUT_MS2 + (options.queueMs || 0));
1810
+ started.cdpUrl = (await fetchBrowser(http, started.id)).cdpUrl;
1811
+ }
1812
+ return new Browser(http(), started, options.captcha === "auto");
1813
+ }
1814
+ var fetchBrowser = (http, id) => http().request("GET", `/api/browsers/${encodeURIComponent(id)}`);
1815
+ async function reattach(http, id) {
1816
+ const found = await fetchBrowser(http, id);
1817
+ return new Browser(http(), asStarted(found), false);
1818
+ }
1819
+ function asStarted(found) {
1820
+ const provider = found.provider || "cdp";
1821
+ return { id: found.id, provider, persona: found.persona || "default", status: "ready", cdpUrl: found.cdpUrl };
1822
+ }
1823
+ var stopBrowsers = (http, ids) => http().request("POST", "/api/browsers/stop", ids === "all" ? { all: true } : { ids }, START_TIMEOUT_MS);
1824
+ var browserApi = (http, wait) => ({
1825
+ /** Start a browser and wait until it can take commands. */
1826
+ start: (options = {}) => start(http, wait, options),
1827
+ /** Reattach to a browser that is already running. */
1828
+ get: (id) => reattach(http, id),
1829
+ /** Every browser on this key. */
1830
+ list: () => http().request("GET", "/api/browsers"),
1831
+ /** Stop some (`ids`) or every browser on this key. Each reports separately. */
1832
+ stop: (ids) => stopBrowsers(http, ids),
1833
+ /** Stop every browser on this key; returns how many stopped. */
1834
+ stopAll: async () => (await stopBrowsers(http, "all")).stopped
1835
+ });
1836
+ var session = (id, action = "") => `/api/control/sessions/${encodeURIComponent(id)}${action}`;
1837
+ var item = (kind, id) => `/api/control/${kind}/${encodeURIComponent(id)}`;
1838
+ var sessionCalls = (http) => ({
1839
+ /** Settings, sessions and recent events at a glance. */
1840
+ overview: () => http().request("GET", "/api/control"),
1841
+ /** Every session, including disconnected and cleanup-pending ones. */
1842
+ sessions: () => http().request("GET", "/api/control/sessions"),
1843
+ /** One session. */
1844
+ session: (id) => http().request("GET", session(id)),
1845
+ /** Update limits, rate cards and retention. */
1846
+ settings: (changes) => http().request("PATCH", "/api/control/project", changes)
1847
+ });
1848
+ var lifecycleCalls = (http) => ({
1849
+ /** Cancel queued or provisioning work. */
1850
+ cancel: (id) => http().request("POST", session(id, "/cancel"), {}),
1851
+ /** Stop a session; `force` stops despite a profile-save error, or reconciles. */
1852
+ stop: (id, force = false) => http().request("POST", session(id, "/stop"), { force }),
1853
+ /** Acquire or release human control, or acknowledge the agent's resume. */
1854
+ takeover: (id, action) => http().request("POST", session(id, "/control"), { action }),
1855
+ /** Send one input as the human holding the control lease. */
1856
+ input: (id, action, params) => http().request("POST", session(id, "/input"), { action, params })
1857
+ });
1858
+ var recoveryCalls = (http) => ({
1859
+ /** Explicitly recover a session, or replace it with a fresh one. */
1860
+ recover: (id, replace = false) => http().request("POST", session(id, "/recover"), { replace }),
1861
+ /** A single-use ticket for the live stream. */
1862
+ ticket: (id) => http().request("POST", session(id, "/ticket"), {}),
1863
+ /** Durable lifecycle events after a cursor. */
1864
+ events: (after = 0) => http().request("GET", `/api/control/events?after=${after}`)
1865
+ });
1866
+ var credentialCalls = (http) => ({
1867
+ /** Mint a service credential. Its token is returned this once. */
1868
+ createCredential: (options) => http().request("POST", "/api/control/credentials", options),
1869
+ /** Revoke a service credential. */
1870
+ revokeCredential: (id) => http().request("DELETE", item("credentials", id))
1871
+ });
1872
+ var memberCalls = (http) => ({
1873
+ /** The owner and every member. */
1874
+ members: () => http().request("GET", "/api/control/members"),
1875
+ /** An invitation code for a new member. */
1876
+ inviteMember: (role = "operator") => http().request("POST", "/api/control/members/invite", { role }),
1877
+ /** Remove a member. */
1878
+ removeMember: (userId) => http().request("DELETE", item("members", userId))
1879
+ });
1880
+ var webhookCalls = (http) => ({
1881
+ /** Register a webhook; `types` limits which events it receives. */
1882
+ createWebhook: (url, types = []) => http().request("POST", "/api/control/webhooks", { url, types }),
1883
+ /** Disable a webhook. */
1884
+ removeWebhook: (id) => http().request("DELETE", item("webhooks", id)),
1885
+ /** Send a delivery again. */
1886
+ replayDelivery: (id) => http().request("POST", `${item("deliveries", id)}/replay`, {})
1887
+ });
1888
+ var controlApi = (http) => ({
1889
+ ...sessionCalls(http),
1890
+ ...lifecycleCalls(http),
1891
+ ...recoveryCalls(http),
1892
+ ...credentialCalls(http),
1893
+ ...memberCalls(http),
1894
+ ...webhookCalls(http)
1895
+ });
1896
+ var playbook = (name) => `/api/playbooks/${encodeURIComponent(name)}`;
1897
+ var playbookApi = (http) => ({
1898
+ /** Every saved playbook, with any draft waiting on it. */
1899
+ list: async () => (await http().request("GET", "/api/playbooks")).playbooks,
1900
+ /** Delete a playbook and its draft, or only the draft with `'<name>:draft'`. */
1901
+ remove: async (name) => {
1902
+ await http().request("DELETE", playbook(name));
1903
+ },
1904
+ /** Replace a playbook with the draft a healed replay saved. Try it first with `browser.play('<name>:draft')`. */
1905
+ promote: (name) => http().request("POST", `${playbook(name)}/promote`, {})
1906
+ });
1907
+ var proxyApi = (http) => ({
1908
+ /** Every proxy this key can use, shared ones included. */
1909
+ list: async () => (await http().request("GET", "/api/proxies")).proxies,
1910
+ /** Add a proxy. Its credentials are never read back. */
1911
+ create: (proxy) => http().request("POST", "/api/proxies", proxy),
1912
+ /** Remove one of this key's proxies. */
1913
+ remove: async (id) => {
1914
+ await http().request("DELETE", `/api/proxies/${encodeURIComponent(id)}`);
1915
+ },
1916
+ /** Dial each proxy and learn its real exit IP. Failing ones cool down and are skipped. */
1917
+ check: async () => (await http().request("POST", "/api/proxies/check", {})).results
1918
+ });
1919
+ var identityCalls = (http) => ({
1920
+ /** Every persona on this key. */
1921
+ list: async () => (await http().request("GET", "/api/personas")).personas,
1922
+ /** One persona. */
1923
+ get: (id) => http().request("GET", `/api/personas/${id}`),
1924
+ /**
1925
+ * Create an identity. The device — platform, timezone, locale — is chosen
1926
+ * here and fixed for its life; `preview()` shows what a choice produces.
1927
+ */
1928
+ create: (options = {}) => http().request("POST", "/api/personas", options),
1929
+ /** Name, concurrency cap and proxy hint. Never the device — clone for that. */
1930
+ update: (id, changes) => http().request("PUT", `/api/personas/${id}`, changes),
1931
+ /** A new persona of the same kind of device: same choices, fresh identity, empty jar. */
1932
+ clone: (id, options = {}) => http().request("POST", `/api/personas/${id}/clone`, options)
1933
+ });
1934
+ var deviceCalls = (http) => ({
1935
+ /** The fingerprint these choices would produce. Persists nothing. */
1936
+ preview: async (prefs = {}) => (await http().request("POST", "/api/personas/preview", { prefs })).fingerprint,
1937
+ /** Platforms, and the timezones and locales each may coherently claim. */
1938
+ options: () => http().request("GET", "/api/personas/options"),
1939
+ /** Pin the persona to one of your proxies, or `null` to let assignment happen at connect. */
1940
+ pinProxy: (id, proxyId) => http().request("PUT", `/api/personas/${id}/proxy`, { proxyId }),
1941
+ /** Delete a persona. */
1942
+ remove: async (id) => {
1943
+ await http().request("DELETE", `/api/personas/${id}`);
1944
+ }
1945
+ });
1946
+ var mfaCalls = (http) => ({
1947
+ /** Store the second factor for this identity. Sealed at rest, never read back. */
1948
+ setMfa: (id, config) => http().request("PUT", `/api/personas/${id}/mfa`, config),
1949
+ /** Remove the persona-wide factor, or the one filed against `domain`. */
1950
+ clearMfa: async (id, domain) => {
1951
+ await http().request("DELETE", `/api/personas/${id}/mfa${domain ? `?domain=${encodeURIComponent(domain)}` : ""}`);
1952
+ }
1953
+ });
1954
+ var loginCalls = (http) => ({
1955
+ /**
1956
+ * Store a site login for this identity. Sealed at rest, never read back.
1957
+ *
1958
+ * Signing in once on the desktop and inheriting the cookies is still the
1959
+ * better path. This is for portals that expire a session server-side
1960
+ * between runs, where an unattended run has nothing else to recover with.
1961
+ */
1962
+ setCredentials: (id, config) => http().request("PUT", `/api/personas/${id}/credentials`, config),
1963
+ /** Which sites this identity can sign in to. Usernames only. */
1964
+ credentials: (id) => http().request("GET", `/api/personas/${id}/credentials`),
1965
+ /** Remove the login stored for one site. */
1966
+ clearCredentials: async (id, domain) => {
1967
+ await http().request("DELETE", `/api/personas/${id}/credentials?domain=${encodeURIComponent(domain)}`);
1968
+ }
1969
+ });
1970
+ var personaApi = (http) => ({
1971
+ ...identityCalls(http),
1972
+ ...deviceCalls(http),
1973
+ ...mfaCalls(http),
1974
+ ...loginCalls(http)
1975
+ });
1976
+ var configApi = (http) => ({
1977
+ /** This key's settings. Secrets read back masked. */
1978
+ get: () => http().request("GET", "/api/config"),
1979
+ /** Change settings; `null` clears a field back to the deployment default. */
1980
+ set: (values) => http().request("POST", "/api/config", values)
1981
+ });
1982
+ var ENDED = ["failed", "stopped", "unknown_outcome"];
1983
+ async function waitUntilConnected(checks, id, timeoutMs) {
1984
+ const deadline = Date.now() + timeoutMs;
1985
+ while (Date.now() < deadline) {
1986
+ if (await isUp(checks, id)) return;
1987
+ await new Promise((r) => setTimeout(r, READY_POLL_MS2));
1988
+ }
1989
+ const seconds = Math.round(timeoutMs / MS_PER_SECOND);
1990
+ throw new OyaError(`Browser ${id} did not come up within ${seconds}s`, Status3.GATEWAY_TIMEOUT, null);
1991
+ }
1992
+ async function isUp(checks, id) {
1993
+ const all = await checks.list();
1994
+ if (all.some((b) => b.id === id && b.health !== "dead")) return true;
1995
+ await assertNotEnded(checks, id);
1996
+ return false;
1997
+ }
1998
+ async function assertNotEnded(checks, id) {
1999
+ try {
2000
+ const session2 = await checks.session(id);
2001
+ if (ENDED.includes(session2.state)) {
2002
+ throw new OyaError(`Browser creation ended in ${session2.state}`, Status3.CONFLICT, session2);
1539
2003
  }
2004
+ } catch (e) {
2005
+ if (!(e instanceof OyaError) || e.status !== Status3.NOT_FOUND) throw e;
1540
2006
  }
1541
- return { command: command2, args: args2, flags: flags2 };
1542
2007
  }
1543
- var flagStr = (flags2, name) => typeof flags2[name] === "string" ? flags2[name] : void 0;
2008
+ var MAX_FILE_BYTES = MAX_FILE_MB * BYTES_PER_MB;
2009
+ var Oya = class {
2010
+ /** The one HTTP path every call goes through. */
2011
+ http;
2012
+ /** Reads the key and URL from `options`, then OYA_API_KEY and OYA_BASE_URL. Throws without a key. */
2013
+ constructor(options = {}) {
2014
+ this.http = createHttp(options);
2015
+ }
2016
+ /** Start, reattach to, list and stop browsers. */
2017
+ browser = browserApi(
2018
+ () => this.http,
2019
+ (id, timeoutMs) => this.waitUntilConnected(id, timeoutMs)
2020
+ );
2021
+ /** Durable operational controls, including disconnected and cleanup-pending sessions. */
2022
+ control = controlApi(() => this.http);
2023
+ /** Playbooks saved with `browser.toPlaybook()`. */
2024
+ playbooks = playbookApi(() => this.http);
2025
+ /**
2026
+ * Proxy exits for your personas. A persona takes one at first connect (by its
2027
+ * geo hint) or by `personas.pinProxy`, and keeps it.
2028
+ */
2029
+ proxies = proxyApi(() => this.http);
2030
+ /** Identities: fingerprint, cookie jar and proxy bound together, plus their stored factors and logins. */
2031
+ personas = personaApi(() => this.http);
2032
+ /**
2033
+ * This key's settings: LLM credentials, browser provider, solver.
2034
+ *
2035
+ * Bring your own LLM key (it pays for its own tokens, so no hourly chat quota applies):
2036
+ * await oya.config.set({ llm_provider: 'gemini', openai_api_key: process.env.GEMINI_API_KEY });
2037
+ * `llm_provider` is the {@link LlmProvider} union, so an editor offers the choices and a
2038
+ * typo is a compile error; `chat_model` overrides its default model. 'vertex' is Gemini
2039
+ * Enterprise (ex-Vertex AI) in express mode, which needs no GCP project; for a
2040
+ * project-scoped endpoint, set `openai_base_url` to `.../endpoints/openapi` and pass an
2041
+ * OAuth access token as `openai_api_key`. See {@link ConfigUpdate} for every field.
2042
+ */
2043
+ config = configApi(() => this.http);
2044
+ /** Saved profiles. `personas` is retained as an alias for existing clients. */
2045
+ profiles = this.personas;
2046
+ /** What this key has spent. */
2047
+ usage() {
2048
+ return this.http.request("GET", "/api/usage");
2049
+ }
2050
+ /** Waits for a starting browser to dial in, through this client's own list and session calls. */
2051
+ async waitUntilConnected(id, timeoutMs) {
2052
+ const checks = { list: () => this.browser.list(), session: (sid) => this.control.session(sid) };
2053
+ await waitUntilConnected(checks, id, timeoutMs);
2054
+ }
2055
+ };
2056
+
2057
+ // src/context.ts
1544
2058
  function client(flags2) {
1545
2059
  const saved = resolved();
1546
2060
  const apiKey = flagStr(flags2, "key") || saved.apiKey;
@@ -1551,8 +2065,9 @@ function client(flags2) {
1551
2065
  }
1552
2066
  return new Oya({ apiKey, baseUrl });
1553
2067
  }
2068
+ var printJson = (value) => console.log(JSON.stringify(value, null, JSON_INDENT2));
1554
2069
  var out = (flags2, value, human) => {
1555
- if (flags2.json) console.log(JSON.stringify(value, null, 2));
2070
+ if (flags2.json) printJson(value);
1556
2071
  else human();
1557
2072
  };
1558
2073
  async function targetBrowser(oya, flags2) {
@@ -1565,399 +2080,474 @@ async function targetBrowser(oya, flags2) {
1565
2080
  }
1566
2081
  return oya.browser.get(all[all.length - 1].id);
1567
2082
  }
1568
- async function cmdLogin(flags2) {
1569
- const current = resolved();
1570
- let apiKey = flagStr(flags2, "key") || "";
1571
- const baseUrl = (flagStr(flags2, "url") || (apiKey ? current.baseUrl : await ask("Control plane URL:", current.baseUrl))).replace(/\/+$/, "");
1572
- const how = apiKey ? "paste" : await choose("How do you want to authenticate?", [
1573
- { id: "paste", label: "Paste an API key", note: "from the dashboard, or a self-hosted key" },
1574
- { id: "password", label: "Sign in with email and password", note: "mints a new key for this machine" }
1575
- ]);
1576
- if (!apiKey && how === "paste") apiKey = await askSecret("API key:");
1577
- if (!apiKey && how === "password") {
1578
- const email = await ask("Email:");
1579
- const password = await askSecret("Password:");
1580
- const res = await fetch(`${baseUrl}/api/auth/login`, {
1581
- method: "POST",
1582
- headers: { "Content-Type": "application/json" },
1583
- body: JSON.stringify({ email, password })
1584
- });
1585
- const session = await res.json();
1586
- if (!res.ok || !session.access_token) throw new Error(session.error || "Sign-in failed");
1587
- const minted = await fetch(`${baseUrl}/api/auth/keys`, {
1588
- method: "POST",
1589
- headers: { Authorization: `Bearer ${session.access_token}`, "Content-Type": "application/json" },
1590
- body: JSON.stringify({ label: "CLI" })
1591
- });
1592
- const created = await minted.json();
1593
- if (!minted.ok || !created.key) throw new Error(created.error || "Could not create an API key");
1594
- apiKey = created.key;
1595
- console.log(' Created a new API key labelled "CLI".');
1596
- }
1597
- if (!apiKey) throw new Error("No API key given");
1598
- const check = await fetch(`${baseUrl}/api/config`, { headers: { Authorization: `Bearer ${apiKey}` } });
1599
- if (!check.ok) throw new Error(`That key was rejected by ${baseUrl} (${check.status})`);
1600
- save({ apiKey, baseUrl });
1601
- console.log(`
1602
- \u2705 Signed in to ${baseUrl}. Saved to ${configPath}`);
1603
- console.log(" Next: `oya init` to pick your model and browser provider.");
1604
- }
1605
- async function cmdInit(flags2) {
1606
- const oya = client(flags2);
1607
- const current = await oya.config.get();
2083
+
2084
+ // src/commands/init.ts
2085
+ var PRESETS = {
2086
+ anthropic: { name: "Anthropic", model: "claude-sonnet-4-5" },
2087
+ openai: { name: "OpenAI", model: "gpt-4o-mini" },
2088
+ gemini: { name: "Gemini", model: "gemini-3.8-flash" },
2089
+ vertex: { name: "Gemini Enterprise", model: "gemini-2.5-flash" }
2090
+ };
2091
+ var modelOptions = (current) => [
2092
+ { id: "anthropic", label: "Claude (Anthropic)" },
2093
+ { id: "openai", label: "OpenAI" },
2094
+ { id: "gemini", label: "Gemini (Google)" },
2095
+ { id: "vertex", label: "Gemini Enterprise (Vertex AI)" },
2096
+ { id: "skip", label: "Skip", note: current.has_openai_key ? "keep what is configured" : "no agent control" }
2097
+ ];
2098
+ async function askModel(current, updates) {
1608
2099
  console.log("\n\u2500\u2500 1. Your model \u2500\u2500");
1609
- const llm = await choose("Which LLM should agents use?", [
1610
- { id: "anthropic", label: "Claude (Anthropic)" },
1611
- { id: "openai", label: "OpenAI" },
1612
- { id: "gemini", label: "Gemini (Google)" },
1613
- { id: "vertex", label: "Gemini Enterprise (Vertex AI)" },
1614
- { id: "skip", label: "Skip", note: current.has_openai_key ? "keep what is configured" : "no agent control" }
1615
- ]);
1616
- const presets = {
1617
- anthropic: { name: "Anthropic", model: "claude-sonnet-4-5" },
1618
- openai: { name: "OpenAI", model: "gpt-4o-mini" },
1619
- gemini: { name: "Gemini", model: "gemini-3.8-flash" },
1620
- vertex: { name: "Gemini Enterprise", model: "gemini-2.5-flash" }
1621
- };
1622
- const updates = {};
1623
- if (llm !== "skip") {
1624
- updates.llm_provider = llm;
1625
- const key = await askSecret(`${presets[llm].name} API key:`);
1626
- if (key) updates.openai_api_key = key;
1627
- const model = await ask("Default model:", presets[llm].model);
1628
- if (model) updates.chat_model = model;
1629
- }
2100
+ const llm = await choose("Which LLM should agents use?", modelOptions(current));
2101
+ if (llm !== "skip") await askModelDetails(llm, updates);
2102
+ }
2103
+ async function askModelDetails(llm, updates) {
2104
+ updates.llm_provider = llm;
2105
+ const key = await askSecret(`${PRESETS[llm].name} API key:`);
2106
+ if (key) updates.openai_api_key = key;
2107
+ const model = await ask("Default model:", PRESETS[llm].model);
2108
+ if (model) updates.chat_model = model;
2109
+ }
2110
+ function providerOptions(current) {
2111
+ const note2 = (p) => p.needs.length ? p.configured ? "configured" : "needs an API key" : void 0;
2112
+ return current.providers.map((p) => ({ id: p.id, label: p.label, note: note2(p) }));
2113
+ }
2114
+ async function askProvider(current, updates) {
1630
2115
  console.log("\n\u2500\u2500 2. Where your browsers run \u2500\u2500");
1631
- const provider = await choose("Browser provider:", current.providers.map((p) => ({
1632
- id: p.id,
1633
- label: p.label,
1634
- note: p.needs.length ? p.configured ? "configured" : "needs an API key" : void 0
1635
- })));
2116
+ const provider = await choose("Browser provider:", providerOptions(current));
1636
2117
  updates.browser_provider = provider;
1637
- const needs = current.providers.find((p) => p.id === provider)?.needs || [];
1638
- for (const field of needs) {
2118
+ for (const field of current.providers.find((p) => p.id === provider)?.needs || []) {
1639
2119
  const value = await askSecret(`${field.replace(/_/g, " ")}:`);
1640
2120
  if (value) updates[field] = value;
1641
2121
  }
2122
+ return provider;
2123
+ }
2124
+ var CAPTCHA_OPTIONS2 = [
2125
+ { id: "", label: "No", note: "providers that solve natively still will" },
2126
+ { id: "capsolver", label: "Yes, via CapSolver" }
2127
+ ];
2128
+ async function askCaptcha(updates) {
1642
2129
  console.log("\n\u2500\u2500 3. CAPTCHAs \u2500\u2500");
1643
- const solver = await choose("Solve CAPTCHAs automatically?", [
1644
- { id: "", label: "No", note: "providers that solve natively still will" },
1645
- { id: "capsolver", label: "Yes, via CapSolver" }
1646
- ]);
2130
+ const solver = await choose("Solve CAPTCHAs automatically?", CAPTCHA_OPTIONS2);
1647
2131
  updates.captcha_solver = solver;
1648
- if (solver) {
1649
- const key = await askSecret("Solver API key:");
1650
- if (key) updates.captcha_api_key = key;
1651
- }
2132
+ if (!solver) return;
2133
+ const key = await askSecret("Solver API key:");
2134
+ if (key) updates.captcha_api_key = key;
2135
+ }
2136
+ function signInHint(provider) {
2137
+ if (provider !== "oya-cloud" && provider !== "oya-selfhosted") return;
2138
+ console.log("\n\u2500\u2500 4. Sign in once, on your own machine \u2500\u2500");
2139
+ console.log(" Your remote browsers reuse the cookies from a desktop sign-in, so agents");
2140
+ console.log(" arrive already logged in \u2014 as the same identity, from the same fingerprint.");
2141
+ console.log(` Download the desktop browser: ${resolved().baseUrl}/downloads`);
2142
+ }
2143
+ async function interview2(oya) {
2144
+ const current = await oya.config.get();
2145
+ const updates = {};
2146
+ await askModel(current, updates);
2147
+ const provider = await askProvider(current, updates);
2148
+ await askCaptcha(updates);
1652
2149
  updates.onboarded = "true";
1653
2150
  await oya.config.set(updates);
2151
+ return provider;
2152
+ }
2153
+ async function cmdInit(flags2) {
2154
+ const provider = await interview2(client(flags2));
1654
2155
  console.log("\n\u2705 Saved against your API key.");
1655
- if (provider === "oya-cloud" || provider === "oya-selfhosted") {
1656
- console.log("\n\u2500\u2500 4. Sign in once, on your own machine \u2500\u2500");
1657
- console.log(" Your remote browsers reuse the cookies from a desktop sign-in, so agents");
1658
- console.log(" arrive already logged in \u2014 as the same identity, from the same fingerprint.");
1659
- console.log(` Download the desktop browser: ${resolved().baseUrl}/downloads`);
1660
- }
2156
+ signInHint(provider);
1661
2157
  console.log("\n Then: oya start && oya goto https://example.com");
1662
2158
  }
1663
- async function cmdStart(flags2) {
1664
- const oya = client(flags2);
1665
- const browser = await oya.browser.start({
2159
+
2160
+ // src/commands/browsers.ts
2161
+ import { spawn as spawn3 } from "child_process";
2162
+ function identityOptions(flags2) {
2163
+ return {
1666
2164
  persona: flagStr(flags2, "persona") || "default",
1667
2165
  captcha: flags2.captcha === false ? "off" : "auto",
1668
2166
  provider: flagStr(flags2, "provider"),
1669
2167
  wsUrl: flagStr(flags2, "ws-url"),
1670
2168
  name: flagStr(flags2, "name"),
1671
- idempotencyKey: flagStr(flags2, "idempotency-key"),
1672
- queueMs: flagStr(flags2, "queue-ms") === void 0 ? void 0 : Number(flagStr(flags2, "queue-ms")),
1673
- budgetUsd: flagStr(flags2, "budget-usd") === void 0 ? void 0 : Number(flagStr(flags2, "budget-usd")),
2169
+ idempotencyKey: flagStr(flags2, "idempotency-key")
2170
+ };
2171
+ }
2172
+ function governanceOptions(flags2) {
2173
+ return {
2174
+ queueMs: flagNum(flags2, "queue-ms"),
2175
+ budgetUsd: flagNum(flags2, "budget-usd"),
1674
2176
  governed: flags2.governed === true,
1675
2177
  priority: flagStr(flags2, "priority"),
1676
2178
  policy: flagStr(flags2, "policy") ? JSON.parse(flagStr(flags2, "policy")) : void 0
1677
- });
1678
- out(flags2, { id: browser.id, provider: browser.provider, persona: browser.persona, cdpUrl: browser.cdpUrl }, () => {
1679
- console.log(`\u2705 ${browser.id}`);
1680
- console.log(` provider: ${browser.provider} persona: ${browser.persona}`);
1681
- if (browser.cdpUrl) console.log(` cdp: ${browser.cdpUrl}`);
2179
+ };
2180
+ }
2181
+ var startOptions = (flags2) => ({ ...identityOptions(flags2), ...governanceOptions(flags2) });
2182
+ async function cmdStart(flags2) {
2183
+ const browser = await client(flags2).browser.start(startOptions(flags2));
2184
+ const { id, provider, persona, cdpUrl } = browser;
2185
+ out(flags2, { id, provider, persona, cdpUrl }, () => {
2186
+ console.log(`\u2705 ${id}`);
2187
+ console.log(` provider: ${provider} persona: ${persona}`);
2188
+ if (cdpUrl) console.log(` cdp: ${cdpUrl}`);
1682
2189
  });
1683
2190
  }
1684
2191
  async function cmdGoto(args2, flags2) {
1685
2192
  const url = args2[0];
1686
2193
  if (!url) throw new Error("Usage: oya goto <url>");
1687
- const oya = client(flags2);
1688
- const browser = await targetBrowser(oya, flags2);
2194
+ const browser = await targetBrowser(client(flags2), flags2);
1689
2195
  await browser.goto(url);
1690
2196
  console.log(`\u2705 ${browser.id} \u2192 ${url}`);
1691
2197
  }
1692
2198
  async function cmdAsk(args2, flags2) {
1693
2199
  const prompt = args2.join(" ");
1694
2200
  if (!prompt) throw new Error('Usage: oya ask "find the pricing page"');
1695
- const oya = client(flags2);
1696
- const browser = await targetBrowser(oya, flags2);
2201
+ const browser = await targetBrowser(client(flags2), flags2);
1697
2202
  console.log(await browser.ask(prompt));
1698
2203
  }
2204
+ var DOT = { ok: "\u25CF", stale: "\u25D0", errors: "\u2717", dead: "\u25CB" };
2205
+ function lsRow(b) {
2206
+ const provider = (b.provider || "oya").padEnd(LsColumns.PROVIDER);
2207
+ const persona = (b.personaName || b.persona || "default").padEnd(LsColumns.PERSONA);
2208
+ const url = b.currentUrl.replace(/^https?:\/\//, "").slice(0, LsColumns.URL);
2209
+ return `${DOT[b.health] || "\xB7"} ${b.id} ${provider} ${persona} ${b.name.padEnd(LsColumns.NAME)} ${b.commands}\xB7${b.errors} ${url}`;
2210
+ }
1699
2211
  async function cmdLs(flags2) {
1700
2212
  const all = await client(flags2).browser.list();
1701
2213
  out(flags2, all, () => {
1702
2214
  if (!all.length) return console.log("No browsers running.");
1703
- const dot = { ok: "\u25CF", stale: "\u25D0", errors: "\u2717", dead: "\u25CB" };
1704
- for (const b of all) {
1705
- console.log(`${dot[b.health] || "\xB7"} ${b.id} ${(b.provider || "oya").padEnd(14)} ${(b.personaName || b.persona || "default").padEnd(14)} ${b.name.padEnd(18)} ${b.commands}\xB7${b.errors} ${b.currentUrl.replace(/^https?:\/\//, "").slice(0, 40)}`);
1706
- }
2215
+ for (const b of all) console.log(lsRow(b));
1707
2216
  console.log(`
1708
2217
  ${all.length} running.`);
1709
2218
  });
1710
2219
  }
2220
+ function rmLine(x) {
2221
+ const removed = x.sandboxRemoved === true ? " (sandbox destroyed)" : "";
2222
+ const note2 = x.sandboxRemoved === false ? " \u2014 sandbox NOT removed, check Oya Cloud" : removed;
2223
+ return `${x.ok ? "\u2705" : "\u2717"} ${x.id}${note2}${x.error ? ` ${x.error}` : ""}`;
2224
+ }
1711
2225
  async function cmdRm(args2, flags2) {
1712
2226
  const oya = client(flags2);
1713
2227
  if (!flags2.all && !args2.length) throw new Error("Usage: oya rm <id>\u2026 | oya rm --all");
1714
2228
  const r = await oya.browser.stop(flags2.all ? "all" : args2);
1715
- for (const x of r.results) {
1716
- const note2 = x.sandboxRemoved === true ? " (sandbox destroyed)" : x.sandboxRemoved === false ? " \u2014 sandbox NOT removed, check Oya Cloud" : "";
1717
- console.log(`${x.ok ? "\u2705" : "\u2717"} ${x.id}${note2}${x.error ? ` ${x.error}` : ""}`);
1718
- }
2229
+ for (const x of r.results) console.log(rmLine(x));
1719
2230
  console.log(`stopped ${r.stopped}`);
1720
2231
  }
2232
+ function activityLine(a) {
2233
+ const error = a.error ? ` \u2014 ${a.error}` : "";
2234
+ return ` ${a.ok ? "\xB7" : "\u2717"} ${a.action.padEnd(StatusColumns.ACTION)} ${a.summary}${error} ${a.ms}ms`;
2235
+ }
2236
+ function printStatus(s) {
2237
+ console.log(`${s.name} ${s.id}`);
2238
+ console.log(` ${s.health} \xB7 ${s.provider || "oya"} \xB7 persona ${s.personaName || s.persona || "default"}`);
2239
+ console.log(` ${s.commands} commands \xB7 ${s.errors} errors \xB7 ${s.pending} in flight \xB7 at ${s.currentUrl || "\u2014"}`);
2240
+ if (s.lastError) console.log(` last error: ${s.lastError}`);
2241
+ if (!s.activity.length) return;
2242
+ console.log(" recent:");
2243
+ for (const a of s.activity.slice(0, StatusColumns.RECENT)) console.log(activityLine(a));
2244
+ }
1721
2245
  async function cmdStatus(flags2) {
1722
- const oya = client(flags2);
1723
- const browser = await targetBrowser(oya, flags2);
2246
+ const browser = await targetBrowser(client(flags2), flags2);
1724
2247
  const s = await browser.status();
1725
- out(flags2, s, () => {
1726
- console.log(`${s.name} ${s.id}`);
1727
- console.log(` ${s.health} \xB7 ${s.provider || "oya"} \xB7 persona ${s.personaName || s.persona || "default"}`);
1728
- console.log(` ${s.commands} commands \xB7 ${s.errors} errors \xB7 ${s.pending} in flight \xB7 at ${s.currentUrl || "\u2014"}`);
1729
- if (s.lastError) console.log(` last error: ${s.lastError}`);
1730
- if (s.activity.length) {
1731
- console.log(" recent:");
1732
- for (const a of s.activity.slice(0, 10)) console.log(` ${a.ok ? "\xB7" : "\u2717"} ${a.action.padEnd(18)} ${a.summary}${a.error ? ` \u2014 ${a.error}` : ""} ${a.ms}ms`);
1733
- }
1734
- });
2248
+ out(flags2, s, () => printStatus(s));
1735
2249
  }
1736
- async function cmdPersonas(args2, flags2) {
1737
- const oya = client(flags2);
1738
- const [sub, ...rest] = args2;
1739
- if (sub === "new" || sub === "create") {
1740
- const platformAlias = {
1741
- win32: "Win32",
1742
- windows: "Win32",
1743
- win: "Win32",
1744
- macintel: "MacIntel",
1745
- mac: "MacIntel",
1746
- macos: "MacIntel",
1747
- linux: "Linux x86_64",
1748
- "linux x86_64": "Linux x86_64"
1749
- };
1750
- const platformFlag = flagStr(flags2, "platform");
1751
- const prefs = {
1752
- ...platformFlag ? { platform: platformAlias[platformFlag.toLowerCase()] || platformFlag } : {},
1753
- ...flagStr(flags2, "tz") ? { timezone: flagStr(flags2, "tz") } : {},
1754
- ...flagStr(flags2, "locale") ? { locale: flagStr(flags2, "locale") } : {}
1755
- };
1756
- if (flags2.preview) {
1757
- const fp = await oya.personas.preview(prefs);
1758
- return out(flags2, fp, () => console.log(`${fp.platform} \xB7 ${fp.timezone} \xB7 ${fp.locale} \xB7 ${fp.screen} \xB7 ${fp.webgl}`));
1759
- }
1760
- const created = await oya.personas.create({
1761
- name: flagStr(flags2, "name") || rest[0],
1762
- prefs,
1763
- proxy: flagStr(flags2, "geo") ? { geo: flagStr(flags2, "geo") } : void 0,
1764
- maxConcurrent: flagStr(flags2, "max") ? Number(flagStr(flags2, "max")) : void 0
1765
- });
1766
- return out(flags2, created, () => console.log(`\u2705 ${created.id} ${created.name} ${created.fingerprint.platform} \xB7 ${created.fingerprint.timezone}`));
1767
- }
1768
- if (sub === "edit") {
1769
- if (!rest[0]) throw new Error("Usage: oya personas edit <id> --name <n> --max <n> --geo <cc>");
1770
- const updated = await oya.personas.update(rest[0], {
1771
- ...flagStr(flags2, "name") ? { name: flagStr(flags2, "name") } : {},
1772
- ...flagStr(flags2, "max") ? { maxConcurrent: flagStr(flags2, "max") === "none" ? null : Number(flagStr(flags2, "max")) } : {},
1773
- ...flagStr(flags2, "geo") ? { proxy: { geo: flagStr(flags2, "geo") } } : {}
1774
- });
1775
- return out(flags2, updated, () => console.log(`\u2705 ${updated.id} ${updated.name} cap ${updated.maxConcurrent ?? "\u221E"}`));
1776
- }
1777
- if (sub === "clone") {
1778
- if (!rest[0]) throw new Error("Usage: oya personas clone <id> [--name <n>]");
1779
- const c = await oya.personas.clone(rest[0], { name: flagStr(flags2, "name") });
1780
- return out(flags2, c, () => console.log(`\u2705 ${c.id} ${c.name} ${c.fingerprint.platform} \xB7 ${c.fingerprint.timezone} (new identity, same kind of device)`));
1781
- }
1782
- if (sub === "rm" || sub === "delete") {
1783
- if (!rest[0]) throw new Error("Usage: oya personas rm <id>");
1784
- await oya.personas.remove(rest[0]);
1785
- return console.log(`\u2705 removed ${rest[0]}`);
1786
- }
1787
- const all = await oya.personas.list();
1788
- out(flags2, all, () => {
1789
- if (!all.length) return console.log("No personas yet \u2014 `oya personas new`.");
1790
- for (const p of all) {
1791
- const cap = p.maxConcurrent === null ? "\u221E" : String(p.maxConcurrent);
1792
- console.log(`${p.id} ${p.name.padEnd(20)} ${p.activeBrowsers}/${cap} running ${p.fingerprint.platform} \xB7 ${p.fingerprint.timezone}${p.exit ? ` via ${p.exit.label}` : p.proxy?.geo ? ` geo ${p.proxy.geo}` : ""}${p.mfa?.configured ? ` mfa:${p.mfa.type}` : ""}${p.isDefault ? " (default)" : ""}`);
1793
- }
1794
- });
2250
+ function opener(url) {
2251
+ if (process.platform === "darwin") return ["open", [url]];
2252
+ if (process.platform === "win32") return ["cmd", ["/c", "start", "", url]];
2253
+ return ["xdg-open", [url]];
1795
2254
  }
1796
2255
  async function cmdOpen(flags2) {
1797
2256
  const browser = await targetBrowser(client(flags2), flags2);
1798
- const url = browser.liveViewUrl();
1799
- const [opener, args2] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
1800
- spawn2(opener, args2, { detached: true, stdio: "ignore" }).unref();
2257
+ const [command2, args2] = opener(browser.liveViewUrl());
2258
+ spawn3(command2, args2, { detached: true, stdio: "ignore" }).unref();
1801
2259
  console.log(`Opening ${browser.id}`);
1802
2260
  }
1803
- async function cmdConfig(args2, flags2) {
2261
+
2262
+ // src/commands/personas.ts
2263
+ var PLATFORM_ALIAS = {
2264
+ win32: "Win32",
2265
+ windows: "Win32",
2266
+ win: "Win32",
2267
+ macintel: "MacIntel",
2268
+ mac: "MacIntel",
2269
+ macos: "MacIntel",
2270
+ linux: "Linux x86_64",
2271
+ "linux x86_64": "Linux x86_64"
2272
+ };
2273
+ function prefsFrom(flags2) {
2274
+ const platformFlag = flagStr(flags2, "platform");
2275
+ return {
2276
+ ...platformFlag ? { platform: PLATFORM_ALIAS[platformFlag.toLowerCase()] || platformFlag } : {},
2277
+ ...flagStr(flags2, "tz") ? { timezone: flagStr(flags2, "tz") } : {},
2278
+ ...flagStr(flags2, "locale") ? { locale: flagStr(flags2, "locale") } : {}
2279
+ };
2280
+ }
2281
+ function createOptions(flags2, rest, prefs) {
2282
+ return {
2283
+ name: flagStr(flags2, "name") || rest[0],
2284
+ prefs,
2285
+ proxy: flagStr(flags2, "geo") ? { geo: flagStr(flags2, "geo") } : void 0,
2286
+ maxConcurrent: flagStr(flags2, "max") ? Number(flagStr(flags2, "max")) : void 0
2287
+ };
2288
+ }
2289
+ var create = async (oya, rest, flags2) => {
2290
+ const prefs = prefsFrom(flags2);
2291
+ if (flags2.preview) return preview(oya, prefs, flags2);
2292
+ const created = await oya.personas.create(createOptions(flags2, rest, prefs));
2293
+ const fp = created.fingerprint;
2294
+ out(flags2, created, () => console.log(`\u2705 ${created.id} ${created.name} ${fp.platform} \xB7 ${fp.timezone}`));
2295
+ };
2296
+ async function preview(oya, prefs, flags2) {
2297
+ const fp = await oya.personas.preview(prefs);
2298
+ out(flags2, fp, () => console.log(`${fp.platform} \xB7 ${fp.timezone} \xB7 ${fp.locale} \xB7 ${fp.screen} \xB7 ${fp.webgl}`));
2299
+ }
2300
+ function editChanges(flags2) {
2301
+ const max = flagStr(flags2, "max");
2302
+ return {
2303
+ ...flagStr(flags2, "name") ? { name: flagStr(flags2, "name") } : {},
2304
+ ...max ? { maxConcurrent: max === "none" ? null : Number(max) } : {},
2305
+ ...flagStr(flags2, "geo") ? { proxy: { geo: flagStr(flags2, "geo") } } : {}
2306
+ };
2307
+ }
2308
+ var edit = async (oya, rest, flags2) => {
2309
+ if (!rest[0]) throw new Error("Usage: oya personas edit <id> --name <n> --max <n> --geo <cc>");
2310
+ const updated = await oya.personas.update(rest[0], editChanges(flags2));
2311
+ out(flags2, updated, () => console.log(`\u2705 ${updated.id} ${updated.name} cap ${updated.maxConcurrent ?? "\u221E"}`));
2312
+ };
2313
+ var clone = async (oya, rest, flags2) => {
2314
+ if (!rest[0]) throw new Error("Usage: oya personas clone <id> [--name <n>]");
2315
+ const c = await oya.personas.clone(rest[0], { name: flagStr(flags2, "name") });
2316
+ const fp = c.fingerprint;
2317
+ out(
2318
+ flags2,
2319
+ c,
2320
+ () => console.log(`\u2705 ${c.id} ${c.name} ${fp.platform} \xB7 ${fp.timezone} (new identity, same kind of device)`)
2321
+ );
2322
+ };
2323
+ var remove = async (oya, rest) => {
2324
+ if (!rest[0]) throw new Error("Usage: oya personas rm <id>");
2325
+ await oya.personas.remove(rest[0]);
2326
+ console.log(`\u2705 removed ${rest[0]}`);
2327
+ };
2328
+ function personaLine(p) {
2329
+ const cap = p.maxConcurrent === null ? "\u221E" : String(p.maxConcurrent);
2330
+ const where = p.exit ? ` via ${p.exit.label}` : p.proxy?.geo ? ` geo ${p.proxy.geo}` : "";
2331
+ const extras = `${where}${p.mfa?.configured ? ` mfa:${p.mfa.type}` : ""}${p.isDefault ? " (default)" : ""}`;
2332
+ const device = `${p.fingerprint.platform} \xB7 ${p.fingerprint.timezone}`;
2333
+ return `${p.id} ${p.name.padEnd(PERSONA_NAME_WIDTH)} ${p.activeBrowsers}/${cap} running ${device}${extras}`;
2334
+ }
2335
+ var list = async (oya, _rest, flags2) => {
2336
+ const all = await oya.personas.list();
2337
+ out(flags2, all, () => {
2338
+ if (!all.length) return console.log("No personas yet \u2014 `oya personas new`.");
2339
+ for (const p of all) console.log(personaLine(p));
2340
+ });
2341
+ };
2342
+ var SUBCOMMANDS = { new: create, create, edit, clone, rm: remove, delete: remove };
2343
+ async function cmdPersonas(args2, flags2) {
1804
2344
  const oya = client(flags2);
1805
- if (!args2.length) {
1806
- const current = await oya.config.get();
1807
- return out(flags2, current, () => console.log(JSON.stringify(current, null, 2)));
1808
- }
2345
+ const [sub, ...rest] = args2;
2346
+ const run2 = sub !== void 0 && Object.hasOwn(SUBCOMMANDS, sub) ? SUBCOMMANDS[sub] : list;
2347
+ await run2(oya, rest, flags2);
2348
+ }
2349
+
2350
+ // src/commands/settings.ts
2351
+ function parsePairs(args2) {
1809
2352
  const updates = {};
1810
2353
  for (const pair of args2) {
1811
2354
  const index = pair.indexOf("=");
1812
2355
  if (index < 1) throw new Error(`Expected key=value, got "${pair}"`);
1813
2356
  updates[pair.slice(0, index)] = pair.slice(index + 1);
1814
2357
  }
2358
+ return updates;
2359
+ }
2360
+ async function cmdConfig(args2, flags2) {
2361
+ const oya = client(flags2);
2362
+ if (!args2.length) {
2363
+ const current = await oya.config.get();
2364
+ return out(flags2, current, () => printJson(current));
2365
+ }
2366
+ const updates = parsePairs(args2);
1815
2367
  await oya.config.set(updates);
1816
2368
  console.log(`\u2705 updated ${Object.keys(updates).join(", ")}`);
1817
2369
  }
1818
2370
  async function cmdUsage(flags2) {
1819
- const usage = await client(flags2).usage();
1820
- console.log(JSON.stringify(usage, null, 2));
2371
+ printJson(await client(flags2).usage());
1821
2372
  }
2373
+ async function cmdWhoami() {
2374
+ printJson({ ...resolved(), apiKey: load().apiKey ? "saved" : "none" });
2375
+ }
2376
+
2377
+ // src/commands/stealth.ts
2378
+ import { spawn as spawn4 } from "child_process";
2379
+ var spawnHarness = (flags2) => spawn4("node", ["test-stealth.js", ...flags2.live ? ["--live"] : []], {
2380
+ cwd: process.env.OYA_SERVER_DIR || "server",
2381
+ stdio: "inherit"
2382
+ });
1822
2383
  function cmdStealthTest(flags2) {
1823
2384
  return new Promise((resolve, reject) => {
1824
- const child = spawn2("node", ["test-stealth.js", ...flags2.live ? ["--live"] : []], {
1825
- cwd: process.env.OYA_SERVER_DIR || "server",
1826
- stdio: "inherit"
1827
- });
1828
- child.on("error", () => reject(new Error(
1829
- "Could not find the stealth harness. Run it from a checkout, or set OYA_SERVER_DIR."
1830
- )));
2385
+ const child = spawnHarness(flags2);
2386
+ const missing = "Could not find the stealth harness. Run it from a checkout, or set OYA_SERVER_DIR.";
2387
+ child.on("error", () => reject(new Error(missing)));
1831
2388
  child.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`stealth test exited ${code}`)));
1832
2389
  });
1833
2390
  }
1834
- var { command, args, flags } = parse(process.argv.slice(2));
1835
- try {
1836
- if (["control", "sessions", "takeover", "release", "resume", "events", "project", "credential", "webhook", "cancel", "recover", "members"].includes(command) || command === "stop" && flags.force === true) {
1837
- const c = client(flags).control;
1838
- const required = (i = 0) => {
1839
- if (!args[i]) throw new Error("Missing argument; run oya help");
1840
- return args[i];
1841
- };
1842
- let result;
1843
- switch (command) {
1844
- case "control":
1845
- result = await c.overview();
1846
- break;
1847
- case "sessions":
1848
- result = args[0] ? await c.session(args[0]) : await c.sessions();
1849
- break;
1850
- case "cancel":
1851
- result = await c.cancel(required());
1852
- break;
1853
- case "recover":
1854
- result = await c.recover(required(), flags.replace === true);
1855
- break;
1856
- case "members":
1857
- if (!args[0]) result = await c.members();
1858
- else if (args[0] === "remove") result = await c.removeMember(required(1));
1859
- else if (args[0] === "invite") {
1860
- const role = flagStr(flags, "role") || "operator";
1861
- if (!["viewer", "operator", "administrator"].includes(role)) throw new Error("Invalid role");
1862
- result = await c.inviteMember(role);
1863
- } else throw new Error("Use members, members invite, or members remove");
1864
- break;
1865
- case "stop":
1866
- result = await c.stop(required(), flags.force === true);
1867
- break;
1868
- case "takeover":
1869
- case "release":
1870
- case "resume":
1871
- result = await c.takeover(required(), command === "takeover" ? "acquire" : command);
1872
- break;
1873
- case "events":
1874
- result = await c.events(Number(flagStr(flags, "after") || 0));
1875
- break;
1876
- case "project":
1877
- result = await c.settings(JSON.parse(required()));
1878
- break;
1879
- case "credential":
1880
- if (required() === "revoke") result = await c.revokeCredential(required(1));
1881
- else if (args[0] === "new") {
1882
- const role = flagStr(flags, "role") || "operator";
1883
- if (!["viewer", "operator", "administrator"].includes(role)) throw new Error("Invalid role");
1884
- result = await c.createCredential({ role, label: flagStr(flags, "label") });
1885
- } else throw new Error("Use credential new or credential revoke");
1886
- break;
1887
- case "webhook":
1888
- if (required() === "new") result = await c.createWebhook(required(1));
1889
- else if (args[0] === "remove") result = await c.removeWebhook(required(1));
1890
- else if (args[0] === "replay") result = await c.replayDelivery(required(1));
1891
- else throw new Error("Use webhook new, remove, or replay");
1892
- break;
1893
- }
1894
- console.log(JSON.stringify(result, null, 2));
1895
- } else switch (command) {
1896
- case "install":
1897
- await cmdInstall(flags);
1898
- break;
1899
- case "login":
1900
- await cmdLogin(flags);
1901
- break;
1902
- case "init":
1903
- await cmdInit(flags);
1904
- break;
1905
- case "start":
1906
- await cmdStart(flags);
1907
- break;
1908
- case "goto":
1909
- await cmdGoto(args, flags);
1910
- break;
1911
- case "ask":
1912
- await cmdAsk(args, flags);
1913
- break;
1914
- case "ls":
1915
- case "list":
1916
- await cmdLs(flags);
1917
- break;
1918
- case "status":
1919
- await cmdStatus(flags);
1920
- break;
1921
- case "rm":
1922
- case "stop":
1923
- await cmdRm(args, flags);
1924
- break;
1925
- case "personas":
1926
- await cmdPersonas(args, flags);
1927
- break;
1928
- case "open":
1929
- await cmdOpen(flags);
1930
- break;
1931
- case "config":
1932
- await cmdConfig(args, flags);
1933
- break;
1934
- case "usage":
1935
- await cmdUsage(flags);
1936
- break;
1937
- case "stealth-test":
1938
- await cmdStealthTest(flags);
1939
- break;
1940
- case "whoami":
1941
- console.log(JSON.stringify({ ...resolved(), apiKey: load().apiKey ? "saved" : "none" }, null, 2));
1942
- break;
1943
- case "help":
1944
- case "--help":
1945
- case "-h":
1946
- console.log(HELP);
1947
- break;
1948
- default:
1949
- console.error(`Unknown command: ${command}
2391
+
2392
+ // src/commands/control.ts
2393
+ var ROLES = ["viewer", "operator", "administrator"];
2394
+ function required(args2, i = 0) {
2395
+ if (!args2[i]) throw new Error("Missing argument; run oya help");
2396
+ return args2[i];
2397
+ }
2398
+ function roleFlag(flags2) {
2399
+ const role = flagStr(flags2, "role") || "operator";
2400
+ if (!ROLES.includes(role)) throw new Error("Invalid role");
2401
+ return role;
2402
+ }
2403
+ function subcommand(table, sub, call2, usage) {
2404
+ if (!Object.hasOwn(table, sub)) throw new Error(usage);
2405
+ return table[sub](call2);
2406
+ }
2407
+ var members = (call2) => {
2408
+ if (!call2.args[0]) return call2.c.members();
2409
+ const table = {
2410
+ remove: ({ c, args: args2 }) => c.removeMember(required(args2, 1)),
2411
+ invite: ({ c, flags: flags2 }) => c.inviteMember(roleFlag(flags2))
2412
+ };
2413
+ return subcommand(table, call2.args[0], call2, "Use members, members invite, or members remove");
2414
+ };
2415
+ var credential = (call2) => {
2416
+ const table = {
2417
+ revoke: ({ c, args: args2 }) => c.revokeCredential(required(args2, 1)),
2418
+ new: ({ c, flags: flags2 }) => c.createCredential({ role: roleFlag(flags2), label: flagStr(flags2, "label") })
2419
+ };
2420
+ return subcommand(table, required(call2.args), call2, "Use credential new or credential revoke");
2421
+ };
2422
+ var webhook = (call2) => {
2423
+ const table = {
2424
+ new: ({ c, args: args2 }) => c.createWebhook(required(args2, 1)),
2425
+ remove: ({ c, args: args2 }) => c.removeWebhook(required(args2, 1)),
2426
+ replay: ({ c, args: args2 }) => c.replayDelivery(required(args2, 1))
2427
+ };
2428
+ return subcommand(table, required(call2.args), call2, "Use webhook new, remove, or replay");
2429
+ };
2430
+ var takeover = ({ c, command: command2, args: args2 }) => c.takeover(required(args2), command2 === "takeover" ? "acquire" : command2);
2431
+ var COMMANDS = {
2432
+ control: ({ c }) => c.overview(),
2433
+ sessions: ({ c, args: args2 }) => args2[0] ? c.session(args2[0]) : c.sessions(),
2434
+ cancel: ({ c, args: args2 }) => c.cancel(required(args2)),
2435
+ recover: ({ c, args: args2, flags: flags2 }) => c.recover(required(args2), flags2.replace === true),
2436
+ members,
2437
+ stop: ({ c, args: args2, flags: flags2 }) => c.stop(required(args2), flags2.force === true),
2438
+ takeover,
2439
+ release: takeover,
2440
+ resume: takeover,
2441
+ events: ({ c, flags: flags2 }) => c.events(Number(flagStr(flags2, "after") || 0)),
2442
+ project: ({ c, args: args2 }) => c.settings(JSON.parse(required(args2))),
2443
+ credential,
2444
+ webhook
2445
+ };
2446
+ function isControlCommand(command2, flags2) {
2447
+ if (command2 === "stop") return flags2.force === true;
2448
+ return Object.hasOwn(COMMANDS, command2);
2449
+ }
2450
+ async function runControl(command2, args2, flags2) {
2451
+ const c = client(flags2).control;
2452
+ printJson(await COMMANDS[command2]({ c, command: command2, args: args2, flags: flags2 }));
2453
+ }
2454
+
2455
+ // src/help.ts
2456
+ var HELP = `oya \u2014 thousands of browsers, one API
2457
+
2458
+ oya install Stand up a self-hosted control plane
2459
+ [--dry-run] [--config oya-install.json]
2460
+ oya login Save an API key for this machine
2461
+ oya init Set your model, browser provider and sign-ins
2462
+ oya start [--persona auto] Start a browser and print its id
2463
+ oya goto <url> [--id <id>] Navigate (defaults to the newest browser)
2464
+ oya ask "<prompt>" [--id <id>] Drive it in plain language
2465
+ oya ls List running browsers
2466
+ oya rm <id> | --all Stop browsers
2467
+ oya personas Identities: fingerprint + cookies + proxy
2468
+ oya personas new [name] --platform Win32|MacIntel|Linux --tz <zone> --locale <l> --max <n>
2469
+ oya personas edit <id> --name <n> --max <n> --geo <cc>
2470
+ oya personas clone|rm <id> A new device of the same kind \xB7 delete
2471
+ oya status [--id <id>] Health, counters and what it has been doing
2472
+ oya open [--id <id>] Open the live view in your browser
2473
+ oya config [key=value ...] Show or change this key's settings
2474
+ oya control Durable project overview
2475
+ oya sessions [id] All sessions, including pending cleanup
2476
+ oya stop <id> --force Stop despite a profile-save error, or reconcile
2477
+ oya takeover <id> Acquire human control
2478
+ oya release <id> Release human control, leaving the agent paused
2479
+ oya resume <id> Acknowledge agent resume
2480
+ oya events [--after <cursor>] Read durable lifecycle events
2481
+ oya project <settings-json> Update limits, rate cards and retention
2482
+ oya start --governed --provider oya-selfhosted [--queue-ms 30000]
2483
+ [--budget-usd 5] [--policy JSON] [--idempotency-key ID]
2484
+ oya cancel <id> Cancel queued or provisioning work
2485
+ oya recover <id> [--replace] Explicitly recover or replace a session
2486
+ oya members [invite|remove] List members, invite, or remove a user
2487
+ oya credential new [--role viewer|operator|administrator]
2488
+ oya credential revoke <id> Revoke a service credential
2489
+ oya webhook new <https-url> Register a signed event webhook
2490
+ oya webhook remove <id> Disable a webhook
2491
+ oya webhook replay <id> Replay a delivery
2492
+ oya usage What this key has spent
2493
+ oya stealth-test [--live] Score this deployment against bot detectors
2494
+
2495
+ Options: --url <control plane> --key <api key> --json
2496
+ Config: ${configPath}
2497
+ `;
2498
+
2499
+ // src/commands/index.ts
2500
+ var help = async () => console.log(HELP);
2501
+ var COMMANDS2 = {
2502
+ install: (_args, flags2) => cmdInstall(flags2),
2503
+ login: (_args, flags2) => cmdLogin(flags2),
2504
+ init: (_args, flags2) => cmdInit(flags2),
2505
+ start: (_args, flags2) => cmdStart(flags2),
2506
+ goto: cmdGoto,
2507
+ ask: cmdAsk,
2508
+ ls: (_args, flags2) => cmdLs(flags2),
2509
+ list: (_args, flags2) => cmdLs(flags2),
2510
+ status: (_args, flags2) => cmdStatus(flags2),
2511
+ rm: cmdRm,
2512
+ stop: cmdRm,
2513
+ personas: cmdPersonas,
2514
+ open: (_args, flags2) => cmdOpen(flags2),
2515
+ config: cmdConfig,
2516
+ usage: (_args, flags2) => cmdUsage(flags2),
2517
+ "stealth-test": (_args, flags2) => cmdStealthTest(flags2),
2518
+ whoami: () => cmdWhoami(),
2519
+ help,
2520
+ "--help": help,
2521
+ "-h": help
2522
+ };
2523
+ function unknown(command2) {
2524
+ console.error(`Unknown command: ${command2}
1950
2525
  `);
1951
- console.log(HELP);
1952
- process.exit(1);
1953
- }
1954
- } catch (err) {
2526
+ console.log(HELP);
2527
+ process.exit(1);
2528
+ }
2529
+ async function dispatch(command2, args2, flags2) {
2530
+ if (isControlCommand(command2, flags2)) return runControl(command2, args2, flags2);
2531
+ if (!Object.hasOwn(COMMANDS2, command2)) unknown(command2);
2532
+ await COMMANDS2[command2](args2, flags2);
2533
+ }
2534
+
2535
+ // src/index.ts
2536
+ function fail2(err) {
1955
2537
  const error = err;
1956
2538
  console.error(`
1957
2539
  \u2717 ${error.message}`);
1958
- if (error.status === 401) console.error(" The API key was rejected. Run `oya login`.");
1959
- if (error.status === 429) console.error(" A quota or a persona concurrency cap. `oya personas` shows what is running.");
2540
+ if (error.status === Status2.UNAUTHORIZED) console.error(" The API key was rejected. Run `oya login`.");
2541
+ if (error.status === Status2.TOO_MANY_REQUESTS) {
2542
+ console.error(" A quota or a persona concurrency cap. `oya personas` shows what is running.");
2543
+ }
1960
2544
  process.exit(1);
2545
+ }
2546
+ var { command, args, flags } = parse(process.argv.slice(ARGV_SKIP));
2547
+ try {
2548
+ await dispatch(command, args, flags);
2549
+ } catch (err) {
2550
+ fail2(err);
1961
2551
  } finally {
1962
2552
  closePrompts();
1963
2553
  }