@elmohq/cli 0.2.0

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/dist/index.js ADDED
@@ -0,0 +1,1051 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import pc from "picocolors";
4
+ import * as p from "@clack/prompts";
5
+ import { spawn, spawnSync } from "node:child_process";
6
+ import fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import os from "node:os";
9
+ import crypto from "node:crypto";
10
+ import semver from "semver";
11
+ import { fileURLToPath } from "node:url";
12
+ import { trackCliEvent } from "./telemetry.js";
13
+ // ── Constants ────────────────────────────────────────────────────────────────
14
+ const CONFIG_HOME = path.join(os.homedir(), ".config", "elmo");
15
+ const CONFIG_FILE = path.join(CONFIG_HOME, "config.json");
16
+ const DEFAULT_ORG_ID = "default";
17
+ const DEFAULT_ORG_NAME = "Default Organization";
18
+ const DEFAULT_APP_NAME = "Elmo";
19
+ const DEFAULT_APP_ICON = "/icons/elmo-icon.svg";
20
+ const DEFAULT_APP_URL = "http://localhost:1515";
21
+ const LOCAL_DATABASE_URL = "postgres://postgres:postgres@postgres:5432/elmo";
22
+ // ── Banner ───────────────────────────────────────────────────────────────────
23
+ const ELMO_ASCII = [
24
+ "",
25
+ " ▄▄ ",
26
+ " ██ ",
27
+ "▄█▀█▄ ██ ███▄███▄ ▄███▄ ",
28
+ "██▄█▀ ██ ██ ██ ██ ██ ██ ",
29
+ "▀█▄▄▄ ██ ██ ██ ██ ▀███▀ ",
30
+ "",
31
+ ].join("\n");
32
+ function printBanner() {
33
+ // text-blue-600 ≈ #2563EB → RGB(37, 99, 235)
34
+ const blue = "\x1b[38;2;37;99;235m";
35
+ const reset = "\x1b[0m";
36
+ console.log(`${blue}${ELMO_ASCII}${reset}`);
37
+ }
38
+ // ── Logging ──────────────────────────────────────────────────────────────────
39
+ const log = {
40
+ info: (msg) => isCI() ? console.log(msg) : p.log.info(msg),
41
+ warn: (msg) => isCI() ? console.warn(pc.yellow(msg)) : p.log.warn(msg),
42
+ error: (msg) => isCI() ? console.error(pc.red(msg)) : p.log.error(msg),
43
+ success: (msg) => isCI() ? console.log(pc.green(msg)) : p.log.success(msg),
44
+ step: (msg) => isCI() ? console.log(msg) : p.log.step(msg),
45
+ };
46
+ // ── Helpers ──────────────────────────────────────────────────────────────────
47
+ function assertNotCancelled(value) {
48
+ if (p.isCancel(value)) {
49
+ p.cancel("Setup cancelled.");
50
+ process.exit(0);
51
+ }
52
+ }
53
+ function isCI() {
54
+ return Boolean(process.env.ELMO_CI);
55
+ }
56
+ function generateSecret(bytes = 32) {
57
+ return crypto.randomBytes(bytes).toString("base64url");
58
+ }
59
+ function link(text, url) {
60
+ // OSC 8 hyperlink: clickable in iTerm2, Windows Terminal, GNOME Terminal, etc.
61
+ // Falls back to plain text in unsupported terminals.
62
+ return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
63
+ }
64
+ // ── Main ─────────────────────────────────────────────────────────────────────
65
+ async function main() {
66
+ const version = await getPackageVersion();
67
+ const program = new Command();
68
+ program
69
+ .name("elmo")
70
+ .version(version)
71
+ .action(() => {
72
+ printBanner();
73
+ program.outputHelp();
74
+ });
75
+ program
76
+ .command("init")
77
+ .description("set up local Elmo instance")
78
+ .option("--dev", "Use local build context (repo only)")
79
+ .option("--dir <path>", "Directory to store config files")
80
+ .option("--docker-dir <path>", "Path to Docker build context (dev mode)")
81
+ .action(async (options) => {
82
+ await withVersionCheck(version, () => runInit(options, version));
83
+ });
84
+ program
85
+ .command("regen")
86
+ .description("regenerate configuration files from current settings")
87
+ .option("--dir <path>", "Config directory")
88
+ .action(async (options) => {
89
+ await withVersionCheck(version, () => runRegen(options));
90
+ });
91
+ program
92
+ .command("start")
93
+ .description("start Elmo instance")
94
+ .option("--dir <path>", "Config directory")
95
+ .action(async (options) => {
96
+ await withVersionCheck(version, () => runStart(options));
97
+ });
98
+ program
99
+ .command("stop")
100
+ .description("stop Elmo instance")
101
+ .option("--dir <path>", "Config directory")
102
+ .action(async (options) => {
103
+ await withVersionCheck(version, () => runStop(options));
104
+ });
105
+ program
106
+ .command("status")
107
+ .description("check Elmo instance health")
108
+ .option("--dir <path>", "Config directory")
109
+ .action(async (options) => {
110
+ await withVersionCheck(version, () => runStatus(options));
111
+ });
112
+ program
113
+ .command("compose")
114
+ .description("run Docker Compose commands using your Elmo config")
115
+ .allowUnknownOption(true)
116
+ .option("--dir <path>", "Config directory")
117
+ .argument("[args...]", "Arguments passed to Docker Compose")
118
+ .action(async (args, options) => {
119
+ await withVersionCheck(version, () => runCompose(args, options));
120
+ });
121
+ program
122
+ .command("logs")
123
+ .description("view Elmo instance logs")
124
+ .allowUnknownOption(true)
125
+ .option("--dir <path>", "Config directory")
126
+ .argument("[args...]", "Arguments passed to Docker Compose logs")
127
+ .action(async (args, options) => {
128
+ await withVersionCheck(version, () => runCompose(["logs", ...args], options));
129
+ });
130
+ program
131
+ .command("build")
132
+ .description("build Docker images locally for development")
133
+ .option("--dir <path>", "Config directory")
134
+ .option("--web-only", "Only build the web image")
135
+ .option("--worker-only", "Only build the worker image")
136
+ .option("--no-cache", "Build without Docker cache")
137
+ .action(async (options) => {
138
+ await withVersionCheck(version, () => runBuild(options));
139
+ });
140
+ await program.parseAsync(process.argv);
141
+ }
142
+ async function withVersionCheck(version, fn) {
143
+ const notifyPromise = maybeNotifyNewVersion(version);
144
+ await fn();
145
+ await notifyPromise.catch(() => undefined);
146
+ }
147
+ // ── Command: init ────────────────────────────────────────────────────────────
148
+ async function runInit(options, version) {
149
+ printBanner();
150
+ if (isCI()) {
151
+ await runInitCI(options, version);
152
+ return;
153
+ }
154
+ await runInitInteractive(options, version);
155
+ }
156
+ async function runInitInteractive(options, version) {
157
+ p.intro(pc.bold("Setting up Elmo"));
158
+ const cwd = process.cwd();
159
+ // ── Resolve config directory ─────────────────────────────────────────
160
+ let configDir;
161
+ if (options.dir) {
162
+ configDir = path.resolve(cwd, options.dir);
163
+ }
164
+ else {
165
+ const dir = await p.text({
166
+ message: "Where should the config be stored?",
167
+ placeholder: "./elmo",
168
+ defaultValue: "./elmo",
169
+ });
170
+ assertNotCancelled(dir);
171
+ configDir = path.resolve(cwd, dir);
172
+ }
173
+ // ── .env safety check ────────────────────────────────────────────────
174
+ const existingEnvPath = path.join(configDir, ".env");
175
+ if (await fileExists(existingEnvPath)) {
176
+ const contents = await fs.readFile(existingEnvPath, "utf8");
177
+ const isElmoEnv = contents.startsWith("# Generated by elmo");
178
+ if (!isElmoEnv) {
179
+ p.log.warn(`A .env file already exists in ${configDir} and was NOT created by Elmo.`);
180
+ const overwrite = await p.confirm({
181
+ message: "Overwrite the existing .env file? This cannot be undone.",
182
+ initialValue: false,
183
+ });
184
+ assertNotCancelled(overwrite);
185
+ if (!overwrite) {
186
+ p.cancel("Setup cancelled. Choose a different directory with --dir.");
187
+ process.exit(0);
188
+ }
189
+ }
190
+ else {
191
+ p.log.info("Existing Elmo config found — it will be updated.");
192
+ }
193
+ }
194
+ // ── Dev mode: resolve docker directory ───────────────────────────────
195
+ let dockerDir;
196
+ let repoRoot;
197
+ if (options.dev) {
198
+ if (options.dockerDir) {
199
+ dockerDir = path.resolve(cwd, options.dockerDir);
200
+ if (!(await fileExists(path.join(dockerDir, "Dockerfile")))) {
201
+ p.log.error(`Dockerfile not found in ${dockerDir}`);
202
+ process.exit(1);
203
+ }
204
+ }
205
+ else {
206
+ dockerDir = await resolveDockerDirInteractive(cwd);
207
+ }
208
+ repoRoot = path.resolve(dockerDir, "..");
209
+ }
210
+ else {
211
+ repoRoot = cwd;
212
+ }
213
+ // ── Data stores ──────────────────────────────────────────────────────
214
+ const postgresMode = await p.select({
215
+ message: "PostgreSQL connection",
216
+ options: [
217
+ {
218
+ value: "docker",
219
+ label: "Run Postgres in Docker",
220
+ },
221
+ {
222
+ value: "external",
223
+ label: "Use existing Postgres (provide DATABASE_URL)",
224
+ },
225
+ ],
226
+ initialValue: "docker",
227
+ });
228
+ assertNotCancelled(postgresMode);
229
+ const env = {};
230
+ env.DEPLOYMENT_MODE = "local";
231
+ env.VITE_DEPLOYMENT_MODE = "local";
232
+ env.BETTER_AUTH_SECRET = generateSecret();
233
+ env.DEFAULT_ORG_ID = DEFAULT_ORG_ID;
234
+ env.DEFAULT_ORG_NAME = DEFAULT_ORG_NAME;
235
+ env.APP_NAME = DEFAULT_APP_NAME;
236
+ env.APP_ICON = DEFAULT_APP_ICON;
237
+ env.APP_URL = DEFAULT_APP_URL;
238
+ env.VITE_APP_NAME = DEFAULT_APP_NAME;
239
+ env.VITE_APP_ICON = DEFAULT_APP_ICON;
240
+ env.VITE_APP_URL = DEFAULT_APP_URL;
241
+ if (postgresMode === "external") {
242
+ const url = await p.text({
243
+ message: "DATABASE_URL",
244
+ validate: (v) => (!v ? "Required" : undefined),
245
+ });
246
+ assertNotCancelled(url);
247
+ env.DATABASE_URL = url;
248
+ }
249
+ else {
250
+ env.DATABASE_URL = LOCAL_DATABASE_URL;
251
+ }
252
+ // ── AI providers ─────────────────────────────────────────────────────
253
+ const setOpenai = await p.confirm({
254
+ message: "Set OpenAI credentials?",
255
+ initialValue: true,
256
+ });
257
+ assertNotCancelled(setOpenai);
258
+ if (setOpenai) {
259
+ const key = await p.text({
260
+ message: "OPENAI_API_KEY",
261
+ validate: (v) => (!v ? "Required" : undefined),
262
+ });
263
+ assertNotCancelled(key);
264
+ env.OPENAI_API_KEY = key;
265
+ }
266
+ const setAnthropic = await p.confirm({
267
+ message: "Set Anthropic credentials?",
268
+ initialValue: true,
269
+ });
270
+ assertNotCancelled(setAnthropic);
271
+ if (setAnthropic) {
272
+ const key = await p.text({
273
+ message: "ANTHROPIC_API_KEY",
274
+ validate: (v) => (!v ? "Required" : undefined),
275
+ });
276
+ assertNotCancelled(key);
277
+ env.ANTHROPIC_API_KEY = key;
278
+ }
279
+ const setDataforseo = await p.confirm({
280
+ message: "Set DataForSEO credentials?",
281
+ initialValue: false,
282
+ });
283
+ assertNotCancelled(setDataforseo);
284
+ if (setDataforseo) {
285
+ const login = await p.text({
286
+ message: "DATAFORSEO_LOGIN",
287
+ validate: (v) => (!v ? "Required" : undefined),
288
+ });
289
+ assertNotCancelled(login);
290
+ env.DATAFORSEO_LOGIN = login;
291
+ const pwd = await p.text({
292
+ message: "DATAFORSEO_PASSWORD",
293
+ validate: (v) => (!v ? "Required" : undefined),
294
+ });
295
+ assertNotCancelled(pwd);
296
+ env.DATAFORSEO_PASSWORD = pwd;
297
+ }
298
+ // ── Scrape targets ──────────────────────────────────────────────────
299
+ // Default based on which API keys were provided
300
+ const defaultTargets = [
301
+ env.OPENAI_API_KEY ? "chatgpt:openai-api:gpt-5-mini:online" : null,
302
+ env.ANTHROPIC_API_KEY ? "claude:anthropic-api:claude-sonnet-4" : null,
303
+ env.DATAFORSEO_LOGIN ? "google-ai-mode:dataforseo:online" : null,
304
+ ].filter(Boolean).join(",");
305
+ const scrapeTargets = await p.text({
306
+ message: "SCRAPE_TARGETS (model:provider[:version][:online], comma-separated)",
307
+ initialValue: defaultTargets || undefined,
308
+ placeholder: "chatgpt:openai-api:gpt-5-mini:online,claude:anthropic-api:claude-sonnet-4,google-ai-mode:dataforseo:online",
309
+ validate: (v) => (!v ? "Required" : undefined),
310
+ });
311
+ assertNotCancelled(scrapeTargets);
312
+ env.SCRAPE_TARGETS = scrapeTargets;
313
+ // ── Product updates ─────────────────────────────────────────────────
314
+ const updatesEmail = await p.text({
315
+ message: "Enter your email to receive product updates (optional)",
316
+ placeholder: "you@example.com",
317
+ });
318
+ const email = p.isCancel(updatesEmail) ? undefined : updatesEmail || undefined;
319
+ // ── Write config ─────────────────────────────────────────────────────
320
+ const composeYaml = buildComposeYaml({
321
+ dev: Boolean(options.dev),
322
+ postgresMode,
323
+ repoRoot,
324
+ dockerDir,
325
+ });
326
+ await ensureDir(configDir);
327
+ await writeConfigFiles(configDir, {
328
+ env,
329
+ composeYaml,
330
+ postgresMode,
331
+ dev: Boolean(options.dev),
332
+ });
333
+ await writeGlobalConfig({
334
+ configDir,
335
+ dockerDir,
336
+ dev: Boolean(options.dev),
337
+ postgresMode,
338
+ repoRoot,
339
+ });
340
+ p.log.success(`Config written to ${configDir}`);
341
+ p.log.warn("Your .env contains secrets — do not commit it to version control.");
342
+ if (options.dev) {
343
+ p.log.info("Dev mode enabled. Run `elmo compose build` before starting.");
344
+ }
345
+ const shouldStart = await p.confirm({
346
+ message: "Start the stack now?",
347
+ initialValue: true,
348
+ });
349
+ assertNotCancelled(shouldStart);
350
+ if (shouldStart) {
351
+ await doStart(configDir);
352
+ }
353
+ else {
354
+ p.log.info("You can start later with `elmo start`.");
355
+ }
356
+ // Fire telemetry in the background — never blocks the CLI
357
+ await trackCliEvent("cli_init", {
358
+ version,
359
+ os: process.platform,
360
+ arch: process.arch,
361
+ node_version: process.version,
362
+ postgres_mode: postgresMode,
363
+ dev_mode: Boolean(options.dev),
364
+ }, email ? { $email: email, wants_updates: true } : undefined);
365
+ 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")}`);
366
+ p.outro(pc.green("Setup complete!"));
367
+ }
368
+ async function runInitCI(options, version) {
369
+ const cwd = process.cwd();
370
+ // Resolve config directory — --dir flag or ELMO_CONFIG_DIR or cwd
371
+ const dirArg = options.dir ?? process.env.ELMO_CONFIG_DIR ?? cwd;
372
+ const configDir = path.resolve(cwd, dirArg);
373
+ // Resolve docker directory for dev mode
374
+ let dockerDir;
375
+ let repoRoot;
376
+ if (options.dev) {
377
+ const explicitDockerDir = options.dockerDir ?? process.env.ELMO_DOCKER_DIR;
378
+ dockerDir = await resolveDockerDirAuto(cwd, explicitDockerDir);
379
+ repoRoot = path.resolve(dockerDir, "..");
380
+ }
381
+ else {
382
+ repoRoot = cwd;
383
+ }
384
+ // Build env from defaults and env vars
385
+ const postgresMode = process.env.ELMO_POSTGRES_MODE ?? "docker";
386
+ const env = {};
387
+ env.DEPLOYMENT_MODE = "local";
388
+ env.VITE_DEPLOYMENT_MODE = "local";
389
+ env.BETTER_AUTH_SECRET = generateSecret();
390
+ env.DEFAULT_ORG_ID = DEFAULT_ORG_ID;
391
+ env.DEFAULT_ORG_NAME = DEFAULT_ORG_NAME;
392
+ env.APP_NAME = DEFAULT_APP_NAME;
393
+ env.APP_ICON = DEFAULT_APP_ICON;
394
+ env.APP_URL = DEFAULT_APP_URL;
395
+ env.VITE_APP_NAME = DEFAULT_APP_NAME;
396
+ env.VITE_APP_ICON = DEFAULT_APP_ICON;
397
+ env.VITE_APP_URL = DEFAULT_APP_URL;
398
+ if (postgresMode === "external") {
399
+ env.DATABASE_URL = process.env.ELMO_DATABASE_URL ?? "";
400
+ }
401
+ else {
402
+ env.DATABASE_URL = LOCAL_DATABASE_URL;
403
+ }
404
+ // AI providers from env vars
405
+ if (process.env.ELMO_OPENAI_API_KEY) {
406
+ env.OPENAI_API_KEY = process.env.ELMO_OPENAI_API_KEY;
407
+ }
408
+ if (process.env.ELMO_ANTHROPIC_API_KEY) {
409
+ env.ANTHROPIC_API_KEY = process.env.ELMO_ANTHROPIC_API_KEY;
410
+ }
411
+ if (process.env.ELMO_DATAFORSEO_LOGIN) {
412
+ env.DATAFORSEO_LOGIN = process.env.ELMO_DATAFORSEO_LOGIN;
413
+ env.DATAFORSEO_PASSWORD =
414
+ process.env.ELMO_DATAFORSEO_PASSWORD ?? "";
415
+ }
416
+ if (process.env.ELMO_SCRAPE_TARGETS) {
417
+ env.SCRAPE_TARGETS = process.env.ELMO_SCRAPE_TARGETS;
418
+ }
419
+ else {
420
+ // Default: use the direct API providers matching the old hardcoded behavior
421
+ env.SCRAPE_TARGETS = "chatgpt:openai-api:gpt-5-mini:online,claude:anthropic-api:claude-sonnet-4,google-ai-mode:dataforseo:online";
422
+ }
423
+ const composeYaml = buildComposeYaml({
424
+ dev: Boolean(options.dev),
425
+ postgresMode,
426
+ repoRoot,
427
+ dockerDir,
428
+ });
429
+ await ensureDir(configDir);
430
+ await writeConfigFiles(configDir, {
431
+ env,
432
+ composeYaml,
433
+ postgresMode,
434
+ dev: Boolean(options.dev),
435
+ });
436
+ await writeGlobalConfig({
437
+ configDir,
438
+ dockerDir,
439
+ dev: Boolean(options.dev),
440
+ postgresMode,
441
+ repoRoot,
442
+ });
443
+ console.log(`Config written to ${configDir}`);
444
+ await trackCliEvent("cli_init", {
445
+ version,
446
+ os: process.platform,
447
+ arch: process.arch,
448
+ node_version: process.version,
449
+ postgres_mode: postgresMode,
450
+ dev_mode: Boolean(options.dev),
451
+ });
452
+ }
453
+ // ── Command: update ──────────────────────────────────────────────────────────
454
+ async function runRegen(options) {
455
+ const configDir = await resolveConfigDir(options.dir);
456
+ const globalConfig = await readGlobalConfig();
457
+ // Determine settings: prefer global config, fall back to detecting from files
458
+ let dev;
459
+ let postgresMode;
460
+ let repoRoot;
461
+ let dockerDir;
462
+ if (globalConfig?.postgresMode) {
463
+ dev = globalConfig.dev ?? false;
464
+ postgresMode = globalConfig.postgresMode;
465
+ repoRoot = globalConfig.repoRoot ?? process.cwd();
466
+ dockerDir = globalConfig.dockerDir;
467
+ }
468
+ else {
469
+ const detected = await detectSettingsFromConfig(configDir);
470
+ dev = detected.dev;
471
+ postgresMode = detected.postgresMode;
472
+ repoRoot = detected.repoRoot;
473
+ dockerDir = detected.dockerDir;
474
+ }
475
+ const composeYaml = buildComposeYaml({
476
+ dev,
477
+ postgresMode,
478
+ repoRoot,
479
+ dockerDir,
480
+ });
481
+ const composePath = path.join(configDir, "elmo.yaml");
482
+ await fs.writeFile(composePath, composeYaml, "utf8");
483
+ await writeGlobalConfig({
484
+ configDir,
485
+ dockerDir,
486
+ dev,
487
+ postgresMode,
488
+ repoRoot,
489
+ });
490
+ log.success(`Regenerated ${composePath}`);
491
+ log.info("The .env file was not modified.");
492
+ }
493
+ // ── Command: start ───────────────────────────────────────────────────────────
494
+ async function runStart(options) {
495
+ const configDir = await resolveConfigDir(options.dir);
496
+ await doStart(configDir);
497
+ }
498
+ async function doStart(configDir) {
499
+ assertDockerRunning();
500
+ log.step("Starting Docker Compose stack...");
501
+ await runDockerCompose(configDir, ["up", "-d"]);
502
+ if (!isCI()) {
503
+ const s = p.spinner();
504
+ s.start("Waiting for services to become healthy...");
505
+ const ok = await waitForHealthy(configDir, 180_000);
506
+ if (ok) {
507
+ s.stop("All services healthy!");
508
+ }
509
+ else {
510
+ s.stop("Health check timed out.");
511
+ p.log.warn("Some services did not report healthy status.");
512
+ }
513
+ }
514
+ else {
515
+ console.log("Waiting for services to become healthy...");
516
+ const ok = await waitForHealthy(configDir, 180_000);
517
+ if (ok) {
518
+ console.log("All services healthy.");
519
+ }
520
+ else {
521
+ console.warn("Some services did not report healthy status.");
522
+ }
523
+ }
524
+ log.info("Examples:");
525
+ console.log(` ${pc.bold("elmo logs -f")}`);
526
+ console.log(` ${pc.bold("elmo compose logs -f web")}`);
527
+ console.log(` ${pc.bold("elmo compose ps")}`);
528
+ }
529
+ // ── Command: stop ────────────────────────────────────────────────────────────
530
+ async function runStop(options) {
531
+ const configDir = await resolveConfigDir(options.dir);
532
+ assertDockerRunning();
533
+ log.step("Stopping Docker Compose stack...");
534
+ await runDockerCompose(configDir, ["down"]);
535
+ log.success("Stack stopped.");
536
+ }
537
+ // ── Command: status ──────────────────────────────────────────────────────────
538
+ async function runStatus(options) {
539
+ const configDir = await resolveConfigDir(options.dir);
540
+ assertDockerRunning();
541
+ const services = await getComposeServices(configDir);
542
+ if (services.length === 0) {
543
+ log.warn("No running services found.");
544
+ return;
545
+ }
546
+ let allHealthy = true;
547
+ for (const service of services) {
548
+ const status = formatServiceStatus(service);
549
+ console.log(status);
550
+ if (!isServiceReady(service)) {
551
+ allHealthy = false;
552
+ }
553
+ }
554
+ if (allHealthy) {
555
+ log.success("All services are healthy.");
556
+ }
557
+ else {
558
+ log.warn("Some services are not healthy yet.");
559
+ }
560
+ }
561
+ // ── Command: compose ─────────────────────────────────────────────────────────
562
+ async function runCompose(args, options) {
563
+ const configDir = await resolveConfigDir(options.dir);
564
+ assertDockerRunning();
565
+ await runDockerCompose(configDir, args);
566
+ }
567
+ // ── Command: build ───────────────────────────────────────────────────────────
568
+ async function runBuild(options) {
569
+ const configDir = await resolveConfigDir(options.dir);
570
+ assertDockerRunning();
571
+ const buildWeb = !options.workerOnly;
572
+ const buildWorker = !options.webOnly;
573
+ const noCache = options.cache === false;
574
+ const targets = [];
575
+ if (buildWeb)
576
+ targets.push("web");
577
+ if (buildWorker)
578
+ targets.push("worker");
579
+ log.step(`Building Docker images: ${targets.join(", ")}${noCache ? " (no cache)" : ""}...`);
580
+ const buildArgs = ["build"];
581
+ if (noCache)
582
+ buildArgs.push("--no-cache");
583
+ for (const target of targets) {
584
+ log.step(`Building ${target}...`);
585
+ await runDockerCompose(configDir, [...buildArgs, target]);
586
+ log.success(`${target} image built successfully.`);
587
+ }
588
+ log.success("All images built.");
589
+ console.log(` Run ${pc.bold("elmo start")} to start the stack.`);
590
+ }
591
+ // ── Compose YAML Builder ─────────────────────────────────────────────────────
592
+ function buildComposeYaml(options) {
593
+ const services = [];
594
+ const volumes = new Set();
595
+ const dependsOnWeb = [];
596
+ const dependsOnWorker = [];
597
+ const dependencyConditions = {
598
+ postgres: "service_healthy",
599
+ "db-migrate": "service_completed_successfully",
600
+ };
601
+ const dockerfilePath = options.dockerDir
602
+ ? path.relative(options.repoRoot, path.join(options.dockerDir, "Dockerfile"))
603
+ : "docker/Dockerfile";
604
+ if (options.postgresMode === "docker") {
605
+ services.push(buildPostgresService());
606
+ services.push(buildDbMigrateService({
607
+ dockerfilePath,
608
+ repoRoot: options.repoRoot,
609
+ }));
610
+ dependsOnWeb.push("db-migrate");
611
+ dependsOnWorker.push("db-migrate");
612
+ volumes.add("postgres_data");
613
+ }
614
+ services.push(buildWebService({
615
+ dev: options.dev,
616
+ dependsOn: dependsOnWeb,
617
+ dependencyConditions,
618
+ repoRoot: options.repoRoot,
619
+ dockerfilePath,
620
+ }));
621
+ services.push(buildWorkerService({
622
+ dev: options.dev,
623
+ dependsOn: dependsOnWorker,
624
+ dependencyConditions,
625
+ repoRoot: options.repoRoot,
626
+ dockerfilePath,
627
+ }));
628
+ const lines = ["name: elmo", "", "services:"];
629
+ lines.push(...services.map((service) => indentBlock(service, 2)));
630
+ if (volumes.size > 0) {
631
+ lines.push("", "volumes:");
632
+ for (const volume of volumes) {
633
+ lines.push(` ${volume}:`);
634
+ }
635
+ }
636
+ return `${lines.join("\n")}\n`;
637
+ }
638
+ function buildPostgresService() {
639
+ return [
640
+ "postgres:",
641
+ " image: postgres:16-alpine",
642
+ " environment:",
643
+ " POSTGRES_USER: postgres",
644
+ " POSTGRES_PASSWORD: postgres",
645
+ " POSTGRES_DB: elmo",
646
+ " volumes:",
647
+ " - postgres_data:/var/lib/postgresql/data",
648
+ " ports:",
649
+ ' - "5432:5432"',
650
+ " healthcheck:",
651
+ ' test: ["CMD-SHELL", "pg_isready -U postgres"]',
652
+ " interval: 5s",
653
+ " timeout: 5s",
654
+ " retries: 5",
655
+ " start_period: 30s",
656
+ ].join("\n");
657
+ }
658
+ function buildDbMigrateService(options) {
659
+ return [
660
+ "db-migrate:",
661
+ " build:",
662
+ ` context: ${options.repoRoot}`,
663
+ ` dockerfile: ${options.dockerfilePath}`,
664
+ " target: migrate",
665
+ " environment:",
666
+ " - DATABASE_URL=postgres://postgres:postgres@postgres:5432/elmo",
667
+ " depends_on:",
668
+ " postgres:",
669
+ " condition: service_healthy",
670
+ ].join("\n");
671
+ }
672
+ function buildWebService(options) {
673
+ const lines = ["web:"];
674
+ if (options.dev) {
675
+ lines.push(" build:", ` context: ${options.repoRoot}`, ` dockerfile: ${options.dockerfilePath}`, " target: web", " args:", " DEPLOYMENT_MODE: local");
676
+ }
677
+ else {
678
+ lines.push(" image: elmohq/web:latest");
679
+ }
680
+ lines.push(" env_file:", " - path: .env", " required: true", " ports:", ' - "1515:3000"');
681
+ if (options.dependsOn.length > 0) {
682
+ lines.push(" depends_on:");
683
+ for (const service of options.dependsOn) {
684
+ const condition = options.dependencyConditions[service] ??
685
+ "service_started";
686
+ lines.push(` ${service}:`, ` condition: ${condition}`);
687
+ }
688
+ }
689
+ return lines.join("\n");
690
+ }
691
+ function buildWorkerService(options) {
692
+ const lines = ["worker:"];
693
+ if (options.dev) {
694
+ lines.push(" build:", ` context: ${options.repoRoot}`, ` dockerfile: ${options.dockerfilePath}`, " target: worker", " args:", " DEPLOYMENT_MODE: local");
695
+ }
696
+ else {
697
+ lines.push(" image: elmohq/worker:latest");
698
+ }
699
+ lines.push(" env_file:", " - path: .env", " required: true");
700
+ if (options.dependsOn.length > 0) {
701
+ lines.push(" depends_on:");
702
+ for (const service of options.dependsOn) {
703
+ const condition = options.dependencyConditions[service] ??
704
+ "service_started";
705
+ lines.push(` ${service}:`, ` condition: ${condition}`);
706
+ }
707
+ }
708
+ return lines.join("\n");
709
+ }
710
+ function indentBlock(block, spaces) {
711
+ const indent = " ".repeat(spaces);
712
+ return block
713
+ .split("\n")
714
+ .map((line) => `${indent}${line}`)
715
+ .join("\n");
716
+ }
717
+ // ── Docker Helpers ───────────────────────────────────────────────────────────
718
+ async function getComposeServices(configDir) {
719
+ const output = await runDockerComposeCapture(configDir, [
720
+ "ps",
721
+ "--format",
722
+ "json",
723
+ ]);
724
+ if (!output.trim()) {
725
+ return [];
726
+ }
727
+ try {
728
+ const trimmed = output.trim();
729
+ const parsed = JSON.parse(trimmed);
730
+ if (Array.isArray(parsed)) {
731
+ return parsed;
732
+ }
733
+ if (typeof parsed === "object" && parsed !== null) {
734
+ return [parsed];
735
+ }
736
+ return [];
737
+ }
738
+ catch {
739
+ try {
740
+ return output
741
+ .trim()
742
+ .split("\n")
743
+ .filter((line) => line.trim())
744
+ .map((line) => JSON.parse(line));
745
+ }
746
+ catch {
747
+ log.warn("Unable to parse docker compose status.");
748
+ return [];
749
+ }
750
+ }
751
+ }
752
+ function formatServiceStatus(service) {
753
+ const health = service.Health ?? "unknown";
754
+ const state = service.State ?? "unknown";
755
+ const label = `${service.Service}`.padEnd(16, " ");
756
+ let color = pc.red;
757
+ if (isServiceReady(service)) {
758
+ color = pc.green;
759
+ }
760
+ else if (health === "starting" ||
761
+ state.includes("starting")) {
762
+ color = pc.yellow;
763
+ }
764
+ return color(`${label} ${state} ${service.Health ? `(${health})` : ""}`.trim());
765
+ }
766
+ function isServiceReady(service) {
767
+ if (service.Health) {
768
+ return service.Health === "healthy";
769
+ }
770
+ if (service.State?.startsWith("running")) {
771
+ return true;
772
+ }
773
+ return false;
774
+ }
775
+ async function waitForHealthy(configDir, timeoutMs) {
776
+ const start = Date.now();
777
+ while (Date.now() - start < timeoutMs) {
778
+ const services = await getComposeServices(configDir);
779
+ if (services.length > 0 &&
780
+ services.every(isServiceReady)) {
781
+ return true;
782
+ }
783
+ await sleep(3000);
784
+ }
785
+ return false;
786
+ }
787
+ function sleep(ms) {
788
+ return new Promise((resolve) => setTimeout(resolve, ms));
789
+ }
790
+ function runDockerCompose(configDir, args) {
791
+ return new Promise((resolve, reject) => {
792
+ const composeFile = path.join(configDir, "elmo.yaml");
793
+ const commandArgs = [
794
+ "compose",
795
+ "-f",
796
+ composeFile,
797
+ ...args,
798
+ ];
799
+ const child = spawn("docker", commandArgs, {
800
+ stdio: "inherit",
801
+ });
802
+ child.on("close", (code) => {
803
+ if (code === 0) {
804
+ resolve();
805
+ }
806
+ else {
807
+ reject(new Error(`docker compose exited with code ${code}`));
808
+ }
809
+ });
810
+ });
811
+ }
812
+ function runDockerComposeCapture(configDir, args) {
813
+ return new Promise((resolve, reject) => {
814
+ const composeFile = path.join(configDir, "elmo.yaml");
815
+ const commandArgs = [
816
+ "compose",
817
+ "-f",
818
+ composeFile,
819
+ ...args,
820
+ ];
821
+ const child = spawn("docker", commandArgs);
822
+ let stdout = "";
823
+ let stderr = "";
824
+ child.stdout.on("data", (data) => {
825
+ stdout += data.toString();
826
+ });
827
+ child.stderr.on("data", (data) => {
828
+ stderr += data.toString();
829
+ });
830
+ child.on("close", (code) => {
831
+ if (code === 0) {
832
+ resolve(stdout);
833
+ }
834
+ else {
835
+ reject(new Error(stderr ||
836
+ `docker compose exited with code ${code}`));
837
+ }
838
+ });
839
+ });
840
+ }
841
+ function assertDockerRunning() {
842
+ const result = spawnSync("docker", ["info"], {
843
+ stdio: "ignore",
844
+ });
845
+ if (result.status !== 0) {
846
+ throw new Error("Docker does not appear to be running. Start Docker and try again.");
847
+ }
848
+ }
849
+ // ── Docker Dir Resolution ────────────────────────────────────────────────────
850
+ async function resolveDockerDirInteractive(cwd) {
851
+ const inCwd = await fileExists(path.join(cwd, "Dockerfile"));
852
+ const inDockerDir = await fileExists(path.join(cwd, "docker", "Dockerfile"));
853
+ const defaultDir = inCwd
854
+ ? "."
855
+ : inDockerDir
856
+ ? "docker"
857
+ : ".";
858
+ const dir = await p.text({
859
+ message: "Path to docker directory (contains Dockerfile)",
860
+ defaultValue: defaultDir,
861
+ });
862
+ assertNotCancelled(dir);
863
+ const resolved = path.resolve(cwd, dir);
864
+ if (!(await fileExists(path.join(resolved, "Dockerfile")))) {
865
+ p.log.error(`Dockerfile not found in ${resolved}. Provide the directory that contains Dockerfile.`);
866
+ process.exit(1);
867
+ }
868
+ return resolved;
869
+ }
870
+ async function resolveDockerDirAuto(cwd, explicitDir) {
871
+ if (explicitDir) {
872
+ const resolved = path.resolve(cwd, explicitDir);
873
+ if (!(await fileExists(path.join(resolved, "Dockerfile")))) {
874
+ throw new Error(`Dockerfile not found in ${resolved}`);
875
+ }
876
+ return resolved;
877
+ }
878
+ // Auto-detect
879
+ if (await fileExists(path.join(cwd, "docker", "Dockerfile"))) {
880
+ return path.resolve(cwd, "docker");
881
+ }
882
+ if (await fileExists(path.join(cwd, "Dockerfile"))) {
883
+ return cwd;
884
+ }
885
+ throw new Error("Could not find Dockerfile. Specify --docker-dir or set ELMO_DOCKER_DIR.");
886
+ }
887
+ // ── Config Dir Resolution ────────────────────────────────────────────────────
888
+ async function resolveConfigDir(explicitDir) {
889
+ if (explicitDir) {
890
+ const resolved = path.resolve(process.cwd(), explicitDir);
891
+ const composePath = path.join(resolved, "elmo.yaml");
892
+ if (!(await fileExists(composePath))) {
893
+ throw new Error(`Config directory does not contain elmo.yaml: ${resolved}\nRun \`elmo init\` to create your local setup.`);
894
+ }
895
+ return resolved;
896
+ }
897
+ const config = await readGlobalConfig();
898
+ if (!config?.configDir) {
899
+ throw new Error("No config found. Run `elmo init` or specify --dir.");
900
+ }
901
+ const resolved = path.resolve(config.configDir);
902
+ const composePath = path.join(resolved, "elmo.yaml");
903
+ if (!(await fileExists(composePath))) {
904
+ throw new Error(`Config directory does not contain elmo.yaml: ${resolved}\nRun \`elmo init\` to regenerate.`);
905
+ }
906
+ return resolved;
907
+ }
908
+ // ── File & Config Helpers ────────────────────────────────────────────────────
909
+ async function readGlobalConfig() {
910
+ try {
911
+ const contents = await fs.readFile(CONFIG_FILE, "utf8");
912
+ return JSON.parse(contents);
913
+ }
914
+ catch {
915
+ return null;
916
+ }
917
+ }
918
+ async function writeGlobalConfig(config) {
919
+ const globalConfig = {
920
+ ...config,
921
+ updatedAt: new Date().toISOString(),
922
+ };
923
+ await ensureDir(CONFIG_HOME);
924
+ await fs.writeFile(CONFIG_FILE, JSON.stringify(globalConfig, null, 2), "utf8");
925
+ }
926
+ async function writeConfigFiles(configDir, initConfig) {
927
+ const envPath = path.join(configDir, ".env");
928
+ const composePath = path.join(configDir, "elmo.yaml");
929
+ await ensureDir(configDir);
930
+ await fs.writeFile(envPath, buildEnvFile(initConfig.env), "utf8");
931
+ await fs.writeFile(composePath, initConfig.composeYaml, "utf8");
932
+ }
933
+ function buildEnvFile(env) {
934
+ const lines = [
935
+ "# Generated by elmo init",
936
+ "# WARNING: contains secrets. Do not commit.",
937
+ "",
938
+ ];
939
+ for (const [key, rawValue] of Object.entries(env)) {
940
+ if (rawValue === undefined) {
941
+ continue;
942
+ }
943
+ lines.push(`${key}=${formatEnvValue(rawValue)}`);
944
+ }
945
+ return `${lines.join("\n")}\n`;
946
+ }
947
+ function formatEnvValue(value) {
948
+ if (value === "") {
949
+ return '""';
950
+ }
951
+ if (/[\s#"']/u.test(value)) {
952
+ const escaped = value
953
+ .replace(/\\/g, "\\\\")
954
+ .replace(/"/g, '\\"');
955
+ return `"${escaped}"`;
956
+ }
957
+ return value;
958
+ }
959
+ async function detectSettingsFromConfig(configDir) {
960
+ const envPath = path.join(configDir, ".env");
961
+ const env = await readEnvFile(envPath);
962
+ const postgresMode = env.DATABASE_URL?.includes("postgres:5432")
963
+ ? "docker"
964
+ : "external";
965
+ const yamlPath = path.join(configDir, "elmo.yaml");
966
+ let dev = false;
967
+ let repoRoot = process.cwd();
968
+ try {
969
+ const yamlContent = await fs.readFile(yamlPath, "utf8");
970
+ dev = yamlContent.includes("build:");
971
+ const contextMatch = yamlContent.match(/context:\s+(.+)/);
972
+ if (contextMatch) {
973
+ repoRoot = contextMatch[1].trim();
974
+ }
975
+ }
976
+ catch {
977
+ // Use defaults
978
+ }
979
+ return { dev, postgresMode, repoRoot };
980
+ }
981
+ async function readEnvFile(envPath) {
982
+ try {
983
+ const contents = await fs.readFile(envPath, "utf8");
984
+ const env = {};
985
+ for (const line of contents.split("\n")) {
986
+ const trimmed = line.trim();
987
+ if (!trimmed || trimmed.startsWith("#"))
988
+ continue;
989
+ const eqIndex = trimmed.indexOf("=");
990
+ if (eqIndex < 0)
991
+ continue;
992
+ const key = trimmed.substring(0, eqIndex);
993
+ let value = trimmed.substring(eqIndex + 1);
994
+ // Remove surrounding quotes
995
+ if ((value.startsWith('"') && value.endsWith('"')) ||
996
+ (value.startsWith("'") && value.endsWith("'"))) {
997
+ value = value.slice(1, -1);
998
+ }
999
+ env[key] = value;
1000
+ }
1001
+ return env;
1002
+ }
1003
+ catch {
1004
+ return {};
1005
+ }
1006
+ }
1007
+ async function fileExists(target) {
1008
+ try {
1009
+ await fs.access(target);
1010
+ return true;
1011
+ }
1012
+ catch {
1013
+ return false;
1014
+ }
1015
+ }
1016
+ async function ensureDir(dir) {
1017
+ await fs.mkdir(dir, { recursive: true });
1018
+ }
1019
+ // ── Version Helpers ──────────────────────────────────────────────────────────
1020
+ async function getPackageVersion() {
1021
+ const selfDir = path.dirname(fileURLToPath(import.meta.url));
1022
+ const packagePath = path.resolve(selfDir, "..", "package.json");
1023
+ const contents = await fs.readFile(packagePath, "utf8");
1024
+ const json = JSON.parse(contents);
1025
+ return json.version;
1026
+ }
1027
+ async function maybeNotifyNewVersion(currentVersion) {
1028
+ try {
1029
+ const response = await fetch("https://registry.npmjs.org/@elmohq/cli/latest");
1030
+ if (!response.ok) {
1031
+ return;
1032
+ }
1033
+ const data = (await response.json());
1034
+ if (!data.version) {
1035
+ return;
1036
+ }
1037
+ if (semver.valid(currentVersion) &&
1038
+ semver.lt(currentVersion, data.version)) {
1039
+ log.warn(`New CLI version available (${data.version}). Run: npm install -g @elmohq/cli@latest`);
1040
+ }
1041
+ }
1042
+ catch {
1043
+ // Ignore update errors
1044
+ }
1045
+ }
1046
+ // ── Entry Point ──────────────────────────────────────────────────────────────
1047
+ main().catch((error) => {
1048
+ const msg = error instanceof Error ? error.message : String(error);
1049
+ console.error(`\n${pc.red("Error:")} ${msg}`);
1050
+ process.exit(1);
1051
+ });
@@ -0,0 +1,51 @@
1
+ import { PostHog } from "posthog-node";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import crypto from "node:crypto";
6
+ const POSTHOG_PUBLIC_KEY = "phc_Jhx9LnI9cTDFHpQmpOzJSDTW127qD9pFU65KRnYym6z";
7
+ const POSTHOG_HOST = "https://us.i.posthog.com";
8
+ const CONFIG_HOME = path.join(os.homedir(), ".config", "elmo");
9
+ const CONFIG_FILE = path.join(CONFIG_HOME, "config.json");
10
+ function isTelemetryDisabled() {
11
+ return Boolean(process.env.DISABLE_TELEMETRY);
12
+ }
13
+ async function readConfig() {
14
+ try {
15
+ const contents = await fs.readFile(CONFIG_FILE, "utf8");
16
+ return JSON.parse(contents);
17
+ }
18
+ catch {
19
+ return {};
20
+ }
21
+ }
22
+ async function writeConfig(config) {
23
+ await fs.mkdir(CONFIG_HOME, { recursive: true });
24
+ await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), "utf8");
25
+ }
26
+ async function getOrCreateDistinctId() {
27
+ const config = await readConfig();
28
+ if (config.telemetryId)
29
+ return config.telemetryId;
30
+ const id = crypto.randomUUID();
31
+ await writeConfig({ ...config, telemetryId: id });
32
+ return id;
33
+ }
34
+ export async function trackCliEvent(eventName, properties, personProperties) {
35
+ if (isTelemetryDisabled())
36
+ return;
37
+ try {
38
+ const distinctId = await getOrCreateDistinctId();
39
+ const client = new PostHog(POSTHOG_PUBLIC_KEY, { host: POSTHOG_HOST });
40
+ client.capture({
41
+ distinctId,
42
+ event: eventName,
43
+ properties,
44
+ ...(personProperties ? { $set: personProperties } : {}),
45
+ });
46
+ await client.shutdown();
47
+ }
48
+ catch {
49
+ // Telemetry should never block the CLI
50
+ }
51
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@elmohq/cli",
3
+ "version": "0.2.0",
4
+ "bin": {
5
+ "elmo": "dist/index.js"
6
+ },
7
+ "files": [
8
+ "dist",
9
+ "templates"
10
+ ],
11
+ "type": "module",
12
+ "dependencies": {
13
+ "@clack/prompts": "^1.2.0",
14
+ "commander": "^14.0.3",
15
+ "picocolors": "^1.1.1",
16
+ "posthog-node": "^5.29.2",
17
+ "semver": "^7.7.4"
18
+ },
19
+ "devDependencies": {
20
+ "@types/node": "^25.6.0",
21
+ "@types/semver": "^7.7.1",
22
+ "typescript": "^5.9.3"
23
+ },
24
+ "scripts": {
25
+ "build": "tsc -p tsconfig.json"
26
+ }
27
+ }