@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 +21 -0
- package/README.md +90 -0
- package/dist/index.js +302 -115
- package/dist/telemetry.js +56 -7
- package/package.json +20 -2
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>
|
|
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>
|
|
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>
|
|
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>
|
|
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
|
|
10
|
-
import semver from "semver";
|
|
6
|
+
import path from "node:path";
|
|
11
7
|
import { fileURLToPath } from "node:url";
|
|
12
|
-
import
|
|
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
|
-
//
|
|
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
|
-
|
|
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/
|
|
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
|
|
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("
|
|
333
|
-
`
|
|
334
|
-
`
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
351
|
-
await
|
|
352
|
-
await
|
|
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")}? (
|
|
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
|
|
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
|
-
|
|
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")}? (
|
|
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
|
|
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
|
-
|
|
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: `
|
|
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
|
|
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
|
|
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
|
+
"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.
|
|
34
|
+
"posthog-node": "^5.30.4",
|
|
17
35
|
"semver": "^7.7.4"
|
|
18
36
|
},
|
|
19
37
|
"devDependencies": {
|