@elmohq/cli 0.3.0 → 0.4.1
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 +1213 -1258
- package/package.json +6 -6
package/dist/index.js
CHANGED
|
@@ -1,23 +1,249 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import pc from "picocolors";
|
|
4
4
|
import fs from "node:fs/promises";
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
import path from "node:path";
|
|
7
|
-
import {
|
|
7
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
8
|
+
import crypto, { randomBytes } from "node:crypto";
|
|
8
9
|
import * as p from "@clack/prompts";
|
|
9
|
-
import { Command } from "commander";
|
|
10
10
|
import { parse } from "dotenv";
|
|
11
|
-
import pc from "picocolors";
|
|
12
|
-
import semver from "semver";
|
|
13
11
|
import { PostHog } from "posthog-node";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
12
|
+
import semver from "semver";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
//#region src/compose-pin.ts
|
|
15
|
+
function renderedByHeader(version) {
|
|
16
|
+
return [`# Rendered by elmo ${version} on ${(/* @__PURE__ */ new Date()).toISOString()}`, "# Run `elmo upgrade` after upgrading the CLI to refresh this file."].join("\n");
|
|
17
|
+
}
|
|
18
|
+
function parseRenderedVersion(contents) {
|
|
19
|
+
const match = contents.match(/^# Rendered by elmo (\S+) on /m);
|
|
20
|
+
return match ? match[1] : null;
|
|
21
|
+
}
|
|
22
|
+
function refreshHeaderVersion(contents, version) {
|
|
23
|
+
if (!/^# Rendered by elmo \S+ on /m.test(contents)) return `${renderedByHeader(version)}\n${contents}`;
|
|
24
|
+
return contents.replace(/^# Rendered by elmo \S+ on .*$/m, `# Rendered by elmo ${version} on ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
25
|
+
}
|
|
26
|
+
function repinImages(contents, version) {
|
|
27
|
+
return contents.replace(/(image:\s*elmohq\/elmo-[a-z-]+):\S+/g, `$1:${version}`);
|
|
28
|
+
}
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/config.ts
|
|
31
|
+
const CONFIG_HOME = path.join(os.homedir(), ".elmo");
|
|
32
|
+
async function resolveConfigDir(explicitDir) {
|
|
33
|
+
const resolved = explicitDir ? path.resolve(process.cwd(), explicitDir) : CONFIG_HOME;
|
|
34
|
+
if (!await fileExists(path.join(resolved, "elmo.yaml"))) {
|
|
35
|
+
if (explicitDir) throw new Error(`Config directory does not contain elmo.yaml: ${resolved}\nRun \`elmo init --dir ${explicitDir}\` to create it.`);
|
|
36
|
+
throw new Error(`No config found at ${resolved}. Run \`elmo init\` to create one, or specify --dir.`);
|
|
37
|
+
}
|
|
38
|
+
return resolved;
|
|
39
|
+
}
|
|
40
|
+
async function writeConfigFiles(configDir, initConfig) {
|
|
41
|
+
const envPath = path.join(configDir, ".env");
|
|
42
|
+
const composePath = path.join(configDir, "elmo.yaml");
|
|
43
|
+
await ensureDir(configDir);
|
|
44
|
+
await fs.writeFile(envPath, buildEnvFile(initConfig.env, initConfig.version), "utf8");
|
|
45
|
+
await fs.writeFile(composePath, initConfig.composeYaml, "utf8");
|
|
46
|
+
}
|
|
47
|
+
function buildEnvFile(env, version) {
|
|
48
|
+
const lines = [
|
|
49
|
+
renderedByHeader(version),
|
|
50
|
+
"# WARNING: contains secrets. Do not commit.",
|
|
51
|
+
""
|
|
52
|
+
];
|
|
53
|
+
for (const [key, rawValue] of Object.entries(env)) {
|
|
54
|
+
if (rawValue === void 0) continue;
|
|
55
|
+
lines.push(`${key}=${formatEnvValue(rawValue)}`);
|
|
56
|
+
}
|
|
57
|
+
return `${lines.join("\n")}\n`;
|
|
58
|
+
}
|
|
59
|
+
function formatEnvValue(value) {
|
|
60
|
+
if (value === "") return "\"\"";
|
|
61
|
+
if (/[\s#"']/u.test(value)) return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
async function readRenderedVersion(filePath) {
|
|
65
|
+
try {
|
|
66
|
+
return parseRenderedVersion(await fs.readFile(filePath, "utf8"));
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function fileExists(target) {
|
|
72
|
+
try {
|
|
73
|
+
await fs.access(target);
|
|
74
|
+
return true;
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async function ensureDir(dir) {
|
|
80
|
+
await fs.mkdir(dir, { recursive: true });
|
|
81
|
+
}
|
|
82
|
+
//#endregion
|
|
83
|
+
//#region src/util.ts
|
|
84
|
+
const ELMO_ASCII = [
|
|
85
|
+
"",
|
|
86
|
+
" ▄▄ ",
|
|
87
|
+
" ██ ",
|
|
88
|
+
"▄█▀█▄ ██ ███▄███▄ ▄███▄ ",
|
|
89
|
+
"██▄█▀ ██ ██ ██ ██ ██ ██ ",
|
|
90
|
+
"▀█▄▄▄ ██ ██ ██ ██ ▀███▀ ",
|
|
91
|
+
""
|
|
92
|
+
].join("\n");
|
|
93
|
+
function printBanner() {
|
|
94
|
+
console.log(`[38;2;37;99;235m${ELMO_ASCII}[0m`);
|
|
95
|
+
}
|
|
96
|
+
const log = {
|
|
97
|
+
info: (msg) => p.log.info(msg),
|
|
98
|
+
warn: (msg) => p.log.warn(msg),
|
|
99
|
+
error: (msg) => p.log.error(msg),
|
|
100
|
+
success: (msg) => p.log.success(msg),
|
|
101
|
+
step: (msg) => p.log.step(msg)
|
|
102
|
+
};
|
|
103
|
+
function assertNotCancelled(value) {
|
|
104
|
+
if (p.isCancel(value)) {
|
|
105
|
+
p.cancel("Setup cancelled.");
|
|
106
|
+
process.exit(0);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function generateSecret(bytes = 32, encoding = "base64url") {
|
|
110
|
+
return crypto.randomBytes(bytes).toString(encoding);
|
|
111
|
+
}
|
|
112
|
+
function link(text, url) {
|
|
113
|
+
return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
|
|
114
|
+
}
|
|
115
|
+
function sleep(ms) {
|
|
116
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
117
|
+
}
|
|
118
|
+
function indentBlock(block, spaces) {
|
|
119
|
+
const indent = " ".repeat(spaces);
|
|
120
|
+
return block.split("\n").map((line) => `${indent}${line}`).join("\n");
|
|
121
|
+
}
|
|
122
|
+
//#endregion
|
|
123
|
+
//#region src/docker.ts
|
|
124
|
+
async function getComposeServices(configDir) {
|
|
125
|
+
const output = await runDockerComposeCapture(configDir, [
|
|
126
|
+
"ps",
|
|
127
|
+
"--format",
|
|
128
|
+
"json"
|
|
129
|
+
]);
|
|
130
|
+
if (!output.trim()) return [];
|
|
131
|
+
try {
|
|
132
|
+
const trimmed = output.trim();
|
|
133
|
+
const parsed = JSON.parse(trimmed);
|
|
134
|
+
if (Array.isArray(parsed)) return parsed;
|
|
135
|
+
if (typeof parsed === "object" && parsed !== null) return [parsed];
|
|
136
|
+
return [];
|
|
137
|
+
} catch {
|
|
138
|
+
try {
|
|
139
|
+
return output.trim().split("\n").filter((line) => line.trim()).map((line) => JSON.parse(line));
|
|
140
|
+
} catch {
|
|
141
|
+
log.warn("Unable to parse docker compose status.");
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function isServiceReady(service) {
|
|
147
|
+
if (service.Health) return service.Health === "healthy";
|
|
148
|
+
if (service.State?.startsWith("running")) return true;
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
async function waitForHealthy(configDir, timeoutMs) {
|
|
152
|
+
const start = Date.now();
|
|
153
|
+
while (Date.now() - start < timeoutMs) {
|
|
154
|
+
const services = await getComposeServices(configDir);
|
|
155
|
+
if (services.length > 0 && services.every(isServiceReady)) return true;
|
|
156
|
+
await sleep(3e3);
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
async function stackHasRunningServices(configDir) {
|
|
161
|
+
try {
|
|
162
|
+
return (await getComposeServices(configDir)).some((s) => s.State?.startsWith("running") ?? false);
|
|
163
|
+
} catch {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async function composeUsesBuild(composePath) {
|
|
168
|
+
try {
|
|
169
|
+
const contents = await fs.readFile(composePath, "utf8");
|
|
170
|
+
return /^\s*build:/m.test(contents);
|
|
171
|
+
} catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function runDockerCompose(configDir, args) {
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
const commandArgs = [
|
|
178
|
+
"compose",
|
|
179
|
+
"-f",
|
|
180
|
+
path.join(configDir, "elmo.yaml"),
|
|
181
|
+
...args
|
|
182
|
+
];
|
|
183
|
+
spawn("docker", commandArgs, { stdio: "inherit" }).on("close", (code) => {
|
|
184
|
+
if (code === 0) resolve();
|
|
185
|
+
else reject(/* @__PURE__ */ new Error(`docker compose exited with code ${code}`));
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
function runDockerComposeCapture(configDir, args) {
|
|
190
|
+
return new Promise((resolve, reject) => {
|
|
191
|
+
const commandArgs = [
|
|
192
|
+
"compose",
|
|
193
|
+
"-f",
|
|
194
|
+
path.join(configDir, "elmo.yaml"),
|
|
195
|
+
...args
|
|
196
|
+
];
|
|
197
|
+
const child = spawn("docker", commandArgs);
|
|
198
|
+
let stdout = "";
|
|
199
|
+
let stderr = "";
|
|
200
|
+
child.stdout.on("data", (data) => {
|
|
201
|
+
stdout += data.toString();
|
|
202
|
+
});
|
|
203
|
+
child.stderr.on("data", (data) => {
|
|
204
|
+
stderr += data.toString();
|
|
205
|
+
});
|
|
206
|
+
child.on("close", (code) => {
|
|
207
|
+
if (code === 0) resolve(stdout);
|
|
208
|
+
else reject(new Error(stderr || `docker compose exited with code ${code}`));
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
function assertDockerRunning() {
|
|
213
|
+
const result = spawnSync("docker", ["info"], { stdio: "ignore" });
|
|
214
|
+
if (result.error?.code === "ENOENT") throw new Error("Docker does not appear to be installed. Install Docker Desktop or Docker Engine and try again: https://docs.docker.com/get-docker/");
|
|
215
|
+
if (result.error) throw new Error(`Could not run Docker: ${result.error.message}`);
|
|
216
|
+
if (result.status !== 0) throw new Error("Docker does not appear to be running. Start Docker and try again.");
|
|
217
|
+
}
|
|
218
|
+
//#endregion
|
|
219
|
+
//#region src/commands/compose.ts
|
|
220
|
+
async function runCompose(args, options) {
|
|
221
|
+
const configDir = await resolveConfigDir(options.dir);
|
|
222
|
+
assertDockerRunning();
|
|
223
|
+
await runDockerCompose(configDir, args);
|
|
224
|
+
}
|
|
225
|
+
//#endregion
|
|
226
|
+
//#region src/commands/edit.ts
|
|
227
|
+
async function runEdit(target, options) {
|
|
228
|
+
const configDir = await resolveConfigDir(options.dir);
|
|
229
|
+
let filePath;
|
|
230
|
+
if (target === "env") filePath = path.join(configDir, ".env");
|
|
231
|
+
else if (target === "compose") filePath = path.join(configDir, "elmo.yaml");
|
|
232
|
+
else throw new Error(`Unknown edit target: ${target}. Use \`env\` or \`compose\`.`);
|
|
233
|
+
if (!await fileExists(filePath)) throw new Error(`File not found: ${filePath}`);
|
|
234
|
+
const parts = (process.env.VISUAL || process.env.EDITOR || "nano").split(/\s+/).filter(Boolean);
|
|
235
|
+
const cmd = parts[0] ?? "nano";
|
|
236
|
+
const args = [...parts.slice(1), filePath];
|
|
237
|
+
await new Promise((resolve, reject) => {
|
|
238
|
+
const child = spawn(cmd, args, { stdio: "inherit" });
|
|
239
|
+
child.on("close", (code) => {
|
|
240
|
+
if (code === 0) resolve();
|
|
241
|
+
else reject(/* @__PURE__ */ new Error(`${cmd} exited with code ${code}`));
|
|
242
|
+
});
|
|
243
|
+
child.on("error", (err) => reject(err));
|
|
244
|
+
});
|
|
245
|
+
log.info("Restart the stack with `elmo compose up -d` to apply changes.");
|
|
246
|
+
}
|
|
21
247
|
//#endregion
|
|
22
248
|
//#region ../../packages/config/src/referrals.ts
|
|
23
249
|
/**
|
|
@@ -121,6 +347,14 @@ const PLAN_KEYS = Object.keys(PLANS);
|
|
|
121
347
|
/** Lowest self-serve monthly price among all plans. */
|
|
122
348
|
const CLOUD_ENTRY_PRICE_USD = Math.min(...PLAN_KEYS.map((key) => PLANS[key].monthlyPriceUsd));
|
|
123
349
|
//#endregion
|
|
350
|
+
//#region ../../packages/config/src/constants.ts
|
|
351
|
+
/**
|
|
352
|
+
* Provider setup guide, linked from SCRAPE_TARGETS errors and the LLMs page.
|
|
353
|
+
* Per-provider anchors are appended to it, so it has to stay the page itself
|
|
354
|
+
* rather than the docs index.
|
|
355
|
+
*/
|
|
356
|
+
const PROVIDERS_DOCS_URL = "https://www.elmohq.com/docs/user-guide/providers";
|
|
357
|
+
//#endregion
|
|
124
358
|
//#region ../../packages/config/src/scrape-targets.ts
|
|
125
359
|
/**
|
|
126
360
|
* The single module that parses and formats the SCRAPE_TARGETS env var.
|
|
@@ -170,364 +404,130 @@ function formatScrapeTarget(config) {
|
|
|
170
404
|
return parts.join(":");
|
|
171
405
|
}
|
|
172
406
|
//#endregion
|
|
173
|
-
//#region src/compose
|
|
174
|
-
function
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
function parseRenderedVersion(contents) {
|
|
178
|
-
const match = contents.match(/^# Rendered by elmo (\S+) on /m);
|
|
179
|
-
return match ? match[1] : null;
|
|
180
|
-
}
|
|
181
|
-
function refreshHeaderVersion(contents, version) {
|
|
182
|
-
if (!/^# Rendered by elmo \S+ on /m.test(contents)) return `${renderedByHeader(version)}\n${contents}`;
|
|
183
|
-
return contents.replace(/^# Rendered by elmo \S+ on .*$/m, `# Rendered by elmo ${version} on ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
407
|
+
//#region src/compose.ts
|
|
408
|
+
async function repinComposeImages(composePath, version) {
|
|
409
|
+
const contents = await fs.readFile(composePath, "utf8");
|
|
410
|
+
await fs.writeFile(composePath, refreshHeaderVersion(repinImages(contents, version), version), "utf8");
|
|
184
411
|
}
|
|
185
|
-
function
|
|
186
|
-
|
|
412
|
+
async function refreshRenderedVersion(filePath, version) {
|
|
413
|
+
try {
|
|
414
|
+
const contents = await fs.readFile(filePath, "utf8");
|
|
415
|
+
await fs.writeFile(filePath, refreshHeaderVersion(contents, version), "utf8");
|
|
416
|
+
} catch {}
|
|
187
417
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
418
|
+
function buildComposeYaml(options) {
|
|
419
|
+
const services = [];
|
|
420
|
+
const volumes = /* @__PURE__ */ new Set();
|
|
421
|
+
const dependsOnWeb = [];
|
|
422
|
+
const dependsOnWorker = [];
|
|
423
|
+
const dependencyConditions = {
|
|
424
|
+
postgres: "service_healthy",
|
|
425
|
+
"db-migrate": "service_completed_successfully"
|
|
426
|
+
};
|
|
427
|
+
const dockerfilePath = options.dockerDir ? path.relative(options.repoRoot, path.join(options.dockerDir, "Dockerfile")) : "docker/Dockerfile";
|
|
428
|
+
if (options.postgresMode === "docker") {
|
|
429
|
+
services.push(buildPostgresService());
|
|
430
|
+
services.push(buildDbMigrateService({
|
|
431
|
+
dev: options.dev,
|
|
432
|
+
dockerfilePath,
|
|
433
|
+
repoRoot: options.repoRoot,
|
|
434
|
+
version: options.version
|
|
435
|
+
}));
|
|
436
|
+
dependsOnWeb.push("db-migrate");
|
|
437
|
+
dependsOnWorker.push("db-migrate");
|
|
438
|
+
volumes.add("postgres_data");
|
|
199
439
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
440
|
+
services.push(buildWebService({
|
|
441
|
+
dev: options.dev,
|
|
442
|
+
dependsOn: dependsOnWeb,
|
|
443
|
+
dependencyConditions,
|
|
444
|
+
repoRoot: options.repoRoot,
|
|
445
|
+
dockerfilePath,
|
|
446
|
+
port: options.port,
|
|
447
|
+
version: options.version
|
|
448
|
+
}));
|
|
449
|
+
services.push(buildWorkerService({
|
|
450
|
+
dev: options.dev,
|
|
451
|
+
dependsOn: dependsOnWorker,
|
|
452
|
+
dependencyConditions,
|
|
453
|
+
repoRoot: options.repoRoot,
|
|
454
|
+
dockerfilePath,
|
|
455
|
+
version: options.version
|
|
456
|
+
}));
|
|
457
|
+
const lines = [
|
|
458
|
+
renderedByHeader(options.version),
|
|
459
|
+
"",
|
|
460
|
+
"name: elmo",
|
|
461
|
+
"",
|
|
462
|
+
"services:"
|
|
463
|
+
];
|
|
464
|
+
lines.push(...services.map((service) => indentBlock(service, 2)));
|
|
465
|
+
if (volumes.size > 0) {
|
|
466
|
+
lines.push("", "volumes:");
|
|
467
|
+
for (const volume of volumes) lines.push(` ${volume}:`);
|
|
205
468
|
}
|
|
206
|
-
return
|
|
469
|
+
return `${lines.join("\n")}\n`;
|
|
207
470
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
471
|
+
function buildPostgresService() {
|
|
472
|
+
return [
|
|
473
|
+
"postgres:",
|
|
474
|
+
" image: postgres:18-alpine",
|
|
475
|
+
" environment:",
|
|
476
|
+
" POSTGRES_USER: postgres",
|
|
477
|
+
" POSTGRES_PASSWORD: postgres",
|
|
478
|
+
" POSTGRES_DB: elmo",
|
|
479
|
+
" volumes:",
|
|
480
|
+
" - postgres_data:/var/lib/postgresql",
|
|
481
|
+
" ports:",
|
|
482
|
+
" - \"5432:5432\"",
|
|
483
|
+
" healthcheck:",
|
|
484
|
+
" test: [\"CMD-SHELL\", \"pg_isready -U postgres\"]",
|
|
485
|
+
" interval: 5s",
|
|
486
|
+
" timeout: 5s",
|
|
487
|
+
" retries: 5",
|
|
488
|
+
" start_period: 30s"
|
|
489
|
+
].join("\n");
|
|
213
490
|
}
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
async run(ctx) {
|
|
221
|
-
const env = await ctx.readEnv();
|
|
222
|
-
if (env.ELMO_ENCRYPTION_KEY !== void 0) return;
|
|
223
|
-
env.ELMO_ENCRYPTION_KEY = randomBytes(32).toString("base64");
|
|
224
|
-
await ctx.writeEnv(env);
|
|
225
|
-
ctx.log.info("Added ELMO_ENCRYPTION_KEY — lets you store provider credentials encrypted in the app.");
|
|
226
|
-
}
|
|
227
|
-
}];
|
|
228
|
-
//#endregion
|
|
229
|
-
//#region src/telemetry.ts
|
|
230
|
-
const POSTHOG_PUBLIC_KEY = "phc_Jhx9LnI9cTDFHpQmpOzJSDTW127qD9pFU65KRnYym6z";
|
|
231
|
-
const POSTHOG_HOST = "https://us.i.posthog.com";
|
|
232
|
-
function envDisabled() {
|
|
233
|
-
return Boolean(process.env.DISABLE_TELEMETRY);
|
|
491
|
+
function buildDbMigrateService(options) {
|
|
492
|
+
const lines = ["db-migrate:"];
|
|
493
|
+
if (options.dev) lines.push(" build:", ` context: ${options.repoRoot}`, ` dockerfile: ${options.dockerfilePath}`, " target: migrate");
|
|
494
|
+
else lines.push(` image: elmohq/elmo-db-migrate:${options.version}`);
|
|
495
|
+
lines.push(" environment:", " - DATABASE_URL=postgres://postgres:postgres@postgres:5432/elmo", " depends_on:", " postgres:", " condition: service_healthy");
|
|
496
|
+
return lines.join("\n");
|
|
234
497
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
498
|
+
function buildWebService(options) {
|
|
499
|
+
const lines = ["web:"];
|
|
500
|
+
if (options.dev) lines.push(" build:", ` context: ${options.repoRoot}`, ` dockerfile: ${options.dockerfilePath}`, " target: web", " args:", " DEPLOYMENT_MODE: local");
|
|
501
|
+
else lines.push(` image: elmohq/elmo-web:${options.version}`);
|
|
502
|
+
lines.push(" env_file:", " - path: .env", " required: true", " ports:", ` - "${options.port}:3000"`);
|
|
503
|
+
if (options.dependsOn.length > 0) {
|
|
504
|
+
lines.push(" depends_on:");
|
|
505
|
+
for (const service of options.dependsOn) {
|
|
506
|
+
const condition = options.dependencyConditions[service] ?? "service_started";
|
|
507
|
+
lines.push(` ${service}:`, ` condition: ${condition}`);
|
|
508
|
+
}
|
|
241
509
|
}
|
|
510
|
+
return lines.join("\n");
|
|
242
511
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
event: eventName,
|
|
256
|
-
properties: {
|
|
257
|
-
...properties,
|
|
258
|
-
...personProperties ? { $set: personProperties } : {}
|
|
259
|
-
}
|
|
260
|
-
});
|
|
261
|
-
await client.shutdown();
|
|
262
|
-
} catch {}
|
|
263
|
-
}
|
|
264
|
-
async function submitNewsletterSignup(configDir, email) {
|
|
265
|
-
try {
|
|
266
|
-
const distinctId = (await isTelemetryDisabled(configDir) ? null : await readEnvKey(configDir, "DEPLOYMENT_ID")) ?? email;
|
|
267
|
-
const client = new PostHog(POSTHOG_PUBLIC_KEY, { host: POSTHOG_HOST });
|
|
268
|
-
client.capture({
|
|
269
|
-
distinctId,
|
|
270
|
-
event: "newsletter_signup",
|
|
271
|
-
properties: {
|
|
272
|
-
source: "cli_init",
|
|
273
|
-
$set: {
|
|
274
|
-
email,
|
|
275
|
-
wants_updates: true
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
});
|
|
279
|
-
await client.shutdown();
|
|
280
|
-
} catch {}
|
|
281
|
-
}
|
|
282
|
-
//#endregion
|
|
283
|
-
//#region src/index.ts
|
|
284
|
-
const CONFIG_HOME = path.join(os.homedir(), ".elmo");
|
|
285
|
-
const DEFAULT_APP_NAME = "Elmo";
|
|
286
|
-
const DEFAULT_APP_ICON = "/icons/elmo-icon.svg";
|
|
287
|
-
const DEFAULT_APP_PORT = 1515;
|
|
288
|
-
const LOCAL_DATABASE_URL = "postgres://postgres:postgres@postgres:5432/elmo";
|
|
289
|
-
const TELEMETRY_DOC_URL = "https://elmohq.com/docs/developer-guide/telemetry";
|
|
290
|
-
const CLOUD_PRICING_URL = cloudPricingUrl("cli");
|
|
291
|
-
const ELMO_ASCII = [
|
|
292
|
-
"",
|
|
293
|
-
" ▄▄ ",
|
|
294
|
-
" ██ ",
|
|
295
|
-
"▄█▀█▄ ██ ███▄███▄ ▄███▄ ",
|
|
296
|
-
"██▄█▀ ██ ██ ██ ██ ██ ██ ",
|
|
297
|
-
"▀█▄▄▄ ██ ██ ██ ██ ▀███▀ ",
|
|
298
|
-
""
|
|
299
|
-
].join("\n");
|
|
300
|
-
function printBanner() {
|
|
301
|
-
console.log(`[38;2;37;99;235m${ELMO_ASCII}[0m`);
|
|
302
|
-
}
|
|
303
|
-
const log = {
|
|
304
|
-
info: (msg) => p.log.info(msg),
|
|
305
|
-
warn: (msg) => p.log.warn(msg),
|
|
306
|
-
error: (msg) => p.log.error(msg),
|
|
307
|
-
success: (msg) => p.log.success(msg),
|
|
308
|
-
step: (msg) => p.log.step(msg)
|
|
309
|
-
};
|
|
310
|
-
function assertNotCancelled(value) {
|
|
311
|
-
if (p.isCancel(value)) {
|
|
312
|
-
p.cancel("Setup cancelled.");
|
|
313
|
-
process.exit(0);
|
|
512
|
+
function buildWorkerService(options) {
|
|
513
|
+
const lines = ["worker:"];
|
|
514
|
+
if (options.dev) lines.push(" build:", ` context: ${options.repoRoot}`, ` dockerfile: ${options.dockerfilePath}`, " target: worker", " args:", " DEPLOYMENT_MODE: local");
|
|
515
|
+
else lines.push(` image: elmohq/elmo-worker:${options.version}`);
|
|
516
|
+
lines.push(" env_file:", " - path: .env", " required: true");
|
|
517
|
+
lines.push(" stop_grace_period: 35s");
|
|
518
|
+
if (options.dependsOn.length > 0) {
|
|
519
|
+
lines.push(" depends_on:");
|
|
520
|
+
for (const service of options.dependsOn) {
|
|
521
|
+
const condition = options.dependencyConditions[service] ?? "service_started";
|
|
522
|
+
lines.push(` ${service}:`, ` condition: ${condition}`);
|
|
523
|
+
}
|
|
314
524
|
}
|
|
525
|
+
return lines.join("\n");
|
|
315
526
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
|
|
321
|
-
}
|
|
322
|
-
async function main() {
|
|
323
|
-
const version = await getPackageVersion();
|
|
324
|
-
const program = new Command();
|
|
325
|
-
program.name("elmo").version(version).option("--dir <path>", "Config directory").configureHelp({ showGlobalOptions: true }).action(() => {
|
|
326
|
-
printBanner();
|
|
327
|
-
program.outputHelp();
|
|
328
|
-
});
|
|
329
|
-
program.command("init").description("set up local Elmo instance").option("--dev", "Use local build context (repo only)").option("--docker-dir <path>", "Path to Docker build context (dev mode)").action(async (_opts, cmd) => {
|
|
330
|
-
await withVersionCheck(version, () => runInit(cmd.optsWithGlobals(), version));
|
|
331
|
-
});
|
|
332
|
-
program.command("compose").description("run Docker Compose commands using your Elmo config").allowUnknownOption(true).argument("[args...]", "Arguments passed to Docker Compose").action(async (args, _opts, cmd) => {
|
|
333
|
-
await withVersionCheck(version, () => runCompose(args, cmd.optsWithGlobals()));
|
|
334
|
-
});
|
|
335
|
-
program.command("edit").description("change API keys, scrape targets, or the Docker Compose YAML").argument("<env|compose>", "which config file to edit").action(async (target, _opts, cmd) => {
|
|
336
|
-
await runEdit(target, cmd.optsWithGlobals());
|
|
337
|
-
});
|
|
338
|
-
program.command("upgrade").description("upgrade your Elmo deployment to this CLI's version").option("--yes", "skip confirmation prompts").action(async (_opts, cmd) => {
|
|
339
|
-
await runUpgrade(cmd.optsWithGlobals(), version);
|
|
340
|
-
});
|
|
341
|
-
await program.parseAsync(process.argv);
|
|
342
|
-
}
|
|
343
|
-
async function withVersionCheck(version, fn) {
|
|
344
|
-
const notifyPromise = maybeNotifyNewVersion(version);
|
|
345
|
-
await fn();
|
|
346
|
-
await notifyPromise.catch(() => void 0);
|
|
347
|
-
}
|
|
348
|
-
/**
|
|
349
|
-
* Guard an existing config from being replaced by accident. Returns the
|
|
350
|
-
* deployment id to carry over, so a re-run keeps reporting as the same install.
|
|
351
|
-
*/
|
|
352
|
-
async function confirmEnvOverwrite(configDir) {
|
|
353
|
-
const existingEnvPath = path.join(configDir, ".env");
|
|
354
|
-
if (!await fileExists(existingEnvPath)) return void 0;
|
|
355
|
-
const contents = await fs.readFile(existingEnvPath, "utf8");
|
|
356
|
-
const isElmoEnv = contents.startsWith("# Rendered by elmo") || contents.startsWith("# Generated by elmo");
|
|
357
|
-
p.log.warn(isElmoEnv ? `An existing Elmo config was found at ${configDir}.` : `A .env file already exists in ${configDir} and was NOT created by Elmo.`);
|
|
358
|
-
const overwrite = await p.confirm({
|
|
359
|
-
message: isElmoEnv ? "Overwrite it with new values? Existing secrets (DATABASE_URL, API keys, etc.) will be replaced." : "Overwrite the existing .env file? This cannot be undone.",
|
|
360
|
-
initialValue: false
|
|
361
|
-
});
|
|
362
|
-
assertNotCancelled(overwrite);
|
|
363
|
-
if (!overwrite) {
|
|
364
|
-
p.cancel(isElmoEnv ? "Setup cancelled. Use `elmo edit env` to change individual values." : "Setup cancelled. Choose a different directory with --dir.");
|
|
365
|
-
process.exit(0);
|
|
366
|
-
}
|
|
367
|
-
return isElmoEnv ? parse(contents).DEPLOYMENT_ID : void 0;
|
|
368
|
-
}
|
|
369
|
-
async function resolveDockerLocation(options, cwd) {
|
|
370
|
-
if (!options.dev) return {
|
|
371
|
-
dockerDir: void 0,
|
|
372
|
-
repoRoot: cwd
|
|
373
|
-
};
|
|
374
|
-
if (!options.dockerDir) {
|
|
375
|
-
const dockerDir = await resolveDockerDirInteractive(cwd);
|
|
376
|
-
return {
|
|
377
|
-
dockerDir,
|
|
378
|
-
repoRoot: path.resolve(dockerDir, "..")
|
|
379
|
-
};
|
|
380
|
-
}
|
|
381
|
-
const dockerDir = path.resolve(cwd, options.dockerDir);
|
|
382
|
-
if (!await fileExists(path.join(dockerDir, "Dockerfile"))) {
|
|
383
|
-
p.log.error(`Dockerfile not found in ${dockerDir}`);
|
|
384
|
-
process.exit(1);
|
|
385
|
-
}
|
|
386
|
-
return {
|
|
387
|
-
dockerDir,
|
|
388
|
-
repoRoot: path.resolve(dockerDir, "..")
|
|
389
|
-
};
|
|
390
|
-
}
|
|
391
|
-
function validatePort(value) {
|
|
392
|
-
if (!value) return void 0;
|
|
393
|
-
const port = Number(value);
|
|
394
|
-
return Number.isInteger(port) && port >= 1 && port <= 65535 ? void 0 : "Must be an integer between 1 and 65535";
|
|
395
|
-
}
|
|
396
|
-
async function runInit(options, version) {
|
|
397
|
-
printBanner();
|
|
398
|
-
p.intro(pc.bold("Setting up Elmo"));
|
|
399
|
-
p.note([
|
|
400
|
-
"Self-hosting Elmo is free, and always will be. But it does mean",
|
|
401
|
-
"running Postgres and a worker, and opening your own scraper and",
|
|
402
|
-
"model provider accounts.",
|
|
403
|
-
"",
|
|
404
|
-
`Elmo Cloud is the same open-source product, hosted by us, from`,
|
|
405
|
-
`${pc.bold(`$${CLOUD_ENTRY_PRICE_USD}/month`)} — scraper and model access included, nothing to`,
|
|
406
|
-
"keep running.",
|
|
407
|
-
"",
|
|
408
|
-
` ${link(pc.cyan(CLOUD_PRICING_URL), CLOUD_PRICING_URL)}`
|
|
409
|
-
].join("\n"), "Prefer not to self-host?");
|
|
410
|
-
const cwd = process.cwd();
|
|
411
|
-
const configDir = options.dir ? path.resolve(cwd, options.dir) : CONFIG_HOME;
|
|
412
|
-
const preservedDeploymentId = await confirmEnvOverwrite(configDir);
|
|
413
|
-
const { dockerDir, repoRoot } = await resolveDockerLocation(options, cwd);
|
|
414
|
-
const postgresMode = await p.select({
|
|
415
|
-
message: "PostgreSQL connection",
|
|
416
|
-
options: [{
|
|
417
|
-
value: "docker",
|
|
418
|
-
label: "Run Postgres in Docker"
|
|
419
|
-
}, {
|
|
420
|
-
value: "external",
|
|
421
|
-
label: "Use existing Postgres (provide DATABASE_URL)"
|
|
422
|
-
}],
|
|
423
|
-
initialValue: "docker"
|
|
424
|
-
});
|
|
425
|
-
assertNotCancelled(postgresMode);
|
|
426
|
-
const env = {};
|
|
427
|
-
env.DEPLOYMENT_MODE = "local";
|
|
428
|
-
env.VITE_DEPLOYMENT_MODE = "local";
|
|
429
|
-
env.DEPLOYMENT_ID = preservedDeploymentId ?? crypto.randomUUID();
|
|
430
|
-
env.BETTER_AUTH_SECRET = generateSecret();
|
|
431
|
-
env.ELMO_ENCRYPTION_KEY = generateSecret(32, "base64");
|
|
432
|
-
env.APP_NAME = DEFAULT_APP_NAME;
|
|
433
|
-
env.APP_ICON = DEFAULT_APP_ICON;
|
|
434
|
-
env.VITE_APP_NAME = DEFAULT_APP_NAME;
|
|
435
|
-
env.VITE_APP_ICON = DEFAULT_APP_ICON;
|
|
436
|
-
if (postgresMode === "external") {
|
|
437
|
-
p.note("Must be an IPv4-compatible direct connection or database pooler.", "DATABASE_URL");
|
|
438
|
-
const url = await p.password({
|
|
439
|
-
message: "DATABASE_URL",
|
|
440
|
-
validate: (v) => !v ? "Required" : void 0
|
|
441
|
-
});
|
|
442
|
-
assertNotCancelled(url);
|
|
443
|
-
env.DATABASE_URL = url;
|
|
444
|
-
} else env.DATABASE_URL = LOCAL_DATABASE_URL;
|
|
445
|
-
const setupMode = await configureProvidersInteractive(env);
|
|
446
|
-
p.note([
|
|
447
|
-
"Elmo is open source and maintained by a small team. Telemetry",
|
|
448
|
-
"from both the CLI and your local deployment (web + worker)",
|
|
449
|
-
"tells us things like which CLI versions are still in use, where",
|
|
450
|
-
"`elmo init` drops off, which providers people pick, and whether",
|
|
451
|
-
"new features actually get used. Without it we are flying blind",
|
|
452
|
-
"on what to fix or build next.",
|
|
453
|
-
"",
|
|
454
|
-
pc.bold("What we send:"),
|
|
455
|
-
" • deployment ID (random UUID stored as DEPLOYMENT_ID in your .env)",
|
|
456
|
-
" • CLI/app version, OS, arch, Node version, deployment mode",
|
|
457
|
-
" • command/event names + non-secret options (e.g. postgres mode)",
|
|
458
|
-
" • feature counts (prompts edited, brands created — never the names or text)",
|
|
459
|
-
" • IP address (recorded on each event by PostHog, used for geolocation)",
|
|
460
|
-
"",
|
|
461
|
-
pc.bold("What we never send:"),
|
|
462
|
-
" API keys, .env contents, brand names, prompt text, and scraped responses.",
|
|
463
|
-
"",
|
|
464
|
-
`Full breakdown: ${link(pc.cyan(TELEMETRY_DOC_URL), TELEMETRY_DOC_URL)}`,
|
|
465
|
-
"Toggle later by editing DISABLE_TELEMETRY in .env (`elmo edit env`)."
|
|
466
|
-
].join("\n"), "Telemetry");
|
|
467
|
-
const telemetryEnabled = await p.confirm({
|
|
468
|
-
message: "Share telemetry?",
|
|
469
|
-
initialValue: true
|
|
470
|
-
});
|
|
471
|
-
assertNotCancelled(telemetryEnabled);
|
|
472
|
-
if (!telemetryEnabled) env.DISABLE_TELEMETRY = "1";
|
|
473
|
-
const updatesEmail = await p.text({
|
|
474
|
-
message: "Enter your work email to receive product updates (optional)",
|
|
475
|
-
placeholder: "you@example.com"
|
|
476
|
-
});
|
|
477
|
-
const email = p.isCancel(updatesEmail) ? void 0 : updatesEmail || void 0;
|
|
478
|
-
const portInput = await p.text({
|
|
479
|
-
message: "Web app port",
|
|
480
|
-
placeholder: String(DEFAULT_APP_PORT),
|
|
481
|
-
defaultValue: String(DEFAULT_APP_PORT),
|
|
482
|
-
validate: validatePort
|
|
483
|
-
});
|
|
484
|
-
assertNotCancelled(portInput);
|
|
485
|
-
const port = Number(portInput);
|
|
486
|
-
env.APP_URL = `http://localhost:${port}`;
|
|
487
|
-
env.VITE_APP_URL = env.APP_URL;
|
|
488
|
-
const composeYaml = buildComposeYaml({
|
|
489
|
-
dev: Boolean(options.dev),
|
|
490
|
-
postgresMode,
|
|
491
|
-
repoRoot,
|
|
492
|
-
dockerDir,
|
|
493
|
-
port,
|
|
494
|
-
version
|
|
495
|
-
});
|
|
496
|
-
await ensureDir(configDir);
|
|
497
|
-
await writeConfigFiles(configDir, {
|
|
498
|
-
env,
|
|
499
|
-
composeYaml,
|
|
500
|
-
postgresMode,
|
|
501
|
-
dev: Boolean(options.dev),
|
|
502
|
-
version
|
|
503
|
-
});
|
|
504
|
-
p.log.success(`Config written to ${configDir}`);
|
|
505
|
-
p.log.warn("Your generated .env file contains secrets — do not commit it to version control.");
|
|
506
|
-
if (options.dev) p.log.info("Dev mode enabled. Run `elmo compose build` before starting.");
|
|
507
|
-
const shouldStart = await p.confirm({
|
|
508
|
-
message: "Start the stack now?",
|
|
509
|
-
initialValue: true
|
|
510
|
-
});
|
|
511
|
-
assertNotCancelled(shouldStart);
|
|
512
|
-
if (shouldStart) await doStart(configDir);
|
|
513
|
-
else p.log.info("You can start later with `elmo compose up -d`.");
|
|
514
|
-
await trackCliEvent(configDir, "cli_init", {
|
|
515
|
-
version,
|
|
516
|
-
os: process.platform,
|
|
517
|
-
arch: process.arch,
|
|
518
|
-
node_version: process.version,
|
|
519
|
-
postgres_mode: postgresMode,
|
|
520
|
-
dev_mode: Boolean(options.dev),
|
|
521
|
-
setup_mode: setupMode,
|
|
522
|
-
has_scraper: Boolean(env.BRIGHTDATA_API_TOKEN || env.OLOSTEP_API_KEY || env.OXYLABS_USERNAME || env.CLORO_API_KEY),
|
|
523
|
-
has_direct_api: hasDirectApiConfigured(env)
|
|
524
|
-
});
|
|
525
|
-
if (email) await submitNewsletterSignup(configDir, email);
|
|
526
|
-
p.log.message(`If you find Elmo useful, star us on GitHub!\n ${link(pc.cyan("https://github.com/elmohq/elmo"), "https://github.com/elmohq/elmo")}`);
|
|
527
|
-
p.outro(pc.green("Setup complete!"));
|
|
528
|
-
}
|
|
529
|
-
const CLORO_AFFILIATE = "https://cloro.dev?fpr=elmo";
|
|
530
|
-
const BRIGHTDATA_AFFILIATE = "https://get.brightdata.com/67h1b7h0shcn";
|
|
527
|
+
//#endregion
|
|
528
|
+
//#region src/providers.ts
|
|
529
|
+
const CLORO_AFFILIATE = "https://affiliate.cloro.dev/elmo?sid=cli";
|
|
530
|
+
const BRIGHTDATA_AFFILIATE = "https://get.brightdata.com/elmo?sid=cli";
|
|
531
531
|
const OXYLABS_AFFILIATE = "https://oxylabs.go2cloud.org/aff_c?offer_id=7&aff_id=2263&url_id=32";
|
|
532
532
|
const OLOSTEP_AFFILIATE = "https://olostep.com/?ref=elmo";
|
|
533
533
|
const DATAFORSEO_AFFILIATE = "https://dataforseo.com/?aff=184966";
|
|
@@ -588,482 +588,298 @@ const DEFAULT_OPENAI_MODEL = "gpt-5-mini";
|
|
|
588
588
|
const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-5";
|
|
589
589
|
const DEFAULT_OPENROUTER_MODEL = "anthropic/claude-sonnet-5";
|
|
590
590
|
const DEFAULT_MISTRAL_MODEL = "mistral-medium-latest";
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
message: "Setup mode",
|
|
611
|
-
options: [{
|
|
612
|
-
value: "recommended",
|
|
613
|
-
label: "Recommended — one scraper + one direct API"
|
|
614
|
-
}, {
|
|
615
|
-
value: "custom",
|
|
616
|
-
label: "Custom — pick each provider individually"
|
|
617
|
-
}],
|
|
618
|
-
initialValue: "recommended"
|
|
619
|
-
});
|
|
620
|
-
assertNotCancelled(mode);
|
|
621
|
-
if (mode === "recommended") await configureProvidersRecommended(env);
|
|
622
|
-
else await configureProvidersCustom(env);
|
|
623
|
-
return mode;
|
|
591
|
+
const required = (v) => !v ? "Required" : void 0;
|
|
592
|
+
function scraperPicker(label, provider, models) {
|
|
593
|
+
return {
|
|
594
|
+
message: `LLM Providers to track via ${label}`,
|
|
595
|
+
options: models.map((model) => ({
|
|
596
|
+
value: formatScrapeTarget({
|
|
597
|
+
model,
|
|
598
|
+
provider,
|
|
599
|
+
webSearch: true
|
|
600
|
+
}),
|
|
601
|
+
label: model
|
|
602
|
+
})),
|
|
603
|
+
initialValues: DEFAULT_SCRAPER_MODELS.map((model) => formatScrapeTarget({
|
|
604
|
+
model,
|
|
605
|
+
provider,
|
|
606
|
+
webSearch: true
|
|
607
|
+
})),
|
|
608
|
+
required: true
|
|
609
|
+
};
|
|
624
610
|
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
611
|
+
const PROVIDER_PROMPTS = [
|
|
612
|
+
{
|
|
613
|
+
id: "openrouter",
|
|
614
|
+
kind: "direct",
|
|
615
|
+
label: "OpenRouter",
|
|
616
|
+
costHint: "one key, many hosted models",
|
|
617
|
+
confirmDefault: false,
|
|
618
|
+
credentials: [{
|
|
619
|
+
envKey: "OPENROUTER_API_KEY",
|
|
620
|
+
message: "OpenRouter API key",
|
|
621
|
+
secret: true,
|
|
622
|
+
validate: required
|
|
623
|
+
}],
|
|
624
|
+
model: {
|
|
625
|
+
message: "OpenRouter model slug",
|
|
626
|
+
default: DEFAULT_OPENROUTER_MODEL
|
|
627
|
+
},
|
|
628
|
+
webSearch: true,
|
|
629
|
+
target: {
|
|
630
|
+
model: "claude",
|
|
631
|
+
provider: "openrouter"
|
|
632
|
+
}
|
|
633
|
+
},
|
|
634
|
+
{
|
|
635
|
+
id: "anthropic",
|
|
636
|
+
kind: "direct",
|
|
637
|
+
label: "Anthropic API",
|
|
638
|
+
costHint: "direct Claude — ~$4–5/mo per prompt per model",
|
|
639
|
+
confirmDefault: false,
|
|
640
|
+
credentials: [{
|
|
641
|
+
envKey: "ANTHROPIC_API_KEY",
|
|
642
|
+
message: "Anthropic API key",
|
|
643
|
+
secret: true,
|
|
644
|
+
validate: required
|
|
645
|
+
}],
|
|
646
|
+
model: {
|
|
647
|
+
message: "Claude model",
|
|
648
|
+
default: DEFAULT_ANTHROPIC_MODEL
|
|
649
|
+
},
|
|
650
|
+
webSearch: true,
|
|
651
|
+
target: {
|
|
652
|
+
model: "claude",
|
|
653
|
+
provider: "anthropic-api"
|
|
654
|
+
}
|
|
655
|
+
},
|
|
656
|
+
{
|
|
657
|
+
id: "openai",
|
|
658
|
+
kind: "direct",
|
|
659
|
+
label: "OpenAI API",
|
|
660
|
+
costHint: "gpt-* with web search — not the real ChatGPT UI",
|
|
661
|
+
confirmDefault: false,
|
|
662
|
+
credentials: [{
|
|
663
|
+
envKey: "OPENAI_API_KEY",
|
|
664
|
+
message: "OpenAI API key",
|
|
665
|
+
secret: true,
|
|
666
|
+
validate: required
|
|
667
|
+
}],
|
|
668
|
+
model: {
|
|
669
|
+
message: "OpenAI model",
|
|
670
|
+
default: DEFAULT_OPENAI_MODEL
|
|
671
|
+
},
|
|
672
|
+
webSearch: true,
|
|
673
|
+
target: {
|
|
674
|
+
model: "chatgpt",
|
|
675
|
+
provider: "openai-api"
|
|
676
|
+
}
|
|
677
|
+
},
|
|
678
|
+
{
|
|
679
|
+
id: "mistral",
|
|
680
|
+
kind: "direct",
|
|
681
|
+
label: "Mistral API",
|
|
682
|
+
costHint: "direct Mistral models",
|
|
683
|
+
confirmDefault: false,
|
|
684
|
+
credentials: [{
|
|
685
|
+
envKey: "MISTRAL_API_KEY",
|
|
686
|
+
message: "Mistral API key",
|
|
687
|
+
secret: true,
|
|
688
|
+
validate: required
|
|
689
|
+
}],
|
|
690
|
+
model: {
|
|
691
|
+
message: "Mistral model",
|
|
692
|
+
default: DEFAULT_MISTRAL_MODEL
|
|
693
|
+
},
|
|
694
|
+
webSearch: true,
|
|
695
|
+
target: {
|
|
696
|
+
model: "mistral",
|
|
697
|
+
provider: "mistral-api"
|
|
698
|
+
}
|
|
699
|
+
},
|
|
700
|
+
{
|
|
701
|
+
id: "cloro",
|
|
702
|
+
kind: "scraper",
|
|
703
|
+
label: "Cloro",
|
|
704
|
+
costHint: "most reliable, every surface — ~$0.65/mo per prompt, $30/mo min",
|
|
705
|
+
confirmDefault: true,
|
|
706
|
+
signup: {
|
|
707
|
+
message: "Sign up and create an API key",
|
|
708
|
+
url: CLORO_AFFILIATE
|
|
709
|
+
},
|
|
710
|
+
credentials: [{
|
|
711
|
+
envKey: "CLORO_API_KEY",
|
|
711
712
|
message: "Cloro API key",
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
|
|
713
|
+
secret: true,
|
|
714
|
+
validate: required
|
|
715
|
+
}],
|
|
716
|
+
picker: scraperPicker("Cloro", "cloro", CLORO_MODELS)
|
|
717
|
+
},
|
|
718
|
+
{
|
|
719
|
+
id: "brightdata",
|
|
720
|
+
kind: "scraper",
|
|
721
|
+
label: "BrightData",
|
|
722
|
+
costHint: "pay-as-you-go, cheaper but slower — ~$0.45/mo per prompt",
|
|
723
|
+
confirmDefault: false,
|
|
724
|
+
signup: {
|
|
725
|
+
message: "Sign up and generate an API token",
|
|
726
|
+
url: BRIGHTDATA_AFFILIATE
|
|
727
|
+
},
|
|
728
|
+
credentials: [{
|
|
729
|
+
envKey: "BRIGHTDATA_API_TOKEN",
|
|
719
730
|
message: "BrightData API token",
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
|
|
731
|
+
secret: true,
|
|
732
|
+
validate: required
|
|
733
|
+
}],
|
|
734
|
+
picker: scraperPicker("BrightData", "brightdata", BRIGHTDATA_MODELS)
|
|
735
|
+
},
|
|
736
|
+
{
|
|
737
|
+
id: "oxylabs",
|
|
738
|
+
kind: "scraper",
|
|
739
|
+
label: "Oxylabs",
|
|
740
|
+
costHint: "cheapest per run, no Gemini/Copilot — $49/mo min",
|
|
741
|
+
confirmDefault: false,
|
|
742
|
+
signup: {
|
|
743
|
+
message: "Sign up and create Web Scraper API credentials",
|
|
744
|
+
url: OXYLABS_AFFILIATE
|
|
745
|
+
},
|
|
746
|
+
credentials: [{
|
|
747
|
+
envKey: "OXYLABS_USERNAME",
|
|
727
748
|
message: "Oxylabs username",
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
const password = await p.password({
|
|
749
|
+
secret: false,
|
|
750
|
+
validate: required
|
|
751
|
+
}, {
|
|
752
|
+
envKey: "OXYLABS_PASSWORD",
|
|
733
753
|
message: "Oxylabs password",
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
|
|
754
|
+
secret: true,
|
|
755
|
+
validate: required
|
|
756
|
+
}],
|
|
757
|
+
picker: scraperPicker("Oxylabs", "oxylabs", OXYLABS_MODELS)
|
|
758
|
+
},
|
|
759
|
+
{
|
|
760
|
+
id: "olostep",
|
|
761
|
+
kind: "scraper",
|
|
762
|
+
label: "Olostep",
|
|
763
|
+
costHint: "premium, built for high volume — ~$2.25/mo per prompt",
|
|
764
|
+
confirmDefault: false,
|
|
765
|
+
signup: {
|
|
766
|
+
message: "Grab an API key",
|
|
767
|
+
url: OLOSTEP_AFFILIATE
|
|
768
|
+
},
|
|
769
|
+
credentials: [{
|
|
770
|
+
envKey: "OLOSTEP_API_KEY",
|
|
741
771
|
message: "Olostep API key",
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
|
|
772
|
+
secret: true,
|
|
773
|
+
validate: required
|
|
774
|
+
}],
|
|
775
|
+
picker: scraperPicker("Olostep", "olostep", OLOSTEP_MODELS)
|
|
776
|
+
},
|
|
777
|
+
{
|
|
778
|
+
id: "dataforseo",
|
|
779
|
+
kind: "scraper",
|
|
780
|
+
label: "DataForSEO",
|
|
781
|
+
costHint: "pay-as-you-go, scrapers + direct APIs — ~$1.20/mo per prompt",
|
|
782
|
+
confirmDefault: false,
|
|
783
|
+
signup: {
|
|
784
|
+
message: "Sign up",
|
|
785
|
+
url: DATAFORSEO_AFFILIATE
|
|
786
|
+
},
|
|
787
|
+
credentials: [{
|
|
788
|
+
envKey: "DATAFORSEO_LOGIN",
|
|
749
789
|
message: "DataForSEO login",
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
const password = await p.password({
|
|
790
|
+
secret: false,
|
|
791
|
+
validate: required
|
|
792
|
+
}, {
|
|
793
|
+
envKey: "DATAFORSEO_PASSWORD",
|
|
755
794
|
message: "DataForSEO password",
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
795
|
+
secret: true,
|
|
796
|
+
validate: required
|
|
797
|
+
}],
|
|
798
|
+
picker: {
|
|
799
|
+
message: "LLM Providers to track via DataForSEO",
|
|
800
|
+
options: DATAFORSEO_TARGETS.map((t) => ({
|
|
801
|
+
value: formatScrapeTarget({
|
|
802
|
+
model: t.model,
|
|
803
|
+
provider: "dataforseo",
|
|
804
|
+
webSearch: true
|
|
805
|
+
}),
|
|
806
|
+
label: `${t.model} (${t.kind})`
|
|
807
|
+
})),
|
|
808
|
+
initialValues: [formatScrapeTarget({
|
|
809
|
+
model: "google-ai-mode",
|
|
810
|
+
provider: "dataforseo",
|
|
811
|
+
webSearch: true
|
|
812
|
+
})],
|
|
813
|
+
required: false
|
|
814
|
+
}
|
|
760
815
|
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
env.ANTHROPIC_API_KEY = key;
|
|
777
|
-
} else if (kind === "openai") {
|
|
778
|
-
const key = await p.password({
|
|
779
|
-
message: "OpenAI API key",
|
|
780
|
-
validate: (v) => !v ? "Required" : void 0
|
|
781
|
-
});
|
|
782
|
-
assertNotCancelled(key);
|
|
783
|
-
env.OPENAI_API_KEY = key;
|
|
784
|
-
} else {
|
|
785
|
-
const key = await p.password({
|
|
786
|
-
message: "Mistral API key",
|
|
787
|
-
validate: (v) => !v ? "Required" : void 0
|
|
788
|
-
});
|
|
789
|
-
assertNotCancelled(key);
|
|
790
|
-
env.MISTRAL_API_KEY = key;
|
|
816
|
+
];
|
|
817
|
+
function providerSpec(id) {
|
|
818
|
+
const spec = PROVIDER_PROMPTS.find((candidate) => candidate.id === id);
|
|
819
|
+
if (!spec) throw new Error(`Unknown provider: ${id}`);
|
|
820
|
+
return spec;
|
|
821
|
+
}
|
|
822
|
+
async function collectCredentials(spec, env) {
|
|
823
|
+
for (const credential of spec.credentials) {
|
|
824
|
+
const options = {
|
|
825
|
+
message: credential.message,
|
|
826
|
+
validate: credential.validate
|
|
827
|
+
};
|
|
828
|
+
const value = credential.secret ? await p.password(options) : await p.text(options);
|
|
829
|
+
assertNotCancelled(value);
|
|
830
|
+
env[credential.envKey] = value;
|
|
791
831
|
}
|
|
792
832
|
}
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
assertNotCancelled(enable);
|
|
799
|
-
if (!enable) return;
|
|
800
|
-
p.log.info(`Sign up and generate an API token: ${link(pc.cyan(BRIGHTDATA_AFFILIATE), BRIGHTDATA_AFFILIATE)}`);
|
|
801
|
-
const key = await p.password({
|
|
802
|
-
message: "BrightData API token",
|
|
803
|
-
validate: (v) => !v ? "Required" : void 0
|
|
804
|
-
});
|
|
805
|
-
assertNotCancelled(key);
|
|
806
|
-
env.BRIGHTDATA_API_TOKEN = key;
|
|
807
|
-
await pickScraperTargets({
|
|
808
|
-
providerLabel: "BrightData",
|
|
809
|
-
providerId: "brightdata",
|
|
810
|
-
allModels: BRIGHTDATA_MODELS,
|
|
811
|
-
targets
|
|
812
|
-
});
|
|
813
|
-
}
|
|
814
|
-
async function collectOlostep(env, targets) {
|
|
815
|
-
const enable = await p.confirm({
|
|
816
|
-
message: `Configure ${pc.bold("Olostep")}? (premium, built for high volume — ~$2.25/mo per prompt)`,
|
|
817
|
-
initialValue: false
|
|
818
|
-
});
|
|
819
|
-
assertNotCancelled(enable);
|
|
820
|
-
if (!enable) return;
|
|
821
|
-
p.log.info(`Grab an API key: ${link(pc.cyan(OLOSTEP_AFFILIATE), OLOSTEP_AFFILIATE)}`);
|
|
822
|
-
const key = await p.password({
|
|
823
|
-
message: "Olostep API key",
|
|
824
|
-
validate: (v) => !v ? "Required" : void 0
|
|
825
|
-
});
|
|
826
|
-
assertNotCancelled(key);
|
|
827
|
-
env.OLOSTEP_API_KEY = key;
|
|
828
|
-
await pickScraperTargets({
|
|
829
|
-
providerLabel: "Olostep",
|
|
830
|
-
providerId: "olostep",
|
|
831
|
-
allModels: OLOSTEP_MODELS,
|
|
832
|
-
targets
|
|
833
|
-
});
|
|
834
|
-
}
|
|
835
|
-
async function collectOxylabs(env, targets) {
|
|
836
|
-
const enable = await p.confirm({
|
|
837
|
-
message: `Configure ${pc.bold("Oxylabs")}? (cheapest per run, no Gemini/Copilot — $49/mo min)`,
|
|
838
|
-
initialValue: false
|
|
839
|
-
});
|
|
840
|
-
assertNotCancelled(enable);
|
|
841
|
-
if (!enable) return;
|
|
842
|
-
p.log.info(`Sign up and create Web Scraper API credentials: ${link(pc.cyan(OXYLABS_AFFILIATE), OXYLABS_AFFILIATE)}`);
|
|
843
|
-
const username = await p.text({
|
|
844
|
-
message: "Oxylabs username",
|
|
845
|
-
validate: (v) => !v ? "Required" : void 0
|
|
846
|
-
});
|
|
847
|
-
assertNotCancelled(username);
|
|
848
|
-
env.OXYLABS_USERNAME = username;
|
|
849
|
-
const password = await p.password({
|
|
850
|
-
message: "Oxylabs password",
|
|
851
|
-
validate: (v) => !v ? "Required" : void 0
|
|
852
|
-
});
|
|
853
|
-
assertNotCancelled(password);
|
|
854
|
-
env.OXYLABS_PASSWORD = password;
|
|
855
|
-
await pickScraperTargets({
|
|
856
|
-
providerLabel: "Oxylabs",
|
|
857
|
-
providerId: "oxylabs",
|
|
858
|
-
allModels: OXYLABS_MODELS,
|
|
859
|
-
targets
|
|
860
|
-
});
|
|
861
|
-
}
|
|
862
|
-
async function collectCloro(env, targets) {
|
|
863
|
-
const enable = await p.confirm({
|
|
864
|
-
message: `Configure ${pc.bold("Cloro")}? (most reliable, every surface — ~$0.65/mo per prompt, $30/mo min)`,
|
|
865
|
-
initialValue: true
|
|
866
|
-
});
|
|
867
|
-
assertNotCancelled(enable);
|
|
868
|
-
if (!enable) return;
|
|
869
|
-
p.log.info(`Sign up and create an API key: ${link(pc.cyan(CLORO_AFFILIATE), CLORO_AFFILIATE)}`);
|
|
870
|
-
const key = await p.password({
|
|
871
|
-
message: "Cloro API key",
|
|
872
|
-
validate: (v) => !v ? "Required" : void 0
|
|
873
|
-
});
|
|
874
|
-
assertNotCancelled(key);
|
|
875
|
-
env.CLORO_API_KEY = key;
|
|
876
|
-
await pickScraperTargets({
|
|
877
|
-
providerLabel: "Cloro",
|
|
878
|
-
providerId: "cloro",
|
|
879
|
-
allModels: CLORO_MODELS,
|
|
880
|
-
targets
|
|
881
|
-
});
|
|
882
|
-
}
|
|
883
|
-
async function pickScraperTargets(args) {
|
|
884
|
-
const selected = await p.multiselect({
|
|
885
|
-
message: `LLM Providers to track via ${args.providerLabel}`,
|
|
886
|
-
options: args.allModels.map((model) => ({
|
|
887
|
-
value: model,
|
|
888
|
-
label: model
|
|
889
|
-
})),
|
|
890
|
-
required: true,
|
|
891
|
-
initialValues: [...DEFAULT_SCRAPER_MODELS]
|
|
892
|
-
});
|
|
893
|
-
assertNotCancelled(selected);
|
|
894
|
-
for (const model of selected) args.targets.push(formatScrapeTarget({
|
|
895
|
-
model,
|
|
896
|
-
provider: args.providerId,
|
|
897
|
-
webSearch: true
|
|
898
|
-
}));
|
|
899
|
-
}
|
|
900
|
-
async function collectAnthropic(env, targets) {
|
|
901
|
-
const enable = await p.confirm({
|
|
902
|
-
message: `Configure ${pc.bold("Anthropic API")}? (direct Claude — ~$4–5/mo per prompt per model)`,
|
|
903
|
-
initialValue: false
|
|
904
|
-
});
|
|
905
|
-
assertNotCancelled(enable);
|
|
906
|
-
if (!enable) return;
|
|
907
|
-
const key = await p.password({
|
|
908
|
-
message: "Anthropic API key",
|
|
909
|
-
validate: (v) => !v ? "Required" : void 0
|
|
910
|
-
});
|
|
911
|
-
assertNotCancelled(key);
|
|
912
|
-
env.ANTHROPIC_API_KEY = key;
|
|
913
|
-
const model = await p.text({
|
|
914
|
-
message: "Claude model",
|
|
915
|
-
placeholder: DEFAULT_ANTHROPIC_MODEL,
|
|
916
|
-
defaultValue: DEFAULT_ANTHROPIC_MODEL
|
|
917
|
-
});
|
|
918
|
-
assertNotCancelled(model);
|
|
919
|
-
const slug = model || DEFAULT_ANTHROPIC_MODEL;
|
|
920
|
-
const webSearch = await p.confirm({
|
|
921
|
-
message: "Enable web search? (recommended, but more expensive)",
|
|
922
|
-
initialValue: true
|
|
923
|
-
});
|
|
924
|
-
assertNotCancelled(webSearch);
|
|
925
|
-
targets.push(formatScrapeTarget({
|
|
926
|
-
model: "claude",
|
|
927
|
-
provider: "anthropic-api",
|
|
928
|
-
version: slug,
|
|
929
|
-
webSearch
|
|
930
|
-
}));
|
|
931
|
-
}
|
|
932
|
-
async function collectOpenAI(env, targets) {
|
|
933
|
-
const enable = await p.confirm({
|
|
934
|
-
message: `Configure ${pc.bold("OpenAI API")}? (gpt-* with web search — not the real ChatGPT UI)`,
|
|
935
|
-
initialValue: false
|
|
936
|
-
});
|
|
937
|
-
assertNotCancelled(enable);
|
|
938
|
-
if (!enable) return;
|
|
939
|
-
const key = await p.password({
|
|
940
|
-
message: "OpenAI API key",
|
|
941
|
-
validate: (v) => !v ? "Required" : void 0
|
|
942
|
-
});
|
|
943
|
-
assertNotCancelled(key);
|
|
944
|
-
env.OPENAI_API_KEY = key;
|
|
945
|
-
const model = await p.text({
|
|
946
|
-
message: "OpenAI model",
|
|
947
|
-
placeholder: DEFAULT_OPENAI_MODEL,
|
|
948
|
-
defaultValue: DEFAULT_OPENAI_MODEL
|
|
949
|
-
});
|
|
950
|
-
assertNotCancelled(model);
|
|
951
|
-
const slug = model || DEFAULT_OPENAI_MODEL;
|
|
952
|
-
const webSearch = await p.confirm({
|
|
953
|
-
message: "Enable web search? (recommended, but more expensive)",
|
|
954
|
-
initialValue: true
|
|
955
|
-
});
|
|
956
|
-
assertNotCancelled(webSearch);
|
|
957
|
-
targets.push(formatScrapeTarget({
|
|
958
|
-
model: "chatgpt",
|
|
959
|
-
provider: "openai-api",
|
|
960
|
-
version: slug,
|
|
961
|
-
webSearch
|
|
962
|
-
}));
|
|
963
|
-
}
|
|
964
|
-
async function collectMistral(env, targets) {
|
|
965
|
-
const enable = await p.confirm({
|
|
966
|
-
message: `Configure ${pc.bold("Mistral API")}? (direct Mistral models)`,
|
|
967
|
-
initialValue: false
|
|
968
|
-
});
|
|
969
|
-
assertNotCancelled(enable);
|
|
970
|
-
if (!enable) return;
|
|
971
|
-
const key = await p.password({
|
|
972
|
-
message: "Mistral API key",
|
|
973
|
-
validate: (v) => !v ? "Required" : void 0
|
|
974
|
-
});
|
|
975
|
-
assertNotCancelled(key);
|
|
976
|
-
env.MISTRAL_API_KEY = key;
|
|
977
|
-
const model = await p.text({
|
|
978
|
-
message: "Mistral model",
|
|
979
|
-
placeholder: DEFAULT_MISTRAL_MODEL,
|
|
980
|
-
defaultValue: DEFAULT_MISTRAL_MODEL
|
|
981
|
-
});
|
|
982
|
-
assertNotCancelled(model);
|
|
983
|
-
const slug = model || DEFAULT_MISTRAL_MODEL;
|
|
984
|
-
const webSearch = await p.confirm({
|
|
985
|
-
message: "Enable web search? (recommended, but more expensive)",
|
|
986
|
-
initialValue: true
|
|
987
|
-
});
|
|
988
|
-
assertNotCancelled(webSearch);
|
|
989
|
-
targets.push(formatScrapeTarget({
|
|
990
|
-
model: "mistral",
|
|
991
|
-
provider: "mistral-api",
|
|
992
|
-
version: slug,
|
|
993
|
-
webSearch
|
|
994
|
-
}));
|
|
995
|
-
}
|
|
996
|
-
async function collectOpenRouter(env, targets) {
|
|
833
|
+
/**
|
|
834
|
+
* Offer one provider: confirm → credentials → optional model + web search →
|
|
835
|
+
* scrape targets. Declining leaves `env` and `targets` untouched.
|
|
836
|
+
*/
|
|
837
|
+
async function collectProvider(spec, env, targets) {
|
|
997
838
|
const enable = await p.confirm({
|
|
998
|
-
message: `Configure ${pc.bold(
|
|
999
|
-
initialValue:
|
|
839
|
+
message: `Configure ${pc.bold(spec.label)}? (${spec.costHint})`,
|
|
840
|
+
initialValue: spec.confirmDefault
|
|
1000
841
|
});
|
|
1001
842
|
assertNotCancelled(enable);
|
|
1002
843
|
if (!enable) return;
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
844
|
+
if (spec.signup) p.log.info(`${spec.signup.message}: ${link(pc.cyan(spec.signup.url), spec.signup.url)}`);
|
|
845
|
+
await collectCredentials(spec, env);
|
|
846
|
+
let version;
|
|
847
|
+
if (spec.model) {
|
|
848
|
+
const model = await p.text({
|
|
849
|
+
message: spec.model.message,
|
|
850
|
+
placeholder: spec.model.default,
|
|
851
|
+
defaultValue: spec.model.default
|
|
852
|
+
});
|
|
853
|
+
assertNotCancelled(model);
|
|
854
|
+
version = model || spec.model.default;
|
|
855
|
+
}
|
|
856
|
+
let webSearch = true;
|
|
857
|
+
if (spec.webSearch) {
|
|
858
|
+
const answer = await p.confirm({
|
|
859
|
+
message: "Enable web search? (recommended, but more expensive)",
|
|
860
|
+
initialValue: true
|
|
861
|
+
});
|
|
862
|
+
assertNotCancelled(answer);
|
|
863
|
+
webSearch = answer;
|
|
864
|
+
}
|
|
865
|
+
if (spec.target) targets.push(formatScrapeTarget({
|
|
866
|
+
...spec.target,
|
|
867
|
+
version,
|
|
1025
868
|
webSearch
|
|
1026
869
|
}));
|
|
870
|
+
if (spec.picker) {
|
|
871
|
+
const selected = await p.multiselect({
|
|
872
|
+
message: spec.picker.message,
|
|
873
|
+
options: spec.picker.options,
|
|
874
|
+
required: spec.picker.required,
|
|
875
|
+
initialValues: [...spec.picker.initialValues]
|
|
876
|
+
});
|
|
877
|
+
assertNotCancelled(selected);
|
|
878
|
+
targets.push(...selected);
|
|
879
|
+
}
|
|
1027
880
|
}
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
message: `Configure ${pc.bold("DataForSEO")}? (pay-as-you-go, scrapers + direct APIs — ~$1.20/mo per prompt)`,
|
|
1031
|
-
initialValue: false
|
|
1032
|
-
});
|
|
1033
|
-
assertNotCancelled(enable);
|
|
1034
|
-
if (!enable) return;
|
|
1035
|
-
p.log.info(`Sign up: ${link(pc.cyan(DATAFORSEO_AFFILIATE), DATAFORSEO_AFFILIATE)}`);
|
|
1036
|
-
const login = await p.text({
|
|
1037
|
-
message: "DataForSEO login",
|
|
1038
|
-
validate: (v) => !v ? "Required" : void 0
|
|
1039
|
-
});
|
|
1040
|
-
assertNotCancelled(login);
|
|
1041
|
-
env.DATAFORSEO_LOGIN = login;
|
|
1042
|
-
const pwd = await p.password({
|
|
1043
|
-
message: "DataForSEO password",
|
|
1044
|
-
validate: (v) => !v ? "Required" : void 0
|
|
1045
|
-
});
|
|
1046
|
-
assertNotCancelled(pwd);
|
|
1047
|
-
env.DATAFORSEO_PASSWORD = pwd;
|
|
1048
|
-
const selected = await p.multiselect({
|
|
1049
|
-
message: "LLM Providers to track via DataForSEO",
|
|
1050
|
-
options: DATAFORSEO_TARGETS.map((t) => ({
|
|
1051
|
-
value: formatScrapeTarget({
|
|
1052
|
-
model: t.model,
|
|
1053
|
-
provider: "dataforseo",
|
|
1054
|
-
webSearch: true
|
|
1055
|
-
}),
|
|
1056
|
-
label: `${t.model} (${t.kind})`
|
|
1057
|
-
})),
|
|
1058
|
-
required: false,
|
|
1059
|
-
initialValues: [formatScrapeTarget({
|
|
1060
|
-
model: "google-ai-mode",
|
|
1061
|
-
provider: "dataforseo",
|
|
1062
|
-
webSearch: true
|
|
1063
|
-
})]
|
|
1064
|
-
});
|
|
1065
|
-
assertNotCancelled(selected);
|
|
1066
|
-
targets.push(...selected);
|
|
881
|
+
function hasDirectApiConfigured(env) {
|
|
882
|
+
return Boolean(env.ANTHROPIC_API_KEY || env.OPENAI_API_KEY || env.MISTRAL_API_KEY || env.OPENROUTER_API_KEY);
|
|
1067
883
|
}
|
|
1068
884
|
async function finalizeScrapeTargets(env, targets, options = {}) {
|
|
1069
885
|
const deduped = dedupeTargets(targets);
|
|
@@ -1125,485 +941,440 @@ function dedupeTargets(targets) {
|
|
|
1125
941
|
}
|
|
1126
942
|
return out.join(",");
|
|
1127
943
|
}
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
if (await waitForHealthy(configDir, 18e4)) s.stop("All services healthy!");
|
|
1135
|
-
else {
|
|
1136
|
-
s.stop("Health check timed out.");
|
|
1137
|
-
p.log.warn("Some services did not report healthy status.");
|
|
1138
|
-
}
|
|
1139
|
-
log.info("Examples:");
|
|
1140
|
-
console.log(` ${pc.bold("elmo compose logs -f")}`);
|
|
1141
|
-
console.log(` ${pc.bold("elmo compose logs -f web")}`);
|
|
1142
|
-
console.log(` ${pc.bold("elmo compose ps")}`);
|
|
1143
|
-
console.log(` ${pc.bold("elmo compose down")}`);
|
|
944
|
+
//#endregion
|
|
945
|
+
//#region src/telemetry.ts
|
|
946
|
+
const POSTHOG_PUBLIC_KEY = "phc_Jhx9LnI9cTDFHpQmpOzJSDTW127qD9pFU65KRnYym6z";
|
|
947
|
+
const POSTHOG_HOST = "https://us.i.posthog.com";
|
|
948
|
+
function envDisabled() {
|
|
949
|
+
return Boolean(process.env.DISABLE_TELEMETRY);
|
|
1144
950
|
}
|
|
1145
|
-
async function
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
951
|
+
async function readEnvKey(configDir, key) {
|
|
952
|
+
try {
|
|
953
|
+
const contents = await fs.readFile(path.join(configDir, ".env"), "utf8");
|
|
954
|
+
return parse(contents)[key];
|
|
955
|
+
} catch {
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
1149
958
|
}
|
|
1150
|
-
async function
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
959
|
+
async function isTelemetryDisabled(configDir) {
|
|
960
|
+
if (envDisabled()) return true;
|
|
961
|
+
return Boolean(await readEnvKey(configDir, "DISABLE_TELEMETRY"));
|
|
962
|
+
}
|
|
963
|
+
async function trackCliEvent(configDir, eventName, properties, personProperties) {
|
|
964
|
+
if (await isTelemetryDisabled(configDir)) return;
|
|
965
|
+
const distinctId = await readEnvKey(configDir, "DEPLOYMENT_ID");
|
|
966
|
+
if (!distinctId) return;
|
|
967
|
+
try {
|
|
968
|
+
const client = new PostHog(POSTHOG_PUBLIC_KEY, { host: POSTHOG_HOST });
|
|
969
|
+
client.capture({
|
|
970
|
+
distinctId,
|
|
971
|
+
event: eventName,
|
|
972
|
+
properties: {
|
|
973
|
+
...properties,
|
|
974
|
+
...personProperties ? { $set: personProperties } : {}
|
|
975
|
+
}
|
|
1165
976
|
});
|
|
1166
|
-
|
|
1167
|
-
}
|
|
1168
|
-
log.info("Restart the stack with `elmo compose up -d` to apply changes.");
|
|
977
|
+
await client.shutdown();
|
|
978
|
+
} catch {}
|
|
1169
979
|
}
|
|
1170
|
-
async function
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
980
|
+
async function submitNewsletterSignup(configDir, email) {
|
|
981
|
+
try {
|
|
982
|
+
const distinctId = (await isTelemetryDisabled(configDir) ? null : await readEnvKey(configDir, "DEPLOYMENT_ID")) ?? email;
|
|
983
|
+
const client = new PostHog(POSTHOG_PUBLIC_KEY, { host: POSTHOG_HOST });
|
|
984
|
+
client.capture({
|
|
985
|
+
distinctId,
|
|
986
|
+
event: "newsletter_signup",
|
|
987
|
+
properties: {
|
|
988
|
+
source: "cli_init",
|
|
989
|
+
$set: {
|
|
990
|
+
email,
|
|
991
|
+
wants_updates: true
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
await client.shutdown();
|
|
996
|
+
} catch {}
|
|
997
|
+
}
|
|
998
|
+
//#endregion
|
|
999
|
+
//#region src/commands/init.ts
|
|
1000
|
+
const DEFAULT_APP_NAME = "Elmo";
|
|
1001
|
+
const DEFAULT_APP_ICON = "/icons/elmo-icon.svg";
|
|
1002
|
+
const DEFAULT_APP_PORT = 1515;
|
|
1003
|
+
const LOCAL_DATABASE_URL = "postgres://postgres:postgres@postgres:5432/elmo";
|
|
1004
|
+
const TELEMETRY_DOC_URL = "https://elmohq.com/docs/developer-guide/telemetry";
|
|
1005
|
+
const CLOUD_PRICING_URL = cloudPricingUrl("cli");
|
|
1006
|
+
/**
|
|
1007
|
+
* Guard an existing config from being replaced by accident. Returns the
|
|
1008
|
+
* deployment id to carry over, so a re-run keeps reporting as the same install.
|
|
1009
|
+
*/
|
|
1010
|
+
async function confirmEnvOverwrite(configDir) {
|
|
1011
|
+
const existingEnvPath = path.join(configDir, ".env");
|
|
1012
|
+
if (!await fileExists(existingEnvPath)) return void 0;
|
|
1013
|
+
const contents = await fs.readFile(existingEnvPath, "utf8");
|
|
1014
|
+
const isElmoEnv = contents.startsWith("# Rendered by elmo") || contents.startsWith("# Generated by elmo");
|
|
1015
|
+
p.log.warn(isElmoEnv ? `An existing Elmo config was found at ${configDir}.` : `A .env file already exists in ${configDir} and was NOT created by Elmo.`);
|
|
1016
|
+
const overwrite = await p.confirm({
|
|
1017
|
+
message: isElmoEnv ? "Overwrite it with new values? Existing secrets (DATABASE_URL, API keys, etc.) will be replaced." : "Overwrite the existing .env file? This cannot be undone.",
|
|
1178
1018
|
initialValue: false
|
|
1179
1019
|
});
|
|
1180
|
-
assertNotCancelled(
|
|
1181
|
-
if (!
|
|
1182
|
-
p.cancel("
|
|
1020
|
+
assertNotCancelled(overwrite);
|
|
1021
|
+
if (!overwrite) {
|
|
1022
|
+
p.cancel(isElmoEnv ? "Setup cancelled. Use `elmo edit env` to change individual values." : "Setup cancelled. Choose a different directory with --dir.");
|
|
1183
1023
|
process.exit(0);
|
|
1184
1024
|
}
|
|
1025
|
+
return isElmoEnv ? parse(contents).DEPLOYMENT_ID : void 0;
|
|
1185
1026
|
}
|
|
1186
|
-
async function
|
|
1187
|
-
|
|
1188
|
-
const
|
|
1189
|
-
|
|
1190
|
-
|
|
1027
|
+
async function resolveDockerDirInteractive(cwd) {
|
|
1028
|
+
const inCwd = await fileExists(path.join(cwd, "Dockerfile"));
|
|
1029
|
+
const inDockerDir = await fileExists(path.join(cwd, "docker", "Dockerfile"));
|
|
1030
|
+
const defaultDir = inCwd ? "." : inDockerDir ? "docker" : ".";
|
|
1031
|
+
const dir = await p.text({
|
|
1032
|
+
message: "Path to docker directory (contains Dockerfile)",
|
|
1033
|
+
defaultValue: defaultDir
|
|
1191
1034
|
});
|
|
1192
|
-
assertNotCancelled(
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
log.step("Pulling images...");
|
|
1197
|
-
await runDockerCompose(configDir, ["pull"]);
|
|
1198
|
-
if (wasRunning) {
|
|
1199
|
-
log.step("Restarting services...");
|
|
1200
|
-
await runDockerCompose(configDir, ["up", "-d"]);
|
|
1201
|
-
}
|
|
1202
|
-
}
|
|
1203
|
-
p.outro(pc.green("Nothing to upgrade."));
|
|
1204
|
-
}
|
|
1205
|
-
/**
|
|
1206
|
-
* With no detected version we can't tell which migrations apply, so we skip
|
|
1207
|
-
* them and just re-pin + pull. (planMigrations would also return [] here since
|
|
1208
|
-
* from === to, but it is special-cased for a clearer message.)
|
|
1209
|
-
*/
|
|
1210
|
-
function planUpgradeMigrations(detectedVersion, fromVersion, cliVersion) {
|
|
1211
|
-
const plan = detectedVersion === null ? [] : planMigrations(fromVersion, cliVersion, MIGRATIONS);
|
|
1212
|
-
if (detectedVersion === null) log.warn(`Couldn't detect the deployment's version — re-pinning images to ${pc.cyan(cliVersion)}.`);
|
|
1213
|
-
else log.info(`Upgrading from ${pc.cyan(fromVersion)} → ${pc.cyan(cliVersion)}`);
|
|
1214
|
-
if (plan.length === 0) {
|
|
1215
|
-
log.step("No migrations to run (docker images will be re-pinned and pulled).");
|
|
1216
|
-
return plan;
|
|
1217
|
-
}
|
|
1218
|
-
log.step(`Migrations to apply: ${plan.length}`);
|
|
1219
|
-
for (const migration of plan) console.log(` • ${pc.bold(`${migration.from} → ${migration.to}`)} ${migration.description}`);
|
|
1220
|
-
return plan;
|
|
1221
|
-
}
|
|
1222
|
-
async function applyUpgradeMigrations(plan, ctx, wasRunning) {
|
|
1223
|
-
try {
|
|
1224
|
-
await runMigrations(plan, ctx);
|
|
1225
|
-
} catch (error) {
|
|
1226
|
-
log.error(`Migration failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1227
|
-
log.info("Your config version was left unchanged. Fix the issue and rerun `elmo upgrade`.");
|
|
1228
|
-
if (wasRunning) log.info("The stack was stopped for the upgrade. Restart with `elmo compose up -d` after fixing.");
|
|
1035
|
+
assertNotCancelled(dir);
|
|
1036
|
+
const resolved = path.resolve(cwd, dir);
|
|
1037
|
+
if (!await fileExists(path.join(resolved, "Dockerfile"))) {
|
|
1038
|
+
p.log.error(`Dockerfile not found in ${resolved}. Provide the directory that contains Dockerfile.`);
|
|
1229
1039
|
process.exit(1);
|
|
1230
1040
|
}
|
|
1041
|
+
return resolved;
|
|
1231
1042
|
}
|
|
1232
|
-
async function
|
|
1233
|
-
if (!
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
return;
|
|
1043
|
+
async function resolveDockerLocation(options, cwd) {
|
|
1044
|
+
if (!options.dev) return {
|
|
1045
|
+
dockerDir: void 0,
|
|
1046
|
+
repoRoot: cwd
|
|
1047
|
+
};
|
|
1048
|
+
if (!options.dockerDir) {
|
|
1049
|
+
const dockerDir = await resolveDockerDirInteractive(cwd);
|
|
1050
|
+
return {
|
|
1051
|
+
dockerDir,
|
|
1052
|
+
repoRoot: path.resolve(dockerDir, "..")
|
|
1053
|
+
};
|
|
1244
1054
|
}
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
}
|
|
1248
|
-
async function runUpgrade(options, cliVersion) {
|
|
1249
|
-
printBanner();
|
|
1250
|
-
p.intro(pc.bold("Upgrading Elmo"));
|
|
1251
|
-
await confirmStaleCli(cliVersion, options.yes);
|
|
1252
|
-
const configDir = await resolveConfigDir(options.dir);
|
|
1253
|
-
const composePath = path.join(configDir, "elmo.yaml");
|
|
1254
|
-
const detectedVersion = await readRenderedVersion(composePath);
|
|
1255
|
-
const fromVersion = detectedVersion ?? cliVersion;
|
|
1256
|
-
if (!semver.valid(fromVersion)) throw new Error(`Could not determine the installed version from ${composePath}.`);
|
|
1257
|
-
if (semver.gt(fromVersion, cliVersion)) {
|
|
1258
|
-
log.warn(`Your deployment (${fromVersion}) is newer than this CLI (${cliVersion}).`);
|
|
1259
|
-
log.info("Upgrade the CLI to match, then rerun:");
|
|
1260
|
-
console.log(` ${pc.bold("npm install -g @elmohq/cli@latest")}`);
|
|
1055
|
+
const dockerDir = path.resolve(cwd, options.dockerDir);
|
|
1056
|
+
if (!await fileExists(path.join(dockerDir, "Dockerfile"))) {
|
|
1057
|
+
p.log.error(`Dockerfile not found in ${dockerDir}`);
|
|
1261
1058
|
process.exit(1);
|
|
1262
1059
|
}
|
|
1263
|
-
if (detectedVersion !== null && semver.eq(detectedVersion, cliVersion)) {
|
|
1264
|
-
await pullAtCurrentVersion(configDir, cliVersion, options.yes);
|
|
1265
|
-
return;
|
|
1266
|
-
}
|
|
1267
|
-
const plan = planUpgradeMigrations(detectedVersion, fromVersion, cliVersion);
|
|
1268
|
-
const confirm = options.yes ? true : await p.confirm({
|
|
1269
|
-
message: "Proceed with upgrade?",
|
|
1270
|
-
initialValue: true
|
|
1271
|
-
});
|
|
1272
|
-
assertNotCancelled(confirm);
|
|
1273
|
-
if (!confirm) {
|
|
1274
|
-
p.cancel("Upgrade cancelled.");
|
|
1275
|
-
process.exit(0);
|
|
1276
|
-
}
|
|
1277
|
-
assertDockerRunning();
|
|
1278
|
-
const wasRunning = await stackHasRunningServices(configDir);
|
|
1279
|
-
if (wasRunning) {
|
|
1280
|
-
log.step("Stopping services...");
|
|
1281
|
-
await runDockerCompose(configDir, ["down"]);
|
|
1282
|
-
}
|
|
1283
|
-
await applyUpgradeMigrations(plan, buildMigrationContext(configDir, cliVersion), wasRunning);
|
|
1284
|
-
const isDev = await composeUsesBuild(composePath);
|
|
1285
|
-
await repinComposeImages(composePath, cliVersion);
|
|
1286
|
-
await refreshRenderedVersion(path.join(configDir, ".env"), cliVersion);
|
|
1287
|
-
log.success(`Pinned config to ${cliVersion}.`);
|
|
1288
|
-
if (isDev) log.info("Dev mode detected — rebuild with `elmo compose build` to apply the new version.");
|
|
1289
|
-
else {
|
|
1290
|
-
log.step("Pulling images...");
|
|
1291
|
-
await runDockerCompose(configDir, ["pull"]);
|
|
1292
|
-
}
|
|
1293
|
-
await restartAfterUpgrade(configDir, wasRunning);
|
|
1294
|
-
await trackCliEvent(configDir, "cli_upgrade", {
|
|
1295
|
-
from_version: fromVersion,
|
|
1296
|
-
to_version: cliVersion,
|
|
1297
|
-
migrations_run: plan.length,
|
|
1298
|
-
was_running: wasRunning,
|
|
1299
|
-
dev_mode: isDev
|
|
1300
|
-
});
|
|
1301
|
-
p.outro(pc.green(`Upgraded to ${cliVersion}.`));
|
|
1302
|
-
}
|
|
1303
|
-
async function stackHasRunningServices(configDir) {
|
|
1304
|
-
try {
|
|
1305
|
-
return (await getComposeServices(configDir)).some((s) => s.State?.startsWith("running") ?? false);
|
|
1306
|
-
} catch {
|
|
1307
|
-
return false;
|
|
1308
|
-
}
|
|
1309
|
-
}
|
|
1310
|
-
async function readRenderedVersion(filePath) {
|
|
1311
|
-
try {
|
|
1312
|
-
return parseRenderedVersion(await fs.readFile(filePath, "utf8"));
|
|
1313
|
-
} catch {
|
|
1314
|
-
return null;
|
|
1315
|
-
}
|
|
1316
|
-
}
|
|
1317
|
-
async function composeUsesBuild(composePath) {
|
|
1318
|
-
try {
|
|
1319
|
-
const contents = await fs.readFile(composePath, "utf8");
|
|
1320
|
-
return /^\s*build:/m.test(contents);
|
|
1321
|
-
} catch {
|
|
1322
|
-
return false;
|
|
1323
|
-
}
|
|
1324
|
-
}
|
|
1325
|
-
async function repinComposeImages(composePath, version) {
|
|
1326
|
-
const contents = await fs.readFile(composePath, "utf8");
|
|
1327
|
-
await fs.writeFile(composePath, refreshHeaderVersion(repinImages(contents, version), version), "utf8");
|
|
1328
|
-
}
|
|
1329
|
-
async function refreshRenderedVersion(filePath, version) {
|
|
1330
|
-
try {
|
|
1331
|
-
const contents = await fs.readFile(filePath, "utf8");
|
|
1332
|
-
await fs.writeFile(filePath, refreshHeaderVersion(contents, version), "utf8");
|
|
1333
|
-
} catch {}
|
|
1334
|
-
}
|
|
1335
|
-
function buildMigrationContext(configDir, version) {
|
|
1336
|
-
const envPath = path.join(configDir, ".env");
|
|
1337
1060
|
return {
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
info: (msg) => log.info(msg),
|
|
1341
|
-
warn: (msg) => log.warn(msg),
|
|
1342
|
-
step: (msg) => log.step(msg)
|
|
1343
|
-
},
|
|
1344
|
-
readEnv: async () => {
|
|
1345
|
-
try {
|
|
1346
|
-
return parse(await fs.readFile(envPath, "utf8"));
|
|
1347
|
-
} catch {
|
|
1348
|
-
return {};
|
|
1349
|
-
}
|
|
1350
|
-
},
|
|
1351
|
-
writeEnv: async (env) => {
|
|
1352
|
-
await fs.writeFile(envPath, buildEnvFile(env, version), "utf8");
|
|
1353
|
-
}
|
|
1061
|
+
dockerDir,
|
|
1062
|
+
repoRoot: path.resolve(dockerDir, "..")
|
|
1354
1063
|
};
|
|
1355
1064
|
}
|
|
1356
|
-
function
|
|
1357
|
-
|
|
1358
|
-
const
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
"
|
|
1364
|
-
};
|
|
1365
|
-
const dockerfilePath = options.dockerDir ? path.relative(options.repoRoot, path.join(options.dockerDir, "Dockerfile")) : "docker/Dockerfile";
|
|
1366
|
-
if (options.postgresMode === "docker") {
|
|
1367
|
-
services.push(buildPostgresService());
|
|
1368
|
-
services.push(buildDbMigrateService({
|
|
1369
|
-
dev: options.dev,
|
|
1370
|
-
dockerfilePath,
|
|
1371
|
-
repoRoot: options.repoRoot,
|
|
1372
|
-
version: options.version
|
|
1373
|
-
}));
|
|
1374
|
-
dependsOnWeb.push("db-migrate");
|
|
1375
|
-
dependsOnWorker.push("db-migrate");
|
|
1376
|
-
volumes.add("postgres_data");
|
|
1377
|
-
}
|
|
1378
|
-
services.push(buildWebService({
|
|
1379
|
-
dev: options.dev,
|
|
1380
|
-
dependsOn: dependsOnWeb,
|
|
1381
|
-
dependencyConditions,
|
|
1382
|
-
repoRoot: options.repoRoot,
|
|
1383
|
-
dockerfilePath,
|
|
1384
|
-
port: options.port,
|
|
1385
|
-
version: options.version
|
|
1386
|
-
}));
|
|
1387
|
-
services.push(buildWorkerService({
|
|
1388
|
-
dev: options.dev,
|
|
1389
|
-
dependsOn: dependsOnWorker,
|
|
1390
|
-
dependencyConditions,
|
|
1391
|
-
repoRoot: options.repoRoot,
|
|
1392
|
-
dockerfilePath,
|
|
1393
|
-
version: options.version
|
|
1394
|
-
}));
|
|
1395
|
-
const lines = [
|
|
1396
|
-
renderedByHeader(options.version),
|
|
1065
|
+
function validatePort(value) {
|
|
1066
|
+
if (!value) return void 0;
|
|
1067
|
+
const port = Number(value);
|
|
1068
|
+
return Number.isInteger(port) && port >= 1 && port <= 65535 ? void 0 : "Must be an integer between 1 and 65535";
|
|
1069
|
+
}
|
|
1070
|
+
async function configureProvidersInteractive(env) {
|
|
1071
|
+
p.note([
|
|
1072
|
+
"Elmo needs two kinds of providers:",
|
|
1397
1073
|
"",
|
|
1398
|
-
"
|
|
1074
|
+
pc.bold("1. A scraper") + " — to track ChatGPT and Google AI Mode (no public APIs):",
|
|
1075
|
+
` • ${pc.cyan("Cloro")} — most reliable, every surface, ~$0.65/mo per prompt ($30/mo min)`,
|
|
1076
|
+
` • ${pc.cyan("BrightData")} — pay-as-you-go and cheaper, but slower, ~$0.45/mo per prompt`,
|
|
1077
|
+
` • ${pc.cyan("Oxylabs")} — cheapest per run, no Gemini/Copilot, $49/mo min`,
|
|
1078
|
+
` • ${pc.cyan("Olostep")} — premium, built for high volume, ~$2.25/mo per prompt`,
|
|
1079
|
+
` • ${pc.cyan("DataForSEO")} — pay-as-you-go, scrapers + direct APIs, ~$1.20/mo per prompt`,
|
|
1399
1080
|
"",
|
|
1400
|
-
"
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
" test: [\"CMD-SHELL\", \"pg_isready -U postgres\"]",
|
|
1423
|
-
" interval: 5s",
|
|
1424
|
-
" timeout: 5s",
|
|
1425
|
-
" retries: 5",
|
|
1426
|
-
" start_period: 30s"
|
|
1427
|
-
].join("\n");
|
|
1428
|
-
}
|
|
1429
|
-
function buildDbMigrateService(options) {
|
|
1430
|
-
const lines = ["db-migrate:"];
|
|
1431
|
-
if (options.dev) lines.push(" build:", ` context: ${options.repoRoot}`, ` dockerfile: ${options.dockerfilePath}`, " target: migrate");
|
|
1432
|
-
else lines.push(` image: elmohq/elmo-db-migrate:${options.version}`);
|
|
1433
|
-
lines.push(" environment:", " - DATABASE_URL=postgres://postgres:postgres@postgres:5432/elmo", " depends_on:", " postgres:", " condition: service_healthy");
|
|
1434
|
-
return lines.join("\n");
|
|
1081
|
+
pc.bold("2. A direct LLM API") + " — for low-latency tasks (onboarding analysis, sentiment scoring,",
|
|
1082
|
+
" ad-hoc LLM calls). Required:",
|
|
1083
|
+
` • ${pc.cyan("OpenRouter")} — one key, all major models (recommended)`,
|
|
1084
|
+
` • ${pc.cyan("Anthropic / OpenAI / Mistral")} — direct provider keys`,
|
|
1085
|
+
"",
|
|
1086
|
+
"Pricing assumes Elmo's default cadence (5 runs/day × 2 surfaces)."
|
|
1087
|
+
].join("\n"), "AI providers");
|
|
1088
|
+
const mode = await p.select({
|
|
1089
|
+
message: "Setup mode",
|
|
1090
|
+
options: [{
|
|
1091
|
+
value: "recommended",
|
|
1092
|
+
label: "Recommended — one scraper + one direct API"
|
|
1093
|
+
}, {
|
|
1094
|
+
value: "custom",
|
|
1095
|
+
label: "Custom — pick each provider individually"
|
|
1096
|
+
}],
|
|
1097
|
+
initialValue: "recommended"
|
|
1098
|
+
});
|
|
1099
|
+
assertNotCancelled(mode);
|
|
1100
|
+
if (mode === "recommended") await configureProvidersRecommended(env);
|
|
1101
|
+
else await configureProvidersCustom(env);
|
|
1102
|
+
return mode;
|
|
1435
1103
|
}
|
|
1436
|
-
function
|
|
1437
|
-
const
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1104
|
+
async function configureProvidersRecommended(env) {
|
|
1105
|
+
const targets = [];
|
|
1106
|
+
const scraper = await p.select({
|
|
1107
|
+
message: "Scraper (tracks ChatGPT + Google AI Mode)",
|
|
1108
|
+
options: [
|
|
1109
|
+
{
|
|
1110
|
+
value: "cloro",
|
|
1111
|
+
label: "Cloro — most reliable, every surface (~$0.65/mo per prompt, $30/mo min)"
|
|
1112
|
+
},
|
|
1113
|
+
{
|
|
1114
|
+
value: "brightdata",
|
|
1115
|
+
label: "BrightData — pay-as-you-go, cheaper but slower (~$0.45/mo per prompt)"
|
|
1116
|
+
},
|
|
1117
|
+
{
|
|
1118
|
+
value: "oxylabs",
|
|
1119
|
+
label: "Oxylabs — cheapest per run, no Gemini/Copilot ($49/mo min)"
|
|
1120
|
+
},
|
|
1121
|
+
{
|
|
1122
|
+
value: "olostep",
|
|
1123
|
+
label: "Olostep — premium, built for high volume (~$2.25/mo per prompt)"
|
|
1124
|
+
},
|
|
1125
|
+
{
|
|
1126
|
+
value: "dataforseo",
|
|
1127
|
+
label: "DataForSEO — pay-as-you-go, scrapers + direct APIs (~$1.20/mo per prompt)"
|
|
1128
|
+
}
|
|
1129
|
+
],
|
|
1130
|
+
initialValue: "cloro"
|
|
1131
|
+
});
|
|
1132
|
+
assertNotCancelled(scraper);
|
|
1133
|
+
await collectScraperKey(scraper, env);
|
|
1134
|
+
for (const model of DEFAULT_SCRAPER_MODELS) targets.push(formatScrapeTarget({
|
|
1135
|
+
model,
|
|
1136
|
+
provider: scraper,
|
|
1137
|
+
webSearch: true
|
|
1138
|
+
}));
|
|
1139
|
+
const direct = await p.select({
|
|
1140
|
+
message: "Direct LLM API (powers onboarding analysis + sentiment scoring)",
|
|
1141
|
+
options: [
|
|
1142
|
+
{
|
|
1143
|
+
value: "openrouter",
|
|
1144
|
+
label: "OpenRouter — one key, all major models (recommended)"
|
|
1145
|
+
},
|
|
1146
|
+
{
|
|
1147
|
+
value: "anthropic",
|
|
1148
|
+
label: "Anthropic — direct Claude"
|
|
1149
|
+
},
|
|
1150
|
+
{
|
|
1151
|
+
value: "openai",
|
|
1152
|
+
label: "OpenAI — direct GPT-* models"
|
|
1153
|
+
},
|
|
1154
|
+
{
|
|
1155
|
+
value: "mistral",
|
|
1156
|
+
label: "Mistral — direct Mistral models"
|
|
1157
|
+
}
|
|
1158
|
+
],
|
|
1159
|
+
initialValue: "openrouter"
|
|
1160
|
+
});
|
|
1161
|
+
assertNotCancelled(direct);
|
|
1162
|
+
await collectDirectApiQuick(direct, env);
|
|
1163
|
+
await finalizeScrapeTargets(env, targets, { skipEdit: true });
|
|
1449
1164
|
}
|
|
1450
|
-
function
|
|
1451
|
-
const
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
lines.push(" stop_grace_period: 35s");
|
|
1456
|
-
if (options.dependsOn.length > 0) {
|
|
1457
|
-
lines.push(" depends_on:");
|
|
1458
|
-
for (const service of options.dependsOn) {
|
|
1459
|
-
const condition = options.dependencyConditions[service] ?? "service_started";
|
|
1460
|
-
lines.push(` ${service}:`, ` condition: ${condition}`);
|
|
1461
|
-
}
|
|
1462
|
-
}
|
|
1463
|
-
return lines.join("\n");
|
|
1165
|
+
async function collectScraperKey(scraper, env) {
|
|
1166
|
+
const spec = providerSpec(scraper);
|
|
1167
|
+
const url = spec.signup?.url;
|
|
1168
|
+
if (url) p.log.info(`Sign up: ${link(pc.cyan(url), url)}`);
|
|
1169
|
+
await collectCredentials(spec, env);
|
|
1464
1170
|
}
|
|
1465
|
-
function
|
|
1466
|
-
|
|
1467
|
-
return block.split("\n").map((line) => `${indent}${line}`).join("\n");
|
|
1171
|
+
async function collectDirectApiQuick(kind, env) {
|
|
1172
|
+
await collectCredentials(providerSpec(kind), env);
|
|
1468
1173
|
}
|
|
1469
|
-
async function
|
|
1470
|
-
const
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
"
|
|
1474
|
-
|
|
1475
|
-
if (!output.trim()) return [];
|
|
1476
|
-
try {
|
|
1477
|
-
const trimmed = output.trim();
|
|
1478
|
-
const parsed = JSON.parse(trimmed);
|
|
1479
|
-
if (Array.isArray(parsed)) return parsed;
|
|
1480
|
-
if (typeof parsed === "object" && parsed !== null) return [parsed];
|
|
1481
|
-
return [];
|
|
1482
|
-
} catch {
|
|
1483
|
-
try {
|
|
1484
|
-
return output.trim().split("\n").filter((line) => line.trim()).map((line) => JSON.parse(line));
|
|
1485
|
-
} catch {
|
|
1486
|
-
log.warn("Unable to parse docker compose status.");
|
|
1487
|
-
return [];
|
|
1488
|
-
}
|
|
1174
|
+
async function configureProvidersCustom(env) {
|
|
1175
|
+
const targets = [];
|
|
1176
|
+
p.log.step(pc.bold("Step 1 of 2 — Direct LLM API (at least one is required)"));
|
|
1177
|
+
while (!hasDirectApiConfigured(env)) {
|
|
1178
|
+
for (const spec of PROVIDER_PROMPTS.filter((candidate) => candidate.kind === "direct")) await collectProvider(spec, env, targets);
|
|
1179
|
+
if (!hasDirectApiConfigured(env)) p.log.warn("Onboarding analysis and other low-latency LLM tasks require a direct API. Configure at least one before continuing.");
|
|
1489
1180
|
}
|
|
1181
|
+
p.log.step(pc.bold("Step 2 of 2 — Scrapers (optional, but needed to track ChatGPT / Google AI Mode)"));
|
|
1182
|
+
for (const spec of PROVIDER_PROMPTS.filter((candidate) => candidate.kind === "scraper")) await collectProvider(spec, env, targets);
|
|
1183
|
+
await finalizeScrapeTargets(env, targets);
|
|
1490
1184
|
}
|
|
1491
|
-
function
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
await sleep(3e3);
|
|
1185
|
+
async function doStart(configDir) {
|
|
1186
|
+
assertDockerRunning();
|
|
1187
|
+
log.step("Starting Docker Compose stack...");
|
|
1188
|
+
await runDockerCompose(configDir, ["up", "-d"]);
|
|
1189
|
+
const s = p.spinner();
|
|
1190
|
+
s.start("Waiting for services to become healthy...");
|
|
1191
|
+
if (await waitForHealthy(configDir, 18e4)) s.stop("All services healthy!");
|
|
1192
|
+
else {
|
|
1193
|
+
s.stop("Health check timed out.");
|
|
1194
|
+
p.log.warn("Some services did not report healthy status.");
|
|
1502
1195
|
}
|
|
1503
|
-
|
|
1504
|
-
}
|
|
1505
|
-
|
|
1506
|
-
|
|
1196
|
+
log.info("Examples:");
|
|
1197
|
+
console.log(` ${pc.bold("elmo compose logs -f")}`);
|
|
1198
|
+
console.log(` ${pc.bold("elmo compose logs -f web")}`);
|
|
1199
|
+
console.log(` ${pc.bold("elmo compose ps")}`);
|
|
1200
|
+
console.log(` ${pc.bold("elmo compose down")}`);
|
|
1507
1201
|
}
|
|
1508
|
-
function
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1202
|
+
async function runInit(options, version) {
|
|
1203
|
+
printBanner();
|
|
1204
|
+
p.intro(pc.bold("Setting up Elmo"));
|
|
1205
|
+
p.note([
|
|
1206
|
+
"Self-hosting Elmo is free, and always will be. But it does mean",
|
|
1207
|
+
"running Postgres and a worker, and opening your own scraper and",
|
|
1208
|
+
"model provider accounts.",
|
|
1209
|
+
"",
|
|
1210
|
+
`Elmo Cloud is the same open-source product, hosted by us, from`,
|
|
1211
|
+
`${pc.bold(`$${CLOUD_ENTRY_PRICE_USD}/month`)} — scraper and model access included, nothing to`,
|
|
1212
|
+
"keep running.",
|
|
1213
|
+
"",
|
|
1214
|
+
` ${link(pc.cyan(CLOUD_PRICING_URL), CLOUD_PRICING_URL)}`
|
|
1215
|
+
].join("\n"), "Prefer not to self-host?");
|
|
1216
|
+
assertDockerRunning();
|
|
1217
|
+
const cwd = process.cwd();
|
|
1218
|
+
const configDir = options.dir ? path.resolve(cwd, options.dir) : CONFIG_HOME;
|
|
1219
|
+
const preservedDeploymentId = await confirmEnvOverwrite(configDir);
|
|
1220
|
+
const { dockerDir, repoRoot } = await resolveDockerLocation(options, cwd);
|
|
1221
|
+
const postgresMode = await p.select({
|
|
1222
|
+
message: "PostgreSQL connection",
|
|
1223
|
+
options: [{
|
|
1224
|
+
value: "docker",
|
|
1225
|
+
label: "Run Postgres in Docker"
|
|
1226
|
+
}, {
|
|
1227
|
+
value: "external",
|
|
1228
|
+
label: "Use existing Postgres (provide DATABASE_URL)"
|
|
1229
|
+
}],
|
|
1230
|
+
initialValue: "docker"
|
|
1520
1231
|
});
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
stderr += data.toString();
|
|
1538
|
-
});
|
|
1539
|
-
child.on("close", (code) => {
|
|
1540
|
-
if (code === 0) resolve(stdout);
|
|
1541
|
-
else reject(new Error(stderr || `docker compose exited with code ${code}`));
|
|
1232
|
+
assertNotCancelled(postgresMode);
|
|
1233
|
+
const env = {};
|
|
1234
|
+
env.DEPLOYMENT_MODE = "local";
|
|
1235
|
+
env.VITE_DEPLOYMENT_MODE = "local";
|
|
1236
|
+
env.DEPLOYMENT_ID = preservedDeploymentId ?? crypto.randomUUID();
|
|
1237
|
+
env.BETTER_AUTH_SECRET = generateSecret();
|
|
1238
|
+
env.ELMO_ENCRYPTION_KEY = generateSecret(32, "base64");
|
|
1239
|
+
env.APP_NAME = DEFAULT_APP_NAME;
|
|
1240
|
+
env.APP_ICON = DEFAULT_APP_ICON;
|
|
1241
|
+
env.VITE_APP_NAME = DEFAULT_APP_NAME;
|
|
1242
|
+
env.VITE_APP_ICON = DEFAULT_APP_ICON;
|
|
1243
|
+
if (postgresMode === "external") {
|
|
1244
|
+
p.note("Must be an IPv4-compatible direct connection or database pooler.", "DATABASE_URL");
|
|
1245
|
+
const url = await p.password({
|
|
1246
|
+
message: "DATABASE_URL",
|
|
1247
|
+
validate: (v) => !v ? "Required" : void 0
|
|
1542
1248
|
});
|
|
1249
|
+
assertNotCancelled(url);
|
|
1250
|
+
env.DATABASE_URL = url;
|
|
1251
|
+
} else env.DATABASE_URL = LOCAL_DATABASE_URL;
|
|
1252
|
+
const setupMode = await configureProvidersInteractive(env);
|
|
1253
|
+
p.note([
|
|
1254
|
+
"Elmo is open source and maintained by a small team. Telemetry",
|
|
1255
|
+
"from both the CLI and your local deployment (web + worker)",
|
|
1256
|
+
"tells us things like which CLI versions are still in use, where",
|
|
1257
|
+
"`elmo init` drops off, which providers people pick, and whether",
|
|
1258
|
+
"new features actually get used. Without it we are flying blind",
|
|
1259
|
+
"on what to fix or build next.",
|
|
1260
|
+
"",
|
|
1261
|
+
pc.bold("What we send:"),
|
|
1262
|
+
" • deployment ID (random UUID stored as DEPLOYMENT_ID in your .env)",
|
|
1263
|
+
" • CLI/app version, OS, arch, Node version, deployment mode",
|
|
1264
|
+
" • command/event names + non-secret options (e.g. postgres mode)",
|
|
1265
|
+
" • feature counts (prompts edited, brands created — never the names or text)",
|
|
1266
|
+
" • IP address (recorded on each event by PostHog, used for geolocation)",
|
|
1267
|
+
"",
|
|
1268
|
+
pc.bold("What we never send:"),
|
|
1269
|
+
" API keys, .env contents, brand names, prompt text, and scraped responses.",
|
|
1270
|
+
"",
|
|
1271
|
+
`Full breakdown: ${link(pc.cyan(TELEMETRY_DOC_URL), TELEMETRY_DOC_URL)}`,
|
|
1272
|
+
"Toggle later by editing DISABLE_TELEMETRY in .env (`elmo edit env`)."
|
|
1273
|
+
].join("\n"), "Telemetry");
|
|
1274
|
+
const telemetryEnabled = await p.confirm({
|
|
1275
|
+
message: "Share telemetry?",
|
|
1276
|
+
initialValue: true
|
|
1543
1277
|
});
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
const inCwd = await fileExists(path.join(cwd, "Dockerfile"));
|
|
1550
|
-
const inDockerDir = await fileExists(path.join(cwd, "docker", "Dockerfile"));
|
|
1551
|
-
const defaultDir = inCwd ? "." : inDockerDir ? "docker" : ".";
|
|
1552
|
-
const dir = await p.text({
|
|
1553
|
-
message: "Path to docker directory (contains Dockerfile)",
|
|
1554
|
-
defaultValue: defaultDir
|
|
1278
|
+
assertNotCancelled(telemetryEnabled);
|
|
1279
|
+
if (!telemetryEnabled) env.DISABLE_TELEMETRY = "1";
|
|
1280
|
+
const updatesEmail = await p.text({
|
|
1281
|
+
message: "Enter your work email to receive product updates (optional)",
|
|
1282
|
+
placeholder: "you@example.com"
|
|
1555
1283
|
});
|
|
1556
|
-
|
|
1557
|
-
const
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1284
|
+
const email = p.isCancel(updatesEmail) ? void 0 : updatesEmail || void 0;
|
|
1285
|
+
const portInput = await p.text({
|
|
1286
|
+
message: "Web app port",
|
|
1287
|
+
placeholder: String(DEFAULT_APP_PORT),
|
|
1288
|
+
defaultValue: String(DEFAULT_APP_PORT),
|
|
1289
|
+
validate: validatePort
|
|
1290
|
+
});
|
|
1291
|
+
assertNotCancelled(portInput);
|
|
1292
|
+
const port = Number(portInput);
|
|
1293
|
+
env.APP_URL = `http://localhost:${port}`;
|
|
1294
|
+
env.VITE_APP_URL = env.APP_URL;
|
|
1295
|
+
const composeYaml = buildComposeYaml({
|
|
1296
|
+
dev: Boolean(options.dev),
|
|
1297
|
+
postgresMode,
|
|
1298
|
+
repoRoot,
|
|
1299
|
+
dockerDir,
|
|
1300
|
+
port,
|
|
1301
|
+
version
|
|
1302
|
+
});
|
|
1303
|
+
await ensureDir(configDir);
|
|
1304
|
+
await writeConfigFiles(configDir, {
|
|
1305
|
+
env,
|
|
1306
|
+
composeYaml,
|
|
1307
|
+
postgresMode,
|
|
1308
|
+
dev: Boolean(options.dev),
|
|
1309
|
+
version
|
|
1310
|
+
});
|
|
1311
|
+
p.log.success(`Config written to ${configDir}`);
|
|
1312
|
+
p.log.warn("Your generated .env file contains secrets — do not commit it to version control.");
|
|
1313
|
+
if (options.dev) p.log.info("Dev mode enabled. Run `elmo compose build` before starting.");
|
|
1314
|
+
const shouldStart = await p.confirm({
|
|
1315
|
+
message: "Start the stack now?",
|
|
1316
|
+
initialValue: true
|
|
1317
|
+
});
|
|
1318
|
+
assertNotCancelled(shouldStart);
|
|
1319
|
+
if (shouldStart) await doStart(configDir);
|
|
1320
|
+
else p.log.info("You can start later with `elmo compose up -d`.");
|
|
1321
|
+
await trackCliEvent(configDir, "cli_init", {
|
|
1322
|
+
version,
|
|
1323
|
+
os: process.platform,
|
|
1324
|
+
arch: process.arch,
|
|
1325
|
+
node_version: process.version,
|
|
1326
|
+
postgres_mode: postgresMode,
|
|
1327
|
+
dev_mode: Boolean(options.dev),
|
|
1328
|
+
setup_mode: setupMode,
|
|
1329
|
+
has_scraper: Boolean(env.BRIGHTDATA_API_TOKEN || env.OLOSTEP_API_KEY || env.OXYLABS_USERNAME || env.CLORO_API_KEY),
|
|
1330
|
+
has_direct_api: hasDirectApiConfigured(env)
|
|
1331
|
+
});
|
|
1332
|
+
if (email) await submitNewsletterSignup(configDir, email);
|
|
1333
|
+
p.log.message(`If you find Elmo useful, star us on GitHub!\n ${link(pc.cyan("https://github.com/elmohq/elmo"), "https://github.com/elmohq/elmo")}`);
|
|
1334
|
+
p.outro(pc.green("Setup complete!"));
|
|
1335
|
+
}
|
|
1336
|
+
//#endregion
|
|
1337
|
+
//#region src/migrations/planner.ts
|
|
1338
|
+
function planMigrations(from, to, all) {
|
|
1339
|
+
if (!semver.valid(from)) throw new Error(`Invalid from version: ${from}`);
|
|
1340
|
+
if (!semver.valid(to)) throw new Error(`Invalid to version: ${to}`);
|
|
1341
|
+
if (semver.gt(from, to)) throw new Error(`Downgrade not supported: ${from} -> ${to}`);
|
|
1342
|
+
if (semver.eq(from, to)) return [];
|
|
1343
|
+
for (const m of all) {
|
|
1344
|
+
if (!semver.valid(m.from)) throw new Error(`Migration has invalid from: ${m.from}`);
|
|
1345
|
+
if (!semver.valid(m.to)) throw new Error(`Migration has invalid to: ${m.to}`);
|
|
1346
|
+
if (!semver.gt(m.to, m.from)) throw new Error(`Migration ${m.from} -> ${m.to}: 'to' must be greater than 'from'`);
|
|
1561
1347
|
}
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
if (explicitDir) throw new Error(`Config directory does not contain elmo.yaml: ${resolved}\nRun \`elmo init --dir ${explicitDir}\` to create it.`);
|
|
1568
|
-
throw new Error(`No config found at ${resolved}. Run \`elmo init\` to create one, or specify --dir.`);
|
|
1348
|
+
const applicable = all.filter((m) => semver.gte(m.from, from) && semver.lt(m.from, to)).slice().sort((a, b) => semver.compare(a.from, b.from));
|
|
1349
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1350
|
+
for (const m of applicable) {
|
|
1351
|
+
if (seen.has(m.from)) throw new Error(`Multiple migrations registered with from=${m.from}`);
|
|
1352
|
+
seen.add(m.from);
|
|
1569
1353
|
}
|
|
1570
|
-
return
|
|
1571
|
-
}
|
|
1572
|
-
async function writeConfigFiles(configDir, initConfig) {
|
|
1573
|
-
const envPath = path.join(configDir, ".env");
|
|
1574
|
-
const composePath = path.join(configDir, "elmo.yaml");
|
|
1575
|
-
await ensureDir(configDir);
|
|
1576
|
-
await fs.writeFile(envPath, buildEnvFile(initConfig.env, initConfig.version), "utf8");
|
|
1577
|
-
await fs.writeFile(composePath, initConfig.composeYaml, "utf8");
|
|
1354
|
+
return applicable;
|
|
1578
1355
|
}
|
|
1579
|
-
function
|
|
1580
|
-
const
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
""
|
|
1584
|
-
];
|
|
1585
|
-
for (const [key, rawValue] of Object.entries(env)) {
|
|
1586
|
-
if (rawValue === void 0) continue;
|
|
1587
|
-
lines.push(`${key}=${formatEnvValue(rawValue)}`);
|
|
1356
|
+
async function runMigrations(plan, ctx) {
|
|
1357
|
+
for (const migration of plan) {
|
|
1358
|
+
ctx.log.step(`Migrating ${migration.from} → ${migration.to}: ${migration.description}`);
|
|
1359
|
+
await migration.run(ctx);
|
|
1588
1360
|
}
|
|
1589
|
-
return `${lines.join("\n")}\n`;
|
|
1590
|
-
}
|
|
1591
|
-
function formatEnvValue(value) {
|
|
1592
|
-
if (value === "") return "\"\"";
|
|
1593
|
-
if (/[\s#"']/u.test(value)) return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
1594
|
-
return value;
|
|
1595
1361
|
}
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1362
|
+
//#endregion
|
|
1363
|
+
//#region src/migrations/index.ts
|
|
1364
|
+
const MIGRATIONS = [{
|
|
1365
|
+
from: "0.2.17",
|
|
1366
|
+
to: "0.2.18",
|
|
1367
|
+
description: "add ELMO_ENCRYPTION_KEY for encrypted in-app provider credentials",
|
|
1368
|
+
async run(ctx) {
|
|
1369
|
+
const env = await ctx.readEnv();
|
|
1370
|
+
if (env.ELMO_ENCRYPTION_KEY !== void 0) return;
|
|
1371
|
+
env.ELMO_ENCRYPTION_KEY = randomBytes(32).toString("base64");
|
|
1372
|
+
await ctx.writeEnv(env);
|
|
1373
|
+
ctx.log.info("Added ELMO_ENCRYPTION_KEY — lets you store provider credentials encrypted in the app.");
|
|
1602
1374
|
}
|
|
1603
|
-
}
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
}
|
|
1375
|
+
}];
|
|
1376
|
+
//#endregion
|
|
1377
|
+
//#region src/version.ts
|
|
1607
1378
|
async function getPackageVersion() {
|
|
1608
1379
|
const selfDir = path.dirname(fileURLToPath(import.meta.url));
|
|
1609
1380
|
const packagePath = path.resolve(selfDir, "..", "package.json");
|
|
@@ -1624,6 +1395,190 @@ async function maybeNotifyNewVersion(currentVersion) {
|
|
|
1624
1395
|
if (!latest) return;
|
|
1625
1396
|
if (semver.valid(currentVersion) && semver.lt(currentVersion, latest)) log.warn(`New CLI version available (${latest}). Run: npm install -g @elmohq/cli@latest`);
|
|
1626
1397
|
}
|
|
1398
|
+
//#endregion
|
|
1399
|
+
//#region src/commands/upgrade.ts
|
|
1400
|
+
async function confirmStaleCli(cliVersion, assumeYes) {
|
|
1401
|
+
const latestCli = await fetchLatestCliVersion();
|
|
1402
|
+
if (!latestCli || !semver.valid(cliVersion) || !semver.lt(cliVersion, latestCli)) return;
|
|
1403
|
+
log.warn(`Your CLI (${cliVersion}) is behind the latest published version (${latestCli}).`);
|
|
1404
|
+
log.info("Recommended: upgrade the CLI first, then rerun this command:");
|
|
1405
|
+
console.log(` ${pc.bold("npm install -g @elmohq/cli@latest")}`);
|
|
1406
|
+
const proceed = assumeYes ? true : await p.confirm({
|
|
1407
|
+
message: `Continue upgrading the stack with CLI ${cliVersion} anyway?`,
|
|
1408
|
+
initialValue: false
|
|
1409
|
+
});
|
|
1410
|
+
assertNotCancelled(proceed);
|
|
1411
|
+
if (!proceed) {
|
|
1412
|
+
p.cancel("Upgrade cancelled. Upgrade the CLI and rerun `elmo upgrade`.");
|
|
1413
|
+
process.exit(0);
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
async function pullAtCurrentVersion(configDir, cliVersion, assumeYes) {
|
|
1417
|
+
log.success(`Already at ${cliVersion}.`);
|
|
1418
|
+
const pull = assumeYes ? true : await p.confirm({
|
|
1419
|
+
message: "Pull images for this version anyway?",
|
|
1420
|
+
initialValue: false
|
|
1421
|
+
});
|
|
1422
|
+
assertNotCancelled(pull);
|
|
1423
|
+
if (pull) {
|
|
1424
|
+
assertDockerRunning();
|
|
1425
|
+
const wasRunning = await stackHasRunningServices(configDir);
|
|
1426
|
+
log.step("Pulling images...");
|
|
1427
|
+
await runDockerCompose(configDir, ["pull"]);
|
|
1428
|
+
if (wasRunning) {
|
|
1429
|
+
log.step("Restarting services...");
|
|
1430
|
+
await runDockerCompose(configDir, ["up", "-d"]);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
p.outro(pc.green("Nothing to upgrade."));
|
|
1434
|
+
}
|
|
1435
|
+
/**
|
|
1436
|
+
* With no detected version we can't tell which migrations apply, so we skip
|
|
1437
|
+
* them and just re-pin + pull. (planMigrations would also return [] here since
|
|
1438
|
+
* from === to, but it is special-cased for a clearer message.)
|
|
1439
|
+
*/
|
|
1440
|
+
function planUpgradeMigrations(detectedVersion, fromVersion, cliVersion) {
|
|
1441
|
+
const plan = detectedVersion === null ? [] : planMigrations(fromVersion, cliVersion, MIGRATIONS);
|
|
1442
|
+
if (detectedVersion === null) log.warn(`Couldn't detect the deployment's version — re-pinning images to ${pc.cyan(cliVersion)}.`);
|
|
1443
|
+
else log.info(`Upgrading from ${pc.cyan(fromVersion)} → ${pc.cyan(cliVersion)}`);
|
|
1444
|
+
if (plan.length === 0) {
|
|
1445
|
+
log.step("No migrations to run (docker images will be re-pinned and pulled).");
|
|
1446
|
+
return plan;
|
|
1447
|
+
}
|
|
1448
|
+
log.step(`Migrations to apply: ${plan.length}`);
|
|
1449
|
+
for (const migration of plan) console.log(` • ${pc.bold(`${migration.from} → ${migration.to}`)} ${migration.description}`);
|
|
1450
|
+
return plan;
|
|
1451
|
+
}
|
|
1452
|
+
async function applyUpgradeMigrations(plan, ctx, wasRunning) {
|
|
1453
|
+
try {
|
|
1454
|
+
await runMigrations(plan, ctx);
|
|
1455
|
+
} catch (error) {
|
|
1456
|
+
log.error(`Migration failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1457
|
+
log.info("Your config version was left unchanged. Fix the issue and rerun `elmo upgrade`.");
|
|
1458
|
+
if (wasRunning) log.info("The stack was stopped for the upgrade. Restart with `elmo compose up -d` after fixing.");
|
|
1459
|
+
process.exit(1);
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
async function restartAfterUpgrade(configDir, wasRunning) {
|
|
1463
|
+
if (!wasRunning) {
|
|
1464
|
+
log.info("Stack was stopped before upgrade — leaving it stopped. Start with `elmo compose up -d`.");
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
log.step("Starting services...");
|
|
1468
|
+
await runDockerCompose(configDir, ["up", "-d"]);
|
|
1469
|
+
const spinner = p.spinner();
|
|
1470
|
+
spinner.start("Waiting for services to become healthy...");
|
|
1471
|
+
if (await waitForHealthy(configDir, 18e4)) {
|
|
1472
|
+
spinner.stop("All services healthy!");
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
spinner.stop("Health check timed out.");
|
|
1476
|
+
log.warn("Some services did not report healthy status.");
|
|
1477
|
+
}
|
|
1478
|
+
async function runUpgrade(options, cliVersion) {
|
|
1479
|
+
printBanner();
|
|
1480
|
+
p.intro(pc.bold("Upgrading Elmo"));
|
|
1481
|
+
await confirmStaleCli(cliVersion, options.yes);
|
|
1482
|
+
const configDir = await resolveConfigDir(options.dir);
|
|
1483
|
+
const composePath = path.join(configDir, "elmo.yaml");
|
|
1484
|
+
const detectedVersion = await readRenderedVersion(composePath);
|
|
1485
|
+
const fromVersion = detectedVersion ?? cliVersion;
|
|
1486
|
+
if (!semver.valid(fromVersion)) throw new Error(`Could not determine the installed version from ${composePath}.`);
|
|
1487
|
+
if (semver.gt(fromVersion, cliVersion)) {
|
|
1488
|
+
log.warn(`Your deployment (${fromVersion}) is newer than this CLI (${cliVersion}).`);
|
|
1489
|
+
log.info("Upgrade the CLI to match, then rerun:");
|
|
1490
|
+
console.log(` ${pc.bold("npm install -g @elmohq/cli@latest")}`);
|
|
1491
|
+
process.exit(1);
|
|
1492
|
+
}
|
|
1493
|
+
if (detectedVersion !== null && semver.eq(detectedVersion, cliVersion)) {
|
|
1494
|
+
await pullAtCurrentVersion(configDir, cliVersion, options.yes);
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
const plan = planUpgradeMigrations(detectedVersion, fromVersion, cliVersion);
|
|
1498
|
+
const confirm = options.yes ? true : await p.confirm({
|
|
1499
|
+
message: "Proceed with upgrade?",
|
|
1500
|
+
initialValue: true
|
|
1501
|
+
});
|
|
1502
|
+
assertNotCancelled(confirm);
|
|
1503
|
+
if (!confirm) {
|
|
1504
|
+
p.cancel("Upgrade cancelled.");
|
|
1505
|
+
process.exit(0);
|
|
1506
|
+
}
|
|
1507
|
+
assertDockerRunning();
|
|
1508
|
+
const wasRunning = await stackHasRunningServices(configDir);
|
|
1509
|
+
if (wasRunning) {
|
|
1510
|
+
log.step("Stopping services...");
|
|
1511
|
+
await runDockerCompose(configDir, ["down"]);
|
|
1512
|
+
}
|
|
1513
|
+
await applyUpgradeMigrations(plan, buildMigrationContext(configDir, cliVersion), wasRunning);
|
|
1514
|
+
const isDev = await composeUsesBuild(composePath);
|
|
1515
|
+
await repinComposeImages(composePath, cliVersion);
|
|
1516
|
+
await refreshRenderedVersion(path.join(configDir, ".env"), cliVersion);
|
|
1517
|
+
log.success(`Pinned config to ${cliVersion}.`);
|
|
1518
|
+
if (isDev) log.info("Dev mode detected — rebuild with `elmo compose build` to apply the new version.");
|
|
1519
|
+
else {
|
|
1520
|
+
log.step("Pulling images...");
|
|
1521
|
+
await runDockerCompose(configDir, ["pull"]);
|
|
1522
|
+
}
|
|
1523
|
+
await restartAfterUpgrade(configDir, wasRunning);
|
|
1524
|
+
await trackCliEvent(configDir, "cli_upgrade", {
|
|
1525
|
+
from_version: fromVersion,
|
|
1526
|
+
to_version: cliVersion,
|
|
1527
|
+
migrations_run: plan.length,
|
|
1528
|
+
was_running: wasRunning,
|
|
1529
|
+
dev_mode: isDev
|
|
1530
|
+
});
|
|
1531
|
+
p.outro(pc.green(`Upgraded to ${cliVersion}.`));
|
|
1532
|
+
}
|
|
1533
|
+
function buildMigrationContext(configDir, version) {
|
|
1534
|
+
const envPath = path.join(configDir, ".env");
|
|
1535
|
+
return {
|
|
1536
|
+
configDir,
|
|
1537
|
+
log: {
|
|
1538
|
+
info: (msg) => log.info(msg),
|
|
1539
|
+
warn: (msg) => log.warn(msg),
|
|
1540
|
+
step: (msg) => log.step(msg)
|
|
1541
|
+
},
|
|
1542
|
+
readEnv: async () => {
|
|
1543
|
+
try {
|
|
1544
|
+
return parse(await fs.readFile(envPath, "utf8"));
|
|
1545
|
+
} catch {
|
|
1546
|
+
return {};
|
|
1547
|
+
}
|
|
1548
|
+
},
|
|
1549
|
+
writeEnv: async (env) => {
|
|
1550
|
+
await fs.writeFile(envPath, buildEnvFile(env, version), "utf8");
|
|
1551
|
+
}
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
//#endregion
|
|
1555
|
+
//#region src/index.ts
|
|
1556
|
+
async function main() {
|
|
1557
|
+
const version = await getPackageVersion();
|
|
1558
|
+
const program = new Command();
|
|
1559
|
+
program.name("elmo").version(version).option("--dir <path>", "Config directory").configureHelp({ showGlobalOptions: true }).action(() => {
|
|
1560
|
+
printBanner();
|
|
1561
|
+
program.outputHelp();
|
|
1562
|
+
});
|
|
1563
|
+
program.command("init").description("set up local Elmo instance").option("--dev", "Use local build context (repo only)").option("--docker-dir <path>", "Path to Docker build context (dev mode)").action(async (_opts, cmd) => {
|
|
1564
|
+
await withVersionCheck(version, () => runInit(cmd.optsWithGlobals(), version));
|
|
1565
|
+
});
|
|
1566
|
+
program.command("compose").description("run Docker Compose commands using your Elmo config").allowUnknownOption(true).argument("[args...]", "Arguments passed to Docker Compose").action(async (args, _opts, cmd) => {
|
|
1567
|
+
await withVersionCheck(version, () => runCompose(args, cmd.optsWithGlobals()));
|
|
1568
|
+
});
|
|
1569
|
+
program.command("edit").description("change API keys, scrape targets, or the Docker Compose YAML").argument("<env|compose>", "which config file to edit").action(async (target, _opts, cmd) => {
|
|
1570
|
+
await runEdit(target, cmd.optsWithGlobals());
|
|
1571
|
+
});
|
|
1572
|
+
program.command("upgrade").description("upgrade your Elmo deployment to this CLI's version").option("--yes", "skip confirmation prompts").action(async (_opts, cmd) => {
|
|
1573
|
+
await runUpgrade(cmd.optsWithGlobals(), version);
|
|
1574
|
+
});
|
|
1575
|
+
await program.parseAsync(process.argv);
|
|
1576
|
+
}
|
|
1577
|
+
async function withVersionCheck(version, fn) {
|
|
1578
|
+
const notifyPromise = maybeNotifyNewVersion(version);
|
|
1579
|
+
await fn();
|
|
1580
|
+
await notifyPromise.catch(() => void 0);
|
|
1581
|
+
}
|
|
1627
1582
|
main().catch((error) => {
|
|
1628
1583
|
const msg = error instanceof Error ? error.message : String(error);
|
|
1629
1584
|
console.error(`\n${pc.red("Error:")} ${msg}`);
|