@elmohq/cli 0.2.11 → 0.2.13

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 CHANGED
@@ -7,318 +7,345 @@ 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
+ import { parse } from "dotenv";
11
11
  import pc from "picocolors";
12
12
  import semver from "semver";
13
- import { submitNewsletterSignup, trackCliEvent } from "./telemetry.js";
14
- // ── Constants ────────────────────────────────────────────────────────────────
13
+ import { PostHog } from "posthog-node";
14
+ //#region ../../packages/config/src/scrape-targets.ts
15
+ /**
16
+ * Parse the SCRAPE_TARGETS env var into structured ModelConfig objects.
17
+ *
18
+ * Parsing: split on ":". First = model, second = provider. If last segment is "online",
19
+ * pop it (websearch = true). Remaining middle segments rejoined with ":" = version slug.
20
+ * This naturally handles OpenRouter variant suffixes like ":free".
21
+ */
22
+ function parseScrapeTargets(envValue) {
23
+ if (!envValue || !envValue.trim()) throw new Error("SCRAPE_TARGETS environment variable is required. Set it to configure which AI models to track. Example:\n SCRAPE_TARGETS=chatgpt:olostep:online,google-ai-mode:olostep:online,copilot:olostep:online\nSee https://docs.elmohq.com/docs/user-guide/providers for details.");
24
+ return envValue.split(",").map((raw) => {
25
+ const trimmed = raw.trim();
26
+ if (!trimmed) throw new Error("Invalid SCRAPE_TARGETS: empty entry (check for trailing commas)");
27
+ const parts = trimmed.split(":");
28
+ if (parts.length < 2) throw new Error(`Invalid SCRAPE_TARGETS entry: "${trimmed}" (need at least model:provider)`);
29
+ const model = parts[0];
30
+ const provider = parts[1];
31
+ const webSearch = parts[parts.length - 1] === "online";
32
+ const versionParts = parts.slice(2, webSearch ? -1 : void 0);
33
+ return {
34
+ model,
35
+ provider,
36
+ version: versionParts.length > 0 ? versionParts.join(":") : void 0,
37
+ webSearch
38
+ };
39
+ });
40
+ }
41
+ /**
42
+ * Inverse of parseScrapeTargets for a single entry: build the
43
+ * model:provider[:version][:online] string from a ModelConfig.
44
+ */
45
+ function formatScrapeTarget(config) {
46
+ const parts = [config.model, config.provider];
47
+ if (config.version) parts.push(config.version);
48
+ if (config.webSearch) parts.push("online");
49
+ return parts.join(":");
50
+ }
51
+ //#endregion
52
+ //#region src/telemetry.ts
53
+ const POSTHOG_PUBLIC_KEY = "phc_Jhx9LnI9cTDFHpQmpOzJSDTW127qD9pFU65KRnYym6z";
54
+ const POSTHOG_HOST = "https://us.i.posthog.com";
55
+ function envDisabled() {
56
+ return Boolean(process.env.DISABLE_TELEMETRY);
57
+ }
58
+ async function readEnvKey(configDir, key) {
59
+ try {
60
+ return parse(await fs.readFile(path.join(configDir, ".env"), "utf8"))[key];
61
+ } catch {
62
+ return;
63
+ }
64
+ }
65
+ async function isTelemetryDisabled(configDir) {
66
+ if (envDisabled()) return true;
67
+ return Boolean(await readEnvKey(configDir, "DISABLE_TELEMETRY"));
68
+ }
69
+ async function trackCliEvent(configDir, eventName, properties, personProperties) {
70
+ if (await isTelemetryDisabled(configDir)) return;
71
+ const distinctId = await readEnvKey(configDir, "DEPLOYMENT_ID");
72
+ if (!distinctId) return;
73
+ try {
74
+ const client = new PostHog(POSTHOG_PUBLIC_KEY, { host: POSTHOG_HOST });
75
+ client.capture({
76
+ distinctId,
77
+ event: eventName,
78
+ properties: {
79
+ ...properties,
80
+ ...personProperties ? { $set: personProperties } : {}
81
+ }
82
+ });
83
+ await client.shutdown();
84
+ } catch {}
85
+ }
86
+ async function submitNewsletterSignup(configDir, email) {
87
+ try {
88
+ const distinctId = (await isTelemetryDisabled(configDir) ? null : await readEnvKey(configDir, "DEPLOYMENT_ID")) ?? email;
89
+ const client = new PostHog(POSTHOG_PUBLIC_KEY, { host: POSTHOG_HOST });
90
+ client.capture({
91
+ distinctId,
92
+ event: "newsletter_signup",
93
+ properties: {
94
+ source: "cli_init",
95
+ $set: {
96
+ email,
97
+ wants_updates: true
98
+ }
99
+ }
100
+ });
101
+ await client.shutdown();
102
+ } catch {}
103
+ }
104
+ //#endregion
105
+ //#region src/index.ts
15
106
  const CONFIG_HOME = path.join(os.homedir(), ".elmo");
16
107
  const DEFAULT_APP_NAME = "Elmo";
17
108
  const DEFAULT_APP_ICON = "/icons/elmo-icon.svg";
18
109
  const DEFAULT_APP_PORT = 1515;
19
110
  const LOCAL_DATABASE_URL = "postgres://postgres:postgres@postgres:5432/elmo";
20
111
  const TELEMETRY_DOC_URL = "https://elmohq.com/docs/developer-guide/telemetry";
21
- // ── Banner ───────────────────────────────────────────────────────────────────
22
112
  const ELMO_ASCII = [
23
- "",
24
- " ▄▄ ",
25
- " ██ ",
26
- "▄█▀█▄ ██ ███▄███▄ ▄███▄ ",
27
- "██▄█▀ ██ ██ ██ ██ ██ ██ ",
28
- "▀█▄▄▄ ██ ██ ██ ██ ▀███▀ ",
29
- "",
113
+ "",
114
+ " ▄▄ ",
115
+ " ██ ",
116
+ "▄█▀█▄ ██ ███▄███▄ ▄███▄ ",
117
+ "██▄█▀ ██ ██ ██ ██ ██ ██ ",
118
+ "▀█▄▄▄ ██ ██ ██ ██ ▀███▀ ",
119
+ ""
30
120
  ].join("\n");
31
121
  function printBanner() {
32
- // text-blue-600 ≈ #2563EB → RGB(37, 99, 235)
33
- const blue = "\x1b[38;2;37;99;235m";
34
- const reset = "\x1b[0m";
35
- console.log(`${blue}${ELMO_ASCII}${reset}`);
122
+ console.log(`${ELMO_ASCII}`);
36
123
  }
37
- // ── Logging ──────────────────────────────────────────────────────────────────
38
124
  const log = {
39
- info: (msg) => p.log.info(msg),
40
- warn: (msg) => p.log.warn(msg),
41
- error: (msg) => p.log.error(msg),
42
- success: (msg) => p.log.success(msg),
43
- step: (msg) => p.log.step(msg),
125
+ info: (msg) => p.log.info(msg),
126
+ warn: (msg) => p.log.warn(msg),
127
+ error: (msg) => p.log.error(msg),
128
+ success: (msg) => p.log.success(msg),
129
+ step: (msg) => p.log.step(msg)
44
130
  };
45
- // ── Helpers ──────────────────────────────────────────────────────────────────
46
131
  function assertNotCancelled(value) {
47
- if (p.isCancel(value)) {
48
- p.cancel("Setup cancelled.");
49
- process.exit(0);
50
- }
132
+ if (p.isCancel(value)) {
133
+ p.cancel("Setup cancelled.");
134
+ process.exit(0);
135
+ }
51
136
  }
52
137
  function generateSecret(bytes = 32) {
53
- return crypto.randomBytes(bytes).toString("base64url");
138
+ return crypto.randomBytes(bytes).toString("base64url");
54
139
  }
55
140
  function link(text, url) {
56
- // OSC 8 hyperlink: clickable in iTerm2, Windows Terminal, GNOME Terminal, etc.
57
- // Falls back to plain text in unsupported terminals.
58
- return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
141
+ return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
59
142
  }
60
- // ── Main ─────────────────────────────────────────────────────────────────────
61
143
  async function main() {
62
- const version = await getPackageVersion();
63
- const program = new Command();
64
- program
65
- .name("elmo")
66
- .version(version)
67
- .option("--dir <path>", "Config directory")
68
- .configureHelp({ showGlobalOptions: true })
69
- .action(() => {
70
- printBanner();
71
- program.outputHelp();
72
- });
73
- program
74
- .command("init")
75
- .description("set up local Elmo instance")
76
- .option("--dev", "Use local build context (repo only)")
77
- .option("--docker-dir <path>", "Path to Docker build context (dev mode)")
78
- .action(async (_opts, cmd) => {
79
- await withVersionCheck(version, () => runInit(cmd.optsWithGlobals(), version));
80
- });
81
- program
82
- .command("compose")
83
- .description("run Docker Compose commands using your Elmo config")
84
- .allowUnknownOption(true)
85
- .argument("[args...]", "Arguments passed to Docker Compose")
86
- .action(async (args, _opts, cmd) => {
87
- await withVersionCheck(version, () => runCompose(args, cmd.optsWithGlobals()));
88
- });
89
- program
90
- .command("edit")
91
- .description("change API keys, scrape targets, or the Docker Compose YAML")
92
- .argument("<env|compose>", "which config file to edit")
93
- .action(async (target, _opts, cmd) => {
94
- await runEdit(target, cmd.optsWithGlobals());
95
- });
96
- await program.parseAsync(process.argv);
144
+ const version = await getPackageVersion();
145
+ const program = new Command();
146
+ program.name("elmo").version(version).option("--dir <path>", "Config directory").configureHelp({ showGlobalOptions: true }).action(() => {
147
+ printBanner();
148
+ program.outputHelp();
149
+ });
150
+ program.command("init").description("set up local Elmo instance").option("--dev", "Use local build context (repo only)").option("--docker-dir <path>", "Path to Docker build context (dev mode)").action(async (_opts, cmd) => {
151
+ await withVersionCheck(version, () => runInit(cmd.optsWithGlobals(), version));
152
+ });
153
+ program.command("compose").description("run Docker Compose commands using your Elmo config").allowUnknownOption(true).argument("[args...]", "Arguments passed to Docker Compose").action(async (args, _opts, cmd) => {
154
+ await withVersionCheck(version, () => runCompose(args, cmd.optsWithGlobals()));
155
+ });
156
+ program.command("edit").description("change API keys, scrape targets, or the Docker Compose YAML").argument("<env|compose>", "which config file to edit").action(async (target, _opts, cmd) => {
157
+ await runEdit(target, cmd.optsWithGlobals());
158
+ });
159
+ await program.parseAsync(process.argv);
97
160
  }
98
161
  async function withVersionCheck(version, fn) {
99
- const notifyPromise = maybeNotifyNewVersion(version);
100
- await fn();
101
- await notifyPromise.catch(() => undefined);
162
+ const notifyPromise = maybeNotifyNewVersion(version);
163
+ await fn();
164
+ await notifyPromise.catch(() => void 0);
102
165
  }
