@elmohq/cli 0.2.18 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +327 -146
  2. package/package.json +6 -6
package/dist/index.js CHANGED
@@ -11,8 +11,127 @@ import { parse } from "dotenv";
11
11
  import pc from "picocolors";
12
12
  import semver from "semver";
13
13
  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";
21
+ //#endregion
22
+ //#region ../../packages/config/src/referrals.ts
23
+ /**
24
+ * Links from the product and the CLI back to our own sites.
25
+ *
26
+ * Each one carries a `ref` naming the surface it was clicked from, so the
27
+ * marketing site can tell a self-hosted operator who arrived from the sign-in
28
+ * page apart from one who arrived from `elmo init` — the two want different
29
+ * things and convert at very different rates.
30
+ */
31
+ const MARKETING_SITE_URL = "https://www.elmohq.com";
32
+ function tagged(url, ref) {
33
+ const link = new URL(url);
34
+ link.searchParams.set("ref", ref);
35
+ return link.toString();
36
+ }
37
+ /** A marketing-site page, tagged with where the click came from. */
38
+ function marketingUrl(path, ref) {
39
+ return tagged(new URL(path, MARKETING_SITE_URL).toString(), ref);
40
+ }
41
+ /** The cloud pricing table, tagged with where the click came from. */
42
+ function cloudPricingUrl(ref) {
43
+ return marketingUrl("/pricing", ref);
44
+ }
45
+ /** The platforms a plan's picks may come from — everything sold as a pick. */
46
+ const STANDARD_PLATFORM_MENU = Object.entries({
47
+ chatgpt: {
48
+ access: "scraped",
49
+ premium: true
50
+ },
51
+ "google-ai-mode": { access: "scraped" },
52
+ "google-ai-overview": { access: "scraped" },
53
+ copilot: { access: "scraped" },
54
+ perplexity: { access: "scraped" },
55
+ gemini: { access: "scraped" },
56
+ qwen: { access: "api" },
57
+ deepseek: { access: "api" },
58
+ grok: { premium: true },
59
+ mistral: { access: "api" },
60
+ claude: {
61
+ access: "api",
62
+ premium: true
63
+ }
64
+ }).filter(([, platform]) => platform.access !== void 0).map(([model]) => model);
65
+ const PLANS = {
66
+ starter: {
67
+ key: "starter",
68
+ name: "Starter",
69
+ monthlyPriceUsd: 29,
70
+ annualPriceUsd: 290,
71
+ maxBrands: 1,
72
+ maxPrompts: 50,
73
+ platformMenu: ["chatgpt"],
74
+ platformPicks: 1,
75
+ standardRunsPerDay: 1,
76
+ premiumIncluded: 0,
77
+ premiumAddonAvailable: false
78
+ },
79
+ basic: {
80
+ key: "basic",
81
+ name: "Basic",
82
+ monthlyPriceUsd: 99,
83
+ annualPriceUsd: 990,
84
+ maxBrands: 1,
85
+ maxPrompts: 50,
86
+ platformMenu: STANDARD_PLATFORM_MENU,
87
+ platformPicks: 4,
88
+ standardRunsPerDay: 4,
89
+ premiumIncluded: 0,
90
+ premiumAddonAvailable: false
91
+ },
92
+ pro: {
93
+ key: "pro",
94
+ name: "Pro",
95
+ monthlyPriceUsd: 299,
96
+ annualPriceUsd: 2990,
97
+ maxBrands: 2,
98
+ maxPrompts: 150,
99
+ platformMenu: STANDARD_PLATFORM_MENU,
100
+ platformPicks: 4,
101
+ standardRunsPerDay: 4,
102
+ premiumIncluded: 20,
103
+ premiumAddonAvailable: true
104
+ },
105
+ business: {
106
+ key: "business",
107
+ name: "Business",
108
+ monthlyPriceUsd: 649,
109
+ annualPriceUsd: 6490,
110
+ maxBrands: 5,
111
+ maxPrompts: 350,
112
+ platformMenu: STANDARD_PLATFORM_MENU,
113
+ platformPicks: 4,
114
+ standardRunsPerDay: 4,
115
+ premiumIncluded: 30,
116
+ premiumAddonAvailable: true
117
+ }
118
+ };
119
+ Math.max(...Object.values(PLANS).map((plan) => plan.maxBrands));
120
+ const PLAN_KEYS = Object.keys(PLANS);
121
+ /** Lowest self-serve monthly price among all plans. */
122
+ const CLOUD_ENTRY_PRICE_USD = Math.min(...PLAN_KEYS.map((key) => PLANS[key].monthlyPriceUsd));
123
+ //#endregion
14
124
  //#region ../../packages/config/src/scrape-targets.ts
