@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.
Files changed (2) hide show
  1. package/dist/index.js +273 -113
  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);
@@ -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-4-6";
452
- 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";
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(PROVIDERS_DOC_URL), PROVIDERS_DOC_URL)}`);
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(PROVIDERS_DOC_URL), PROVIDERS_DOC_URL)}`);
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 runUpgrade(options, cliVersion) {
1034
- printBanner();
1035
- p.intro(pc.bold("Upgrading Elmo"));
1170
+ async function confirmStaleCli(cliVersion, assumeYes) {
1036
1171
  const latestCli = await fetchLatestCliVersion();
1037
- if (latestCli && semver.valid(cliVersion) && semver.lt(cliVersion, latestCli)) {
1038
- log.warn(`Your CLI (${cliVersion}) is behind the latest published version (${latestCli}).`);
1039
- log.info("Recommended: upgrade the CLI first, then rerun this command:");
1040
- console.log(` ${pc.bold("npm install -g @elmohq/cli@latest")}`);
1041
- const proceed = options.yes ? true : await p.confirm({
1042
- message: `Continue upgrading the stack with CLI ${cliVersion} anyway?`,
1043
- initialValue: false
1044
- });
1045
- assertNotCancelled(proceed);
1046
- if (!proceed) {
1047
- p.cancel("Upgrade cancelled. Upgrade the CLI and rerun `elmo upgrade`.");
1048
- 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"]);
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
- log.success(`Already at ${cliVersion}.`);
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 === null ? [] : planMigrations(fromVersion, cliVersion, MIGRATIONS);
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
- const ctx = buildMigrationContext(configDir, cliVersion);
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
- if (wasRunning) {
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.2.19",
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.19"
44
+ "@workspace/config": "0.3.0"
45
45
  },
46
46
  "scripts": {
47
47
  "build": "node scripts/build.mjs",