103
- // ── Command: init ────────────────────────────────────────────────────────────
104
166
  async function runInit(options, version) {
105
- printBanner();
106
- p.intro(pc.bold("Setting up Elmo"));
107
- const cwd = process.cwd();
108
- // ── Resolve config directory ─────────────────────────────────────────
109
- const configDir = options.dir ? path.resolve(cwd, options.dir) : CONFIG_HOME;
110
- // ── .env safety check ────────────────────────────────────────────────
111
- const existingEnvPath = path.join(configDir, ".env");
112
- let preservedDeploymentId;
113
- if (await fileExists(existingEnvPath)) {
114
- const contents = await fs.readFile(existingEnvPath, "utf8");
115
- const isElmoEnv = contents.startsWith("# Rendered by elmo") || contents.startsWith("# Generated by elmo");
116
- if (!isElmoEnv) {
117
- p.log.warn(`A .env file already exists in ${configDir} and was NOT created by Elmo.`);
118
- const overwrite = await p.confirm({
119
- message: "Overwrite the existing .env file? This cannot be undone.",
120
- initialValue: false,
121
- });
122
- assertNotCancelled(overwrite);
123
- if (!overwrite) {
124
- p.cancel("Setup cancelled. Choose a different directory with --dir.");
125
- process.exit(0);
126
- }
127
- }
128
- else {
129
- p.log.warn(`An existing Elmo config was found at ${configDir}.`);
130
- const overwrite = await p.confirm({
131
- message: "Overwrite it with new values? Existing secrets (DATABASE_URL, API keys, etc.) will be replaced.",
132
- initialValue: false,
133
- });
134
- assertNotCancelled(overwrite);
135
- if (!overwrite) {
136
- p.cancel("Setup cancelled. Use `elmo edit env` to change individual values.");
137
- process.exit(0);
138
- }
139
- preservedDeploymentId = parseDotenv(contents).DEPLOYMENT_ID;
140
- }
141
- }
142
- // ── Dev mode: resolve docker directory ───────────────────────────────
143
- let dockerDir;
144
- let repoRoot;
145
- if (options.dev) {
146
- if (options.dockerDir) {
147
- dockerDir = path.resolve(cwd, options.dockerDir);
148
- if (!(await fileExists(path.join(dockerDir, "Dockerfile")))) {
149
- p.log.error(`Dockerfile not found in ${dockerDir}`);
150
- process.exit(1);
151
- }
152
- }
153
- else {
154
- dockerDir = await resolveDockerDirInteractive(cwd);
155
- }
156
- repoRoot = path.resolve(dockerDir, "..");
157
- }
158
- else {
159
- repoRoot = cwd;
160
- }
161
- // ── Data stores ──────────────────────────────────────────────────────
162
- const postgresMode = await p.select({
163
- message: "PostgreSQL connection",
164
- options: [
165
- {
166
- value: "docker",
167
- label: "Run Postgres in Docker",
168
- },
169
- {
170
- value: "external",
171
- label: "Use existing Postgres (provide DATABASE_URL)",
172
- },
173
- ],
174
- initialValue: "docker",
175
- });
176
- assertNotCancelled(postgresMode);
177
- const env = {};
178
- env.DEPLOYMENT_MODE = "local";
179
- env.VITE_DEPLOYMENT_MODE = "local";
180
- env.DEPLOYMENT_ID = preservedDeploymentId ?? crypto.randomUUID();
181
- env.BETTER_AUTH_SECRET = generateSecret();
182
- env.APP_NAME = DEFAULT_APP_NAME;
183
- env.APP_ICON = DEFAULT_APP_ICON;
184
- env.VITE_APP_NAME = DEFAULT_APP_NAME;
185
- env.VITE_APP_ICON = DEFAULT_APP_ICON;
186
- if (postgresMode === "external") {
187
- p.note("Must be an IPv4-compatible direct connection or database pooler.", "DATABASE_URL");
188
- const url = await p.password({
189
- message: "DATABASE_URL",
190
- validate: (v) => (!v ? "Required" : undefined),
191
- });
192
- assertNotCancelled(url);
193
- env.DATABASE_URL = url;
194
- }
195
- else {
196
- env.DATABASE_URL = LOCAL_DATABASE_URL;
197
- }
198
- // ── AI providers ─────────────────────────────────────────────────────
199
- const setupMode = await configureProvidersInteractive(env);
200
- // ── Telemetry ───────────────────────────────────────────────────────
201
- p.note([
202
- "Elmo is open source and maintained by a small team. Telemetry",
203
- "from both the CLI and your local deployment (web + worker)",
204
- "tells us things like which CLI versions are still in use, where",
205
- "`elmo init` drops off, which providers people pick, and whether",
206
- "new features actually get used. Without it we are flying blind",
207
- "on what to fix or build next.",
208
- "",
209
- pc.bold("What we send:"),
210
- " • deployment ID (random UUID stored as DEPLOYMENT_ID in your .env)",
211
- " • CLI/app version, OS, arch, Node version, deployment mode",
212
- " • command/event names + non-secret options (e.g. postgres mode)",
213
- " • feature counts (prompts edited, brands created — never the names or text)",
214
- " • IP address (recorded on each event by PostHog, used for geolocation)",
215
- "",
216
- pc.bold("What we never send:"),
217
- " API keys, .env contents, brand names, prompt text, and scraped responses.",
218
- "",
219
- `Full breakdown: ${link(pc.cyan(TELEMETRY_DOC_URL), TELEMETRY_DOC_URL)}`,
220
- "Toggle later by editing DISABLE_TELEMETRY in .env (`elmo edit env`).",
221
- ].join("\n"), "Telemetry");
222
- const telemetryEnabled = await p.confirm({
223
- message: "Share telemetry?",
224
- initialValue: true,
225
- });
226
- assertNotCancelled(telemetryEnabled);
227
- if (!telemetryEnabled) {
228
- env.DISABLE_TELEMETRY = "1";
229
- }
230
- // ── Product updates ─────────────────────────────────────────────────
231
- const updatesEmail = await p.text({
232
- message: "Enter your work email to receive product updates (optional)",
233
- placeholder: "you@example.com",
234
- });
235
- const email = p.isCancel(updatesEmail) ? undefined : updatesEmail || undefined;
236
- // ── Web app port ────────────────────────────────────────────────────
237
- const portInput = await p.text({
238
- message: "Web app port",
239
- placeholder: String(DEFAULT_APP_PORT),
240
- defaultValue: String(DEFAULT_APP_PORT),
241
- validate: (v) => {
242
- if (!v)
243
- return undefined;
244
- const n = Number(v);
245
- if (!Number.isInteger(n) || n < 1 || n > 65535) {
246
- return "Must be an integer between 1 and 65535";
247
- }
248
- return undefined;
249
- },
250
- });
251
- assertNotCancelled(portInput);
252
- const port = Number(portInput);
253
- env.APP_URL = `http://localhost:${port}`;
254
- env.VITE_APP_URL = env.APP_URL;
255
- // ── Write config ─────────────────────────────────────────────────────
256
- const composeYaml = buildComposeYaml({
257
- dev: Boolean(options.dev),
258
- postgresMode,
259
- repoRoot,
260
- dockerDir,
261
- port,
262
- version,
263
- });
264
- await ensureDir(configDir);
265
- await writeConfigFiles(configDir, {
266
- env,
267
- composeYaml,
268
- postgresMode,
269
- dev: Boolean(options.dev),
270
- version,
271
- });
272
- p.log.success(`Config written to ${configDir}`);
273
- p.log.warn("Your generated .env file contains secrets — do not commit it to version control.");
274
- if (options.dev) {
275
- p.log.info("Dev mode enabled. Run `elmo compose build` before starting.");
276
- }
277
- const shouldStart = await p.confirm({
278
- message: "Start the stack now?",
279
- initialValue: true,
280
- });
281
- assertNotCancelled(shouldStart);
282
- if (shouldStart) {
283
- await doStart(configDir);
284
- }
285
- else {
286
- p.log.info("You can start later with `elmo compose up -d`.");
287
- }
288
- // CLI telemetry — silently dropped if the user opted out above.
289
- await trackCliEvent(configDir, "cli_init", {
290
- version,
291
- os: process.platform,
292
- arch: process.arch,
293
- node_version: process.version,
294
- postgres_mode: postgresMode,
295
- dev_mode: Boolean(options.dev),
296
- setup_mode: setupMode,
297
- has_scraper: Boolean(env.BRIGHTDATA_API_TOKEN || env.OLOSTEP_API_KEY),
298
- has_direct_api: hasDirectApiConfigured(env),
299
- });
300
- // Newsletter signup is a separate, explicit opt-in and runs even when
301
- // telemetry is disabled.
302
- if (email) {
303
- await submitNewsletterSignup(configDir, email);
304
- }
305
- 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")}`);
306
- p.outro(pc.green("Setup complete!"));
167
+ printBanner();
168
+ p.intro(pc.bold("Setting up Elmo"));
169
+ const cwd = process.cwd();
170
+ const configDir = options.dir ? path.resolve(cwd, options.dir) : CONFIG_HOME;
171
+ const existingEnvPath = path.join(configDir, ".env");
172
+ let preservedDeploymentId;
173
+ if (await fileExists(existingEnvPath)) {
174
+ const contents = await fs.readFile(existingEnvPath, "utf8");
175
+ if (!(contents.startsWith("# Rendered by elmo") || contents.startsWith("# Generated by elmo"))) {
176
+ p.log.warn(`A .env file already exists in ${configDir} and was NOT created by Elmo.`);
177
+ const overwrite = await p.confirm({
178
+ message: "Overwrite the existing .env file? This cannot be undone.",
179
+ initialValue: false
180
+ });
181
+ assertNotCancelled(overwrite);
182
+ if (!overwrite) {
183
+ p.cancel("Setup cancelled. Choose a different directory with --dir.");
184
+ process.exit(0);
185
+ }
186
+ } else {
187
+ p.log.warn(`An existing Elmo config was found at ${configDir}.`);
188
+ const overwrite = await p.confirm({
189
+ message: "Overwrite it with new values? Existing secrets (DATABASE_URL, API keys, etc.) will be replaced.",
190
+ initialValue: false
191
+ });
192
+ assertNotCancelled(overwrite);
193
+ if (!overwrite) {
194
+ p.cancel("Setup cancelled. Use `elmo edit env` to change individual values.");
195
+ process.exit(0);
196
+ }
197
+ preservedDeploymentId = parse(contents).DEPLOYMENT_ID;
198
+ }
199
+ }
200
+ let dockerDir;
201
+ let repoRoot;
202
+ if (options.dev) {
203
+ if (options.dockerDir) {
204
+ dockerDir = path.resolve(cwd, options.dockerDir);
205
+ if (!await fileExists(path.join(dockerDir, "Dockerfile"))) {
206
+ p.log.error(`Dockerfile not found in ${dockerDir}`);
207
+ process.exit(1);
208
+ }
209
+ } else dockerDir = await resolveDockerDirInteractive(cwd);
210
+ repoRoot = path.resolve(dockerDir, "..");
211
+ } else repoRoot = cwd;
212
+ const postgresMode = await p.select({
213
+ message: "PostgreSQL connection",
214
+ options: [{
215
+ value: "docker",
216
+ label: "Run Postgres in Docker"
217
+ }, {
218
+ value: "external",
219
+ label: "Use existing Postgres (provide DATABASE_URL)"
220
+ }],
221
+ initialValue: "docker"
222
+ });
223
+ assertNotCancelled(postgresMode);
224
+ const env = {};
225
+ env.DEPLOYMENT_MODE = "local";
226
+ env.VITE_DEPLOYMENT_MODE = "local";
227
+ env.DEPLOYMENT_ID = preservedDeploymentId ?? crypto.randomUUID();
228
+ env.BETTER_AUTH_SECRET = generateSecret();
229
+ env.APP_NAME = DEFAULT_APP_NAME;
230
+ env.APP_ICON = DEFAULT_APP_ICON;
231
+ env.VITE_APP_NAME = DEFAULT_APP_NAME;
232
+ env.VITE_APP_ICON = DEFAULT_APP_ICON;
233
+ if (postgresMode === "external") {
234
+ p.note("Must be an IPv4-compatible direct connection or database pooler.", "DATABASE_URL");
235
+ const url = await p.password({
236
+ message: "DATABASE_URL",
237
+ validate: (v) => !v ? "Required" : void 0
238
+ });
239
+ assertNotCancelled(url);
240
+ env.DATABASE_URL = url;
241
+ } else env.DATABASE_URL = LOCAL_DATABASE_URL;
242
+ const setupMode = await configureProvidersInteractive(env);
243
+ p.note([
244
+ "Elmo is open source and maintained by a small team. Telemetry",
245
+ "from both the CLI and your local deployment (web + worker)",
246
+ "tells us things like which CLI versions are still in use, where",
247
+ "`elmo init` drops off, which providers people pick, and whether",
248
+ "new features actually get used. Without it we are flying blind",
249
+ "on what to fix or build next.",
250
+ "",
251
+ pc.bold("What we send:"),
252
+ " • deployment ID (random UUID stored as DEPLOYMENT_ID in your .env)",
253
+ " • CLI/app version, OS, arch, Node version, deployment mode",
254
+ " • command/event names + non-secret options (e.g. postgres mode)",
255
+ " • feature counts (prompts edited, brands created — never the names or text)",
256
+ " • IP address (recorded on each event by PostHog, used for geolocation)",
257
+ "",
258
+ pc.bold("What we never send:"),
259
+ " API keys, .env contents, brand names, prompt text, and scraped responses.",
260
+ "",
261
+ `Full breakdown: ${link(pc.cyan(TELEMETRY_DOC_URL), TELEMETRY_DOC_URL)}`,
262
+ "Toggle later by editing DISABLE_TELEMETRY in .env (`elmo edit env`)."
263
+ ].join("\n"), "Telemetry");
264
+ const telemetryEnabled = await p.confirm({
265
+ message: "Share telemetry?",
266
+ initialValue: true
267
+ });
268
+ assertNotCancelled(telemetryEnabled);
269
+ if (!telemetryEnabled) env.DISABLE_TELEMETRY = "1";
270
+ const updatesEmail = await p.text({
271
+ message: "Enter your work email to receive product updates (optional)",
272
+ placeholder: "you@example.com"
273
+ });
274
+ const email = p.isCancel(updatesEmail) ? void 0 : updatesEmail || void 0;
275
+ const portInput = await p.text({
276
+ message: "Web app port",
277
+ placeholder: String(DEFAULT_APP_PORT),
278
+ defaultValue: String(DEFAULT_APP_PORT),
279
+ validate: (v) => {
280
+ if (!v) return void 0;
281
+ const n = Number(v);
282
+ if (!Number.isInteger(n) || n < 1 || n > 65535) return "Must be an integer between 1 and 65535";
283
+ }
284
+ });
285
+ assertNotCancelled(portInput);
286
+ const port = Number(portInput);
287
+ env.APP_URL = `http://localhost:${port}`;
288
+ env.VITE_APP_URL = env.APP_URL;
289
+ const composeYaml = buildComposeYaml({
290
+ dev: Boolean(options.dev),
291
+ postgresMode,
292
+ repoRoot,
293
+ dockerDir,
294
+ port,
295
+ version
296
+ });
297
+ await ensureDir(configDir);
298
+ await writeConfigFiles(configDir, {
299
+ env,
300
+ composeYaml,
301
+ postgresMode,
302
+ dev: Boolean(options.dev),
303
+ version
304
+ });
305
+ p.log.success(`Config written to ${configDir}`);
306
+ p.log.warn("Your generated .env file contains secrets — do not commit it to version control.");
307
+ if (options.dev) p.log.info("Dev mode enabled. Run `elmo compose build` before starting.");
308
+ const shouldStart = await p.confirm({
309
+ message: "Start the stack now?",
310
+ initialValue: true
311
+ });
312
+ assertNotCancelled(shouldStart);
313
+ if (shouldStart) await doStart(configDir);
314
+ else p.log.info("You can start later with `elmo compose up -d`.");
315
+ await trackCliEvent(configDir, "cli_init", {
316
+ version,
317
+ os: process.platform,
318
+ arch: process.arch,
319
+ node_version: process.version,
320
+ postgres_mode: postgresMode,
321
+ dev_mode: Boolean(options.dev),
322
+ setup_mode: setupMode,
323
+ has_scraper: Boolean(env.BRIGHTDATA_API_TOKEN || env.OLOSTEP_API_KEY),
324
+ has_direct_api: hasDirectApiConfigured(env)
325
+ });
326
+ if (email) await submitNewsletterSignup(configDir, email);
327
+ 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")}`);
328
+ p.outro(pc.green("Setup complete!"));
307
329
  }
