@elmohq/cli 0.2.19 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1384 -1269
- package/package.json +7 -7
package/dist/index.js
CHANGED
|
@@ -1,54 +1,16 @@
|
|
|
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
|
-
* Parse the SCRAPE_TARGETS env var into structured ModelConfig objects.
|
|
17
|
-
*
|
|
18
|
-
* Parsing: split on ":". First = model, second = provider. If last segment is "online",
|
|
19
|
-
* pop it (websearch = true). Remaining middle segments rejoined with ":" = version slug.
|
|
20
|
-
* This naturally handles OpenRouter variant suffixes like ":free".
|
|
21
|
-
*/
|
|
22
|
-
function parseScrapeTargets(envValue) {
|
|
23
|
-
if (!envValue || !envValue.trim()) throw new Error("SCRAPE_TARGETS environment variable is required. Set it to configure which AI models to track. Example:\n SCRAPE_TARGETS=chatgpt:olostep:online,google-ai-mode:olostep:online,copilot:olostep:online\nSee https://docs.elmohq.com/docs/user-guide/providers for details.");
|
|
24
|
-
return envValue.split(",").map((raw) => {
|
|
25
|
-
const trimmed = raw.trim();
|
|
26
|
-
if (!trimmed) throw new Error("Invalid SCRAPE_TARGETS: empty entry (check for trailing commas)");
|
|
27
|
-
const parts = trimmed.split(":");
|
|
28
|
-
if (parts.length < 2) throw new Error(`Invalid SCRAPE_TARGETS entry: "${trimmed}" (need at least model:provider)`);
|
|
29
|
-
const model = parts[0];
|
|
30
|
-
const provider = parts[1];
|
|
31
|
-
const webSearch = parts[parts.length - 1] === "online";
|
|
32
|
-
const versionParts = parts.slice(2, webSearch ? -1 : void 0);
|
|
33
|
-
return {
|
|
34
|
-
model,
|
|
35
|
-
provider,
|
|
36
|
-
version: versionParts.length > 0 ? versionParts.join(":") : void 0,
|
|
37
|
-
webSearch
|
|
38
|
-
};
|
|
39
|
-
});
|
|
40
|
-
}
|
|
41
|
-
/**
|
|
42
|
-
* Inverse of parseScrapeTargets for a single entry: build the
|
|
43
|
-
* model:provider[:version][:online] string from a ModelConfig.
|
|
44
|
-
*/
|
|
45
|
-
function formatScrapeTarget(config) {
|
|
46
|
-
const parts = [config.model, config.provider];
|
|
47
|
-
if (config.version) parts.push(config.version);
|
|
48
|
-
if (config.webSearch) parts.push("online");
|
|
49
|
-
return parts.join(":");
|
|
50
|
-
}
|
|
51
|
-
//#endregion
|
|
12
|
+
import semver from "semver";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
52
14
|
//#region src/compose-pin.ts
|
|
53
15
|
function renderedByHeader(version) {
|
|
54
16
|
return [`# Rendered by elmo ${version} on ${(/* @__PURE__ */ new Date()).toISOString()}`, "# Run `elmo upgrade` after upgrading the CLI to refresh this file."].join("\n");
|
|
@@ -65,107 +27,60 @@ function repinImages(contents, version) {
|
|
|
65
27
|
return contents.replace(/(image:\s*elmohq\/elmo-[a-z-]+):\S+/g, `$1:${version}`);
|
|
66
28
|
}
|
|
67
29
|
//#endregion
|
|
68
|
-
//#region src/
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (!semver.valid(m.from)) throw new Error(`Migration has invalid from: ${m.from}`);
|
|
76
|
-
if (!semver.valid(m.to)) throw new Error(`Migration has invalid to: ${m.to}`);
|
|
77
|
-
if (!semver.gt(m.to, m.from)) throw new Error(`Migration ${m.from} -> ${m.to}: 'to' must be greater than 'from'`);
|
|
78
|
-
}
|
|
79
|
-
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));
|
|
80
|
-
const seen = /* @__PURE__ */ new Set();
|
|
81
|
-
for (const m of applicable) {
|
|
82
|
-
if (seen.has(m.from)) throw new Error(`Multiple migrations registered with from=${m.from}`);
|
|
83
|
-
seen.add(m.from);
|
|
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.`);
|
|
84
37
|
}
|
|
85
|
-
return
|
|
38
|
+
return resolved;
|
|
86
39
|
}
|
|
87
|
-
async function
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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");
|
|
92
46
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
env.ELMO_ENCRYPTION_KEY = randomBytes(32).toString("base64");
|
|
103
|
-
await ctx.writeEnv(env);
|
|
104
|
-
ctx.log.info("Added ELMO_ENCRYPTION_KEY — lets you store provider credentials encrypted in the app.");
|
|
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)}`);
|
|
105
56
|
}
|
|
106
|
-
}
|
|
107
|
-
//#endregion
|
|
108
|
-
//#region src/telemetry.ts
|
|
109
|
-
const POSTHOG_PUBLIC_KEY = "phc_Jhx9LnI9cTDFHpQmpOzJSDTW127qD9pFU65KRnYym6z";
|
|
110
|
-
const POSTHOG_HOST = "https://us.i.posthog.com";
|
|
111
|
-
function envDisabled() {
|
|
112
|
-
return Boolean(process.env.DISABLE_TELEMETRY);
|
|
57
|
+
return `${lines.join("\n")}\n`;
|
|
113
58
|
}
|
|
114
|
-
|
|
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) {
|
|
115
65
|
try {
|
|
116
|
-
|
|
117
|
-
return parse(contents)[key];
|
|
66
|
+
return parseRenderedVersion(await fs.readFile(filePath, "utf8"));
|
|
118
67
|
} catch {
|
|
119
|
-
return;
|
|
68
|
+
return null;
|
|
120
69
|
}
|
|
121
70
|
}
|
|
122
|
-
async function
|
|
123
|
-
if (envDisabled()) return true;
|
|
124
|
-
return Boolean(await readEnvKey(configDir, "DISABLE_TELEMETRY"));
|
|
125
|
-
}
|
|
126
|
-
async function trackCliEvent(configDir, eventName, properties, personProperties) {
|
|
127
|
-
if (await isTelemetryDisabled(configDir)) return;
|
|
128
|
-
const distinctId = await readEnvKey(configDir, "DEPLOYMENT_ID");
|
|
129
|
-
if (!distinctId) return;
|
|
71
|
+
async function fileExists(target) {
|
|
130
72
|
try {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
...properties,
|
|
137
|
-
...personProperties ? { $set: personProperties } : {}
|
|
138
|
-
}
|
|
139
|
-
});
|
|
140
|
-
await client.shutdown();
|
|
141
|
-
} catch {}
|
|
73
|
+
await fs.access(target);
|
|
74
|
+
return true;
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
142
78
|
}
|
|
143
|
-
async function
|
|
144
|
-
|
|
145
|
-
const distinctId = (await isTelemetryDisabled(configDir) ? null : await readEnvKey(configDir, "DEPLOYMENT_ID")) ?? email;
|
|
146
|
-
const client = new PostHog(POSTHOG_PUBLIC_KEY, { host: POSTHOG_HOST });
|
|
147
|
-
client.capture({
|
|
148
|
-
distinctId,
|
|
149
|
-
event: "newsletter_signup",
|
|
150
|
-
properties: {
|
|
151
|
-
source: "cli_init",
|
|
152
|
-
$set: {
|
|
153
|
-
email,
|
|
154
|
-
wants_updates: true
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
});
|
|
158
|
-
await client.shutdown();
|
|
159
|
-
} catch {}
|
|
79
|
+
async function ensureDir(dir) {
|
|
80
|
+
await fs.mkdir(dir, { recursive: true });
|
|
160
81
|
}
|
|
161
82
|
//#endregion
|
|
162
|
-
//#region src/
|
|
163
|
-
const CONFIG_HOME = path.join(os.homedir(), ".elmo");
|
|
164
|
-
const DEFAULT_APP_NAME = "Elmo";
|
|
165
|
-
const DEFAULT_APP_ICON = "/icons/elmo-icon.svg";
|
|
166
|
-
const DEFAULT_APP_PORT = 1515;
|
|
167
|
-
const LOCAL_DATABASE_URL = "postgres://postgres:postgres@postgres:5432/elmo";
|
|
168
|
-
const TELEMETRY_DOC_URL = "https://elmohq.com/docs/developer-guide/telemetry";
|
|
83
|
+
//#region src/util.ts
|
|
169
84
|
const ELMO_ASCII = [
|
|
170
85
|
"",
|
|
171
86
|
" ▄▄ ",
|
|
@@ -197,742 +112,780 @@ function generateSecret(bytes = 32, encoding = "base64url") {
|
|
|
197
112
|
function link(text, url) {
|
|
198
113
|
return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
|
|
199
114
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const program = new Command();
|
|
203
|
-
program.name("elmo").version(version).option("--dir <path>", "Config directory").configureHelp({ showGlobalOptions: true }).action(() => {
|
|
204
|
-
printBanner();
|
|
205
|
-
program.outputHelp();
|
|
206
|
-
});
|
|
207
|
-
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) => {
|
|
208
|
-
await withVersionCheck(version, () => runInit(cmd.optsWithGlobals(), version));
|
|
209
|
-
});
|
|
210
|
-
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) => {
|
|
211
|
-
await withVersionCheck(version, () => runCompose(args, cmd.optsWithGlobals()));
|
|
212
|
-
});
|
|
213
|
-
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) => {
|
|
214
|
-
await runEdit(target, cmd.optsWithGlobals());
|
|
215
|
-
});
|
|
216
|
-
program.command("upgrade").description("upgrade your Elmo deployment to this CLI's version").option("--yes", "skip confirmation prompts").action(async (_opts, cmd) => {
|
|
217
|
-
await runUpgrade(cmd.optsWithGlobals(), version);
|
|
218
|
-
});
|
|
219
|
-
await program.parseAsync(process.argv);
|
|
115
|
+
function sleep(ms) {
|
|
116
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
220
117
|
}
|
|
221
|
-
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
await notifyPromise.catch(() => void 0);
|
|
118
|
+
function indentBlock(block, spaces) {
|
|
119
|
+
const indent = " ".repeat(spaces);
|
|
120
|
+
return block.split("\n").map((line) => `${indent}${line}`).join("\n");
|
|
225
121
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
p.log.warn(`An existing Elmo config was found at ${configDir}.`);
|
|
248
|
-
const overwrite = await p.confirm({
|
|
249
|
-
message: "Overwrite it with new values? Existing secrets (DATABASE_URL, API keys, etc.) will be replaced.",
|
|
250
|
-
initialValue: false
|
|
251
|
-
});
|
|
252
|
-
assertNotCancelled(overwrite);
|
|
253
|
-
if (!overwrite) {
|
|
254
|
-
p.cancel("Setup cancelled. Use `elmo edit env` to change individual values.");
|
|
255
|
-
process.exit(0);
|
|
256
|
-
}
|
|
257
|
-
preservedDeploymentId = parse(contents).DEPLOYMENT_ID;
|
|
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 [];
|
|
258
143
|
}
|
|
259
144
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
if (
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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}`));
|
|
299
186
|
});
|
|
300
|
-
assertNotCancelled(url);
|
|
301
|
-
env.DATABASE_URL = url;
|
|
302
|
-
} else env.DATABASE_URL = LOCAL_DATABASE_URL;
|
|
303
|
-
const setupMode = await configureProvidersInteractive(env);
|
|
304
|
-
p.note([
|
|
305
|
-
"Elmo is open source and maintained by a small team. Telemetry",
|
|
306
|
-
"from both the CLI and your local deployment (web + worker)",
|
|
307
|
-
"tells us things like which CLI versions are still in use, where",
|
|
308
|
-
"`elmo init` drops off, which providers people pick, and whether",
|
|
309
|
-
"new features actually get used. Without it we are flying blind",
|
|
310
|
-
"on what to fix or build next.",
|
|
311
|
-
"",
|
|
312
|
-
pc.bold("What we send:"),
|
|
313
|
-
" • deployment ID (random UUID stored as DEPLOYMENT_ID in your .env)",
|
|
314
|
-
" • CLI/app version, OS, arch, Node version, deployment mode",
|
|
315
|
-
" • command/event names + non-secret options (e.g. postgres mode)",
|
|
316
|
-
" • feature counts (prompts edited, brands created — never the names or text)",
|
|
317
|
-
" • IP address (recorded on each event by PostHog, used for geolocation)",
|
|
318
|
-
"",
|
|
319
|
-
pc.bold("What we never send:"),
|
|
320
|
-
" API keys, .env contents, brand names, prompt text, and scraped responses.",
|
|
321
|
-
"",
|
|
322
|
-
`Full breakdown: ${link(pc.cyan(TELEMETRY_DOC_URL), TELEMETRY_DOC_URL)}`,
|
|
323
|
-
"Toggle later by editing DISABLE_TELEMETRY in .env (`elmo edit env`)."
|
|
324
|
-
].join("\n"), "Telemetry");
|
|
325
|
-
const telemetryEnabled = await p.confirm({
|
|
326
|
-
message: "Share telemetry?",
|
|
327
|
-
initialValue: true
|
|
328
|
-
});
|
|
329
|
-
assertNotCancelled(telemetryEnabled);
|
|
330
|
-
if (!telemetryEnabled) env.DISABLE_TELEMETRY = "1";
|
|
331
|
-
const updatesEmail = await p.text({
|
|
332
|
-
message: "Enter your work email to receive product updates (optional)",
|
|
333
|
-
placeholder: "you@example.com"
|
|
334
|
-
});
|
|
335
|
-
const email = p.isCancel(updatesEmail) ? void 0 : updatesEmail || void 0;
|
|
336
|
-
const portInput = await p.text({
|
|
337
|
-
message: "Web app port",
|
|
338
|
-
placeholder: String(DEFAULT_APP_PORT),
|
|
339
|
-
defaultValue: String(DEFAULT_APP_PORT),
|
|
340
|
-
validate: (v) => {
|
|
341
|
-
if (!v) return void 0;
|
|
342
|
-
const n = Number(v);
|
|
343
|
-
if (!Number.isInteger(n) || n < 1 || n > 65535) return "Must be an integer between 1 and 65535";
|
|
344
|
-
}
|
|
345
|
-
});
|
|
346
|
-
assertNotCancelled(portInput);
|
|
347
|
-
const port = Number(portInput);
|
|
348
|
-
env.APP_URL = `http://localhost:${port}`;
|
|
349
|
-
env.VITE_APP_URL = env.APP_URL;
|
|
350
|
-
const composeYaml = buildComposeYaml({
|
|
351
|
-
dev: Boolean(options.dev),
|
|
352
|
-
postgresMode,
|
|
353
|
-
repoRoot,
|
|
354
|
-
dockerDir,
|
|
355
|
-
port,
|
|
356
|
-
version
|
|
357
|
-
});
|
|
358
|
-
await ensureDir(configDir);
|
|
359
|
-
await writeConfigFiles(configDir, {
|
|
360
|
-
env,
|
|
361
|
-
composeYaml,
|
|
362
|
-
postgresMode,
|
|
363
|
-
dev: Boolean(options.dev),
|
|
364
|
-
version
|
|
365
187
|
});
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
+
});
|
|
372
210
|
});
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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));
|
|
386
244
|
});
|
|
387
|
-
|
|
388
|
-
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")}`);
|
|
389
|
-
p.outro(pc.green("Setup complete!"));
|
|
245
|
+
log.info("Restart the stack with `elmo compose up -d` to apply changes.");
|
|
390
246
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
"chatgpt",
|
|
421
|
-
"google-ai-mode",
|
|
422
|
-
"google-ai-overview",
|
|
423
|
-
"perplexity",
|
|
424
|
-
"copilot",
|
|
425
|
-
"gemini"
|
|
426
|
-
];
|
|
427
|
-
const DEFAULT_SCRAPER_MODELS = ["chatgpt", "google-ai-mode"];
|
|
428
|
-
const DATAFORSEO_TARGETS = [
|
|
429
|
-
{
|
|
430
|
-
model: "google-ai-mode",
|
|
431
|
-
kind: "scraper"
|
|
247
|
+
//#endregion
|
|
248
|
+
//#region ../../packages/config/src/referrals.ts
|
|
249
|
+
/**
|
|
250
|
+
* Links from the product and the CLI back to our own sites.
|
|
251
|
+
*
|
|
252
|
+
* Each one carries a `ref` naming the surface it was clicked from, so the
|
|
253
|
+
* marketing site can tell a self-hosted operator who arrived from the sign-in
|
|
254
|
+
* page apart from one who arrived from `elmo init` — the two want different
|
|
255
|
+
* things and convert at very different rates.
|
|
256
|
+
*/
|
|
257
|
+
const MARKETING_SITE_URL = "https://www.elmohq.com";
|
|
258
|
+
function tagged(url, ref) {
|
|
259
|
+
const link = new URL(url);
|
|
260
|
+
link.searchParams.set("ref", ref);
|
|
261
|
+
return link.toString();
|
|
262
|
+
}
|
|
263
|
+
/** A marketing-site page, tagged with where the click came from. */
|
|
264
|
+
function marketingUrl(path, ref) {
|
|
265
|
+
return tagged(new URL(path, MARKETING_SITE_URL).toString(), ref);
|
|
266
|
+
}
|
|
267
|
+
/** The cloud pricing table, tagged with where the click came from. */
|
|
268
|
+
function cloudPricingUrl(ref) {
|
|
269
|
+
return marketingUrl("/pricing", ref);
|
|
270
|
+
}
|
|
271
|
+
/** The platforms a plan's picks may come from — everything sold as a pick. */
|
|
272
|
+
const STANDARD_PLATFORM_MENU = Object.entries({
|
|
273
|
+
chatgpt: {
|
|
274
|
+
access: "scraped",
|
|
275
|
+
premium: true
|
|
432
276
|
},
|
|
433
|
-
{
|
|
434
|
-
|
|
435
|
-
|
|
277
|
+
"google-ai-mode": { access: "scraped" },
|
|
278
|
+
"google-ai-overview": { access: "scraped" },
|
|
279
|
+
copilot: { access: "scraped" },
|
|
280
|
+
perplexity: { access: "scraped" },
|
|
281
|
+
gemini: { access: "scraped" },
|
|
282
|
+
qwen: { access: "api" },
|
|
283
|
+
deepseek: { access: "api" },
|
|
284
|
+
grok: { premium: true },
|
|
285
|
+
mistral: { access: "api" },
|
|
286
|
+
claude: {
|
|
287
|
+
access: "api",
|
|
288
|
+
premium: true
|
|
289
|
+
}
|
|
290
|
+
}).filter(([, platform]) => platform.access !== void 0).map(([model]) => model);
|
|
291
|
+
const PLANS = {
|
|
292
|
+
starter: {
|
|
293
|
+
key: "starter",
|
|
294
|
+
name: "Starter",
|
|
295
|
+
monthlyPriceUsd: 29,
|
|
296
|
+
annualPriceUsd: 290,
|
|
297
|
+
maxBrands: 1,
|
|
298
|
+
maxPrompts: 50,
|
|
299
|
+
platformMenu: ["chatgpt"],
|
|
300
|
+
platformPicks: 1,
|
|
301
|
+
standardRunsPerDay: 1,
|
|
302
|
+
premiumIncluded: 0,
|
|
303
|
+
premiumAddonAvailable: false
|
|
436
304
|
},
|
|
437
|
-
{
|
|
438
|
-
|
|
439
|
-
|
|
305
|
+
basic: {
|
|
306
|
+
key: "basic",
|
|
307
|
+
name: "Basic",
|
|
308
|
+
monthlyPriceUsd: 99,
|
|
309
|
+
annualPriceUsd: 990,
|
|
310
|
+
maxBrands: 1,
|
|
311
|
+
maxPrompts: 50,
|
|
312
|
+
platformMenu: STANDARD_PLATFORM_MENU,
|
|
313
|
+
platformPicks: 4,
|
|
314
|
+
standardRunsPerDay: 4,
|
|
315
|
+
premiumIncluded: 0,
|
|
316
|
+
premiumAddonAvailable: false
|
|
440
317
|
},
|
|
441
|
-
{
|
|
442
|
-
|
|
443
|
-
|
|
318
|
+
pro: {
|
|
319
|
+
key: "pro",
|
|
320
|
+
name: "Pro",
|
|
321
|
+
monthlyPriceUsd: 299,
|
|
322
|
+
annualPriceUsd: 2990,
|
|
323
|
+
maxBrands: 2,
|
|
324
|
+
maxPrompts: 150,
|
|
325
|
+
platformMenu: STANDARD_PLATFORM_MENU,
|
|
326
|
+
platformPicks: 4,
|
|
327
|
+
standardRunsPerDay: 4,
|
|
328
|
+
premiumIncluded: 20,
|
|
329
|
+
premiumAddonAvailable: true
|
|
444
330
|
},
|
|
445
|
-
{
|
|
446
|
-
|
|
447
|
-
|
|
331
|
+
business: {
|
|
332
|
+
key: "business",
|
|
333
|
+
name: "Business",
|
|
334
|
+
monthlyPriceUsd: 649,
|
|
335
|
+
annualPriceUsd: 6490,
|
|
336
|
+
maxBrands: 5,
|
|
337
|
+
maxPrompts: 350,
|
|
338
|
+
platformMenu: STANDARD_PLATFORM_MENU,
|
|
339
|
+
platformPicks: 4,
|
|
340
|
+
standardRunsPerDay: 4,
|
|
341
|
+
premiumIncluded: 30,
|
|
342
|
+
premiumAddonAvailable: true
|
|
448
343
|
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
const
|
|
452
|
-
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
344
|
+
};
|
|
345
|
+
Math.max(...Object.values(PLANS).map((plan) => plan.maxBrands));
|
|
346
|
+
const PLAN_KEYS = Object.keys(PLANS);
|
|
347
|
+
/** Lowest self-serve monthly price among all plans. */
|
|
348
|
+
const CLOUD_ENTRY_PRICE_USD = Math.min(...PLAN_KEYS.map((key) => PLANS[key].monthlyPriceUsd));
|
|
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
|
|
358
|
+
//#region ../../packages/config/src/scrape-targets.ts
|
|
359
|
+
/**
|
|
360
|
+
* The single module that parses and formats the SCRAPE_TARGETS env var.
|
|
361
|
+
*
|
|
362
|
+
* Format: model:provider[:version][:online]
|
|
363
|
+
* - model: AI model to track (chatgpt, google-ai-mode, copilot, etc.)
|
|
364
|
+
* - provider: How to reach it (olostep, brightdata, direct, openrouter, dataforseo)
|
|
365
|
+
* - version: Specific version slug, required for direct/openrouter (may contain colons for OpenRouter variants)
|
|
366
|
+
* - :online: Append to enable web search. Omit = no web search.
|
|
367
|
+
*/
|
|
368
|
+
/**
|
|
369
|
+
* Parse the SCRAPE_TARGETS env var into structured ModelConfig objects.
|
|
370
|
+
*
|
|
371
|
+
* Parsing: split on ":". First = model, second = provider. If last segment is "online",
|
|
372
|
+
* pop it (websearch = true). Remaining middle segments rejoined with ":" = version slug.
|
|
373
|
+
* This naturally handles OpenRouter variant suffixes like ":free".
|
|
374
|
+
*/
|
|
375
|
+
function parseScrapeTargets(envValue) {
|
|
376
|
+
if (!envValue || !envValue.trim()) throw new Error(`SCRAPE_TARGETS environment variable is required. Set it to configure which AI models to track. Example:
|
|
377
|
+
SCRAPE_TARGETS=chatgpt:olostep:online,google-ai-mode:olostep:online,copilot:olostep:online
|
|
378
|
+
See ${PROVIDERS_DOCS_URL} for details.`);
|
|
379
|
+
return envValue.split(",").map((raw) => {
|
|
380
|
+
const trimmed = raw.trim();
|
|
381
|
+
if (!trimmed) throw new Error("Invalid SCRAPE_TARGETS: empty entry (check for trailing commas)");
|
|
382
|
+
const parts = trimmed.split(":");
|
|
383
|
+
if (parts.length < 2) throw new Error(`Invalid SCRAPE_TARGETS entry: "${trimmed}" (need at least model:provider)`);
|
|
384
|
+
const model = parts[0];
|
|
385
|
+
const provider = parts[1];
|
|
386
|
+
const webSearch = parts[parts.length - 1] === "online";
|
|
387
|
+
const versionParts = parts.slice(2, webSearch ? -1 : void 0);
|
|
388
|
+
return {
|
|
389
|
+
model,
|
|
390
|
+
provider,
|
|
391
|
+
version: versionParts.length > 0 ? versionParts.join(":") : void 0,
|
|
392
|
+
webSearch
|
|
393
|
+
};
|
|
482
394
|
});
|
|
483
|
-
assertNotCancelled(mode);
|
|
484
|
-
if (mode === "recommended") await configureProvidersRecommended(env);
|
|
485
|
-
else await configureProvidersCustom(env);
|
|
486
|
-
return mode;
|
|
487
395
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
{
|
|
498
|
-
value: "brightdata",
|
|
499
|
-
label: "BrightData — pay-as-you-go, cheaper but slower (~$0.45/mo per prompt)"
|
|
500
|
-
},
|
|
501
|
-
{
|
|
502
|
-
value: "oxylabs",
|
|
503
|
-
label: "Oxylabs — cheapest per run, no Gemini/Copilot ($49/mo min)"
|
|
504
|
-
},
|
|
505
|
-
{
|
|
506
|
-
value: "olostep",
|
|
507
|
-
label: "Olostep — premium, built for high volume (~$2.25/mo per prompt)"
|
|
508
|
-
},
|
|
509
|
-
{
|
|
510
|
-
value: "dataforseo",
|
|
511
|
-
label: "DataForSEO — pay-as-you-go, scrapers + direct APIs (~$1.20/mo per prompt)"
|
|
512
|
-
}
|
|
513
|
-
],
|
|
514
|
-
initialValue: "cloro"
|
|
515
|
-
});
|
|
516
|
-
assertNotCancelled(scraper);
|
|
517
|
-
await collectScraperKey(scraper, env);
|
|
518
|
-
for (const model of DEFAULT_SCRAPER_MODELS) targets.push(formatScrapeTarget({
|
|
519
|
-
model,
|
|
520
|
-
provider: scraper,
|
|
521
|
-
webSearch: true
|
|
522
|
-
}));
|
|
523
|
-
const direct = await p.select({
|
|
524
|
-
message: "Direct LLM API (powers onboarding analysis + sentiment scoring)",
|
|
525
|
-
options: [
|
|
526
|
-
{
|
|
527
|
-
value: "openrouter",
|
|
528
|
-
label: "OpenRouter — one key, all major models (recommended)"
|
|
529
|
-
},
|
|
530
|
-
{
|
|
531
|
-
value: "anthropic",
|
|
532
|
-
label: "Anthropic — direct Claude"
|
|
533
|
-
},
|
|
534
|
-
{
|
|
535
|
-
value: "openai",
|
|
536
|
-
label: "OpenAI — direct GPT-* models"
|
|
537
|
-
},
|
|
538
|
-
{
|
|
539
|
-
value: "mistral",
|
|
540
|
-
label: "Mistral — direct Mistral models"
|
|
541
|
-
}
|
|
542
|
-
],
|
|
543
|
-
initialValue: "openrouter"
|
|
544
|
-
});
|
|
545
|
-
assertNotCancelled(direct);
|
|
546
|
-
await collectDirectApiQuick(direct, env);
|
|
547
|
-
await finalizeScrapeTargets(env, targets, { skipEdit: true });
|
|
396
|
+
/**
|
|
397
|
+
* Inverse of parseScrapeTargets for a single entry: build the
|
|
398
|
+
* model:provider[:version][:online] string from a ModelConfig.
|
|
399
|
+
*/
|
|
400
|
+
function formatScrapeTarget(config) {
|
|
401
|
+
const parts = [config.model, config.provider];
|
|
402
|
+
if (config.version) parts.push(config.version);
|
|
403
|
+
if (config.webSearch) parts.push("online");
|
|
404
|
+
return parts.join(":");
|
|
548
405
|
}
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
await collectAnthropic(env, targets);
|
|
555
|
-
await collectOpenAI(env, targets);
|
|
556
|
-
await collectMistral(env, targets);
|
|
557
|
-
if (!hasDirectApiConfigured(env)) p.log.warn("Onboarding analysis and other low-latency LLM tasks require a direct API. Configure at least one before continuing.");
|
|
558
|
-
}
|
|
559
|
-
p.log.step(pc.bold("Step 2 of 2 — Scrapers (optional, but needed to track ChatGPT / Google AI Mode)"));
|
|
560
|
-
await collectCloro(env, targets);
|
|
561
|
-
await collectBrightData(env, targets);
|
|
562
|
-
await collectOxylabs(env, targets);
|
|
563
|
-
await collectOlostep(env, targets);
|
|
564
|
-
await collectDataForSEO(env, targets);
|
|
565
|
-
await finalizeScrapeTargets(env, targets);
|
|
406
|
+
//#endregion
|
|
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");
|
|
566
411
|
}
|
|
567
|
-
function
|
|
568
|
-
|
|
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 {}
|
|
569
417
|
}
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
validate: (v) => !v ? "Required" : void 0
|
|
592
|
-
});
|
|
593
|
-
assertNotCancelled(username);
|
|
594
|
-
env.OXYLABS_USERNAME = username;
|
|
595
|
-
const password = await p.password({
|
|
596
|
-
message: "Oxylabs password",
|
|
597
|
-
validate: (v) => !v ? "Required" : void 0
|
|
598
|
-
});
|
|
599
|
-
assertNotCancelled(password);
|
|
600
|
-
env.OXYLABS_PASSWORD = password;
|
|
601
|
-
} else if (scraper === "olostep") {
|
|
602
|
-
p.log.info(`Sign up: ${link(pc.cyan(OLOSTEP_AFFILIATE), OLOSTEP_AFFILIATE)}`);
|
|
603
|
-
const key = await p.password({
|
|
604
|
-
message: "Olostep API key",
|
|
605
|
-
validate: (v) => !v ? "Required" : void 0
|
|
606
|
-
});
|
|
607
|
-
assertNotCancelled(key);
|
|
608
|
-
env.OLOSTEP_API_KEY = key;
|
|
609
|
-
} else {
|
|
610
|
-
p.log.info(`Sign up: ${link(pc.cyan(DATAFORSEO_AFFILIATE), DATAFORSEO_AFFILIATE)}`);
|
|
611
|
-
const login = await p.text({
|
|
612
|
-
message: "DataForSEO login",
|
|
613
|
-
validate: (v) => !v ? "Required" : void 0
|
|
614
|
-
});
|
|
615
|
-
assertNotCancelled(login);
|
|
616
|
-
env.DATAFORSEO_LOGIN = login;
|
|
617
|
-
const password = await p.password({
|
|
618
|
-
message: "DataForSEO password",
|
|
619
|
-
validate: (v) => !v ? "Required" : void 0
|
|
620
|
-
});
|
|
621
|
-
assertNotCancelled(password);
|
|
622
|
-
env.DATAFORSEO_PASSWORD = password;
|
|
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");
|
|
623
439
|
}
|
|
624
|
-
|
|
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
|
-
assertNotCancelled(key);
|
|
653
|
-
env.MISTRAL_API_KEY = key;
|
|
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}:`);
|
|
654
468
|
}
|
|
469
|
+
return `${lines.join("\n")}\n`;
|
|
655
470
|
}
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
});
|
|
676
|
-
}
|
|
677
|
-
async function collectOlostep(env, targets) {
|
|
678
|
-
const enable = await p.confirm({
|
|
679
|
-
message: `Configure ${pc.bold("Olostep")}? (premium, built for high volume — ~$2.25/mo per prompt)`,
|
|
680
|
-
initialValue: false
|
|
681
|
-
});
|
|
682
|
-
assertNotCancelled(enable);
|
|
683
|
-
if (!enable) return;
|
|
684
|
-
p.log.info(`Grab an API key: ${link(pc.cyan(OLOSTEP_AFFILIATE), OLOSTEP_AFFILIATE)}`);
|
|
685
|
-
const key = await p.password({
|
|
686
|
-
message: "Olostep API key",
|
|
687
|
-
validate: (v) => !v ? "Required" : void 0
|
|
688
|
-
});
|
|
689
|
-
assertNotCancelled(key);
|
|
690
|
-
env.OLOSTEP_API_KEY = key;
|
|
691
|
-
await pickScraperTargets({
|
|
692
|
-
providerLabel: "Olostep",
|
|
693
|
-
providerId: "olostep",
|
|
694
|
-
allModels: OLOSTEP_MODELS,
|
|
695
|
-
targets
|
|
696
|
-
});
|
|
697
|
-
}
|
|
698
|
-
async function collectOxylabs(env, targets) {
|
|
699
|
-
const enable = await p.confirm({
|
|
700
|
-
message: `Configure ${pc.bold("Oxylabs")}? (cheapest per run, no Gemini/Copilot — $49/mo min)`,
|
|
701
|
-
initialValue: false
|
|
702
|
-
});
|
|
703
|
-
assertNotCancelled(enable);
|
|
704
|
-
if (!enable) return;
|
|
705
|
-
p.log.info(`Sign up and create Web Scraper API credentials: ${link(pc.cyan(OXYLABS_AFFILIATE), OXYLABS_AFFILIATE)}`);
|
|
706
|
-
const username = await p.text({
|
|
707
|
-
message: "Oxylabs username",
|
|
708
|
-
validate: (v) => !v ? "Required" : void 0
|
|
709
|
-
});
|
|
710
|
-
assertNotCancelled(username);
|
|
711
|
-
env.OXYLABS_USERNAME = username;
|
|
712
|
-
const password = await p.password({
|
|
713
|
-
message: "Oxylabs password",
|
|
714
|
-
validate: (v) => !v ? "Required" : void 0
|
|
715
|
-
});
|
|
716
|
-
assertNotCancelled(password);
|
|
717
|
-
env.OXYLABS_PASSWORD = password;
|
|
718
|
-
await pickScraperTargets({
|
|
719
|
-
providerLabel: "Oxylabs",
|
|
720
|
-
providerId: "oxylabs",
|
|
721
|
-
allModels: OXYLABS_MODELS,
|
|
722
|
-
targets
|
|
723
|
-
});
|
|
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");
|
|
724
490
|
}
|
|
725
|
-
|
|
726
|
-
const
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
if (!enable) return;
|
|
732
|
-
p.log.info(`Sign up and create an API key: ${link(pc.cyan(CLORO_AFFILIATE), CLORO_AFFILIATE)}`);
|
|
733
|
-
const key = await p.password({
|
|
734
|
-
message: "Cloro API key",
|
|
735
|
-
validate: (v) => !v ? "Required" : void 0
|
|
736
|
-
});
|
|
737
|
-
assertNotCancelled(key);
|
|
738
|
-
env.CLORO_API_KEY = key;
|
|
739
|
-
await pickScraperTargets({
|
|
740
|
-
providerLabel: "Cloro",
|
|
741
|
-
providerId: "cloro",
|
|
742
|
-
allModels: CLORO_MODELS,
|
|
743
|
-
targets
|
|
744
|
-
});
|
|
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");
|
|
745
497
|
}
|
|
746
|
-
|
|
747
|
-
const
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
provider: args.providerId,
|
|
760
|
-
webSearch: true
|
|
761
|
-
}));
|
|
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
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return lines.join("\n");
|
|
762
511
|
}
|
|
763
|
-
|
|
764
|
-
const
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
if (
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
message: "Claude model",
|
|
778
|
-
placeholder: DEFAULT_ANTHROPIC_MODEL,
|
|
779
|
-
defaultValue: DEFAULT_ANTHROPIC_MODEL
|
|
780
|
-
});
|
|
781
|
-
assertNotCancelled(model);
|
|
782
|
-
const slug = model || DEFAULT_ANTHROPIC_MODEL;
|
|
783
|
-
const webSearch = await p.confirm({
|
|
784
|
-
message: "Enable web search? (recommended, but more expensive)",
|
|
785
|
-
initialValue: true
|
|
786
|
-
});
|
|
787
|
-
assertNotCancelled(webSearch);
|
|
788
|
-
targets.push(formatScrapeTarget({
|
|
789
|
-
model: "claude",
|
|
790
|
-
provider: "anthropic-api",
|
|
791
|
-
version: slug,
|
|
792
|
-
webSearch
|
|
793
|
-
}));
|
|
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
|
+
}
|
|
524
|
+
}
|
|
525
|
+
return lines.join("\n");
|
|
794
526
|
}
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
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
|
+
const OXYLABS_AFFILIATE = "https://oxylabs.go2cloud.org/aff_c?offer_id=7&aff_id=2263&url_id=32";
|
|
532
|
+
const OLOSTEP_AFFILIATE = "https://olostep.com/?ref=elmo";
|
|
533
|
+
const DATAFORSEO_AFFILIATE = "https://dataforseo.com/?aff=184966";
|
|
534
|
+
const BRIGHTDATA_MODELS = [
|
|
535
|
+
"chatgpt",
|
|
536
|
+
"google-ai-mode",
|
|
537
|
+
"google-ai-overview",
|
|
538
|
+
"perplexity",
|
|
539
|
+
"copilot",
|
|
540
|
+
"gemini"
|
|
541
|
+
];
|
|
542
|
+
const OLOSTEP_MODELS = [
|
|
543
|
+
"chatgpt",
|
|
544
|
+
"google-ai-mode",
|
|
545
|
+
"google-ai-overview",
|
|
546
|
+
"perplexity",
|
|
547
|
+
"copilot",
|
|
548
|
+
"gemini"
|
|
549
|
+
];
|
|
550
|
+
const OXYLABS_MODELS = [
|
|
551
|
+
"chatgpt",
|
|
552
|
+
"google-ai-mode",
|
|
553
|
+
"google-ai-overview",
|
|
554
|
+
"perplexity"
|
|
555
|
+
];
|
|
556
|
+
const CLORO_MODELS = [
|
|
557
|
+
"chatgpt",
|
|
558
|
+
"google-ai-mode",
|
|
559
|
+
"google-ai-overview",
|
|
560
|
+
"perplexity",
|
|
561
|
+
"copilot",
|
|
562
|
+
"gemini"
|
|
563
|
+
];
|
|
564
|
+
const DEFAULT_SCRAPER_MODELS = ["chatgpt", "google-ai-mode"];
|
|
565
|
+
const DATAFORSEO_TARGETS = [
|
|
566
|
+
{
|
|
567
|
+
model: "google-ai-mode",
|
|
568
|
+
kind: "scraper"
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
model: "google-ai-overview",
|
|
572
|
+
kind: "scraper"
|
|
573
|
+
},
|
|
574
|
+
{
|
|
821
575
|
model: "chatgpt",
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
576
|
+
kind: "scraper"
|
|
577
|
+
},
|
|
578
|
+
{
|
|
579
|
+
model: "gemini",
|
|
580
|
+
kind: "scraper"
|
|
581
|
+
},
|
|
582
|
+
{
|
|
583
|
+
model: "perplexity",
|
|
584
|
+
kind: "api"
|
|
585
|
+
}
|
|
586
|
+
];
|
|
587
|
+
const DEFAULT_OPENAI_MODEL = "gpt-5-mini";
|
|
588
|
+
const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-5";
|
|
589
|
+
const DEFAULT_OPENROUTER_MODEL = "anthropic/claude-sonnet-5";
|
|
590
|
+
const DEFAULT_MISTRAL_MODEL = "mistral-medium-latest";
|
|
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
|
+
};
|
|
826
610
|
}
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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",
|
|
712
|
+
message: "Cloro API key",
|
|
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",
|
|
730
|
+
message: "BrightData API token",
|
|
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",
|
|
748
|
+
message: "Oxylabs username",
|
|
749
|
+
secret: false,
|
|
750
|
+
validate: required
|
|
751
|
+
}, {
|
|
752
|
+
envKey: "OXYLABS_PASSWORD",
|
|
753
|
+
message: "Oxylabs password",
|
|
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",
|
|
771
|
+
message: "Olostep API key",
|
|
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",
|
|
789
|
+
message: "DataForSEO login",
|
|
790
|
+
secret: false,
|
|
791
|
+
validate: required
|
|
792
|
+
}, {
|
|
793
|
+
envKey: "DATAFORSEO_PASSWORD",
|
|
794
|
+
message: "DataForSEO password",
|
|
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
|
+
}
|
|
815
|
+
}
|
|
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;
|
|
831
|
+
}
|
|
858
832
|
}
|
|
859
|
-
|
|
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) {
|
|
860
838
|
const enable = await p.confirm({
|
|
861
|
-
message: `Configure ${pc.bold(
|
|
862
|
-
initialValue:
|
|
839
|
+
message: `Configure ${pc.bold(spec.label)}? (${spec.costHint})`,
|
|
840
|
+
initialValue: spec.confirmDefault
|
|
863
841
|
});
|
|
864
842
|
assertNotCancelled(enable);
|
|
865
843
|
if (!enable) return;
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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,
|
|
888
868
|
webSearch
|
|
889
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
|
+
}
|
|
890
880
|
}
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
message: `Configure ${pc.bold("DataForSEO")}? (pay-as-you-go, scrapers + direct APIs — ~$1.20/mo per prompt)`,
|
|
894
|
-
initialValue: false
|
|
895
|
-
});
|
|
896
|
-
assertNotCancelled(enable);
|
|
897
|
-
if (!enable) return;
|
|
898
|
-
p.log.info(`Sign up: ${link(pc.cyan(DATAFORSEO_AFFILIATE), DATAFORSEO_AFFILIATE)}`);
|
|
899
|
-
const login = await p.text({
|
|
900
|
-
message: "DataForSEO login",
|
|
901
|
-
validate: (v) => !v ? "Required" : void 0
|
|
902
|
-
});
|
|
903
|
-
assertNotCancelled(login);
|
|
904
|
-
env.DATAFORSEO_LOGIN = login;
|
|
905
|
-
const pwd = await p.password({
|
|
906
|
-
message: "DataForSEO password",
|
|
907
|
-
validate: (v) => !v ? "Required" : void 0
|
|
908
|
-
});
|
|
909
|
-
assertNotCancelled(pwd);
|
|
910
|
-
env.DATAFORSEO_PASSWORD = pwd;
|
|
911
|
-
const selected = await p.multiselect({
|
|
912
|
-
message: "LLM Providers to track via DataForSEO",
|
|
913
|
-
options: DATAFORSEO_TARGETS.map((t) => ({
|
|
914
|
-
value: formatScrapeTarget({
|
|
915
|
-
model: t.model,
|
|
916
|
-
provider: "dataforseo",
|
|
917
|
-
webSearch: true
|
|
918
|
-
}),
|
|
919
|
-
label: `${t.model} (${t.kind})`
|
|
920
|
-
})),
|
|
921
|
-
required: false,
|
|
922
|
-
initialValues: [formatScrapeTarget({
|
|
923
|
-
model: "google-ai-mode",
|
|
924
|
-
provider: "dataforseo",
|
|
925
|
-
webSearch: true
|
|
926
|
-
})]
|
|
927
|
-
});
|
|
928
|
-
assertNotCancelled(selected);
|
|
929
|
-
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);
|
|
930
883
|
}
|
|
931
884
|
async function finalizeScrapeTargets(env, targets, options = {}) {
|
|
932
885
|
const deduped = dedupeTargets(targets);
|
|
933
886
|
if (!deduped) {
|
|
934
887
|
p.log.warn("No SCRAPE_TARGETS configured. Elmo will not run scheduled checks until you set them.");
|
|
935
|
-
p.log.info(`Reference: ${link(pc.cyan(
|
|
888
|
+
p.log.info(`Reference: ${link(pc.cyan(PROVIDERS_DOCS_URL), PROVIDERS_DOCS_URL)}`);
|
|
936
889
|
const addManual = await p.confirm({
|
|
937
890
|
message: "Enter SCRAPE_TARGETS manually now?",
|
|
938
891
|
initialValue: false
|
|
@@ -959,491 +912,469 @@ async function finalizeScrapeTargets(env, targets, options = {}) {
|
|
|
959
912
|
});
|
|
960
913
|
assertNotCancelled(customize);
|
|
961
914
|
if (customize) {
|
|
962
|
-
p.log.info(`Reference: ${link(pc.cyan(
|
|
915
|
+
p.log.info(`Reference: ${link(pc.cyan(PROVIDERS_DOCS_URL), PROVIDERS_DOCS_URL)}`);
|
|
963
916
|
const manual = await p.text({
|
|
964
917
|
message: "SCRAPE_TARGETS",
|
|
965
918
|
initialValue: deduped,
|
|
966
919
|
validate: validateScrapeTargetsInput
|
|
967
920
|
});
|
|
968
|
-
assertNotCancelled(manual);
|
|
969
|
-
env.SCRAPE_TARGETS = manual;
|
|
970
|
-
p.log.step(`SCRAPE_TARGETS:\n ${pc.cyan(manual)}`);
|
|
971
|
-
} else env.SCRAPE_TARGETS = deduped;
|
|
972
|
-
}
|
|
973
|
-
function validateScrapeTargetsInput(value) {
|
|
974
|
-
if (!value) return "Required";
|
|
975
|
-
try {
|
|
976
|
-
parseScrapeTargets(value);
|
|
977
|
-
} catch (error) {
|
|
978
|
-
return error instanceof Error ? error.message.split("\n")[0] : String(error);
|
|
979
|
-
}
|
|
980
|
-
}
|
|
981
|
-
function dedupeTargets(targets) {
|
|
982
|
-
const seen = /* @__PURE__ */ new Set();
|
|
983
|
-
const out = [];
|
|
984
|
-
for (const t of targets) {
|
|
985
|
-
if (seen.has(t)) continue;
|
|
986
|
-
seen.add(t);
|
|
987
|
-
out.push(t);
|
|
988
|
-
}
|
|
989
|
-
return out.join(",");
|
|
990
|
-
}
|
|
991
|
-
async function doStart(configDir) {
|
|
992
|
-
assertDockerRunning();
|
|
993
|
-
log.step("Starting Docker Compose stack...");
|
|
994
|
-
await runDockerCompose(configDir, ["up", "-d"]);
|
|
995
|
-
const s = p.spinner();
|
|
996
|
-
s.start("Waiting for services to become healthy...");
|
|
997
|
-
if (await waitForHealthy(configDir, 18e4)) s.stop("All services healthy!");
|
|
998
|
-
else {
|
|
999
|
-
s.stop("Health check timed out.");
|
|
1000
|
-
p.log.warn("Some services did not report healthy status.");
|
|
1001
|
-
}
|
|
1002
|
-
log.info("Examples:");
|
|
1003
|
-
console.log(` ${pc.bold("elmo compose logs -f")}`);
|
|
1004
|
-
console.log(` ${pc.bold("elmo compose logs -f web")}`);
|
|
1005
|
-
console.log(` ${pc.bold("elmo compose ps")}`);
|
|
1006
|
-
console.log(` ${pc.bold("elmo compose down")}`);
|
|
1007
|
-
}
|
|
1008
|
-
async function runCompose(args, options) {
|
|
1009
|
-
const configDir = await resolveConfigDir(options.dir);
|
|
1010
|
-
assertDockerRunning();
|
|
1011
|
-
await runDockerCompose(configDir, args);
|
|
1012
|
-
}
|
|
1013
|
-
async function runEdit(target, options) {
|
|
1014
|
-
const configDir = await resolveConfigDir(options.dir);
|
|
1015
|
-
let filePath;
|
|
1016
|
-
if (target === "env") filePath = path.join(configDir, ".env");
|
|
1017
|
-
else if (target === "compose") filePath = path.join(configDir, "elmo.yaml");
|
|
1018
|
-
else throw new Error(`Unknown edit target: ${target}. Use \`env\` or \`compose\`.`);
|
|
1019
|
-
if (!await fileExists(filePath)) throw new Error(`File not found: ${filePath}`);
|
|
1020
|
-
const parts = (process.env.VISUAL || process.env.EDITOR || "nano").split(/\s+/).filter(Boolean);
|
|
1021
|
-
const cmd = parts[0] ?? "nano";
|
|
1022
|
-
const args = [...parts.slice(1), filePath];
|
|
1023
|
-
await new Promise((resolve, reject) => {
|
|
1024
|
-
const child = spawn(cmd, args, { stdio: "inherit" });
|
|
1025
|
-
child.on("close", (code) => {
|
|
1026
|
-
if (code === 0) resolve();
|
|
1027
|
-
else reject(/* @__PURE__ */ new Error(`${cmd} exited with code ${code}`));
|
|
1028
|
-
});
|
|
1029
|
-
child.on("error", (err) => reject(err));
|
|
1030
|
-
});
|
|
1031
|
-
log.info("Restart the stack with `elmo compose up -d` to apply changes.");
|
|
1032
|
-
}
|
|
1033
|
-
async function runUpgrade(options, cliVersion) {
|
|
1034
|
-
printBanner();
|
|
1035
|
-
p.intro(pc.bold("Upgrading Elmo"));
|
|
1036
|
-
const latestCli = await fetchLatestCliVersion();
|
|
1037
|
-
if (latestCli && semver.valid(cliVersion) && semver.lt(cliVersion, latestCli)) {
|
|
1038
|
-
log.warn(`Your CLI (${cliVersion}) is behind the latest published version (${latestCli}).`);
|
|
1039
|
-
log.info("Recommended: upgrade the CLI first, then rerun this command:");
|
|
1040
|
-
console.log(` ${pc.bold("npm install -g @elmohq/cli@latest")}`);
|
|
1041
|
-
const proceed = options.yes ? true : await p.confirm({
|
|
1042
|
-
message: `Continue upgrading the stack with CLI ${cliVersion} anyway?`,
|
|
1043
|
-
initialValue: false
|
|
1044
|
-
});
|
|
1045
|
-
assertNotCancelled(proceed);
|
|
1046
|
-
if (!proceed) {
|
|
1047
|
-
p.cancel("Upgrade cancelled. Upgrade the CLI and rerun `elmo upgrade`.");
|
|
1048
|
-
process.exit(0);
|
|
1049
|
-
}
|
|
1050
|
-
}
|
|
1051
|
-
const configDir = await resolveConfigDir(options.dir);
|
|
1052
|
-
const composePath = path.join(configDir, "elmo.yaml");
|
|
1053
|
-
const detectedVersion = await readRenderedVersion(composePath);
|
|
1054
|
-
const fromVersion = detectedVersion ?? cliVersion;
|
|
1055
|
-
if (!semver.valid(fromVersion)) throw new Error(`Could not determine the installed version from ${composePath}.`);
|
|
1056
|
-
if (semver.gt(fromVersion, cliVersion)) {
|
|
1057
|
-
log.warn(`Your deployment (${fromVersion}) is newer than this CLI (${cliVersion}).`);
|
|
1058
|
-
log.info("Upgrade the CLI to match, then rerun:");
|
|
1059
|
-
console.log(` ${pc.bold("npm install -g @elmohq/cli@latest")}`);
|
|
1060
|
-
process.exit(1);
|
|
1061
|
-
}
|
|
1062
|
-
if (detectedVersion !== null && semver.eq(detectedVersion, cliVersion)) {
|
|
1063
|
-
log.success(`Already at ${cliVersion}.`);
|
|
1064
|
-
const pull = options.yes ? true : await p.confirm({
|
|
1065
|
-
message: "Pull images for this version anyway?",
|
|
1066
|
-
initialValue: false
|
|
1067
|
-
});
|
|
1068
|
-
assertNotCancelled(pull);
|
|
1069
|
-
if (pull) {
|
|
1070
|
-
assertDockerRunning();
|
|
1071
|
-
const wasRunning = await stackHasRunningServices(configDir);
|
|
1072
|
-
log.step("Pulling images...");
|
|
1073
|
-
await runDockerCompose(configDir, ["pull"]);
|
|
1074
|
-
if (wasRunning) {
|
|
1075
|
-
log.step("Restarting services...");
|
|
1076
|
-
await runDockerCompose(configDir, ["up", "-d"]);
|
|
1077
|
-
}
|
|
1078
|
-
}
|
|
1079
|
-
p.outro(pc.green("Nothing to upgrade."));
|
|
1080
|
-
return;
|
|
1081
|
-
}
|
|
1082
|
-
const plan = detectedVersion === null ? [] : planMigrations(fromVersion, cliVersion, MIGRATIONS);
|
|
1083
|
-
if (detectedVersion === null) log.warn(`Couldn't detect the deployment's version — re-pinning images to ${pc.cyan(cliVersion)}.`);
|
|
1084
|
-
else log.info(`Upgrading from ${pc.cyan(fromVersion)} → ${pc.cyan(cliVersion)}`);
|
|
1085
|
-
if (plan.length === 0) log.step("No migrations to run (docker images will be re-pinned and pulled).");
|
|
1086
|
-
else {
|
|
1087
|
-
log.step(`Migrations to apply: ${plan.length}`);
|
|
1088
|
-
for (const m of plan) console.log(` • ${pc.bold(`${m.from} → ${m.to}`)} ${m.description}`);
|
|
1089
|
-
}
|
|
1090
|
-
const confirm = options.yes ? true : await p.confirm({
|
|
1091
|
-
message: "Proceed with upgrade?",
|
|
1092
|
-
initialValue: true
|
|
1093
|
-
});
|
|
1094
|
-
assertNotCancelled(confirm);
|
|
1095
|
-
if (!confirm) {
|
|
1096
|
-
p.cancel("Upgrade cancelled.");
|
|
1097
|
-
process.exit(0);
|
|
1098
|
-
}
|
|
1099
|
-
assertDockerRunning();
|
|
1100
|
-
const wasRunning = await stackHasRunningServices(configDir);
|
|
1101
|
-
if (wasRunning) {
|
|
1102
|
-
log.step("Stopping services...");
|
|
1103
|
-
await runDockerCompose(configDir, ["down"]);
|
|
1104
|
-
}
|
|
1105
|
-
const ctx = buildMigrationContext(configDir, cliVersion);
|
|
1106
|
-
try {
|
|
1107
|
-
await runMigrations(plan, ctx);
|
|
1108
|
-
} catch (error) {
|
|
1109
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
1110
|
-
log.error(`Migration failed: ${msg}`);
|
|
1111
|
-
log.info("Your config version was left unchanged. Fix the issue and rerun `elmo upgrade`.");
|
|
1112
|
-
if (wasRunning) log.info("The stack was stopped for the upgrade. Restart with `elmo compose up -d` after fixing.");
|
|
1113
|
-
process.exit(1);
|
|
1114
|
-
}
|
|
1115
|
-
const isDev = await composeUsesBuild(composePath);
|
|
1116
|
-
await repinComposeImages(composePath, cliVersion);
|
|
1117
|
-
await refreshRenderedVersion(path.join(configDir, ".env"), cliVersion);
|
|
1118
|
-
log.success(`Pinned config to ${cliVersion}.`);
|
|
1119
|
-
if (isDev) log.info("Dev mode detected — rebuild with `elmo compose build` to apply the new version.");
|
|
1120
|
-
else {
|
|
1121
|
-
log.step("Pulling images...");
|
|
1122
|
-
await runDockerCompose(configDir, ["pull"]);
|
|
1123
|
-
}
|
|
1124
|
-
if (wasRunning) {
|
|
1125
|
-
log.step("Starting services...");
|
|
1126
|
-
await runDockerCompose(configDir, ["up", "-d"]);
|
|
1127
|
-
const s = p.spinner();
|
|
1128
|
-
s.start("Waiting for services to become healthy...");
|
|
1129
|
-
if (await waitForHealthy(configDir, 18e4)) s.stop("All services healthy!");
|
|
1130
|
-
else {
|
|
1131
|
-
s.stop("Health check timed out.");
|
|
1132
|
-
log.warn("Some services did not report healthy status.");
|
|
1133
|
-
}
|
|
1134
|
-
} else log.info("Stack was stopped before upgrade — leaving it stopped. Start with `elmo compose up -d`.");
|
|
1135
|
-
await trackCliEvent(configDir, "cli_upgrade", {
|
|
1136
|
-
from_version: fromVersion,
|
|
1137
|
-
to_version: cliVersion,
|
|
1138
|
-
migrations_run: plan.length,
|
|
1139
|
-
was_running: wasRunning,
|
|
1140
|
-
dev_mode: isDev
|
|
1141
|
-
});
|
|
1142
|
-
p.outro(pc.green(`Upgraded to ${cliVersion}.`));
|
|
921
|
+
assertNotCancelled(manual);
|
|
922
|
+
env.SCRAPE_TARGETS = manual;
|
|
923
|
+
p.log.step(`SCRAPE_TARGETS:\n ${pc.cyan(manual)}`);
|
|
924
|
+
} else env.SCRAPE_TARGETS = deduped;
|
|
1143
925
|
}
|
|
1144
|
-
|
|
926
|
+
function validateScrapeTargetsInput(value) {
|
|
927
|
+
if (!value) return "Required";
|
|
1145
928
|
try {
|
|
1146
|
-
|
|
1147
|
-
} catch {
|
|
1148
|
-
return
|
|
929
|
+
parseScrapeTargets(value);
|
|
930
|
+
} catch (error) {
|
|
931
|
+
return error instanceof Error ? error.message.split("\n")[0] : String(error);
|
|
1149
932
|
}
|
|
1150
933
|
}
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
934
|
+
function dedupeTargets(targets) {
|
|
935
|
+
const seen = /* @__PURE__ */ new Set();
|
|
936
|
+
const out = [];
|
|
937
|
+
for (const t of targets) {
|
|
938
|
+
if (seen.has(t)) continue;
|
|
939
|
+
seen.add(t);
|
|
940
|
+
out.push(t);
|
|
1156
941
|
}
|
|
942
|
+
return out.join(",");
|
|
1157
943
|
}
|
|
1158
|
-
|
|
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);
|
|
950
|
+
}
|
|
951
|
+
async function readEnvKey(configDir, key) {
|
|
1159
952
|
try {
|
|
1160
|
-
const contents = await fs.readFile(
|
|
1161
|
-
return
|
|
953
|
+
const contents = await fs.readFile(path.join(configDir, ".env"), "utf8");
|
|
954
|
+
return parse(contents)[key];
|
|
1162
955
|
} catch {
|
|
1163
|
-
return
|
|
956
|
+
return;
|
|
1164
957
|
}
|
|
1165
958
|
}
|
|
1166
|
-
async function
|
|
1167
|
-
|
|
1168
|
-
|
|
959
|
+
async function isTelemetryDisabled(configDir) {
|
|
960
|
+
if (envDisabled()) return true;
|
|
961
|
+
return Boolean(await readEnvKey(configDir, "DISABLE_TELEMETRY"));
|
|
1169
962
|
}
|
|
1170
|
-
async function
|
|
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;
|
|
1171
967
|
try {
|
|
1172
|
-
const
|
|
1173
|
-
|
|
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
|
+
}
|
|
976
|
+
});
|
|
977
|
+
await client.shutdown();
|
|
1174
978
|
} catch {}
|
|
1175
979
|
}
|
|
1176
|
-
function
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
return {};
|
|
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
|
+
}
|
|
1190
993
|
}
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
}
|
|
1195
|
-
};
|
|
994
|
+
});
|
|
995
|
+
await client.shutdown();
|
|
996
|
+
} catch {}
|
|
1196
997
|
}
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
repoRoot: options.repoRoot,
|
|
1224
|
-
dockerfilePath,
|
|
1225
|
-
port: options.port,
|
|
1226
|
-
version: options.version
|
|
1227
|
-
}));
|
|
1228
|
-
services.push(buildWorkerService({
|
|
1229
|
-
dev: options.dev,
|
|
1230
|
-
dependsOn: dependsOnWorker,
|
|
1231
|
-
dependencyConditions,
|
|
1232
|
-
repoRoot: options.repoRoot,
|
|
1233
|
-
dockerfilePath,
|
|
1234
|
-
version: options.version
|
|
1235
|
-
}));
|
|
1236
|
-
const lines = [
|
|
1237
|
-
renderedByHeader(options.version),
|
|
1238
|
-
"",
|
|
1239
|
-
"name: elmo",
|
|
1240
|
-
"",
|
|
1241
|
-
"services:"
|
|
1242
|
-
];
|
|
1243
|
-
lines.push(...services.map((service) => indentBlock(service, 2)));
|
|
1244
|
-
if (volumes.size > 0) {
|
|
1245
|
-
lines.push("", "volumes:");
|
|
1246
|
-
for (const volume of volumes) lines.push(` ${volume}:`);
|
|
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.",
|
|
1018
|
+
initialValue: false
|
|
1019
|
+
});
|
|
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.");
|
|
1023
|
+
process.exit(0);
|
|
1247
1024
|
}
|
|
1248
|
-
return
|
|
1249
|
-
}
|
|
1250
|
-
function buildPostgresService() {
|
|
1251
|
-
return [
|
|
1252
|
-
"postgres:",
|
|
1253
|
-
" image: postgres:18-alpine",
|
|
1254
|
-
" environment:",
|
|
1255
|
-
" POSTGRES_USER: postgres",
|
|
1256
|
-
" POSTGRES_PASSWORD: postgres",
|
|
1257
|
-
" POSTGRES_DB: elmo",
|
|
1258
|
-
" volumes:",
|
|
1259
|
-
" - postgres_data:/var/lib/postgresql",
|
|
1260
|
-
" ports:",
|
|
1261
|
-
" - \"5432:5432\"",
|
|
1262
|
-
" healthcheck:",
|
|
1263
|
-
" test: [\"CMD-SHELL\", \"pg_isready -U postgres\"]",
|
|
1264
|
-
" interval: 5s",
|
|
1265
|
-
" timeout: 5s",
|
|
1266
|
-
" retries: 5",
|
|
1267
|
-
" start_period: 30s"
|
|
1268
|
-
].join("\n");
|
|
1269
|
-
}
|
|
1270
|
-
function buildDbMigrateService(options) {
|
|
1271
|
-
const lines = ["db-migrate:"];
|
|
1272
|
-
if (options.dev) lines.push(" build:", ` context: ${options.repoRoot}`, ` dockerfile: ${options.dockerfilePath}`, " target: migrate");
|
|
1273
|
-
else lines.push(` image: elmohq/elmo-db-migrate:${options.version}`);
|
|
1274
|
-
lines.push(" environment:", " - DATABASE_URL=postgres://postgres:postgres@postgres:5432/elmo", " depends_on:", " postgres:", " condition: service_healthy");
|
|
1275
|
-
return lines.join("\n");
|
|
1025
|
+
return isElmoEnv ? parse(contents).DEPLOYMENT_ID : void 0;
|
|
1276
1026
|
}
|
|
1277
|
-
function
|
|
1278
|
-
const
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
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
|
|
1034
|
+
});
|
|
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.`);
|
|
1039
|
+
process.exit(1);
|
|
1288
1040
|
}
|
|
1289
|
-
return
|
|
1041
|
+
return resolved;
|
|
1290
1042
|
}
|
|
1291
|
-
function
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
if (options.
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
}
|
|
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
|
+
};
|
|
1302
1054
|
}
|
|
1303
|
-
|
|
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}`);
|
|
1058
|
+
process.exit(1);
|
|
1059
|
+
}
|
|
1060
|
+
return {
|
|
1061
|
+
dockerDir,
|
|
1062
|
+
repoRoot: path.resolve(dockerDir, "..")
|
|
1063
|
+
};
|
|
1064
|
+
}
|
|
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:",
|
|
1073
|
+
"",
|
|
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`,
|
|
1080
|
+
"",
|
|
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;
|
|
1304
1103
|
}
|
|
1305
|
-
function
|
|
1306
|
-
const
|
|
1307
|
-
|
|
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 });
|
|
1308
1164
|
}
|
|
1309
|
-
async function
|
|
1310
|
-
const
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
]);
|
|
1315
|
-
if (!output.trim()) return [];
|
|
1316
|
-
try {
|
|
1317
|
-
const trimmed = output.trim();
|
|
1318
|
-
const parsed = JSON.parse(trimmed);
|
|
1319
|
-
if (Array.isArray(parsed)) return parsed;
|
|
1320
|
-
if (typeof parsed === "object" && parsed !== null) return [parsed];
|
|
1321
|
-
return [];
|
|
1322
|
-
} catch {
|
|
1323
|
-
try {
|
|
1324
|
-
return output.trim().split("\n").filter((line) => line.trim()).map((line) => JSON.parse(line));
|
|
1325
|
-
} catch {
|
|
1326
|
-
log.warn("Unable to parse docker compose status.");
|
|
1327
|
-
return [];
|
|
1328
|
-
}
|
|
1329
|
-
}
|
|
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);
|
|
1330
1170
|
}
|
|
1331
|
-
function
|
|
1332
|
-
|
|
1333
|
-
if (service.State?.startsWith("running")) return true;
|
|
1334
|
-
return false;
|
|
1171
|
+
async function collectDirectApiQuick(kind, env) {
|
|
1172
|
+
await collectCredentials(providerSpec(kind), env);
|
|
1335
1173
|
}
|
|
1336
|
-
async function
|
|
1337
|
-
const
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
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.");
|
|
1342
1180
|
}
|
|
1343
|
-
|
|
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);
|
|
1344
1184
|
}
|
|
1345
|
-
function
|
|
1346
|
-
|
|
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.");
|
|
1195
|
+
}
|
|
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")}`);
|
|
1347
1201
|
}
|
|
1348
|
-
function
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
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"
|
|
1360
1231
|
});
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
stderr += data.toString();
|
|
1378
|
-
});
|
|
1379
|
-
child.on("close", (code) => {
|
|
1380
|
-
if (code === 0) resolve(stdout);
|
|
1381
|
-
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
|
|
1382
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
|
|
1277
|
+
});
|
|
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"
|
|
1283
|
+
});
|
|
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
|
|
1383
1302
|
});
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
const defaultDir = inCwd ? "." : inDockerDir ? "docker" : ".";
|
|
1392
|
-
const dir = await p.text({
|
|
1393
|
-
message: "Path to docker directory (contains Dockerfile)",
|
|
1394
|
-
defaultValue: defaultDir
|
|
1303
|
+
await ensureDir(configDir);
|
|
1304
|
+
await writeConfigFiles(configDir, {
|
|
1305
|
+
env,
|
|
1306
|
+
composeYaml,
|
|
1307
|
+
postgresMode,
|
|
1308
|
+
dev: Boolean(options.dev),
|
|
1309
|
+
version
|
|
1395
1310
|
});
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
if (
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
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!"));
|
|
1403
1335
|
}
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
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'`);
|
|
1409
1347
|
}
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
await ensureDir(configDir);
|
|
1416
|
-
await fs.writeFile(envPath, buildEnvFile(initConfig.env, initConfig.version), "utf8");
|
|
1417
|
-
await fs.writeFile(composePath, initConfig.composeYaml, "utf8");
|
|
1418
|
-
}
|
|
1419
|
-
function buildEnvFile(env, version) {
|
|
1420
|
-
const lines = [
|
|
1421
|
-
renderedByHeader(version),
|
|
1422
|
-
"# WARNING: contains secrets. Do not commit.",
|
|
1423
|
-
""
|
|
1424
|
-
];
|
|
1425
|
-
for (const [key, rawValue] of Object.entries(env)) {
|
|
1426
|
-
if (rawValue === void 0) continue;
|
|
1427
|
-
lines.push(`${key}=${formatEnvValue(rawValue)}`);
|
|
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);
|
|
1428
1353
|
}
|
|
1429
|
-
return
|
|
1430
|
-
}
|
|
1431
|
-
function formatEnvValue(value) {
|
|
1432
|
-
if (value === "") return "\"\"";
|
|
1433
|
-
if (/[\s#"']/u.test(value)) return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
1434
|
-
return value;
|
|
1354
|
+
return applicable;
|
|
1435
1355
|
}
|
|
1436
|
-
async function
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
} catch {
|
|
1441
|
-
return false;
|
|
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);
|
|
1442
1360
|
}
|
|
1443
1361
|
}
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
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.");
|
|
1374
|
+
}
|
|
1375
|
+
}];
|
|
1376
|
+
//#endregion
|
|
1377
|
+
//#region src/version.ts
|
|
1447
1378
|
async function getPackageVersion() {
|
|
1448
1379
|
const selfDir = path.dirname(fileURLToPath(import.meta.url));
|
|
1449
1380
|
const packagePath = path.resolve(selfDir, "..", "package.json");
|
|
@@ -1464,6 +1395,190 @@ async function maybeNotifyNewVersion(currentVersion) {
|
|
|
1464
1395
|
if (!latest) return;
|
|
1465
1396
|
if (semver.valid(currentVersion) && semver.lt(currentVersion, latest)) log.warn(`New CLI version available (${latest}). Run: npm install -g @elmohq/cli@latest`);
|
|
1466
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
|
+
}
|
|
1467
1582
|
main().catch((error) => {
|
|
1468
1583
|
const msg = error instanceof Error ? error.message : String(error);
|
|
1469
1584
|
console.error(`\n${pc.red("Error:")} ${msg}`);
|