@elmohq/cli 0.2.9 → 0.2.10

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,28 @@ 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
61
  | `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) |
62
+ | `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`) |
63
+ | `elmo edit <env\|compose>` | Open `.env` or `elmo.yaml` in `$VISUAL` / `$EDITOR` (fallback: `nano`) |
68
64
 
69
65
  Run `elmo --help` or `elmo <command> --help` for the full list of flags.
70
66
 
71
67
  ### Useful flags
72
68
 
73
- - `--dir <path>` — point any command at a specific config directory (defaults to `~/.config/elmo`).
69
+ - `--dir <path>` — point any command at a specific config directory (defaults to `~/.elmo`).
74
70
  - `elmo init --dev` — build images from a local checkout of the repo instead of pulling from the registry.
75
71
 
76
72
  ## Telemetry
77
73
 
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:
74
+ 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
75
 
80
76
  ```bash
81
- export DISABLE_TELEMETRY=1
77
+ elmo edit env # add DISABLE_TELEMETRY=1
78
+ elmo compose up -d # restart so the deployment picks it up
82
79
  ```
83
80
 
81
+ See [the telemetry docs](https://www.elmohq.com/docs/developer-guide/telemetry) for details on what is collected.
82
+
84
83
  ## Star, contribute, and chat
85
84
 
86
85
  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 ───────────────────────────────────────────────────────────────────
@@ -77,27 +77,6 @@ async function main() {
77
77
  .action(async (options) => {
78
78
  await withVersionCheck(version, () => runInit(options, version));
79
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
80
  program
102
81
  .command("status")
103
82
  .description("check Elmo instance health")
@@ -115,72 +94,15 @@ async function main() {
115
94
  await withVersionCheck(version, () => runCompose(args, options));
116
95
  });
117
96
  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")
97
+ .command("edit")
98
+ .description("open .env or elmo.yaml in $VISUAL / $EDITOR (fallback: nano)")
99
+ .argument("<target>", "`env` or `compose`")
129
100
  .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
- }
101
+ .action(async (target, options) => {
102
+ await runEdit(target, options);
165
103
  });
166
104
  await program.parseAsync(process.argv);
167
105
  }
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
106
  async function withVersionCheck(version, fn) {
185
107
  const notifyPromise = maybeNotifyNewVersion(version);
186
108
  await fn();
@@ -192,24 +114,13 @@ async function runInit(options, version) {
192
114
  p.intro(pc.bold("Setting up Elmo"));
193
115
  const cwd = process.cwd();
194
116
  // ── 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
- }
117
+ const configDir = options.dir ? path.resolve(cwd, options.dir) : CONFIG_HOME;
208
118
  // ── .env safety check ────────────────────────────────────────────────
209
119
  const existingEnvPath = path.join(configDir, ".env");
120
+ let preservedDeploymentId;
210
121
  if (await fileExists(existingEnvPath)) {
211
122
  const contents = await fs.readFile(existingEnvPath, "utf8");
212
- const isElmoEnv = contents.startsWith("# Generated by elmo");
123
+ const isElmoEnv = contents.startsWith("# Rendered by elmo") || contents.startsWith("# Generated by elmo");
213
124
  if (!isElmoEnv) {
214
125
  p.log.warn(`A .env file already exists in ${configDir} and was NOT created by Elmo.`);
215
126
  const overwrite = await p.confirm({
@@ -223,7 +134,17 @@ async function runInit(options, version) {
223
134
  }
224
135
  }
225
136
  else {
226
- p.log.info("Existing Elmo config found — it will be updated.");
137
+ p.log.warn(`An existing Elmo config was found at ${configDir}.`);
138
+ const overwrite = await p.confirm({
139
+ message: "Overwrite it with new values? Existing secrets (DATABASE_URL, API keys, etc.) will be replaced.",
140
+ initialValue: false,
141
+ });
142
+ assertNotCancelled(overwrite);
143
+ if (!overwrite) {
144
+ p.cancel("Setup cancelled. Use `elmo edit env` to change individual values.");
145
+ process.exit(0);
146
+ }
147
+ preservedDeploymentId = parseDotenv(contents).DEPLOYMENT_ID;
227
148
  }
228
149
  }
229
150
  // ── Dev mode: resolve docker directory ───────────────────────────────
@@ -264,14 +185,12 @@ async function runInit(options, version) {
264
185
  const env = {};
265
186
  env.DEPLOYMENT_MODE = "local";
266
187
  env.VITE_DEPLOYMENT_MODE = "local";
267
- env.DEPLOYMENT_ID = crypto.randomUUID();
188
+ env.DEPLOYMENT_ID = preservedDeploymentId ?? crypto.randomUUID();
268
189
  env.BETTER_AUTH_SECRET = generateSecret();
269
190
  env.APP_NAME = DEFAULT_APP_NAME;
270
191
  env.APP_ICON = DEFAULT_APP_ICON;
271
- env.APP_URL = DEFAULT_APP_URL;
272
192
  env.VITE_APP_NAME = DEFAULT_APP_NAME;
273
193
  env.VITE_APP_ICON = DEFAULT_APP_ICON;
274
- env.VITE_APP_URL = DEFAULT_APP_URL;
275
194
  if (postgresMode === "external") {
276
195
  p.note("Must be an IPv4-compatible direct connection or database pooler.", "DATABASE_URL");
277
196
  const url = await p.password({
@@ -296,7 +215,7 @@ async function runInit(options, version) {
296
215
  "on what to fix or build next.",
297
216
  "",
298
217
  pc.bold("What we send:"),
299
- " • install ID (random UUID stored in ~/.config/elmo/config.json)",
218
+ " • deployment ID (random UUID stored as DEPLOYMENT_ID in your .env)",
300
219
  " • CLI/app version, OS, arch, Node version, deployment mode",
301
220
  " • command/event names + non-secret options (e.g. postgres mode)",
302
221
  " • feature counts (prompts edited, brands created — never the names or text)",
@@ -306,14 +225,13 @@ async function runInit(options, version) {
306
225
  " API keys, .env contents, brand names, prompt text, and scraped responses.",
307
226
  "",
308
227
  `Full breakdown: ${link(pc.cyan(TELEMETRY_DOC_URL), TELEMETRY_DOC_URL)}`,
309
- "Toggle later with `elmo telemetry enable|disable`.",
228
+ "Toggle later by editing DISABLE_TELEMETRY in .env (`elmo edit env`).",
310
229
  ].join("\n"), "Telemetry");
311
230
  const telemetryEnabled = await p.confirm({
312
231
  message: "Share telemetry?",
313
232
  initialValue: true,
314
233
  });
315
234
  assertNotCancelled(telemetryEnabled);
316
- await setTelemetryEnabled(telemetryEnabled);
317
235
  if (!telemetryEnabled) {
318
236
  env.DISABLE_TELEMETRY = "1";
319
237
  }
@@ -323,12 +241,33 @@ async function runInit(options, version) {
323
241
  placeholder: "you@example.com",
324
242
  });
325
243
  const email = p.isCancel(updatesEmail) ? undefined : updatesEmail || undefined;
244
+ // ── Web app port ────────────────────────────────────────────────────
245
+ const portInput = await p.text({
246
+ message: "Web app port",
247
+ placeholder: String(DEFAULT_APP_PORT),
248
+ defaultValue: String(DEFAULT_APP_PORT),
249
+ validate: (v) => {
250
+ if (!v)
251
+ return undefined;
252
+ const n = Number(v);
253
+ if (!Number.isInteger(n) || n < 1 || n > 65535) {
254
+ return "Must be an integer between 1 and 65535";
255
+ }
256
+ return undefined;
257
+ },
258
+ });
259
+ assertNotCancelled(portInput);
260
+ const port = Number(portInput);
261
+ env.APP_URL = `http://localhost:${port}`;
262
+ env.VITE_APP_URL = env.APP_URL;
326
263
  // ── Write config ─────────────────────────────────────────────────────