308
- // ── Provider Configuration ───────────────────────────────────────────────────
309
330
  const BRIGHTDATA_AFFILIATE = "https://get.brightdata.com/67h1b7h0shcn";
310
331
  const OLOSTEP_AFFILIATE = "https://olostep.com/?ref=elmo";
311
332
  const PROVIDERS_DOC_URL = "https://docs.elmohq.com/docs/user-guide/providers";
312
- // Surfaces each scraper can track — the first two are the "recommended starter" set.
313
- const BRIGHTDATA_MODELS = ["chatgpt", "google-ai-mode", "perplexity", "copilot", "gemini", "grok"];
333
+ const BRIGHTDATA_MODELS = [
334
+ "chatgpt",
335
+ "google-ai-mode",
336
+ "perplexity",
337
+ "copilot",
338
+ "gemini",
339
+ "grok"
340
+ ];
314
341
  const OLOSTEP_MODELS = [
315
- "chatgpt",
316
- "google-ai-mode",
317
- "google-ai-overview",
318
- "perplexity",
319
- "copilot",
320
- "gemini",
321
- "grok",
342
+ "chatgpt",
343
+ "google-ai-mode",
344
+ "google-ai-overview",
345
+ "perplexity",
346
+ "copilot",
347
+ "gemini",
348
+ "grok"
322
349
  ];
323
350
  const DEFAULT_SCRAPER_MODELS = ["chatgpt", "google-ai-mode"];
324
351
  const DEFAULT_OPENAI_MODEL = "gpt-5-mini";
@@ -326,806 +353,740 @@ const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-20250514";
326
353
  const DEFAULT_OPENROUTER_MODEL = "anthropic/claude-sonnet-4.6";
327
354
  const DEFAULT_MISTRAL_MODEL = "mistral-medium-latest";
328
355
  async function configureProvidersInteractive(env) {
329
- p.note([
330
- "Elmo needs two kinds of providers:",
331
- "",
332
- pc.bold("1. A scraper") + " — to track ChatGPT and Google AI Mode (no public APIs):",
333
- ` • ${pc.cyan("BrightData")} — cheap solid option, ~$0.45/mo per prompt`,
334
- ` • ${pc.cyan("Olostep")} — premium option, powers Peec/AirOps, ~$2.25/mo per prompt`,
335
- "",
336
- pc.bold("2. A direct LLM API") + " — for low-latency tasks (onboarding analysis, sentiment scoring,",
337
- " ad-hoc LLM calls). Required:",
338
- ` • ${pc.cyan("OpenRouter")} — one key, all major models (recommended)`,
339
- ` • ${pc.cyan("Anthropic / OpenAI / Mistral")} — direct provider keys`,
340
- "",
341
- "Pricing assumes Elmo's default cadence (5 runs/day × 2 surfaces).",
342
- ].join("\n"), "AI providers");
343
- const mode = await p.select({
344
- message: "Setup mode",
345
- options: [
346
- { value: "recommended", label: "Recommended — one scraper + one direct API" },
347
- { value: "custom", label: "Custom — pick each provider individually" },
348
- ],
349
- initialValue: "recommended",
350
- });
351
- assertNotCancelled(mode);
352
- if (mode === "recommended") {
353
- await configureProvidersRecommended(env);
354
- }
355
- else {
356
- await configureProvidersCustom(env);
357
- }
358
- return mode;
356
+ p.note([
357
+ "Elmo needs two kinds of providers:",
358
+ "",
359
+ pc.bold("1. A scraper") + " — to track ChatGPT and Google AI Mode (no public APIs):",
360
+ ` • ${pc.cyan("BrightData")} — cheap solid option, ~$0.45/mo per prompt`,
361
+ ` • ${pc.cyan("Olostep")} — premium option, powers Peec/AirOps, ~$2.25/mo per prompt`,
362
+ "",
363
+ pc.bold("2. A direct LLM API") + " — for low-latency tasks (onboarding analysis, sentiment scoring,",
364
+ " ad-hoc LLM calls). Required:",
365
+ ` • ${pc.cyan("OpenRouter")} — one key, all major models (recommended)`,
366
+ ` • ${pc.cyan("Anthropic / OpenAI / Mistral")} — direct provider keys`,
367
+ "",
368
+ "Pricing assumes Elmo's default cadence (5 runs/day × 2 surfaces)."
369
+ ].join("\n"), "AI providers");
370
+ const mode = await p.select({
371
+ message: "Setup mode",
372
+ options: [{
373
+ value: "recommended",
374
+ label: "Recommended — one scraper + one direct API"
375
+ }, {
376
+ value: "custom",
377
+ label: "Custom — pick each provider individually"
378
+ }],
379
+ initialValue: "recommended"
380
+ });
381
+ assertNotCancelled(mode);
382
+ if (mode === "recommended") await configureProvidersRecommended(env);
383
+ else await configureProvidersCustom(env);
384
+ return mode;
359
385
  }
360
386
  async function configureProvidersRecommended(env) {
361
- const targets = [];
362
- // ── Scraper ─────────────────────────────────────────────────────────────
363
- const scraper = await p.select({
364
- message: "Scraper (tracks ChatGPT + Google AI Mode)",
365
- options: [
366
- { value: "brightdata", label: "BrightData — ~$0.45/mo per prompt (cheaper)" },
367
- { value: "olostep", label: "Olostep — ~$2.25/mo per prompt (premium)" },
368
- ],
369
- initialValue: "brightdata",
370
- });
371
- assertNotCancelled(scraper);
372
- await collectScraperKey(scraper, env);
373
- for (const model of DEFAULT_SCRAPER_MODELS) {
374
- targets.push(`${model}:${scraper}:online`);
375
- }
376
- // ── Direct API ──────────────────────────────────────────────────────────
377
- const direct = await p.select({
378
- message: "Direct LLM API (powers onboarding analysis + sentiment scoring)",
379
- options: [
380
- { value: "openrouter", label: "OpenRouter — one key, all major models (recommended)" },
381
- { value: "anthropic", label: "Anthropic — direct Claude" },
382
- { value: "openai", label: "OpenAI — direct GPT-* models" },
383
- { value: "mistral", label: "Mistral — direct Mistral models" },
384
- ],
385
- initialValue: "openrouter",
386
- });
387
- assertNotCancelled(direct);
388
- await collectDirectApiQuick(direct, env);
389
- await finalizeScrapeTargets(env, targets, { skipEdit: true });
387
+ const targets = [];
388
+ const scraper = await p.select({
389
+ message: "Scraper (tracks ChatGPT + Google AI Mode)",
390
+ options: [{
391
+ value: "brightdata",
392
+ label: "BrightData — ~$0.45/mo per prompt (cheaper)"
393
+ }, {
394
+ value: "olostep",
395
+ label: "Olostep — ~$2.25/mo per prompt (premium)"
396
+ }],
397
+ initialValue: "brightdata"
398
+ });
399
+ assertNotCancelled(scraper);
400
+ await collectScraperKey(scraper, env);
401
+ for (const model of DEFAULT_SCRAPER_MODELS) targets.push(formatScrapeTarget({
402
+ model,
403
+ provider: scraper,
404
+ webSearch: true
405
+ }));
406
+ const direct = await p.select({
407
+ message: "Direct LLM API (powers onboarding analysis + sentiment scoring)",
408
+ options: [
409
+ {
410
+ value: "openrouter",
411
+ label: "OpenRouter — one key, all major models (recommended)"
412
+ },
413
+ {
414
+ value: "anthropic",
415
+ label: "Anthropic — direct Claude"
416
+ },
417
+ {
418
+ value: "openai",
419
+ label: "OpenAI — direct GPT-* models"
420
+ },
421
+ {
422
+ value: "mistral",
423
+ label: "Mistral — direct Mistral models"
424
+ }
425
+ ],
426
+ initialValue: "openrouter"
427
+ });
428
+ assertNotCancelled(direct);
429
+ await collectDirectApiQuick(direct, env);
430
+ await finalizeScrapeTargets(env, targets, { skipEdit: true });
390
431
  }
