@oya-ai/cli 1.0.60 → 1.0.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +912 -34
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { spawn } from "child_process";
|
|
4
|
+
import { spawn as spawn2 } from "child_process";
|
|
5
5
|
|
|
6
6
|
// ../sdk/dist/index.js
|
|
7
7
|
var OyaError = class extends Error {
|
|
@@ -351,48 +351,921 @@ function resolved() {
|
|
|
351
351
|
}
|
|
352
352
|
|
|
353
353
|
// src/prompt.ts
|
|
354
|
-
import { createInterface } from "readline
|
|
354
|
+
import { createInterface } from "readline";
|
|
355
355
|
import { stdin, stdout } from "process";
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
356
|
+
var ESC = "\x1B";
|
|
357
|
+
var plain = !stdout.isTTY || !!process.env.NO_COLOR || process.env.TERM === "dumb";
|
|
358
|
+
var wrap = (open) => (s) => plain ? s : `${ESC}[${open}m${s}${ESC}[0m`;
|
|
359
|
+
var style = {
|
|
360
|
+
bold: wrap("1"),
|
|
361
|
+
dim: wrap("2"),
|
|
362
|
+
green: wrap("32"),
|
|
363
|
+
cyan: wrap("36"),
|
|
364
|
+
yellow: wrap("33"),
|
|
365
|
+
red: wrap("31"),
|
|
366
|
+
grey: wrap("90")
|
|
367
|
+
};
|
|
368
|
+
var icon = {
|
|
369
|
+
ok: style.green("\u2714"),
|
|
370
|
+
fail: style.red("\u2717"),
|
|
371
|
+
warn: style.yellow("!"),
|
|
372
|
+
arrow: style.green("\u276F")
|
|
373
|
+
};
|
|
374
|
+
var clearLine = plain ? "" : `${ESC}[K`;
|
|
375
|
+
var up = (n) => `${ESC}[${n}A`;
|
|
376
|
+
var width = () => Math.min(stdout.columns || 80, 74);
|
|
377
|
+
function banner(title, subtitle) {
|
|
378
|
+
const line = "\u2500".repeat(width());
|
|
379
|
+
stdout.write(`
|
|
380
|
+
${style.grey(line)}
|
|
381
|
+
${style.bold(title)}
|
|
382
|
+
`);
|
|
383
|
+
if (subtitle) stdout.write(` ${style.grey(subtitle)}
|
|
384
|
+
`);
|
|
385
|
+
stdout.write(`${style.grey(line)}
|
|
386
|
+
`);
|
|
387
|
+
}
|
|
388
|
+
var stepNumber = 0;
|
|
389
|
+
var stepTotal = 0;
|
|
390
|
+
function steps(total) {
|
|
391
|
+
stepTotal = total;
|
|
392
|
+
stepNumber = 0;
|
|
393
|
+
}
|
|
394
|
+
function step(title, hint) {
|
|
395
|
+
stepNumber += 1;
|
|
396
|
+
const counter = stepTotal ? style.grey(`${stepNumber}/${stepTotal}`) : "";
|
|
397
|
+
stdout.write(`
|
|
398
|
+
${style.cyan("\u25C6")} ${style.bold(title)} ${counter}
|
|
399
|
+
`);
|
|
400
|
+
if (hint) stdout.write(`${style.grey(" " + hint)}
|
|
401
|
+
`);
|
|
402
|
+
}
|
|
403
|
+
function note(text) {
|
|
404
|
+
stdout.write(`${style.grey(" " + text)}
|
|
405
|
+
`);
|
|
406
|
+
}
|
|
407
|
+
function success(text) {
|
|
408
|
+
stdout.write(`${icon.ok} ${text}
|
|
409
|
+
`);
|
|
410
|
+
}
|
|
411
|
+
function warn(text) {
|
|
412
|
+
stdout.write(`${icon.warn} ${style.yellow(text)}
|
|
413
|
+
`);
|
|
414
|
+
}
|
|
415
|
+
function spinner(label) {
|
|
416
|
+
if (plain) {
|
|
417
|
+
stdout.write(` ${label}\u2026 `);
|
|
418
|
+
return {
|
|
419
|
+
done: (msg = "ok") => stdout.write(`${msg}
|
|
420
|
+
`),
|
|
421
|
+
fail: (msg = "failed") => stdout.write(`${msg}
|
|
422
|
+
`)
|
|
423
|
+
};
|
|
363
424
|
}
|
|
425
|
+
const frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
426
|
+
let i = 0;
|
|
427
|
+
const timer = setInterval(() => {
|
|
428
|
+
stdout.write(`\r ${style.cyan(frames[i++ % frames.length])} ${label}\u2026 `);
|
|
429
|
+
}, 80);
|
|
430
|
+
const stop = (mark, msg) => {
|
|
431
|
+
clearInterval(timer);
|
|
432
|
+
stdout.write(`\r ${mark} ${label} ${style.grey(msg)}${clearLine}
|
|
433
|
+
`);
|
|
434
|
+
};
|
|
435
|
+
return { done: (msg = "") => stop(icon.ok, msg), fail: (msg = "") => stop(icon.fail, msg) };
|
|
364
436
|
}
|
|
365
|
-
|
|
366
|
-
|
|
437
|
+
var rl = null;
|
|
438
|
+
var masked = false;
|
|
439
|
+
var pending = [];
|
|
440
|
+
var waiter = null;
|
|
441
|
+
var ended = false;
|
|
442
|
+
function iface() {
|
|
443
|
+
if (rl) return rl;
|
|
444
|
+
rl = createInterface({ input: stdin, output: stdout, terminal: !!stdin.isTTY });
|
|
445
|
+
const deliver = (line) => {
|
|
446
|
+
if (waiter) {
|
|
447
|
+
const w = waiter;
|
|
448
|
+
waiter = null;
|
|
449
|
+
w(line);
|
|
450
|
+
} else pending.push(line);
|
|
451
|
+
};
|
|
452
|
+
rl.on("line", deliver);
|
|
453
|
+
rl.on("close", () => {
|
|
454
|
+
ended = true;
|
|
455
|
+
if (waiter) {
|
|
456
|
+
const w = waiter;
|
|
457
|
+
waiter = null;
|
|
458
|
+
w("");
|
|
459
|
+
}
|
|
460
|
+
});
|
|
367
461
|
const asAny = rl;
|
|
368
462
|
asAny._writeToOutput = (s) => {
|
|
369
|
-
|
|
463
|
+
if (!masked) {
|
|
464
|
+
asAny.output.write(s);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
asAny.output.write(s.includes("\n") ? "\n" : "*");
|
|
468
|
+
};
|
|
469
|
+
return rl;
|
|
470
|
+
}
|
|
471
|
+
function nextLine() {
|
|
472
|
+
const queued = pending.shift();
|
|
473
|
+
if (queued !== void 0) return Promise.resolve(queued);
|
|
474
|
+
if (ended) return Promise.resolve("");
|
|
475
|
+
iface();
|
|
476
|
+
return new Promise((resolve) => {
|
|
477
|
+
waiter = resolve;
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
function closePrompts() {
|
|
481
|
+
if (stdin.isTTY && stdin.isRaw) stdin.setRawMode(false);
|
|
482
|
+
rl?.close();
|
|
483
|
+
rl = null;
|
|
484
|
+
}
|
|
485
|
+
var InputError = class extends Error {
|
|
486
|
+
};
|
|
487
|
+
async function readValidated(render, fallback, options) {
|
|
488
|
+
for (; ; ) {
|
|
489
|
+
render();
|
|
490
|
+
masked = !!options.secret;
|
|
491
|
+
let answer;
|
|
492
|
+
try {
|
|
493
|
+
answer = (await nextLine()).trim() || fallback;
|
|
494
|
+
} finally {
|
|
495
|
+
if (options.secret) {
|
|
496
|
+
masked = false;
|
|
497
|
+
stdout.write("\n");
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
if (!stdin.isTTY && !options.secret) stdout.write(`${answer}
|
|
501
|
+
`);
|
|
502
|
+
const problem = options.validate?.(answer);
|
|
503
|
+
if (!problem) return answer;
|
|
504
|
+
stdout.write(` ${icon.fail} ${style.red(problem)}
|
|
505
|
+
`);
|
|
506
|
+
if (ended && !pending.length) throw new InputError(problem);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
function ask(question, fallback = "", options = {}) {
|
|
510
|
+
return readValidated(
|
|
511
|
+
() => stdout.write(` ${style.green("?")} ${question}${fallback ? style.grey(` (${fallback})`) : ""} `),
|
|
512
|
+
fallback,
|
|
513
|
+
options
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
function askSecret(question, options = {}) {
|
|
517
|
+
return readValidated(
|
|
518
|
+
() => stdout.write(` ${style.green("?")} ${question} `),
|
|
519
|
+
"",
|
|
520
|
+
{ ...options, secret: true }
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
async function confirm(question, fallback = false) {
|
|
524
|
+
const answer = await ask(`${question} ${style.grey(fallback ? "[Y/n]" : "[y/N]")}`, fallback ? "y" : "n");
|
|
525
|
+
return /^y/i.test(answer);
|
|
526
|
+
}
|
|
527
|
+
var visible = (text) => text.replace(/\u001b\[[0-9;]*m/g, "").length;
|
|
528
|
+
function renderOptions(options, active, interactive) {
|
|
529
|
+
const columns = stdout.columns || 80;
|
|
530
|
+
let rows = 0;
|
|
531
|
+
options.forEach((o, i) => {
|
|
532
|
+
const chosen = interactive && i === active;
|
|
533
|
+
const bullet = chosen ? icon.arrow : " ";
|
|
534
|
+
const number = interactive ? "" : style.grey(`${i + 1}) `);
|
|
535
|
+
const hint = o.disabled ? `\u2014 ${o.disabled}` : o.note ? `\u2014 ${o.note}` : "";
|
|
536
|
+
const label = o.disabled ? style.grey(o.label) : chosen ? style.green(o.label) : o.label;
|
|
537
|
+
const line = ` ${bullet} ${number}${label}${hint ? style.grey(` ${hint}`) : ""}`;
|
|
538
|
+
stdout.write(`${line}${clearLine}
|
|
539
|
+
`);
|
|
540
|
+
rows += Math.max(1, Math.ceil(visible(line) / columns));
|
|
541
|
+
});
|
|
542
|
+
return rows;
|
|
543
|
+
}
|
|
544
|
+
async function choose(question, options) {
|
|
545
|
+
const pickable = options.map((o, i) => o.disabled ? -1 : i).filter((i) => i >= 0);
|
|
546
|
+
if (!pickable.length) throw new Error(`No option available for: ${question}`);
|
|
547
|
+
stdout.write(` ${style.green("?")} ${style.bold(question)}
|
|
548
|
+
`);
|
|
549
|
+
if (!stdin.isTTY) {
|
|
550
|
+
renderOptions(options, -1, false);
|
|
551
|
+
const answer = await ask("choice:", String(pickable[0] + 1), {
|
|
552
|
+
validate: (v) => {
|
|
553
|
+
const picked = options[Number(v) - 1] || options.find((o) => o.id === v);
|
|
554
|
+
if (!picked) return "Pick one of the numbers above.";
|
|
555
|
+
if (picked.disabled) return `${picked.label} is ${picked.disabled}. Pick another.`;
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
return (options[Number(answer) - 1] || options.find((o) => o.id === answer)).id;
|
|
559
|
+
}
|
|
560
|
+
const hint = style.grey(" \u2191\u2193 move \xB7 enter select");
|
|
561
|
+
let active = pickable[0];
|
|
562
|
+
let rows = renderOptions(options, active, true) + 1;
|
|
563
|
+
const canRedraw = rows < (stdout.rows || 24) - 1;
|
|
564
|
+
rl?.pause();
|
|
565
|
+
const wasRaw = !!stdin.isRaw;
|
|
566
|
+
stdin.setRawMode(true);
|
|
567
|
+
stdin.resume();
|
|
568
|
+
stdout.write(`${hint}
|
|
569
|
+
`);
|
|
570
|
+
const redraw = () => {
|
|
571
|
+
if (!canRedraw) return;
|
|
572
|
+
stdout.write(up(rows));
|
|
573
|
+
rows = renderOptions(options, active, true) + 1;
|
|
574
|
+
stdout.write(`${hint}
|
|
575
|
+
`);
|
|
576
|
+
};
|
|
577
|
+
const move = (delta) => {
|
|
578
|
+
const here = pickable.indexOf(active);
|
|
579
|
+
active = pickable[(here + delta + pickable.length) % pickable.length];
|
|
580
|
+
redraw();
|
|
370
581
|
};
|
|
371
582
|
try {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
583
|
+
return await new Promise((resolve, reject) => {
|
|
584
|
+
const cleanup = () => {
|
|
585
|
+
stdin.off("data", onData);
|
|
586
|
+
if (!wasRaw) stdin.setRawMode(false);
|
|
587
|
+
rl?.resume();
|
|
588
|
+
};
|
|
589
|
+
const onData = (buf) => {
|
|
590
|
+
const key = buf.toString();
|
|
591
|
+
if (key === "") {
|
|
592
|
+
cleanup();
|
|
593
|
+
stdout.write("\n");
|
|
594
|
+
reject(new InputError("Cancelled."));
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
if (key === `${ESC}[A` || key === "k") return move(-1);
|
|
598
|
+
if (key === `${ESC}[B` || key === "j") return move(1);
|
|
599
|
+
if (/^[1-9]$/.test(key)) {
|
|
600
|
+
const target = Number(key) - 1;
|
|
601
|
+
if (options[target] && !options[target].disabled) {
|
|
602
|
+
active = target;
|
|
603
|
+
redraw();
|
|
604
|
+
}
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
if (key === "\r" || key === "\n") {
|
|
608
|
+
cleanup();
|
|
609
|
+
if (canRedraw) {
|
|
610
|
+
stdout.write(up(rows));
|
|
611
|
+
for (let i = 0; i < rows; i++) stdout.write(`${clearLine}
|
|
612
|
+
`);
|
|
613
|
+
stdout.write(up(rows));
|
|
614
|
+
stdout.write(` ${icon.arrow} ${style.green(options[active].label)}${clearLine}
|
|
615
|
+
`);
|
|
616
|
+
} else {
|
|
617
|
+
stdout.write(` ${icon.arrow} ${style.green(options[active].label)}
|
|
618
|
+
`);
|
|
619
|
+
}
|
|
620
|
+
resolve(options[active].id);
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
stdin.on("data", onData);
|
|
624
|
+
});
|
|
375
625
|
} finally {
|
|
376
|
-
|
|
626
|
+
if (!wasRaw && stdin.isTTY && stdin.isRaw) stdin.setRawMode(false);
|
|
377
627
|
}
|
|
378
628
|
}
|
|
379
|
-
|
|
629
|
+
|
|
630
|
+
// src/install.ts
|
|
631
|
+
import { randomBytes } from "crypto";
|
|
632
|
+
import { existsSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2, chmodSync as chmodSync2, statSync } from "fs";
|
|
633
|
+
import { join as join2, dirname as dirname2 } from "path";
|
|
634
|
+
import { spawn } from "child_process";
|
|
635
|
+
import { createConnection } from "net";
|
|
636
|
+
var ANSWERS_FILE = "oya-install.json";
|
|
637
|
+
var token = () => randomBytes(32).toString("base64url");
|
|
638
|
+
var SECRET_KEYS = /* @__PURE__ */ new Set([
|
|
639
|
+
"OYA_PROFILE_SECRET",
|
|
640
|
+
"OYA_OPERATOR_TOKEN",
|
|
641
|
+
"OYA_METRICS_TOKEN",
|
|
642
|
+
"API_KEYS",
|
|
643
|
+
"FLEET_TOKEN",
|
|
644
|
+
"OYA_CLUSTER_SECRET",
|
|
645
|
+
"DATABASE_URL",
|
|
646
|
+
"SUPABASE_SERVICE_KEY",
|
|
647
|
+
"OPENAI_API_KEY",
|
|
648
|
+
"OYA_CLOUD_API_KEY",
|
|
649
|
+
"ANCHOR_API_KEY",
|
|
650
|
+
"BROWSERBASE_API_KEY",
|
|
651
|
+
"STEEL_API_KEY",
|
|
652
|
+
"BROWSERUSE_API_KEY",
|
|
653
|
+
"OYA_CAPTCHA_API_KEY",
|
|
654
|
+
"OYA_CDP_WS_URL"
|
|
655
|
+
]);
|
|
656
|
+
function run(cmd, args2, opts = { cwd: process.cwd() }) {
|
|
657
|
+
return new Promise((resolve, reject) => {
|
|
658
|
+
const child = spawn(cmd, args2, { cwd: opts.cwd, stdio: opts.quiet ? "ignore" : "inherit" });
|
|
659
|
+
child.on("error", reject);
|
|
660
|
+
child.on("close", (code) => code === 0 ? resolve() : reject(new Error(`${cmd} ${args2.join(" ")} exited ${code}`)));
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
async function capture(cmd, args2, cwd = process.cwd()) {
|
|
664
|
+
return new Promise((resolve) => {
|
|
665
|
+
const child = spawn(cmd, args2, { cwd, stdio: ["ignore", "pipe", "ignore"] });
|
|
666
|
+
let out2 = "";
|
|
667
|
+
child.stdout.on("data", (d) => {
|
|
668
|
+
out2 += d;
|
|
669
|
+
});
|
|
670
|
+
child.on("error", () => resolve(null));
|
|
671
|
+
child.on("close", (code) => resolve(code === 0 ? out2.trim() : null));
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
var has = async (cmd) => await capture(cmd, ["--version"]) !== null;
|
|
675
|
+
function atLeast(version, major, minor) {
|
|
676
|
+
const m = /(\d+)\.(\d+)/.exec(version || "");
|
|
677
|
+
if (!m) return false;
|
|
678
|
+
return Number(m[1]) > major || Number(m[1]) === major && Number(m[2]) >= minor;
|
|
679
|
+
}
|
|
680
|
+
function portFree(port) {
|
|
681
|
+
return new Promise((resolve) => {
|
|
682
|
+
const socket = createConnection({ port, host: "127.0.0.1" }).on("connect", () => {
|
|
683
|
+
socket.destroy();
|
|
684
|
+
resolve(false);
|
|
685
|
+
}).on("error", () => resolve(true));
|
|
686
|
+
setTimeout(() => {
|
|
687
|
+
socket.destroy();
|
|
688
|
+
resolve(true);
|
|
689
|
+
}, 700);
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
function findRepoRoot(from = process.cwd()) {
|
|
693
|
+
let dir = from;
|
|
694
|
+
for (let i = 0; i < 8; i++) {
|
|
695
|
+
if (existsSync(join2(dir, "docker-compose.yml")) && existsSync(join2(dir, "server", "src", "index.js"))) return dir;
|
|
696
|
+
const up2 = dirname2(dir);
|
|
697
|
+
if (up2 === dir) break;
|
|
698
|
+
dir = up2;
|
|
699
|
+
}
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
async function locateRepo() {
|
|
703
|
+
const found = findRepoRoot();
|
|
704
|
+
if (found) return found;
|
|
705
|
+
console.log("\nThis does not look like an oya-browser checkout.");
|
|
706
|
+
const where = await ask("Clone it to:", join2(process.cwd(), "oya-browser"));
|
|
707
|
+
if (existsSync(where)) throw new Error(`${where} already exists`);
|
|
708
|
+
await run("git", ["clone", "--depth", "1", "https://github.com/OyadotAI/oya-browser.git", where], { cwd: process.cwd() });
|
|
709
|
+
return where;
|
|
710
|
+
}
|
|
711
|
+
function readEnv(path) {
|
|
712
|
+
if (!existsSync(path)) return {};
|
|
713
|
+
const out2 = {};
|
|
714
|
+
for (const line of readFileSync2(path, "utf8").split("\n")) {
|
|
715
|
+
const m = /^([A-Z0-9_]+)=(.*)$/.exec(line.trim());
|
|
716
|
+
if (m) out2[m[1]] = m[2];
|
|
717
|
+
}
|
|
718
|
+
return out2;
|
|
719
|
+
}
|
|
720
|
+
var SECTIONS = [
|
|
721
|
+
["Server", ["PORT", "OYA_UI_MODE", "OYA_DATA_DIR"]],
|
|
722
|
+
["Tenant keys \u2014 each is an identity; add more from the dashboard", ["API_KEYS"]],
|
|
723
|
+
["Host operations: /metrics, drain, fleet provision", ["OYA_OPERATOR_TOKEN", "OYA_METRICS_TOKEN"]],
|
|
724
|
+
["Credentials at rest. Lose this and every stored cookie, proxy and TOTP seed is unreadable", ["OYA_PROFILE_SECRET"]],
|
|
725
|
+
["Database", ["SUPABASE_URL", "SUPABASE_SERVICE_KEY", "DATABASE_URL", "OYA_RECORDING_BUCKET"]],
|
|
726
|
+
["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"]],
|
|
727
|
+
["Browser fleet", ["OYA_BROWSER_PROVIDER", "OYA_FLEET_RUNTIME", "OYA_PUBLIC_WS_URL", "OYA_CDP_WS_URL"]],
|
|
728
|
+
["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"]],
|
|
729
|
+
["Oya Cloud", ["OYA_CLOUD_API_KEY", "OYA_CLOUD_SNAPSHOT", "OYA_CLOUD_TARGET"]],
|
|
730
|
+
["Hosted CDP vendors", ["BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID", "STEEL_API_KEY", "ANCHOR_API_KEY", "BROWSERUSE_API_KEY"]],
|
|
731
|
+
["CAPTCHA", ["OYA_CAPTCHA_PROVIDER", "OYA_CAPTCHA_API_KEY"]],
|
|
732
|
+
["Multi-replica. One SQLite control database allows exactly one writer", ["OYA_INSTANCE_URL", "OYA_CLUSTER_SECRET"]]
|
|
733
|
+
];
|
|
734
|
+
function renderEnv(values, extra) {
|
|
735
|
+
const lines = ["# Written by `oya install`. Re-run it, or edit by hand \u2014 both are fine.", ""];
|
|
736
|
+
const placed = /* @__PURE__ */ new Set();
|
|
737
|
+
for (const [title, keys] of SECTIONS) {
|
|
738
|
+
const present = keys.filter((k) => values[k] !== void 0 && values[k] !== "");
|
|
739
|
+
if (!present.length) continue;
|
|
740
|
+
lines.push(`# \u2500\u2500 ${title} \u2500\u2500`);
|
|
741
|
+
for (const k of present) {
|
|
742
|
+
lines.push(`${k}=${values[k]}`);
|
|
743
|
+
placed.add(k);
|
|
744
|
+
}
|
|
745
|
+
lines.push("");
|
|
746
|
+
}
|
|
747
|
+
const leftover = Object.keys(values).filter((k) => !placed.has(k) && values[k]);
|
|
748
|
+
if (leftover.length) {
|
|
749
|
+
lines.push("# \u2500\u2500 Other \u2500\u2500");
|
|
750
|
+
for (const k of leftover) lines.push(`${k}=${values[k]}`);
|
|
751
|
+
lines.push("");
|
|
752
|
+
}
|
|
753
|
+
if (extra.length) lines.push(...extra, "");
|
|
754
|
+
return lines.join("\n");
|
|
755
|
+
}
|
|
756
|
+
function writeEnv(path, body) {
|
|
757
|
+
writeFileSync2(path, body, { mode: 384 });
|
|
758
|
+
chmodSync2(path, 384);
|
|
759
|
+
}
|
|
760
|
+
var LLM_PRESETS = {
|
|
761
|
+
anthropic: { base: "https://api.anthropic.com/v1", model: "claude-sonnet-4-5", label: "Anthropic (Claude)" },
|
|
762
|
+
openai: { base: "https://api.openai.com/v1", model: "gpt-4o-mini", label: "OpenAI" }
|
|
763
|
+
};
|
|
764
|
+
async function askLlm() {
|
|
765
|
+
step("Agent LLM", "Drives `oya ask` and the chat API.");
|
|
766
|
+
note("CDP, the SDK and MCP all work without one.");
|
|
767
|
+
const provider = await choose("Which LLM should agents use?", [
|
|
768
|
+
{ id: "anthropic", label: "Anthropic (Claude)" },
|
|
769
|
+
{ id: "openai", label: "OpenAI" },
|
|
770
|
+
{ id: "compatible", label: "An OpenAI-compatible endpoint", note: "OpenRouter, Together, Groq, Azure" },
|
|
771
|
+
{ id: "local", label: "A local model", note: "Ollama, vLLM, LM Studio" },
|
|
772
|
+
{ id: "skip", label: "Skip", note: "no agent control; add it later with `oya config`" }
|
|
773
|
+
]);
|
|
774
|
+
if (provider === "skip") return { answers: { provider, baseUrl: "", model: "" }, key: "" };
|
|
775
|
+
const preset = LLM_PRESETS[provider];
|
|
776
|
+
const baseUrl = preset ? preset.base : (await ask("Base URL:", provider === "local" ? "http://localhost:11434/v1" : "https://openrouter.ai/api/v1")).replace(/\/+$/, "");
|
|
777
|
+
const model = await ask("Model:", preset?.model || (provider === "local" ? "qwen2.5" : ""));
|
|
778
|
+
for (; ; ) {
|
|
779
|
+
const key = await askSecret(provider === "local" ? "API key (blank if none):" : "API key:");
|
|
780
|
+
if (!key) return { answers: { provider, baseUrl, model }, key: "" };
|
|
781
|
+
const problem = await verifyLlm(baseUrl, key, model);
|
|
782
|
+
if (!problem) return { answers: { provider, baseUrl, model }, key };
|
|
783
|
+
warn(problem);
|
|
784
|
+
const what = await choose("What now?", [
|
|
785
|
+
{ id: "retry", label: "Enter the key again" },
|
|
786
|
+
{ id: "skip", label: "Continue without an LLM", note: "add it later with `oya config`" }
|
|
787
|
+
]);
|
|
788
|
+
if (what === "skip") return { answers: { provider: "skip", baseUrl: "", model: "" }, key: "" };
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
async function verifyLlm(baseUrl, key, model) {
|
|
792
|
+
const spin = spinner("checking the key");
|
|
793
|
+
try {
|
|
794
|
+
const anthropic = /(^|\.)anthropic\.com$/i.test(new URL(baseUrl).hostname);
|
|
795
|
+
const headers = anthropic ? { "x-api-key": key, "anthropic-version": "2023-06-01" } : { Authorization: `Bearer ${key}` };
|
|
796
|
+
const res = await fetch(`${baseUrl}/models`, { headers });
|
|
797
|
+
if (res.status === 401 || res.status === 403) {
|
|
798
|
+
spin.fail();
|
|
799
|
+
return `${baseUrl} rejected that key (HTTP ${res.status}).`;
|
|
800
|
+
}
|
|
801
|
+
if (!res.ok) {
|
|
802
|
+
spin.fail(`could not verify (HTTP ${res.status}) \u2014 continuing anyway`);
|
|
803
|
+
return null;
|
|
804
|
+
}
|
|
805
|
+
const body = await res.json().catch(() => null);
|
|
806
|
+
const ids = (body?.data || []).map((m) => m.id);
|
|
807
|
+
if (model && ids.length && !ids.includes(model)) {
|
|
808
|
+
spin.done(`key works, though "${model}" is not in this endpoint's list`);
|
|
809
|
+
return null;
|
|
810
|
+
}
|
|
811
|
+
spin.done("ok");
|
|
812
|
+
return null;
|
|
813
|
+
} catch (err) {
|
|
814
|
+
spin.fail(`could not reach it (${err.message}) \u2014 continuing anyway`);
|
|
815
|
+
return null;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
var hostOptions = [
|
|
819
|
+
{ id: "docker", label: "Docker on this machine", note: "one command, no cluster" },
|
|
820
|
+
{ id: "k8s", label: "Kubernetes", disabled: "not in this build yet" },
|
|
821
|
+
{ id: "ecs", label: "Amazon ECS", disabled: "not in this build yet" },
|
|
822
|
+
{ id: "fly", label: "Fly.io", disabled: "coming soon" },
|
|
823
|
+
{ id: "exe", label: "exe.dev", disabled: "coming soon" }
|
|
824
|
+
];
|
|
825
|
+
var dbOptions = (host) => [
|
|
826
|
+
{ id: "sqlite", label: "SQLite", note: "zero config, one replica, API keys only" },
|
|
827
|
+
{ id: "supabase", label: "Supabase", note: "adds email sign-in and the dashboard" },
|
|
828
|
+
{ id: "postgres", label: "Postgres", note: "many replicas; needs psql for migrations" }
|
|
829
|
+
];
|
|
830
|
+
var fleetOptions = [
|
|
831
|
+
{ id: "docker-workers", label: "Docker browser workers here", note: "always-on containers, no daemon access needed" },
|
|
832
|
+
{ id: "oya-selfhosted", label: "Governed Docker, one container per session", note: "policies and budgets; needs Docker socket access" },
|
|
833
|
+
{ id: "oya-cloud", label: "Oya Cloud", note: "managed sandboxes, nothing to run yourself" },
|
|
834
|
+
{ id: "browserbase", label: "Browserbase" },
|
|
835
|
+
{ id: "steel", label: "Steel" },
|
|
836
|
+
{ id: "anchor", label: "Anchor" },
|
|
837
|
+
{ id: "browseruse", label: "Browser Use Cloud" },
|
|
838
|
+
{ id: "cdp", label: "Your own Chrome over CDP" },
|
|
839
|
+
{ id: "k8s", label: "Kubernetes fleet, one pod per session", note: "needs kubectl and a cluster" },
|
|
840
|
+
{ id: "ecs", label: "ECS fleet, one task per session", disabled: "not in this build yet" }
|
|
841
|
+
];
|
|
842
|
+
var VENDOR_KEYS = {
|
|
843
|
+
browserbase: ["BROWSERBASE_API_KEY", "BROWSERBASE_PROJECT_ID"],
|
|
844
|
+
steel: ["STEEL_API_KEY"],
|
|
845
|
+
anchor: ["ANCHOR_API_KEY"],
|
|
846
|
+
browseruse: ["BROWSERUSE_API_KEY"]
|
|
847
|
+
};
|
|
848
|
+
async function interview(preset, replay) {
|
|
849
|
+
const secrets = {};
|
|
850
|
+
let migrateUrl = "";
|
|
851
|
+
const secretFor = async (field, hidden = true) => {
|
|
852
|
+
if (replay) return process.env[field] || "";
|
|
853
|
+
return hidden ? askSecret(`${field}:`) : ask(`${field}:`);
|
|
854
|
+
};
|
|
855
|
+
if (!preset.host) step("Control plane", "Where the server itself runs.");
|
|
856
|
+
const host = preset.host || await choose("Where should the control plane run?", hostOptions);
|
|
857
|
+
if (!preset.database) step("Database", "What holds sessions, keys and personas.");
|
|
858
|
+
const database = preset.database || await choose("Which database?", dbOptions(host));
|
|
859
|
+
if (database === "sqlite") {
|
|
860
|
+
note("SQLite means API-key auth only \u2014 email sign-in needs Supabase.");
|
|
861
|
+
note("The dashboard still works: sign in with the API key printed at the end.");
|
|
862
|
+
}
|
|
863
|
+
const isPostgresUrl = (v) => {
|
|
864
|
+
if (!v) return;
|
|
865
|
+
try {
|
|
866
|
+
const url = new URL(v);
|
|
867
|
+
if (!["postgres:", "postgresql:"].includes(url.protocol)) return "Must start with postgres://";
|
|
868
|
+
} catch {
|
|
869
|
+
return "That is not a connection string \u2014 postgres://user:pass@host:5432/db";
|
|
870
|
+
}
|
|
871
|
+
};
|
|
872
|
+
if (database === "supabase") {
|
|
873
|
+
secrets.SUPABASE_URL = replay ? process.env.SUPABASE_URL || "" : await ask("Supabase project URL:");
|
|
874
|
+
secrets.SUPABASE_SERVICE_KEY = await secretFor("SUPABASE_SERVICE_KEY");
|
|
875
|
+
if (!replay) {
|
|
876
|
+
note("Migrations need the Postgres connection string, not the API URL.");
|
|
877
|
+
note("Leave it blank to apply them yourself later with `make migrate`.");
|
|
878
|
+
migrateUrl = await askSecret("Postgres connection string (optional):", { validate: isPostgresUrl });
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
if (database === "postgres") {
|
|
882
|
+
secrets.DATABASE_URL = replay ? process.env.DATABASE_URL || "" : await askSecret("Postgres connection string:", {
|
|
883
|
+
validate: (v) => !v ? "Required for a Postgres deployment." : isPostgresUrl(v)
|
|
884
|
+
});
|
|
885
|
+
migrateUrl = secrets.DATABASE_URL;
|
|
886
|
+
}
|
|
887
|
+
if (!preset.fleet) step("Browsers", "What actually runs Chrome.");
|
|
888
|
+
const fleet = preset.fleet || await choose("Where should browsers run?", fleetOptions);
|
|
889
|
+
let workers = preset.workers ?? 0;
|
|
890
|
+
if (fleet === "docker-workers" && preset.workers === void 0) {
|
|
891
|
+
workers = Number(await ask("How many browser workers?", "2", {
|
|
892
|
+
validate: (v) => /^[1-9][0-9]*$/.test(v) ? void 0 : "Enter a whole number of 1 or more."
|
|
893
|
+
}));
|
|
894
|
+
}
|
|
895
|
+
for (const field of VENDOR_KEYS[fleet] || []) {
|
|
896
|
+
secrets[field] = await secretFor(field, !field.endsWith("PROJECT_ID"));
|
|
897
|
+
}
|
|
898
|
+
if (fleet === "oya-cloud") secrets.OYA_CLOUD_API_KEY = await secretFor("OYA_CLOUD_API_KEY");
|
|
899
|
+
if (fleet === "cdp") secrets.OYA_CDP_WS_URL = await secretFor("OYA_CDP_WS_URL");
|
|
900
|
+
let k8sFleet = preset.k8sFleet;
|
|
901
|
+
if (fleet === "k8s" && !k8sFleet) {
|
|
902
|
+
step("Kubernetes fleet", "One pod per session.");
|
|
903
|
+
note("The image must be digest-pinned: a tag can move between verification and scheduling.");
|
|
904
|
+
k8sFleet = {
|
|
905
|
+
namespace: await ask("Namespace for browser pods:", "oya-browsers"),
|
|
906
|
+
image: await ask("Browser image:", "ghcr.io/oyadotai/oya-browser:latest"),
|
|
907
|
+
controlUrl: await ask("Control-plane WebSocket URL, as reachable from the cluster:", "ws://oya-server.oya-browser.svc:3100/ws"),
|
|
908
|
+
proxyUrl: await ask("Egress proxy URL, as reachable from the cluster:", "http://oya-server.oya-browser.svc:3128")
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
const llm = preset.llm ? { answers: preset.llm, key: replay ? process.env.OPENAI_API_KEY || "" : "" } : await askLlm();
|
|
912
|
+
if (llm.key) secrets.OPENAI_API_KEY = llm.key;
|
|
913
|
+
step("Address", "Where clients and browsers reach this control plane.");
|
|
914
|
+
const publicUrl = (preset.publicUrl || await ask("Public URL of this control plane:", "http://localhost:3100", {
|
|
915
|
+
validate: (v) => {
|
|
916
|
+
try {
|
|
917
|
+
const url = new URL(v);
|
|
918
|
+
if (!["http:", "https:"].includes(url.protocol)) return "Must be http:// or https://";
|
|
919
|
+
} catch {
|
|
920
|
+
return "That is not a URL \u2014 try http://localhost:3100";
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
})).replace(/\/+$/, "");
|
|
924
|
+
const optional = preset.optional || { captcha: "", recordingBucket: "", metrics: false };
|
|
925
|
+
if (!preset.optional) {
|
|
926
|
+
step("Optional services", "Everything here has a sensible default.");
|
|
927
|
+
const more = await choose("Configure optional services now?", [
|
|
928
|
+
{ id: "no", label: "No", note: "change any of it later" },
|
|
929
|
+
{ id: "yes", label: "Yes", note: "CAPTCHA solver, recording storage, metrics" }
|
|
930
|
+
]);
|
|
931
|
+
if (more === "yes") {
|
|
932
|
+
optional.captcha = await choose("Solve CAPTCHAs automatically?", [
|
|
933
|
+
{ id: "", label: "No", note: "hosted vendors still solve natively" },
|
|
934
|
+
{ id: "capsolver", label: "CapSolver" },
|
|
935
|
+
{ id: "2captcha", label: "2Captcha" }
|
|
936
|
+
]);
|
|
937
|
+
if (optional.captcha) secrets.OYA_CAPTCHA_API_KEY = await secretFor("OYA_CAPTCHA_API_KEY");
|
|
938
|
+
if (database === "supabase") optional.recordingBucket = await ask("Private Storage bucket for recordings (blank to keep them on disk):", "");
|
|
939
|
+
optional.metrics = await choose("Expose /metrics to a Prometheus scraper?", [
|
|
940
|
+
{ id: "no", label: "No" },
|
|
941
|
+
{ id: "yes", label: "Yes", note: "generates a scrape token" }
|
|
942
|
+
]) === "yes";
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
return {
|
|
946
|
+
answers: { version: 1, host, database, fleet, workers, k8sFleet, llm: llm.answers, publicUrl, optional },
|
|
947
|
+
secrets,
|
|
948
|
+
migrateUrl
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
async function preflight(root, a) {
|
|
952
|
+
const checks = [];
|
|
953
|
+
const needsDocker = a.host === "docker" || a.fleet === "docker-workers" || a.fleet === "oya-selfhosted";
|
|
954
|
+
if (needsDocker) {
|
|
955
|
+
const version = await capture("docker", ["--version"]);
|
|
956
|
+
checks.push({ ok: !!version, label: "docker", detail: version || "not found", fatal: true });
|
|
957
|
+
const daemon = await capture("docker", ["info", "--format", "{{.ID}}"]);
|
|
958
|
+
checks.push({ ok: !!daemon, label: "docker daemon reachable", detail: daemon ? "ok" : "not running", fatal: true });
|
|
959
|
+
const compose = await capture("docker", ["compose", "version", "--short"]);
|
|
960
|
+
checks.push({ ok: atLeast(compose, 2, 24), label: "docker compose \u2265 2.24", detail: compose || "not found", fatal: !compose });
|
|
961
|
+
}
|
|
962
|
+
if (a.host !== "docker") {
|
|
963
|
+
checks.push({ ok: atLeast(process.versions.node, 22, 13), label: "node \u2265 22.13 (node:sqlite)", detail: process.versions.node, fatal: true });
|
|
964
|
+
}
|
|
965
|
+
if (a.fleet === "k8s" || a.host === "k8s") {
|
|
966
|
+
const version = await capture("kubectl", ["version", "--client=true", "-o", "json"]);
|
|
967
|
+
checks.push({ ok: !!version, label: "kubectl", detail: version ? "ok" : "not found", fatal: true });
|
|
968
|
+
const cluster = await capture("kubectl", ["get", "namespace", "kube-system", "-o", "name"]);
|
|
969
|
+
checks.push({ ok: !!cluster, label: "cluster reachable", detail: cluster || "kubectl cannot reach a cluster", fatal: true });
|
|
970
|
+
}
|
|
971
|
+
if (a.database === "postgres" || a.database === "supabase") {
|
|
972
|
+
checks.push({ ok: await has("psql"), label: "psql (to apply migrations)", detail: "from libpq/postgresql-client", fatal: true });
|
|
973
|
+
}
|
|
974
|
+
const port = Number(new URL(a.publicUrl).port || 3100);
|
|
975
|
+
const free = await portFree(port);
|
|
976
|
+
checks.push({ ok: free, label: `port ${port} free`, detail: free ? "ok" : "something is already listening" });
|
|
977
|
+
const envPath = join2(root, ".env");
|
|
978
|
+
if (existsSync(envPath)) {
|
|
979
|
+
const mode = (statSync(envPath).mode & 511).toString(8);
|
|
980
|
+
checks.push({ ok: true, label: ".env exists \u2014 keeping its OYA_PROFILE_SECRET and API keys", detail: `mode ${mode}` });
|
|
981
|
+
}
|
|
982
|
+
return checks;
|
|
983
|
+
}
|
|
984
|
+
function buildEnv(a, secrets, existing) {
|
|
985
|
+
const v = {};
|
|
986
|
+
const port = String(Number(new URL(a.publicUrl).port || 3100));
|
|
987
|
+
v.OYA_PROFILE_SECRET = existing.OYA_PROFILE_SECRET || token();
|
|
988
|
+
const apiKey = existing.API_KEYS?.split(",")[0] || `oya_${token()}`;
|
|
989
|
+
v.API_KEYS = existing.API_KEYS || apiKey;
|
|
990
|
+
v.OYA_OPERATOR_TOKEN = existing.OYA_OPERATOR_TOKEN || token();
|
|
991
|
+
if (a.host !== "docker") v.PORT = port;
|
|
992
|
+
Object.assign(v, secrets);
|
|
993
|
+
if (a.llm.provider !== "skip") {
|
|
994
|
+
v.OPENAI_BASE_URL = a.llm.baseUrl;
|
|
995
|
+
v.CHAT_MODEL = a.llm.model;
|
|
996
|
+
}
|
|
997
|
+
const wsUrl = a.publicUrl.replace(/^http/, "ws") + "/ws";
|
|
998
|
+
if (a.fleet === "oya-cloud") {
|
|
999
|
+
v.OYA_BROWSER_PROVIDER = "oya-cloud";
|
|
1000
|
+
v.OYA_PUBLIC_WS_URL = wsUrl;
|
|
1001
|
+
v.OYA_CLOUD_SNAPSHOT = existing.OYA_CLOUD_SNAPSHOT || existing.DAYTONA_SNAPSHOT || "oya-browser";
|
|
1002
|
+
} else if (a.fleet === "oya-selfhosted") {
|
|
1003
|
+
v.OYA_BROWSER_PROVIDER = "oya-selfhosted";
|
|
1004
|
+
v.OYA_FLEET_RUNTIME = "docker";
|
|
1005
|
+
v.OYA_MANAGED_NETWORK = "oya-browsers";
|
|
1006
|
+
v.OYA_MANAGED_IMAGE = "oya-browser:local";
|
|
1007
|
+
v.OYA_MANAGED_CONTROL_URL = a.host === "docker" ? "ws://server:3100/ws" : wsUrl;
|
|
1008
|
+
v.OYA_EGRESS_PORT = "3128";
|
|
1009
|
+
v.OYA_EGRESS_HOST = "0.0.0.0";
|
|
1010
|
+
v.OYA_MANAGED_PROXY_URL = a.host === "docker" ? "http://server:3128" : "http://127.0.0.1:3128";
|
|
1011
|
+
v.OYA_MANAGED_REGION = "local";
|
|
1012
|
+
} else if (a.fleet === "k8s" && a.k8sFleet) {
|
|
1013
|
+
v.OYA_BROWSER_PROVIDER = "oya-selfhosted";
|
|
1014
|
+
v.OYA_FLEET_RUNTIME = "k8s";
|
|
1015
|
+
v.OYA_K8S_NAMESPACE = a.k8sFleet.namespace;
|
|
1016
|
+
v.OYA_MANAGED_NETWORK = "oya-governed-egress";
|
|
1017
|
+
v.OYA_MANAGED_IMAGE = a.k8sFleet.image;
|
|
1018
|
+
v.OYA_MANAGED_CONTROL_URL = a.k8sFleet.controlUrl;
|
|
1019
|
+
v.OYA_MANAGED_PROXY_URL = a.k8sFleet.proxyUrl;
|
|
1020
|
+
v.OYA_MANAGED_REGION = a.k8sFleet.namespace;
|
|
1021
|
+
v.OYA_EGRESS_PORT = "3128";
|
|
1022
|
+
v.OYA_EGRESS_HOST = "0.0.0.0";
|
|
1023
|
+
} else if (a.fleet === "docker-workers") {
|
|
1024
|
+
v.OYA_BROWSER_PROVIDER = "oya-selfhosted";
|
|
1025
|
+
} else {
|
|
1026
|
+
v.OYA_BROWSER_PROVIDER = a.fleet;
|
|
1027
|
+
}
|
|
1028
|
+
if (a.optional.captcha) v.OYA_CAPTCHA_PROVIDER = a.optional.captcha;
|
|
1029
|
+
if (a.optional.recordingBucket) v.OYA_RECORDING_BUCKET = a.optional.recordingBucket;
|
|
1030
|
+
if (a.optional.metrics) v.OYA_METRICS_TOKEN = existing.OYA_METRICS_TOKEN || token();
|
|
1031
|
+
const extra = [
|
|
1032
|
+
"# \u2500\u2500 Shared fleet enrolment \u2500\u2500",
|
|
1033
|
+
"# Uncomment to let any browser presenting this token enrol itself.",
|
|
1034
|
+
`# FLEET_TOKEN=${token()}`
|
|
1035
|
+
];
|
|
1036
|
+
return { values: v, extra, apiKey };
|
|
1037
|
+
}
|
|
1038
|
+
async function priorState() {
|
|
1039
|
+
const volumes = await capture("docker", ["volume", "ls", "-q", "--filter", "name=oya-data"]) || "";
|
|
1040
|
+
for (const name of volumes.split("\n").map((v) => v.trim()).filter(Boolean)) {
|
|
1041
|
+
const listing = await capture("docker", ["run", "--rm", "-v", `${name}:/d`, "alpine", "ls", "/d"]);
|
|
1042
|
+
if (listing === null || /cookies\.json|personas\.json|profiles|\.secret/.test(listing)) return name;
|
|
1043
|
+
}
|
|
1044
|
+
return null;
|
|
1045
|
+
}
|
|
1046
|
+
async function resolveKekConflict(volume) {
|
|
1047
|
+
warn(`${volume} already holds encrypted data from an earlier install.`);
|
|
1048
|
+
note("Cookies, proxy credentials and TOTP seeds in it were sealed with that");
|
|
1049
|
+
note("install's OYA_PROFILE_SECRET. A new secret cannot open them, and the");
|
|
1050
|
+
note("server refuses to start rather than silently losing them.");
|
|
1051
|
+
const choice = await choose("How should that be handled?", [
|
|
1052
|
+
{ id: "paste", label: "I have the previous OYA_PROFILE_SECRET", note: "reuse it and keep the data" },
|
|
1053
|
+
{ id: "reset", label: "Delete the old data and start clean", note: "personas, cookies and sessions are lost" },
|
|
1054
|
+
{ id: "abort", label: "Stop so I can go and find it" }
|
|
1055
|
+
]);
|
|
1056
|
+
if (choice === "paste") {
|
|
1057
|
+
return askSecret("Previous OYA_PROFILE_SECRET:", {
|
|
1058
|
+
validate: (v) => v ? void 0 : "Paste the secret, or pick another option."
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
if (choice === "abort") throw new InputError("Stopped without changing anything.");
|
|
1062
|
+
if (!await confirm(`Delete ${volume} and everything stored in it?`, false)) {
|
|
1063
|
+
throw new InputError("Stopped without changing anything.");
|
|
1064
|
+
}
|
|
1065
|
+
await run("docker", ["compose", "down", "-v"], { cwd: process.cwd(), quiet: true }).catch(() => {
|
|
1066
|
+
});
|
|
1067
|
+
await run("docker", ["volume", "rm", "-f", volume], { cwd: process.cwd(), quiet: true }).catch(() => {
|
|
1068
|
+
});
|
|
1069
|
+
success(`removed ${volume}`);
|
|
1070
|
+
return null;
|
|
1071
|
+
}
|
|
1072
|
+
async function waitReady(url, timeoutMs = 18e4) {
|
|
1073
|
+
const deadline = Date.now() + timeoutMs;
|
|
1074
|
+
process.stdout.write(" waiting for /readyz\u2026 ");
|
|
1075
|
+
while (Date.now() < deadline) {
|
|
1076
|
+
try {
|
|
1077
|
+
const res = await fetch(`${url}/readyz`);
|
|
1078
|
+
if (res.ok) {
|
|
1079
|
+
console.log("ready");
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
} catch {
|
|
1083
|
+
}
|
|
1084
|
+
await new Promise((r) => setTimeout(r, 2e3));
|
|
1085
|
+
}
|
|
1086
|
+
console.log("timed out");
|
|
1087
|
+
throw new Error(`${url}/readyz did not become ready. Check logs with: docker compose logs -f server`);
|
|
1088
|
+
}
|
|
1089
|
+
async function pinImage(reference) {
|
|
1090
|
+
if (/@sha256:[0-9a-f]{64}$/.test(reference)) return reference;
|
|
1091
|
+
process.stdout.write(` resolving ${reference} to a digest\u2026 `);
|
|
1092
|
+
const raw = await capture("docker", ["manifest", "inspect", "--verbose", reference]);
|
|
1093
|
+
const digest = raw && /"digest":\s*"(sha256:[0-9a-f]{64})"/.exec(raw)?.[1];
|
|
1094
|
+
if (!digest) {
|
|
1095
|
+
console.log("could not");
|
|
1096
|
+
throw new Error(
|
|
1097
|
+
`Kubernetes browsers must run a digest-pinned image, and ${reference} is a tag.
|
|
1098
|
+
Get the digest with: docker manifest inspect --verbose ${reference}
|
|
1099
|
+
then re-run with the image written as ${reference.split(":")[0]}@sha256:<digest>`
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
const pinned = `${reference.split("@")[0].replace(/:[^:/]+$/, "")}@${digest}`;
|
|
1103
|
+
console.log(digest.slice(0, 19) + "\u2026");
|
|
1104
|
+
return pinned;
|
|
1105
|
+
}
|
|
1106
|
+
function networkPolicy(namespace, name) {
|
|
1107
|
+
return JSON.stringify({
|
|
1108
|
+
apiVersion: "networking.k8s.io/v1",
|
|
1109
|
+
kind: "NetworkPolicy",
|
|
1110
|
+
metadata: { name, namespace },
|
|
1111
|
+
spec: {
|
|
1112
|
+
podSelector: { matchLabels: { app: "oya-managed-browser" } },
|
|
1113
|
+
policyTypes: ["Egress"],
|
|
1114
|
+
egress: [
|
|
1115
|
+
// DNS only, to the cluster resolver.
|
|
1116
|
+
{ ports: [{ protocol: "UDP", port: 53 }, { protocol: "TCP", port: 53 }] },
|
|
1117
|
+
// Everything else must go through the control plane's egress proxy, which
|
|
1118
|
+
// is what makes per-session egress policy and budgets enforceable.
|
|
1119
|
+
// `app: server` is the label k8s/base/server.yaml gives the control plane;
|
|
1120
|
+
// the empty namespaceSelector lets the browsers live in their own namespace.
|
|
1121
|
+
// A control plane OUTSIDE the cluster is not matched by any pod selector —
|
|
1122
|
+
// add an ipBlock rule here for that case, or the pods will resolve DNS and
|
|
1123
|
+
// then fail to enrol.
|
|
1124
|
+
{ to: [{ namespaceSelector: {}, podSelector: { matchLabels: { app: "server" } } }] }
|
|
1125
|
+
]
|
|
1126
|
+
}
|
|
1127
|
+
}, null, 2);
|
|
1128
|
+
}
|
|
1129
|
+
async function provisionK8sFleet(root, a) {
|
|
1130
|
+
const fleet = a.k8sFleet;
|
|
1131
|
+
const policyName = "oya-governed-egress";
|
|
1132
|
+
const pinned = await pinImage(fleet.image);
|
|
1133
|
+
const nsDoc = JSON.stringify({ apiVersion: "v1", kind: "Namespace", metadata: { name: fleet.namespace } });
|
|
1134
|
+
await pipeTo("kubectl", ["apply", "-f", "-"], nsDoc, root);
|
|
1135
|
+
const { writeFileSync: writeFileSync3, mkdirSync: mkdirSync2 } = await import("fs");
|
|
1136
|
+
mkdirSync2(join2(root, "k8s", "fleet"), { recursive: true });
|
|
1137
|
+
const policyPath = join2(root, "k8s", "fleet", "networkpolicy.json");
|
|
1138
|
+
writeFileSync3(policyPath, networkPolicy(fleet.namespace, policyName) + "\n");
|
|
1139
|
+
await pipeTo("kubectl", ["apply", "-f", "-"], networkPolicy(fleet.namespace, policyName), root);
|
|
1140
|
+
console.log(` applied namespace ${fleet.namespace} and NetworkPolicy ${policyName}`);
|
|
1141
|
+
console.log(` policy written to ${policyPath} \u2014 review it; it is your egress boundary`);
|
|
1142
|
+
return pinned;
|
|
1143
|
+
}
|
|
1144
|
+
function pipeTo(cmd, args2, stdin2, cwd) {
|
|
1145
|
+
return new Promise((resolve, reject) => {
|
|
1146
|
+
const child = spawn(cmd, args2, { cwd, stdio: ["pipe", "inherit", "inherit"] });
|
|
1147
|
+
child.on("error", reject);
|
|
1148
|
+
child.on("close", (code) => code === 0 ? resolve() : reject(new Error(`${cmd} ${args2.join(" ")} exited ${code}`)));
|
|
1149
|
+
child.stdin.end(stdin2);
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
async function provisionDocker(root, a) {
|
|
1153
|
+
if (a.fleet === "oya-selfhosted") {
|
|
1154
|
+
const exists = await capture("docker", ["network", "inspect", "oya-browsers"]);
|
|
1155
|
+
if (!exists) await run("docker", ["network", "create", "--internal", "--driver", "bridge", "oya-browsers"], { cwd: root });
|
|
1156
|
+
console.log(" building the governed browser image\u2026");
|
|
1157
|
+
await run("docker", ["build", "-t", "oya-browser:local", "browser"], { cwd: root });
|
|
1158
|
+
console.log("\n Governed browsers need Docker daemon access from the server container.");
|
|
1159
|
+
console.log(" Add this to the server service in docker-compose.yml, then re-run `docker compose up -d`:");
|
|
1160
|
+
console.log(" volumes:\n - /var/run/docker.sock:/var/run/docker.sock");
|
|
1161
|
+
}
|
|
1162
|
+
const scale = a.fleet === "docker-workers" ? ["--scale", `browser=${a.workers}`] : ["--scale", "browser=0"];
|
|
1163
|
+
await run("docker", ["compose", "up", "-d", "--build", ...scale], { cwd: root });
|
|
1164
|
+
}
|
|
1165
|
+
async function cmdInstall(flags2) {
|
|
1166
|
+
const dryRun = flags2["dry-run"] === true;
|
|
1167
|
+
const root = await locateRepo();
|
|
1168
|
+
const configPath2 = typeof flags2.config === "string" ? flags2.config : null;
|
|
1169
|
+
const preset = configPath2 ? JSON.parse(readFileSync2(configPath2, "utf8")) : {};
|
|
1170
|
+
banner("Oya Browser \u2014 self-host install", root);
|
|
1171
|
+
if (configPath2) note(`replaying ${configPath2}`);
|
|
1172
|
+
steps(configPath2 ? 0 : 6);
|
|
1173
|
+
const { answers, secrets, migrateUrl } = await interview(preset, !!configPath2);
|
|
1174
|
+
const checks = await preflight(root, answers);
|
|
1175
|
+
console.log(`
|
|
1176
|
+
${style.bold(" Preflight")}`);
|
|
1177
|
+
for (const c of checks) {
|
|
1178
|
+
const mark = c.ok ? icon.ok : c.fatal ? icon.fail : icon.warn;
|
|
1179
|
+
console.log(` ${mark} ${c.label}${c.detail ? style.grey(` \u2014 ${c.detail}`) : ""}`);
|
|
1180
|
+
}
|
|
1181
|
+
const blocking = checks.filter((c) => !c.ok && c.fatal);
|
|
1182
|
+
if (blocking.length && !dryRun) throw new Error(`Install cannot continue: ${blocking.map((c) => c.label).join(", ")}`);
|
|
1183
|
+
if (answers.host === "docker" && /(localhost|127\.0\.0\.1)/.test(secrets.DATABASE_URL || "")) {
|
|
1184
|
+
warn("DATABASE_URL points at localhost, which inside a container means the container itself.");
|
|
1185
|
+
note("Use host.docker.internal (macOS, Windows) or the host IP so the server can reach it.");
|
|
1186
|
+
}
|
|
1187
|
+
const envPath = join2(root, ".env");
|
|
1188
|
+
const existing = readEnv(envPath);
|
|
1189
|
+
if (!existing.OYA_PROFILE_SECRET && answers.host === "docker" && !dryRun) {
|
|
1190
|
+
const volume = await priorState();
|
|
1191
|
+
if (volume) {
|
|
1192
|
+
const reused = await resolveKekConflict(volume);
|
|
1193
|
+
if (reused) existing.OYA_PROFILE_SECRET = reused;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
const { values, extra, apiKey } = buildEnv(answers, secrets, existing);
|
|
1197
|
+
const body = renderEnv(values, extra);
|
|
380
1198
|
console.log(`
|
|
381
|
-
${
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
console.log("
|
|
1199
|
+
${style.bold(" Plan")}`);
|
|
1200
|
+
const row = (k, v) => console.log(` ${style.grey(k.padEnd(12))}${v}`);
|
|
1201
|
+
row("control", `${answers.host} \xB7 ${answers.database}`);
|
|
1202
|
+
row("browsers", `${answers.fleet}${answers.workers ? ` \xD7${answers.workers}` : ""}`);
|
|
1203
|
+
row("llm", answers.llm.provider === "skip" ? style.grey("none") : `${answers.llm.model} ${style.grey(`@ ${answers.llm.baseUrl}`)}`);
|
|
1204
|
+
row("writes", `${envPath} ${style.grey("(0600)")}`);
|
|
1205
|
+
row("then", `docker compose up -d, wait for ${answers.publicUrl}/readyz`);
|
|
1206
|
+
if (dryRun) {
|
|
1207
|
+
console.log("\n--dry-run: nothing was written. Settings that would be applied:");
|
|
1208
|
+
for (const k of Object.keys(values)) {
|
|
1209
|
+
console.log(` ${k}=${SECRET_KEYS.has(k) ? style.grey("<hidden>") : values[k]}`);
|
|
1210
|
+
}
|
|
1211
|
+
return;
|
|
390
1212
|
}
|
|
1213
|
+
writeEnv(envPath, body);
|
|
1214
|
+
writeFileSync2(join2(root, ANSWERS_FILE), JSON.stringify(answers, null, 2) + "\n");
|
|
1215
|
+
success(`wrote ${envPath}`);
|
|
1216
|
+
if (!existing.OYA_PROFILE_SECRET) {
|
|
1217
|
+
warn("OYA_PROFILE_SECRET was generated \u2014 back it up.");
|
|
1218
|
+
note("Without it, stored cookies, proxy credentials and TOTP seeds cannot be decrypted.");
|
|
1219
|
+
}
|
|
1220
|
+
if (migrateUrl) {
|
|
1221
|
+
const spin = spinner("applying migrations");
|
|
1222
|
+
try {
|
|
1223
|
+
await new Promise((resolve, reject) => {
|
|
1224
|
+
const child = spawn(process.execPath, [join2(root, "server", "migrations", "run.mjs")], {
|
|
1225
|
+
cwd: root,
|
|
1226
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1227
|
+
env: { ...process.env, DATABASE_URL: migrateUrl }
|
|
1228
|
+
});
|
|
1229
|
+
let err = "";
|
|
1230
|
+
child.stderr.on("data", (d) => {
|
|
1231
|
+
err += d;
|
|
1232
|
+
});
|
|
1233
|
+
child.on("error", reject);
|
|
1234
|
+
child.on("close", (code) => code === 0 ? resolve() : reject(new Error(err.trim() || `migrations exited ${code}`)));
|
|
1235
|
+
});
|
|
1236
|
+
spin.done();
|
|
1237
|
+
} catch (e) {
|
|
1238
|
+
spin.fail();
|
|
1239
|
+
throw new Error(`Migrations failed: ${e.message}
|
|
1240
|
+
Fix the database, then re-run: DATABASE_URL=\u2026 make migrate`);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
if (answers.fleet === "k8s") {
|
|
1244
|
+
const pinned = await provisionK8sFleet(root, answers);
|
|
1245
|
+
if (pinned !== values.OYA_MANAGED_IMAGE) {
|
|
1246
|
+
values.OYA_MANAGED_IMAGE = pinned;
|
|
1247
|
+
writeEnv(envPath, renderEnv(values, extra));
|
|
1248
|
+
console.log(" .env updated with the pinned image digest");
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
if (answers.host === "docker") await provisionDocker(root, answers);
|
|
1252
|
+
await waitReady(answers.publicUrl);
|
|
1253
|
+
banner(`${answers.publicUrl} is up`, "Sign in to the dashboard with the key below.");
|
|
1254
|
+
console.log(`
|
|
1255
|
+
${style.bold("API key")} ${style.green(apiKey)}
|
|
1256
|
+
`);
|
|
1257
|
+
console.log(` ${style.grey("# your shell may export OYA_API_KEY for the hosted service")}`);
|
|
1258
|
+
console.log(` unset OYA_API_KEY`);
|
|
1259
|
+
console.log(` oya login --url ${answers.publicUrl} --key ${apiKey}`);
|
|
1260
|
+
console.log(` oya ls && oya goto https://example.com
|
|
1261
|
+
`);
|
|
391
1262
|
}
|
|
392
1263
|
|
|
393
1264
|
// src/index.ts
|
|
394
1265
|
var HELP = `oya \u2014 thousands of browsers, one API
|
|
395
1266
|
|
|
1267
|
+
oya install Stand up a self-hosted control plane
|
|
1268
|
+
[--dry-run] [--config oya-install.json]
|
|
396
1269
|
oya login Save an API key for this machine
|
|
397
1270
|
oya init Set your model, browser provider and sign-ins
|
|
398
1271
|
oya start [--persona auto] Start a browser and print its id
|
|
@@ -436,12 +1309,12 @@ function parse(argv) {
|
|
|
436
1309
|
const args2 = [];
|
|
437
1310
|
const flags2 = {};
|
|
438
1311
|
for (let i = 0; i < rest.length; i++) {
|
|
439
|
-
const
|
|
440
|
-
if (!
|
|
441
|
-
args2.push(
|
|
1312
|
+
const token2 = rest[i];
|
|
1313
|
+
if (!token2.startsWith("--")) {
|
|
1314
|
+
args2.push(token2);
|
|
442
1315
|
continue;
|
|
443
1316
|
}
|
|
444
|
-
const name =
|
|
1317
|
+
const name = token2.slice(2);
|
|
445
1318
|
const next = rest[i + 1];
|
|
446
1319
|
if (next === void 0 || next.startsWith("--")) flags2[name] = true;
|
|
447
1320
|
else {
|
|
@@ -616,8 +1489,8 @@ async function cmdRm(args2, flags2) {
|
|
|
616
1489
|
if (!flags2.all && !args2.length) throw new Error("Usage: oya rm <id>\u2026 | oya rm --all");
|
|
617
1490
|
const r = await oya.browser.stop(flags2.all ? "all" : args2);
|
|
618
1491
|
for (const x of r.results) {
|
|
619
|
-
const
|
|
620
|
-
console.log(`${x.ok ? "\u2705" : "\u2717"} ${x.id}${
|
|
1492
|
+
const note2 = x.sandboxRemoved === true ? " (sandbox destroyed)" : x.sandboxRemoved === false ? " \u2014 sandbox NOT removed, check Oya Cloud" : "";
|
|
1493
|
+
console.log(`${x.ok ? "\u2705" : "\u2717"} ${x.id}${note2}${x.error ? ` ${x.error}` : ""}`);
|
|
621
1494
|
}
|
|
622
1495
|
console.log(`stopped ${r.stopped}`);
|
|
623
1496
|
}
|
|
@@ -700,7 +1573,7 @@ async function cmdOpen(flags2) {
|
|
|
700
1573
|
const browser = await targetBrowser(client(flags2), flags2);
|
|
701
1574
|
const url = browser.liveViewUrl();
|
|
702
1575
|
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
703
|
-
|
|
1576
|
+
spawn2(opener, [url], { detached: true, stdio: "ignore" }).unref();
|
|
704
1577
|
console.log(`Opening ${browser.id}`);
|
|
705
1578
|
}
|
|
706
1579
|
async function cmdConfig(args2, flags2) {
|
|
@@ -724,7 +1597,7 @@ async function cmdUsage(flags2) {
|
|
|
724
1597
|
}
|
|
725
1598
|
function cmdStealthTest(flags2) {
|
|
726
1599
|
return new Promise((resolve, reject) => {
|
|
727
|
-
const child =
|
|
1600
|
+
const child = spawn2("node", ["test-stealth.js", ...flags2.live ? ["--live"] : []], {
|
|
728
1601
|
cwd: process.env.OYA_SERVER_DIR || "server",
|
|
729
1602
|
stdio: "inherit"
|
|
730
1603
|
});
|
|
@@ -796,6 +1669,9 @@ try {
|
|
|
796
1669
|
}
|
|
797
1670
|
console.log(JSON.stringify(result, null, 2));
|
|
798
1671
|
} else switch (command) {
|
|
1672
|
+
case "install":
|
|
1673
|
+
await cmdInstall(flags);
|
|
1674
|
+
break;
|
|
799
1675
|
case "login":
|
|
800
1676
|
await cmdLogin(flags);
|
|
801
1677
|
break;
|
|
@@ -858,4 +1734,6 @@ try {
|
|
|
858
1734
|
if (error.status === 401) console.error(" The API key was rejected. Run `oya login`.");
|
|
859
1735
|
if (error.status === 429) console.error(" A quota or a persona concurrency cap. `oya personas` shows what is running.");
|
|
860
1736
|
process.exit(1);
|
|
1737
|
+
} finally {
|
|
1738
|
+
closePrompts();
|
|
861
1739
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oya-ai/cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.61",
|
|
4
4
|
"description": "Command line for the Oya browser control plane.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://browser.getoya.ai",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"access": "public"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
|
-
"@oya-ai/browser": "1.0.
|
|
42
|
+
"@oya-ai/browser": "1.0.61",
|
|
43
43
|
"@types/node": "^20.19.43",
|
|
44
44
|
"tsup": "^8.0.0",
|
|
45
45
|
"typescript": "^5.4.0"
|