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