391
432
  async function configureProvidersCustom(env) {
392
- const targets = [];
393
- p.log.step(pc.bold("Step 1 of 2 — Direct LLM API (at least one is required)"));
394
- // Order matches the auto-pick preference in onboarding/llm.ts so the first
395
- // provider asked is the one onboarding will reach for by default.
396
- while (!hasDirectApiConfigured(env)) {
397
- await collectOpenRouter(env, targets);
398
- await collectAnthropic(env, targets);
399
- await collectOpenAI(env, targets);
400
- await collectMistral(env, targets);
401
- if (!hasDirectApiConfigured(env)) {
402
- p.log.warn("Onboarding analysis and other low-latency LLM tasks require a direct API. Configure at least one before continuing.");
403
- }
404
- }
405
- p.log.step(pc.bold("Step 2 of 2 — Scrapers (optional, but needed to track ChatGPT / Google AI Mode)"));
406
- await collectBrightData(env, targets);
407
- await collectOlostep(env, targets);
408
- await collectDataForSEO(env, targets);
409
- await finalizeScrapeTargets(env, targets);
433
+ const targets = [];
434
+ p.log.step(pc.bold("Step 1 of 2 — Direct LLM API (at least one is required)"));
435
+ while (!hasDirectApiConfigured(env)) {
436
+ await collectOpenRouter(env, targets);
437
+ await collectAnthropic(env, targets);
438
+ await collectOpenAI(env, targets);
439
+ await collectMistral(env, targets);
440
+ if (!hasDirectApiConfigured(env)) p.log.warn("Onboarding analysis and other low-latency LLM tasks require a direct API. Configure at least one before continuing.");
441
+ }
442
+ p.log.step(pc.bold("Step 2 of 2 — Scrapers (optional, but needed to track ChatGPT / Google AI Mode)"));
443
+ await collectBrightData(env, targets);
444
+ await collectOlostep(env, targets);
445
+ await collectDataForSEO(env, targets);
446
+ await finalizeScrapeTargets(env, targets);
410
447
  }
411
448
  function hasDirectApiConfigured(env) {
412
- return Boolean(env.ANTHROPIC_API_KEY || env.OPENAI_API_KEY || env.MISTRAL_API_KEY || env.OPENROUTER_API_KEY);
449
+ return Boolean(env.ANTHROPIC_API_KEY || env.OPENAI_API_KEY || env.MISTRAL_API_KEY || env.OPENROUTER_API_KEY);
413
450
  }
414
451
  async function collectScraperKey(scraper, env) {
415
- if (scraper === "brightdata") {
416
- p.log.info(`Sign up: ${link(pc.cyan(BRIGHTDATA_AFFILIATE), BRIGHTDATA_AFFILIATE)}`);
417
- const key = await p.password({
418
- message: "BrightData API token",
419
- validate: (v) => (!v ? "Required" : undefined),
420
- });
421
- assertNotCancelled(key);
422
- env.BRIGHTDATA_API_TOKEN = key;
423
- }
424
- else {
425
- p.log.info(`Sign up: ${link(pc.cyan(OLOSTEP_AFFILIATE), OLOSTEP_AFFILIATE)}`);
426
- const key = await p.password({
427
- message: "Olostep API key",
428
- validate: (v) => (!v ? "Required" : undefined),
429
- });
430
- assertNotCancelled(key);
431
- env.OLOSTEP_API_KEY = key;
432
- }
452
+ if (scraper === "brightdata") {
453
+ p.log.info(`Sign up: ${link(pc.cyan(BRIGHTDATA_AFFILIATE), BRIGHTDATA_AFFILIATE)}`);
454
+ const key = await p.password({
455
+ message: "BrightData API token",
456
+ validate: (v) => !v ? "Required" : void 0
457
+ });
458
+ assertNotCancelled(key);
459
+ env.BRIGHTDATA_API_TOKEN = key;
460
+ } else {
461
+ p.log.info(`Sign up: ${link(pc.cyan(OLOSTEP_AFFILIATE), OLOSTEP_AFFILIATE)}`);
462
+ const key = await p.password({
463
+ message: "Olostep API key",
464
+ validate: (v) => !v ? "Required" : void 0
465
+ });
466
+ assertNotCancelled(key);
467
+ env.OLOSTEP_API_KEY = key;
468
+ }
433
469
  }
434
470
  async function collectDirectApiQuick(kind, env) {
435
- if (kind === "openrouter") {
436
- const key = await p.password({
437
- message: "OpenRouter API key",
438
- validate: (v) => (!v ? "Required" : undefined),
439
- });
440
- assertNotCancelled(key);
441
- env.OPENROUTER_API_KEY = key;
442
- }
443
- else if (kind === "anthropic") {
444
- const key = await p.password({
445
- message: "Anthropic API key",
446
- validate: (v) => (!v ? "Required" : undefined),
447
- });
448
- assertNotCancelled(key);
449
- env.ANTHROPIC_API_KEY = key;
450
- }
451
- else if (kind === "openai") {
452
- const key = await p.password({
453
- message: "OpenAI API key",
454
- validate: (v) => (!v ? "Required" : undefined),
455
- });
456
- assertNotCancelled(key);
457
- env.OPENAI_API_KEY = key;
458
- }
459
- else {
460
- const key = await p.password({
461
- message: "Mistral API key",
462
- validate: (v) => (!v ? "Required" : undefined),
463
- });
464
- assertNotCancelled(key);
465
- env.MISTRAL_API_KEY = key;
466
- }
471
+ if (kind === "openrouter") {
472
+ const key = await p.password({
473
+ message: "OpenRouter API key",
474
+ validate: (v) => !v ? "Required" : void 0
475
+ });
476
+ assertNotCancelled(key);
477
+ env.OPENROUTER_API_KEY = key;
478
+ } else if (kind === "anthropic") {
479
+ const key = await p.password({
480
+ message: "Anthropic API key",
481
+ validate: (v) => !v ? "Required" : void 0
482
+ });
483
+ assertNotCancelled(key);
484
+ env.ANTHROPIC_API_KEY = key;
485
+ } else if (kind === "openai") {
486
+ const key = await p.password({
487
+ message: "OpenAI API key",
488
+ validate: (v) => !v ? "Required" : void 0
489
+ });
490
+ assertNotCancelled(key);
491
+ env.OPENAI_API_KEY = key;
492
+ } else {
493
+ const key = await p.password({
494
+ message: "Mistral API key",
495
+ validate: (v) => !v ? "Required" : void 0
496
+ });
497
+ assertNotCancelled(key);
498
+ env.MISTRAL_API_KEY = key;
499
+ }
467
500
  }
468
501
  async function collectBrightData(env, targets) {
469
- const enable = await p.confirm({
470
- message: `Configure ${pc.bold("BrightData")}? (~$0.45/mo per prompt)`,
471
- initialValue: true,
472
- });
473
- assertNotCancelled(enable);
474
- if (!enable)
475
- return;
476
- p.log.info(`Sign up and generate an API token: ${link(pc.cyan(BRIGHTDATA_AFFILIATE), BRIGHTDATA_AFFILIATE)}`);
477
- const key = await p.password({
478
- message: "BrightData API token",
479
- validate: (v) => (!v ? "Required" : undefined),
480
- });
481
- assertNotCancelled(key);
482
- env.BRIGHTDATA_API_TOKEN = key;
483
- await pickScraperTargets({
484
- providerLabel: "BrightData",
485
- providerId: "brightdata",
486
- allModels: BRIGHTDATA_MODELS,
487
- targets,
488
- });
502
+ const enable = await p.confirm({
503
+ message: `Configure ${pc.bold("BrightData")}? (~$0.45/mo per prompt)`,
504
+ initialValue: true
505
+ });
506
+ assertNotCancelled(enable);
507
+ if (!enable) return;
508
+ p.log.info(`Sign up and generate an API token: ${link(pc.cyan(BRIGHTDATA_AFFILIATE), BRIGHTDATA_AFFILIATE)}`);
509
+ const key = await p.password({
510
+ message: "BrightData API token",
511
+ validate: (v) => !v ? "Required" : void 0
512
+ });
513
+ assertNotCancelled(key);
514
+ env.BRIGHTDATA_API_TOKEN = key;
515
+ await pickScraperTargets({
516
+ providerLabel: "BrightData",
517
+ providerId: "brightdata",
518
+ allModels: BRIGHTDATA_MODELS,
519
+ targets
520
+ });
489
521
  }
490
522
  async function collectOlostep(env, targets) {
491
- const enable = await p.confirm({
492
- message: `Configure ${pc.bold("Olostep")}? (~$2.25/mo per prompt)`,
493
- initialValue: false,
494
- });
495
- assertNotCancelled(enable);
496
- if (!enable)
497
- return;
498
- p.log.info(`Grab an API key: ${link(pc.cyan(OLOSTEP_AFFILIATE), OLOSTEP_AFFILIATE)}`);
499
- const key = await p.password({
500
- message: "Olostep API key",
501
- validate: (v) => (!v ? "Required" : undefined),
502
- });
503
- assertNotCancelled(key);
504
- env.OLOSTEP_API_KEY = key;
505
- await pickScraperTargets({
506
- providerLabel: "Olostep",
507
- providerId: "olostep",
508
- allModels: OLOSTEP_MODELS,
509
- targets,
510
- });
523
+ const enable = await p.confirm({
524
+ message: `Configure ${pc.bold("Olostep")}? (~$2.25/mo per prompt)`,
525
+ initialValue: false
526
+ });
527
+ assertNotCancelled(enable);
528
+ if (!enable) return;
529
+ p.log.info(`Grab an API key: ${link(pc.cyan(OLOSTEP_AFFILIATE), OLOSTEP_AFFILIATE)}`);
530
+ const key = await p.password({
531
+ message: "Olostep API key",
532
+ validate: (v) => !v ? "Required" : void 0
533
+ });
534
+ assertNotCancelled(key);
535
+ env.OLOSTEP_API_KEY = key;
536
+ await pickScraperTargets({
537
+ providerLabel: "Olostep",
538
+ providerId: "olostep",
539
+ allModels: OLOSTEP_MODELS,
540
+ targets
541
+ });
511
542
  }
512
543
  async function pickScraperTargets(args) {
513
- const selected = (await p.multiselect({
514
- message: `LLM Providers to track via ${args.providerLabel}`,
515
- options: args.allModels.map((model) => ({ value: model, label: model })),
516
- required: true,
517
- initialValues: [...DEFAULT_SCRAPER_MODELS],
518
- }));
519
- assertNotCancelled(selected);
520
- for (const model of selected) {
521
- args.targets.push(`${model}:${args.providerId}:online`);
522
- }
544
+ const selected = await p.multiselect({
545
+ message: `LLM Providers to track via ${args.providerLabel}`,
546
+ options: args.allModels.map((model) => ({
547
+ value: model,
548
+ label: model
549
+ })),
550
+ required: true,
551
+ initialValues: [...DEFAULT_SCRAPER_MODELS]
552
+ });
553
+ assertNotCancelled(selected);
554
+ for (const model of selected) args.targets.push(formatScrapeTarget({
555
+ model,
556
+ provider: args.providerId,
557
+ webSearch: true
558
+ }));
523
559
  }