15
125
  /**
126
+ * The single module that parses and formats the SCRAPE_TARGETS env var.
127
+ *
128
+ * Format: model:provider[:version][:online]
129
+ * - model: AI model to track (chatgpt, google-ai-mode, copilot, etc.)
130
+ * - provider: How to reach it (olostep, brightdata, direct, openrouter, dataforseo)
131
+ * - version: Specific version slug, required for direct/openrouter (may contain colons for OpenRouter variants)
132
+ * - :online: Append to enable web search. Omit = no web search.
133
+ */
134
+ /**
16
135
  * Parse the SCRAPE_TARGETS env var into structured ModelConfig objects.
17
136
  *
18
137
  * Parsing: split on ":". First = model, second = provider. If last segment is "online",
@@ -20,7 +139,9 @@ import { PostHog } from "posthog-node";
20
139
  * This naturally handles OpenRouter variant suffixes like ":free".
21
140
  */
22
141
  function parseScrapeTargets(envValue) {
23
- if (!envValue || !envValue.trim()) throw new Error("SCRAPE_TARGETS environment variable is required. Set it to configure which AI models to track. Example:\n SCRAPE_TARGETS=chatgpt:olostep:online,google-ai-mode:olostep:online,copilot:olostep:online\nSee https://docs.elmohq.com/docs/user-guide/providers for details.");
142
+ if (!envValue || !envValue.trim()) throw new Error(`SCRAPE_TARGETS environment variable is required. Set it to configure which AI models to track. Example:
143
+ SCRAPE_TARGETS=chatgpt:olostep:online,google-ai-mode:olostep:online,copilot:olostep:online
144
+ See ${PROVIDERS_DOCS_URL} for details.`);
24
145
  return envValue.split(",").map((raw) => {
25
146
  const trimmed = raw.trim();
26
147
  if (!trimmed) throw new Error("Invalid SCRAPE_TARGETS: empty entry (check for trailing commas)");
@@ -166,6 +287,7 @@ const DEFAULT_APP_ICON = "/icons/elmo-icon.svg";
166
287
  const DEFAULT_APP_PORT = 1515;
167
288
  const LOCAL_DATABASE_URL = "postgres://postgres:postgres@postgres:5432/elmo";
168
289
  const TELEMETRY_DOC_URL = "https://elmohq.com/docs/developer-guide/telemetry";
290
+ const CLOUD_PRICING_URL = cloudPricingUrl("cli");
169
291
  const ELMO_ASCII = [
170
292
  "",
171
293
  " ▄▄ ",
@@ -223,52 +345,72 @@ async function withVersionCheck(version, fn) {
223
345
  await fn();
224
346
  await notifyPromise.catch(() => void 0);
225
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
+ }
226
396
  async function runInit(options, version) {
227
397
  printBanner();
228
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?");
229
410
  const cwd = process.cwd();
230
411
  const configDir = options.dir ? path.resolve(cwd, options.dir) : CONFIG_HOME;
231
- const existingEnvPath = path.join(configDir, ".env");
232
- let preservedDeploymentId;
233
- if (await fileExists(existingEnvPath)) {
234
- const contents = await fs.readFile(existingEnvPath, "utf8");
235
- if (!(contents.startsWith("# Rendered by elmo") || contents.startsWith("# Generated by elmo"))) {
236
- p.log.warn(`A .env file already exists in ${configDir} and was NOT created by Elmo.`);
237
- const overwrite = await p.confirm({
238
- message: "Overwrite the existing .env file? This cannot be undone.",
239
- initialValue: false
240
- });
241
- assertNotCancelled(overwrite);
242
- if (!overwrite) {
243
- p.cancel("Setup cancelled. Choose a different directory with --dir.");
244
- process.exit(0);
245
- }
246
- } else {
247
- p.log.warn(`An existing Elmo config was found at ${configDir}.`);
248
- const overwrite = await p.confirm({
249
- message: "Overwrite it with new values? Existing secrets (DATABASE_URL, API keys, etc.) will be replaced.",
250
- initialValue: false
251
- });
252
- assertNotCancelled(overwrite);
253
- if (!overwrite) {
254
- p.cancel("Setup cancelled. Use `elmo edit env` to change individual values.");
255
- process.exit(0);
256
- }
257
- preservedDeploymentId = parse(contents).DEPLOYMENT_ID;
258
- }
259
- }
260
- let dockerDir;
261
- let repoRoot;
262
- if (options.dev) {
263
- if (options.dockerDir) {
264
- dockerDir = path.resolve(cwd, options.dockerDir);
265
- if (!await fileExists(path.join(dockerDir, "Dockerfile"))) {
266
- p.log.error(`Dockerfile not found in ${dockerDir}`);
267
- process.exit(1);
268
- }
269
- } else dockerDir = await resolveDockerDirInteractive(cwd);
270
- repoRoot = path.resolve(dockerDir, "..");
271
- } else repoRoot = cwd;
412
+ const preservedDeploymentId = await confirmEnvOverwrite(configDir);
413
+ const { dockerDir, repoRoot } = await resolveDockerLocation(options, cwd);
272
414
  const postgresMode = await p.select({
273
415
  message: "PostgreSQL connection",
274
416
  options: [{
@@ -337,11 +479,7 @@ async function runInit(options, version) {
337
479
  message: "Web app port",
338
480
  placeholder: String(DEFAULT_APP_PORT),
339
481
  defaultValue: String(DEFAULT_APP_PORT),
340
- validate: (v) => {
341
- if (!v) return void 0;
342
- const n = Number(v);
343
- if (!Number.isInteger(n) || n < 1 || n > 65535) return "Must be an integer between 1 and 65535";
344
- }
482
+ validate: validatePort
345
483
  });
346
484
  assertNotCancelled(portInput);
347
485
  const port = Number(portInput);
@@ -388,11 +526,11 @@ async function runInit(options, version) {
388
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")}`);
389
527
  p.outro(pc.green("Setup complete!"));
390
528
  }
529
+ const CLORO_AFFILIATE = "https://cloro.dev?fpr=elmo";
391
530
  const BRIGHTDATA_AFFILIATE = "https://get.brightdata.com/67h1b7h0shcn";
392
- const OLOSTEP_AFFILIATE = "https://olostep.com/?ref=elmo";
393
531
  const OXYLABS_AFFILIATE = "https://oxylabs.go2cloud.org/aff_c?offer_id=7&aff_id=2263&url_id=32";
394
- const CLORO_AFFILIATE = "https://cloro.dev?fpr=elmo";
395
- const PROVIDERS_DOC_URL = "https://docs.elmohq.com/docs/user-guide/providers";
532
+ const OLOSTEP_AFFILIATE = "https://olostep.com/?ref=elmo";
533
+ const DATAFORSEO_AFFILIATE = "https://dataforseo.com/?aff=184966";
396
534
  const BRIGHTDATA_MODELS = [
397
535
  "chatgpt",
398
536
  "google-ai-mode",
@@ -447,18 +585,19 @@ const DATAFORSEO_TARGETS = [
447
585
  }
448
586
  ];
449
587
  const DEFAULT_OPENAI_MODEL = "gpt-5-mini";
450
- const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-6";
451
- const DEFAULT_OPENROUTER_MODEL = "anthropic/claude-sonnet-4.6";
588
+ const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-5";
589
+ const DEFAULT_OPENROUTER_MODEL = "anthropic/claude-sonnet-5";
452
590
  const DEFAULT_MISTRAL_MODEL = "mistral-medium-latest";
453
591
  async function configureProvidersInteractive(env) {
454
592
  p.note([
455
593
  "Elmo needs two kinds of providers:",
456
594
  "",
457
595
  pc.bold("1. A scraper") + " — to track ChatGPT and Google AI Mode (no public APIs):",
458
- ` • ${pc.cyan("BrightData")} — cheap solid option, ~$0.45/mo per prompt`,
459
- ` • ${pc.cyan("Oxylabs")} — async job API, pay-as-you-go`,
460
- ` • ${pc.cyan("Cloro")} — every surface, credit plans from $30/mo, ~$0.65/mo per prompt`,
461
- ` • ${pc.cyan("Olostep")} — premium option, powers Peec/AirOps, ~$2.25/mo per prompt`,
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`,
462
601
  "",
463
602
  pc.bold("2. A direct LLM API") + " — for low-latency tasks (onboarding analysis, sentiment scoring,",
464
603
  " ad-hoc LLM calls). Required:",
@@ -488,24 +627,28 @@ async function configureProvidersRecommended(env) {
488
627
  const scraper = await p.select({
489
628
  message: "Scraper (tracks ChatGPT + Google AI Mode)",
490
629
  options: [
630
+ {
631
+ value: "cloro",
632
+ label: "Cloro — most reliable, every surface (~$0.65/mo per prompt, $30/mo min)"
633
+ },
491
634
  {
492
635
  value: "brightdata",
493
- label: "BrightData — ~$0.45/mo per prompt (cheaper)"
636
+ label: "BrightData — pay-as-you-go, cheaper but slower (~$0.45/mo per prompt)"
494
637
  },
495
638
  {
496
639
  value: "oxylabs",
497
- label: "Oxylabs — async job API, pay-as-you-go"
640
+ label: "Oxylabs — cheapest per run, no Gemini/Copilot ($49/mo min)"
498
641
  },
499
642
  {
500
- value: "cloro",
501
- label: "Cloro — ~$0.65/mo per prompt (credit plans from $30/mo)"
643
+ value: "olostep",
644
+ label: "Olostep — premium, built for high volume (~$2.25/mo per prompt)"
502
645
  },
503
646
  {
504
- value: "olostep",
505
- label: "Olostep — ~$2.25/mo per prompt (premium)"
647
+ value: "dataforseo",
648
+ label: "DataForSEO — pay-as-you-go, scrapers + direct APIs (~$1.20/mo per prompt)"
506
649
  }
507
650
  ],
508
- initialValue: "brightdata"
651
+ initialValue: "cloro"
509
652
  });
510
653
  assertNotCancelled(scraper);
511
654
  await collectScraperKey(scraper, env);
@@ -551,9 +694,9 @@ async function configureProvidersCustom(env) {
551
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.");
552
695
  }
553
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);
554
698
  await collectBrightData(env, targets);
555
699
  await collectOxylabs(env, targets);
556
- await collectCloro(env, targets);
557
700
  await collectOlostep(env, targets);
558
701
  await collectDataForSEO(env, targets);
559
702
  await finalizeScrapeTargets(env, targets);
@@ -562,7 +705,15 @@ function hasDirectApiConfigured(env) {
562
705
  return Boolean(env.ANTHROPIC_API_KEY || env.OPENAI_API_KEY || env.MISTRAL_API_KEY || env.OPENROUTER_API_KEY);
563
706
  }
564
707
  async function collectScraperKey(scraper, env) {
565
- if (scraper === "brightdata") {
708
+ if (scraper === "cloro") {
709
+ p.log.info(`Sign up: ${link(pc.cyan(CLORO_AFFILIATE), CLORO_AFFILIATE)}`);
710
+ const key = await p.password({
711
+ 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") {
566
717
  p.log.info(`Sign up: ${link(pc.cyan(BRIGHTDATA_AFFILIATE), BRIGHTDATA_AFFILIATE)}`);
567
718
  const key = await p.password({
568
719
  message: "BrightData API token",
@@ -584,15 +735,7 @@ async function collectScraperKey(scraper, env) {
584
735
  });
585
736
  assertNotCancelled(password);
586
737
  env.OXYLABS_PASSWORD = password;
587
- } else if (scraper === "cloro") {
588
- p.log.info(`Sign up: ${link(pc.cyan(CLORO_AFFILIATE), CLORO_AFFILIATE)}`);
589
- const key = await p.password({
590
- message: "Cloro API key",
591
- validate: (v) => !v ? "Required" : void 0
592
- });
593
- assertNotCancelled(key);
594
- env.CLORO_API_KEY = key;
595
- } else {
738
+ } else if (scraper === "olostep") {
596
739
  p.log.info(`Sign up: ${link(pc.cyan(OLOSTEP_AFFILIATE), OLOSTEP_AFFILIATE)}`);
597
740
  const key = await p.password({
598
741
  message: "Olostep API key",
@@ -600,6 +743,20 @@ async function collectScraperKey(scraper, env) {
600
743
  });
601
744
  assertNotCancelled(key);
602
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({
749
+ 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({
755
+ message: "DataForSEO password",
756
+ validate: (v) => !v ? "Required" : void 0
757
+ });
758
+ assertNotCancelled(password);
759
+ env.DATAFORSEO_PASSWORD = password;
603
760
  }
604
761
  }
605
762
  async function collectDirectApiQuick(kind, env) {
@@ -635,8 +792,8 @@ async function collectDirectApiQuick(kind, env) {
635
792
  }
636
793
  async function collectBrightData(env, targets) {
637
794
  const enable = await p.confirm({
638
- message: `Configure ${pc.bold("BrightData")}? (~$0.45/mo per prompt)`,
639
- initialValue: true
795
+ message: `Configure ${pc.bold("BrightData")}? (pay-as-you-go, cheaper but slower — ~$0.45/mo per prompt)`,
796
+ initialValue: false
640
797
  });
641
798
  assertNotCancelled(enable);
642
799
  if (!enable) return;
@@ -656,7 +813,7 @@ async function collectBrightData(env, targets) {
656
813
  }
657
814
  async function collectOlostep(env, targets) {
658
815
  const enable = await p.confirm({
659
- message: `Configure ${pc.bold("Olostep")}? (~$2.25/mo per prompt)`,
816
+ message: `Configure ${pc.bold("Olostep")}? (premium, built for high volume — ~$2.25/mo per prompt)`,
660
817
  initialValue: false
661
818
  });
662
819
  assertNotCancelled(enable);
@@ -677,7 +834,7 @@ async function collectOlostep(env, targets) {
677
834
  }
678
835
  async function collectOxylabs(env, targets) {
679
836
  const enable = await p.confirm({
680
- message: `Configure ${pc.bold("Oxylabs")}? (async job API, pay-as-you-go)`,
837
+ message: `Configure ${pc.bold("Oxylabs")}? (cheapest per run, no Gemini/Copilot — $49/mo min)`,
681
838
  initialValue: false
682
839
  });
683
840
  assertNotCancelled(enable);
@@ -704,8 +861,8 @@ async function collectOxylabs(env, targets) {
704
861
  }
705
862
  async function collectCloro(env, targets) {
706
863
  const enable = await p.confirm({
707
- message: `Configure ${pc.bold("Cloro")}? (async task API, credit plans from $30/mo)`,
708
- initialValue: false
864
+ message: `Configure ${pc.bold("Cloro")}? (most reliable, every surface — ~$0.65/mo per prompt, $30/mo min)`,
865
+ initialValue: true
709
866
  });
710
867
  assertNotCancelled(enable);
711
868
  if (!enable) return;
@@ -870,11 +1027,12 @@ async function collectOpenRouter(env, targets) {
870
1027
  }
871
1028
  async function collectDataForSEO(env, targets) {
872
1029
  const enable = await p.confirm({
873
- message: `Configure ${pc.bold("DataForSEO")}? (Google AI Mode + ChatGPT/Gemini scrapers)`,
1030
+ message: `Configure ${pc.bold("DataForSEO")}? (pay-as-you-go, scrapers + direct APIs — ~$1.20/mo per prompt)`,
874
1031
  initialValue: false
875
1032
  });
876
1033
  assertNotCancelled(enable);
877
1034
  if (!enable) return;
1035
+ p.log.info(`Sign up: ${link(pc.cyan(DATAFORSEO_AFFILIATE), DATAFORSEO_AFFILIATE)}`);
878
1036
  const login = await p.text({
879
1037
  message: "DataForSEO login",
880
1038
  validate: (v) => !v ? "Required" : void 0
@@ -911,7 +1069,7 @@ async function finalizeScrapeTargets(env, targets, options = {}) {
911
1069
  const deduped = dedupeTargets(targets);
912
1070
  if (!deduped) {
913
1071
  p.log.warn("No SCRAPE_TARGETS configured. Elmo will not run scheduled checks until you set them.");
914
- p.log.info(`Reference: ${link(pc.cyan(PROVIDERS_DOC_URL), PROVIDERS_DOC_URL)}`);
1072
+ p.log.info(`Reference: ${link(pc.cyan(PROVIDERS_DOCS_URL), PROVIDERS_DOCS_URL)}`);
915
1073
  const addManual = await p.confirm({
916
1074
  message: "Enter SCRAPE_TARGETS manually now?",
917
1075
  initialValue: false
@@ -938,7 +1096,7 @@ async function finalizeScrapeTargets(env, targets, options = {}) {
938
1096
  });
939
1097
  assertNotCancelled(customize);
940
1098
  if (customize) {
941
- p.log.info(`Reference: ${link(pc.cyan(PROVIDERS_DOC_URL), PROVIDERS_DOC_URL)}`);
1099
+ p.log.info(`Reference: ${link(pc.cyan(PROVIDERS_DOCS_URL), PROVIDERS_DOCS_URL)}`);
942
1100
  const manual = await p.text({
943
1101
  message: "SCRAPE_TARGETS",
944
1102
  initialValue: deduped,
@@ -1009,24 +1167,88 @@ async function runEdit(target, options) {
1009
1167
  });
1010
1168
  log.info("Restart the stack with `elmo compose up -d` to apply changes.");
1011
1169
  }
1012
- async function runUpgrade(options, cliVersion) {
1013
- printBanner();
1014
- p.intro(pc.bold("Upgrading Elmo"));
1170
+ async function confirmStaleCli(cliVersion, assumeYes) {
1015
1171
  const latestCli = await fetchLatestCliVersion();
1016
- if (latestCli && semver.valid(cliVersion) && semver.lt(cliVersion, latestCli)) {
1017
- log.warn(`Your CLI (${cliVersion}) is behind the latest published version (${latestCli}).`);
1018
- log.info("Recommended: upgrade the CLI first, then rerun this command:");
1019
- console.log(` ${pc.bold("npm install -g @elmohq/cli@latest")}`);
1020
- const proceed = options.yes ? true : await p.confirm({
1021
- message: `Continue upgrading the stack with CLI ${cliVersion} anyway?`,
1022
- initialValue: false
1023
- });
1024
- assertNotCancelled(proceed);
1025
- if (!proceed) {
1026
- p.cancel("Upgrade cancelled. Upgrade the CLI and rerun `elmo upgrade`.");
1027
- process.exit(0);
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?`,
1178
+ initialValue: false
1179
+ });
1180
+ assertNotCancelled(proceed);
1181
+ if (!proceed) {
1182
+ p.cancel("Upgrade cancelled. Upgrade the CLI and rerun `elmo upgrade`.");
1183
+ process.exit(0);
1184
+ }
1185
+ }
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
1191
+ });
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"]);
1028
1201
  }
1029
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.");
1229
+ process.exit(1);
1230
+ }
1231
+ }
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;
1244
+ }
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);
1030
1252
  const configDir = await resolveConfigDir(options.dir);
1031
1253
  const composePath = path.join(configDir, "elmo.yaml");
1032
1254
  const detectedVersion = await readRenderedVersion(composePath);
@@ -1039,33 +1261,10 @@ async function runUpgrade(options, cliVersion) {
1039
1261
  process.exit(1);
1040
1262
  }
1041
1263
  if (detectedVersion !== null && semver.eq(detectedVersion, cliVersion)) {
1042
- log.success(`Already at ${cliVersion}.`);
1043
- const pull = options.yes ? true : await p.confirm({
1044
- message: "Pull images for this version anyway?",
1045
- initialValue: false
1046
- });
1047
- assertNotCancelled(pull);
1048
- if (pull) {
1049
- assertDockerRunning();
1050
- const wasRunning = await stackHasRunningServices(configDir);
1051
- log.step("Pulling images...");
1052
- await runDockerCompose(configDir, ["pull"]);
1053
- if (wasRunning) {
1054
- log.step("Restarting services...");
1055
- await runDockerCompose(configDir, ["up", "-d"]);
1056
- }
1057
- }
1058
- p.outro(pc.green("Nothing to upgrade."));
1264
+ await pullAtCurrentVersion(configDir, cliVersion, options.yes);
1059
1265
  return;
1060
1266
  }
1061
- const plan = detectedVersion === null ? [] : planMigrations(fromVersion, cliVersion, MIGRATIONS);
1062
- if (detectedVersion === null) log.warn(`Couldn't detect the deployment's version — re-pinning images to ${pc.cyan(cliVersion)}.`);
1063
- else log.info(`Upgrading from ${pc.cyan(fromVersion)} → ${pc.cyan(cliVersion)}`);
1064
- if (plan.length === 0) log.step("No migrations to run (docker images will be re-pinned and pulled).");
1065
- else {
1066
- log.step(`Migrations to apply: ${plan.length}`);
1067
- for (const m of plan) console.log(` • ${pc.bold(`${m.from} → ${m.to}`)} ${m.description}`);
1068
- }
1267
+ const plan = planUpgradeMigrations(detectedVersion, fromVersion, cliVersion);
1069
1268
  const confirm = options.yes ? true : await p.confirm({
1070
1269
  message: "Proceed with upgrade?",
1071
1270
  initialValue: true
@@ -1081,16 +1280,7 @@ async function runUpgrade(options, cliVersion) {
1081
1280
  log.step("Stopping services...");
1082
1281
  await runDockerCompose(configDir, ["down"]);
1083
1282
  }
1084
- const ctx = buildMigrationContext(configDir, cliVersion);
1085
- try {
1086
- await runMigrations(plan, ctx);
1087
- } catch (error) {
1088
- const msg = error instanceof Error ? error.message : String(error);
1089
- log.error(`Migration failed: ${msg}`);
1090
- log.info("Your config version was left unchanged. Fix the issue and rerun `elmo upgrade`.");
1091
- if (wasRunning) log.info("The stack was stopped for the upgrade. Restart with `elmo compose up -d` after fixing.");
1092
- process.exit(1);
1093
- }
1283
+ await applyUpgradeMigrations(plan, buildMigrationContext(configDir, cliVersion), wasRunning);
1094
1284
  const isDev = await composeUsesBuild(composePath);
1095
1285
  await repinComposeImages(composePath, cliVersion);
1096
1286
  await refreshRenderedVersion(path.join(configDir, ".env"), cliVersion);
@@ -1100,17 +1290,7 @@ async function runUpgrade(options, cliVersion) {
1100
1290
  log.step("Pulling images...");
1101
1291
  await runDockerCompose(configDir, ["pull"]);
1102
1292
  }
1103
- if (wasRunning) {
1104
- log.step("Starting services...");
1105
- await runDockerCompose(configDir, ["up", "-d"]);
1106
- const s = p.spinner();
1107
- s.start("Waiting for services to become healthy...");
1108
- if (await waitForHealthy(configDir, 18e4)) s.stop("All services healthy!");
1109
- else {
1110
- s.stop("Health check timed out.");
1111
- log.warn("Some services did not report healthy status.");
1112
- }
1113
- } else log.info("Stack was stopped before upgrade — leaving it stopped. Start with `elmo compose up -d`.");
1293
+ await restartAfterUpgrade(configDir, wasRunning);
1114
1294
  await trackCliEvent(configDir, "cli_upgrade", {
1115
1295
  from_version: fromVersion,
1116
1296
  to_version: cliVersion,
@@ -1229,13 +1409,13 @@ function buildComposeYaml(options) {
1229
1409
  function buildPostgresService() {
1230
1410
  return [
1231
1411
  "postgres:",
1232
- " image: postgres:16-alpine",
1412
+ " image: postgres:18-alpine",
1233
1413
  " environment:",
1234
1414
  " POSTGRES_USER: postgres",
1235
1415
  " POSTGRES_PASSWORD: postgres",
1236
1416
  " POSTGRES_DB: elmo",
1237
1417
  " volumes:",
1238
- " - postgres_data:/var/lib/postgresql/data",
1418
+ " - postgres_data:/var/lib/postgresql",
1239
1419
  " ports:",
1240
1420
  " - \"5432:5432\"",
1241
1421
  " healthcheck:",
@@ -1272,6 +1452,7 @@ function buildWorkerService(options) {
1272
1452
  if (options.dev) lines.push(" build:", ` context: ${options.repoRoot}`, ` dockerfile: ${options.dockerfilePath}`, " target: worker", " args:", " DEPLOYMENT_MODE: local");
1273
1453
  else lines.push(` image: elmohq/elmo-worker:${options.version}`);
1274
1454
  lines.push(" env_file:", " - path: .env", " required: true");
1455
+ lines.push(" stop_grace_period: 35s");
1275
1456
  if (options.dependsOn.length > 0) {
1276
1457
  lines.push(" depends_on:");
1277
1458
  for (const service of options.dependsOn) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elmohq/cli",
3
- "version": "0.2.18",
3
+ "version": "0.3.0",
4
4
  "license": "MIT",
5
5
  "keywords": [
6
6
  "marketing",
@@ -32,16 +32,16 @@
32
32
  "commander": "^15.0.0",
33
33
  "dotenv": "17.4.2",
34
34
  "picocolors": "^1.1.1",
35
- "posthog-node": "^5.47.3",
35
+ "posthog-node": "^5.49.1",
36
36
  "semver": "^7.8.5"
37
37
  },
38
38
  "devDependencies": {
39
- "@types/node": "^26.1.2",
40
- "@types/semver": "^7.7.1",
41
- "rolldown": "^1.2.1",
39
+ "@types/node": "^26.2.0",
40
+ "@types/semver": "^7.8.0",
41
+ "rolldown": "^1.2.4",
42
42
  "typescript": "^7.0.2",
43
43
  "vitest": "^4.1.10",
44
- "@workspace/config": "0.2.18"
44
+ "@workspace/config": "0.3.0"
45
45
  },
46
46
  "scripts": {
47
47
  "build": "node scripts/build.mjs",