@elmohq/cli 0.2.9 → 0.2.11

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/README.md CHANGED
@@ -44,7 +44,7 @@ Requires [Docker](https://docs.docker.com/get-docker/) and Docker Compose.
44
44
  elmo init
45
45
 
46
46
  # 2. Start the stack
47
- elmo start
47
+ elmo compose up -d
48
48
 
49
49
  # 3. Open the app at http://localhost:1515
50
50
  ```
@@ -58,29 +58,27 @@ For the full self-hosting walkthrough, see the [Elmo docs](https://www.elmohq.co
58
58
  | Command | Description |
59
59
  | --- | --- |
60
60
  | `elmo init` | Interactive wizard to set up a local Elmo instance |
61
- | `elmo start` | Start the Elmo stack |
62
- | `elmo stop` | Stop the Elmo stack |
63
- | `elmo status` | Check the health of running services |
64
- | `elmo logs [service]` | Tail container logs (pass `-f` to follow) |
65
- | `elmo regen` | Regenerate `elmo.yaml` / `.env` from your saved config |
66
- | `elmo compose <args...>` | Run any `docker compose` command against your Elmo project |
67
- | `elmo build` | Build Docker images locally (for `--dev` installs) |
61
+ | `elmo compose <args...>` | Run any `docker compose` command against your Elmo project (e.g. `elmo compose up -d`, `elmo compose down`, `elmo compose logs -f`, `elmo compose build`, `elmo compose ps`) |
62
+ | `elmo edit <env\|compose>` | Change API keys, scrape targets, or the Docker Compose YAML |
68
63
 
69
64
  Run `elmo --help` or `elmo <command> --help` for the full list of flags.
70
65
 
71
66
  ### Useful flags
72
67
 
73
- - `--dir <path>` — point any command at a specific config directory (defaults to `~/.config/elmo`).
68
+ - `--dir <path>` — point any command at a specific config directory (defaults to `~/.elmo`).
74
69
  - `elmo init --dev` — build images from a local checkout of the repo instead of pulling from the registry.
75
70
 
76
71
  ## Telemetry
77
72
 
78
- The CLI sends anonymous install and command events so we can understand which flows people use and where setup breaks. To opt out, set:
73
+ The CLI sends anonymous install and command events so we can understand which flows people use and where setup breaks. To opt out, either export `DISABLE_TELEMETRY=1` in your shell, or add it to your `.env`:
79
74
 
80
75
  ```bash
81
- export DISABLE_TELEMETRY=1
76
+ elmo edit env # add DISABLE_TELEMETRY=1
77
+ elmo compose up -d # restart so the deployment picks it up
82
78
  ```
83
79
 
80
+ See [the telemetry docs](https://www.elmohq.com/docs/developer-guide/telemetry) for details on what is collected.
81
+
84
82
  ## Star, contribute, and chat
85
83
 
86
84
  Elmo is built in the open and we'd love your help.
package/dist/index.js CHANGED
@@ -7,15 +7,15 @@ import path from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import * as p from "@clack/prompts";
9
9
  import { Command } from "commander";
10
+ import { parse as parseDotenv } from "dotenv";
10
11
  import pc from "picocolors";
11
12
  import semver from "semver";
12
- import { getTelemetryStatus, setTelemetryEnabled, submitNewsletterSignup, trackCliEvent } from "./telemetry.js";
13
+ import { submitNewsletterSignup, trackCliEvent } from "./telemetry.js";
13
14
  // ── Constants ────────────────────────────────────────────────────────────────
14
- const CONFIG_HOME = path.join(os.homedir(), ".config", "elmo");
15
- const CONFIG_FILE = path.join(CONFIG_HOME, "config.json");
15
+ const CONFIG_HOME = path.join(os.homedir(), ".elmo");
16
16
  const DEFAULT_APP_NAME = "Elmo";
17
17
  const DEFAULT_APP_ICON = "/icons/elmo-icon.svg";
18
- const DEFAULT_APP_URL = "http://localhost:1515";
18
+ const DEFAULT_APP_PORT = 1515;
19
19
  const LOCAL_DATABASE_URL = "postgres://postgres:postgres@postgres:5432/elmo";
20
20
  const TELEMETRY_DOC_URL = "https://elmohq.com/docs/developer-guide/telemetry";
21
21
  // ── Banner ───────────────────────────────────────────────────────────────────
@@ -64,6 +64,8 @@ async function main() {
64
64
  program
65
65
  .name("elmo")
66
66
  .version(version)
67
+ .option("--dir <path>", "Config directory")
68
+ .configureHelp({ showGlobalOptions: true })
67
69
  .action(() => {
68
70
  printBanner();
69
71
  program.outputHelp();
@@ -72,115 +74,27 @@ async function main() {
72
74
  .command("init")
73
75
  .description("set up local Elmo instance")
74
76
  .option("--dev", "Use local build context (repo only)")
75
- .option("--dir <path>", "Directory to store config files")
76
77
  .option("--docker-dir <path>", "Path to Docker build context (dev mode)")
77
- .action(async (options) => {
78
- await withVersionCheck(version, () => runInit(options, version));
79
- });
80
- program
81
- .command("regen")
82
- .description("regenerate configuration files from current settings")
83
- .option("--dir <path>", "Config directory")
84
- .action(async (options) => {
85
- await withVersionCheck(version, () => runRegen(options));
86
- });
87
- program
88
- .command("start")
89
- .description("start Elmo instance")
90
- .option("--dir <path>", "Config directory")
91
- .action(async (options) => {
92
- await withVersionCheck(version, () => runStart(options));
93
- });
94
- program
95
- .command("stop")
96
- .description("stop Elmo instance")
97
- .option("--dir <path>", "Config directory")
98
- .action(async (options) => {
99
- await withVersionCheck(version, () => runStop(options));
100
- });
101
- program
102
- .command("status")
103
- .description("check Elmo instance health")
104
- .option("--dir <path>", "Config directory")
105
- .action(async (options) => {
106
- await withVersionCheck(version, () => runStatus(options));
78
+ .action(async (_opts, cmd) => {
79
+ await withVersionCheck(version, () => runInit(cmd.optsWithGlobals(), version));
107
80
  });
108
81
  program
109
82
  .command("compose")
110
83
  .description("run Docker Compose commands using your Elmo config")
111
84
  .allowUnknownOption(true)
112
- .option("--dir <path>", "Config directory")
113
85
  .argument("[args...]", "Arguments passed to Docker Compose")
114
- .action(async (args, options) => {
115
- await withVersionCheck(version, () => runCompose(args, options));
86
+ .action(async (args, _opts, cmd) => {
87
+ await withVersionCheck(version, () => runCompose(args, cmd.optsWithGlobals()));
116
88
  });
117
89
  program
118
- .command("logs")
119
- .description("view Elmo instance logs")
120
- .allowUnknownOption(true)
121
- .option("--dir <path>", "Config directory")
122
- .argument("[args...]", "Arguments passed to Docker Compose logs")
123
- .action(async (args, options) => {
124
- await withVersionCheck(version, () => runCompose(["logs", ...args], options));
125
- });
126
- program
127
- .command("build")
128
- .description("build Docker images locally for development")
129
- .option("--dir <path>", "Config directory")
130
- .option("--web-only", "Only build the web image")
131
- .option("--worker-only", "Only build the worker image")
132
- .option("--no-cache", "Build without Docker cache")
133
- .action(async (options) => {
134
- await withVersionCheck(version, () => runBuild(options));
135
- });
136
- const telemetry = program.command("telemetry").description("manage CLI + local-deployment telemetry");
137
- telemetry
138
- .command("status")
139
- .description("show whether telemetry is enabled and what is collected")
140
- .action(async () => {
141
- await runTelemetryStatus();
142
- });
143
- telemetry
144
- .command("enable")
145
- .description("enable telemetry")
146
- .action(async () => {
147
- await setTelemetryEnabled(true);
148
- const updated = await updateDeploymentEnvTelemetry(true);
149
- log.success("Telemetry enabled.");
150
- if (updated) {
151
- log.info(`Updated ${updated}. Restart the stack with \`elmo start\` to apply.`);
152
- }
153
- console.log(` Details: ${link(pc.cyan(TELEMETRY_DOC_URL), TELEMETRY_DOC_URL)}`);
154
- });
155
- telemetry
156
- .command("disable")
157
- .description("disable telemetry")
158
- .action(async () => {
159
- await setTelemetryEnabled(false);
160
- const updated = await updateDeploymentEnvTelemetry(false);
161
- log.success("Telemetry disabled. No further events will be sent.");
162
- if (updated) {
163
- log.info(`Updated ${updated}. Restart the stack with \`elmo start\` to apply.`);
164
- }
90
+ .command("edit")
91
+ .description("change API keys, scrape targets, or the Docker Compose YAML")
92
+ .argument("<env|compose>", "which config file to edit")
93
+ .action(async (target, _opts, cmd) => {
94
+ await runEdit(target, cmd.optsWithGlobals());
165
95
  });
166
96
  await program.parseAsync(process.argv);
167
97
  }
168
- async function runTelemetryStatus() {
169
- const status = await getTelemetryStatus();
170
- const state = status.enabled ? pc.green("enabled") : pc.yellow("disabled");
171
- console.log(`Telemetry: ${state}`);
172
- if (status.source === "env") {
173
- console.log(" Source: DISABLE_TELEMETRY environment variable");
174
- }
175
- else if (status.source === "config") {
176
- console.log(` Source: ${CONFIG_FILE}`);
177
- }
178
- if (status.distinctId) {
179
- console.log(` Install ID: ${status.distinctId}`);
180
- }
181
- console.log(` What we collect: ${link(pc.cyan(TELEMETRY_DOC_URL), TELEMETRY_DOC_URL)}`);
182
- console.log(" Toggle: `elmo telemetry enable` / `elmo telemetry disable`");
183
- }
184
98
  async function withVersionCheck(version, fn) {
185
99
  const notifyPromise = maybeNotifyNewVersion(version);
186
100
  await fn();
@@ -192,24 +106,13 @@ async function runInit(options, version) {
192
106
  p.intro(pc.bold("Setting up Elmo"));
193
107
  const cwd = process.cwd();
194
108
  // ── Resolve config directory ─────────────────────────────────────────
195
- let configDir;
196
- if (options.dir) {
197
- configDir = path.resolve(cwd, options.dir);
198
- }
199
- else {
200
- const dir = await p.text({
201
- message: "Where should the config be stored?",
202
- placeholder: "./elmo",
203
- defaultValue: "./elmo",
204
- });
205
- assertNotCancelled(dir);
206
- configDir = path.resolve(cwd, dir);
207
- }
109
+ const configDir = options.dir ? path.resolve(cwd, options.dir) : CONFIG_HOME;
208
110
  // ── .env safety check ────────────────────────────────────────────────
209
111
  const existingEnvPath = path.join(configDir, ".env");
112
+ let preservedDeploymentId;
210
113
  if (await fileExists(existingEnvPath)) {
211
114
  const contents = await fs.readFile(existingEnvPath, "utf8");
212
- const isElmoEnv = contents.startsWith("# Generated by elmo");
115
+ const isElmoEnv = contents.startsWith("# Rendered by elmo") || contents.startsWith("# Generated by elmo");
213
116
  if (!isElmoEnv) {
214
117
  p.log.warn(`A .env file already exists in ${configDir} and was NOT created by Elmo.`);
215
118
  const overwrite = await p.confirm({
@@ -223,7 +126,17 @@ async function runInit(options, version) {
223
126
  }
224
127
  }
225
128
  else {
226
- p.log.info("Existing Elmo config found — it will be updated.");
129
+ p.log.warn(`An existing Elmo config was found at ${configDir}.`);
130
+ const overwrite = await p.confirm({
131
+ message: "Overwrite it with new values? Existing secrets (DATABASE_URL, API keys, etc.) will be replaced.",
132
+ initialValue: false,
133
+ });
134
+ assertNotCancelled(overwrite);
135
+ if (!overwrite) {
136
+ p.cancel("Setup cancelled. Use `elmo edit env` to change individual values.");
137
+ process.exit(0);
138
+ }
139
+ preservedDeploymentId = parseDotenv(contents).DEPLOYMENT_ID;
227
140
  }
228
141
  }
229
142
  // ── Dev mode: resolve docker directory ───────────────────────────────
@@ -264,14 +177,12 @@ async function runInit(options, version) {
264
177
  const env = {};
265
178
  env.DEPLOYMENT_MODE = "local";
266
179
  env.VITE_DEPLOYMENT_MODE = "local";
267
- env.DEPLOYMENT_ID = crypto.randomUUID();
180
+ env.DEPLOYMENT_ID = preservedDeploymentId ?? crypto.randomUUID();
268
181
  env.BETTER_AUTH_SECRET = generateSecret();
269
182
  env.APP_NAME = DEFAULT_APP_NAME;
270
183
  env.APP_ICON = DEFAULT_APP_ICON;
271
- env.APP_URL = DEFAULT_APP_URL;
272
184
  env.VITE_APP_NAME = DEFAULT_APP_NAME;
273
185
  env.VITE_APP_ICON = DEFAULT_APP_ICON;
274
- env.VITE_APP_URL = DEFAULT_APP_URL;
275
186
  if (postgresMode === "external") {
276
187
  p.note("Must be an IPv4-compatible direct connection or database pooler.", "DATABASE_URL");
277
188
  const url = await p.password({
@@ -296,7 +207,7 @@ async function runInit(options, version) {
296
207
  "on what to fix or build next.",
297
208
  "",
298
209
  pc.bold("What we send:"),
299
- " • install ID (random UUID stored in ~/.config/elmo/config.json)",
210
+ " • deployment ID (random UUID stored as DEPLOYMENT_ID in your .env)",
300
211
  " • CLI/app version, OS, arch, Node version, deployment mode",
301
212
  " • command/event names + non-secret options (e.g. postgres mode)",
302
213
  " • feature counts (prompts edited, brands created — never the names or text)",
@@ -306,14 +217,13 @@ async function runInit(options, version) {
306
217
  " API keys, .env contents, brand names, prompt text, and scraped responses.",
307
218
  "",
308
219
  `Full breakdown: ${link(pc.cyan(TELEMETRY_DOC_URL), TELEMETRY_DOC_URL)}`,
309
- "Toggle later with `elmo telemetry enable|disable`.",
220
+ "Toggle later by editing DISABLE_TELEMETRY in .env (`elmo edit env`).",
310
221
  ].join("\n"), "Telemetry");
311
222
  const telemetryEnabled = await p.confirm({
312
223
  message: "Share telemetry?",
313
224
  initialValue: true,
314
225
  });
315
226
  assertNotCancelled(telemetryEnabled);
316
- await setTelemetryEnabled(telemetryEnabled);
317
227
  if (!telemetryEnabled) {
318
228
  env.DISABLE_TELEMETRY = "1";
319
229
  }
@@ -323,12 +233,33 @@ async function runInit(options, version) {
323
233
  placeholder: "you@example.com",
324
234
  });
325
235
  const email = p.isCancel(updatesEmail) ? undefined : updatesEmail || undefined;
236
+ // ── Web app port ────────────────────────────────────────────────────
237
+ const portInput = await p.text({
238
+ message: "Web app port",
239
+ placeholder: String(DEFAULT_APP_PORT),
240
+ defaultValue: String(DEFAULT_APP_PORT),
241
+ validate: (v) => {
242
+ if (!v)
243
+ return undefined;
244
+ const n = Number(v);
245
+ if (!Number.isInteger(n) || n < 1 || n > 65535) {
246
+ return "Must be an integer between 1 and 65535";
247
+ }
248
+ return undefined;
249
+ },
250
+ });
251
+ assertNotCancelled(portInput);
252
+ const port = Number(portInput);
253
+ env.APP_URL = `http://localhost:${port}`;
254
+ env.VITE_APP_URL = env.APP_URL;
326
255
  // ── Write config ─────────────────────────────────────────────────────
327
256
  const composeYaml = buildComposeYaml({
328
257
  dev: Boolean(options.dev),
329
258
  postgresMode,
330
259
  repoRoot,
331
260
  dockerDir,
261
+ port,
262
+ version,
332
263
  });
333
264
  await ensureDir(configDir);
334
265
  await writeConfigFiles(configDir, {
@@ -336,13 +267,7 @@ async function runInit(options, version) {
336
267
  composeYaml,
337
268
  postgresMode,
338
269
  dev: Boolean(options.dev),
339
- });
340
- await writeGlobalConfig({
341
- configDir,
342
- dockerDir,
343
- dev: Boolean(options.dev),
344
- postgresMode,
345
- repoRoot,
270
+ version,
346
271
  });
347
272
  p.log.success(`Config written to ${configDir}`);
348
273
  p.log.warn("Your generated .env file contains secrets — do not commit it to version control.");
@@ -358,10 +283,10 @@ async function runInit(options, version) {
358
283
  await doStart(configDir);
359
284
  }
360
285
  else {
361
- p.log.info("You can start later with `elmo start`.");
286
+ p.log.info("You can start later with `elmo compose up -d`.");
362
287
  }
363
288
  // CLI telemetry — silently dropped if the user opted out above.
364
- await trackCliEvent("cli_init", {
289
+ await trackCliEvent(configDir, "cli_init", {
365
290
  version,
366
291
  os: process.platform,
367
292
  arch: process.arch,
@@ -375,7 +300,7 @@ async function runInit(options, version) {
375
300
  // Newsletter signup is a separate, explicit opt-in and runs even when
376
301
  // telemetry is disabled.
377
302
  if (email) {
378
- await submitNewsletterSignup(email);
303
+ await submitNewsletterSignup(configDir, email);
379
304
  }
380
305
  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")}`);
381
306
  p.outro(pc.green("Setup complete!"));
@@ -460,7 +385,7 @@ async function configureProvidersRecommended(env) {
460
385
  initialValue: "openrouter",
461
386
  });
462
387
  assertNotCancelled(direct);
463
- await collectDirectApiQuick(direct, env, targets);
388
+ await collectDirectApiQuick(direct, env);
464
389
  await finalizeScrapeTargets(env, targets, { skipEdit: true });
465
390
  }
466
391
  async function configureProvidersCustom(env) {
@@ -506,7 +431,7 @@ async function collectScraperKey(scraper, env) {
506
431
  env.OLOSTEP_API_KEY = key;
507
432
  }
508
433
  }
509
- async function collectDirectApiQuick(kind, env, targets) {
434
+ async function collectDirectApiQuick(kind, env) {
510
435
  if (kind === "openrouter") {
511
436
  const key = await p.password({
512
437
  message: "OpenRouter API key",
@@ -514,7 +439,6 @@ async function collectDirectApiQuick(kind, env, targets) {
514
439
  });
515
440
  assertNotCancelled(key);
516
441
  env.OPENROUTER_API_KEY = key;
517
- targets.push(`claude:openrouter:${DEFAULT_OPENROUTER_MODEL}:online`);
518
442
  }
519
443
  else if (kind === "anthropic") {
520
444
  const key = await p.password({
@@ -523,7 +447,6 @@ async function collectDirectApiQuick(kind, env, targets) {
523
447
  });
524
448
  assertNotCancelled(key);
525
449
  env.ANTHROPIC_API_KEY = key;
526
- targets.push(`claude:anthropic-api:${DEFAULT_ANTHROPIC_MODEL}:online`);
527
450
  }
528
451
  else if (kind === "openai") {
529
452
  const key = await p.password({
@@ -532,7 +455,6 @@ async function collectDirectApiQuick(kind, env, targets) {
532
455
  });
533
456
  assertNotCancelled(key);
534
457
  env.OPENAI_API_KEY = key;
535
- targets.push(`chatgpt:openai-api:${DEFAULT_OPENAI_MODEL}:online`);
536
458
  }
537
459
  else {
538
460
  const key = await p.password({
@@ -541,7 +463,6 @@ async function collectDirectApiQuick(kind, env, targets) {
541
463
  });
542
464
  assertNotCancelled(key);
543
465
  env.MISTRAL_API_KEY = key;
544
- targets.push(`mistral:mistral-api:${DEFAULT_MISTRAL_MODEL}:online`);
545
466
  }
546
467
  }
547
468
  async function collectBrightData(env, targets) {
@@ -797,51 +718,7 @@ function dedupeTargets(targets) {
797
718
  }
798
719
  return out.join(",");
799
720
  }
800
- // ── Command: update ──────────────────────────────────────────────────────────
801
- async function runRegen(options) {
802
- const configDir = await resolveConfigDir(options.dir);
803
- const globalConfig = await readGlobalConfig();
804
- // Determine settings: prefer global config, fall back to detecting from files
805
- let dev;
806
- let postgresMode;
807
- let repoRoot;
808
- let dockerDir;
809
- if (globalConfig?.postgresMode) {
810
- dev = globalConfig.dev ?? false;
811
- postgresMode = globalConfig.postgresMode;
812
- repoRoot = globalConfig.repoRoot ?? process.cwd();
813
- dockerDir = globalConfig.dockerDir;
814
- }
815
- else {
816
- const detected = await detectSettingsFromConfig(configDir);
817
- dev = detected.dev;
818
- postgresMode = detected.postgresMode;
819
- repoRoot = detected.repoRoot;
820
- dockerDir = detected.dockerDir;
821
- }
822
- const composeYaml = buildComposeYaml({
823
- dev,
824
- postgresMode,
825
- repoRoot,
826
- dockerDir,
827
- });
828
- const composePath = path.join(configDir, "elmo.yaml");
829
- await fs.writeFile(composePath, composeYaml, "utf8");
830
- await writeGlobalConfig({
831
- configDir,
832
- dockerDir,
833
- dev,
834
- postgresMode,
835
- repoRoot,
836
- });
837
- log.success(`Regenerated ${composePath}`);
838
- log.info("The .env file was not modified.");
839
- }
840
- // ── Command: start ───────────────────────────────────────────────────────────
841
- async function runStart(options) {
842
- const configDir = await resolveConfigDir(options.dir);
843
- await doStart(configDir);
844
- }
721
+ // ── Start helper (used by init) ──────────────────────────────────────────────
845
722
  async function doStart(configDir) {
846
723
  assertDockerRunning();
847
724
  log.step("Starting Docker Compose stack...");
@@ -857,71 +734,48 @@ async function doStart(configDir) {
857
734
  p.log.warn("Some services did not report healthy status.");
858
735
  }
859
736
  log.info("Examples:");
860
- console.log(` ${pc.bold("elmo logs -f")}`);
737
+ console.log(` ${pc.bold("elmo compose logs -f")}`);
861
738
  console.log(` ${pc.bold("elmo compose logs -f web")}`);
862
739
  console.log(` ${pc.bold("elmo compose ps")}`);
740
+ console.log(` ${pc.bold("elmo compose down")}`);
863
741
  }
864
- // ── Command: stop ────────────────────────────────────────────────────────────
865
- async function runStop(options) {
742
+ // ── Command: compose ─────────────────────────────────────────────────────────
743
+ async function runCompose(args, options) {
866
744
  const configDir = await resolveConfigDir(options.dir);
867
745
  assertDockerRunning();
868
- log.step("Stopping Docker Compose stack...");
869
- await runDockerCompose(configDir, ["down"]);
870
- log.success("Stack stopped.");
746
+ await runDockerCompose(configDir, args);
871
747
  }
872
- // ── Command: status ──────────────────────────────────────────────────────────
873
- async function runStatus(options) {
748
+ // ── Command: edit ────────────────────────────────────────────────────────────
749
+ async function runEdit(target, options) {
874
750
  const configDir = await resolveConfigDir(options.dir);
875
- assertDockerRunning();
876
- const services = await getComposeServices(configDir);
877
- if (services.length === 0) {
878
- log.warn("No running services found.");
879
- return;
880
- }
881
- let allHealthy = true;
882
- for (const service of services) {
883
- const status = formatServiceStatus(service);
884
- console.log(status);
885
- if (!isServiceReady(service)) {
886
- allHealthy = false;
887
- }
751
+ let filePath;
752
+ if (target === "env") {
753
+ filePath = path.join(configDir, ".env");
888
754
  }
889
- if (allHealthy) {
890
- log.success("All services are healthy.");
755
+ else if (target === "compose") {
756
+ filePath = path.join(configDir, "elmo.yaml");
891
757
  }
892
758
  else {
893
- log.warn("Some services are not healthy yet.");
759
+ throw new Error(`Unknown edit target: ${target}. Use \`env\` or \`compose\`.`);
894
760
  }
895
- }
896
- // ── Command: compose ─────────────────────────────────────────────────────────
897
- async function runCompose(args, options) {
898
- const configDir = await resolveConfigDir(options.dir);
899
- assertDockerRunning();
900
- await runDockerCompose(configDir, args);
901
- }
902
- // ── Command: build ───────────────────────────────────────────────────────────
903
- async function runBuild(options) {
904
- const configDir = await resolveConfigDir(options.dir);
905
- assertDockerRunning();
906
- const buildWeb = !options.workerOnly;
907
- const buildWorker = !options.webOnly;
908
- const noCache = options.cache === false;
909
- const targets = [];
910
- if (buildWeb)
911
- targets.push("web");
912
- if (buildWorker)
913
- targets.push("worker");
914
- log.step(`Building Docker images: ${targets.join(", ")}${noCache ? " (no cache)" : ""}...`);
915
- const buildArgs = ["build"];
916
- if (noCache)
917
- buildArgs.push("--no-cache");
918
- for (const target of targets) {
919
- log.step(`Building ${target}...`);
920
- await runDockerCompose(configDir, [...buildArgs, target]);
921
- log.success(`${target} image built successfully.`);
761
+ if (!(await fileExists(filePath))) {
762
+ throw new Error(`File not found: ${filePath}`);
922
763
  }
923
- log.success("All images built.");
924
- console.log(` Run ${pc.bold("elmo start")} to start the stack.`);
764
+ const editorEnv = process.env.VISUAL || process.env.EDITOR || "nano";
765
+ const parts = editorEnv.split(/\s+/).filter(Boolean);
766
+ const cmd = parts[0] ?? "nano";
767
+ const args = [...parts.slice(1), filePath];
768
+ await new Promise((resolve, reject) => {
769
+ const child = spawn(cmd, args, { stdio: "inherit" });
770
+ child.on("close", (code) => {
771
+ if (code === 0)
772
+ resolve();
773
+ else
774
+ reject(new Error(`${cmd} exited with code ${code}`));
775
+ });
776
+ child.on("error", (err) => reject(err));
777
+ });
778
+ log.info("Restart the stack with `elmo compose up -d` to apply changes.");
925
779
  }
926
780
  // ── Compose YAML Builder ─────────────────────────────────────────────────────
927
781
  function buildComposeYaml(options) {
@@ -942,6 +796,7 @@ function buildComposeYaml(options) {
942
796
  dev: options.dev,
943
797
  dockerfilePath,
944
798
  repoRoot: options.repoRoot,
799
+ version: options.version,
945
800
  }));
946
801
  dependsOnWeb.push("db-migrate");
947
802
  dependsOnWorker.push("db-migrate");
@@ -953,6 +808,8 @@ function buildComposeYaml(options) {
953
808
  dependencyConditions,
954
809
  repoRoot: options.repoRoot,
955
810
  dockerfilePath,
811
+ port: options.port,
812
+ version: options.version,
956
813
  }));
957
814
  services.push(buildWorkerService({
958
815
  dev: options.dev,
@@ -960,8 +817,9 @@ function buildComposeYaml(options) {
960
817
  dependencyConditions,
961
818
  repoRoot: options.repoRoot,
962
819
  dockerfilePath,
820
+ version: options.version,
963
821
  }));
964
- const lines = ["name: elmo", "", "services:"];
822
+ const lines = [renderedByHeader(options.version), "", "name: elmo", "", "services:"];
965
823
  lines.push(...services.map((service) => indentBlock(service, 2)));
966
824
  if (volumes.size > 0) {
967
825
  lines.push("", "volumes:");
@@ -997,7 +855,7 @@ function buildDbMigrateService(options) {
997
855
  lines.push(" build:", ` context: ${options.repoRoot}`, ` dockerfile: ${options.dockerfilePath}`, " target: migrate");
998
856
  }
999
857
  else {
1000
- lines.push(" image: elmohq/elmo-db-migrate:latest");
858
+ lines.push(` image: elmohq/elmo-db-migrate:${options.version}`);
1001
859
  }
1002
860
  lines.push(" environment:", " - DATABASE_URL=postgres://postgres:postgres@postgres:5432/elmo", " depends_on:", " postgres:", " condition: service_healthy");
1003
861
  return lines.join("\n");
@@ -1008,9 +866,9 @@ function buildWebService(options) {
1008
866
  lines.push(" build:", ` context: ${options.repoRoot}`, ` dockerfile: ${options.dockerfilePath}`, " target: web", " args:", " DEPLOYMENT_MODE: local");
1009
867
  }
1010
868
  else {
1011
- lines.push(" image: elmohq/elmo-web:latest");
869
+ lines.push(` image: elmohq/elmo-web:${options.version}`);
1012
870
  }
1013
- lines.push(" env_file:", " - path: .env", " required: true", " ports:", ' - "1515:3000"');
871
+ lines.push(" env_file:", " - path: .env", " required: true", " ports:", ` - "${options.port}:3000"`);
1014
872
  if (options.dependsOn.length > 0) {
1015
873
  lines.push(" depends_on:");
1016
874
  for (const service of options.dependsOn) {
@@ -1026,7 +884,7 @@ function buildWorkerService(options) {
1026
884
  lines.push(" build:", ` context: ${options.repoRoot}`, ` dockerfile: ${options.dockerfilePath}`, " target: worker", " args:", " DEPLOYMENT_MODE: local");
1027
885
  }
1028
886
  else {
1029
- lines.push(" image: elmohq/elmo-worker:latest");
887
+ lines.push(` image: elmohq/elmo-worker:${options.version}`);
1030
888
  }
1031
889
  lines.push(" env_file:", " - path: .env", " required: true");
1032
890
  if (options.dependsOn.length > 0) {
@@ -1076,19 +934,6 @@ async function getComposeServices(configDir) {
1076
934
  }
1077
935
  }
1078
936
  }
1079
- function formatServiceStatus(service) {
1080
- const health = service.Health ?? "unknown";
1081
- const state = service.State ?? "unknown";
1082
- const label = `${service.Service}`.padEnd(16, " ");
1083
- let color = pc.red;
1084
- if (isServiceReady(service)) {
1085
- color = pc.green;
1086
- }
1087
- else if (health === "starting" || state.includes("starting")) {
1088
- color = pc.yellow;
1089
- }
1090
- return color(`${label} ${state} ${service.Health ? `(${health})` : ""}`.trim());
1091
- }
1092
937
  function isServiceReady(service) {
1093
938
  if (service.Health) {
1094
939
  return service.Health === "healthy";
@@ -1196,52 +1041,32 @@ async function resolveDockerDirAuto(cwd, explicitDir) {
1196
1041
  }
1197
1042
  // ── Config Dir Resolution ────────────────────────────────────────────────────
1198
1043
  async function resolveConfigDir(explicitDir) {
1199
- if (explicitDir) {
1200
- const resolved = path.resolve(process.cwd(), explicitDir);
1201
- const composePath = path.join(resolved, "elmo.yaml");
1202
- if (!(await fileExists(composePath))) {
1203
- throw new Error(`Config directory does not contain elmo.yaml: ${resolved}\nRun \`elmo init\` to create your local setup.`);
1204
- }
1205
- return resolved;
1206
- }
1207
- const config = await readGlobalConfig();
1208
- if (!config?.configDir) {
1209
- throw new Error("No config found. Run `elmo init` or specify --dir.");
1210
- }
1211
- const resolved = path.resolve(config.configDir);
1044
+ const resolved = explicitDir ? path.resolve(process.cwd(), explicitDir) : CONFIG_HOME;
1212
1045
  const composePath = path.join(resolved, "elmo.yaml");
1213
1046
  if (!(await fileExists(composePath))) {
1214
- throw new Error(`Config directory does not contain elmo.yaml: ${resolved}\nRun \`elmo init\` to regenerate.`);
1047
+ if (explicitDir) {
1048
+ throw new Error(`Config directory does not contain elmo.yaml: ${resolved}\nRun \`elmo init --dir ${explicitDir}\` to create it.`);
1049
+ }
1050
+ throw new Error(`No config found at ${resolved}. Run \`elmo init\` to create one, or specify --dir.`);
1215
1051
  }
1216
1052
  return resolved;
1217
1053
  }
1218
1054
  // ── File & Config Helpers ────────────────────────────────────────────────────
1219
- async function readGlobalConfig() {
1220
- try {
1221
- const contents = await fs.readFile(CONFIG_FILE, "utf8");
1222
- return JSON.parse(contents);
1223
- }
1224
- catch {
1225
- return null;
1226
- }
1227
- }
1228
- async function writeGlobalConfig(config) {
1229
- const globalConfig = {
1230
- ...config,
1231
- updatedAt: new Date().toISOString(),
1232
- };
1233
- await ensureDir(CONFIG_HOME);
1234
- await fs.writeFile(CONFIG_FILE, JSON.stringify(globalConfig, null, 2), "utf8");
1235
- }
1236
1055
  async function writeConfigFiles(configDir, initConfig) {
1237
1056
  const envPath = path.join(configDir, ".env");
1238
1057
  const composePath = path.join(configDir, "elmo.yaml");
1239
1058
  await ensureDir(configDir);
1240
- await fs.writeFile(envPath, buildEnvFile(initConfig.env), "utf8");
1059
+ await fs.writeFile(envPath, buildEnvFile(initConfig.env, initConfig.version), "utf8");
1241
1060
  await fs.writeFile(composePath, initConfig.composeYaml, "utf8");
1242
1061
  }
1243
- function buildEnvFile(env) {
1244
- const lines = ["# Generated by elmo init", "# WARNING: contains secrets. Do not commit.", ""];
1062
+ function renderedByHeader(version) {
1063
+ return [
1064
+ `# Rendered by elmo ${version} on ${new Date().toISOString()}`,
1065
+ "# Re-run `elmo init` after upgrading the CLI to refresh this file.",
1066
+ ].join("\n");
1067
+ }
1068
+ function buildEnvFile(env, version) {
1069
+ const lines = [renderedByHeader(version), "# WARNING: contains secrets. Do not commit.", ""];
1245
1070
  for (const [key, rawValue] of Object.entries(env)) {
1246
1071
  if (rawValue === undefined) {
1247
1072
  continue;
@@ -1260,68 +1085,6 @@ function formatEnvValue(value) {
1260
1085
  }
1261
1086
  return value;
1262
1087
  }
1263
- async function detectSettingsFromConfig(configDir) {
1264
- const envPath = path.join(configDir, ".env");
1265
- const env = await readEnvFile(envPath);
1266
- const postgresMode = env.DATABASE_URL?.includes("postgres:5432") ? "docker" : "external";
1267
- const yamlPath = path.join(configDir, "elmo.yaml");
1268
- let dev = false;
1269
- let repoRoot = process.cwd();
1270
- try {
1271
- const yamlContent = await fs.readFile(yamlPath, "utf8");
1272
- dev = yamlContent.includes("build:");
1273
- const contextMatch = yamlContent.match(/context:\s+(.+)/);
1274
- if (contextMatch) {
1275
- repoRoot = contextMatch[1].trim();
1276
- }
1277
- }
1278
- catch {
1279
- // Use defaults
1280
- }
1281
- return { dev, postgresMode, repoRoot };
1282
- }
1283
- async function updateDeploymentEnvTelemetry(enabled) {
1284
- const config = await readGlobalConfig();
1285
- if (!config?.configDir)
1286
- return null;
1287
- const envPath = path.join(config.configDir, ".env");
1288
- if (!(await fileExists(envPath)))
1289
- return null;
1290
- const contents = await fs.readFile(envPath, "utf8");
1291
- const lines = contents.split("\n");
1292
- const filtered = lines.filter((line) => !/^\s*DISABLE_TELEMETRY\s*=/.test(line));
1293
- if (!enabled) {
1294
- const insertIdx = filtered.length > 0 && filtered[filtered.length - 1] === "" ? filtered.length - 1 : filtered.length;
1295
- filtered.splice(insertIdx, 0, "DISABLE_TELEMETRY=1");
1296
- }
1297
- await fs.writeFile(envPath, filtered.join("\n"), "utf8");
1298
- return envPath;
1299
- }
1300
- async function readEnvFile(envPath) {
1301
- try {
1302
- const contents = await fs.readFile(envPath, "utf8");
1303
- const env = {};
1304
- for (const line of contents.split("\n")) {
1305
- const trimmed = line.trim();
1306
- if (!trimmed || trimmed.startsWith("#"))
1307
- continue;
1308
- const eqIndex = trimmed.indexOf("=");
1309
- if (eqIndex < 0)
1310
- continue;
1311
- const key = trimmed.substring(0, eqIndex);
1312
- let value = trimmed.substring(eqIndex + 1);
1313
- // Remove surrounding quotes
1314
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
1315
- value = value.slice(1, -1);
1316
- }
1317
- env[key] = value;
1318
- }
1319
- return env;
1320
- }
1321
- catch {
1322
- return {};
1323
- }
1324
- }
1325
1088
  async function fileExists(target) {
1326
1089
  try {
1327
1090
  await fs.access(target);
package/dist/telemetry.js CHANGED
@@ -1,67 +1,33 @@
1
- import crypto from "node:crypto";
2
1
  import fs from "node:fs/promises";
3
- import os from "node:os";
4
2
  import path from "node:path";
3
+ import { parse } from "dotenv";
5
4
  import { PostHog } from "posthog-node";
6
5
  const POSTHOG_PUBLIC_KEY = "phc_Jhx9LnI9cTDFHpQmpOzJSDTW127qD9pFU65KRnYym6z";
7
6
  const POSTHOG_HOST = "https://us.i.posthog.com";
8
- const CONFIG_HOME = path.join(os.homedir(), ".config", "elmo");
9
- const CONFIG_FILE = path.join(CONFIG_HOME, "config.json");
10
- async function readConfig() {
7
+ function envDisabled() {
8
+ return Boolean(process.env.DISABLE_TELEMETRY);
9
+ }
10
+ async function readEnvKey(configDir, key) {
11
11
  try {
12
- const contents = await fs.readFile(CONFIG_FILE, "utf8");
13
- return JSON.parse(contents);
12
+ const contents = await fs.readFile(path.join(configDir, ".env"), "utf8");
13
+ return parse(contents)[key];
14
14
  }
15
15
  catch {
16
- return {};
16
+ return undefined;
17
17
  }
18
18
  }
19
- async function writeConfig(config) {
20
- await fs.mkdir(CONFIG_HOME, { recursive: true });
21
- await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), "utf8");
22
- }
23
- function envDisabled() {
24
- return Boolean(process.env.DISABLE_TELEMETRY);
25
- }
26
- export async function isTelemetryDisabled() {
19
+ async function isTelemetryDisabled(configDir) {
27
20
  if (envDisabled())
28
21
  return true;
29
- const config = await readConfig();
30
- return config.telemetryDisabled === true;
22
+ return Boolean(await readEnvKey(configDir, "DISABLE_TELEMETRY"));
31
23
  }
32
- export async function setTelemetryEnabled(enabled) {
33
- const config = await readConfig();
34
- if (enabled) {
35
- delete config.telemetryDisabled;
36
- }
37
- else {
38
- config.telemetryDisabled = true;
39
- }
40
- await writeConfig(config);
41
- }
42
- export async function getTelemetryStatus() {
43
- if (envDisabled()) {
44
- return { enabled: false, source: "env" };
45
- }
46
- const config = await readConfig();
47
- if (config.telemetryDisabled === true) {
48
- return { enabled: false, source: "config", distinctId: config.telemetryId };
49
- }
50
- return { enabled: true, source: "default", distinctId: config.telemetryId };
51
- }
52
- async function getOrCreateDistinctId() {
53
- const config = await readConfig();
54
- if (config.telemetryId)
55
- return config.telemetryId;
56
- const id = crypto.randomUUID();
57
- await writeConfig({ ...config, telemetryId: id });
58
- return id;
59
- }
60
- export async function trackCliEvent(eventName, properties, personProperties) {
61
- if (await isTelemetryDisabled())
24
+ export async function trackCliEvent(configDir, eventName, properties, personProperties) {
25
+ if (await isTelemetryDisabled(configDir))
26
+ return;
27
+ const distinctId = await readEnvKey(configDir, "DEPLOYMENT_ID");
28
+ if (!distinctId)
62
29
  return;
63
30
  try {
64
- const distinctId = await getOrCreateDistinctId();
65
31
  const client = new PostHog(POSTHOG_PUBLIC_KEY, { host: POSTHOG_HOST });
66
32
  client.capture({
67
33
  distinctId,
@@ -77,14 +43,15 @@ export async function trackCliEvent(eventName, properties, personProperties) {
77
43
  }
78
44
  // Newsletter signup is an explicit user action with clear intent, so it
79
45
  // fires even when anonymous telemetry is disabled. When telemetry is on the
80
- // event is keyed off the install UUID and the email is attached as a person
81
- // property — same identity as the rest of the CLI events. When telemetry is
82
- // off the event is keyed off the email itself, so it is never linked back
83
- // to the anonymous install UUID.
84
- export async function submitNewsletterSignup(email) {
46
+ // event is keyed off the deployment UUID and the email is attached as a
47
+ // person property — same identity as the rest of the CLI events. When
48
+ // telemetry is off the event is keyed off the email itself, so it is never
49
+ // linked back to the anonymous deployment UUID.
50
+ export async function submitNewsletterSignup(configDir, email) {
85
51
  try {
86
- const disabled = await isTelemetryDisabled();
87
- const distinctId = disabled ? email : await getOrCreateDistinctId();
52
+ const disabled = await isTelemetryDisabled(configDir);
53
+ const deploymentId = disabled ? null : await readEnvKey(configDir, "DEPLOYMENT_ID");
54
+ const distinctId = deploymentId ?? email;
88
55
  const client = new PostHog(POSTHOG_PUBLIC_KEY, { host: POSTHOG_HOST });
89
56
  client.capture({
90
57
  distinctId,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@elmohq/cli",
3
- "version": "0.2.9",
4
- "license": "AGPL-3.0-or-later",
3
+ "version": "0.2.11",
4
+ "license": "MIT",
5
5
  "keywords": [
6
6
  "marketing",
7
7
  "typescript",
@@ -28,14 +28,15 @@
28
28
  ],
29
29
  "type": "module",
30
30
  "dependencies": {
31
- "@clack/prompts": "^1.3.0",
32
- "commander": "^14.0.3",
31
+ "@clack/prompts": "^1.5.0",
32
+ "commander": "^15.0.0",
33
+ "dotenv": "17.4.2",
33
34
  "picocolors": "^1.1.1",
34
- "posthog-node": "^5.33.0",
35
- "semver": "^7.7.4"
35
+ "posthog-node": "^5.35.6",
36
+ "semver": "^7.8.1"
36
37
  },
37
38
  "devDependencies": {
38
- "@types/node": "^25.6.0",
39
+ "@types/node": "^25.9.1",
39
40
  "@types/semver": "^7.7.1",
40
41
  "typescript": "^6.0.3"
41
42
  },