524
560
  async function collectAnthropic(env, targets) {
525
- const enable = await p.confirm({
526
- message: `Configure ${pc.bold("Anthropic API")}? (direct Claude — ~$4–5/mo per prompt per model)`,
527
- initialValue: false,
528
- });
529
- assertNotCancelled(enable);
530
- if (!enable)
531
- return;
532
- const key = await p.password({
533
- message: "Anthropic API key",
534
- validate: (v) => (!v ? "Required" : undefined),
535
- });
536
- assertNotCancelled(key);
537
- env.ANTHROPIC_API_KEY = key;
538
- const model = await p.text({
539
- message: "Claude model",
540
- placeholder: DEFAULT_ANTHROPIC_MODEL,
541
- defaultValue: DEFAULT_ANTHROPIC_MODEL,
542
- });
543
- assertNotCancelled(model);
544
- const slug = model || DEFAULT_ANTHROPIC_MODEL;
545
- const webSearch = await p.confirm({
546
- message: "Enable web search? (recommended, but more expensive)",
547
- initialValue: true,
548
- });
549
- assertNotCancelled(webSearch);
550
- targets.push(webSearch ? `claude:anthropic-api:${slug}:online` : `claude:anthropic-api:${slug}`);
561
+ const enable = await p.confirm({
562
+ message: `Configure ${pc.bold("Anthropic API")}? (direct Claude — ~$4–5/mo per prompt per model)`,
563
+ initialValue: false
564
+ });
565
+ assertNotCancelled(enable);
566
+ if (!enable) return;
567
+ const key = await p.password({
568
+ message: "Anthropic API key",
569
+ validate: (v) => !v ? "Required" : void 0
570
+ });
571
+ assertNotCancelled(key);
572
+ env.ANTHROPIC_API_KEY = key;
573
+ const model = await p.text({
574
+ message: "Claude model",
575
+ placeholder: DEFAULT_ANTHROPIC_MODEL,
576
+ defaultValue: DEFAULT_ANTHROPIC_MODEL
577
+ });
578
+ assertNotCancelled(model);
579
+ const slug = model || DEFAULT_ANTHROPIC_MODEL;
580
+ const webSearch = await p.confirm({
581
+ message: "Enable web search? (recommended, but more expensive)",
582
+ initialValue: true
583
+ });
584
+ assertNotCancelled(webSearch);
585
+ targets.push(formatScrapeTarget({
586
+ model: "claude",
587
+ provider: "anthropic-api",
588
+ version: slug,
589
+ webSearch
590
+ }));
551
591
  }
552
592
  async function collectOpenAI(env, targets) {
553
- const enable = await p.confirm({
554
- message: `Configure ${pc.bold("OpenAI API")}? (gpt-* with web search — not the real ChatGPT UI)`,
555
- initialValue: false,
556
- });
557
- assertNotCancelled(enable);
558
- if (!enable)
559
- return;
560
- const key = await p.password({
561
- message: "OpenAI API key",
562
- validate: (v) => (!v ? "Required" : undefined),
563
- });
564
- assertNotCancelled(key);
565
- env.OPENAI_API_KEY = key;
566
- const model = await p.text({
567
- message: "OpenAI model",
568
- placeholder: DEFAULT_OPENAI_MODEL,
569
- defaultValue: DEFAULT_OPENAI_MODEL,
570
- });
571
- assertNotCancelled(model);
572
- const slug = model || DEFAULT_OPENAI_MODEL;
573
- const webSearch = await p.confirm({
574
- message: "Enable web search? (recommended, but more expensive)",
575
- initialValue: true,
576
- });
577
- assertNotCancelled(webSearch);
578
- targets.push(webSearch ? `chatgpt:openai-api:${slug}:online` : `chatgpt:openai-api:${slug}`);
593
+ const enable = await p.confirm({
594
+ message: `Configure ${pc.bold("OpenAI API")}? (gpt-* with web search — not the real ChatGPT UI)`,
595
+ initialValue: false
596
+ });
597
+ assertNotCancelled(enable);
598
+ if (!enable) return;
599
+ const key = await p.password({
600
+ message: "OpenAI API key",
601
+ validate: (v) => !v ? "Required" : void 0
602
+ });
603
+ assertNotCancelled(key);
604
+ env.OPENAI_API_KEY = key;
605
+ const model = await p.text({
606
+ message: "OpenAI model",
607
+ placeholder: DEFAULT_OPENAI_MODEL,
608
+ defaultValue: DEFAULT_OPENAI_MODEL
609
+ });
610
+ assertNotCancelled(model);
611
+ const slug = model || DEFAULT_OPENAI_MODEL;
612
+ const webSearch = await p.confirm({
613
+ message: "Enable web search? (recommended, but more expensive)",
614
+ initialValue: true
615
+ });
616
+ assertNotCancelled(webSearch);
617
+ targets.push(formatScrapeTarget({
618
+ model: "chatgpt",
619
+ provider: "openai-api",
620
+ version: slug,
621
+ webSearch
622
+ }));
579
623
  }
580
624
  async function collectMistral(env, targets) {
581
- const enable = await p.confirm({
582
- message: `Configure ${pc.bold("Mistral API")}? (direct Mistral models)`,
583
- initialValue: false,
584
- });
585
- assertNotCancelled(enable);
586
- if (!enable)
587
- return;
588
- const key = await p.password({
589
- message: "Mistral API key",
590
- validate: (v) => (!v ? "Required" : undefined),
591
- });
592
- assertNotCancelled(key);
593
- env.MISTRAL_API_KEY = key;
594
- const model = await p.text({
595
- message: "Mistral model",
596
- placeholder: DEFAULT_MISTRAL_MODEL,
597
- defaultValue: DEFAULT_MISTRAL_MODEL,
598
- });
599
- assertNotCancelled(model);
600
- const slug = model || DEFAULT_MISTRAL_MODEL;
601
- const webSearch = await p.confirm({
602
- message: "Enable web search? (recommended, but more expensive)",
603
- initialValue: true,
604
- });
605
- assertNotCancelled(webSearch);
606
- targets.push(webSearch ? `mistral:mistral-api:${slug}:online` : `mistral:mistral-api:${slug}`);
625
+ const enable = await p.confirm({
626
+ message: `Configure ${pc.bold("Mistral API")}? (direct Mistral models)`,
627
+ initialValue: false
628
+ });
629
+ assertNotCancelled(enable);
630
+ if (!enable) return;
631
+ const key = await p.password({
632
+ message: "Mistral API key",
633
+ validate: (v) => !v ? "Required" : void 0
634
+ });
635
+ assertNotCancelled(key);
636
+ env.MISTRAL_API_KEY = key;
637
+ const model = await p.text({
638
+ message: "Mistral model",
639
+ placeholder: DEFAULT_MISTRAL_MODEL,
640
+ defaultValue: DEFAULT_MISTRAL_MODEL
641
+ });
642
+ assertNotCancelled(model);
643
+ const slug = model || DEFAULT_MISTRAL_MODEL;
644
+ const webSearch = await p.confirm({
645
+ message: "Enable web search? (recommended, but more expensive)",
646
+ initialValue: true
647
+ });
648
+ assertNotCancelled(webSearch);
649
+ targets.push(formatScrapeTarget({
650
+ model: "mistral",
651
+ provider: "mistral-api",
652
+ version: slug,
653
+ webSearch
654
+ }));
607
655
  }
608
656
  async function collectOpenRouter(env, targets) {
609
- const enable = await p.confirm({
610
- message: `Configure ${pc.bold("OpenRouter")}? (one key, many hosted models)`,
611
- initialValue: false,
612
- });
613
- assertNotCancelled(enable);
614
- if (!enable)
615
- return;
616
- const key = await p.password({
617
- message: "OpenRouter API key",
618
- validate: (v) => (!v ? "Required" : undefined),
619
- });
620
- assertNotCancelled(key);
621
- env.OPENROUTER_API_KEY = key;
622
- const model = await p.text({
623
- message: "OpenRouter model slug",
624
- placeholder: DEFAULT_OPENROUTER_MODEL,
625
- defaultValue: DEFAULT_OPENROUTER_MODEL,
626
- });
627
- assertNotCancelled(model);
628
- const slug = model || DEFAULT_OPENROUTER_MODEL;
629
- const webSearch = await p.confirm({
630
- message: "Enable web search? (recommended, but more expensive)",
631
- initialValue: true,
632
- });
633
- assertNotCancelled(webSearch);
634
- targets.push(webSearch ? `claude:openrouter:${slug}:online` : `claude:openrouter:${slug}`);
657
+ const enable = await p.confirm({
658
+ message: `Configure ${pc.bold("OpenRouter")}? (one key, many hosted models)`,
659
+ initialValue: false
660
+ });
661
+ assertNotCancelled(enable);
662
+ if (!enable) return;
663
+ const key = await p.password({
664
+ message: "OpenRouter API key",
665
+ validate: (v) => !v ? "Required" : void 0
666
+ });
667
+ assertNotCancelled(key);
668
+ env.OPENROUTER_API_KEY = key;
669
+ const model = await p.text({
670
+ message: "OpenRouter model slug",
671
+ placeholder: DEFAULT_OPENROUTER_MODEL,
672
+ defaultValue: DEFAULT_OPENROUTER_MODEL
673
+ });
674
+ assertNotCancelled(model);
675
+ const slug = model || DEFAULT_OPENROUTER_MODEL;
676
+ const webSearch = await p.confirm({
677
+ message: "Enable web search? (recommended, but more expensive)",
678
+ initialValue: true
679
+ });
680
+ assertNotCancelled(webSearch);
681
+ targets.push(formatScrapeTarget({
682
+ model: "claude",
683
+ provider: "openrouter",
684
+ version: slug,
685
+ webSearch
686
+ }));
635
687
  }
636
688
  async function collectDataForSEO(env, targets) {
637
- const enable = await p.confirm({
638
- message: `Configure ${pc.bold("DataForSEO")}? (Google AI Mode scraping)`,
639
- initialValue: false,
640
- });
641
- assertNotCancelled(enable);
642
- if (!enable)
643
- return;
644
- const login = await p.text({
645
- message: "DataForSEO login",
646
- validate: (v) => (!v ? "Required" : undefined),
647
- });
648
- assertNotCancelled(login);
649
- env.DATAFORSEO_LOGIN = login;
650
- const pwd = await p.password({
651
- message: "DataForSEO password",
652
- validate: (v) => (!v ? "Required" : undefined),
653
- });
654
- assertNotCancelled(pwd);
655
- env.DATAFORSEO_PASSWORD = pwd;
656
- const addTarget = await p.confirm({
657
- message: "Also scrape Google AI Mode via DataForSEO? (google-ai-mode:dataforseo:online)",
658
- initialValue: false,
659
- });
660
- assertNotCancelled(addTarget);
661
- if (addTarget) {
662
- targets.push("google-ai-mode:dataforseo:online");
663
- }
689
+ const enable = await p.confirm({
690
+ message: `Configure ${pc.bold("DataForSEO")}? (Google AI Mode scraping)`,
691
+ initialValue: false
692
+ });
693
+ assertNotCancelled(enable);
694
+ if (!enable) return;
695
+ const login = await p.text({
696
+ message: "DataForSEO login",
697
+ validate: (v) => !v ? "Required" : void 0
698
+ });
699
+ assertNotCancelled(login);
700
+ env.DATAFORSEO_LOGIN = login;
701
+ const pwd = await p.password({
702
+ message: "DataForSEO password",
703
+ validate: (v) => !v ? "Required" : void 0
704
+ });
705
+ assertNotCancelled(pwd);
706
+ env.DATAFORSEO_PASSWORD = pwd;
707
+ const addTarget = await p.confirm({
708
+ message: "Also scrape Google AI Mode via DataForSEO? (google-ai-mode:dataforseo:online)",
709
+ initialValue: false
710
+ });
711
+ assertNotCancelled(addTarget);
712
+ if (addTarget) targets.push(formatScrapeTarget({
713
+ model: "google-ai-mode",
714
+ provider: "dataforseo",
715
+ webSearch: true
716
+ }));
664
717
  }
