@elmohq/cli 0.2.3 → 0.2.5

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/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Blue Whale Software, LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ <p align="center">
2
+ <a href="https://github.com/elmohq/elmo">
3
+ <img src="https://raw.githubusercontent.com/elmohq/elmo/main/apps/www/public/brand/logos/elmo-logo-xl.png" alt="Elmo" width="300">
4
+ </a>
5
+ </p>
6
+
7
+ <p align="center">
8
+ The official CLI for <a href="https://www.elmohq.com/">Elmo</a> — open source AI visibility tracking and optimization.
9
+ <br />
10
+ <br />
11
+ <a href="https://www.elmohq.com/docs"><strong>Read the docs »</strong></a>
12
+ </p>
13
+
14
+ <br />
15
+
16
+ <p align="center">
17
+ <a href="https://www.npmjs.com/package/@elmohq/cli"><img src="https://img.shields.io/npm/v/@elmohq/cli?color=2563eb&label=npm" alt="npm version"></a>&nbsp;
18
+ <a href="https://www.elmohq.com/docs"><img src="https://img.shields.io/badge/Docs-2563eb?style=flat&logo=readthedocs&logoColor=white" alt="Docs"></a>&nbsp;
19
+ <a href="https://demo.elmohq.com"><img src="https://img.shields.io/badge/Demo-22c55e?style=flat&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xNSAxNGMuMi0xIC43LTEuNyAxLjUtMi41IDEtLjkgMS41LTIuMiAxLjUtMy41QTYgNiAwIDAgMCA2IDhjMCAxIC4yIDIuMiAxLjUgMy41LjcuNyAxLjMgMS41IDEuNSAyLjUiLz48cGF0aCBkPSJNOSAxOGg2Ii8+PHBhdGggZD0iTTEwIDIyaDQiLz48L3N2Zz4%3D" alt="Demo"></a>&nbsp;
20
+ <a href="https://github.com/elmohq/elmo"><img src="https://img.shields.io/github/stars/elmohq/elmo?style=flat&logo=github&color=ee964b&label=Star" alt="GitHub stars"></a>&nbsp;
21
+ <a href="https://discord.gg/s24nubCtKz"><img src="https://img.shields.io/badge/Discord-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
22
+ </p>
23
+
24
+ <br />
25
+
26
+ ## What is Elmo?
27
+
28
+ [Elmo](https://www.elmohq.com/) is an open source platform for tracking and optimizing how your brand shows up in AI assistants like ChatGPT, Claude, Gemini, and Perplexity. Define the prompts your customers ask, run them across providers on a schedule, and get visibility into mentions, sentiment, citations, and competitor positioning over time.
29
+
30
+ `@elmohq/cli` is the fastest way to run Elmo on your own infrastructure. It generates a Docker Compose stack, manages secrets and configuration, and gives you a single command to start, stop, and operate your instance.
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ npm install -g @elmohq/cli
36
+ ```
37
+
38
+ Requires [Docker](https://docs.docker.com/get-docker/) and Docker Compose.
39
+
40
+ ## Quick Start
41
+
42
+ ```bash
43
+ # 1. Walk through the interactive setup wizard
44
+ elmo init
45
+
46
+ # 2. Start the stack
47
+ elmo start
48
+
49
+ # 3. Open the app at http://localhost:1515
50
+ ```
51
+
52
+ `elmo init` will prompt you for a few things (database, AI provider credentials), generate `elmo.yaml` and `.env`, and optionally start the stack for you.
53
+
54
+ For the full self-hosting walkthrough, see the [Elmo docs](https://www.elmohq.com/docs).
55
+
56
+ ## Commands
57
+
58
+ | Command | Description |
59
+ | --- | --- |
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) |
68
+
69
+ Run `elmo --help` or `elmo <command> --help` for the full list of flags.
70
+
71
+ ### Useful flags
72
+
73
+ - `--dir <path>` — point any command at a specific config directory (defaults to `~/.config/elmo`).
74
+ - `elmo init --dev` — build images from a local checkout of the repo instead of pulling from the registry.
75
+
76
+ ## Telemetry
77
+
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:
79
+
80
+ ```bash
81
+ export DISABLE_TELEMETRY=1
82
+ ```
83
+
84
+ ## Star, contribute, and chat
85
+
86
+ Elmo is built in the open and we'd love your help.
87
+
88
+ - ⭐ **[Star us on GitHub](https://github.com/elmohq/elmo)** — it genuinely helps more people find the project.
89
+ - 💬 **[Join the Discord](https://discord.gg/s24nubCtKz)** — get help, share what you're building, and talk to the team.
90
+ - 🐛 **[File an issue](https://github.com/elmohq/elmo/issues)** if something breaks or you have a feature request.
package/dist/index.js CHANGED
@@ -1,24 +1,23 @@
1
1
  #!/usr/bin/env node
2
- import { Command } from "commander";
3
- import pc from "picocolors";
4
- import * as p from "@clack/prompts";
5
2
  import { spawn, spawnSync } from "node:child_process";
3
+ import crypto from "node:crypto";
6
4
  import fs from "node:fs/promises";
7
- import path from "node:path";
8
5
  import os from "node:os";
9
- import crypto from "node:crypto";
10
- import semver from "semver";
6
+ import path from "node:path";
11
7
  import { fileURLToPath } from "node:url";
12
- import { trackCliEvent } from "./telemetry.js";
8
+ import * as p from "@clack/prompts";
9
+ import { Command } from "commander";
10
+ import pc from "picocolors";
11
+ import semver from "semver";
12
+ import { getTelemetryStatus, setTelemetryEnabled, submitNewsletterSignup, trackCliEvent } from "./telemetry.js";
13
13
  // ── Constants ────────────────────────────────────────────────────────────────
14
14
  const CONFIG_HOME = path.join(os.homedir(), ".config", "elmo");
15
15
  const CONFIG_FILE = path.join(CONFIG_HOME, "config.json");
16
- const DEFAULT_ORG_ID = "default";
17
- const DEFAULT_ORG_NAME = "Default Organization";
18
16
  const DEFAULT_APP_NAME = "Elmo";
19
17
  const DEFAULT_APP_ICON = "/icons/elmo-icon.svg";
20
18
  const DEFAULT_APP_URL = "http://localhost:1515";
21
19
  const LOCAL_DATABASE_URL = "postgres://postgres:postgres@postgres:5432/elmo";
20
+ const TELEMETRY_DOC_URL = "https://elmohq.com/docs/developer-guide/telemetry";
22
21
  // ── Banner ───────────────────────────────────────────────────────────────────
23
22
  const ELMO_ASCII = [
24
23
  "",
@@ -134,8 +133,54 @@ async function main() {
134
133
  .action(async (options) => {
135
134
  await withVersionCheck(version, () => runBuild(options));
136
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
+ }
165
+ });
137
166
  await program.parseAsync(process.argv);
138
167
  }
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
+ }
139
184
  async function withVersionCheck(version, fn) {
140
185
  const notifyPromise = maybeNotifyNewVersion(version);
141
186
  await fn();
@@ -219,9 +264,8 @@ async function runInit(options, version) {
219
264
  const env = {};
220
265
  env.DEPLOYMENT_MODE = "local";
221
266
  env.VITE_DEPLOYMENT_MODE = "local";
267
+ env.DEPLOYMENT_ID = crypto.randomUUID();
222
268
  env.BETTER_AUTH_SECRET = generateSecret();
223
- env.DEFAULT_ORG_ID = DEFAULT_ORG_ID;
224
- env.DEFAULT_ORG_NAME = DEFAULT_ORG_NAME;
225
269
  env.APP_NAME = DEFAULT_APP_NAME;
226
270
  env.APP_ICON = DEFAULT_APP_ICON;
227
271
  env.APP_URL = DEFAULT_APP_URL;
@@ -241,7 +285,38 @@ async function runInit(options, version) {
241
285
  env.DATABASE_URL = LOCAL_DATABASE_URL;
242
286
  }
243
287
  // ── AI providers ─────────────────────────────────────────────────────
244
- await configureProvidersInteractive(env);
288
+ const setupMode = await configureProvidersInteractive(env);
289
+ // ── Telemetry ───────────────────────────────────────────────────────
290
+ p.note([
291
+ "Elmo is open source and maintained by a small team. Telemetry",
292
+ "from both the CLI and your local deployment (web + worker)",
293
+ "tells us things like which CLI versions are still in use, where",
294
+ "`elmo init` drops off, which providers people pick, and whether",
295
+ "new features actually get used. Without it we are flying blind",
296
+ "on what to fix or build next.",
297
+ "",
298
+ pc.bold("What we send:"),
299
+ " • install ID (random UUID stored in ~/.config/elmo/config.json)",
300
+ " • CLI/app version, OS, arch, Node version, deployment mode",
301
+ " • command/event names + non-secret options (e.g. postgres mode)",
302
+ " • feature counts (prompts edited, brands created — never the names or text)",
303
+ " • IP address (recorded on each event by PostHog, used for geolocation)",
304
+ "",
305
+ pc.bold("What we never send:"),
306
+ " API keys, .env contents, brand names, prompt text, and scraped responses.",
307
+ "",
308
+ `Full breakdown: ${link(pc.cyan(TELEMETRY_DOC_URL), TELEMETRY_DOC_URL)}`,
309
+ "Toggle later with `elmo telemetry enable|disable`.",
310
+ ].join("\n"), "Telemetry");
311
+ const telemetryEnabled = await p.confirm({
312
+ message: "Share telemetry?",
313
+ initialValue: true,
314
+ });
315
+ assertNotCancelled(telemetryEnabled);
316
+ await setTelemetryEnabled(telemetryEnabled);
317
+ if (!telemetryEnabled) {
318
+ env.DISABLE_TELEMETRY = "1";
319
+ }
245
320
  // ── Product updates ─────────────────────────────────────────────────
246
321
  const updatesEmail = await p.text({
247
322
  message: "Enter your work email to receive product updates (optional)",
@@ -285,7 +360,7 @@ async function runInit(options, version) {
285
360
  else {
286
361
  p.log.info("You can start later with `elmo start`.");
287
362
  }
288
- // Fire telemetry in the background — never blocks the CLI
363
+ // CLI telemetry — silently dropped if the user opted out above.
289
364
  await trackCliEvent("cli_init", {
290
365
  version,
291
366
  os: process.platform,
@@ -293,23 +368,24 @@ async function runInit(options, version) {
293
368
  node_version: process.version,
294
369
  postgres_mode: postgresMode,
295
370
  dev_mode: Boolean(options.dev),
296
- }, email ? { $email: email, wants_updates: true } : undefined);
371
+ setup_mode: setupMode,
372
+ has_scraper: Boolean(env.BRIGHTDATA_API_TOKEN || env.OLOSTEP_API_KEY),
373
+ has_direct_api: hasDirectApiConfigured(env),
374
+ });
375
+ // Newsletter signup is a separate, explicit opt-in and runs even when
376
+ // telemetry is disabled.
377
+ if (email) {
378
+ await submitNewsletterSignup(email);
379
+ }
297
380
  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")}`);
