@elmohq/cli 0.2.17 → 0.2.19

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 +138 -39
  2. package/package.json +5 -5
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn, spawnSync } from "node:child_process";
3
- import crypto from "node:crypto";
3
+ import crypto, { randomBytes } from "node:crypto";
4
4
  import fs from "node:fs/promises";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
@@ -92,7 +92,18 @@ async function runMigrations(plan, ctx) {
92
92
  }
93
93
  //#endregion
94
94
  //#region src/migrations/index.ts
95
- const MIGRATIONS = [];
95
+ const MIGRATIONS = [{
96
+ from: "0.2.17",
97
+ to: "0.2.18",
98
+ description: "add ELMO_ENCRYPTION_KEY for encrypted in-app provider credentials",
99
+ async run(ctx) {
100
+ const env = await ctx.readEnv();
101
+ if (env.ELMO_ENCRYPTION_KEY !== void 0) return;
102
+ env.ELMO_ENCRYPTION_KEY = randomBytes(32).toString("base64");
103
+ await ctx.writeEnv(env);
104
+ ctx.log.info("Added ELMO_ENCRYPTION_KEY — lets you store provider credentials encrypted in the app.");
105
+ }
106
+ }];
96
107
  //#endregion
97
108
  //#region src/telemetry.ts
98
109
  const POSTHOG_PUBLIC_KEY = "phc_Jhx9LnI9cTDFHpQmpOzJSDTW127qD9pFU65KRnYym6z";
@@ -102,7 +113,8 @@ function envDisabled() {
102
113
  }
103
114
  async function readEnvKey(configDir, key) {
104
115
  try {
105
- return parse(await fs.readFile(path.join(configDir, ".env"), "utf8"))[key];
116
+ const contents = await fs.readFile(path.join(configDir, ".env"), "utf8");
117
+ return parse(contents)[key];
106
118
  } catch {
107
119
  return;
108
120
  }
@@ -179,8 +191,8 @@ function assertNotCancelled(value) {
179
191
  process.exit(0);
180
192
  }
181
193
  }