665
718
  async function finalizeScrapeTargets(env, targets, options = {}) {
666
- const deduped = dedupeTargets(targets);
667
- if (!deduped) {
668
- p.log.warn("No SCRAPE_TARGETS configured. Elmo will not run scheduled checks until you set them.");
669
- p.log.info(`Reference: ${link(pc.cyan(PROVIDERS_DOC_URL), PROVIDERS_DOC_URL)}`);
670
- const addManual = await p.confirm({
671
- message: "Enter SCRAPE_TARGETS manually now?",
672
- initialValue: false,
673
- });
674
- assertNotCancelled(addManual);
675
- if (addManual) {
676
- const manual = await p.text({
677
- message: "SCRAPE_TARGETS (model:provider[:version][:online], comma-separated)",
678
- placeholder: "chatgpt:brightdata:online,google-ai-mode:brightdata:online",
679
- validate: (v) => (!v ? "Required" : undefined),
680
- });
681
- assertNotCancelled(manual);
682
- env.SCRAPE_TARGETS = manual;
683
- }
684
- return;
685
- }
686
- if (options.skipEdit) {
687
- env.SCRAPE_TARGETS = deduped;
688
- return;
689
- }
690
- const customize = await p.confirm({
691
- message: "Edit SCRAPE_TARGETS before saving?",
692
- initialValue: false,
693
- });
694
- assertNotCancelled(customize);
695
- if (customize) {
696
- p.log.info(`Reference: ${link(pc.cyan(PROVIDERS_DOC_URL), PROVIDERS_DOC_URL)}`);
697
- const manual = await p.text({
698
- message: "SCRAPE_TARGETS",
699
- initialValue: deduped,
700
- validate: (v) => (!v ? "Required" : undefined),
701
- });
702
- assertNotCancelled(manual);
703
- env.SCRAPE_TARGETS = manual;
704
- p.log.step(`SCRAPE_TARGETS:\n ${pc.cyan(manual)}`);
705
- }
706
- else {
707
- env.SCRAPE_TARGETS = deduped;
708
- }
719
+ const deduped = dedupeTargets(targets);
720
+ if (!deduped) {
721
+ p.log.warn("No SCRAPE_TARGETS configured. Elmo will not run scheduled checks until you set them.");
722
+ p.log.info(`Reference: ${link(pc.cyan(PROVIDERS_DOC_URL), PROVIDERS_DOC_URL)}`);
723
+ const addManual = await p.confirm({
724
+ message: "Enter SCRAPE_TARGETS manually now?",
725
+ initialValue: false
726
+ });
727
+ assertNotCancelled(addManual);
728
+ if (addManual) {
729
+ const manual = await p.text({
730
+ message: "SCRAPE_TARGETS (model:provider[:version][:online], comma-separated)",
731
+ placeholder: "chatgpt:brightdata:online,google-ai-mode:brightdata:online",
732
+ validate: validateScrapeTargetsInput
733
+ });
734
+ assertNotCancelled(manual);
735
+ env.SCRAPE_TARGETS = manual;
736
+ }
737
+ return;
738
+ }
739
+ if (options.skipEdit) {
740
+ env.SCRAPE_TARGETS = deduped;
741
+ return;
742
+ }
743
+ const customize = await p.confirm({
744
+ message: "Edit SCRAPE_TARGETS before saving?",
745
+ initialValue: false
746
+ });
747
+ assertNotCancelled(customize);
748
+ if (customize) {
749
+ p.log.info(`Reference: ${link(pc.cyan(PROVIDERS_DOC_URL), PROVIDERS_DOC_URL)}`);
750
+ const manual = await p.text({
751
+ message: "SCRAPE_TARGETS",
752
+ initialValue: deduped,
753
+ validate: validateScrapeTargetsInput
754
+ });
755
+ assertNotCancelled(manual);
756
+ env.SCRAPE_TARGETS = manual;
757
+ p.log.step(`SCRAPE_TARGETS:\n ${pc.cyan(manual)}`);
758
+ } else env.SCRAPE_TARGETS = deduped;
759
+ }
760
+ function validateScrapeTargetsInput(value) {
761
+ if (!value) return "Required";
762
+ try {
763
+ parseScrapeTargets(value);
764
+ } catch (error) {
765
+ return error instanceof Error ? error.message.split("\n")[0] : String(error);
766
+ }
709
767
  }
710
768
  function dedupeTargets(targets) {
711
- const seen = new Set();
712
- const out = [];
713
- for (const t of targets) {
714
- if (seen.has(t))
715
- continue;
716
- seen.add(t);
717
- out.push(t);
718
- }
719
- return out.join(",");
769
+ const seen = /* @__PURE__ */ new Set();
770
+ const out = [];
771
+ for (const t of targets) {
772
+ if (seen.has(t)) continue;
773
+ seen.add(t);
774
+ out.push(t);
775
+ }
776
+ return out.join(",");
720
777
  }
721
- // ── Start helper (used by init) ──────────────────────────────────────────────
722
778
  async function doStart(configDir) {
723
- assertDockerRunning();
724
- log.step("Starting Docker Compose stack...");
725
- await runDockerCompose(configDir, ["up", "-d"]);
726
- const s = p.spinner();
727
- s.start("Waiting for services to become healthy...");
728
- const ok = await waitForHealthy(configDir, 180_000);
729
- if (ok) {
730
- s.stop("All services healthy!");
731
- }
732
- else {
733
- s.stop("Health check timed out.");
734
- p.log.warn("Some services did not report healthy status.");
735
- }
736
- log.info("Examples:");
737
- console.log(` ${pc.bold("elmo compose logs -f")}`);
738
- console.log(` ${pc.bold("elmo compose logs -f web")}`);
739
- console.log(` ${pc.bold("elmo compose ps")}`);
740
- console.log(` ${pc.bold("elmo compose down")}`);
779
+ assertDockerRunning();
780
+ log.step("Starting Docker Compose stack...");
781
+ await runDockerCompose(configDir, ["up", "-d"]);
782
+ const s = p.spinner();
783
+ s.start("Waiting for services to become healthy...");
784
+ if (await waitForHealthy(configDir, 18e4)) s.stop("All services healthy!");
785
+ else {
786
+ s.stop("Health check timed out.");
787
+ p.log.warn("Some services did not report healthy status.");
788
+ }
789
+ log.info("Examples:");
790
+ console.log(` ${pc.bold("elmo compose logs -f")}`);
791
+ console.log(` ${pc.bold("elmo compose logs -f web")}`);
792
+ console.log(` ${pc.bold("elmo compose ps")}`);
793
+ console.log(` ${pc.bold("elmo compose down")}`);
741
794
  }
742
- // ── Command: compose ─────────────────────────────────────────────────────────
743
795
  async function runCompose(args, options) {
744
- const configDir = await resolveConfigDir(options.dir);
745
- assertDockerRunning();
746
- await runDockerCompose(configDir, args);
796
+ const configDir = await resolveConfigDir(options.dir);
797
+ assertDockerRunning();
798
+ await runDockerCompose(configDir, args);
747
799
  }
748
- // ── Command: edit ────────────────────────────────────────────────────────────
749
800
  async function runEdit(target, options) {
750
- const configDir = await resolveConfigDir(options.dir);
751
- let filePath;
752
- if (target === "env") {
753
- filePath = path.join(configDir, ".env");
754
- }
755
- else if (target === "compose") {
756
- filePath = path.join(configDir, "elmo.yaml");
757
- }
758
- else {
759
- throw new Error(`Unknown edit target: ${target}. Use \`env\` or \`compose\`.`);
760
- }
761
- if (!(await fileExists(filePath))) {
762
- throw new Error(`File not found: ${filePath}`);
763
- }
764
- const editorEnv = process.env.VISUAL || process.env.EDITOR || "nano";
765
- const parts = editorEnv.split(/\s+/).filter(Boolean);
766
- const cmd = parts[0] ?? "nano";
767
- const args = [...parts.slice(1), filePath];
768
- await new Promise((resolve, reject) => {
769
- const child = spawn(cmd, args, { stdio: "inherit" });
770
- child.on("close", (code) => {
771
- if (code === 0)
772
- resolve();
773
- else
774
- reject(new Error(`${cmd} exited with code ${code}`));
775
- });
776
- child.on("error", (err) => reject(err));
777
- });
778
- log.info("Restart the stack with `elmo compose up -d` to apply changes.");
801
+ const configDir = await resolveConfigDir(options.dir);
802
+ let filePath;
803
+ if (target === "env") filePath = path.join(configDir, ".env");
804
+ else if (target === "compose") filePath = path.join(configDir, "elmo.yaml");
805
+ else throw new Error(`Unknown edit target: ${target}. Use \`env\` or \`compose\`.`);
806
+ if (!await fileExists(filePath)) throw new Error(`File not found: ${filePath}`);
807
+ const parts = (process.env.VISUAL || process.env.EDITOR || "nano").split(/\s+/).filter(Boolean);
808
+ const cmd = parts[0] ?? "nano";
809
+ const args = [...parts.slice(1), filePath];
810
+ await new Promise((resolve, reject) => {
811
+ const child = spawn(cmd, args, { stdio: "inherit" });
812
+ child.on("close", (code) => {
813
+ if (code === 0) resolve();
814
+ else reject(/* @__PURE__ */ new Error(`${cmd} exited with code ${code}`));
815
+ });
816
+ child.on("error", (err) => reject(err));
817
+ });
818
+ log.info("Restart the stack with `elmo compose up -d` to apply changes.");
779
819
  }
