@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.
- package/dist/index.js +327 -146
- 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(
|
|
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
|
|
232
|
-
|
|
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:
|
|
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
|
|
395
|
-
const
|
|
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-
|
|
451
|
-
const DEFAULT_OPENROUTER_MODEL = "anthropic/claude-sonnet-
|
|
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("
|
|
459
|
-
` • ${pc.cyan("
|
|
460
|
-
` • ${pc.cyan("
|
|
461
|
-
` • ${pc.cyan("Olostep")} — premium
|
|
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
|
|
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 —
|
|
640
|
+
label: "Oxylabs — cheapest per run, no Gemini/Copilot ($49/mo min)"
|
|
498
641
|
},
|
|
499
642
|
{
|
|
500
|
-
value: "
|
|
501
|
-
label: "
|
|
643
|
+
value: "olostep",
|
|
644
|
+
label: "Olostep — premium, built for high volume (~$2.25/mo per prompt)"
|
|
502
645
|
},
|
|
503
646
|
{
|
|
504
|
-
value: "
|
|
505
|
-
label: "
|
|
647
|
+
value: "dataforseo",
|
|
648
|
+
label: "DataForSEO — pay-as-you-go, scrapers + direct APIs (~$1.20/mo per prompt)"
|
|
506
649
|
}
|
|
507
650
|
],
|
|
508
|
-
initialValue: "
|
|
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 === "
|
|
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 === "
|
|
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:
|
|
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")}? (
|
|
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")}? (
|
|
708
|
-
initialValue:
|
|
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")}? (
|
|
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(
|
|
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(
|
|
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
|
|
1013
|
-
printBanner();
|
|
1014
|
-
p.intro(pc.bold("Upgrading Elmo"));
|
|
1170
|
+
async function confirmStaleCli(cliVersion, assumeYes) {
|
|
1015
1171
|
const latestCli = await fetchLatestCliVersion();
|
|
1016
|
-
if (latestCli
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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.
|
|
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.
|
|
35
|
+
"posthog-node": "^5.49.1",
|
|
36
36
|
"semver": "^7.8.5"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
|
-
"@types/node": "^26.
|
|
40
|
-
"@types/semver": "^7.
|
|
41
|
-
"rolldown": "^1.2.
|
|
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.
|
|
44
|
+
"@workspace/config": "0.3.0"
|
|
45
45
|
},
|
|
46
46
|
"scripts": {
|
|
47
47
|
"build": "node scripts/build.mjs",
|