182
- function generateSecret(bytes = 32) {
183
- return crypto.randomBytes(bytes).toString("base64url");
194
+ function generateSecret(bytes = 32, encoding = "base64url") {
195
+ return crypto.randomBytes(bytes).toString(encoding);
184
196
  }
185
197
  function link(text, url) {
186
198
  return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
@@ -274,6 +286,7 @@ async function runInit(options, version) {
274
286
  env.VITE_DEPLOYMENT_MODE = "local";
275
287
  env.DEPLOYMENT_ID = preservedDeploymentId ?? crypto.randomUUID();
276
288
  env.BETTER_AUTH_SECRET = generateSecret();
289
+ env.ELMO_ENCRYPTION_KEY = generateSecret(32, "base64");
277
290
  env.APP_NAME = DEFAULT_APP_NAME;
278
291
  env.APP_ICON = DEFAULT_APP_ICON;
279
292
  env.VITE_APP_NAME = DEFAULT_APP_NAME;
@@ -368,16 +381,18 @@ async function runInit(options, version) {
368
381
  postgres_mode: postgresMode,
369
382
  dev_mode: Boolean(options.dev),
370
383
  setup_mode: setupMode,
371
- has_scraper: Boolean(env.BRIGHTDATA_API_TOKEN || env.OLOSTEP_API_KEY || env.OXYLABS_USERNAME),
384
+ has_scraper: Boolean(env.BRIGHTDATA_API_TOKEN || env.OLOSTEP_API_KEY || env.OXYLABS_USERNAME || env.CLORO_API_KEY),
372
385
  has_direct_api: hasDirectApiConfigured(env)
373
386
  });
374
387
  if (email) await submitNewsletterSignup(configDir, email);
375
388
  p.log.message(`If you find Elmo useful, star us on GitHub!\n ${link(pc.cyan("https://github.com/elmohq/elmo"), "https://github.com/elmohq/elmo")}`);
376
389
  p.outro(pc.green("Setup complete!"));
377
390
  }
391
+ const CLORO_AFFILIATE = "https://cloro.dev?fpr=elmo";
378
392
  const BRIGHTDATA_AFFILIATE = "https://get.brightdata.com/67h1b7h0shcn";
379
- const OLOSTEP_AFFILIATE = "https://olostep.com/?ref=elmo";
380
393
  const OXYLABS_AFFILIATE = "https://oxylabs.go2cloud.org/aff_c?offer_id=7&aff_id=2263&url_id=32";
394
+ const OLOSTEP_AFFILIATE = "https://olostep.com/?ref=elmo";
395
+ const DATAFORSEO_AFFILIATE = "https://dataforseo.com/?aff=184966";
381
396
  const PROVIDERS_DOC_URL = "https://docs.elmohq.com/docs/user-guide/providers";
382
397
  const BRIGHTDATA_MODELS = [
383
398
  "chatgpt",
@@ -401,14 +416,37 @@ const OXYLABS_MODELS = [
401
416
  "google-ai-overview",
402
417
  "perplexity"
403
418
  ];
404
- const DEFAULT_SCRAPER_MODELS = ["chatgpt", "google-ai-mode"];
405
- const DATAFORSEO_MODELS = [
419
+ const CLORO_MODELS = [
420
+ "chatgpt",
406
421
  "google-ai-mode",
407
422
  "google-ai-overview",
408
- "chatgpt",
409
423
  "perplexity",
424
+ "copilot",
410
425
  "gemini"
411
426
  ];
427
+ const DEFAULT_SCRAPER_MODELS = ["chatgpt", "google-ai-mode"];
428
+ const DATAFORSEO_TARGETS = [
429
+ {
430
+ model: "google-ai-mode",
431
+ kind: "scraper"
432
+ },
433
+ {
434
+ model: "google-ai-overview",
435
+ kind: "scraper"
436
+ },
437
+ {
438
+ model: "chatgpt",
439
+ kind: "scraper"
440
+ },
441
+ {
442
+ model: "gemini",
443
+ kind: "scraper"
444
+ },
445
+ {
446
+ model: "perplexity",
447
+ kind: "api"
448
+ }
449
+ ];
412
450
  const DEFAULT_OPENAI_MODEL = "gpt-5-mini";
413
451
  const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-6";
414
452
  const DEFAULT_OPENROUTER_MODEL = "anthropic/claude-sonnet-4.6";
@@ -418,9 +456,11 @@ async function configureProvidersInteractive(env) {
418
456
  "Elmo needs two kinds of providers:",
419
457
  "",
420
458
  pc.bold("1. A scraper") + " — to track ChatGPT and Google AI Mode (no public APIs):",
421
- ` • ${pc.cyan("BrightData")} — cheap solid option, ~$0.45/mo per prompt`,
422
- ` • ${pc.cyan("Oxylabs")} — sync realtime API, pay-as-you-go`,
423
- ` • ${pc.cyan("Olostep")} — premium option, powers Peec/AirOps, ~$2.25/mo per prompt`,
459
+ ` • ${pc.cyan("Cloro")} — most reliable, every surface, ~$0.65/mo per prompt ($30/mo min)`,
460
+ ` • ${pc.cyan("BrightData")} — pay-as-you-go and cheaper, but slower, ~$0.45/mo per prompt`,
461
+ ` • ${pc.cyan("Oxylabs")} — cheapest per run, no Gemini/Copilot, $49/mo min`,
462
+ ` • ${pc.cyan("Olostep")} — premium, built for high volume, ~$2.25/mo per prompt`,
463
+ ` • ${pc.cyan("DataForSEO")} — pay-as-you-go, scrapers + direct APIs, ~$1.20/mo per prompt`,
424
464
  "",
425
465
  pc.bold("2. A direct LLM API") + " — for low-latency tasks (onboarding analysis, sentiment scoring,",
426
466
  " ad-hoc LLM calls). Required:",
@@ -450,20 +490,28 @@ async function configureProvidersRecommended(env) {
450
490
  const scraper = await p.select({
451
491
  message: "Scraper (tracks ChatGPT + Google AI Mode)",
452
492
  options: [
493
+ {
494
+ value: "cloro",
495
+ label: "Cloro — most reliable, every surface (~$0.65/mo per prompt, $30/mo min)"
496
+ },
453
497
  {
454
498
  value: "brightdata",
455
- label: "BrightData — ~$0.45/mo per prompt (cheaper)"
499
+ label: "BrightData — pay-as-you-go, cheaper but slower (~$0.45/mo per prompt)"
456
500
  },
457
501
  {
458
502
  value: "oxylabs",
459
- label: "Oxylabs — sync realtime API, pay-as-you-go"
503
+ label: "Oxylabs — cheapest per run, no Gemini/Copilot ($49/mo min)"
460
504
  },
461
505
  {
462
506
  value: "olostep",
463
- label: "Olostep — ~$2.25/mo per prompt (premium)"
507
+ label: "Olostep — premium, built for high volume (~$2.25/mo per prompt)"
508
+ },
509
+ {
510
+ value: "dataforseo",
511
+ label: "DataForSEO — pay-as-you-go, scrapers + direct APIs (~$1.20/mo per prompt)"
464
512
  }
465
513
  ],
466
- initialValue: "brightdata"
514
+ initialValue: "cloro"
467
515
  });
468
516
  assertNotCancelled(scraper);
469
517
  await collectScraperKey(scraper, env);
@@ -509,6 +557,7 @@ async function configureProvidersCustom(env) {
509
557
  if (!hasDirectApiConfigured(env)) p.log.warn("Onboarding analysis and other low-latency LLM tasks require a direct API. Configure at least one before continuing.");
510
558
  }
511
559
  p.log.step(pc.bold("Step 2 of 2 — Scrapers (optional, but needed to track ChatGPT / Google AI Mode)"));
560
+ await collectCloro(env, targets);
512
561
  await collectBrightData(env, targets);
513
562
  await collectOxylabs(env, targets);
514
563
  await collectOlostep(env, targets);
@@ -519,7 +568,15 @@ function hasDirectApiConfigured(env) {
519
568
  return Boolean(env.ANTHROPIC_API_KEY || env.OPENAI_API_KEY || env.MISTRAL_API_KEY || env.OPENROUTER_API_KEY);
520
569
  }
521
570
  async function collectScraperKey(scraper, env) {
522
- if (scraper === "brightdata") {
571
+ if (scraper === "cloro") {
572
+ p.log.info(`Sign up: ${link(pc.cyan(CLORO_AFFILIATE), CLORO_AFFILIATE)}`);
573
+ const key = await p.password({
574
+ message: "Cloro API key",
575
+ validate: (v) => !v ? "Required" : void 0
576
+ });
577
+ assertNotCancelled(key);
578
+ env.CLORO_API_KEY = key;
579
+ } else if (scraper === "brightdata") {
523
580
  p.log.info(`Sign up: ${link(pc.cyan(BRIGHTDATA_AFFILIATE), BRIGHTDATA_AFFILIATE)}`);
524
581
  const key = await p.password({
525
582
  message: "BrightData API token",
@@ -541,7 +598,7 @@ async function collectScraperKey(scraper, env) {
541
598
  });
542
599
  assertNotCancelled(password);
543
600
  env.OXYLABS_PASSWORD = password;
544
- } else {
601
+ } else if (scraper === "olostep") {
545
602
  p.log.info(`Sign up: ${link(pc.cyan(OLOSTEP_AFFILIATE), OLOSTEP_AFFILIATE)}`);
546
603
  const key = await p.password({
547
604
  message: "Olostep API key",
@@ -549,6 +606,20 @@ async function collectScraperKey(scraper, env) {
549
606
  });
550
607
  assertNotCancelled(key);
551
608
  env.OLOSTEP_API_KEY = key;
609
+ } else {
610
+ p.log.info(`Sign up: ${link(pc.cyan(DATAFORSEO_AFFILIATE), DATAFORSEO_AFFILIATE)}`);
611
+ const login = await p.text({
612
+ message: "DataForSEO login",
613
+ validate: (v) => !v ? "Required" : void 0
614
+ });
615
+ assertNotCancelled(login);
616
+ env.DATAFORSEO_LOGIN = login;
617
+ const password = await p.password({
618
+ message: "DataForSEO password",
619
+ validate: (v) => !v ? "Required" : void 0
620
+ });
621
+ assertNotCancelled(password);
622
+ env.DATAFORSEO_PASSWORD = password;
552
623
  }
553
624
  }
554
625
  async function collectDirectApiQuick(kind, env) {
@@ -584,8 +655,8 @@ async function collectDirectApiQuick(kind, env) {
584
655
  }
585
656
  async function collectBrightData(env, targets) {
586
657
  const enable = await p.confirm({
587
- message: `Configure ${pc.bold("BrightData")}? (~$0.45/mo per prompt)`,
588
- initialValue: true
658
+ message: `Configure ${pc.bold("BrightData")}? (pay-as-you-go, cheaper but slower — ~$0.45/mo per prompt)`,
659
+ initialValue: false
589
660
  });
590
661
  assertNotCancelled(enable);
591
662
  if (!enable) return;
@@ -605,7 +676,7 @@ async function collectBrightData(env, targets) {
605
676
  }
606
677
  async function collectOlostep(env, targets) {
607
678
  const enable = await p.confirm({
608
- message: `Configure ${pc.bold("Olostep")}? (~$2.25/mo per prompt)`,
679
+ message: `Configure ${pc.bold("Olostep")}? (premium, built for high volume — ~$2.25/mo per prompt)`,
609
680
  initialValue: false
610
681
  });
611
682
  assertNotCancelled(enable);
@@ -626,7 +697,7 @@ async function collectOlostep(env, targets) {
626
697
  }
627
698
  async function collectOxylabs(env, targets) {
628
699
  const enable = await p.confirm({
629
- message: `Configure ${pc.bold("Oxylabs")}? (sync realtime API, pay-as-you-go)`,
700
+ message: `Configure ${pc.bold("Oxylabs")}? (cheapest per run, no Gemini/Copilot — $49/mo min)`,
630
701
  initialValue: false
631
702
  });
632
703
  assertNotCancelled(enable);
@@ -651,6 +722,27 @@ async function collectOxylabs(env, targets) {
651
722
  targets
652
723
  });
653
724
  }
725
+ async function collectCloro(env, targets) {
726
+ const enable = await p.confirm({
727
+ message: `Configure ${pc.bold("Cloro")}? (most reliable, every surface — ~$0.65/mo per prompt, $30/mo min)`,
728
+ initialValue: true
729
+ });
730
+ assertNotCancelled(enable);
731
+ if (!enable) return;
732
+ p.log.info(`Sign up and create an API key: ${link(pc.cyan(CLORO_AFFILIATE), CLORO_AFFILIATE)}`);
733
+ const key = await p.password({
734
+ message: "Cloro API key",
735
+ validate: (v) => !v ? "Required" : void 0
736
+ });
737
+ assertNotCancelled(key);
738
+ env.CLORO_API_KEY = key;
739
+ await pickScraperTargets({
740
+ providerLabel: "Cloro",
741
+ providerId: "cloro",
742
+ allModels: CLORO_MODELS,
743
+ targets
744
+ });
745
+ }
654
746
  async function pickScraperTargets(args) {
655
747
  const selected = await p.multiselect({
656
748
  message: `LLM Providers to track via ${args.providerLabel}`,
@@ -798,11 +890,12 @@ async function collectOpenRouter(env, targets) {
798
890
  }
799
891
  async function collectDataForSEO(env, targets) {
800
892
  const enable = await p.confirm({
801
- message: `Configure ${pc.bold("DataForSEO")}? (Google AI Mode + LLM Responses)`,
893
+ message: `Configure ${pc.bold("DataForSEO")}? (pay-as-you-go, scrapers + direct APIs — ~$1.20/mo per prompt)`,
802
894
  initialValue: false
803
895
  });
804
896
  assertNotCancelled(enable);
805
897
  if (!enable) return;
898
+ p.log.info(`Sign up: ${link(pc.cyan(DATAFORSEO_AFFILIATE), DATAFORSEO_AFFILIATE)}`);
806
899
  const login = await p.text({
807
900
  message: "DataForSEO login",
808
901
  validate: (v) => !v ? "Required" : void 0
@@ -817,19 +910,23 @@ async function collectDataForSEO(env, targets) {
817
910
  env.DATAFORSEO_PASSWORD = pwd;
818
911
  const selected = await p.multiselect({
819
912
  message: "LLM Providers to track via DataForSEO",
820
- options: DATAFORSEO_MODELS.map((model) => ({
821
- value: model,
822
- label: model
913
+ options: DATAFORSEO_TARGETS.map((t) => ({
914
+ value: formatScrapeTarget({
915
+ model: t.model,
916
+ provider: "dataforseo",
917
+ webSearch: true
918
+ }),
919
+ label: `${t.model} (${t.kind})`
823
920
  })),
824
921
  required: false,
825
- initialValues: ["google-ai-mode"]
922
+ initialValues: [formatScrapeTarget({
923
+ model: "google-ai-mode",
924
+ provider: "dataforseo",
925
+ webSearch: true
926
+ })]
826
927
  });
827
928
  assertNotCancelled(selected);
828
- for (const model of selected) targets.push(formatScrapeTarget({
829
- model,
830
- provider: "dataforseo",
831
- webSearch: true
832
- }));
929
+ targets.push(...selected);
833
930
  }
834
931
  async function finalizeScrapeTargets(env, targets, options = {}) {
835
932
  const deduped = dedupeTargets(targets);
@@ -1153,13 +1250,13 @@ function buildComposeYaml(options) {
1153
1250
  function buildPostgresService() {
1154
1251
  return [
1155
1252
  "postgres:",
1156
- " image: postgres:16-alpine",
1253
+ " image: postgres:18-alpine",
1157
1254
  " environment:",
1158
1255
  " POSTGRES_USER: postgres",
1159
1256
  " POSTGRES_PASSWORD: postgres",
1160
1257
  " POSTGRES_DB: elmo",
1161
1258
  " volumes:",
1162
- " - postgres_data:/var/lib/postgresql/data",
1259
+ " - postgres_data:/var/lib/postgresql",
1163
1260
  " ports:",
1164
1261
  " - \"5432:5432\"",
1165
1262
  " healthcheck:",
@@ -1250,12 +1347,13 @@ function sleep(ms) {
1250
1347
  }
1251
1348
  function runDockerCompose(configDir, args) {
1252
1349
  return new Promise((resolve, reject) => {
1253
- spawn("docker", [
1350
+ const commandArgs = [
1254
1351
  "compose",
1255
1352
  "-f",
1256
1353
  path.join(configDir, "elmo.yaml"),
1257
1354
  ...args
1258
- ], { stdio: "inherit" }).on("close", (code) => {
1355
+ ];
1356
+ spawn("docker", commandArgs, { stdio: "inherit" }).on("close", (code) => {
1259
1357
  if (code === 0) resolve();
1260
1358
  else reject(/* @__PURE__ */ new Error(`docker compose exited with code ${code}`));
1261
1359
  });
@@ -1263,12 +1361,13 @@ function runDockerCompose(configDir, args) {
1263
1361
  }
1264
1362
  function runDockerComposeCapture(configDir, args) {
1265
1363
  return new Promise((resolve, reject) => {
1266
- const child = spawn("docker", [
1364
+ const commandArgs = [
1267
1365
  "compose",
1268
1366
  "-f",
1269
1367
  path.join(configDir, "elmo.yaml"),
1270
1368
  ...args
1271
- ]);
1369
+ ];
1370
+ const child = spawn("docker", commandArgs);
1272
1371
  let stdout = "";
1273
1372
  let stderr = "";
1274
1373
  child.stdout.on("data", (data) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elmohq/cli",
3
- "version": "0.2.17",
3
+ "version": "0.2.19",
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.41.0",
35
+ "posthog-node": "^5.47.3",
36
36
  "semver": "^7.8.5"
37
37
  },
38
38
  "devDependencies": {
39
- "@types/node": "^26.1.1",
39
+ "@types/node": "^26.1.2",
40
40
  "@types/semver": "^7.7.1",
41
- "rolldown": "^1.1.5",
41
+ "rolldown": "^1.2.1",
42
42
  "typescript": "^7.0.2",
43
43
  "vitest": "^4.1.10",
44
- "@workspace/config": "0.2.17"
44
+ "@workspace/config": "0.2.19"
45
45
  },
46
46
  "scripts": {
47
47
  "build": "node scripts/build.mjs",