780
- // ── Compose YAML Builder ─────────────────────────────────────────────────────
781
820
  function buildComposeYaml(options) {
782
- const services = [];
783
- const volumes = new Set();
784
- const dependsOnWeb = [];
785
- const dependsOnWorker = [];
786
- const dependencyConditions = {
787
- postgres: "service_healthy",
788
- "db-migrate": "service_completed_successfully",
789
- };
790
- const dockerfilePath = options.dockerDir
791
- ? path.relative(options.repoRoot, path.join(options.dockerDir, "Dockerfile"))
792
- : "docker/Dockerfile";
793
- if (options.postgresMode === "docker") {
794
- services.push(buildPostgresService());
795
- services.push(buildDbMigrateService({
796
- dev: options.dev,
797
- dockerfilePath,
798
- repoRoot: options.repoRoot,
799
- version: options.version,
800
- }));
801
- dependsOnWeb.push("db-migrate");
802
- dependsOnWorker.push("db-migrate");
803
- volumes.add("postgres_data");
804
- }
805
- services.push(buildWebService({
806
- dev: options.dev,
807
- dependsOn: dependsOnWeb,
808
- dependencyConditions,
809
- repoRoot: options.repoRoot,
810
- dockerfilePath,
811
- port: options.port,
812
- version: options.version,
813
- }));
814
- services.push(buildWorkerService({
815
- dev: options.dev,
816
- dependsOn: dependsOnWorker,
817
- dependencyConditions,
818
- repoRoot: options.repoRoot,
819
- dockerfilePath,
820
- version: options.version,
821
- }));
822
- const lines = [renderedByHeader(options.version), "", "name: elmo", "", "services:"];
823
- lines.push(...services.map((service) => indentBlock(service, 2)));
824
- if (volumes.size > 0) {
825
- lines.push("", "volumes:");
826
- for (const volume of volumes) {
827
- lines.push(` ${volume}:`);
828
- }
829
- }
830
- return `${lines.join("\n")}\n`;
821
+ const services = [];
822
+ const volumes = /* @__PURE__ */ new Set();
823
+ const dependsOnWeb = [];
824
+ const dependsOnWorker = [];
825
+ const dependencyConditions = {
826
+ postgres: "service_healthy",
827
+ "db-migrate": "service_completed_successfully"
828
+ };
829
+ const dockerfilePath = options.dockerDir ? path.relative(options.repoRoot, path.join(options.dockerDir, "Dockerfile")) : "docker/Dockerfile";
830
+ if (options.postgresMode === "docker") {
831
+ services.push(buildPostgresService());
832
+ services.push(buildDbMigrateService({
833
+ dev: options.dev,
834
+ dockerfilePath,
835
+ repoRoot: options.repoRoot,
836
+ version: options.version
837
+ }));
838
+ dependsOnWeb.push("db-migrate");
839
+ dependsOnWorker.push("db-migrate");
840
+ volumes.add("postgres_data");
841
+ }
842
+ services.push(buildWebService({
843
+ dev: options.dev,
844
+ dependsOn: dependsOnWeb,
845
+ dependencyConditions,
846
+ repoRoot: options.repoRoot,
847
+ dockerfilePath,
848
+ port: options.port,
849
+ version: options.version
850
+ }));
851
+ services.push(buildWorkerService({
852
+ dev: options.dev,
853
+ dependsOn: dependsOnWorker,
854
+ dependencyConditions,
855
+ repoRoot: options.repoRoot,
856
+ dockerfilePath,
857
+ version: options.version
858
+ }));
859
+ const lines = [
860
+ renderedByHeader(options.version),
861
+ "",
862
+ "name: elmo",
863
+ "",
864
+ "services:"
865
+ ];
866
+ lines.push(...services.map((service) => indentBlock(service, 2)));
867
+ if (volumes.size > 0) {
868
+ lines.push("", "volumes:");
869
+ for (const volume of volumes) lines.push(` ${volume}:`);
870
+ }
871
+ return `${lines.join("\n")}\n`;
831
872
  }
832
873
  function buildPostgresService() {
833
- return [
834
- "postgres:",
835
- " image: postgres:16-alpine",
836
- " environment:",
837
- " POSTGRES_USER: postgres",
838
- " POSTGRES_PASSWORD: postgres",
839
- " POSTGRES_DB: elmo",
840
- " volumes:",
841
- " - postgres_data:/var/lib/postgresql/data",
842
- " ports:",
843
- ' - "5432:5432"',
844
- " healthcheck:",
845
- ' test: ["CMD-SHELL", "pg_isready -U postgres"]',
846
- " interval: 5s",
847
- " timeout: 5s",
848
- " retries: 5",
849
- " start_period: 30s",
850
- ].join("\n");
874
+ return [
875
+ "postgres:",
876
+ " image: postgres:16-alpine",
877
+ " environment:",
878
+ " POSTGRES_USER: postgres",
879
+ " POSTGRES_PASSWORD: postgres",
880
+ " POSTGRES_DB: elmo",
881
+ " volumes:",
882
+ " - postgres_data:/var/lib/postgresql/data",
883
+ " ports:",
884
+ " - \"5432:5432\"",
885
+ " healthcheck:",
886
+ " test: [\"CMD-SHELL\", \"pg_isready -U postgres\"]",
887
+ " interval: 5s",
888
+ " timeout: 5s",
889
+ " retries: 5",
890
+ " start_period: 30s"
891
+ ].join("\n");
851
892
  }
852
893
  function buildDbMigrateService(options) {
853
- const lines = ["db-migrate:"];
854
- if (options.dev) {
855
- lines.push(" build:", ` context: ${options.repoRoot}`, ` dockerfile: ${options.dockerfilePath}`, " target: migrate");
856
- }
857
- else {
858
- lines.push(` image: elmohq/elmo-db-migrate:${options.version}`);
859
- }
860
- lines.push(" environment:", " - DATABASE_URL=postgres://postgres:postgres@postgres:5432/elmo", " depends_on:", " postgres:", " condition: service_healthy");
861
- return lines.join("\n");
894
+ const lines = ["db-migrate:"];
895
+ if (options.dev) lines.push(" build:", ` context: ${options.repoRoot}`, ` dockerfile: ${options.dockerfilePath}`, " target: migrate");
896
+ else lines.push(` image: elmohq/elmo-db-migrate:${options.version}`);
897
+ lines.push(" environment:", " - DATABASE_URL=postgres://postgres:postgres@postgres:5432/elmo", " depends_on:", " postgres:", " condition: service_healthy");
898
+ return lines.join("\n");
862
899
  }
863
900
  function buildWebService(options) {
864
- const lines = ["web:"];
865
- if (options.dev) {
866
- lines.push(" build:", ` context: ${options.repoRoot}`, ` dockerfile: ${options.dockerfilePath}`, " target: web", " args:", " DEPLOYMENT_MODE: local");
867
- }
868
- else {
869
- lines.push(` image: elmohq/elmo-web:${options.version}`);
870
- }
871
- lines.push(" env_file:", " - path: .env", " required: true", " ports:", ` - "${options.port}:3000"`);
872
- if (options.dependsOn.length > 0) {
873
- lines.push(" depends_on:");
874
- for (const service of options.dependsOn) {
875
- const condition = options.dependencyConditions[service] ?? "service_started";
876
- lines.push(` ${service}:`, ` condition: ${condition}`);
877
- }
878
- }
879
- return lines.join("\n");
901
+ const lines = ["web:"];
902
+ if (options.dev) lines.push(" build:", ` context: ${options.repoRoot}`, ` dockerfile: ${options.dockerfilePath}`, " target: web", " args:", " DEPLOYMENT_MODE: local");
903
+ else lines.push(` image: elmohq/elmo-web:${options.version}`);
904
+ lines.push(" env_file:", " - path: .env", " required: true", " ports:", ` - "${options.port}:3000"`);
905
+ if (options.dependsOn.length > 0) {
906
+ lines.push(" depends_on:");
907
+ for (const service of options.dependsOn) {
908
+ const condition = options.dependencyConditions[service] ?? "service_started";
909
+ lines.push(` ${service}:`, ` condition: ${condition}`);
910
+ }
911
+ }
912
+ return lines.join("\n");
880
913
  }
881
914
  function buildWorkerService(options) {
882
- const lines = ["worker:"];
883
- if (options.dev) {
884
- lines.push(" build:", ` context: ${options.repoRoot}`, ` dockerfile: ${options.dockerfilePath}`, " target: worker", " args:", " DEPLOYMENT_MODE: local");
885
- }
886
- else {
887
- lines.push(` image: elmohq/elmo-worker:${options.version}`);
888
- }
889
- lines.push(" env_file:", " - path: .env", " required: true");
890
- if (options.dependsOn.length > 0) {
891
- lines.push(" depends_on:");
892
- for (const service of options.dependsOn) {
893
- const condition = options.dependencyConditions[service] ?? "service_started";
894
- lines.push(` ${service}:`, ` condition: ${condition}`);
895
- }
896
- }
897
- return lines.join("\n");
915
+ const lines = ["worker:"];
916
+ if (options.dev) lines.push(" build:", ` context: ${options.repoRoot}`, ` dockerfile: ${options.dockerfilePath}`, " target: worker", " args:", " DEPLOYMENT_MODE: local");
917
+ else lines.push(` image: elmohq/elmo-worker:${options.version}`);
918
+ lines.push(" env_file:", " - path: .env", " required: true");
919
+ if (options.dependsOn.length > 0) {
920
+ lines.push(" depends_on:");
921
+ for (const service of options.dependsOn) {
922
+ const condition = options.dependencyConditions[service] ?? "service_started";
923
+ lines.push(` ${service}:`, ` condition: ${condition}`);
924
+ }
925
+ }
926
+ return lines.join("\n");
898
927
  }
899
928
  function indentBlock(block, spaces) {
900
- const indent = " ".repeat(spaces);
901
- return block
902
- .split("\n")
903
- .map((line) => `${indent}${line}`)
904
- .join("\n");
929
+ const indent = " ".repeat(spaces);
930
+ return block.split("\n").map((line) => `${indent}${line}`).join("\n");
905
931
  }
906
- // ── Docker Helpers ───────────────────────────────────────────────────────────
907
932
  async function getComposeServices(configDir) {
908
- const output = await runDockerComposeCapture(configDir, ["ps", "--format", "json"]);
909
- if (!output.trim()) {
910
- return [];
911
- }
912
- try {
913
- const trimmed = output.trim();
914
- const parsed = JSON.parse(trimmed);
915
- if (Array.isArray(parsed)) {
916
- return parsed;
917
- }
918
- if (typeof parsed === "object" && parsed !== null) {
919
- return [parsed];
920
- }
921
- return [];
922
- }
923
- catch {
924
- try {
925
- return output
926
- .trim()
927
- .split("\n")
928
- .filter((line) => line.trim())
929
- .map((line) => JSON.parse(line));
930
- }
931
- catch {
932
- log.warn("Unable to parse docker compose status.");
933
- return [];
934
- }
935
- }
933
+ const output = await runDockerComposeCapture(configDir, [
934
+ "ps",
935
+ "--format",
936
+ "json"
937
+ ]);
938
+ if (!output.trim()) return [];
939
+ try {
940
+ const trimmed = output.trim();
941
+ const parsed = JSON.parse(trimmed);
942
+ if (Array.isArray(parsed)) return parsed;
943
+ if (typeof parsed === "object" && parsed !== null) return [parsed];
944
+ return [];
945
+ } catch {
946
+ try {
947
+ return output.trim().split("\n").filter((line) => line.trim()).map((line) => JSON.parse(line));
948
+ } catch {
949
+ log.warn("Unable to parse docker compose status.");
950
+ return [];
951
+ }
952
+ }
936
953
  }
937
954
  function isServiceReady(service) {
938
- if (service.Health) {
939
- return service.Health === "healthy";
940
- }
941
- if (service.State?.startsWith("running")) {
942
- return true;
943
- }
944
- return false;
955
+ if (service.Health) return service.Health === "healthy";
956
+ if (service.State?.startsWith("running")) return true;
957
+ return false;
945
958
  }
946
959
  async function waitForHealthy(configDir, timeoutMs) {
947
- const start = Date.now();
948
- while (Date.now() - start < timeoutMs) {
949
- const services = await getComposeServices(configDir);
950
- if (services.length > 0 && services.every(isServiceReady)) {
951
- return true;
952
- }
953
- await sleep(3000);
954
- }
955
- return false;
960
+ const start = Date.now();
961
+ while (Date.now() - start < timeoutMs) {
962
+ const services = await getComposeServices(configDir);
963
+ if (services.length > 0 && services.every(isServiceReady)) return true;
964
+ await sleep(3e3);
965
+ }
966
+ return false;
956
967
  }
957
968
  function sleep(ms) {
958
- return new Promise((resolve) => setTimeout(resolve, ms));
969
+ return new Promise((resolve) => setTimeout(resolve, ms));
959
970
  }
960
971
  function runDockerCompose(configDir, args) {
961
- return new Promise((resolve, reject) => {
962
- const composeFile = path.join(configDir, "elmo.yaml");
963
- const commandArgs = ["compose", "-f", composeFile, ...args];
964
- const child = spawn("docker", commandArgs, {
965
- stdio: "inherit",
966
- });
967
- child.on("close", (code) => {
968
- if (code === 0) {
969
- resolve();
970
- }
971
- else {
972
- reject(new Error(`docker compose exited with code ${code}`));
973
- }
974
- });
975
- });
972
+ return new Promise((resolve, reject) => {
973
+ spawn("docker", [
974
+ "compose",
975
+ "-f",
976
+ path.join(configDir, "elmo.yaml"),
977
+ ...args
978
+ ], { stdio: "inherit" }).on("close", (code) => {
979
+ if (code === 0) resolve();
980
+ else reject(/* @__PURE__ */ new Error(`docker compose exited with code ${code}`));
981
+ });
982
+ });
976
983
  }
