@elmohq/cli 0.2.19 → 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 +273 -113
- 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);
|
|
@@ -393,7 +531,6 @@ const BRIGHTDATA_AFFILIATE = "https://get.brightdata.com/67h1b7h0shcn";
|
|
|
393
531
|
const OXYLABS_AFFILIATE = "https://oxylabs.go2cloud.org/aff_c?offer_id=7&aff_id=2263&url_id=32";
|
|
394
532
|
const OLOSTEP_AFFILIATE = "https://olostep.com/?ref=elmo";
|
|
395
533
|
const DATAFORSEO_AFFILIATE = "https://dataforseo.com/?aff=184966";
|
|
396
|
-
const PROVIDERS_DOC_URL = "https://docs.elmohq.com/docs/user-guide/providers";
|
|
397
534
|
const BRIGHTDATA_MODELS = [
|
|
398
535
|
"chatgpt",
|
|
399
536
|
"google-ai-mode",
|
|
@@ -448,8 +585,8 @@ const DATAFORSEO_TARGETS = [
|
|
|
448
585
|
}
|
|
449
586
|
];
|
|
450
587
|
const DEFAULT_OPENAI_MODEL = "gpt-5-mini";
|
|
451
|
-
const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-
|
|
452
|
-
const DEFAULT_OPENROUTER_MODEL = "anthropic/claude-sonnet-
|
|
588
|
+
const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-5";
|
|
589
|
+
const DEFAULT_OPENROUTER_MODEL = "anthropic/claude-sonnet-5";
|
|
453
590
|
const DEFAULT_MISTRAL_MODEL = "mistral-medium-latest";
|
|
454
591
|
async function configureProvidersInteractive(env) {
|
|
455
592
|
p.note([
|
|
@@ -932,7 +1069,7 @@ async function finalizeScrapeTargets(env, targets, options = {}) {
|
|
|
932
1069
|
const deduped = dedupeTargets(targets);
|
|
933
1070
|
if (!deduped) {
|
|
934
1071
|
p.log.warn("No SCRAPE_TARGETS configured. Elmo will not run scheduled checks until you set them.");
|
|
935
|
-
p.log.info(`Reference: ${link(pc.cyan(
|
|
1072
|
+
p.log.info(`Reference: ${link(pc.cyan(PROVIDERS_DOCS_URL), PROVIDERS_DOCS_URL)}`);
|
|
936
1073
|
const addManual = await p.confirm({
|
|
937
1074
|
message: "Enter SCRAPE_TARGETS manually now?",
|
|
938
1075
|
initialValue: false
|
|
@@ -959,7 +1096,7 @@ async function finalizeScrapeTargets(env, targets, options = {}) {
|
|
|
959
1096
|
});
|
|
960
1097
|
assertNotCancelled(customize);
|
|
961
1098
|
if (customize) {
|
|
962
|
-
p.log.info(`Reference: ${link(pc.cyan(
|
|
1099
|
+
p.log.info(`Reference: ${link(pc.cyan(PROVIDERS_DOCS_URL), PROVIDERS_DOCS_URL)}`);
|
|
963
1100
|
const manual = await p.text({
|
|
964
1101
|
message: "SCRAPE_TARGETS",
|
|
965
1102
|
initialValue: deduped,
|
|
@@ -1030,24 +1167,88 @@ async function runEdit(target, options) {
|
|
|
1030
1167
|
});
|
|
1031
1168
|
log.info("Restart the stack with `elmo compose up -d` to apply changes.");
|
|
1032
1169
|
}
|
|
1033
|
-
async function
|
|
1034
|
-
printBanner();
|
|
1035
|
-
p.intro(pc.bold("Upgrading Elmo"));
|
|
1170
|
+
async function confirmStaleCli(cliVersion, assumeYes) {
|
|
1036
1171
|
const latestCli = await fetchLatestCliVersion();
|
|
1037
|
-
if (latestCli
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
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"]);
|
|
1049
1201
|
}
|
|
1050
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);
|
|
1051
1252
|
const configDir = await resolveConfigDir(options.dir);
|
|
1052
1253
|
const composePath = path.join(configDir, "elmo.yaml");
|
|
1053
1254
|
const detectedVersion = await readRenderedVersion(composePath);
|
|
@@ -1060,33 +1261,10 @@ async function runUpgrade(options, cliVersion) {
|
|
|
1060
1261
|
process.exit(1);
|
|
1061
1262
|
}
|
|
1062
1263
|
if (detectedVersion !== null && semver.eq(detectedVersion, cliVersion)) {
|
|
1063
|
-
|
|
1064
|
-
const pull = options.yes ? true : await p.confirm({
|
|
1065
|
-
message: "Pull images for this version anyway?",
|
|
1066
|
-
initialValue: false
|
|
1067
|
-
});
|
|
1068
|
-
assertNotCancelled(pull);
|
|
1069
|
-
if (pull) {
|
|
1070
|
-
assertDockerRunning();
|
|
1071
|
-
const wasRunning = await stackHasRunningServices(configDir);
|
|
1072
|
-
log.step("Pulling images...");
|
|
1073
|
-
await runDockerCompose(configDir, ["pull"]);
|
|
1074
|
-
if (wasRunning) {
|
|
1075
|
-
log.step("Restarting services...");
|
|
1076
|
-
await runDockerCompose(configDir, ["up", "-d"]);
|
|
1077
|
-
}
|
|
1078
|
-
}
|
|
1079
|
-
p.outro(pc.green("Nothing to upgrade."));
|
|
1264
|
+
await pullAtCurrentVersion(configDir, cliVersion, options.yes);
|
|
1080
1265
|
return;
|
|
1081
1266
|
}
|
|
1082
|
-
const plan = detectedVersion
|
|
1083
|
-
if (detectedVersion === null) log.warn(`Couldn't detect the deployment's version — re-pinning images to ${pc.cyan(cliVersion)}.`);
|
|
1084
|
-
else log.info(`Upgrading from ${pc.cyan(fromVersion)} → ${pc.cyan(cliVersion)}`);
|
|
1085
|
-
if (plan.length === 0) log.step("No migrations to run (docker images will be re-pinned and pulled).");
|
|
1086
|
-
else {
|
|
1087
|
-
log.step(`Migrations to apply: ${plan.length}`);
|
|
1088
|
-
for (const m of plan) console.log(` • ${pc.bold(`${m.from} → ${m.to}`)} ${m.description}`);
|
|
1089
|
-
}
|
|
1267
|
+
const plan = planUpgradeMigrations(detectedVersion, fromVersion, cliVersion);
|
|
1090
1268
|
const confirm = options.yes ? true : await p.confirm({
|
|
1091
1269
|
message: "Proceed with upgrade?",
|
|
1092
1270
|
initialValue: true
|
|
@@ -1102,16 +1280,7 @@ async function runUpgrade(options, cliVersion) {
|
|
|
1102
1280
|
log.step("Stopping services...");
|
|
1103
1281
|
await runDockerCompose(configDir, ["down"]);
|
|
1104
1282
|
}
|
|
1105
|
-
|
|
1106
|
-
try {
|
|
1107
|
-
await runMigrations(plan, ctx);
|
|
1108
|
-
} catch (error) {
|
|
1109
|
-
const msg = error instanceof Error ? error.message : String(error);
|
|
1110
|
-
log.error(`Migration failed: ${msg}`);
|
|
1111
|
-
log.info("Your config version was left unchanged. Fix the issue and rerun `elmo upgrade`.");
|
|
1112
|
-
if (wasRunning) log.info("The stack was stopped for the upgrade. Restart with `elmo compose up -d` after fixing.");
|
|
1113
|
-
process.exit(1);
|
|
1114
|
-
}
|
|
1283
|
+
await applyUpgradeMigrations(plan, buildMigrationContext(configDir, cliVersion), wasRunning);
|
|
1115
1284
|
const isDev = await composeUsesBuild(composePath);
|
|
1116
1285
|
await repinComposeImages(composePath, cliVersion);
|
|
1117
1286
|
await refreshRenderedVersion(path.join(configDir, ".env"), cliVersion);
|
|
@@ -1121,17 +1290,7 @@ async function runUpgrade(options, cliVersion) {
|
|
|
1121
1290
|
log.step("Pulling images...");
|
|
1122
1291
|
await runDockerCompose(configDir, ["pull"]);
|
|
1123
1292
|
}
|
|
1124
|
-
|
|
1125
|
-
log.step("Starting services...");
|
|
1126
|
-
await runDockerCompose(configDir, ["up", "-d"]);
|
|
1127
|
-
const s = p.spinner();
|
|
1128
|
-
s.start("Waiting for services to become healthy...");
|
|
1129
|
-
if (await waitForHealthy(configDir, 18e4)) s.stop("All services healthy!");
|
|
1130
|
-
else {
|
|
1131
|
-
s.stop("Health check timed out.");
|
|
1132
|
-
log.warn("Some services did not report healthy status.");
|
|
1133
|
-
}
|
|
1134
|
-
} else log.info("Stack was stopped before upgrade — leaving it stopped. Start with `elmo compose up -d`.");
|
|
1293
|
+
await restartAfterUpgrade(configDir, wasRunning);
|
|
1135
1294
|
await trackCliEvent(configDir, "cli_upgrade", {
|
|
1136
1295
|
from_version: fromVersion,
|
|
1137
1296
|
to_version: cliVersion,
|
|
@@ -1293,6 +1452,7 @@ function buildWorkerService(options) {
|
|
|
1293
1452
|
if (options.dev) lines.push(" build:", ` context: ${options.repoRoot}`, ` dockerfile: ${options.dockerfilePath}`, " target: worker", " args:", " DEPLOYMENT_MODE: local");
|
|
1294
1453
|
else lines.push(` image: elmohq/elmo-worker:${options.version}`);
|
|
1295
1454
|
lines.push(" env_file:", " - path: .env", " required: true");
|
|
1455
|
+
lines.push(" stop_grace_period: 35s");
|
|
1296
1456
|
if (options.dependsOn.length > 0) {
|
|
1297
1457
|
lines.push(" depends_on:");
|
|
1298
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",
|