298
381
  p.outro(pc.green("Setup complete!"));
299
382
  }
300
383
  // ── Provider Configuration ───────────────────────────────────────────────────
301
384
  const BRIGHTDATA_AFFILIATE = "https://get.brightdata.com/67h1b7h0shcn";
302
385
  const OLOSTEP_AFFILIATE = "https://olostep.com/?ref=elmo";
303
- const PROVIDERS_DOC_URL = "https://docs.elmohq.com/docs/deployment/providers";
386
+ const PROVIDERS_DOC_URL = "https://docs.elmohq.com/docs/user-guide/providers";
304
387
  // Surfaces each scraper can track — the first two are the "recommended starter" set.
305
- const BRIGHTDATA_MODELS = [
306
- "chatgpt",
307
- "google-ai-mode",
308
- "perplexity",
309
- "copilot",
310
- "gemini",
311
- "grok",
312
- ];
388
+ const BRIGHTDATA_MODELS = ["chatgpt", "google-ai-mode", "perplexity", "copilot", "gemini", "grok"];
313
389
  const OLOSTEP_MODELS = [
314
390
  "chatgpt",
315
391
  "google-ai-mode",
@@ -323,44 +399,159 @@ const DEFAULT_SCRAPER_MODELS = ["chatgpt", "google-ai-mode"];
323
399
  const DEFAULT_OPENAI_MODEL = "gpt-5-mini";
324
400
  const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-20250514";
325
401
  const DEFAULT_OPENROUTER_MODEL = "anthropic/claude-sonnet-4.6";
402
+ const DEFAULT_MISTRAL_MODEL = "mistral-medium-latest";
326
403
  async function configureProvidersInteractive(env) {
327
404
  p.note([
328
- "Elmo tracks which AI answer engines mention your brand. Most of",
329
- "that traffic comes from ChatGPT and Google AI Mode — neither has",
330
- "a public API, so tracking them requires a scraper.",
405
+ "Elmo needs two kinds of providers:",
331
406
  "",
332
- pc.bold("Recommended scrapers:"),
333
- ` • ${pc.cyan("BrightData")} — cheap solid option, ~$0.45/mo per prompt`,
334
- ` • ${pc.cyan("Olostep")} — premium option, powers Peec/AirOps, ~$2.25/mo per prompt`,
407
+ pc.bold("1. A scraper") + " — to track ChatGPT and Google AI Mode (no public APIs):",
408
+ ` • ${pc.cyan("BrightData")} — cheap solid option, ~$0.45/mo per prompt`,
409
+ ` • ${pc.cyan("Olostep")} — premium option, powers Peec/AirOps, ~$2.25/mo per prompt`,
410
+ "",
411
+ pc.bold("2. A direct LLM API") + " — for low-latency tasks (onboarding analysis, sentiment scoring,",
412
+ " ad-hoc LLM calls). Required:",
413
+ ` • ${pc.cyan("OpenRouter")} — one key, all major models (recommended)`,
414
+ ` • ${pc.cyan("Anthropic / OpenAI / Mistral")} — direct provider keys`,
335
415
  "",
336
416
  "Pricing assumes Elmo's default cadence (5 runs/day × 2 surfaces).",
337
- "Configure any combination below — every target is opt-in.",
338
- ].join("\n"), "AI visibility providers");
339
- const targets = [];
340
- const brightDataTookDefaults = await collectBrightData(env, targets);
341
- if (brightDataTookDefaults) {
342
- await finalizeScrapeTargets(env, targets, { skipEdit: true });
343
- return;
417
+ ].join("\n"), "AI providers");
418
+ const mode = await p.select({
419
+ message: "Setup mode",
420
+ options: [
421
+ { value: "recommended", label: "Recommended — one scraper + one direct API (4 prompts)" },
422
+ { value: "custom", label: "Custom — pick each provider individually" },
423
+ ],
424
+ initialValue: "recommended",
425
+ });
426
+ assertNotCancelled(mode);
427
+ if (mode === "recommended") {
428
+ await configureProvidersRecommended(env);
344
429
  }
345
- const olostepTookDefaults = await collectOlostep(env, targets);
346
- if (olostepTookDefaults) {
347
- await finalizeScrapeTargets(env, targets, { skipEdit: true });
348
- return;
430
+ else {
431
+ await configureProvidersCustom(env);
432
+ }
433
+ return mode;
434
+ }
435
+ async function configureProvidersRecommended(env) {
436
+ const targets = [];
437
+ // ── Scraper ─────────────────────────────────────────────────────────────
438
+ const scraper = await p.select({
439
+ message: "Scraper (tracks ChatGPT + Google AI Mode)",
440
+ options: [
441
+ { value: "brightdata", label: "BrightData — ~$0.45/mo per prompt (cheaper)" },
442
+ { value: "olostep", label: "Olostep — ~$2.25/mo per prompt (premium)" },
443
+ ],
444
+ initialValue: "brightdata",
445
+ });
446
+ assertNotCancelled(scraper);
447
+ await collectScraperKey(scraper, env);
448
+ for (const model of DEFAULT_SCRAPER_MODELS) {
449
+ targets.push(`${model}:${scraper}:online`);
450
+ }
451
+ // ── Direct API ──────────────────────────────────────────────────────────
452
+ const direct = await p.select({
453
+ message: "Direct LLM API (powers onboarding analysis + sentiment scoring)",
454
+ options: [
455
+ { value: "openrouter", label: "OpenRouter — one key, all major models (recommended)" },
456
+ { value: "anthropic", label: "Anthropic — direct Claude" },
457
+ { value: "openai", label: "OpenAI — direct GPT-* models" },
458
+ { value: "mistral", label: "Mistral — direct Mistral models" },
459
+ ],
460
+ initialValue: "openrouter",
461
+ });
462
+ assertNotCancelled(direct);
463
+ await collectDirectApiQuick(direct, env, targets);
464
+ await finalizeScrapeTargets(env, targets, { skipEdit: true });
465
+ }
466
+ async function configureProvidersCustom(env) {
467
+ const targets = [];
468
+ p.log.step(pc.bold("Step 1 of 2 — Direct LLM API (at least one is required)"));
469
+ // Order matches the auto-pick preference in onboarding/llm.ts so the first
470
+ // provider asked is the one onboarding will reach for by default.
471
+ while (!hasDirectApiConfigured(env)) {
472
+ await collectOpenRouter(env, targets);
473
+ await collectAnthropic(env, targets);
474
+ await collectOpenAI(env, targets);
475
+ await collectMistral(env, targets);
476
+ if (!hasDirectApiConfigured(env)) {
477
+ p.log.warn("Onboarding analysis and other low-latency LLM tasks require a direct API. Configure at least one before continuing.");
478
+ }
349
479
  }
350
- await collectAnthropic(env, targets);
351
- await collectOpenAI(env, targets);
352
- await collectOpenRouter(env, targets);
480
+ p.log.step(pc.bold("Step 2 of 2 — Scrapers (optional, but needed to track ChatGPT / Google AI Mode)"));
481
+ await collectBrightData(env, targets);
482
+ await collectOlostep(env, targets);
353
483
  await collectDataForSEO(env, targets);
354
484
  await finalizeScrapeTargets(env, targets);
355
485
  }
486
+ function hasDirectApiConfigured(env) {
487
+ return Boolean(env.ANTHROPIC_API_KEY || env.OPENAI_API_KEY || env.MISTRAL_API_KEY || env.OPENROUTER_API_KEY);
488
+ }
489
+ async function collectScraperKey(scraper, env) {
490
+ if (scraper === "brightdata") {
491
+ p.log.info(`Sign up: ${link(pc.cyan(BRIGHTDATA_AFFILIATE), BRIGHTDATA_AFFILIATE)}`);
492
+ const key = await p.password({
493
+ message: "BrightData API token",
494
+ validate: (v) => (!v ? "Required" : undefined),
495
+ });
496
+ assertNotCancelled(key);
497
+ env.BRIGHTDATA_API_TOKEN = key;
498
+ }
499
+ else {
500
+ p.log.info(`Sign up: ${link(pc.cyan(OLOSTEP_AFFILIATE), OLOSTEP_AFFILIATE)}`);
501
+ const key = await p.password({
502
+ message: "Olostep API key",
503
+ validate: (v) => (!v ? "Required" : undefined),
504
+ });
505
+ assertNotCancelled(key);
506
+ env.OLOSTEP_API_KEY = key;
507
+ }
508
+ }
509
+ async function collectDirectApiQuick(kind, env, targets) {
510
+ if (kind === "openrouter") {
511
+ const key = await p.password({
512
+ message: "OpenRouter API key",
513
+ validate: (v) => (!v ? "Required" : undefined),
514
+ });
515
+ assertNotCancelled(key);
516
+ env.OPENROUTER_API_KEY = key;
517
+ targets.push(`claude:openrouter:${DEFAULT_OPENROUTER_MODEL}:online`);
518
+ }
519
+ else if (kind === "anthropic") {
520
+ const key = await p.password({
521
+ message: "Anthropic API key",
522
+ validate: (v) => (!v ? "Required" : undefined),
523
+ });
524
+ assertNotCancelled(key);
525
+ env.ANTHROPIC_API_KEY = key;
526
+ targets.push(`claude:anthropic-api:${DEFAULT_ANTHROPIC_MODEL}:online`);
527
+ }
528
+ else if (kind === "openai") {
529
+ const key = await p.password({
530
+ message: "OpenAI API key",
531
+ validate: (v) => (!v ? "Required" : undefined),
532
+ });
533
+ assertNotCancelled(key);
534
+ env.OPENAI_API_KEY = key;
535
+ targets.push(`chatgpt:openai-api:${DEFAULT_OPENAI_MODEL}:online`);
536
+ }
537
+ else {
538
+ const key = await p.password({
539
+ message: "Mistral API key",
540
+ validate: (v) => (!v ? "Required" : undefined),
541
+ });
542
+ assertNotCancelled(key);
543
+ env.MISTRAL_API_KEY = key;
544
+ targets.push(`mistral:mistral-api:${DEFAULT_MISTRAL_MODEL}:online`);
545
+ }
546
+ }
356
547
  async function collectBrightData(env, targets) {
357
548
  const enable = await p.confirm({
358
- message: `Configure ${pc.bold("BrightData")}? (recommended scraper — ~$0.45/mo per prompt)`,
549
+ message: `Configure ${pc.bold("BrightData")}? (~$0.45/mo per prompt)`,
359
550
  initialValue: true,
360
551
  });
361
552
  assertNotCancelled(enable);
362
553
  if (!enable)
363
- return false;
554
+ return;
364
555
  p.log.info(`Sign up and generate an API token: ${link(pc.cyan(BRIGHTDATA_AFFILIATE), BRIGHTDATA_AFFILIATE)}`);
365
556
  const key = await p.password({
366
557
  message: "BrightData API token",
@@ -368,7 +559,7 @@ async function collectBrightData(env, targets) {
368
559
  });
369
560
  assertNotCancelled(key);
370
561
  env.BRIGHTDATA_API_TOKEN = key;
371
- return await pickScraperTargets({
562
+ await pickScraperTargets({
372
563
  providerLabel: "BrightData",
373
564
  providerId: "brightdata",
374
565
  allModels: BRIGHTDATA_MODELS,
@@ -377,12 +568,12 @@ async function collectBrightData(env, targets) {
377
568
  }
378
569
  async function collectOlostep(env, targets) {
379
570
  const enable = await p.confirm({
380
- message: `Configure ${pc.bold("Olostep")}? (recommended scraper — ~$2.25/mo per prompt)`,
571
+ message: `Configure ${pc.bold("Olostep")}? (~$2.25/mo per prompt)`,
381
572
  initialValue: false,
382
573
  });
383
574
  assertNotCancelled(enable);
384
575
  if (!enable)
385
- return false;
576
+ return;
386
577
  p.log.info(`Grab an API key: ${link(pc.cyan(OLOSTEP_AFFILIATE), OLOSTEP_AFFILIATE)}`);
387
578
  const key = await p.password({
388
579
  message: "Olostep API key",
@@ -390,7 +581,7 @@ async function collectOlostep(env, targets) {
390
581
  });
391
582
  assertNotCancelled(key);
392
583
  env.OLOSTEP_API_KEY = key;
393
- return await pickScraperTargets({
584
+ await pickScraperTargets({
394
585
  providerLabel: "Olostep",
395
586
  providerId: "olostep",
396
587
  allModels: OLOSTEP_MODELS,
@@ -398,32 +589,16 @@ async function collectOlostep(env, targets) {
398
589
  });
399
590
  }
400
591
  async function pickScraperTargets(args) {
401
- const { providerLabel, providerId, allModels, targets } = args;
402
- const useDefault = await p.confirm({
403
- message: `Track ChatGPT and Google AI Mode via ${providerLabel}? (recommended)`,
404
- initialValue: true,
405
- });
406
- assertNotCancelled(useDefault);
407
- if (useDefault) {
408
- for (const model of DEFAULT_SCRAPER_MODELS) {
409
- targets.push(`${model}:${providerId}:online`);
410
- }
411
- return true;
412
- }
413
592
  const selected = (await p.multiselect({
414
- message: `Pick which surfaces to track via ${providerLabel}`,
415
- options: allModels.map((model) => ({
416
- value: model,
417
- label: model,
418
- })),
593
+ message: `Surfaces to track via ${args.providerLabel}`,
594
+ options: args.allModels.map((model) => ({ value: model, label: model })),
419
595
  required: true,
420
596
  initialValues: [...DEFAULT_SCRAPER_MODELS],
421
597
  }));
422
598
  assertNotCancelled(selected);
423
599
  for (const model of selected) {
424
- targets.push(`${model}:${providerId}:online`);
600
+ args.targets.push(`${model}:${args.providerId}:online`);
425
601
  }
426
- return false;
427
602
  }
428
603
  async function collectAnthropic(env, targets) {
429
604
  const enable = await p.confirm({
@@ -481,6 +656,34 @@ async function collectOpenAI(env, targets) {
481
656
  assertNotCancelled(webSearch);
482
657
  targets.push(webSearch ? `chatgpt:openai-api:${slug}:online` : `chatgpt:openai-api:${slug}`);
483
658
  }
659
+ async function collectMistral(env, targets) {
660
+ const enable = await p.confirm({
661
+ message: `Configure ${pc.bold("Mistral API")}? (direct Mistral models)`,
662
+ initialValue: false,
663
+ });
664
+ assertNotCancelled(enable);
665
+ if (!enable)
666
+ return;
667
+ const key = await p.password({
668
+ message: "Mistral API key",
669
+ validate: (v) => (!v ? "Required" : undefined),
670
+ });
671
+ assertNotCancelled(key);
672
+ env.MISTRAL_API_KEY = key;
673
+ const model = await p.text({
674
+ message: "Mistral model",
675
+ placeholder: DEFAULT_MISTRAL_MODEL,
676
+ defaultValue: DEFAULT_MISTRAL_MODEL,
677
+ });
678
+ assertNotCancelled(model);
679
+ const slug = model || DEFAULT_MISTRAL_MODEL;
680
+ const webSearch = await p.confirm({
681
+ message: "Enable Mistral's web search tool? (uses the beta Conversations API)",
682
+ initialValue: true,
683
+ });
684
+ assertNotCancelled(webSearch);
685
+ targets.push(webSearch ? `mistral:mistral-api:${slug}:online` : `mistral:mistral-api:${slug}`);
686
+ }
484
687
  async function collectOpenRouter(env, targets) {
485
688
  const enable = await p.confirm({
486
689
  message: `Configure ${pc.bold("OpenRouter")}? (one key, many hosted models)`,
@@ -813,8 +1016,7 @@ function buildWebService(options) {
813
1016
  if (options.dependsOn.length > 0) {
814
1017
  lines.push(" depends_on:");
815
1018
  for (const service of options.dependsOn) {
816
- const condition = options.dependencyConditions[service] ??
817
- "service_started";
1019
+ const condition = options.dependencyConditions[service] ?? "service_started";
818
1020
  lines.push(` ${service}:`, ` condition: ${condition}`);
819
1021
  }
820
1022
  }
@@ -832,8 +1034,7 @@ function buildWorkerService(options) {
832
1034
  if (options.dependsOn.length > 0) {
833
1035
  lines.push(" depends_on:");
834
1036
  for (const service of options.dependsOn) {
835
- const condition = options.dependencyConditions[service] ??
836
- "service_started";
1037
+ const condition = options.dependencyConditions[service] ?? "service_started";
837
1038
  lines.push(` ${service}:`, ` condition: ${condition}`);
838
1039
  }
839
1040
  }
@@ -848,11 +1049,7 @@ function indentBlock(block, spaces) {
848
1049
  }
849
1050
  // ── Docker Helpers ───────────────────────────────────────────────────────────
850
1051
  async function getComposeServices(configDir) {
851
- const output = await runDockerComposeCapture(configDir, [
852
- "ps",
853
- "--format",
854
- "json",
855
- ]);
1052
+ const output = await runDockerComposeCapture(configDir, ["ps", "--format", "json"]);
856
1053
  if (!output.trim()) {
857
1054
  return [];
858
1055
  }
@@ -889,8 +1086,7 @@ function formatServiceStatus(service) {
889
1086
  if (isServiceReady(service)) {
890
1087
  color = pc.green;
891
1088
  }
892
- else if (health === "starting" ||
893
- state.includes("starting")) {
1089
+ else if (health === "starting" || state.includes("starting")) {
894
1090
  color = pc.yellow;
895
1091
  }
896
1092
  return color(`${label} ${state} ${service.Health ? `(${health})` : ""}`.trim());
@@ -908,8 +1104,7 @@ async function waitForHealthy(configDir, timeoutMs) {
908
1104
  const start = Date.now();
909
1105
  while (Date.now() - start < timeoutMs) {
910
1106
  const services = await getComposeServices(configDir);
911
- if (services.length > 0 &&
912
- services.every(isServiceReady)) {
1107
+ if (services.length > 0 && services.every(isServiceReady)) {
913
1108
  return true;
914
1109
  }
915
1110
  await sleep(3000);
@@ -922,12 +1117,7 @@ function sleep(ms) {
922
1117
  function runDockerCompose(configDir, args) {
923
1118
  return new Promise((resolve, reject) => {
924
1119
  const composeFile = path.join(configDir, "elmo.yaml");
925
- const commandArgs = [
926
- "compose",
927
- "-f",
928
- composeFile,
929
- ...args,
930
- ];
1120
+ const commandArgs = ["compose", "-f", composeFile, ...args];
931
1121
  const child = spawn("docker", commandArgs, {
932
1122
  stdio: "inherit",
933
1123
  });
@@ -944,12 +1134,7 @@ function runDockerCompose(configDir, args) {
944
1134
  function runDockerComposeCapture(configDir, args) {
945
1135
  return new Promise((resolve, reject) => {
946
1136
  const composeFile = path.join(configDir, "elmo.yaml");
947
- const commandArgs = [
948
- "compose",
949
- "-f",
950
- composeFile,
951
- ...args,
952
- ];
1137
+ const commandArgs = ["compose", "-f", composeFile, ...args];
953
1138
  const child = spawn("docker", commandArgs);
954
1139
  let stdout = "";
955
1140
  let stderr = "";
@@ -964,8 +1149,7 @@ function runDockerComposeCapture(configDir, args) {
964
1149
  resolve(stdout);
965
1150
  }
966
1151
  else {
967
- reject(new Error(stderr ||
968
- `docker compose exited with code ${code}`));
1152
+ reject(new Error(stderr || `docker compose exited with code ${code}`));
969
1153
  }
970
1154
  });
971
1155
  });
@@ -982,11 +1166,7 @@ function assertDockerRunning() {
982
1166
  async function resolveDockerDirInteractive(cwd) {
983
1167
  const inCwd = await fileExists(path.join(cwd, "Dockerfile"));
984
1168
  const inDockerDir = await fileExists(path.join(cwd, "docker", "Dockerfile"));
985
- const defaultDir = inCwd
986
- ? "."
987
- : inDockerDir
988
- ? "docker"
989
- : ".";
1169
+ const defaultDir = inCwd ? "." : inDockerDir ? "docker" : ".";
990
1170
  const dir = await p.text({
991
1171
  message: "Path to docker directory (contains Dockerfile)",
992
1172
  defaultValue: defaultDir,
@@ -1063,11 +1243,7 @@ async function writeConfigFiles(configDir, initConfig) {
1063
1243
  await fs.writeFile(composePath, initConfig.composeYaml, "utf8");
1064
1244
  }
1065
1245
  function buildEnvFile(env) {
1066
- const lines = [
1067
- "# Generated by elmo init",
1068
- "# WARNING: contains secrets. Do not commit.",
1069
- "",
1070
- ];
1246
+ const lines = ["# Generated by elmo init", "# WARNING: contains secrets. Do not commit.", ""];
1071
1247
  for (const [key, rawValue] of Object.entries(env)) {
1072
1248
  if (rawValue === undefined) {
1073
1249
  continue;
@@ -1081,9 +1257,7 @@ function formatEnvValue(value) {
1081
1257
  return '""';
1082
1258
  }
1083
1259
  if (/[\s#"']/u.test(value)) {
1084
- const escaped = value
1085
- .replace(/\\/g, "\\\\")
1086
- .replace(/"/g, '\\"');
1260
+ const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
1087
1261
  return `"${escaped}"`;
1088
1262
  }
1089
1263
  return value;
@@ -1091,9 +1265,7 @@ function formatEnvValue(value) {
1091
1265
  async function detectSettingsFromConfig(configDir) {
1092
1266
  const envPath = path.join(configDir, ".env");
1093
1267
  const env = await readEnvFile(envPath);
1094
- const postgresMode = env.DATABASE_URL?.includes("postgres:5432")
1095
- ? "docker"
1096
- : "external";
1268
+ const postgresMode = env.DATABASE_URL?.includes("postgres:5432") ? "docker" : "external";
1097
1269
  const yamlPath = path.join(configDir, "elmo.yaml");
1098
1270
  let dev = false;
1099
1271
  let repoRoot = process.cwd();
@@ -1110,6 +1282,23 @@ async function detectSettingsFromConfig(configDir) {
1110
1282
  }
1111
1283
  return { dev, postgresMode, repoRoot };
1112
1284
  }
1285
+ async function updateDeploymentEnvTelemetry(enabled) {
1286
+ const config = await readGlobalConfig();
1287
+ if (!config?.configDir)
1288
+ return null;
1289
+ const envPath = path.join(config.configDir, ".env");
1290
+ if (!(await fileExists(envPath)))
1291
+ return null;
1292
+ const contents = await fs.readFile(envPath, "utf8");
1293
+ const lines = contents.split("\n");
1294
+ const filtered = lines.filter((line) => !/^\s*DISABLE_TELEMETRY\s*=/.test(line));
1295
+ if (!enabled) {
1296
+ const insertIdx = filtered.length > 0 && filtered[filtered.length - 1] === "" ? filtered.length - 1 : filtered.length;
1297
+ filtered.splice(insertIdx, 0, "DISABLE_TELEMETRY=1");
1298
+ }
1299
+ await fs.writeFile(envPath, filtered.join("\n"), "utf8");
1300
+ return envPath;
1301
+ }
1113
1302
  async function readEnvFile(envPath) {
1114
1303
  try {
1115
1304
  const contents = await fs.readFile(envPath, "utf8");
@@ -1124,8 +1313,7 @@ async function readEnvFile(envPath) {
1124
1313
  const key = trimmed.substring(0, eqIndex);
1125
1314
  let value = trimmed.substring(eqIndex + 1);
1126
1315
  // Remove surrounding quotes
1127
- if ((value.startsWith('"') && value.endsWith('"')) ||
1128
- (value.startsWith("'") && value.endsWith("'"))) {
1316
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
1129
1317
  value = value.slice(1, -1);
1130
1318
  }
1131
1319
  env[key] = value;
@@ -1166,8 +1354,7 @@ async function maybeNotifyNewVersion(currentVersion) {
1166
1354
  if (!data.version) {
1167
1355
  return;
1168
1356
  }
1169
- if (semver.valid(currentVersion) &&
1170
- semver.lt(currentVersion, data.version)) {
1357
+ if (semver.valid(currentVersion) && semver.lt(currentVersion, data.version)) {
1171
1358
  log.warn(`New CLI version available (${data.version}). Run: npm install -g @elmohq/cli@latest`);
1172
1359
  }
1173
1360
  }
package/dist/telemetry.js CHANGED
@@ -1,15 +1,12 @@
1
- import { PostHog } from "posthog-node";
1
+ import crypto from "node:crypto";
2
2
  import fs from "node:fs/promises";
3
- import path from "node:path";
4
3
  import os from "node:os";
5
- import crypto from "node:crypto";
4
+ import path from "node:path";
5
+ import { PostHog } from "posthog-node";
6
6
  const POSTHOG_PUBLIC_KEY = "phc_Jhx9LnI9cTDFHpQmpOzJSDTW127qD9pFU65KRnYym6z";
7
7
  const POSTHOG_HOST = "https://us.i.posthog.com";
8
8
  const CONFIG_HOME = path.join(os.homedir(), ".config", "elmo");
9
9
  const CONFIG_FILE = path.join(CONFIG_HOME, "config.json");
10
- function isTelemetryDisabled() {
11
- return Boolean(process.env.DISABLE_TELEMETRY);
12
- }
13
10
  async function readConfig() {
14
11
  try {
15
12
  const contents = await fs.readFile(CONFIG_FILE, "utf8");
@@ -23,6 +20,35 @@ async function writeConfig(config) {
23
20
  await fs.mkdir(CONFIG_HOME, { recursive: true });
24
21
  await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), "utf8");
25
22
  }
23
+ function envDisabled() {
24
+ return Boolean(process.env.DISABLE_TELEMETRY);
25
+ }
26
+ export async function isTelemetryDisabled() {
27
+ if (envDisabled())
28
+ return true;
29
+ const config = await readConfig();
30
+ return config.telemetryDisabled === true;
31
+ }
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
+ }
26
52
  async function getOrCreateDistinctId() {
27
53
  const config = await readConfig();
28
54
  if (config.telemetryId)
@@ -32,7 +58,7 @@ async function getOrCreateDistinctId() {
32
58
  return id;
33
59
  }
34
60
  export async function trackCliEvent(eventName, properties, personProperties) {
35
- if (isTelemetryDisabled())
61
+ if (await isTelemetryDisabled())
36
62
  return;
37
63
  try {
38
64
  const distinctId = await getOrCreateDistinctId();
@@ -49,3 +75,26 @@ export async function trackCliEvent(eventName, properties, personProperties) {
49
75
  // Telemetry should never block the CLI
50
76
  }
51
77
  }
78
+ // Newsletter signup is an explicit user action with clear intent, so it
79
+ // 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) {
85
+ try {
86
+ const disabled = await isTelemetryDisabled();
87
+ const distinctId = disabled ? email : await getOrCreateDistinctId();
88
+ const client = new PostHog(POSTHOG_PUBLIC_KEY, { host: POSTHOG_HOST });
89
+ client.capture({
90
+ distinctId,
91
+ event: "newsletter_signup",
92
+ properties: { source: "cli_init" },
93
+ ...{ $set: { $email: email, wants_updates: true } },
94
+ });
95
+ await client.shutdown();
96
+ }
97
+ catch {
98
+ // Newsletter signup should never block the CLI
99
+ }
100
+ }
package/package.json CHANGED
@@ -1,6 +1,24 @@
1
1
  {
2
2
  "name": "@elmohq/cli",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
+ "license": "AGPL-3.0-or-later",
5
+ "keywords": [
6
+ "marketing",
7
+ "typescript",
8
+ "tools",
9
+ "aio",
10
+ "seo",
11
+ "geo",
12
+ "gemini",
13
+ "copilot",
14
+ "grok",
15
+ "aso",
16
+ "perplexity",
17
+ "aeo",
18
+ "llmo",
19
+ "chatgpt",
20
+ "ai-visibility"
21
+ ],
4
22
  "bin": {
5
23
  "elmo": "dist/index.js"
6
24
  },
@@ -13,7 +31,7 @@
13
31
  "@clack/prompts": "^1.2.0",
14
32
  "commander": "^14.0.3",
15
33
  "picocolors": "^1.1.1",
16
- "posthog-node": "^5.29.2",
34
+ "posthog-node": "^5.30.4",
17
35
  "semver": "^7.7.4"
18
36
  },
19
37
  "devDependencies": {