977
984
  function runDockerComposeCapture(configDir, args) {
978
- return new Promise((resolve, reject) => {
979
- const composeFile = path.join(configDir, "elmo.yaml");
980
- const commandArgs = ["compose", "-f", composeFile, ...args];
981
- const child = spawn("docker", commandArgs);
982
- let stdout = "";
983
- let stderr = "";
984
- child.stdout.on("data", (data) => {
985
- stdout += data.toString();
986
- });
987
- child.stderr.on("data", (data) => {
988
- stderr += data.toString();
989
- });
990
- child.on("close", (code) => {
991
- if (code === 0) {
992
- resolve(stdout);
993
- }
994
- else {
995
- reject(new Error(stderr || `docker compose exited with code ${code}`));
996
- }
997
- });
998
- });
985
+ return new Promise((resolve, reject) => {
986
+ const child = spawn("docker", [
987
+ "compose",
988
+ "-f",
989
+ path.join(configDir, "elmo.yaml"),
990
+ ...args
991
+ ]);
992
+ let stdout = "";
993
+ let stderr = "";
994
+ child.stdout.on("data", (data) => {
995
+ stdout += data.toString();
996
+ });
997
+ child.stderr.on("data", (data) => {
998
+ stderr += data.toString();
999
+ });
1000
+ child.on("close", (code) => {
1001
+ if (code === 0) resolve(stdout);
1002
+ else reject(new Error(stderr || `docker compose exited with code ${code}`));
1003
+ });
1004
+ });
999
1005
  }
1000
1006
  function assertDockerRunning() {
1001
- const result = spawnSync("docker", ["info"], {
1002
- stdio: "ignore",
1003
- });
1004
- if (result.status !== 0) {
1005
- throw new Error("Docker does not appear to be running. Start Docker and try again.");
1006
- }
1007
+ if (spawnSync("docker", ["info"], { stdio: "ignore" }).status !== 0) throw new Error("Docker does not appear to be running. Start Docker and try again.");
1007
1008
  }
1008
- // ── Docker Dir Resolution ────────────────────────────────────────────────────
1009
1009
  async function resolveDockerDirInteractive(cwd) {
1010
- const inCwd = await fileExists(path.join(cwd, "Dockerfile"));
1011
- const inDockerDir = await fileExists(path.join(cwd, "docker", "Dockerfile"));
1012
- const defaultDir = inCwd ? "." : inDockerDir ? "docker" : ".";
1013
- const dir = await p.text({
1014
- message: "Path to docker directory (contains Dockerfile)",
1015
- defaultValue: defaultDir,
1016
- });
1017
- assertNotCancelled(dir);
1018
- const resolved = path.resolve(cwd, dir);
1019
- if (!(await fileExists(path.join(resolved, "Dockerfile")))) {
1020
- p.log.error(`Dockerfile not found in ${resolved}. Provide the directory that contains Dockerfile.`);
1021
- process.exit(1);
1022
- }
1023
- return resolved;
1024
- }
1025
- async function resolveDockerDirAuto(cwd, explicitDir) {
1026
- if (explicitDir) {
1027
- const resolved = path.resolve(cwd, explicitDir);
1028
- if (!(await fileExists(path.join(resolved, "Dockerfile")))) {
1029
- throw new Error(`Dockerfile not found in ${resolved}`);
1030
- }
1031
- return resolved;
1032
- }
1033
- // Auto-detect
1034
- if (await fileExists(path.join(cwd, "docker", "Dockerfile"))) {
1035
- return path.resolve(cwd, "docker");
1036
- }
1037
- if (await fileExists(path.join(cwd, "Dockerfile"))) {
1038
- return cwd;
1039
- }
1040
- throw new Error("Could not find Dockerfile. Specify --docker-dir or set ELMO_DOCKER_DIR.");
1010
+ const inCwd = await fileExists(path.join(cwd, "Dockerfile"));
1011
+ const inDockerDir = await fileExists(path.join(cwd, "docker", "Dockerfile"));
1012
+ const defaultDir = inCwd ? "." : inDockerDir ? "docker" : ".";
1013
+ const dir = await p.text({
1014
+ message: "Path to docker directory (contains Dockerfile)",
1015
+ defaultValue: defaultDir
1016
+ });
1017
+ assertNotCancelled(dir);
1018
+ const resolved = path.resolve(cwd, dir);
1019
+ if (!await fileExists(path.join(resolved, "Dockerfile"))) {
1020
+ p.log.error(`Dockerfile not found in ${resolved}. Provide the directory that contains Dockerfile.`);
1021
+ process.exit(1);
1022
+ }
1023
+ return resolved;
1041
1024
  }
1042
- // ── Config Dir Resolution ────────────────────────────────────────────────────
1043
1025
  async function resolveConfigDir(explicitDir) {
1044
- const resolved = explicitDir ? path.resolve(process.cwd(), explicitDir) : CONFIG_HOME;
1045
- const composePath = path.join(resolved, "elmo.yaml");
1046
- if (!(await fileExists(composePath))) {
1047
- if (explicitDir) {
1048
- throw new Error(`Config directory does not contain elmo.yaml: ${resolved}\nRun \`elmo init --dir ${explicitDir}\` to create it.`);
1049
- }
1050
- throw new Error(`No config found at ${resolved}. Run \`elmo init\` to create one, or specify --dir.`);
1051
- }
1052
- return resolved;
1026
+ const resolved = explicitDir ? path.resolve(process.cwd(), explicitDir) : CONFIG_HOME;
1027
+ if (!await fileExists(path.join(resolved, "elmo.yaml"))) {
1028
+ if (explicitDir) throw new Error(`Config directory does not contain elmo.yaml: ${resolved}\nRun \`elmo init --dir ${explicitDir}\` to create it.`);
1029
+ throw new Error(`No config found at ${resolved}. Run \`elmo init\` to create one, or specify --dir.`);
1030
+ }
1031
+ return resolved;
1053
1032
  }
1054
- // ── File & Config Helpers ────────────────────────────────────────────────────
1055
1033
  async function writeConfigFiles(configDir, initConfig) {
1056
- const envPath = path.join(configDir, ".env");
1057
- const composePath = path.join(configDir, "elmo.yaml");
1058
- await ensureDir(configDir);
1059
- await fs.writeFile(envPath, buildEnvFile(initConfig.env, initConfig.version), "utf8");
1060
- await fs.writeFile(composePath, initConfig.composeYaml, "utf8");
1034
+ const envPath = path.join(configDir, ".env");
1035
+ const composePath = path.join(configDir, "elmo.yaml");
1036
+ await ensureDir(configDir);
1037
+ await fs.writeFile(envPath, buildEnvFile(initConfig.env, initConfig.version), "utf8");
1038
+ await fs.writeFile(composePath, initConfig.composeYaml, "utf8");
1061
1039
  }
1062
1040
  function renderedByHeader(version) {
1063
- return [
1064
- `# Rendered by elmo ${version} on ${new Date().toISOString()}`,
1065
- "# Re-run `elmo init` after upgrading the CLI to refresh this file.",
1066
- ].join("\n");
1041
+ return [`# Rendered by elmo ${version} on ${(/* @__PURE__ */ new Date()).toISOString()}`, "# Re-run `elmo init` after upgrading the CLI to refresh this file."].join("\n");
1067
1042
  }
1068
1043
  function buildEnvFile(env, version) {
1069
- const lines = [renderedByHeader(version), "# WARNING: contains secrets. Do not commit.", ""];
1070
- for (const [key, rawValue] of Object.entries(env)) {
1071
- if (rawValue === undefined) {
1072
- continue;
1073
- }
1074
- lines.push(`${key}=${formatEnvValue(rawValue)}`);
1075
- }
1076
- return `${lines.join("\n")}\n`;
1044
+ const lines = [
1045
+ renderedByHeader(version),
1046
+ "# WARNING: contains secrets. Do not commit.",
1047
+ ""
1048
+ ];
1049
+ for (const [key, rawValue] of Object.entries(env)) {
1050
+ if (rawValue === void 0) continue;
1051
+ lines.push(`${key}=${formatEnvValue(rawValue)}`);
1052
+ }
1053
+ return `${lines.join("\n")}\n`;
1077
1054
  }
1078
1055
  function formatEnvValue(value) {
1079
- if (value === "") {
1080
- return '""';
1081
- }
1082
- if (/[\s#"']/u.test(value)) {
1083
- const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
1084
- return `"${escaped}"`;
1085
- }
1086
- return value;
1056
+ if (value === "") return "\"\"";
1057
+ if (/[\s#"']/u.test(value)) return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
1058
+ return value;
1087
1059
  }
1088
1060
  async function fileExists(target) {
1089
- try {
1090
- await fs.access(target);
1091
- return true;
1092
- }
1093
- catch {
1094
- return false;
1095
- }
1061
+ try {
1062
+ await fs.access(target);
1063
+ return true;
1064
+ } catch {
1065
+ return false;
1066
+ }
1096
1067
  }
1097
1068
  async function ensureDir(dir) {
1098
- await fs.mkdir(dir, { recursive: true });
1069
+ await fs.mkdir(dir, { recursive: true });
1099
1070
  }
1100
- // ── Version Helpers ──────────────────────────────────────────────────────────
1101
1071
  async function getPackageVersion() {
1102
- const selfDir = path.dirname(fileURLToPath(import.meta.url));
1103
- const packagePath = path.resolve(selfDir, "..", "package.json");
1104
- const contents = await fs.readFile(packagePath, "utf8");
1105
- const json = JSON.parse(contents);
1106
- return json.version;
1072
+ const selfDir = path.dirname(fileURLToPath(import.meta.url));
1073
+ const packagePath = path.resolve(selfDir, "..", "package.json");
1074
+ const contents = await fs.readFile(packagePath, "utf8");
1075
+ return JSON.parse(contents).version;
1107
1076
  }
1108
1077
  async function maybeNotifyNewVersion(currentVersion) {
1109
- try {
1110
- const response = await fetch("https://registry.npmjs.org/@elmohq/cli/latest");
1111
- if (!response.ok) {
1112
- return;
1113
- }
1114
- const data = (await response.json());
1115
- if (!data.version) {
1116
- return;
1117
- }
1118
- if (semver.valid(currentVersion) && semver.lt(currentVersion, data.version)) {
1119
- log.warn(`New CLI version available (${data.version}). Run: npm install -g @elmohq/cli@latest`);
1120
- }
1121
- }
1122
- catch {
1123
- // Ignore update errors
1124
- }
1078
+ try {
1079
+ const response = await fetch("https://registry.npmjs.org/@elmohq/cli/latest");
1080
+ if (!response.ok) return;
1081
+ const data = await response.json();
1082
+ if (!data.version) return;
1083
+ if (semver.valid(currentVersion) && semver.lt(currentVersion, data.version)) log.warn(`New CLI version available (${data.version}). Run: npm install -g @elmohq/cli@latest`);
1084
+ } catch {}
1125
1085
  }
1126
- // ── Entry Point ──────────────────────────────────────────────────────────────
1127
1086
  main().catch((error) => {
1128
- const msg = error instanceof Error ? error.message : String(error);
1129
- console.error(`\n${pc.red("Error:")} ${msg}`);
1130
- process.exit(1);
1087
+ const msg = error instanceof Error ? error.message : String(error);
1088
+ console.error(`\n${pc.red("Error:")} ${msg}`);
1089
+ process.exit(1);
1131
1090
  });
1091
+ //#endregion
1092
+ export {};