ava-engine 0.1.0 → 0.3.0
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/.env.example +11 -0
- package/README.md +14 -10
- package/lib/asr.mjs +5 -1
- package/lib/cli/apply.mjs +58 -0
- package/lib/cli/main.mjs +77 -9
- package/lib/cli/openrouter-oauth.mjs +87 -0
- package/lib/cli/setup-server.mjs +222 -0
- package/lib/cli/setup.mjs +118 -80
- package/lib/kokoro-model.mjs +51 -0
- package/lib/mock.mjs +78 -0
- package/lib/openrouter.mjs +5 -0
- package/lib/ours.mjs +40 -28
- package/lib/platforms.mjs +15 -1
- package/lib/playbooks.mjs +111 -0
- package/lib/spend.mjs +59 -0
- package/lib/state.mjs +14 -5
- package/lib/tts.mjs +4 -4
- package/lib/webhooks.mjs +68 -0
- package/package.json +10 -2
- package/public/app.js +2 -0
- package/public/index.html +2 -0
- package/public/setup.css +97 -0
- package/public/setup.html +135 -0
- package/public/setup.js +289 -0
- package/public/style.css +4 -0
- package/server.mjs +24 -7
package/.env.example
CHANGED
|
@@ -16,11 +16,22 @@ PORT=5190
|
|
|
16
16
|
# ACCESS_PASSWORD=
|
|
17
17
|
# TRUST_PROXY=1 # behind Fly/Cloudflare/nginx: rate-limit by the real client IP
|
|
18
18
|
# SESSION_BUDGET_USD=2 # model spend cap per call
|
|
19
|
+
# DAILY_BUDGET_USD=20 # model spend cap per UTC day across all calls (unset = none)
|
|
19
20
|
# MAX_SESSIONS=50 # concurrent calls (idle ones expire after 30 min)
|
|
20
21
|
# MAX_ASR_CONNECTIONS=40 # concurrent speech-to-text streams
|
|
21
22
|
# RATE_SESSIONS_PER_MIN=10 # new calls per client IP per minute
|
|
22
23
|
# RATE_API_PER_MIN=240 # other API requests per client IP per minute
|
|
23
24
|
|
|
25
|
+
# ── Your products ──
|
|
26
|
+
# PLAYBOOKS_DIR=/srv/ava/playbooks # one JSON file per product (default ~/.ava-engine/playbooks): npx ava-engine playbooks new <id>
|
|
27
|
+
# BUILTIN_PLAYBOOKS=off # hide the built-in sample products from the lobby
|
|
28
|
+
|
|
29
|
+
# ── Events & handoff alerts ──
|
|
30
|
+
# WEBHOOK_URL=https://your-app.example/ava-events # receives every call event as JSON (comma-separate several)
|
|
31
|
+
# WEBHOOK_SECRET= # signs deliveries: X-Ava-Signature: sha256=HMAC(secret, "<timestamp>.<body>")
|
|
32
|
+
# SLACK_WEBHOOK_URL= # Slack incoming webhook: posts when a customer asks for a person
|
|
33
|
+
# PUBLIC_URL=https://ava.your-site.example # base for the specialist links in those alerts
|
|
34
|
+
|
|
24
35
|
# ── Models (OpenRouter slugs) — one per role ──
|
|
25
36
|
# MODEL_TALKER=meta-llama/llama-3.3-70b-instruct # the spoken reply (latency-critical)
|
|
26
37
|
# MODEL_PERCEPTION=google/gemini-2.5-flash # reads the shared screen
|
package/README.md
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
</p>
|
|
12
12
|
|
|
13
13
|
<p align="center">
|
|
14
|
+
<a href="https://www.npmjs.com/package/ava-engine"><img alt="npm" src="https://img.shields.io/npm/v/ava-engine?color=0A0A0C&label=npm"></a>
|
|
15
|
+
<a href="https://scorecard.dev/viewer/?uri=github.com/Mr-vero/ava-engine"><img alt="OpenSSF Scorecard" src="https://api.scorecard.dev/projects/github.com/Mr-vero/ava-engine/badge"></a>
|
|
14
16
|
<a href="https://github.com/Mr-vero/ava-engine/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/Mr-vero/ava-engine/actions/workflows/ci.yml/badge.svg"></a>
|
|
15
17
|
<a href="COMMERCIAL-LICENSE.md"><img alt="License: Ava Engine License (source-available)" src="https://img.shields.io/badge/license-source--available-0A0A0C"></a>
|
|
16
18
|
<img alt="Node 22.9+" src="https://img.shields.io/badge/node-22.9%2B-4F46E5">
|
|
@@ -56,25 +58,25 @@ Requires **Node 22.9+** and desktop **Chrome**. One command installs Ava and wal
|
|
|
56
58
|
npx ava-engine
|
|
57
59
|
```
|
|
58
60
|
|
|
59
|
-
|
|
60
|
-
1.
|
|
61
|
-
2.
|
|
62
|
-
3.
|
|
63
|
-
4.
|
|
64
|
-
5.
|
|
61
|
+
Set up **in your browser** (a step-by-step page) or **in the terminal**; both do the same:
|
|
62
|
+
1. check your OpenRouter key (or look around with no keys, in MOCK mode);
|
|
63
|
+
2. pick Ava's voice, and download the free offline voice (Kokoro, ~90 MB) with progress;
|
|
64
|
+
3. download offline speech recognition, also with progress;
|
|
65
|
+
4. protect Ava with a password if other people will use it;
|
|
66
|
+
5. start Ava and open **http://localhost:5190/app**.
|
|
65
67
|
|
|
66
68
|
After that, `npx ava-engine` just starts Ava.
|
|
67
69
|
|
|
68
70
|
| Command | What it does |
|
|
69
71
|
| --- | --- |
|
|
70
|
-
| `npx ava-engine setup` | Re-run
|
|
72
|
+
| `npx ava-engine setup` | Re-run setup to change keys, voice, password or port (`--web` for the browser, `--terminal` for here) |
|
|
71
73
|
| `npx ava-engine doctor` | Check everything and get a fix for anything that's wrong |
|
|
72
|
-
| `npx ava-engine models` |
|
|
74
|
+
| `npx ava-engine models` | Download speech models or the Kokoro voice (`models kokoro`) |
|
|
73
75
|
| `npx ava-engine start --port 8080` | Start on another port |
|
|
74
76
|
|
|
75
77
|
Settings live in `~/.ava-engine` (set `AVA_HOME` to move them). **Watch the demo locally** at `http://localhost:5190/app?demo=simple`.
|
|
76
78
|
|
|
77
|
-
**
|
|
79
|
+
**On a server:** `npx ava-engine setup --web` works over SSH too. Forward the port (`ssh -L 5190:localhost:5190 your-server`) and open the link it prints. Or skip the questions: `OPENROUTER_API_KEY=… ACCESS_PASSWORD=… npx ava-engine setup --yes --models english-small,kokoro`. There's also a Docker image; see [DEPLOY.md](DEPLOY.md).
|
|
78
80
|
|
|
79
81
|
<details>
|
|
80
82
|
<summary><b>Working on Ava itself? Run from source</b></summary>
|
|
@@ -147,12 +149,14 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
|
147
149
|
**For the customer**
|
|
148
150
|
- **Talks, naturally.** Live voice with streamed replies, interruptible at any time (barge-in), in text or voice.
|
|
149
151
|
- **Sees their screen.** Reads the shared tab — the page, what's selected, the numbers on it — and grounds every answer in it. Never invents buttons; if it isn't sure, it takes another look.
|
|
150
|
-
- **Helps with anything on the page.** A *Flexible agent* for whatever the customer is doing, and an *Onboarding agent* that walks a journey step by step on a
|
|
152
|
+
- **Helps with anything on the page.** A *Flexible agent* for whatever the customer is doing, and an *Onboarding agent* that walks a journey step by step on your product, described in a JSON [playbook](docs/setup/customize.md) (samples included for Todoist, Supabase, Stripe, AWS Console, Salesforce, Datadog and Vercel).
|
|
151
153
|
- **Speaks up.** Reacts when the screen changes, and flags risky or costly moves before they happen (a $9,900 seat count, a destructive click).
|
|
152
154
|
- **Knows when it's done.** Confirms each step actually worked, and goes quiet once the task is complete.
|
|
153
155
|
- **A person, one tap away.** Hands off to a human specialist with the goal, the screen and the progress already shared — the specialist joins the same call over WebRTC.
|
|
154
156
|
|
|
155
157
|
**For the team running it**
|
|
158
|
+
- **Events** — webhooks for call started, step completed, handoff requested and call ended, plus a Slack alert with a join link when a customer asks for a person.
|
|
159
|
+
- **Spend caps** — per call and per day across all calls.
|
|
156
160
|
- **Session recap** — an AI summary, highlights and stats, plus a replay of how the agents worked, with a timestamped timeline.
|
|
157
161
|
- **Console** — end-to-end latency per turn, which agents ran and how long each took, the compiled context the talker saw, and live spend.
|
|
158
162
|
- **Per-platform theming** — onboarding calls take on the product's brand colour.
|
package/lib/asr.mjs
CHANGED
|
@@ -76,7 +76,11 @@ function getOffline() {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
export function warmAsr() {
|
|
79
|
-
if (!asrAvailable()) {
|
|
79
|
+
if (!asrAvailable()) {
|
|
80
|
+
if (initErr) console.log(" speech recognition: the offline engine didn't load; using the browser's (run `npx ava-engine doctor`)");
|
|
81
|
+
else console.log(" speech recognition: using the browser's. For offline recognition: npx ava-engine models");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
80
84
|
const e = availableEngines();
|
|
81
85
|
if (e.includes("zipformer") && getOnline()) console.log(" sherpa ASR ready — English streaming (zipformer)");
|
|
82
86
|
if (e.includes("sensevoice")) console.log(" sherpa ASR ready — multilingual (SenseVoice), lazy-loaded on first use");
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Turning setup answers into saved settings. Shared by the terminal wizard and
|
|
2
|
+
// the browser setup, so both produce exactly the same configuration.
|
|
3
|
+
import { MODELS_DIR, DEFAULT_PORT } from "./config.mjs";
|
|
4
|
+
|
|
5
|
+
export const VOICES = {
|
|
6
|
+
kokoro: { label: "Kokoro", hint: "free, runs on this computer (~90 MB download)" },
|
|
7
|
+
elevenlabs: { label: "ElevenLabs", hint: "premium cloud voices, needs an ElevenLabs key" },
|
|
8
|
+
browser: { label: "The browser's voice", hint: "no setup, sounds robotic" },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* answers: {
|
|
13
|
+
* openrouterKey?: string | null (null/"" → MOCK mode; undefined → keep the current key)
|
|
14
|
+
* voice: "kokoro" | "elevenlabs" | "browser",
|
|
15
|
+
* elevenKey?, elevenVoice?,
|
|
16
|
+
* audience: "me" | "others", password?, proxy?: boolean,
|
|
17
|
+
* port?: number
|
|
18
|
+
* }
|
|
19
|
+
*/
|
|
20
|
+
export function buildConfig(prev, a) {
|
|
21
|
+
const cfg = { ...prev };
|
|
22
|
+
const set = (k, v) => { if (v === undefined || v === null || v === "") delete cfg[k]; else cfg[k] = String(v); };
|
|
23
|
+
|
|
24
|
+
if (a.openrouterKey !== undefined) set("OPENROUTER_API_KEY", a.openrouterKey);
|
|
25
|
+
|
|
26
|
+
const voice = VOICES[a.voice] ? a.voice : "kokoro";
|
|
27
|
+
set("TTS_DEFAULT", voice);
|
|
28
|
+
set("KOKORO", voice === "kokoro" ? "on" : "off");
|
|
29
|
+
if (voice === "elevenlabs") {
|
|
30
|
+
if (a.elevenKey !== undefined) set("ELEVENLABS_API_KEY", a.elevenKey);
|
|
31
|
+
if (a.elevenVoice !== undefined) set("ELEVEN_VOICE_ID", a.elevenVoice);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (a.audience === "others") {
|
|
35
|
+
set("HOST", "");
|
|
36
|
+
if (a.password !== undefined) set("ACCESS_PASSWORD", a.password);
|
|
37
|
+
set("TRUST_PROXY", a.proxy ? "1" : "");
|
|
38
|
+
} else {
|
|
39
|
+
set("HOST", "127.0.0.1");
|
|
40
|
+
set("ACCESS_PASSWORD", "");
|
|
41
|
+
set("TRUST_PROXY", "");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const port = Number(a.port);
|
|
45
|
+
set("PORT", Number.isInteger(port) && port > 0 && port < 65536 ? port : prev.PORT || DEFAULT_PORT);
|
|
46
|
+
set("AVA_MODELS_DIR", MODELS_DIR);
|
|
47
|
+
return cfg;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Problems that must be fixed before saving, as a list of human messages. */
|
|
51
|
+
export function validateAnswers(a) {
|
|
52
|
+
const errors = [];
|
|
53
|
+
if (a.audience === "others" && (!a.password || String(a.password).length < 10)) errors.push("Choose an access password of at least 10 characters.");
|
|
54
|
+
if (a.voice === "elevenlabs" && a.elevenKey === "") errors.push("Add your ElevenLabs key, or pick another voice.");
|
|
55
|
+
const port = Number(a.port);
|
|
56
|
+
if (a.port !== undefined && !(Number.isInteger(port) && port > 0 && port < 65536)) errors.push("The port must be a number between 1 and 65535.");
|
|
57
|
+
return errors;
|
|
58
|
+
}
|
package/lib/cli/main.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The `ava-engine` command.
|
|
2
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join, dirname } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
4
5
|
import { fileURLToPath } from "node:url";
|
|
5
6
|
import { createRequire } from "node:module";
|
|
6
7
|
import { AVA_HOME, CONFIG_FILE, MODELS_DIR, DEFAULT_PORT, readConfig, checkOpenRouterKey, portFree, openBrowser } from "./config.mjs";
|
|
@@ -14,17 +15,21 @@ const HELP = `
|
|
|
14
15
|
|
|
15
16
|
Usage
|
|
16
17
|
npx ava-engine first run: setup wizard, then start. After that: start
|
|
17
|
-
npx ava-engine setup (re)run the
|
|
18
|
+
npx ava-engine setup (re)run setup; asks: in the browser or the terminal
|
|
19
|
+
npx ava-engine setup --web setup in your browser
|
|
20
|
+
npx ava-engine setup --terminal
|
|
18
21
|
npx ava-engine start start the server
|
|
19
22
|
npx ava-engine doctor check your setup and tell you what to fix
|
|
20
|
-
npx ava-engine models [..] download
|
|
23
|
+
npx ava-engine models [..] download models: ${Object.keys(MODEL_PACKS).join(", ")}, kokoro (voice)
|
|
24
|
+
npx ava-engine playbooks list and check your product playbooks
|
|
25
|
+
npx ava-engine playbooks new <id> write a starter playbook to fill in
|
|
21
26
|
|
|
22
27
|
Options
|
|
23
28
|
--port <n> port to listen on (default ${DEFAULT_PORT})
|
|
24
29
|
--open open the browser once Ava is up (default on a first run)
|
|
25
30
|
--no-open don't open the browser
|
|
26
31
|
--yes setup without questions, from environment variables
|
|
27
|
-
--models <a,b> with --yes:
|
|
32
|
+
--models <a,b> with --yes: what to download, e.g. english-small,kokoro
|
|
28
33
|
-v, --version print the version
|
|
29
34
|
-h, --help show this help
|
|
30
35
|
|
|
@@ -40,6 +45,8 @@ function parseArgs(argv) {
|
|
|
40
45
|
else if (a === "-v" || a === "--version") out.flags.version = true;
|
|
41
46
|
else if (a === "--yes" || a === "-y") out.flags.yes = true;
|
|
42
47
|
else if (a === "--open") out.flags.open = true;
|
|
48
|
+
else if (a === "--web") out.flags.web = true;
|
|
49
|
+
else if (a === "--terminal") out.flags.terminal = true;
|
|
43
50
|
else if (a === "--no-open") out.flags.open = false;
|
|
44
51
|
else if (a === "--port") out.flags.port = Number(argv[++i]);
|
|
45
52
|
else if (a.startsWith("--port=")) out.flags.port = Number(a.slice(7));
|
|
@@ -73,6 +80,35 @@ async function start({ port, open }) {
|
|
|
73
80
|
}
|
|
74
81
|
}
|
|
75
82
|
|
|
83
|
+
/** `ava-engine playbooks [new <id>]`: your products as JSON files. */
|
|
84
|
+
async function playbooks(args) {
|
|
85
|
+
const { loadPlaybooks, validatePlaybook, template } = await import("../playbooks.mjs");
|
|
86
|
+
const cfg = readConfig() || {};
|
|
87
|
+
const dir = (process.env.PLAYBOOKS_DIR || cfg.PLAYBOOKS_DIR || join(AVA_HOME, "playbooks")).replace(/^~(?=$|[\\/])/, homedir());
|
|
88
|
+
if (args[0] === "new") {
|
|
89
|
+
const id = args[1];
|
|
90
|
+
if (!id || validatePlaybook({ ...template(id), id }).errors) { console.error("✗ usage: ava-engine playbooks new <id> (lowercase letters, digits and dashes, e.g. acme)"); return 2; }
|
|
91
|
+
const file = join(dir, `${id}.json`);
|
|
92
|
+
if (existsSync(file)) { console.error(`✗ ${file} already exists`); return 1; }
|
|
93
|
+
mkdirSync(dir, { recursive: true });
|
|
94
|
+
writeFileSync(file, JSON.stringify(template(id), null, 2) + "\n");
|
|
95
|
+
console.log(`\n ✓ Wrote ${file}\n\n Fill it in for your product, check it with: npx ava-engine playbooks\n then restart Ava. Field guide: https://github.com/Mr-vero/ava-engine/blob/main/docs/setup/customize.md\n`);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
if (args.length) { console.error(`✗ unknown playbooks command "${args[0]}" (try: ava-engine playbooks new <id>)`); return 2; }
|
|
99
|
+
const { playbooks: ok, problems } = loadPlaybooks(dir);
|
|
100
|
+
console.log(`\n ΛVΛ Playbooks in ${dir}\n`);
|
|
101
|
+
if (!ok.length && !problems.length) console.log(" None yet. Create one with: npx ava-engine playbooks new <id>");
|
|
102
|
+
for (const p of ok) console.log(` ✓ ${p.name} (${p.id}): ${p.journey.length} steps, ${p.knowledge.length} knowledge entries [${p.source}]`);
|
|
103
|
+
for (const p of problems) {
|
|
104
|
+
for (const e of p.errors) console.log(` ✗ ${p.file}: ${e}`);
|
|
105
|
+
for (const w of p.warnings) console.log(` ! ${p.file}: ${w}`);
|
|
106
|
+
}
|
|
107
|
+
const hidden = /^(off|0|false|no)$/i.test(process.env.BUILTIN_PLAYBOOKS || cfg.BUILTIN_PLAYBOOKS || "");
|
|
108
|
+
console.log(`\n Built-in sample products are ${hidden ? "hidden (BUILTIN_PLAYBOOKS=off)" : "shown too. Hide them with BUILTIN_PLAYBOOKS=off in " + CONFIG_FILE}.\n`);
|
|
109
|
+
return problems.some((p) => p.errors.length) ? 1 : 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
76
112
|
async function doctor() {
|
|
77
113
|
const cfg = readConfig();
|
|
78
114
|
const rows = [];
|
|
@@ -114,9 +150,10 @@ export async function main(argv) {
|
|
|
114
150
|
switch (cmd) {
|
|
115
151
|
case "help": console.log(HELP); return 0;
|
|
116
152
|
case "doctor": return doctor();
|
|
153
|
+
case "playbooks": return playbooks(args._.slice(1));
|
|
117
154
|
case "start": await start({ port: flags.port, open: flags.open === true }); return null; // keeps running
|
|
118
155
|
case "models": {
|
|
119
|
-
const { downloadPacks } = await import("./setup.mjs");
|
|
156
|
+
const { downloadPacks, downloadKokoro } = await import("./setup.mjs");
|
|
120
157
|
let packs = args._.slice(1);
|
|
121
158
|
if (!packs.length && interactive) {
|
|
122
159
|
const p = await import("@clack/prompts");
|
|
@@ -126,19 +163,50 @@ export async function main(argv) {
|
|
|
126
163
|
packs = sel;
|
|
127
164
|
}
|
|
128
165
|
if (!packs.length) packs = ["english-small"];
|
|
129
|
-
const bad = packs.filter((x) => !MODEL_PACKS[x]);
|
|
130
|
-
if (bad.length) { console.error(`✗ unknown model
|
|
131
|
-
|
|
166
|
+
const bad = packs.filter((x) => !MODEL_PACKS[x] && x !== "kokoro");
|
|
167
|
+
if (bad.length) { console.error(`✗ unknown model: ${bad.join(", ")}. Choose from: ${Object.keys(MODEL_PACKS).join(", ")}, kokoro`); return 2; }
|
|
168
|
+
if (packs.includes("kokoro")) await downloadKokoro();
|
|
169
|
+
await downloadPacks(packs.filter((x) => x !== "kokoro"));
|
|
132
170
|
return 0;
|
|
133
171
|
}
|
|
134
172
|
case "setup":
|
|
135
173
|
case "first-run": {
|
|
136
174
|
const setup = await import("./setup.mjs");
|
|
137
|
-
|
|
175
|
+
// --web needs a browser, not a terminal, so it works over SSH and from scripts too.
|
|
176
|
+
if (!interactive && !(flags.web && !flags.yes)) {
|
|
138
177
|
await setup.runSetupNonInteractive({ models: flags.models || [], port: flags.port });
|
|
139
178
|
if (cmd === "first-run") { await start({ port: flags.port, open: flags.open === true }); return null; }
|
|
140
179
|
return 0;
|
|
141
180
|
}
|
|
181
|
+
// Where to run the wizard: the browser (default) or this terminal.
|
|
182
|
+
let where = flags.web ? "web" : flags.terminal ? "terminal" : null;
|
|
183
|
+
if (!where) {
|
|
184
|
+
const p = await import("@clack/prompts");
|
|
185
|
+
console.log("");
|
|
186
|
+
p.intro(" ΛVΛ Ava Engine setup ");
|
|
187
|
+
where = await p.select({
|
|
188
|
+
message: "Where would you like to set up Ava?",
|
|
189
|
+
options: [
|
|
190
|
+
{ value: "web", label: "In my browser", hint: "recommended; a step-by-step page" },
|
|
191
|
+
{ value: "terminal", label: "Here in the terminal" },
|
|
192
|
+
],
|
|
193
|
+
});
|
|
194
|
+
if (p.isCancel(where)) { p.cancel("Setup cancelled. Nothing was changed."); return 0; }
|
|
195
|
+
}
|
|
196
|
+
if (where === "web") {
|
|
197
|
+
const { runWebSetup } = await import("./setup-server.mjs");
|
|
198
|
+
const r = await runWebSetup({
|
|
199
|
+
preferredPort: flags.port,
|
|
200
|
+
formPort: flags.port,
|
|
201
|
+
onReady: ({ url }) => {
|
|
202
|
+
console.log(`\n Setup is open in your browser. If it didn't open, go to:\n ${url}\n\n (Keep this terminal open. Press Ctrl+C to cancel.)\n`);
|
|
203
|
+
if (flags.open !== false) openBrowser(url);
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
console.log(" Settings saved. Starting Ava…");
|
|
207
|
+
await start({ port: r.port, open: false }); // the port chosen on the page wins; the page redirects itself
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
142
210
|
const r = await setup.runSetup();
|
|
143
211
|
if (r.start) { await start({ port: flags.port || r.port, open: flags.open !== false }); return null; }
|
|
144
212
|
return 0;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// "Connect OpenRouter": get an API key by signing in, instead of copying one.
|
|
2
|
+
//
|
|
3
|
+
// OpenRouter's OAuth PKCE flow (https://openrouter.ai/docs/use-cases/oauth-pkce):
|
|
4
|
+
// 1. open openrouter.ai/auth with a localhost callback and a S256 code challenge,
|
|
5
|
+
// 2. the person approves; OpenRouter redirects to the callback with ?code=…,
|
|
6
|
+
// 3. we trade the code (plus the verifier only we know) for a key.
|
|
7
|
+
// The key is billed to their own OpenRouter credit. Codes expire after 10 minutes.
|
|
8
|
+
|
|
9
|
+
import http from "node:http";
|
|
10
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
11
|
+
|
|
12
|
+
// Overridable so tests can stand in for openrouter.ai; never set this in real use.
|
|
13
|
+
const BASE = () => (process.env.AVA_OPENROUTER_BASE || "https://openrouter.ai").replace(/\/+$/, "");
|
|
14
|
+
export const FLOW_TIMEOUT_MS = 10 * 60_000;
|
|
15
|
+
|
|
16
|
+
/** A fresh PKCE pair and CSRF state. */
|
|
17
|
+
export function pkce() {
|
|
18
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
19
|
+
return { verifier, challenge: createHash("sha256").update(verifier).digest("base64url"), state: randomBytes(16).toString("base64url") };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function authUrl({ callbackUrl, challenge, state }) {
|
|
23
|
+
const q = new URLSearchParams({ callback_url: callbackUrl, code_challenge: challenge, code_challenge_method: "S256", state, key_label: "Ava Engine" });
|
|
24
|
+
return `${BASE()}/auth?${q}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Trade the callback's code for an API key. Throws an Error with a message fit to show. */
|
|
28
|
+
export async function exchangeCode({ code, verifier }) {
|
|
29
|
+
let r;
|
|
30
|
+
try {
|
|
31
|
+
r = await fetch(`${BASE()}/api/v1/auth/keys`, {
|
|
32
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
33
|
+
body: JSON.stringify({ code, code_verifier: verifier, code_challenge_method: "S256" }),
|
|
34
|
+
signal: AbortSignal.timeout(15_000),
|
|
35
|
+
});
|
|
36
|
+
} catch { throw new Error("Couldn't reach OpenRouter. Check your connection and try again."); }
|
|
37
|
+
// The docs say 403 for a bad or expired code; the live API answers 400.
|
|
38
|
+
if (r.status === 400 || r.status === 403) throw new Error("OpenRouter didn't accept the sign-in (it may have expired). Try connecting again.");
|
|
39
|
+
if (!r.ok) throw new Error(`OpenRouter answered ${r.status}. Try again, or paste a key instead.`);
|
|
40
|
+
const j = await r.json().catch(() => ({}));
|
|
41
|
+
if (typeof j.key !== "string" || j.key.length < 20) throw new Error("OpenRouter didn't return a key. Try again, or paste a key instead.");
|
|
42
|
+
return j.key;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The page the browser lands on after approving. Fixed text only: nothing from the request is echoed. */
|
|
46
|
+
export function callbackPage(ok, { cssHref } = {}) {
|
|
47
|
+
const [title, text] = ok
|
|
48
|
+
? ["OpenRouter is connected", "You can close this tab and go back to setup."]
|
|
49
|
+
: ["Not connected", "OpenRouter didn't connect. Close this tab and try again, or paste a key instead."];
|
|
50
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title} · Ava setup</title>${cssHref ? `<link rel="stylesheet" href="${cssHref}">` : ""}</head>`
|
|
51
|
+
+ `<body><main class="panel launching"><h1>${ok ? "✓ " : ""}${title}</h1><p class="lead">${text}</p></main></body></html>`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Terminal flow: listen on 127.0.0.1, open the browser, wait for the callback.
|
|
56
|
+
* Resolves the key, or rejects (timeout, refused, bad state).
|
|
57
|
+
* onUrl(url) is told the sign-in URL (open it and print it).
|
|
58
|
+
*/
|
|
59
|
+
export function connectInBrowser({ onUrl, timeoutMs = FLOW_TIMEOUT_MS } = {}) {
|
|
60
|
+
const { verifier, challenge, state } = pkce();
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
let done = false;
|
|
63
|
+
const finish = (err, key) => {
|
|
64
|
+
if (done) return; done = true;
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
setTimeout(() => server.close(), 200).unref(); // let the callback page finish sending
|
|
67
|
+
server.closeAllConnections?.();
|
|
68
|
+
if (err) reject(err); else resolve(key);
|
|
69
|
+
};
|
|
70
|
+
const server = http.createServer(async (req, res) => {
|
|
71
|
+
const url = new URL(req.url, "http://localhost");
|
|
72
|
+
if (url.pathname !== "/callback") { res.writeHead(404); return res.end(); }
|
|
73
|
+
const page = (ok) => { res.writeHead(ok ? 200 : 400, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "Referrer-Policy": "no-referrer", "Content-Security-Policy": "default-src 'none'" }); res.end(callbackPage(ok)); };
|
|
74
|
+
const code = url.searchParams.get("code");
|
|
75
|
+
if (!code) { page(false); return finish(new Error("The sign-in was cancelled.")); }
|
|
76
|
+
if (url.searchParams.get("state") !== state) { page(false); return; } // not ours: keep waiting for the real one
|
|
77
|
+
try { const key = await exchangeCode({ code, verifier }); page(true); finish(null, key); }
|
|
78
|
+
catch (e) { page(false); finish(e); }
|
|
79
|
+
});
|
|
80
|
+
const timer = setTimeout(() => finish(new Error("Timed out waiting for the OpenRouter sign-in.")), timeoutMs);
|
|
81
|
+
timer.unref?.();
|
|
82
|
+
server.on("error", (e) => finish(e));
|
|
83
|
+
server.listen(0, "127.0.0.1", () => {
|
|
84
|
+
onUrl?.(authUrl({ callbackUrl: `http://localhost:${server.address().port}/callback`, challenge, state }));
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// `ava-engine setup --web`: the setup wizard as a page in your browser.
|
|
2
|
+
//
|
|
3
|
+
// A small, separate server that runs only during setup:
|
|
4
|
+
// - listens on 127.0.0.1 only,
|
|
5
|
+
// - every API call needs the one-time token from the URL the CLI opens,
|
|
6
|
+
// - rejects requests whose Host isn't localhost (stops DNS-rebinding attacks),
|
|
7
|
+
// - serves a strict Content-Security-Policy.
|
|
8
|
+
// When you press "Launch", it saves the settings, closes, and the CLI starts Ava.
|
|
9
|
+
|
|
10
|
+
import http from "node:http";
|
|
11
|
+
import { readFile } from "node:fs/promises";
|
|
12
|
+
import { join, dirname } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
15
|
+
import { CONFIG_FILE, MODELS_DIR, DEFAULT_PORT, readConfig, writeConfig, checkOpenRouterKey, checkElevenLabsKey, portFree, suggestPassword } from "./config.mjs";
|
|
16
|
+
import { buildConfig, validateAnswers, VOICES } from "./apply.mjs";
|
|
17
|
+
import { MODEL_PACKS, installedPacks, fetchPack } from "../fetch-models.mjs";
|
|
18
|
+
import { loadKokoro, kokoroInstalled, KOKORO_SIZE } from "../kokoro-model.mjs";
|
|
19
|
+
import { pkce, authUrl, exchangeCode, callbackPage, FLOW_TIMEOUT_MS } from "./openrouter-oauth.mjs";
|
|
20
|
+
|
|
21
|
+
const PUBLIC = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "public");
|
|
22
|
+
const STATIC = new Map([
|
|
23
|
+
["/setup", ["setup.html", "text/html; charset=utf-8"]],
|
|
24
|
+
["/setup.js", ["setup.js", "text/javascript; charset=utf-8"]],
|
|
25
|
+
["/setup.css", ["setup.css", "text/css; charset=utf-8"]],
|
|
26
|
+
["/favicon.svg", ["favicon.svg", "image/svg+xml"]],
|
|
27
|
+
["/fonts/inter-var.woff2", ["fonts/inter-var.woff2", "font/woff2"]],
|
|
28
|
+
["/fonts/inter-tight-var.woff2", ["fonts/inter-tight-var.woff2", "font/woff2"]],
|
|
29
|
+
["/fonts/plex-mono-400.woff2", ["fonts/plex-mono-400.woff2", "font/woff2"]],
|
|
30
|
+
["/fonts/plex-mono-500.woff2", ["fonts/plex-mono-500.woff2", "font/woff2"]],
|
|
31
|
+
]);
|
|
32
|
+
const HEADERS = {
|
|
33
|
+
"X-Content-Type-Options": "nosniff",
|
|
34
|
+
"Referrer-Policy": "no-referrer",
|
|
35
|
+
"Cache-Control": "no-store",
|
|
36
|
+
"Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self' http://localhost:* http://127.0.0.1:*; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const mask = (v) => (v ? `${v.slice(0, 6)}…${v.slice(-4)}` : "");
|
|
40
|
+
|
|
41
|
+
async function findPort(preferred) {
|
|
42
|
+
for (let p = preferred; p < preferred + 20; p++) if (await portFree(p, "127.0.0.1")) return p;
|
|
43
|
+
return 0; // let the OS pick
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Run the browser setup. Resolves { start: true, port } once you press Launch.
|
|
48
|
+
* onReady({ url }) is called when the page is ready to open.
|
|
49
|
+
*/
|
|
50
|
+
export async function runWebSetup({ preferredPort, formPort, onReady } = {}) {
|
|
51
|
+
process.env.AVA_MODELS_DIR ??= MODELS_DIR; // so Kokoro downloads land in ~/.ava-engine/models
|
|
52
|
+
const prev = readConfig() || {};
|
|
53
|
+
const token = randomBytes(24).toString("base64url");
|
|
54
|
+
const jobs = {}; // id → { label, state: "running" | "done" | "error", pct, error }
|
|
55
|
+
// "Connect OpenRouter": one sign-in at a time. The key stays here; the page only sees it masked.
|
|
56
|
+
let oauth = null; // { verifier, state, startedAt, status: "waiting" | "connected" | "error", key, error }
|
|
57
|
+
|
|
58
|
+
const okToken = (req) => {
|
|
59
|
+
const t = Buffer.from(String(req.headers["x-setup-token"] || ""));
|
|
60
|
+
const want = Buffer.from(token);
|
|
61
|
+
return t.length === want.length && timingSafeEqual(t, want);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function startJob(id) {
|
|
65
|
+
if (jobs[id]?.state === "running" || jobs[id]?.state === "done") return;
|
|
66
|
+
if (id === "kokoro") {
|
|
67
|
+
jobs[id] = { label: `Kokoro voice (${KOKORO_SIZE})`, state: "running", pct: 0 };
|
|
68
|
+
if (kokoroInstalled()) { jobs[id] = { ...jobs[id], state: "done", pct: 100 }; return; }
|
|
69
|
+
loadKokoro({ onProgress: ({ pct }) => { jobs[id].pct = pct; } })
|
|
70
|
+
.then(() => { jobs[id].state = "done"; jobs[id].pct = 100; })
|
|
71
|
+
.catch((e) => { jobs[id].state = "error"; jobs[id].error = e.message; });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const spec = MODEL_PACKS[id];
|
|
75
|
+
jobs[id] = { label: `${spec.label} (${spec.size})`, state: "running", pct: 0 };
|
|
76
|
+
fetchPack(id, MODELS_DIR, (ev) => {
|
|
77
|
+
if (ev.phase === "download" && ev.total) jobs[id].pct = Math.min(99, Math.floor((ev.done / ev.total) * 100));
|
|
78
|
+
if (ev.phase === "extract") jobs[id].phase = "unpacking";
|
|
79
|
+
}).then(() => { jobs[id].state = "done"; jobs[id].pct = 100; })
|
|
80
|
+
.catch((e) => { jobs[id].state = "error"; jobs[id].error = e.message; });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function state() {
|
|
84
|
+
const c = readConfig() || prev;
|
|
85
|
+
return {
|
|
86
|
+
configFile: CONFIG_FILE,
|
|
87
|
+
current: {
|
|
88
|
+
openrouterKey: mask(c.OPENROUTER_API_KEY),
|
|
89
|
+
voice: VOICES[c.TTS_DEFAULT] ? c.TTS_DEFAULT : "kokoro",
|
|
90
|
+
elevenKey: mask(c.ELEVENLABS_API_KEY),
|
|
91
|
+
elevenVoice: c.ELEVEN_VOICE_ID || "",
|
|
92
|
+
audience: c.ACCESS_PASSWORD ? "others" : "me",
|
|
93
|
+
hasPassword: !!c.ACCESS_PASSWORD,
|
|
94
|
+
proxy: c.TRUST_PROXY === "1",
|
|
95
|
+
port: formPort || Number(c.PORT) || DEFAULT_PORT,
|
|
96
|
+
},
|
|
97
|
+
voices: VOICES,
|
|
98
|
+
kokoro: { installed: kokoroInstalled(), size: KOKORO_SIZE },
|
|
99
|
+
packs: Object.fromEntries(Object.entries(MODEL_PACKS).map(([id, m]) => [id, { label: m.label, size: m.size, hint: m.hint }])),
|
|
100
|
+
installedPacks: installedPacks(MODELS_DIR),
|
|
101
|
+
suggestedPassword: suggestPassword(),
|
|
102
|
+
jobs,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let finish;
|
|
107
|
+
const finished = new Promise((r) => { finish = r; });
|
|
108
|
+
let listenPort = 0;
|
|
109
|
+
|
|
110
|
+
const server = http.createServer(async (req, res) => {
|
|
111
|
+
const send = (code, body, type = "application/json") => {
|
|
112
|
+
res.writeHead(code, { ...HEADERS, "Content-Type": type });
|
|
113
|
+
res.end(typeof body === "string" || Buffer.isBuffer(body) ? body : JSON.stringify(body));
|
|
114
|
+
};
|
|
115
|
+
// DNS-rebinding guard: only answer requests addressed to this machine.
|
|
116
|
+
const host = String(req.headers.host || "");
|
|
117
|
+
if (!/^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i.test(host)) return send(403, { error: "forbidden host" });
|
|
118
|
+
let url;
|
|
119
|
+
try { url = new URL(req.url, "http://localhost"); } catch { return send(400, { error: "bad request" }); }
|
|
120
|
+
|
|
121
|
+
if (req.method === "GET" && STATIC.has(url.pathname)) {
|
|
122
|
+
const [file, type] = STATIC.get(url.pathname);
|
|
123
|
+
try { return send(200, await readFile(join(PUBLIC, file)), type); } catch { return send(404, "not found", "text/plain"); }
|
|
124
|
+
}
|
|
125
|
+
if (req.method === "GET" && url.pathname === "/") { res.writeHead(302, { Location: "/setup" }); return res.end(); }
|
|
126
|
+
// OpenRouter redirects the browser here after sign-in. It can't carry the page's
|
|
127
|
+
// token, so the one-time state from /openrouter/start proves it's ours.
|
|
128
|
+
if (req.method === "GET" && url.pathname === "/openrouter/callback") {
|
|
129
|
+
const page = (ok) => send(ok ? 200 : 400, callbackPage(ok, { cssHref: "/setup.css" }), "text/html; charset=utf-8");
|
|
130
|
+
const f = oauth;
|
|
131
|
+
if (!f || f.status !== "waiting" || Date.now() - f.startedAt > FLOW_TIMEOUT_MS) return page(false);
|
|
132
|
+
const code = url.searchParams.get("code");
|
|
133
|
+
if (!code) { f.status = "error"; f.error = "The sign-in was cancelled."; return page(false); }
|
|
134
|
+
const st = Buffer.from(String(url.searchParams.get("state") || "")), want = Buffer.from(f.state);
|
|
135
|
+
if (!(st.length === want.length && timingSafeEqual(st, want))) return page(false);
|
|
136
|
+
f.status = "exchanging";
|
|
137
|
+
try { f.key = await exchangeCode({ code, verifier: f.verifier }); f.status = "connected"; return page(true); }
|
|
138
|
+
catch (e) { f.status = "error"; f.error = e.message; return page(false); }
|
|
139
|
+
}
|
|
140
|
+
if (!url.pathname.startsWith("/api/setup/")) return send(404, "not found", "text/plain");
|
|
141
|
+
if (!okToken(req)) return send(401, { error: "This setup link has expired. Run `npx ava-engine setup --web` for a new one." });
|
|
142
|
+
|
|
143
|
+
let body = {};
|
|
144
|
+
if (req.method === "POST") {
|
|
145
|
+
if (!/^application\/json\b/i.test(String(req.headers["content-type"] || ""))) return send(415, { error: "expected application/json" });
|
|
146
|
+
const chunks = []; let size = 0;
|
|
147
|
+
for await (const c of req) { size += c.length; if (size > 64 * 1024) return send(413, { error: "too large" }); chunks.push(c); }
|
|
148
|
+
try { body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); } catch { return send(400, { error: "invalid JSON" }); }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
const route = `${req.method} ${url.pathname}`;
|
|
153
|
+
if (route === "GET /api/setup/state") return send(200, state());
|
|
154
|
+
if (route === "GET /api/setup/jobs") return send(200, { jobs, installedPacks: installedPacks(MODELS_DIR), kokoroInstalled: kokoroInstalled() });
|
|
155
|
+
if (route === "GET /api/setup/password") return send(200, { password: suggestPassword() });
|
|
156
|
+
if (route === "POST /api/setup/check") {
|
|
157
|
+
const key = String(body.key || "").trim();
|
|
158
|
+
if (key.length < 20) return send(200, { ok: false, error: "That doesn't look like a full key." });
|
|
159
|
+
const r = body.service === "elevenlabs" ? await checkElevenLabsKey(key) : await checkOpenRouterKey(key);
|
|
160
|
+
if (r.ok && r.label && /^sk-or/i.test(r.label)) delete r.label; // unnamed keys report a masked key as their label
|
|
161
|
+
return send(200, r);
|
|
162
|
+
}
|
|
163
|
+
if (route === "POST /api/setup/openrouter/start") {
|
|
164
|
+
const { verifier, challenge, state: st } = pkce();
|
|
165
|
+
oauth = { verifier, state: st, startedAt: Date.now(), status: "waiting" };
|
|
166
|
+
return send(200, { url: authUrl({ callbackUrl: `http://localhost:${listenPort}/openrouter/callback`, challenge, state: st }) });
|
|
167
|
+
}
|
|
168
|
+
if (route === "GET /api/setup/openrouter/status") {
|
|
169
|
+
if (oauth?.status === "waiting" && Date.now() - oauth.startedAt > FLOW_TIMEOUT_MS) { oauth.status = "error"; oauth.error = "The sign-in timed out. Try again."; }
|
|
170
|
+
return send(200, oauth ? { status: oauth.status, key: oauth.key ? mask(oauth.key) : undefined, error: oauth.error } : { status: "none" });
|
|
171
|
+
}
|
|
172
|
+
if (route === "POST /api/setup/download") {
|
|
173
|
+
const id = String(body.id || "");
|
|
174
|
+
if (id !== "kokoro" && !MODEL_PACKS[id]) return send(400, { error: "unknown download" });
|
|
175
|
+
startJob(id);
|
|
176
|
+
return send(200, { ok: true, job: jobs[id] });
|
|
177
|
+
}
|
|
178
|
+
if (route === "POST /api/setup/save") {
|
|
179
|
+
const a = body.answers || {};
|
|
180
|
+
// "keep" means: leave the saved key as it is; "connected": the key from Connect OpenRouter.
|
|
181
|
+
if (a.mode !== "mock" && a.openrouterKey === "connected" && oauth?.status !== "connected") return send(400, { error: "Connect OpenRouter again: the sign-in didn't finish." });
|
|
182
|
+
const answers = {
|
|
183
|
+
openrouterKey: a.mode === "mock" ? null : a.openrouterKey === "keep" ? undefined : a.openrouterKey === "connected" ? oauth.key : String(a.openrouterKey || "") || null,
|
|
184
|
+
voice: a.voice,
|
|
185
|
+
elevenKey: a.elevenKey === "keep" ? undefined : a.elevenKey,
|
|
186
|
+
elevenVoice: a.elevenVoice,
|
|
187
|
+
audience: a.audience === "others" ? "others" : "me",
|
|
188
|
+
password: a.audience === "others" ? (a.password === "keep" ? prev.ACCESS_PASSWORD || readConfig()?.ACCESS_PASSWORD : a.password) : undefined,
|
|
189
|
+
proxy: !!a.proxy,
|
|
190
|
+
port: a.port,
|
|
191
|
+
};
|
|
192
|
+
const errors = validateAnswers(answers);
|
|
193
|
+
if (errors.length) return send(400, { error: errors.join(" ") });
|
|
194
|
+
const cfg = buildConfig(readConfig() || prev, answers);
|
|
195
|
+
const p = Number(cfg.PORT);
|
|
196
|
+
if (p !== listenPort && !(await portFree(p, cfg.HOST))) return send(409, { error: `Port ${p} is in use by another program. Pick another port.` });
|
|
197
|
+
writeConfig(cfg);
|
|
198
|
+
return send(200, { ok: true, port: p, url: `http://localhost:${p}/app`, configFile: CONFIG_FILE });
|
|
199
|
+
}
|
|
200
|
+
if (route === "POST /api/setup/finish") {
|
|
201
|
+
const cfg = readConfig();
|
|
202
|
+
if (!cfg) return send(400, { error: "Save your settings first." });
|
|
203
|
+
// Downloads still running (e.g. a voice you switched away from) keep going in
|
|
204
|
+
// this process after Ava starts; the page already waited for the ones you chose.
|
|
205
|
+
send(200, { ok: true, port: Number(cfg.PORT) || DEFAULT_PORT });
|
|
206
|
+
// let the response flush, then hand the port over to Ava
|
|
207
|
+
setTimeout(() => { server.closeAllConnections?.(); server.close(() => finish({ start: true, port: Number(cfg.PORT) || DEFAULT_PORT })); }, 150);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
return send(404, { error: "unknown endpoint" });
|
|
211
|
+
} catch (e) {
|
|
212
|
+
return send(500, { error: e.message });
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
listenPort = await findPort(preferredPort || Number(prev.PORT) || DEFAULT_PORT);
|
|
217
|
+
await new Promise((r) => server.listen(listenPort, "127.0.0.1", r));
|
|
218
|
+
listenPort = server.address().port;
|
|
219
|
+
const url = `http://localhost:${listenPort}/setup#token=${token}`;
|
|
220
|
+
onReady?.({ url, port: listenPort });
|
|
221
|
+
return finished;
|
|
222
|
+
}
|