@elmohq/cli 0.2.9 → 0.2.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -10
- package/dist/index.js +103 -298
- package/dist/telemetry.js +23 -56
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ Requires [Docker](https://docs.docker.com/get-docker/) and Docker Compose.
|
|
|
44
44
|
elmo init
|
|
45
45
|
|
|
46
46
|
# 2. Start the stack
|
|
47
|
-
elmo
|
|
47
|
+
elmo compose up -d
|
|
48
48
|
|
|
49
49
|
# 3. Open the app at http://localhost:1515
|
|
50
50
|
```
|
|
@@ -58,29 +58,28 @@ For the full self-hosting walkthrough, see the [Elmo docs](https://www.elmohq.co
|
|
|
58
58
|
| Command | Description |
|
|
59
59
|
| --- | --- |
|
|
60
60
|
| `elmo init` | Interactive wizard to set up a local Elmo instance |
|
|
61
|
-
| `elmo start` | Start the Elmo stack |
|
|
62
|
-
| `elmo stop` | Stop the Elmo stack |
|
|
63
61
|
| `elmo status` | Check the health of running services |
|
|
64
|
-
| `elmo
|
|
65
|
-
| `elmo
|
|
66
|
-
| `elmo compose <args...>` | Run any `docker compose` command against your Elmo project |
|
|
67
|
-
| `elmo build` | Build Docker images locally (for `--dev` installs) |
|
|
62
|
+
| `elmo compose <args...>` | Run any `docker compose` command against your Elmo project (e.g. `elmo compose up -d`, `elmo compose down`, `elmo compose logs -f`, `elmo compose build`) |
|
|
63
|
+
| `elmo edit <env\|compose>` | Open `.env` or `elmo.yaml` in `$VISUAL` / `$EDITOR` (fallback: `nano`) |
|
|
68
64
|
|
|
69
65
|
Run `elmo --help` or `elmo <command> --help` for the full list of flags.
|
|
70
66
|
|
|
71
67
|
### Useful flags
|
|
72
68
|
|
|
73
|
-
- `--dir <path>` — point any command at a specific config directory (defaults to `~/.
|
|
69
|
+
- `--dir <path>` — point any command at a specific config directory (defaults to `~/.elmo`).
|
|
74
70
|
- `elmo init --dev` — build images from a local checkout of the repo instead of pulling from the registry.
|
|
75
71
|
|
|
76
72
|
## Telemetry
|
|
77
73
|
|
|
78
|
-
The CLI sends anonymous install and command events so we can understand which flows people use and where setup breaks. To opt out,
|
|
74
|
+
The CLI sends anonymous install and command events so we can understand which flows people use and where setup breaks. To opt out, either export `DISABLE_TELEMETRY=1` in your shell, or add it to your `.env`:
|
|
79
75
|
|
|
80
76
|
```bash
|
|
81
|
-
|
|
77
|
+
elmo edit env # add DISABLE_TELEMETRY=1
|
|
78
|
+
elmo compose up -d # restart so the deployment picks it up
|
|
82
79
|
```
|
|
83
80
|
|
|
81
|
+
See [the telemetry docs](https://www.elmohq.com/docs/developer-guide/telemetry) for details on what is collected.
|
|
82
|
+
|
|
84
83
|
## Star, contribute, and chat
|
|
85
84
|
|
|
86
85
|
Elmo is built in the open and we'd love your help.
|
package/dist/index.js
CHANGED
|
@@ -7,15 +7,15 @@ import path from "node:path";
|
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import * as p from "@clack/prompts";
|
|
9
9
|
import { Command } from "commander";
|
|
10
|
+
import { parse as parseDotenv } from "dotenv";
|
|
10
11
|
import pc from "picocolors";
|
|
11
12
|
import semver from "semver";
|
|
12
|
-
import {
|
|
13
|
+
import { submitNewsletterSignup, trackCliEvent } from "./telemetry.js";
|
|
13
14
|
// ── Constants ────────────────────────────────────────────────────────────────
|
|
14
|
-
const CONFIG_HOME = path.join(os.homedir(), ".
|
|
15
|
-
const CONFIG_FILE = path.join(CONFIG_HOME, "config.json");
|
|
15
|
+
const CONFIG_HOME = path.join(os.homedir(), ".elmo");
|
|
16
16
|
const DEFAULT_APP_NAME = "Elmo";
|
|
17
17
|
const DEFAULT_APP_ICON = "/icons/elmo-icon.svg";
|
|
18
|
-
const
|
|
18
|
+
const DEFAULT_APP_PORT = 1515;
|
|
19
19
|
const LOCAL_DATABASE_URL = "postgres://postgres:postgres@postgres:5432/elmo";
|
|
20
20
|
const TELEMETRY_DOC_URL = "https://elmohq.com/docs/developer-guide/telemetry";
|
|
21
21
|
// ── Banner ───────────────────────────────────────────────────────────────────
|
|
@@ -77,27 +77,6 @@ async function main() {
|
|
|
77
77
|
.action(async (options) => {
|
|
78
78
|
await withVersionCheck(version, () => runInit(options, version));
|
|
79
79
|
});
|
|
80
|
-
program
|
|
81
|
-
.command("regen")
|
|
82
|
-
.description("regenerate configuration files from current settings")
|
|
83
|
-
.option("--dir <path>", "Config directory")
|
|
84
|
-
.action(async (options) => {
|
|
85
|
-
await withVersionCheck(version, () => runRegen(options));
|
|
86
|
-
});
|
|
87
|
-
program
|
|
88
|
-
.command("start")
|
|
89
|
-
.description("start Elmo instance")
|
|
90
|
-
.option("--dir <path>", "Config directory")
|
|
91
|
-
.action(async (options) => {
|
|
92
|
-
await withVersionCheck(version, () => runStart(options));
|
|
93
|
-
});
|
|
94
|
-
program
|
|
95
|
-
.command("stop")
|
|
96
|
-
.description("stop Elmo instance")
|
|
97
|
-
.option("--dir <path>", "Config directory")
|
|
98
|
-
.action(async (options) => {
|
|
99
|
-
await withVersionCheck(version, () => runStop(options));
|
|
100
|
-
});
|
|
101
80
|
program
|
|
102
81
|
.command("status")
|
|
103
82
|
.description("check Elmo instance health")
|
|
@@ -115,72 +94,15 @@ async function main() {
|
|
|
115
94
|
await withVersionCheck(version, () => runCompose(args, options));
|
|
116
95
|
});
|
|
117
96
|
program
|
|
118
|
-
.command("
|
|
119
|
-
.description("
|
|
120
|
-
.
|
|
121
|
-
.option("--dir <path>", "Config directory")
|
|
122
|
-
.argument("[args...]", "Arguments passed to Docker Compose logs")
|
|
123
|
-
.action(async (args, options) => {
|
|
124
|
-
await withVersionCheck(version, () => runCompose(["logs", ...args], options));
|
|
125
|
-
});
|
|
126
|
-
program
|
|
127
|
-
.command("build")
|
|
128
|
-
.description("build Docker images locally for development")
|
|
97
|
+
.command("edit")
|
|
98
|
+
.description("open .env or elmo.yaml in $VISUAL / $EDITOR (fallback: nano)")
|
|
99
|
+
.argument("<target>", "`env` or `compose`")
|
|
129
100
|
.option("--dir <path>", "Config directory")
|
|
130
|
-
.
|
|
131
|
-
|
|
132
|
-
.option("--no-cache", "Build without Docker cache")
|
|
133
|
-
.action(async (options) => {
|
|
134
|
-
await withVersionCheck(version, () => runBuild(options));
|
|
135
|
-
});
|
|
136
|
-
const telemetry = program.command("telemetry").description("manage CLI + local-deployment telemetry");
|
|
137
|
-
telemetry
|
|
138
|
-
.command("status")
|
|
139
|
-
.description("show whether telemetry is enabled and what is collected")
|
|
140
|
-
.action(async () => {
|
|
141
|
-
await runTelemetryStatus();
|
|
142
|
-
});
|
|
143
|
-
telemetry
|
|
144
|
-
.command("enable")
|
|
145
|
-
.description("enable telemetry")
|
|
146
|
-
.action(async () => {
|
|
147
|
-
await setTelemetryEnabled(true);
|
|
148
|
-
const updated = await updateDeploymentEnvTelemetry(true);
|
|
149
|
-
log.success("Telemetry enabled.");
|
|
150
|
-
if (updated) {
|
|
151
|
-
log.info(`Updated ${updated}. Restart the stack with \`elmo start\` to apply.`);
|
|
152
|
-
}
|
|
153
|
-
console.log(` Details: ${link(pc.cyan(TELEMETRY_DOC_URL), TELEMETRY_DOC_URL)}`);
|
|
154
|
-
});
|
|
155
|
-
telemetry
|
|
156
|
-
.command("disable")
|
|
157
|
-
.description("disable telemetry")
|
|
158
|
-
.action(async () => {
|
|
159
|
-
await setTelemetryEnabled(false);
|
|
160
|
-
const updated = await updateDeploymentEnvTelemetry(false);
|
|
161
|
-
log.success("Telemetry disabled. No further events will be sent.");
|
|
162
|
-
if (updated) {
|
|
163
|
-
log.info(`Updated ${updated}. Restart the stack with \`elmo start\` to apply.`);
|
|
164
|
-
}
|
|
101
|
+
.action(async (target, options) => {
|
|
102
|
+
await runEdit(target, options);
|
|
165
103
|
});
|
|
166
104
|
await program.parseAsync(process.argv);
|
|
167
105
|
}
|
|
168
|
-
async function runTelemetryStatus() {
|
|
169
|
-
const status = await getTelemetryStatus();
|
|
170
|
-
const state = status.enabled ? pc.green("enabled") : pc.yellow("disabled");
|
|
171
|
-
console.log(`Telemetry: ${state}`);
|
|
172
|
-
if (status.source === "env") {
|
|
173
|
-
console.log(" Source: DISABLE_TELEMETRY environment variable");
|
|
174
|
-
}
|
|
175
|
-
else if (status.source === "config") {
|
|
176
|
-
console.log(` Source: ${CONFIG_FILE}`);
|
|
177
|
-
}
|
|
178
|
-
if (status.distinctId) {
|
|
179
|
-
console.log(` Install ID: ${status.distinctId}`);
|
|
180
|
-
}
|
|
181
|
-
console.log(` What we collect: ${link(pc.cyan(TELEMETRY_DOC_URL), TELEMETRY_DOC_URL)}`);
|
|
182
|
-
console.log(" Toggle: `elmo telemetry enable` / `elmo telemetry disable`");
|
|
183
|
-
}
|
|
184
106
|
async function withVersionCheck(version, fn) {
|
|
185
107
|
const notifyPromise = maybeNotifyNewVersion(version);
|
|
186
108
|
await fn();
|
|
@@ -192,24 +114,13 @@ async function runInit(options, version) {
|
|
|
192
114
|
p.intro(pc.bold("Setting up Elmo"));
|
|
193
115
|
const cwd = process.cwd();
|
|
194
116
|
// ── Resolve config directory ─────────────────────────────────────────
|
|
195
|
-
|
|
196
|
-
if (options.dir) {
|
|
197
|
-
configDir = path.resolve(cwd, options.dir);
|
|
198
|
-
}
|
|
199
|
-
else {
|
|
200
|
-
const dir = await p.text({
|
|
201
|
-
message: "Where should the config be stored?",
|
|
202
|
-
placeholder: "./elmo",
|
|
203
|
-
defaultValue: "./elmo",
|
|
204
|
-
});
|
|
205
|
-
assertNotCancelled(dir);
|
|
206
|
-
configDir = path.resolve(cwd, dir);
|
|
207
|
-
}
|
|
117
|
+
const configDir = options.dir ? path.resolve(cwd, options.dir) : CONFIG_HOME;
|
|
208
118
|
// ── .env safety check ────────────────────────────────────────────────
|
|
209
119
|
const existingEnvPath = path.join(configDir, ".env");
|
|
120
|
+
let preservedDeploymentId;
|
|
210
121
|
if (await fileExists(existingEnvPath)) {
|
|
211
122
|
const contents = await fs.readFile(existingEnvPath, "utf8");
|
|
212
|
-
const isElmoEnv = contents.startsWith("# Generated by elmo");
|
|
123
|
+
const isElmoEnv = contents.startsWith("# Rendered by elmo") || contents.startsWith("# Generated by elmo");
|
|
213
124
|
if (!isElmoEnv) {
|
|
214
125
|
p.log.warn(`A .env file already exists in ${configDir} and was NOT created by Elmo.`);
|
|
215
126
|
const overwrite = await p.confirm({
|
|
@@ -223,7 +134,17 @@ async function runInit(options, version) {
|
|
|
223
134
|
}
|
|
224
135
|
}
|
|
225
136
|
else {
|
|
226
|
-
p.log.
|
|
137
|
+
p.log.warn(`An existing Elmo config was found at ${configDir}.`);
|
|
138
|
+
const overwrite = await p.confirm({
|
|
139
|
+
message: "Overwrite it with new values? Existing secrets (DATABASE_URL, API keys, etc.) will be replaced.",
|
|
140
|
+
initialValue: false,
|
|
141
|
+
});
|
|
142
|
+
assertNotCancelled(overwrite);
|
|
143
|
+
if (!overwrite) {
|
|
144
|
+
p.cancel("Setup cancelled. Use `elmo edit env` to change individual values.");
|
|
145
|
+
process.exit(0);
|
|
146
|
+
}
|
|
147
|
+
preservedDeploymentId = parseDotenv(contents).DEPLOYMENT_ID;
|
|
227
148
|
}
|
|
228
149
|
}
|
|
229
150
|
// ── Dev mode: resolve docker directory ───────────────────────────────
|
|
@@ -264,14 +185,12 @@ async function runInit(options, version) {
|
|
|
264
185
|
const env = {};
|
|
265
186
|
env.DEPLOYMENT_MODE = "local";
|
|
266
187
|
env.VITE_DEPLOYMENT_MODE = "local";
|
|
267
|
-
env.DEPLOYMENT_ID = crypto.randomUUID();
|
|
188
|
+
env.DEPLOYMENT_ID = preservedDeploymentId ?? crypto.randomUUID();
|
|
268
189
|
env.BETTER_AUTH_SECRET = generateSecret();
|
|
269
190
|
env.APP_NAME = DEFAULT_APP_NAME;
|
|
270
191
|
env.APP_ICON = DEFAULT_APP_ICON;
|
|
271
|
-
env.APP_URL = DEFAULT_APP_URL;
|
|
272
192
|
env.VITE_APP_NAME = DEFAULT_APP_NAME;
|
|
273
193
|
env.VITE_APP_ICON = DEFAULT_APP_ICON;
|
|
274
|
-
env.VITE_APP_URL = DEFAULT_APP_URL;
|
|
275
194
|
if (postgresMode === "external") {
|
|
276
195
|
p.note("Must be an IPv4-compatible direct connection or database pooler.", "DATABASE_URL");
|
|
277
196
|
const url = await p.password({
|
|
@@ -296,7 +215,7 @@ async function runInit(options, version) {
|
|
|
296
215
|
"on what to fix or build next.",
|
|
297
216
|
"",
|
|
298
217
|
pc.bold("What we send:"),
|
|
299
|
-
" •
|
|
218
|
+
" • deployment ID (random UUID stored as DEPLOYMENT_ID in your .env)",
|
|
300
219
|
" • CLI/app version, OS, arch, Node version, deployment mode",
|
|
301
220
|
" • command/event names + non-secret options (e.g. postgres mode)",
|
|
302
221
|
" • feature counts (prompts edited, brands created — never the names or text)",
|
|
@@ -306,14 +225,13 @@ async function runInit(options, version) {
|
|
|
306
225
|
" API keys, .env contents, brand names, prompt text, and scraped responses.",
|
|
307
226
|
"",
|
|
308
227
|
`Full breakdown: ${link(pc.cyan(TELEMETRY_DOC_URL), TELEMETRY_DOC_URL)}`,
|
|
309
|
-
"Toggle later
|
|
228
|
+
"Toggle later by editing DISABLE_TELEMETRY in .env (`elmo edit env`).",
|
|
310
229
|
].join("\n"), "Telemetry");
|
|
311
230
|
const telemetryEnabled = await p.confirm({
|
|
312
231
|
message: "Share telemetry?",
|
|
313
232
|
initialValue: true,
|
|
314
233
|
});
|
|
315
234
|
assertNotCancelled(telemetryEnabled);
|
|
316
|
-
await setTelemetryEnabled(telemetryEnabled);
|
|
317
235
|
if (!telemetryEnabled) {
|
|
318
236
|
env.DISABLE_TELEMETRY = "1";
|
|
319
237
|
}
|
|
@@ -323,12 +241,33 @@ async function runInit(options, version) {
|
|
|
323
241
|
placeholder: "you@example.com",
|
|
324
242
|
});
|
|
325
243
|
const email = p.isCancel(updatesEmail) ? undefined : updatesEmail || undefined;
|
|
244
|
+
// ── Web app port ────────────────────────────────────────────────────
|
|
245
|
+
const portInput = await p.text({
|
|
246
|
+
message: "Web app port",
|
|
247
|
+
placeholder: String(DEFAULT_APP_PORT),
|
|
248
|
+
defaultValue: String(DEFAULT_APP_PORT),
|
|
249
|
+
validate: (v) => {
|
|
250
|
+
if (!v)
|
|
251
|
+
return undefined;
|
|
252
|
+
const n = Number(v);
|
|
253
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) {
|
|
254
|
+
return "Must be an integer between 1 and 65535";
|
|
255
|
+
}
|
|
256
|
+
return undefined;
|
|
257
|
+
},
|
|
258
|
+
});
|
|
259
|
+
assertNotCancelled(portInput);
|
|
260
|
+
const port = Number(portInput);
|
|
261
|
+
env.APP_URL = `http://localhost:${port}`;
|
|
262
|
+
env.VITE_APP_URL = env.APP_URL;
|
|
326
263
|
// ── Write config ─────────────────────────────────────────────────────
|
|
327
264
|
const composeYaml = buildComposeYaml({
|
|
328
265
|
dev: Boolean(options.dev),
|
|
329
266
|
postgresMode,
|
|
330
267
|
repoRoot,
|
|
331
268
|
dockerDir,
|
|
269
|
+
port,
|
|
270
|
+
version,
|
|
332
271
|
});
|
|
333
272
|
await ensureDir(configDir);
|
|
334
273
|
await writeConfigFiles(configDir, {
|
|
@@ -336,13 +275,7 @@ async function runInit(options, version) {
|
|
|
336
275
|
composeYaml,
|
|
337
276
|
postgresMode,
|
|
338
277
|
dev: Boolean(options.dev),
|
|
339
|
-
|
|
340
|
-
await writeGlobalConfig({
|
|
341
|
-
configDir,
|
|
342
|
-
dockerDir,
|
|
343
|
-
dev: Boolean(options.dev),
|
|
344
|
-
postgresMode,
|
|
345
|
-
repoRoot,
|
|
278
|
+
version,
|
|
346
279
|
});
|
|
347
280
|
p.log.success(`Config written to ${configDir}`);
|
|
348
281
|
p.log.warn("Your generated .env file contains secrets — do not commit it to version control.");
|
|
@@ -358,10 +291,10 @@ async function runInit(options, version) {
|
|
|
358
291
|
await doStart(configDir);
|
|
359
292
|
}
|
|
360
293
|
else {
|
|
361
|
-
p.log.info("You can start later with `elmo
|
|
294
|
+
p.log.info("You can start later with `elmo compose up -d`.");
|
|
362
295
|
}
|
|
363
296
|
// CLI telemetry — silently dropped if the user opted out above.
|
|
364
|
-
await trackCliEvent("cli_init", {
|
|
297
|
+
await trackCliEvent(configDir, "cli_init", {
|
|
365
298
|
version,
|
|
366
299
|
os: process.platform,
|
|
367
300
|
arch: process.arch,
|
|
@@ -375,7 +308,7 @@ async function runInit(options, version) {
|
|
|
375
308
|
// Newsletter signup is a separate, explicit opt-in and runs even when
|
|
376
309
|
// telemetry is disabled.
|
|
377
310
|
if (email) {
|
|
378
|
-
await submitNewsletterSignup(email);
|
|
311
|
+
await submitNewsletterSignup(configDir, email);
|
|
379
312
|
}
|
|
380
313
|
p.log.message(`If you find Elmo useful, star us on GitHub!\n ${link(pc.cyan("https://github.com/elmohq/elmo"), "https://github.com/elmohq/elmo")}`);
|
|
381
314
|
p.outro(pc.green("Setup complete!"));
|
|
@@ -460,7 +393,7 @@ async function configureProvidersRecommended(env) {
|
|
|
460
393
|
initialValue: "openrouter",
|
|
461
394
|
});
|
|
462
395
|
assertNotCancelled(direct);
|
|
463
|
-
await collectDirectApiQuick(direct, env
|
|
396
|
+
await collectDirectApiQuick(direct, env);
|
|
464
397
|
await finalizeScrapeTargets(env, targets, { skipEdit: true });
|
|
465
398
|
}
|
|
466
399
|
async function configureProvidersCustom(env) {
|
|
@@ -506,7 +439,7 @@ async function collectScraperKey(scraper, env) {
|
|
|
506
439
|
env.OLOSTEP_API_KEY = key;
|
|
507
440
|
}
|
|
508
441
|
}
|
|
509
|
-
async function collectDirectApiQuick(kind, env
|
|
442
|
+
async function collectDirectApiQuick(kind, env) {
|
|
510
443
|
if (kind === "openrouter") {
|
|
511
444
|
const key = await p.password({
|
|
512
445
|
message: "OpenRouter API key",
|
|
@@ -514,7 +447,6 @@ async function collectDirectApiQuick(kind, env, targets) {
|
|
|
514
447
|
});
|
|
515
448
|
assertNotCancelled(key);
|
|
516
449
|
env.OPENROUTER_API_KEY = key;
|
|
517
|
-
targets.push(`claude:openrouter:${DEFAULT_OPENROUTER_MODEL}:online`);
|
|
518
450
|
}
|
|
519
451
|
else if (kind === "anthropic") {
|
|
520
452
|
const key = await p.password({
|
|
@@ -523,7 +455,6 @@ async function collectDirectApiQuick(kind, env, targets) {
|
|
|
523
455
|
});
|
|
524
456
|
assertNotCancelled(key);
|
|
525
457
|
env.ANTHROPIC_API_KEY = key;
|
|
526
|
-
targets.push(`claude:anthropic-api:${DEFAULT_ANTHROPIC_MODEL}:online`);
|
|
527
458
|
}
|
|
528
459
|
else if (kind === "openai") {
|
|
529
460
|
const key = await p.password({
|
|
@@ -532,7 +463,6 @@ async function collectDirectApiQuick(kind, env, targets) {
|
|
|
532
463
|
});
|
|
533
464
|
assertNotCancelled(key);
|
|
534
465
|
env.OPENAI_API_KEY = key;
|
|
535
|
-
targets.push(`chatgpt:openai-api:${DEFAULT_OPENAI_MODEL}:online`);
|
|
536
466
|
}
|
|
537
467
|
else {
|
|
538
468
|
const key = await p.password({
|
|
@@ -541,7 +471,6 @@ async function collectDirectApiQuick(kind, env, targets) {
|
|
|
541
471
|
});
|
|
542
472
|
assertNotCancelled(key);
|
|
543
473
|
env.MISTRAL_API_KEY = key;
|
|
544
|
-
targets.push(`mistral:mistral-api:${DEFAULT_MISTRAL_MODEL}:online`);
|
|
545
474
|
}
|
|
546
475
|
}
|
|
547
476
|
async function collectBrightData(env, targets) {
|
|
@@ -797,51 +726,7 @@ function dedupeTargets(targets) {
|
|
|
797
726
|
}
|
|
798
727
|
return out.join(",");
|
|
799
728
|
}
|
|
800
|
-
// ──
|
|
801
|
-
async function runRegen(options) {
|
|
802
|
-
const configDir = await resolveConfigDir(options.dir);
|
|
803
|
-
const globalConfig = await readGlobalConfig();
|
|
804
|
-
// Determine settings: prefer global config, fall back to detecting from files
|
|
805
|
-
let dev;
|
|
806
|
-
let postgresMode;
|
|
807
|
-
let repoRoot;
|
|
808
|
-
let dockerDir;
|
|
809
|
-
if (globalConfig?.postgresMode) {
|
|
810
|
-
dev = globalConfig.dev ?? false;
|
|
811
|
-
postgresMode = globalConfig.postgresMode;
|
|
812
|
-
repoRoot = globalConfig.repoRoot ?? process.cwd();
|
|
813
|
-
dockerDir = globalConfig.dockerDir;
|
|
814
|
-
}
|
|
815
|
-
else {
|
|
816
|
-
const detected = await detectSettingsFromConfig(configDir);
|
|
817
|
-
dev = detected.dev;
|
|
818
|
-
postgresMode = detected.postgresMode;
|
|
819
|
-
repoRoot = detected.repoRoot;
|
|
820
|
-
dockerDir = detected.dockerDir;
|
|
821
|
-
}
|
|
822
|
-
const composeYaml = buildComposeYaml({
|
|
823
|
-
dev,
|
|
824
|
-
postgresMode,
|
|
825
|
-
repoRoot,
|
|
826
|
-
dockerDir,
|
|
827
|
-
});
|
|
828
|
-
const composePath = path.join(configDir, "elmo.yaml");
|
|
829
|
-
await fs.writeFile(composePath, composeYaml, "utf8");
|
|
830
|
-
await writeGlobalConfig({
|
|
831
|
-
configDir,
|
|
832
|
-
dockerDir,
|
|
833
|
-
dev,
|
|
834
|
-
postgresMode,
|
|
835
|
-
repoRoot,
|
|
836
|
-
});
|
|
837
|
-
log.success(`Regenerated ${composePath}`);
|
|
838
|
-
log.info("The .env file was not modified.");
|
|
839
|
-
}
|
|
840
|
-
// ── Command: start ───────────────────────────────────────────────────────────
|
|
841
|
-
async function runStart(options) {
|
|
842
|
-
const configDir = await resolveConfigDir(options.dir);
|
|
843
|
-
await doStart(configDir);
|
|
844
|
-
}
|
|
729
|
+
// ── Start helper (used by init) ──────────────────────────────────────────────
|
|
845
730
|
async function doStart(configDir) {
|
|
846
731
|
assertDockerRunning();
|
|
847
732
|
log.step("Starting Docker Compose stack...");
|
|
@@ -857,17 +742,10 @@ async function doStart(configDir) {
|
|
|
857
742
|
p.log.warn("Some services did not report healthy status.");
|
|
858
743
|
}
|
|
859
744
|
log.info("Examples:");
|
|
860
|
-
console.log(` ${pc.bold("elmo logs -f")}`);
|
|
745
|
+
console.log(` ${pc.bold("elmo compose logs -f")}`);
|
|
861
746
|
console.log(` ${pc.bold("elmo compose logs -f web")}`);
|
|
862
747
|
console.log(` ${pc.bold("elmo compose ps")}`);
|
|
863
|
-
}
|
|
864
|
-
// ── Command: stop ────────────────────────────────────────────────────────────
|
|
865
|
-
async function runStop(options) {
|
|
866
|
-
const configDir = await resolveConfigDir(options.dir);
|
|
867
|
-
assertDockerRunning();
|
|
868
|
-
log.step("Stopping Docker Compose stack...");
|
|
869
|
-
await runDockerCompose(configDir, ["down"]);
|
|
870
|
-
log.success("Stack stopped.");
|
|
748
|
+
console.log(` ${pc.bold("elmo compose down")}`);
|
|
871
749
|
}
|
|
872
750
|
// ── Command: status ──────────────────────────────────────────────────────────
|
|
873
751
|
async function runStatus(options) {
|
|
@@ -899,29 +777,37 @@ async function runCompose(args, options) {
|
|
|
899
777
|
assertDockerRunning();
|
|
900
778
|
await runDockerCompose(configDir, args);
|
|
901
779
|
}
|
|
902
|
-
// ── Command:
|
|
903
|
-
async function
|
|
780
|
+
// ── Command: edit ────────────────────────────────────────────────────────────
|
|
781
|
+
async function runEdit(target, options) {
|
|
904
782
|
const configDir = await resolveConfigDir(options.dir);
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
783
|
+
let filePath;
|
|
784
|
+
if (target === "env") {
|
|
785
|
+
filePath = path.join(configDir, ".env");
|
|
786
|
+
}
|
|
787
|
+
else if (target === "compose") {
|
|
788
|
+
filePath = path.join(configDir, "elmo.yaml");
|
|
789
|
+
}
|
|
790
|
+
else {
|
|
791
|
+
throw new Error(`Unknown edit target: ${target}. Use \`env\` or \`compose\`.`);
|
|
792
|
+
}
|
|
793
|
+
if (!(await fileExists(filePath))) {
|
|
794
|
+
throw new Error(`File not found: ${filePath}`);
|
|
795
|
+
}
|
|
796
|
+
const editorEnv = process.env.VISUAL || process.env.EDITOR || "nano";
|
|
797
|
+
const parts = editorEnv.split(/\s+/).filter(Boolean);
|
|
798
|
+
const cmd = parts[0] ?? "nano";
|
|
799
|
+
const args = [...parts.slice(1), filePath];
|
|
800
|
+
await new Promise((resolve, reject) => {
|
|
801
|
+
const child = spawn(cmd, args, { stdio: "inherit" });
|
|
802
|
+
child.on("close", (code) => {
|
|
803
|
+
if (code === 0)
|
|
804
|
+
resolve();
|
|
805
|
+
else
|
|
806
|
+
reject(new Error(`${cmd} exited with code ${code}`));
|
|
807
|
+
});
|
|
808
|
+
child.on("error", (err) => reject(err));
|
|
809
|
+
});
|
|
810
|
+
log.info("Restart the stack with `elmo compose up -d` to apply changes.");
|
|
925
811
|
}
|
|
926
812
|
// ── Compose YAML Builder ─────────────────────────────────────────────────────
|
|
927
813
|
function buildComposeYaml(options) {
|
|
@@ -953,6 +839,7 @@ function buildComposeYaml(options) {
|
|
|
953
839
|
dependencyConditions,
|
|
954
840
|
repoRoot: options.repoRoot,
|
|
955
841
|
dockerfilePath,
|
|
842
|
+
port: options.port,
|
|
956
843
|
}));
|
|
957
844
|
services.push(buildWorkerService({
|
|
958
845
|
dev: options.dev,
|
|
@@ -961,7 +848,7 @@ function buildComposeYaml(options) {
|
|
|
961
848
|
repoRoot: options.repoRoot,
|
|
962
849
|
dockerfilePath,
|
|
963
850
|
}));
|
|
964
|
-
const lines = ["name: elmo", "", "services:"];
|
|
851
|
+
const lines = [renderedByHeader(options.version), "", "name: elmo", "", "services:"];
|
|
965
852
|
lines.push(...services.map((service) => indentBlock(service, 2)));
|
|
966
853
|
if (volumes.size > 0) {
|
|
967
854
|
lines.push("", "volumes:");
|
|
@@ -1010,7 +897,7 @@ function buildWebService(options) {
|
|
|
1010
897
|
else {
|
|
1011
898
|
lines.push(" image: elmohq/elmo-web:latest");
|
|
1012
899
|
}
|
|
1013
|
-
lines.push(" env_file:", " - path: .env", " required: true", " ports:",
|
|
900
|
+
lines.push(" env_file:", " - path: .env", " required: true", " ports:", ` - "${options.port}:3000"`);
|
|
1014
901
|
if (options.dependsOn.length > 0) {
|
|
1015
902
|
lines.push(" depends_on:");
|
|
1016
903
|
for (const service of options.dependsOn) {
|
|
@@ -1196,52 +1083,32 @@ async function resolveDockerDirAuto(cwd, explicitDir) {
|
|
|
1196
1083
|
}
|
|
1197
1084
|
// ── Config Dir Resolution ────────────────────────────────────────────────────
|
|
1198
1085
|
async function resolveConfigDir(explicitDir) {
|
|
1199
|
-
|
|
1200
|
-
const resolved = path.resolve(process.cwd(), explicitDir);
|
|
1201
|
-
const composePath = path.join(resolved, "elmo.yaml");
|
|
1202
|
-
if (!(await fileExists(composePath))) {
|
|
1203
|
-
throw new Error(`Config directory does not contain elmo.yaml: ${resolved}\nRun \`elmo init\` to create your local setup.`);
|
|
1204
|
-
}
|
|
1205
|
-
return resolved;
|
|
1206
|
-
}
|
|
1207
|
-
const config = await readGlobalConfig();
|
|
1208
|
-
if (!config?.configDir) {
|
|
1209
|
-
throw new Error("No config found. Run `elmo init` or specify --dir.");
|
|
1210
|
-
}
|
|
1211
|
-
const resolved = path.resolve(config.configDir);
|
|
1086
|
+
const resolved = explicitDir ? path.resolve(process.cwd(), explicitDir) : CONFIG_HOME;
|
|
1212
1087
|
const composePath = path.join(resolved, "elmo.yaml");
|
|
1213
1088
|
if (!(await fileExists(composePath))) {
|
|
1214
|
-
|
|
1089
|
+
if (explicitDir) {
|
|
1090
|
+
throw new Error(`Config directory does not contain elmo.yaml: ${resolved}\nRun \`elmo init --dir ${explicitDir}\` to create it.`);
|
|
1091
|
+
}
|
|
1092
|
+
throw new Error(`No config found at ${resolved}. Run \`elmo init\` to create one, or specify --dir.`);
|
|
1215
1093
|
}
|
|
1216
1094
|
return resolved;
|
|
1217
1095
|
}
|
|
1218
1096
|
// ── File & Config Helpers ────────────────────────────────────────────────────
|
|
1219
|
-
async function readGlobalConfig() {
|
|
1220
|
-
try {
|
|
1221
|
-
const contents = await fs.readFile(CONFIG_FILE, "utf8");
|
|
1222
|
-
return JSON.parse(contents);
|
|
1223
|
-
}
|
|
1224
|
-
catch {
|
|
1225
|
-
return null;
|
|
1226
|
-
}
|
|
1227
|
-
}
|
|
1228
|
-
async function writeGlobalConfig(config) {
|
|
1229
|
-
const globalConfig = {
|
|
1230
|
-
...config,
|
|
1231
|
-
updatedAt: new Date().toISOString(),
|
|
1232
|
-
};
|
|
1233
|
-
await ensureDir(CONFIG_HOME);
|
|
1234
|
-
await fs.writeFile(CONFIG_FILE, JSON.stringify(globalConfig, null, 2), "utf8");
|
|
1235
|
-
}
|
|
1236
1097
|
async function writeConfigFiles(configDir, initConfig) {
|
|
1237
1098
|
const envPath = path.join(configDir, ".env");
|
|
1238
1099
|
const composePath = path.join(configDir, "elmo.yaml");
|
|
1239
1100
|
await ensureDir(configDir);
|
|
1240
|
-
await fs.writeFile(envPath, buildEnvFile(initConfig.env), "utf8");
|
|
1101
|
+
await fs.writeFile(envPath, buildEnvFile(initConfig.env, initConfig.version), "utf8");
|
|
1241
1102
|
await fs.writeFile(composePath, initConfig.composeYaml, "utf8");
|
|
1242
1103
|
}
|
|
1243
|
-
function
|
|
1244
|
-
|
|
1104
|
+
function renderedByHeader(version) {
|
|
1105
|
+
return [
|
|
1106
|
+
`# Rendered by elmo ${version} on ${new Date().toISOString()}`,
|
|
1107
|
+
"# Re-run `elmo init` after upgrading the CLI to refresh this file.",
|
|
1108
|
+
].join("\n");
|
|
1109
|
+
}
|
|
1110
|
+
function buildEnvFile(env, version) {
|
|
1111
|
+
const lines = [renderedByHeader(version), "# WARNING: contains secrets. Do not commit.", ""];
|
|
1245
1112
|
for (const [key, rawValue] of Object.entries(env)) {
|
|
1246
1113
|
if (rawValue === undefined) {
|
|
1247
1114
|
continue;
|
|
@@ -1260,68 +1127,6 @@ function formatEnvValue(value) {
|
|
|
1260
1127
|
}
|
|
1261
1128
|
return value;
|
|
1262
1129
|
}
|
|
1263
|
-
async function detectSettingsFromConfig(configDir) {
|
|
1264
|
-
const envPath = path.join(configDir, ".env");
|
|
1265
|
-
const env = await readEnvFile(envPath);
|
|
1266
|
-
const postgresMode = env.DATABASE_URL?.includes("postgres:5432") ? "docker" : "external";
|
|
1267
|
-
const yamlPath = path.join(configDir, "elmo.yaml");
|
|
1268
|
-
let dev = false;
|
|
1269
|
-
let repoRoot = process.cwd();
|
|
1270
|
-
try {
|
|
1271
|
-
const yamlContent = await fs.readFile(yamlPath, "utf8");
|
|
1272
|
-
dev = yamlContent.includes("build:");
|
|
1273
|
-
const contextMatch = yamlContent.match(/context:\s+(.+)/);
|
|
1274
|
-
if (contextMatch) {
|
|
1275
|
-
repoRoot = contextMatch[1].trim();
|
|
1276
|
-
}
|
|
1277
|
-
}
|
|
1278
|
-
catch {
|
|
1279
|
-
// Use defaults
|
|
1280
|
-
}
|
|
1281
|
-
return { dev, postgresMode, repoRoot };
|
|
1282
|
-
}
|
|
1283
|
-
async function updateDeploymentEnvTelemetry(enabled) {
|
|
1284
|
-
const config = await readGlobalConfig();
|
|
1285
|
-
if (!config?.configDir)
|
|
1286
|
-
return null;
|
|
1287
|
-
const envPath = path.join(config.configDir, ".env");
|
|
1288
|
-
if (!(await fileExists(envPath)))
|
|
1289
|
-
return null;
|
|
1290
|
-
const contents = await fs.readFile(envPath, "utf8");
|
|
1291
|
-
const lines = contents.split("\n");
|
|
1292
|
-
const filtered = lines.filter((line) => !/^\s*DISABLE_TELEMETRY\s*=/.test(line));
|
|
1293
|
-
if (!enabled) {
|
|
1294
|
-
const insertIdx = filtered.length > 0 && filtered[filtered.length - 1] === "" ? filtered.length - 1 : filtered.length;
|
|
1295
|
-
filtered.splice(insertIdx, 0, "DISABLE_TELEMETRY=1");
|
|
1296
|
-
}
|
|
1297
|
-
await fs.writeFile(envPath, filtered.join("\n"), "utf8");
|
|
1298
|
-
return envPath;
|
|
1299
|
-
}
|
|
1300
|
-
async function readEnvFile(envPath) {
|
|
1301
|
-
try {
|
|
1302
|
-
const contents = await fs.readFile(envPath, "utf8");
|
|
1303
|
-
const env = {};
|
|
1304
|
-
for (const line of contents.split("\n")) {
|
|
1305
|
-
const trimmed = line.trim();
|
|
1306
|
-
if (!trimmed || trimmed.startsWith("#"))
|
|
1307
|
-
continue;
|
|
1308
|
-
const eqIndex = trimmed.indexOf("=");
|
|
1309
|
-
if (eqIndex < 0)
|
|
1310
|
-
continue;
|
|
1311
|
-
const key = trimmed.substring(0, eqIndex);
|
|
1312
|
-
let value = trimmed.substring(eqIndex + 1);
|
|
1313
|
-
// Remove surrounding quotes
|
|
1314
|
-
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
1315
|
-
value = value.slice(1, -1);
|
|
1316
|
-
}
|
|
1317
|
-
env[key] = value;
|
|
1318
|
-
}
|
|
1319
|
-
return env;
|
|
1320
|
-
}
|
|
1321
|
-
catch {
|
|
1322
|
-
return {};
|
|
1323
|
-
}
|
|
1324
|
-
}
|
|
1325
1130
|
async function fileExists(target) {
|
|
1326
1131
|
try {
|
|
1327
1132
|
await fs.access(target);
|
package/dist/telemetry.js
CHANGED
|
@@ -1,67 +1,33 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
1
|
import fs from "node:fs/promises";
|
|
3
|
-
import os from "node:os";
|
|
4
2
|
import path from "node:path";
|
|
3
|
+
import { parse } from "dotenv";
|
|
5
4
|
import { PostHog } from "posthog-node";
|
|
6
5
|
const POSTHOG_PUBLIC_KEY = "phc_Jhx9LnI9cTDFHpQmpOzJSDTW127qD9pFU65KRnYym6z";
|
|
7
6
|
const POSTHOG_HOST = "https://us.i.posthog.com";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
function envDisabled() {
|
|
8
|
+
return Boolean(process.env.DISABLE_TELEMETRY);
|
|
9
|
+
}
|
|
10
|
+
async function readEnvKey(configDir, key) {
|
|
11
11
|
try {
|
|
12
|
-
const contents = await fs.readFile(
|
|
13
|
-
return
|
|
12
|
+
const contents = await fs.readFile(path.join(configDir, ".env"), "utf8");
|
|
13
|
+
return parse(contents)[key];
|
|
14
14
|
}
|
|
15
15
|
catch {
|
|
16
|
-
return
|
|
16
|
+
return undefined;
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
|
-
async function
|
|
20
|
-
await fs.mkdir(CONFIG_HOME, { recursive: true });
|
|
21
|
-
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), "utf8");
|
|
22
|
-
}
|
|
23
|
-
function envDisabled() {
|
|
24
|
-
return Boolean(process.env.DISABLE_TELEMETRY);
|
|
25
|
-
}
|
|
26
|
-
export async function isTelemetryDisabled() {
|
|
19
|
+
async function isTelemetryDisabled(configDir) {
|
|
27
20
|
if (envDisabled())
|
|
28
21
|
return true;
|
|
29
|
-
|
|
30
|
-
return config.telemetryDisabled === true;
|
|
22
|
+
return Boolean(await readEnvKey(configDir, "DISABLE_TELEMETRY"));
|
|
31
23
|
}
|
|
32
|
-
export async function
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
else {
|
|
38
|
-
config.telemetryDisabled = true;
|
|
39
|
-
}
|
|
40
|
-
await writeConfig(config);
|
|
41
|
-
}
|
|
42
|
-
export async function getTelemetryStatus() {
|
|
43
|
-
if (envDisabled()) {
|
|
44
|
-
return { enabled: false, source: "env" };
|
|
45
|
-
}
|
|
46
|
-
const config = await readConfig();
|
|
47
|
-
if (config.telemetryDisabled === true) {
|
|
48
|
-
return { enabled: false, source: "config", distinctId: config.telemetryId };
|
|
49
|
-
}
|
|
50
|
-
return { enabled: true, source: "default", distinctId: config.telemetryId };
|
|
51
|
-
}
|
|
52
|
-
async function getOrCreateDistinctId() {
|
|
53
|
-
const config = await readConfig();
|
|
54
|
-
if (config.telemetryId)
|
|
55
|
-
return config.telemetryId;
|
|
56
|
-
const id = crypto.randomUUID();
|
|
57
|
-
await writeConfig({ ...config, telemetryId: id });
|
|
58
|
-
return id;
|
|
59
|
-
}
|
|
60
|
-
export async function trackCliEvent(eventName, properties, personProperties) {
|
|
61
|
-
if (await isTelemetryDisabled())
|
|
24
|
+
export async function trackCliEvent(configDir, eventName, properties, personProperties) {
|
|
25
|
+
if (await isTelemetryDisabled(configDir))
|
|
26
|
+
return;
|
|
27
|
+
const distinctId = await readEnvKey(configDir, "DEPLOYMENT_ID");
|
|
28
|
+
if (!distinctId)
|
|
62
29
|
return;
|
|
63
30
|
try {
|
|
64
|
-
const distinctId = await getOrCreateDistinctId();
|
|
65
31
|
const client = new PostHog(POSTHOG_PUBLIC_KEY, { host: POSTHOG_HOST });
|
|
66
32
|
client.capture({
|
|
67
33
|
distinctId,
|
|
@@ -77,14 +43,15 @@ export async function trackCliEvent(eventName, properties, personProperties) {
|
|
|
77
43
|
}
|
|
78
44
|
// Newsletter signup is an explicit user action with clear intent, so it
|
|
79
45
|
// fires even when anonymous telemetry is disabled. When telemetry is on the
|
|
80
|
-
// event is keyed off the
|
|
81
|
-
// property — same identity as the rest of the CLI events. When
|
|
82
|
-
// off the event is keyed off the email itself, so it is never
|
|
83
|
-
// to the anonymous
|
|
84
|
-
export async function submitNewsletterSignup(email) {
|
|
46
|
+
// event is keyed off the deployment UUID and the email is attached as a
|
|
47
|
+
// person property — same identity as the rest of the CLI events. When
|
|
48
|
+
// telemetry is off the event is keyed off the email itself, so it is never
|
|
49
|
+
// linked back to the anonymous deployment UUID.
|
|
50
|
+
export async function submitNewsletterSignup(configDir, email) {
|
|
85
51
|
try {
|
|
86
|
-
const disabled = await isTelemetryDisabled();
|
|
87
|
-
const
|
|
52
|
+
const disabled = await isTelemetryDisabled(configDir);
|
|
53
|
+
const deploymentId = disabled ? null : await readEnvKey(configDir, "DEPLOYMENT_ID");
|
|
54
|
+
const distinctId = deploymentId ?? email;
|
|
88
55
|
const client = new PostHog(POSTHOG_PUBLIC_KEY, { host: POSTHOG_HOST });
|
|
89
56
|
client.capture({
|
|
90
57
|
distinctId,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elmohq/cli",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"license": "
|
|
3
|
+
"version": "0.2.10",
|
|
4
|
+
"license": "MIT",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"marketing",
|
|
7
7
|
"typescript",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@clack/prompts": "^1.3.0",
|
|
32
32
|
"commander": "^14.0.3",
|
|
33
|
+
"dotenv": "16.6.1",
|
|
33
34
|
"picocolors": "^1.1.1",
|
|
34
35
|
"posthog-node": "^5.33.0",
|
|
35
36
|
"semver": "^7.7.4"
|