327
264
  const composeYaml = buildComposeYaml({
328
265
  dev: Boolean(options.dev),
329
266
  postgresMode,
330
267
  repoRoot,
331
268
  dockerDir,
269
+ port,
270
+ version,
332
271
  });
333
272
  await ensureDir(configDir);
334
273
  await writeConfigFiles(configDir, {
@@ -336,13 +275,7 @@ async function runInit(options, version) {
336
275
  composeYaml,
337
276
  postgresMode,
338
277
  dev: Boolean(options.dev),
339
- });
340
- await writeGlobalConfig({
341
- configDir,
342
- dockerDir,
343
- dev: Boolean(options.dev),
344
- postgresMode,
345
- repoRoot,
278
+ version,
346
279
  });
347
280
  p.log.success(`Config written to ${configDir}`);
348
281
  p.log.warn("Your generated .env file contains secrets — do not commit it to version control.");
@@ -358,10 +291,10 @@ async function runInit(options, version) {
358
291
  await doStart(configDir);
359
292
  }
360
293
  else {
361
- p.log.info("You can start later with `elmo start`.");
294
+ p.log.info("You can start later with `elmo compose up -d`.");
362
295
  }
363
296
  // CLI telemetry — silently dropped if the user opted out above.
364
- await trackCliEvent("cli_init", {
297
+ await trackCliEvent(configDir, "cli_init", {
365
298
  version,
366
299
  os: process.platform,
367
300
  arch: process.arch,
@@ -375,7 +308,7 @@ async function runInit(options, version) {
375
308
  // Newsletter signup is a separate, explicit opt-in and runs even when
376
309
  // telemetry is disabled.
377
310
  if (email) {
378
- await submitNewsletterSignup(email);
311
+ await submitNewsletterSignup(configDir, email);
379
312
  }
380
313
  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
314
  p.outro(pc.green("Setup complete!"));
@@ -460,7 +393,7 @@ async function configureProvidersRecommended(env) {
460
393
  initialValue: "openrouter",
461
394
  });
462
395
  assertNotCancelled(direct);
463
- await collectDirectApiQuick(direct, env, targets);
396
+ await collectDirectApiQuick(direct, env);
464
397
  await finalizeScrapeTargets(env, targets, { skipEdit: true });
465
398
  }
466
399
  async function configureProvidersCustom(env) {
@@ -506,7 +439,7 @@ async function collectScraperKey(scraper, env) {
506
439
  env.OLOSTEP_API_KEY = key;
507
440
  }
508
441
  }
509
- async function collectDirectApiQuick(kind, env, targets) {
442
+ async function collectDirectApiQuick(kind, env) {
510
443
  if (kind === "openrouter") {
511
444
  const key = await p.password({
512
445
  message: "OpenRouter API key",
@@ -514,7 +447,6 @@ async function collectDirectApiQuick(kind, env, targets) {
514
447
  });
515
448
  assertNotCancelled(key);
516
449
  env.OPENROUTER_API_KEY = key;
517
- targets.push(`claude:openrouter:${DEFAULT_OPENROUTER_MODEL}:online`);
518
450
  }
519
451
  else if (kind === "anthropic") {
520
452
  const key = await p.password({
@@ -523,7 +455,6 @@ async function collectDirectApiQuick(kind, env, targets) {
523
455
  });
524
456
  assertNotCancelled(key);
525
457
  env.ANTHROPIC_API_KEY = key;
526
- targets.push(`claude:anthropic-api:${DEFAULT_ANTHROPIC_MODEL}:online`);
527
458
  }
528
459
  else if (kind === "openai") {
529
460
  const key = await p.password({
@@ -532,7 +463,6 @@ async function collectDirectApiQuick(kind, env, targets) {
532
463
  });
533
464
  assertNotCancelled(key);
534
465
  env.OPENAI_API_KEY = key;
535
- targets.push(`chatgpt:openai-api:${DEFAULT_OPENAI_MODEL}:online`);
536
466
  }
537
467
  else {
538
468
  const key = await p.password({
@@ -541,7 +471,6 @@ async function collectDirectApiQuick(kind, env, targets) {
541
471
  });
542
472
  assertNotCancelled(key);
543
473
  env.MISTRAL_API_KEY = key;
544
- targets.push(`mistral:mistral-api:${DEFAULT_MISTRAL_MODEL}:online`);
545
474
  }
546
475
  }
547
476
  async function collectBrightData(env, targets) {
@@ -797,51 +726,7 @@ function dedupeTargets(targets) {
797
726
  }
798
727
  return out.join(",");
799
728
  }
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
- }
729
+ // ── Start helper (used by init) ──────────────────────────────────────────────
845
730
  async function doStart(configDir) {
846
731
  assertDockerRunning();
847
732
  log.step("Starting Docker Compose stack...");
@@ -857,17 +742,10 @@ async function doStart(configDir) {
857
742
  p.log.warn("Some services did not report healthy status.");
858
743
  }
859
744
  log.info("Examples:");
860
- console.log(` ${pc.bold("elmo logs -f")}`);
745
+ console.log(` ${pc.bold("elmo compose logs -f")}`);
861
746
  console.log(` ${pc.bold("elmo compose logs -f web")}`);
862
747
  console.log(` ${pc.bold("elmo compose ps")}`);
863
- }
864
- // ── Command: stop ────────────────────────────────────────────────────────────
865
- async function runStop(options) {
866
- const configDir = await resolveConfigDir(options.dir);
867
- assertDockerRunning();
868
- log.step("Stopping Docker Compose stack...");
869
- await runDockerCompose(configDir, ["down"]);
870
- log.success("Stack stopped.");
748
+ console.log(` ${pc.bold("elmo compose down")}`);
871
749
  }
872
750
  // ── Command: status ──────────────────────────────────────────────────────────
873
751
  async function runStatus(options) {
@@ -899,29 +777,37 @@ async function runCompose(args, options) {
899
777
  assertDockerRunning();
900
778
  await runDockerCompose(configDir, args);
901
779
  }
902
- // ── Command: build ───────────────────────────────────────────────────────────
903
- async function runBuild(options) {
780
+ // ── Command: edit ────────────────────────────────────────────────────────────
781
+ async function runEdit(target, options) {
904
782
  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.`);
922
- }
923
- log.success("All images built.");
924
- console.log(` Run ${pc.bold("elmo start")} to start the stack.`);
783
+ let filePath;
784
+ if (target === "env") {
785
+ filePath = path.join(configDir, ".env");
786
+ }
787
+ else if (target === "compose") {
788
+ filePath = path.join(configDir, "elmo.yaml");
789
+ }
790
+ else {
791
+ throw new Error(`Unknown edit target: ${target}. Use \`env\` or \`compose\`.`);
792
+ }
793
+ if (!(await fileExists(filePath))) {
794
+ throw new Error(`File not found: ${filePath}`);
795
+ }
796
+ const editorEnv = process.env.VISUAL || process.env.EDITOR || "nano";
797
+ const parts = editorEnv.split(/\s+/).filter(Boolean);
798
+ const cmd = parts[0] ?? "nano";
799
+ const args = [...parts.slice(1), filePath];
800
+ await new Promise((resolve, reject) => {
801
+ const child = spawn(cmd, args, { stdio: "inherit" });
802
+ child.on("close", (code) => {
803
+ if (code === 0)
804
+ resolve();
805
+ else
806
+ reject(new Error(`${cmd} exited with code ${code}`));
807
+ });
808
+ child.on("error", (err) => reject(err));
809
+ });
810
+ log.info("Restart the stack with `elmo compose up -d` to apply changes.");
925
811
  }
926
812
  // ── Compose YAML Builder ─────────────────────────────────────────────────────
927
813
  function buildComposeYaml(options) {
@@ -953,6 +839,7 @@ function buildComposeYaml(options) {
953
839
  dependencyConditions,
954
840
  repoRoot: options.repoRoot,
955
841
  dockerfilePath,
842
+ port: options.port,
956
843
  }));
957
844
  services.push(buildWorkerService({
958
845
  dev: options.dev,
@@ -961,7 +848,7 @@ function buildComposeYaml(options) {
961
848
  repoRoot: options.repoRoot,
962
849
  dockerfilePath,
963
850
  }));
964
- const lines = ["name: elmo", "", "services:"];
851
+ const lines = [renderedByHeader(options.version), "", "name: elmo", "", "services:"];
965
852
  lines.push(...services.map((service) => indentBlock(service, 2)));
966
853
  if (volumes.size > 0) {
967
854
  lines.push("", "volumes:");
@@ -1010,7 +897,7 @@ function buildWebService(options) {
1010
897
  else {
1011
898
  lines.push(" image: elmohq/elmo-web:latest");
1012
899
  }
1013
- lines.push(" env_file:", " - path: .env", " required: true", " ports:", ' - "1515:3000"');
900
+ lines.push(" env_file:", " - path: .env", " required: true", " ports:", ` - "${options.port}:3000"`);
1014
901
  if (options.dependsOn.length > 0) {
1015
902
  lines.push(" depends_on:");
1016
903
  for (const service of options.dependsOn) {
@@ -1196,52 +1083,32 @@ async function resolveDockerDirAuto(cwd, explicitDir) {
1196
1083
  }
1197
1084
  // ── Config Dir Resolution ────────────────────────────────────────────────────
1198
1085
  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);
1086
+ const resolved = explicitDir ? path.resolve(process.cwd(), explicitDir) : CONFIG_HOME;
1212
1087
  const composePath = path.join(resolved, "elmo.yaml");
1213
1088
  if (!(await fileExists(composePath))) {
1214
- throw new Error(`Config directory does not contain elmo.yaml: ${resolved}\nRun \`elmo init\` to regenerate.`);
1089
+ if (explicitDir) {
1090
+ throw new Error(`Config directory does not contain elmo.yaml: ${resolved}\nRun \`elmo init --dir ${explicitDir}\` to create it.`);
1091
+ }
1092
+ throw new Error(`No config found at ${resolved}. Run \`elmo init\` to create one, or specify --dir.`);
1215
1093
  }
1216
1094
  return resolved;
1217
1095
  }
1218
1096
  // ── 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
1097
  async function writeConfigFiles(configDir, initConfig) {
1237
1098
  const envPath = path.join(configDir, ".env");
1238
1099
  const composePath = path.join(configDir, "elmo.yaml");
1239
1100
  await ensureDir(configDir);
1240
- await fs.writeFile(envPath, buildEnvFile(initConfig.env), "utf8");
1101
+ await fs.writeFile(envPath, buildEnvFile(initConfig.env, initConfig.version), "utf8");
1241
1102
  await fs.writeFile(composePath, initConfig.composeYaml, "utf8");
1242
1103
  }
1243
- function buildEnvFile(env) {
1244
- const lines = ["# Generated by elmo init", "# WARNING: contains secrets. Do not commit.", ""];
1104
+ function renderedByHeader(version) {
1105
+ return [
1106
+ `# Rendered by elmo ${version} on ${new Date().toISOString()}`,
1107
+ "# Re-run `elmo init` after upgrading the CLI to refresh this file.",
1108
+ ].join("\n");
1109
+ }
1110
+ function buildEnvFile(env, version) {
1111
+ const lines = [renderedByHeader(version), "# WARNING: contains secrets. Do not commit.", ""];
1245
1112
  for (const [key, rawValue] of Object.entries(env)) {
1246
1113
  if (rawValue === undefined) {
1247
1114
  continue;
@@ -1260,68 +1127,6 @@ function formatEnvValue(value) {
1260
1127
  }
1261
1128
  return value;
1262
1129
  }
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
1130
  async function fileExists(target) {
1326
1131
  try {
1327
1132
  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.10",
4
+ "license": "MIT",
5
5
  "keywords": [
6
6
  "marketing",
7
7
  "typescript",
@@ -30,6 +30,7 @@
30
30
  "dependencies": {
31
31
  "@clack/prompts": "^1.3.0",
32
32
  "commander": "^14.0.3",
33
+ "dotenv": "16.6.1",
33
34
  "picocolors": "^1.1.1",
34
35
  "posthog-node": "^5.33.0",
35
36
  "semver": "^7.7.4"