@elmohq/cli 0.2.4 → 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 CHANGED
@@ -16,6 +16,7 @@
16
16
  <p align="center">
17
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
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;
19
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;
20
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>
21
22
  </p>
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import * as p from "@clack/prompts";
9
9
  import { Command } from "commander";
10
10
  import pc from "picocolors";
11
11
  import semver from "semver";
12
- import { trackCliEvent } from "./telemetry.js";
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");
@@ -17,6 +17,7 @@ const DEFAULT_APP_NAME = "Elmo";
17
17
  const DEFAULT_APP_ICON = "/icons/elmo-icon.svg";
18
18
  const DEFAULT_APP_URL = "http://localhost:1515";
19
19
  const LOCAL_DATABASE_URL = "postgres://postgres:postgres@postgres:5432/elmo";
20
+ const TELEMETRY_DOC_URL = "https://elmohq.com/docs/developer-guide/telemetry";
20
21
  // ── Banner ───────────────────────────────────────────────────────────────────
21
22
  const ELMO_ASCII = [
22
23
  "",
@@ -132,8 +133,54 @@ async function main() {
132
133
  .action(async (options) => {
133
134
  await withVersionCheck(version, () => runBuild(options));
134
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
+ });
135
166
  await program.parseAsync(process.argv);
136
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
+ }
137
184
  async function withVersionCheck(version, fn) {
138
185
  const notifyPromise = maybeNotifyNewVersion(version);
139
186
  await fn();
@@ -217,6 +264,7 @@ async function runInit(options, version) {
217
264
  const env = {};
218
265
  env.DEPLOYMENT_MODE = "local";
219
266
  env.VITE_DEPLOYMENT_MODE = "local";
267
+ env.DEPLOYMENT_ID = crypto.randomUUID();
220
268
  env.BETTER_AUTH_SECRET = generateSecret();
221
269
  env.APP_NAME = DEFAULT_APP_NAME;
222
270
  env.APP_ICON = DEFAULT_APP_ICON;
@@ -237,7 +285,38 @@ async function runInit(options, version) {
237
285
  env.DATABASE_URL = LOCAL_DATABASE_URL;
238
286
  }
239
287
  // ── AI providers ─────────────────────────────────────────────────────
240
- 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
+ }
241
320
  // ── Product updates ─────────────────────────────────────────────────
242
321
  const updatesEmail = await p.text({
243
322
  message: "Enter your work email to receive product updates (optional)",
@@ -281,7 +360,7 @@ async function runInit(options, version) {
281
360
  else {
282
361
  p.log.info("You can start later with `elmo start`.");
283
362
  }
284
- // Fire telemetry in the background — never blocks the CLI
363
+ // CLI telemetry — silently dropped if the user opted out above.
285
364
  await trackCliEvent("cli_init", {
286
365
  version,
287
366
  os: process.platform,
@@ -289,14 +368,22 @@ async function runInit(options, version) {
289
368
  node_version: process.version,
290
369
  postgres_mode: postgresMode,
291
370
  dev_mode: Boolean(options.dev),
292
- }, 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
+ }
293
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")}`);
294
381
  p.outro(pc.green("Setup complete!"));
295
382
  }
296
383
  // ── Provider Configuration ───────────────────────────────────────────────────
297
384
  const BRIGHTDATA_AFFILIATE = "https://get.brightdata.com/67h1b7h0shcn";
298
385
  const OLOSTEP_AFFILIATE = "https://olostep.com/?ref=elmo";
299
- const PROVIDERS_DOC_URL = "https://docs.elmohq.com/docs/deployment/providers";
386
+ const PROVIDERS_DOC_URL = "https://docs.elmohq.com/docs/user-guide/providers";
300
387
  // Surfaces each scraper can track — the first two are the "recommended starter" set.
301
388
  const BRIGHTDATA_MODELS = ["chatgpt", "google-ai-mode", "perplexity", "copilot", "gemini", "grok"];
302
389
  const OLOSTEP_MODELS = [
@@ -312,44 +399,159 @@ const DEFAULT_SCRAPER_MODELS = ["chatgpt", "google-ai-mode"];
312
399
  const DEFAULT_OPENAI_MODEL = "gpt-5-mini";
313
400
  const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-20250514";
314
401
  const DEFAULT_OPENROUTER_MODEL = "anthropic/claude-sonnet-4.6";
402
+ const DEFAULT_MISTRAL_MODEL = "mistral-medium-latest";
315
403
  async function configureProvidersInteractive(env) {
316
404
  p.note([
317
- "Elmo tracks which AI answer engines mention your brand. Most of",
318
- "that traffic comes from ChatGPT and Google AI Mode — neither has",
319
- "a public API, so tracking them requires a scraper.",
405
+ "Elmo needs two kinds of providers:",
406
+ "",
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`,
320
410
  "",
321
- pc.bold("Recommended scrapers:"),
322
- ` • ${pc.cyan("BrightData")} — cheap solid option, ~$0.45/mo per prompt`,
323
- ` • ${pc.cyan("Olostep")} — premium option, powers Peec/AirOps, ~$2.25/mo per prompt`,
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`,
324
415
  "",
325
416
  "Pricing assumes Elmo's default cadence (5 runs/day × 2 surfaces).",
326
- "Configure any combination below — every target is opt-in.",
327
- ].join("\n"), "AI visibility providers");
328
- const targets = [];
329
- const brightDataTookDefaults = await collectBrightData(env, targets);
330
- if (brightDataTookDefaults) {
331
- await finalizeScrapeTargets(env, targets, { skipEdit: true });
332
- 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);
333
429
  }
334
- const olostepTookDefaults = await collectOlostep(env, targets);
335
- if (olostepTookDefaults) {
336
- await finalizeScrapeTargets(env, targets, { skipEdit: true });
337
- return;
430
+ else {
431
+ await configureProvidersCustom(env);
338
432
  }
339
- await collectAnthropic(env, targets);
340
- await collectOpenAI(env, targets);
341
- await collectOpenRouter(env, targets);
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
+ }
479
+ }
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);
342
483
  await collectDataForSEO(env, targets);
343
484
  await finalizeScrapeTargets(env, targets);
344
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
+ }
345
547
  async function collectBrightData(env, targets) {
346
548
  const enable = await p.confirm({
347
- message: `Configure ${pc.bold("BrightData")}? (recommended scraper — ~$0.45/mo per prompt)`,
549
+ message: `Configure ${pc.bold("BrightData")}? (~$0.45/mo per prompt)`,
348
550
  initialValue: true,
349
551
  });
350
552
  assertNotCancelled(enable);
351
553
  if (!enable)
352
- return false;
554
+ return;
353
555
  p.log.info(`Sign up and generate an API token: ${link(pc.cyan(BRIGHTDATA_AFFILIATE), BRIGHTDATA_AFFILIATE)}`);
354
556
  const key = await p.password({
355
557
  message: "BrightData API token",
@@ -357,7 +559,7 @@ async function collectBrightData(env, targets) {
357
559
  });
358
560
  assertNotCancelled(key);
359
561
  env.BRIGHTDATA_API_TOKEN = key;
360
- return await pickScraperTargets({
562
+ await pickScraperTargets({
361
563
  providerLabel: "BrightData",
362
564
  providerId: "brightdata",
363
565
  allModels: BRIGHTDATA_MODELS,
@@ -366,12 +568,12 @@ async function collectBrightData(env, targets) {
366
568
  }
367
569
  async function collectOlostep(env, targets) {
368
570
  const enable = await p.confirm({
369
- message: `Configure ${pc.bold("Olostep")}? (recommended scraper — ~$2.25/mo per prompt)`,
571
+ message: `Configure ${pc.bold("Olostep")}? (~$2.25/mo per prompt)`,
370
572
  initialValue: false,
371
573
  });
372
574
  assertNotCancelled(enable);
373
575
  if (!enable)
374
- return false;
576
+ return;
375
577
  p.log.info(`Grab an API key: ${link(pc.cyan(OLOSTEP_AFFILIATE), OLOSTEP_AFFILIATE)}`);
376
578
  const key = await p.password({
377
579
  message: "Olostep API key",
@@ -379,7 +581,7 @@ async function collectOlostep(env, targets) {
379
581
  });
380
582
  assertNotCancelled(key);
381
583
  env.OLOSTEP_API_KEY = key;
382
- return await pickScraperTargets({
584
+ await pickScraperTargets({
383
585
  providerLabel: "Olostep",
384
586
  providerId: "olostep",
385
587
  allModels: OLOSTEP_MODELS,
@@ -387,32 +589,16 @@ async function collectOlostep(env, targets) {
387
589
  });
388
590
  }
389
591
  async function pickScraperTargets(args) {
390
- const { providerLabel, providerId, allModels, targets } = args;
391
- const useDefault = await p.confirm({
392
- message: `Track ChatGPT and Google AI Mode via ${providerLabel}? (recommended)`,
393
- initialValue: true,
394
- });
395
- assertNotCancelled(useDefault);
396
- if (useDefault) {
397
- for (const model of DEFAULT_SCRAPER_MODELS) {
398
- targets.push(`${model}:${providerId}:online`);
399
- }
400
- return true;
401
- }
402
592
  const selected = (await p.multiselect({
403
- message: `Pick which surfaces to track via ${providerLabel}`,
404
- options: allModels.map((model) => ({
405
- value: model,
406
- label: model,
407
- })),
593
+ message: `Surfaces to track via ${args.providerLabel}`,
594
+ options: args.allModels.map((model) => ({ value: model, label: model })),
408
595
  required: true,
409
596
  initialValues: [...DEFAULT_SCRAPER_MODELS],
410
597
  }));
411
598
  assertNotCancelled(selected);
412
599
  for (const model of selected) {
413
- targets.push(`${model}:${providerId}:online`);
600
+ args.targets.push(`${model}:${args.providerId}:online`);
414
601
  }
415
- return false;
416
602
  }
417
603
  async function collectAnthropic(env, targets) {
418
604
  const enable = await p.confirm({
@@ -470,6 +656,34 @@ async function collectOpenAI(env, targets) {
470
656
  assertNotCancelled(webSearch);
471
657
  targets.push(webSearch ? `chatgpt:openai-api:${slug}:online` : `chatgpt:openai-api:${slug}`);
472
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
+ }
473
687
  async function collectOpenRouter(env, targets) {
474
688
  const enable = await p.confirm({
475
689
  message: `Configure ${pc.bold("OpenRouter")}? (one key, many hosted models)`,
@@ -1068,6 +1282,23 @@ async function detectSettingsFromConfig(configDir) {
1068
1282
  }
1069
1283
  return { dev, postgresMode, repoRoot };
1070
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
+ }
1071
1302
  async function readEnvFile(envPath) {
1072
1303
  try {
1073
1304
  const contents = await fs.readFile(envPath, "utf8");
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,7 @@
1
1
  {
2
2
  "name": "@elmohq/cli",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
+ "license": "AGPL-3.0-or-later",
4
5
  "keywords": [
5
6
  "marketing",
6
7
  "typescript",
@@ -30,7 +31,7 @@
30
31
  "@clack/prompts": "^1.2.0",
31
32
  "commander": "^14.0.3",
32
33
  "picocolors": "^1.1.1",
33
- "posthog-node": "^5.29.2",
34
+ "posthog-node": "^5.30.4",
34
35
  "semver": "^7.7.4"
35
36
  },
36
37
  "devDependencies": {