@elmohq/cli 0.2.12 → 0.2.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/index.js +1253 -1011
- package/package.json +12 -7
- package/dist/telemetry.js +0 -67
package/dist/index.js
CHANGED
|
@@ -7,1125 +7,1367 @@ 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
|
|
10
|
+
import { parse } from "dotenv";
|
|
11
11
|
import pc from "picocolors";
|
|
12
12
|
import semver from "semver";
|
|
13
|
-
import {
|
|
14
|
-
|
|
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/compose-pin.ts
|
|
53
|
+
function renderedByHeader(version) {
|
|
54
|
+
return [`# Rendered by elmo ${version} on ${(/* @__PURE__ */ new Date()).toISOString()}`, "# Run `elmo upgrade` after upgrading the CLI to refresh this file."].join("\n");
|
|
55
|
+
}
|
|
56
|
+
function parseRenderedVersion(contents) {
|
|
57
|
+
const match = contents.match(/^# Rendered by elmo (\S+) on /m);
|
|
58
|
+
return match ? match[1] : null;
|
|
59
|
+
}
|
|
60
|
+
function refreshHeaderVersion(contents, version) {
|
|
61
|
+
if (!/^# Rendered by elmo \S+ on /m.test(contents)) return `${renderedByHeader(version)}\n${contents}`;
|
|
62
|
+
return contents.replace(/^# Rendered by elmo \S+ on .*$/m, `# Rendered by elmo ${version} on ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
63
|
+
}
|
|
64
|
+
function repinImages(contents, version) {
|
|
65
|
+
return contents.replace(/(image:\s*elmohq\/elmo-[a-z-]+):\S+/g, `$1:${version}`);
|
|
66
|
+
}
|
|
67
|
+
//#endregion
|
|
68
|
+
//#region src/migrations/planner.ts
|
|
69
|
+
function planMigrations(from, to, all) {
|
|
70
|
+
if (!semver.valid(from)) throw new Error(`Invalid from version: ${from}`);
|
|
71
|
+
if (!semver.valid(to)) throw new Error(`Invalid to version: ${to}`);
|
|
72
|
+
if (semver.gt(from, to)) throw new Error(`Downgrade not supported: ${from} -> ${to}`);
|
|
73
|
+
if (semver.eq(from, to)) return [];
|
|
74
|
+
for (const m of all) {
|
|
75
|
+
if (!semver.valid(m.from)) throw new Error(`Migration has invalid from: ${m.from}`);
|
|
76
|
+
if (!semver.valid(m.to)) throw new Error(`Migration has invalid to: ${m.to}`);
|
|
77
|
+
if (!semver.gt(m.to, m.from)) throw new Error(`Migration ${m.from} -> ${m.to}: 'to' must be greater than 'from'`);
|
|
78
|
+
}
|
|
79
|
+
const applicable = all.filter((m) => semver.gte(m.from, from) && semver.lt(m.from, to)).slice().sort((a, b) => semver.compare(a.from, b.from));
|
|
80
|
+
const seen = /* @__PURE__ */ new Set();
|
|
81
|
+
for (const m of applicable) {
|
|
82
|
+
if (seen.has(m.from)) throw new Error(`Multiple migrations registered with from=${m.from}`);
|
|
83
|
+
seen.add(m.from);
|
|
84
|
+
}
|
|
85
|
+
return applicable;
|
|
86
|
+
}
|
|
87
|
+
async function runMigrations(plan, ctx) {
|
|
88
|
+
for (const migration of plan) {
|
|
89
|
+
ctx.log.step(`Migrating ${migration.from} → ${migration.to}: ${migration.description}`);
|
|
90
|
+
await migration.run(ctx);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
//#endregion
|
|
94
|
+
//#region src/migrations/index.ts
|
|
95
|
+
const MIGRATIONS = [];
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region src/telemetry.ts
|
|
98
|
+
const POSTHOG_PUBLIC_KEY = "phc_Jhx9LnI9cTDFHpQmpOzJSDTW127qD9pFU65KRnYym6z";
|
|
99
|
+
const POSTHOG_HOST = "https://us.i.posthog.com";
|
|
100
|
+
function envDisabled() {
|
|
101
|
+
return Boolean(process.env.DISABLE_TELEMETRY);
|
|
102
|
+
}
|
|
103
|
+
async function readEnvKey(configDir, key) {
|
|
104
|
+
try {
|
|
105
|
+
return parse(await fs.readFile(path.join(configDir, ".env"), "utf8"))[key];
|
|
106
|
+
} catch {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async function isTelemetryDisabled(configDir) {
|
|
111
|
+
if (envDisabled()) return true;
|
|
112
|
+
return Boolean(await readEnvKey(configDir, "DISABLE_TELEMETRY"));
|
|
113
|
+
}
|
|
114
|
+
async function trackCliEvent(configDir, eventName, properties, personProperties) {
|
|
115
|
+
if (await isTelemetryDisabled(configDir)) return;
|
|
116
|
+
const distinctId = await readEnvKey(configDir, "DEPLOYMENT_ID");
|
|
117
|
+
if (!distinctId) return;
|
|
118
|
+
try {
|
|
119
|
+
const client = new PostHog(POSTHOG_PUBLIC_KEY, { host: POSTHOG_HOST });
|
|
120
|
+
client.capture({
|
|
121
|
+
distinctId,
|
|
122
|
+
event: eventName,
|
|
123
|
+
properties: {
|
|
124
|
+
...properties,
|
|
125
|
+
...personProperties ? { $set: personProperties } : {}
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
await client.shutdown();
|
|
129
|
+
} catch {}
|
|
130
|
+
}
|
|
131
|
+
async function submitNewsletterSignup(configDir, email) {
|
|
132
|
+
try {
|
|
133
|
+
const distinctId = (await isTelemetryDisabled(configDir) ? null : await readEnvKey(configDir, "DEPLOYMENT_ID")) ?? email;
|
|
134
|
+
const client = new PostHog(POSTHOG_PUBLIC_KEY, { host: POSTHOG_HOST });
|
|
135
|
+
client.capture({
|
|
136
|
+
distinctId,
|
|
137
|
+
event: "newsletter_signup",
|
|
138
|
+
properties: {
|
|
139
|
+
source: "cli_init",
|
|
140
|
+
$set: {
|
|
141
|
+
email,
|
|
142
|
+
wants_updates: true
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
await client.shutdown();
|
|
147
|
+
} catch {}
|
|
148
|
+
}
|
|
149
|
+
//#endregion
|
|
150
|
+
//#region src/index.ts
|
|
15
151
|
const CONFIG_HOME = path.join(os.homedir(), ".elmo");
|
|
16
152
|
const DEFAULT_APP_NAME = "Elmo";
|
|
17
153
|
const DEFAULT_APP_ICON = "/icons/elmo-icon.svg";
|
|
18
154
|
const DEFAULT_APP_PORT = 1515;
|
|
19
155
|
const LOCAL_DATABASE_URL = "postgres://postgres:postgres@postgres:5432/elmo";
|
|
20
156
|
const TELEMETRY_DOC_URL = "https://elmohq.com/docs/developer-guide/telemetry";
|
|
21
|
-
// ── Banner ───────────────────────────────────────────────────────────────────
|
|
22
157
|
const ELMO_ASCII = [
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
158
|
+
"",
|
|
159
|
+
" ▄▄ ",
|
|
160
|
+
" ██ ",
|
|
161
|
+
"▄█▀█▄ ██ ███▄███▄ ▄███▄ ",
|
|
162
|
+
"██▄█▀ ██ ██ ██ ██ ██ ██ ",
|
|
163
|
+
"▀█▄▄▄ ██ ██ ██ ██ ▀███▀ ",
|
|
164
|
+
""
|
|
30
165
|
].join("\n");
|
|
31
166
|
function printBanner() {
|
|
32
|
-
|
|
33
|
-
const blue = "\x1b[38;2;37;99;235m";
|
|
34
|
-
const reset = "\x1b[0m";
|
|
35
|
-
console.log(`${blue}${ELMO_ASCII}${reset}`);
|
|
167
|
+
console.log(`[38;2;37;99;235m${ELMO_ASCII}[0m`);
|
|
36
168
|
}
|
|
37
|
-
// ── Logging ──────────────────────────────────────────────────────────────────
|
|
38
169
|
const log = {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
170
|
+
info: (msg) => p.log.info(msg),
|
|
171
|
+
warn: (msg) => p.log.warn(msg),
|
|
172
|
+
error: (msg) => p.log.error(msg),
|
|
173
|
+
success: (msg) => p.log.success(msg),
|
|
174
|
+
step: (msg) => p.log.step(msg)
|
|
44
175
|
};
|
|
45
|
-
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
46
176
|
function assertNotCancelled(value) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
177
|
+
if (p.isCancel(value)) {
|
|
178
|
+
p.cancel("Setup cancelled.");
|
|
179
|
+
process.exit(0);
|
|
180
|
+
}
|
|
51
181
|
}
|
|
52
182
|
function generateSecret(bytes = 32) {
|
|
53
|
-
|
|
183
|
+
return crypto.randomBytes(bytes).toString("base64url");
|
|
54
184
|
}
|
|
55
185
|
function link(text, url) {
|
|
56
|
-
|
|
57
|
-
// Falls back to plain text in unsupported terminals.
|
|
58
|
-
return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
|
|
186
|
+
return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
|
|
59
187
|
}
|
|
60
|
-
// ── Main ─────────────────────────────────────────────────────────────────────
|
|
61
188
|
async function main() {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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);
|
|
189
|
+
const version = await getPackageVersion();
|
|
190
|
+
const program = new Command();
|
|
191
|
+
program.name("elmo").version(version).option("--dir <path>", "Config directory").configureHelp({ showGlobalOptions: true }).action(() => {
|
|
192
|
+
printBanner();
|
|
193
|
+
program.outputHelp();
|
|
194
|
+
});
|
|
195
|
+
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) => {
|
|
196
|
+
await withVersionCheck(version, () => runInit(cmd.optsWithGlobals(), version));
|
|
197
|
+
});
|
|
198
|
+
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) => {
|
|
199
|
+
await withVersionCheck(version, () => runCompose(args, cmd.optsWithGlobals()));
|
|
200
|
+
});
|
|
201
|
+
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) => {
|
|
202
|
+
await runEdit(target, cmd.optsWithGlobals());
|
|
203
|
+
});
|
|
204
|
+
program.command("upgrade").description("upgrade your Elmo deployment to this CLI's version").option("--yes", "skip confirmation prompts").action(async (_opts, cmd) => {
|
|
205
|
+
await runUpgrade(cmd.optsWithGlobals(), version);
|
|
206
|
+
});
|
|
207
|
+
await program.parseAsync(process.argv);
|
|
97
208
|
}
|
|
98
209
|
async function withVersionCheck(version, fn) {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
210
|
+
const notifyPromise = maybeNotifyNewVersion(version);
|
|
211
|
+
await fn();
|
|
212
|
+
await notifyPromise.catch(() => void 0);
|
|
102
213
|
}
|
|
103
|
-
// ── Command: init ────────────────────────────────────────────────────────────
|
|
104
214
|
async function runInit(options, version) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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!"));
|
|
307
|
-
}
|
|
308
|
-
// ── Provider Configuration ───────────────────────────────────────────────────
|
|
215
|
+
printBanner();
|
|
216
|
+
p.intro(pc.bold("Setting up Elmo"));
|
|
217
|
+
const cwd = process.cwd();
|
|
218
|
+
const configDir = options.dir ? path.resolve(cwd, options.dir) : CONFIG_HOME;
|
|
219
|
+
const existingEnvPath = path.join(configDir, ".env");
|
|
220
|
+
let preservedDeploymentId;
|
|
221
|
+
if (await fileExists(existingEnvPath)) {
|
|
222
|
+
const contents = await fs.readFile(existingEnvPath, "utf8");
|
|
223
|
+
if (!(contents.startsWith("# Rendered by elmo") || contents.startsWith("# Generated by elmo"))) {
|
|
224
|
+
p.log.warn(`A .env file already exists in ${configDir} and was NOT created by Elmo.`);
|
|
225
|
+
const overwrite = await p.confirm({
|
|
226
|
+
message: "Overwrite the existing .env file? This cannot be undone.",
|
|
227
|
+
initialValue: false
|
|
228
|
+
});
|
|
229
|
+
assertNotCancelled(overwrite);
|
|
230
|
+
if (!overwrite) {
|
|
231
|
+
p.cancel("Setup cancelled. Choose a different directory with --dir.");
|
|
232
|
+
process.exit(0);
|
|
233
|
+
}
|
|
234
|
+
} else {
|
|
235
|
+
p.log.warn(`An existing Elmo config was found at ${configDir}.`);
|
|
236
|
+
const overwrite = await p.confirm({
|
|
237
|
+
message: "Overwrite it with new values? Existing secrets (DATABASE_URL, API keys, etc.) will be replaced.",
|
|
238
|
+
initialValue: false
|
|
239
|
+
});
|
|
240
|
+
assertNotCancelled(overwrite);
|
|
241
|
+
if (!overwrite) {
|
|
242
|
+
p.cancel("Setup cancelled. Use `elmo edit env` to change individual values.");
|
|
243
|
+
process.exit(0);
|
|
244
|
+
}
|
|
245
|
+
preservedDeploymentId = parse(contents).DEPLOYMENT_ID;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
let dockerDir;
|
|
249
|
+
let repoRoot;
|
|
250
|
+
if (options.dev) {
|
|
251
|
+
if (options.dockerDir) {
|
|
252
|
+
dockerDir = path.resolve(cwd, options.dockerDir);
|
|
253
|
+
if (!await fileExists(path.join(dockerDir, "Dockerfile"))) {
|
|
254
|
+
p.log.error(`Dockerfile not found in ${dockerDir}`);
|
|
255
|
+
process.exit(1);
|
|
256
|
+
}
|
|
257
|
+
} else dockerDir = await resolveDockerDirInteractive(cwd);
|
|
258
|
+
repoRoot = path.resolve(dockerDir, "..");
|
|
259
|
+
} else repoRoot = cwd;
|
|
260
|
+
const postgresMode = await p.select({
|
|
261
|
+
message: "PostgreSQL connection",
|
|
262
|
+
options: [{
|
|
263
|
+
value: "docker",
|
|
264
|
+
label: "Run Postgres in Docker"
|
|
265
|
+
}, {
|
|
266
|
+
value: "external",
|
|
267
|
+
label: "Use existing Postgres (provide DATABASE_URL)"
|
|
268
|
+
}],
|
|
269
|
+
initialValue: "docker"
|
|
270
|
+
});
|
|
271
|
+
assertNotCancelled(postgresMode);
|
|
272
|
+
const env = {};
|
|
273
|
+
env.DEPLOYMENT_MODE = "local";
|
|
274
|
+
env.VITE_DEPLOYMENT_MODE = "local";
|
|
275
|
+
env.DEPLOYMENT_ID = preservedDeploymentId ?? crypto.randomUUID();
|
|
276
|
+
env.BETTER_AUTH_SECRET = generateSecret();
|
|
277
|
+
env.APP_NAME = DEFAULT_APP_NAME;
|
|
278
|
+
env.APP_ICON = DEFAULT_APP_ICON;
|
|
279
|
+
env.VITE_APP_NAME = DEFAULT_APP_NAME;
|
|
280
|
+
env.VITE_APP_ICON = DEFAULT_APP_ICON;
|
|
281
|
+
if (postgresMode === "external") {
|
|
282
|
+
p.note("Must be an IPv4-compatible direct connection or database pooler.", "DATABASE_URL");
|
|
283
|
+
const url = await p.password({
|
|
284
|
+
message: "DATABASE_URL",
|
|
285
|
+
validate: (v) => !v ? "Required" : void 0
|
|
286
|
+
});
|
|
287
|
+
assertNotCancelled(url);
|
|
288
|
+
env.DATABASE_URL = url;
|
|
289
|
+
} else env.DATABASE_URL = LOCAL_DATABASE_URL;
|
|
290
|
+
const setupMode = await configureProvidersInteractive(env);
|
|
291
|
+
p.note([
|
|
292
|
+
"Elmo is open source and maintained by a small team. Telemetry",
|
|
293
|
+
"from both the CLI and your local deployment (web + worker)",
|
|
294
|
+
"tells us things like which CLI versions are still in use, where",
|
|
295
|
+
"`elmo init` drops off, which providers people pick, and whether",
|
|
296
|
+
"new features actually get used. Without it we are flying blind",
|
|
297
|
+
"on what to fix or build next.",
|
|
298
|
+
"",
|
|
299
|
+
pc.bold("What we send:"),
|
|
300
|
+
" • deployment ID (random UUID stored as DEPLOYMENT_ID in your .env)",
|
|
301
|
+
" • CLI/app version, OS, arch, Node version, deployment mode",
|
|
302
|
+
" • command/event names + non-secret options (e.g. postgres mode)",
|
|
303
|
+
" • feature counts (prompts edited, brands created — never the names or text)",
|
|
304
|
+
" • IP address (recorded on each event by PostHog, used for geolocation)",
|
|
305
|
+
"",
|
|
306
|
+
pc.bold("What we never send:"),
|
|
307
|
+
" API keys, .env contents, brand names, prompt text, and scraped responses.",
|
|
308
|
+
"",
|
|
309
|
+
`Full breakdown: ${link(pc.cyan(TELEMETRY_DOC_URL), TELEMETRY_DOC_URL)}`,
|
|
310
|
+
"Toggle later by editing DISABLE_TELEMETRY in .env (`elmo edit env`)."
|
|
311
|
+
].join("\n"), "Telemetry");
|
|
312
|
+
const telemetryEnabled = await p.confirm({
|
|
313
|
+
message: "Share telemetry?",
|
|
314
|
+
initialValue: true
|
|
315
|
+
});
|
|
316
|
+
assertNotCancelled(telemetryEnabled);
|
|
317
|
+
if (!telemetryEnabled) env.DISABLE_TELEMETRY = "1";
|
|
318
|
+
const updatesEmail = await p.text({
|
|
319
|
+
message: "Enter your work email to receive product updates (optional)",
|
|
320
|
+
placeholder: "you@example.com"
|
|
321
|
+
});
|
|
322
|
+
const email = p.isCancel(updatesEmail) ? void 0 : updatesEmail || void 0;
|
|
323
|
+
const portInput = await p.text({
|
|
324
|
+
message: "Web app port",
|
|
325
|
+
placeholder: String(DEFAULT_APP_PORT),
|
|
326
|
+
defaultValue: String(DEFAULT_APP_PORT),
|
|
327
|
+
validate: (v) => {
|
|
328
|
+
if (!v) return void 0;
|
|
329
|
+
const n = Number(v);
|
|
330
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) return "Must be an integer between 1 and 65535";
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
assertNotCancelled(portInput);
|
|
334
|
+
const port = Number(portInput);
|
|
335
|
+
env.APP_URL = `http://localhost:${port}`;
|
|
336
|
+
env.VITE_APP_URL = env.APP_URL;
|
|
337
|
+
const composeYaml = buildComposeYaml({
|
|
338
|
+
dev: Boolean(options.dev),
|
|
339
|
+
postgresMode,
|
|
340
|
+
repoRoot,
|
|
341
|
+
dockerDir,
|
|
342
|
+
port,
|
|
343
|
+
version
|
|
344
|
+
});
|
|
345
|
+
await ensureDir(configDir);
|
|
346
|
+
await writeConfigFiles(configDir, {
|
|
347
|
+
env,
|
|
348
|
+
composeYaml,
|
|
349
|
+
postgresMode,
|
|
350
|
+
dev: Boolean(options.dev),
|
|
351
|
+
version
|
|
352
|
+
});
|
|
353
|
+
p.log.success(`Config written to ${configDir}`);
|
|
354
|
+
p.log.warn("Your generated .env file contains secrets — do not commit it to version control.");
|
|
355
|
+
if (options.dev) p.log.info("Dev mode enabled. Run `elmo compose build` before starting.");
|
|
356
|
+
const shouldStart = await p.confirm({
|
|
357
|
+
message: "Start the stack now?",
|
|
358
|
+
initialValue: true
|
|
359
|
+
});
|
|
360
|
+
assertNotCancelled(shouldStart);
|
|
361
|
+
if (shouldStart) await doStart(configDir);
|
|
362
|
+
else p.log.info("You can start later with `elmo compose up -d`.");
|
|
363
|
+
await trackCliEvent(configDir, "cli_init", {
|
|
364
|
+
version,
|
|
365
|
+
os: process.platform,
|
|
366
|
+
arch: process.arch,
|
|
367
|
+
node_version: process.version,
|
|
368
|
+
postgres_mode: postgresMode,
|
|
369
|
+
dev_mode: Boolean(options.dev),
|
|
370
|
+
setup_mode: setupMode,
|
|
371
|
+
has_scraper: Boolean(env.BRIGHTDATA_API_TOKEN || env.OLOSTEP_API_KEY || env.OXYLABS_USERNAME),
|
|
372
|
+
has_direct_api: hasDirectApiConfigured(env)
|
|
373
|
+
});
|
|
374
|
+
if (email) await submitNewsletterSignup(configDir, email);
|
|
375
|
+
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")}`);
|
|
376
|
+
p.outro(pc.green("Setup complete!"));
|
|
377
|
+
}
|
|
309
378
|
const BRIGHTDATA_AFFILIATE = "https://get.brightdata.com/67h1b7h0shcn";
|
|
310
379
|
const OLOSTEP_AFFILIATE = "https://olostep.com/?ref=elmo";
|
|
380
|
+
const OXYLABS_AFFILIATE = "https://oxylabs.go2cloud.org/aff_c?offer_id=7&aff_id=2263&url_id=32";
|
|
311
381
|
const PROVIDERS_DOC_URL = "https://docs.elmohq.com/docs/user-guide/providers";
|
|
312
|
-
|
|
313
|
-
|
|
382
|
+
const BRIGHTDATA_MODELS = [
|
|
383
|
+
"chatgpt",
|
|
384
|
+
"google-ai-mode",
|
|
385
|
+
"perplexity",
|
|
386
|
+
"copilot",
|
|
387
|
+
"gemini",
|
|
388
|
+
"grok"
|
|
389
|
+
];
|
|
314
390
|
const OLOSTEP_MODELS = [
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
391
|
+
"chatgpt",
|
|
392
|
+
"google-ai-mode",
|
|
393
|
+
"google-ai-overview",
|
|
394
|
+
"perplexity",
|
|
395
|
+
"copilot",
|
|
396
|
+
"gemini",
|
|
397
|
+
"grok"
|
|
398
|
+
];
|
|
399
|
+
const OXYLABS_MODELS = [
|
|
400
|
+
"chatgpt",
|
|
401
|
+
"google-ai-mode",
|
|
402
|
+
"perplexity"
|
|
322
403
|
];
|
|
323
404
|
const DEFAULT_SCRAPER_MODELS = ["chatgpt", "google-ai-mode"];
|
|
405
|
+
const DATAFORSEO_MODELS = [
|
|
406
|
+
"google-ai-mode",
|
|
407
|
+
"chatgpt",
|
|
408
|
+
"perplexity",
|
|
409
|
+
"gemini"
|
|
410
|
+
];
|
|
324
411
|
const DEFAULT_OPENAI_MODEL = "gpt-5-mini";
|
|
325
|
-
const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-
|
|
412
|
+
const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-6";
|
|
326
413
|
const DEFAULT_OPENROUTER_MODEL = "anthropic/claude-sonnet-4.6";
|
|
327
414
|
const DEFAULT_MISTRAL_MODEL = "mistral-medium-latest";
|
|
328
415
|
async function configureProvidersInteractive(env) {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
416
|
+
p.note([
|
|
417
|
+
"Elmo needs two kinds of providers:",
|
|
418
|
+
"",
|
|
419
|
+
pc.bold("1. A scraper") + " — to track ChatGPT and Google AI Mode (no public APIs):",
|
|
420
|
+
` • ${pc.cyan("BrightData")} — cheap solid option, ~$0.45/mo per prompt`,
|
|
421
|
+
` • ${pc.cyan("Oxylabs")} — sync realtime API, pay-as-you-go`,
|
|
422
|
+
` • ${pc.cyan("Olostep")} — premium option, powers Peec/AirOps, ~$2.25/mo per prompt`,
|
|
423
|
+
"",
|
|
424
|
+
pc.bold("2. A direct LLM API") + " — for low-latency tasks (onboarding analysis, sentiment scoring,",
|
|
425
|
+
" ad-hoc LLM calls). Required:",
|
|
426
|
+
` • ${pc.cyan("OpenRouter")} — one key, all major models (recommended)`,
|
|
427
|
+
` • ${pc.cyan("Anthropic / OpenAI / Mistral")} — direct provider keys`,
|
|
428
|
+
"",
|
|
429
|
+
"Pricing assumes Elmo's default cadence (5 runs/day × 2 surfaces)."
|
|
430
|
+
].join("\n"), "AI providers");
|
|
431
|
+
const mode = await p.select({
|
|
432
|
+
message: "Setup mode",
|
|
433
|
+
options: [{
|
|
434
|
+
value: "recommended",
|
|
435
|
+
label: "Recommended — one scraper + one direct API"
|
|
436
|
+
}, {
|
|
437
|
+
value: "custom",
|
|
438
|
+
label: "Custom — pick each provider individually"
|
|
439
|
+
}],
|
|
440
|
+
initialValue: "recommended"
|
|
441
|
+
});
|
|
442
|
+
assertNotCancelled(mode);
|
|
443
|
+
if (mode === "recommended") await configureProvidersRecommended(env);
|
|
444
|
+
else await configureProvidersCustom(env);
|
|
445
|
+
return mode;
|
|
359
446
|
}
|
|
360
447
|
async function configureProvidersRecommended(env) {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
448
|
+
const targets = [];
|
|
449
|
+
const scraper = await p.select({
|
|
450
|
+
message: "Scraper (tracks ChatGPT + Google AI Mode)",
|
|
451
|
+
options: [
|
|
452
|
+
{
|
|
453
|
+
value: "brightdata",
|
|
454
|
+
label: "BrightData — ~$0.45/mo per prompt (cheaper)"
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
value: "oxylabs",
|
|
458
|
+
label: "Oxylabs — sync realtime API, pay-as-you-go"
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
value: "olostep",
|
|
462
|
+
label: "Olostep — ~$2.25/mo per prompt (premium)"
|
|
463
|
+
}
|
|
464
|
+
],
|
|
465
|
+
initialValue: "brightdata"
|
|
466
|
+
});
|
|
467
|
+
assertNotCancelled(scraper);
|
|
468
|
+
await collectScraperKey(scraper, env);
|
|
469
|
+
for (const model of DEFAULT_SCRAPER_MODELS) targets.push(formatScrapeTarget({
|
|
470
|
+
model,
|
|
471
|
+
provider: scraper,
|
|
472
|
+
webSearch: true
|
|
473
|
+
}));
|
|
474
|
+
const direct = await p.select({
|
|
475
|
+
message: "Direct LLM API (powers onboarding analysis + sentiment scoring)",
|
|
476
|
+
options: [
|
|
477
|
+
{
|
|
478
|
+
value: "openrouter",
|
|
479
|
+
label: "OpenRouter — one key, all major models (recommended)"
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
value: "anthropic",
|
|
483
|
+
label: "Anthropic — direct Claude"
|
|
484
|
+
},
|
|
485
|
+
{
|
|
486
|
+
value: "openai",
|
|
487
|
+
label: "OpenAI — direct GPT-* models"
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
value: "mistral",
|
|
491
|
+
label: "Mistral — direct Mistral models"
|
|
492
|
+
}
|
|
493
|
+
],
|
|
494
|
+
initialValue: "openrouter"
|
|
495
|
+
});
|
|
496
|
+
assertNotCancelled(direct);
|
|
497
|
+
await collectDirectApiQuick(direct, env);
|
|
498
|
+
await finalizeScrapeTargets(env, targets, { skipEdit: true });
|
|
390
499
|
}
|
|
391
500
|
async function configureProvidersCustom(env) {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
await collectOlostep(env, targets);
|
|
408
|
-
await collectDataForSEO(env, targets);
|
|
409
|
-
await finalizeScrapeTargets(env, targets);
|
|
501
|
+
const targets = [];
|
|
502
|
+
p.log.step(pc.bold("Step 1 of 2 — Direct LLM API (at least one is required)"));
|
|
503
|
+
while (!hasDirectApiConfigured(env)) {
|
|
504
|
+
await collectOpenRouter(env, targets);
|
|
505
|
+
await collectAnthropic(env, targets);
|
|
506
|
+
await collectOpenAI(env, targets);
|
|
507
|
+
await collectMistral(env, targets);
|
|
508
|
+
if (!hasDirectApiConfigured(env)) p.log.warn("Onboarding analysis and other low-latency LLM tasks require a direct API. Configure at least one before continuing.");
|
|
509
|
+
}
|
|
510
|
+
p.log.step(pc.bold("Step 2 of 2 — Scrapers (optional, but needed to track ChatGPT / Google AI Mode)"));
|
|
511
|
+
await collectBrightData(env, targets);
|
|
512
|
+
await collectOxylabs(env, targets);
|
|
513
|
+
await collectOlostep(env, targets);
|
|
514
|
+
await collectDataForSEO(env, targets);
|
|
515
|
+
await finalizeScrapeTargets(env, targets);
|
|
410
516
|
}
|
|
411
517
|
function hasDirectApiConfigured(env) {
|
|
412
|
-
|
|
518
|
+
return Boolean(env.ANTHROPIC_API_KEY || env.OPENAI_API_KEY || env.MISTRAL_API_KEY || env.OPENROUTER_API_KEY);
|
|
413
519
|
}
|
|
414
520
|
async function collectScraperKey(scraper, env) {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
521
|
+
if (scraper === "brightdata") {
|
|
522
|
+
p.log.info(`Sign up: ${link(pc.cyan(BRIGHTDATA_AFFILIATE), BRIGHTDATA_AFFILIATE)}`);
|
|
523
|
+
const key = await p.password({
|
|
524
|
+
message: "BrightData API token",
|
|
525
|
+
validate: (v) => !v ? "Required" : void 0
|
|
526
|
+
});
|
|
527
|
+
assertNotCancelled(key);
|
|
528
|
+
env.BRIGHTDATA_API_TOKEN = key;
|
|
529
|
+
} else if (scraper === "oxylabs") {
|
|
530
|
+
p.log.info(`Sign up: ${link(pc.cyan(OXYLABS_AFFILIATE), OXYLABS_AFFILIATE)}`);
|
|
531
|
+
const username = await p.text({
|
|
532
|
+
message: "Oxylabs username",
|
|
533
|
+
validate: (v) => !v ? "Required" : void 0
|
|
534
|
+
});
|
|
535
|
+
assertNotCancelled(username);
|
|
536
|
+
env.OXYLABS_USERNAME = username;
|
|
537
|
+
const password = await p.password({
|
|
538
|
+
message: "Oxylabs password",
|
|
539
|
+
validate: (v) => !v ? "Required" : void 0
|
|
540
|
+
});
|
|
541
|
+
assertNotCancelled(password);
|
|
542
|
+
env.OXYLABS_PASSWORD = password;
|
|
543
|
+
} else {
|
|
544
|
+
p.log.info(`Sign up: ${link(pc.cyan(OLOSTEP_AFFILIATE), OLOSTEP_AFFILIATE)}`);
|
|
545
|
+
const key = await p.password({
|
|
546
|
+
message: "Olostep API key",
|
|
547
|
+
validate: (v) => !v ? "Required" : void 0
|
|
548
|
+
});
|
|
549
|
+
assertNotCancelled(key);
|
|
550
|
+
env.OLOSTEP_API_KEY = key;
|
|
551
|
+
}
|
|
433
552
|
}
|
|
434
553
|
async function collectDirectApiQuick(kind, env) {
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
assertNotCancelled(key);
|
|
465
|
-
env.MISTRAL_API_KEY = key;
|
|
466
|
-
}
|
|
554
|
+
if (kind === "openrouter") {
|
|
555
|
+
const key = await p.password({
|
|
556
|
+
message: "OpenRouter API key",
|
|
557
|
+
validate: (v) => !v ? "Required" : void 0
|
|
558
|
+
});
|
|
559
|
+
assertNotCancelled(key);
|
|
560
|
+
env.OPENROUTER_API_KEY = key;
|
|
561
|
+
} else if (kind === "anthropic") {
|
|
562
|
+
const key = await p.password({
|
|
563
|
+
message: "Anthropic API key",
|
|
564
|
+
validate: (v) => !v ? "Required" : void 0
|
|
565
|
+
});
|
|
566
|
+
assertNotCancelled(key);
|
|
567
|
+
env.ANTHROPIC_API_KEY = key;
|
|
568
|
+
} else if (kind === "openai") {
|
|
569
|
+
const key = await p.password({
|
|
570
|
+
message: "OpenAI API key",
|
|
571
|
+
validate: (v) => !v ? "Required" : void 0
|
|
572
|
+
});
|
|
573
|
+
assertNotCancelled(key);
|
|
574
|
+
env.OPENAI_API_KEY = key;
|
|
575
|
+
} else {
|
|
576
|
+
const key = await p.password({
|
|
577
|
+
message: "Mistral API key",
|
|
578
|
+
validate: (v) => !v ? "Required" : void 0
|
|
579
|
+
});
|
|
580
|
+
assertNotCancelled(key);
|
|
581
|
+
env.MISTRAL_API_KEY = key;
|
|
582
|
+
}
|
|
467
583
|
}
|
|
468
584
|
async function collectBrightData(env, targets) {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
});
|
|
585
|
+
const enable = await p.confirm({
|
|
586
|
+
message: `Configure ${pc.bold("BrightData")}? (~$0.45/mo per prompt)`,
|
|
587
|
+
initialValue: true
|
|
588
|
+
});
|
|
589
|
+
assertNotCancelled(enable);
|
|
590
|
+
if (!enable) return;
|
|
591
|
+
p.log.info(`Sign up and generate an API token: ${link(pc.cyan(BRIGHTDATA_AFFILIATE), BRIGHTDATA_AFFILIATE)}`);
|
|
592
|
+
const key = await p.password({
|
|
593
|
+
message: "BrightData API token",
|
|
594
|
+
validate: (v) => !v ? "Required" : void 0
|
|
595
|
+
});
|
|
596
|
+
assertNotCancelled(key);
|
|
597
|
+
env.BRIGHTDATA_API_TOKEN = key;
|
|
598
|
+
await pickScraperTargets({
|
|
599
|
+
providerLabel: "BrightData",
|
|
600
|
+
providerId: "brightdata",
|
|
601
|
+
allModels: BRIGHTDATA_MODELS,
|
|
602
|
+
targets
|
|
603
|
+
});
|
|
489
604
|
}
|
|
490
605
|
async function collectOlostep(env, targets) {
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
606
|
+
const enable = await p.confirm({
|
|
607
|
+
message: `Configure ${pc.bold("Olostep")}? (~$2.25/mo per prompt)`,
|
|
608
|
+
initialValue: false
|
|
609
|
+
});
|
|
610
|
+
assertNotCancelled(enable);
|
|
611
|
+
if (!enable) return;
|
|
612
|
+
p.log.info(`Grab an API key: ${link(pc.cyan(OLOSTEP_AFFILIATE), OLOSTEP_AFFILIATE)}`);
|
|
613
|
+
const key = await p.password({
|
|
614
|
+
message: "Olostep API key",
|
|
615
|
+
validate: (v) => !v ? "Required" : void 0
|
|
616
|
+
});
|
|
617
|
+
assertNotCancelled(key);
|
|
618
|
+
env.OLOSTEP_API_KEY = key;
|
|
619
|
+
await pickScraperTargets({
|
|
620
|
+
providerLabel: "Olostep",
|
|
621
|
+
providerId: "olostep",
|
|
622
|
+
allModels: OLOSTEP_MODELS,
|
|
623
|
+
targets
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
async function collectOxylabs(env, targets) {
|
|
627
|
+
const enable = await p.confirm({
|
|
628
|
+
message: `Configure ${pc.bold("Oxylabs")}? (sync realtime API, pay-as-you-go)`,
|
|
629
|
+
initialValue: false
|
|
630
|
+
});
|
|
631
|
+
assertNotCancelled(enable);
|
|
632
|
+
if (!enable) return;
|
|
633
|
+
p.log.info(`Sign up and create Web Scraper API credentials: ${link(pc.cyan(OXYLABS_AFFILIATE), OXYLABS_AFFILIATE)}`);
|
|
634
|
+
const username = await p.text({
|
|
635
|
+
message: "Oxylabs username",
|
|
636
|
+
validate: (v) => !v ? "Required" : void 0
|
|
637
|
+
});
|
|
638
|
+
assertNotCancelled(username);
|
|
639
|
+
env.OXYLABS_USERNAME = username;
|
|
640
|
+
const password = await p.password({
|
|
641
|
+
message: "Oxylabs password",
|
|
642
|
+
validate: (v) => !v ? "Required" : void 0
|
|
643
|
+
});
|
|
644
|
+
assertNotCancelled(password);
|
|
645
|
+
env.OXYLABS_PASSWORD = password;
|
|
646
|
+
await pickScraperTargets({
|
|
647
|
+
providerLabel: "Oxylabs",
|
|
648
|
+
providerId: "oxylabs",
|
|
649
|
+
allModels: OXYLABS_MODELS,
|
|
650
|
+
targets
|
|
651
|
+
});
|
|
511
652
|
}
|
|
512
653
|
async function pickScraperTargets(args) {
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
654
|
+
const selected = await p.multiselect({
|
|
655
|
+
message: `LLM Providers to track via ${args.providerLabel}`,
|
|
656
|
+
options: args.allModels.map((model) => ({
|
|
657
|
+
value: model,
|
|
658
|
+
label: model
|
|
659
|
+
})),
|
|
660
|
+
required: true,
|
|
661
|
+
initialValues: [...DEFAULT_SCRAPER_MODELS]
|
|
662
|
+
});
|
|
663
|
+
assertNotCancelled(selected);
|
|
664
|
+
for (const model of selected) args.targets.push(formatScrapeTarget({
|
|
665
|
+
model,
|
|
666
|
+
provider: args.providerId,
|
|
667
|
+
webSearch: true
|
|
668
|
+
}));
|
|
523
669
|
}
|
|
524
670
|
async function collectAnthropic(env, targets) {
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
671
|
+
const enable = await p.confirm({
|
|
672
|
+
message: `Configure ${pc.bold("Anthropic API")}? (direct Claude — ~$4–5/mo per prompt per model)`,
|
|
673
|
+
initialValue: false
|
|
674
|
+
});
|
|
675
|
+
assertNotCancelled(enable);
|
|
676
|
+
if (!enable) return;
|
|
677
|
+
const key = await p.password({
|
|
678
|
+
message: "Anthropic API key",
|
|
679
|
+
validate: (v) => !v ? "Required" : void 0
|
|
680
|
+
});
|
|
681
|
+
assertNotCancelled(key);
|
|
682
|
+
env.ANTHROPIC_API_KEY = key;
|
|
683
|
+
const model = await p.text({
|
|
684
|
+
message: "Claude model",
|
|
685
|
+
placeholder: DEFAULT_ANTHROPIC_MODEL,
|
|
686
|
+
defaultValue: DEFAULT_ANTHROPIC_MODEL
|
|
687
|
+
});
|
|
688
|
+
assertNotCancelled(model);
|
|
689
|
+
const slug = model || DEFAULT_ANTHROPIC_MODEL;
|
|
690
|
+
const webSearch = await p.confirm({
|
|
691
|
+
message: "Enable web search? (recommended, but more expensive)",
|
|
692
|
+
initialValue: true
|
|
693
|
+
});
|
|
694
|
+
assertNotCancelled(webSearch);
|
|
695
|
+
targets.push(formatScrapeTarget({
|
|
696
|
+
model: "claude",
|
|
697
|
+
provider: "anthropic-api",
|
|
698
|
+
version: slug,
|
|
699
|
+
webSearch
|
|
700
|
+
}));
|
|
551
701
|
}
|
|
552
702
|
async function collectOpenAI(env, targets) {
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
703
|
+
const enable = await p.confirm({
|
|
704
|
+
message: `Configure ${pc.bold("OpenAI API")}? (gpt-* with web search — not the real ChatGPT UI)`,
|
|
705
|
+
initialValue: false
|
|
706
|
+
});
|
|
707
|
+
assertNotCancelled(enable);
|
|
708
|
+
if (!enable) return;
|
|
709
|
+
const key = await p.password({
|
|
710
|
+
message: "OpenAI API key",
|
|
711
|
+
validate: (v) => !v ? "Required" : void 0
|
|
712
|
+
});
|
|
713
|
+
assertNotCancelled(key);
|
|
714
|
+
env.OPENAI_API_KEY = key;
|
|
715
|
+
const model = await p.text({
|
|
716
|
+
message: "OpenAI model",
|
|
717
|
+
placeholder: DEFAULT_OPENAI_MODEL,
|
|
718
|
+
defaultValue: DEFAULT_OPENAI_MODEL
|
|
719
|
+
});
|
|
720
|
+
assertNotCancelled(model);
|
|
721
|
+
const slug = model || DEFAULT_OPENAI_MODEL;
|
|
722
|
+
const webSearch = await p.confirm({
|
|
723
|
+
message: "Enable web search? (recommended, but more expensive)",
|
|
724
|
+
initialValue: true
|
|
725
|
+
});
|
|
726
|
+
assertNotCancelled(webSearch);
|
|
727
|
+
targets.push(formatScrapeTarget({
|
|
728
|
+
model: "chatgpt",
|
|
729
|
+
provider: "openai-api",
|
|
730
|
+
version: slug,
|
|
731
|
+
webSearch
|
|
732
|
+
}));
|
|
579
733
|
}
|
|
580
734
|
async function collectMistral(env, targets) {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
735
|
+
const enable = await p.confirm({
|
|
736
|
+
message: `Configure ${pc.bold("Mistral API")}? (direct Mistral models)`,
|
|
737
|
+
initialValue: false
|
|
738
|
+
});
|
|
739
|
+
assertNotCancelled(enable);
|
|
740
|
+
if (!enable) return;
|
|
741
|
+
const key = await p.password({
|
|
742
|
+
message: "Mistral API key",
|
|
743
|
+
validate: (v) => !v ? "Required" : void 0
|
|
744
|
+
});
|
|
745
|
+
assertNotCancelled(key);
|
|
746
|
+
env.MISTRAL_API_KEY = key;
|
|
747
|
+
const model = await p.text({
|
|
748
|
+
message: "Mistral model",
|
|
749
|
+
placeholder: DEFAULT_MISTRAL_MODEL,
|
|
750
|
+
defaultValue: DEFAULT_MISTRAL_MODEL
|
|
751
|
+
});
|
|
752
|
+
assertNotCancelled(model);
|
|
753
|
+
const slug = model || DEFAULT_MISTRAL_MODEL;
|
|
754
|
+
const webSearch = await p.confirm({
|
|
755
|
+
message: "Enable web search? (recommended, but more expensive)",
|
|
756
|
+
initialValue: true
|
|
757
|
+
});
|
|
758
|
+
assertNotCancelled(webSearch);
|
|
759
|
+
targets.push(formatScrapeTarget({
|
|
760
|
+
model: "mistral",
|
|
761
|
+
provider: "mistral-api",
|
|
762
|
+
version: slug,
|
|
763
|
+
webSearch
|
|
764
|
+
}));
|
|
607
765
|
}
|
|
608
766
|
async function collectOpenRouter(env, targets) {
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
767
|
+
const enable = await p.confirm({
|
|
768
|
+
message: `Configure ${pc.bold("OpenRouter")}? (one key, many hosted models)`,
|
|
769
|
+
initialValue: false
|
|
770
|
+
});
|
|
771
|
+
assertNotCancelled(enable);
|
|
772
|
+
if (!enable) return;
|
|
773
|
+
const key = await p.password({
|
|
774
|
+
message: "OpenRouter API key",
|
|
775
|
+
validate: (v) => !v ? "Required" : void 0
|
|
776
|
+
});
|
|
777
|
+
assertNotCancelled(key);
|
|
778
|
+
env.OPENROUTER_API_KEY = key;
|
|
779
|
+
const model = await p.text({
|
|
780
|
+
message: "OpenRouter model slug",
|
|
781
|
+
placeholder: DEFAULT_OPENROUTER_MODEL,
|
|
782
|
+
defaultValue: DEFAULT_OPENROUTER_MODEL
|
|
783
|
+
});
|
|
784
|
+
assertNotCancelled(model);
|
|
785
|
+
const slug = model || DEFAULT_OPENROUTER_MODEL;
|
|
786
|
+
const webSearch = await p.confirm({
|
|
787
|
+
message: "Enable web search? (recommended, but more expensive)",
|
|
788
|
+
initialValue: true
|
|
789
|
+
});
|
|
790
|
+
assertNotCancelled(webSearch);
|
|
791
|
+
targets.push(formatScrapeTarget({
|
|
792
|
+
model: "claude",
|
|
793
|
+
provider: "openrouter",
|
|
794
|
+
version: slug,
|
|
795
|
+
webSearch
|
|
796
|
+
}));
|
|
635
797
|
}
|
|
636
798
|
async function collectDataForSEO(env, targets) {
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
799
|
+
const enable = await p.confirm({
|
|
800
|
+
message: `Configure ${pc.bold("DataForSEO")}? (Google AI Mode + LLM Responses)`,
|
|
801
|
+
initialValue: false
|
|
802
|
+
});
|
|
803
|
+
assertNotCancelled(enable);
|
|
804
|
+
if (!enable) return;
|
|
805
|
+
const login = await p.text({
|
|
806
|
+
message: "DataForSEO login",
|
|
807
|
+
validate: (v) => !v ? "Required" : void 0
|
|
808
|
+
});
|
|
809
|
+
assertNotCancelled(login);
|
|
810
|
+
env.DATAFORSEO_LOGIN = login;
|
|
811
|
+
const pwd = await p.password({
|
|
812
|
+
message: "DataForSEO password",
|
|
813
|
+
validate: (v) => !v ? "Required" : void 0
|
|
814
|
+
});
|
|
815
|
+
assertNotCancelled(pwd);
|
|
816
|
+
env.DATAFORSEO_PASSWORD = pwd;
|
|
817
|
+
const selected = await p.multiselect({
|
|
818
|
+
message: "LLM Providers to track via DataForSEO",
|
|
819
|
+
options: DATAFORSEO_MODELS.map((model) => ({
|
|
820
|
+
value: model,
|
|
821
|
+
label: model
|
|
822
|
+
})),
|
|
823
|
+
required: false,
|
|
824
|
+
initialValues: ["google-ai-mode"]
|
|
825
|
+
});
|
|
826
|
+
assertNotCancelled(selected);
|
|
827
|
+
for (const model of selected) targets.push(formatScrapeTarget({
|
|
828
|
+
model,
|
|
829
|
+
provider: "dataforseo",
|
|
830
|
+
webSearch: true
|
|
831
|
+
}));
|
|
664
832
|
}
|
|
665
833
|
async function finalizeScrapeTargets(env, targets, options = {}) {
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
834
|
+
const deduped = dedupeTargets(targets);
|
|
835
|
+
if (!deduped) {
|
|
836
|
+
p.log.warn("No SCRAPE_TARGETS configured. Elmo will not run scheduled checks until you set them.");
|
|
837
|
+
p.log.info(`Reference: ${link(pc.cyan(PROVIDERS_DOC_URL), PROVIDERS_DOC_URL)}`);
|
|
838
|
+
const addManual = await p.confirm({
|
|
839
|
+
message: "Enter SCRAPE_TARGETS manually now?",
|
|
840
|
+
initialValue: false
|
|
841
|
+
});
|
|
842
|
+
assertNotCancelled(addManual);
|
|
843
|
+
if (addManual) {
|
|
844
|
+
const manual = await p.text({
|
|
845
|
+
message: "SCRAPE_TARGETS (model:provider[:version][:online], comma-separated)",
|
|
846
|
+
placeholder: "chatgpt:brightdata:online,google-ai-mode:brightdata:online",
|
|
847
|
+
validate: validateScrapeTargetsInput
|
|
848
|
+
});
|
|
849
|
+
assertNotCancelled(manual);
|
|
850
|
+
env.SCRAPE_TARGETS = manual;
|
|
851
|
+
}
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
if (options.skipEdit) {
|
|
855
|
+
env.SCRAPE_TARGETS = deduped;
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
const customize = await p.confirm({
|
|
859
|
+
message: "Edit SCRAPE_TARGETS before saving?",
|
|
860
|
+
initialValue: false
|
|
861
|
+
});
|
|
862
|
+
assertNotCancelled(customize);
|
|
863
|
+
if (customize) {
|
|
864
|
+
p.log.info(`Reference: ${link(pc.cyan(PROVIDERS_DOC_URL), PROVIDERS_DOC_URL)}`);
|
|
865
|
+
const manual = await p.text({
|
|
866
|
+
message: "SCRAPE_TARGETS",
|
|
867
|
+
initialValue: deduped,
|
|
868
|
+
validate: validateScrapeTargetsInput
|
|
869
|
+
});
|
|
870
|
+
assertNotCancelled(manual);
|
|
871
|
+
env.SCRAPE_TARGETS = manual;
|
|
872
|
+
p.log.step(`SCRAPE_TARGETS:\n ${pc.cyan(manual)}`);
|
|
873
|
+
} else env.SCRAPE_TARGETS = deduped;
|
|
874
|
+
}
|
|
875
|
+
function validateScrapeTargetsInput(value) {
|
|
876
|
+
if (!value) return "Required";
|
|
877
|
+
try {
|
|
878
|
+
parseScrapeTargets(value);
|
|
879
|
+
} catch (error) {
|
|
880
|
+
return error instanceof Error ? error.message.split("\n")[0] : String(error);
|
|
881
|
+
}
|
|
709
882
|
}
|
|
710
883
|
function dedupeTargets(targets) {
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
}
|
|
721
|
-
// ── Start helper (used by init) ──────────────────────────────────────────────
|
|
884
|
+
const seen = /* @__PURE__ */ new Set();
|
|
885
|
+
const out = [];
|
|
886
|
+
for (const t of targets) {
|
|
887
|
+
if (seen.has(t)) continue;
|
|
888
|
+
seen.add(t);
|
|
889
|
+
out.push(t);
|
|
890
|
+
}
|
|
891
|
+
return out.join(",");
|
|
892
|
+
}
|
|
722
893
|
async function doStart(configDir) {
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
console.log(` ${pc.bold("elmo compose ps")}`);
|
|
740
|
-
console.log(` ${pc.bold("elmo compose down")}`);
|
|
741
|
-
}
|
|
742
|
-
// ── Command: compose ─────────────────────────────────────────────────────────
|
|
894
|
+
assertDockerRunning();
|
|
895
|
+
log.step("Starting Docker Compose stack...");
|
|
896
|
+
await runDockerCompose(configDir, ["up", "-d"]);
|
|
897
|
+
const s = p.spinner();
|
|
898
|
+
s.start("Waiting for services to become healthy...");
|
|
899
|
+
if (await waitForHealthy(configDir, 18e4)) s.stop("All services healthy!");
|
|
900
|
+
else {
|
|
901
|
+
s.stop("Health check timed out.");
|
|
902
|
+
p.log.warn("Some services did not report healthy status.");
|
|
903
|
+
}
|
|
904
|
+
log.info("Examples:");
|
|
905
|
+
console.log(` ${pc.bold("elmo compose logs -f")}`);
|
|
906
|
+
console.log(` ${pc.bold("elmo compose logs -f web")}`);
|
|
907
|
+
console.log(` ${pc.bold("elmo compose ps")}`);
|
|
908
|
+
console.log(` ${pc.bold("elmo compose down")}`);
|
|
909
|
+
}
|
|
743
910
|
async function runCompose(args, options) {
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
911
|
+
const configDir = await resolveConfigDir(options.dir);
|
|
912
|
+
assertDockerRunning();
|
|
913
|
+
await runDockerCompose(configDir, args);
|
|
747
914
|
}
|
|
748
|
-
// ── Command: edit ────────────────────────────────────────────────────────────
|
|
749
915
|
async function runEdit(target, options) {
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
916
|
+
const configDir = await resolveConfigDir(options.dir);
|
|
917
|
+
let filePath;
|
|
918
|
+
if (target === "env") filePath = path.join(configDir, ".env");
|
|
919
|
+
else if (target === "compose") filePath = path.join(configDir, "elmo.yaml");
|
|
920
|
+
else throw new Error(`Unknown edit target: ${target}. Use \`env\` or \`compose\`.`);
|
|
921
|
+
if (!await fileExists(filePath)) throw new Error(`File not found: ${filePath}`);
|
|
922
|
+
const parts = (process.env.VISUAL || process.env.EDITOR || "nano").split(/\s+/).filter(Boolean);
|
|
923
|
+
const cmd = parts[0] ?? "nano";
|
|
924
|
+
const args = [...parts.slice(1), filePath];
|
|
925
|
+
await new Promise((resolve, reject) => {
|
|
926
|
+
const child = spawn(cmd, args, { stdio: "inherit" });
|
|
927
|
+
child.on("close", (code) => {
|
|
928
|
+
if (code === 0) resolve();
|
|
929
|
+
else reject(/* @__PURE__ */ new Error(`${cmd} exited with code ${code}`));
|
|
930
|
+
});
|
|
931
|
+
child.on("error", (err) => reject(err));
|
|
932
|
+
});
|
|
933
|
+
log.info("Restart the stack with `elmo compose up -d` to apply changes.");
|
|
934
|
+
}
|
|
935
|
+
async function runUpgrade(options, cliVersion) {
|
|
936
|
+
printBanner();
|
|
937
|
+
p.intro(pc.bold("Upgrading Elmo"));
|
|
938
|
+
const latestCli = await fetchLatestCliVersion();
|
|
939
|
+
if (latestCli && semver.valid(cliVersion) && semver.lt(cliVersion, latestCli)) {
|
|
940
|
+
log.warn(`Your CLI (${cliVersion}) is behind the latest published version (${latestCli}).`);
|
|
941
|
+
log.info("Recommended: upgrade the CLI first, then rerun this command:");
|
|
942
|
+
console.log(` ${pc.bold("npm install -g @elmohq/cli@latest")}`);
|
|
943
|
+
const proceed = options.yes ? true : await p.confirm({
|
|
944
|
+
message: `Continue upgrading the stack with CLI ${cliVersion} anyway?`,
|
|
945
|
+
initialValue: false
|
|
946
|
+
});
|
|
947
|
+
assertNotCancelled(proceed);
|
|
948
|
+
if (!proceed) {
|
|
949
|
+
p.cancel("Upgrade cancelled. Upgrade the CLI and rerun `elmo upgrade`.");
|
|
950
|
+
process.exit(0);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
const configDir = await resolveConfigDir(options.dir);
|
|
954
|
+
const composePath = path.join(configDir, "elmo.yaml");
|
|
955
|
+
const detectedVersion = await readRenderedVersion(composePath);
|
|
956
|
+
const fromVersion = detectedVersion ?? cliVersion;
|
|
957
|
+
if (!semver.valid(fromVersion)) throw new Error(`Could not determine the installed version from ${composePath}.`);
|
|
958
|
+
if (semver.gt(fromVersion, cliVersion)) {
|
|
959
|
+
log.warn(`Your deployment (${fromVersion}) is newer than this CLI (${cliVersion}).`);
|
|
960
|
+
log.info("Upgrade the CLI to match, then rerun:");
|
|
961
|
+
console.log(` ${pc.bold("npm install -g @elmohq/cli@latest")}`);
|
|
962
|
+
process.exit(1);
|
|
963
|
+
}
|
|
964
|
+
if (detectedVersion !== null && semver.eq(detectedVersion, cliVersion)) {
|
|
965
|
+
log.success(`Already at ${cliVersion}.`);
|
|
966
|
+
const pull = options.yes ? true : await p.confirm({
|
|
967
|
+
message: "Pull images for this version anyway?",
|
|
968
|
+
initialValue: false
|
|
969
|
+
});
|
|
970
|
+
assertNotCancelled(pull);
|
|
971
|
+
if (pull) {
|
|
972
|
+
assertDockerRunning();
|
|
973
|
+
const wasRunning = await stackHasRunningServices(configDir);
|
|
974
|
+
log.step("Pulling images...");
|
|
975
|
+
await runDockerCompose(configDir, ["pull"]);
|
|
976
|
+
if (wasRunning) {
|
|
977
|
+
log.step("Restarting services...");
|
|
978
|
+
await runDockerCompose(configDir, ["up", "-d"]);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
p.outro(pc.green("Nothing to upgrade."));
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
const plan = detectedVersion === null ? [] : planMigrations(fromVersion, cliVersion, MIGRATIONS);
|
|
985
|
+
if (detectedVersion === null) log.warn(`Couldn't detect the deployment's version — re-pinning images to ${pc.cyan(cliVersion)}.`);
|
|
986
|
+
else log.info(`Upgrading from ${pc.cyan(fromVersion)} → ${pc.cyan(cliVersion)}`);
|
|
987
|
+
if (plan.length === 0) log.step("No migrations to run (docker images will be re-pinned and pulled).");
|
|
988
|
+
else {
|
|
989
|
+
log.step(`Migrations to apply: ${plan.length}`);
|
|
990
|
+
for (const m of plan) console.log(` • ${pc.bold(`${m.from} → ${m.to}`)} ${m.description}`);
|
|
991
|
+
}
|
|
992
|
+
const confirm = options.yes ? true : await p.confirm({
|
|
993
|
+
message: "Proceed with upgrade?",
|
|
994
|
+
initialValue: true
|
|
995
|
+
});
|
|
996
|
+
assertNotCancelled(confirm);
|
|
997
|
+
if (!confirm) {
|
|
998
|
+
p.cancel("Upgrade cancelled.");
|
|
999
|
+
process.exit(0);
|
|
1000
|
+
}
|
|
1001
|
+
assertDockerRunning();
|
|
1002
|
+
const wasRunning = await stackHasRunningServices(configDir);
|
|
1003
|
+
if (wasRunning) {
|
|
1004
|
+
log.step("Stopping services...");
|
|
1005
|
+
await runDockerCompose(configDir, ["down"]);
|
|
1006
|
+
}
|
|
1007
|
+
const ctx = buildMigrationContext(configDir, cliVersion);
|
|
1008
|
+
try {
|
|
1009
|
+
await runMigrations(plan, ctx);
|
|
1010
|
+
} catch (error) {
|
|
1011
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
1012
|
+
log.error(`Migration failed: ${msg}`);
|
|
1013
|
+
log.info("Your config version was left unchanged. Fix the issue and rerun `elmo upgrade`.");
|
|
1014
|
+
if (wasRunning) log.info("The stack was stopped for the upgrade. Restart with `elmo compose up -d` after fixing.");
|
|
1015
|
+
process.exit(1);
|
|
1016
|
+
}
|
|
1017
|
+
const isDev = await composeUsesBuild(composePath);
|
|
1018
|
+
await repinComposeImages(composePath, cliVersion);
|
|
1019
|
+
await refreshRenderedVersion(path.join(configDir, ".env"), cliVersion);
|
|
1020
|
+
log.success(`Pinned config to ${cliVersion}.`);
|
|
1021
|
+
if (isDev) log.info("Dev mode detected — rebuild with `elmo compose build` to apply the new version.");
|
|
1022
|
+
else {
|
|
1023
|
+
log.step("Pulling images...");
|
|
1024
|
+
await runDockerCompose(configDir, ["pull"]);
|
|
1025
|
+
}
|
|
1026
|
+
if (wasRunning) {
|
|
1027
|
+
log.step("Starting services...");
|
|
1028
|
+
await runDockerCompose(configDir, ["up", "-d"]);
|
|
1029
|
+
const s = p.spinner();
|
|
1030
|
+
s.start("Waiting for services to become healthy...");
|
|
1031
|
+
if (await waitForHealthy(configDir, 18e4)) s.stop("All services healthy!");
|
|
1032
|
+
else {
|
|
1033
|
+
s.stop("Health check timed out.");
|
|
1034
|
+
log.warn("Some services did not report healthy status.");
|
|
1035
|
+
}
|
|
1036
|
+
} else log.info("Stack was stopped before upgrade — leaving it stopped. Start with `elmo compose up -d`.");
|
|
1037
|
+
await trackCliEvent(configDir, "cli_upgrade", {
|
|
1038
|
+
from_version: fromVersion,
|
|
1039
|
+
to_version: cliVersion,
|
|
1040
|
+
migrations_run: plan.length,
|
|
1041
|
+
was_running: wasRunning,
|
|
1042
|
+
dev_mode: isDev
|
|
1043
|
+
});
|
|
1044
|
+
p.outro(pc.green(`Upgraded to ${cliVersion}.`));
|
|
1045
|
+
}
|
|
1046
|
+
async function stackHasRunningServices(configDir) {
|
|
1047
|
+
try {
|
|
1048
|
+
return (await getComposeServices(configDir)).some((s) => s.State?.startsWith("running") ?? false);
|
|
1049
|
+
} catch {
|
|
1050
|
+
return false;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
async function readRenderedVersion(filePath) {
|
|
1054
|
+
try {
|
|
1055
|
+
return parseRenderedVersion(await fs.readFile(filePath, "utf8"));
|
|
1056
|
+
} catch {
|
|
1057
|
+
return null;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
async function composeUsesBuild(composePath) {
|
|
1061
|
+
try {
|
|
1062
|
+
const contents = await fs.readFile(composePath, "utf8");
|
|
1063
|
+
return /^\s*build:/m.test(contents);
|
|
1064
|
+
} catch {
|
|
1065
|
+
return false;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
async function repinComposeImages(composePath, version) {
|
|
1069
|
+
const contents = await fs.readFile(composePath, "utf8");
|
|
1070
|
+
await fs.writeFile(composePath, refreshHeaderVersion(repinImages(contents, version), version), "utf8");
|
|
1071
|
+
}
|
|
1072
|
+
async function refreshRenderedVersion(filePath, version) {
|
|
1073
|
+
try {
|
|
1074
|
+
const contents = await fs.readFile(filePath, "utf8");
|
|
1075
|
+
await fs.writeFile(filePath, refreshHeaderVersion(contents, version), "utf8");
|
|
1076
|
+
} catch {}
|
|
1077
|
+
}
|
|
1078
|
+
function buildMigrationContext(configDir, version) {
|
|
1079
|
+
const envPath = path.join(configDir, ".env");
|
|
1080
|
+
return {
|
|
1081
|
+
configDir,
|
|
1082
|
+
log: {
|
|
1083
|
+
info: (msg) => log.info(msg),
|
|
1084
|
+
warn: (msg) => log.warn(msg),
|
|
1085
|
+
step: (msg) => log.step(msg)
|
|
1086
|
+
},
|
|
1087
|
+
readEnv: async () => {
|
|
1088
|
+
try {
|
|
1089
|
+
return parse(await fs.readFile(envPath, "utf8"));
|
|
1090
|
+
} catch {
|
|
1091
|
+
return {};
|
|
1092
|
+
}
|
|
1093
|
+
},
|
|
1094
|
+
writeEnv: async (env) => {
|
|
1095
|
+
await fs.writeFile(envPath, buildEnvFile(env, version), "utf8");
|
|
1096
|
+
}
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
781
1099
|
function buildComposeYaml(options) {
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
1100
|
+
const services = [];
|
|
1101
|
+
const volumes = /* @__PURE__ */ new Set();
|
|
1102
|
+
const dependsOnWeb = [];
|
|
1103
|
+
const dependsOnWorker = [];
|
|
1104
|
+
const dependencyConditions = {
|
|
1105
|
+
postgres: "service_healthy",
|
|
1106
|
+
"db-migrate": "service_completed_successfully"
|
|
1107
|
+
};
|
|
1108
|
+
const dockerfilePath = options.dockerDir ? path.relative(options.repoRoot, path.join(options.dockerDir, "Dockerfile")) : "docker/Dockerfile";
|
|
1109
|
+
if (options.postgresMode === "docker") {
|
|
1110
|
+
services.push(buildPostgresService());
|
|
1111
|
+
services.push(buildDbMigrateService({
|
|
1112
|
+
dev: options.dev,
|
|
1113
|
+
dockerfilePath,
|
|
1114
|
+
repoRoot: options.repoRoot,
|
|
1115
|
+
version: options.version
|
|
1116
|
+
}));
|
|
1117
|
+
dependsOnWeb.push("db-migrate");
|
|
1118
|
+
dependsOnWorker.push("db-migrate");
|
|
1119
|
+
volumes.add("postgres_data");
|
|
1120
|
+
}
|
|
1121
|
+
services.push(buildWebService({
|
|
1122
|
+
dev: options.dev,
|
|
1123
|
+
dependsOn: dependsOnWeb,
|
|
1124
|
+
dependencyConditions,
|
|
1125
|
+
repoRoot: options.repoRoot,
|
|
1126
|
+
dockerfilePath,
|
|
1127
|
+
port: options.port,
|
|
1128
|
+
version: options.version
|
|
1129
|
+
}));
|
|
1130
|
+
services.push(buildWorkerService({
|
|
1131
|
+
dev: options.dev,
|
|
1132
|
+
dependsOn: dependsOnWorker,
|
|
1133
|
+
dependencyConditions,
|
|
1134
|
+
repoRoot: options.repoRoot,
|
|
1135
|
+
dockerfilePath,
|
|
1136
|
+
version: options.version
|
|
1137
|
+
}));
|
|
1138
|
+
const lines = [
|
|
1139
|
+
renderedByHeader(options.version),
|
|
1140
|
+
"",
|
|
1141
|
+
"name: elmo",
|
|
1142
|
+
"",
|
|
1143
|
+
"services:"
|
|
1144
|
+
];
|
|
1145
|
+
lines.push(...services.map((service) => indentBlock(service, 2)));
|
|
1146
|
+
if (volumes.size > 0) {
|
|
1147
|
+
lines.push("", "volumes:");
|
|
1148
|
+
for (const volume of volumes) lines.push(` ${volume}:`);
|
|
1149
|
+
}
|
|
1150
|
+
return `${lines.join("\n")}\n`;
|
|
831
1151
|
}
|
|
832
1152
|
function buildPostgresService() {
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
1153
|
+
return [
|
|
1154
|
+
"postgres:",
|
|
1155
|
+
" image: postgres:16-alpine",
|
|
1156
|
+
" environment:",
|
|
1157
|
+
" POSTGRES_USER: postgres",
|
|
1158
|
+
" POSTGRES_PASSWORD: postgres",
|
|
1159
|
+
" POSTGRES_DB: elmo",
|
|
1160
|
+
" volumes:",
|
|
1161
|
+
" - postgres_data:/var/lib/postgresql/data",
|
|
1162
|
+
" ports:",
|
|
1163
|
+
" - \"5432:5432\"",
|
|
1164
|
+
" healthcheck:",
|
|
1165
|
+
" test: [\"CMD-SHELL\", \"pg_isready -U postgres\"]",
|
|
1166
|
+
" interval: 5s",
|
|
1167
|
+
" timeout: 5s",
|
|
1168
|
+
" retries: 5",
|
|
1169
|
+
" start_period: 30s"
|
|
1170
|
+
].join("\n");
|
|
851
1171
|
}
|
|
852
1172
|
function buildDbMigrateService(options) {
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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");
|
|
1173
|
+
const lines = ["db-migrate:"];
|
|
1174
|
+
if (options.dev) lines.push(" build:", ` context: ${options.repoRoot}`, ` dockerfile: ${options.dockerfilePath}`, " target: migrate");
|
|
1175
|
+
else lines.push(` image: elmohq/elmo-db-migrate:${options.version}`);
|
|
1176
|
+
lines.push(" environment:", " - DATABASE_URL=postgres://postgres:postgres@postgres:5432/elmo", " depends_on:", " postgres:", " condition: service_healthy");
|
|
1177
|
+
return lines.join("\n");
|
|
862
1178
|
}
|
|
863
1179
|
function buildWebService(options) {
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
lines.push(` ${service}:`, ` condition: ${condition}`);
|
|
877
|
-
}
|
|
878
|
-
}
|
|
879
|
-
return lines.join("\n");
|
|
1180
|
+
const lines = ["web:"];
|
|
1181
|
+
if (options.dev) lines.push(" build:", ` context: ${options.repoRoot}`, ` dockerfile: ${options.dockerfilePath}`, " target: web", " args:", " DEPLOYMENT_MODE: local");
|
|
1182
|
+
else lines.push(` image: elmohq/elmo-web:${options.version}`);
|
|
1183
|
+
lines.push(" env_file:", " - path: .env", " required: true", " ports:", ` - "${options.port}:3000"`);
|
|
1184
|
+
if (options.dependsOn.length > 0) {
|
|
1185
|
+
lines.push(" depends_on:");
|
|
1186
|
+
for (const service of options.dependsOn) {
|
|
1187
|
+
const condition = options.dependencyConditions[service] ?? "service_started";
|
|
1188
|
+
lines.push(` ${service}:`, ` condition: ${condition}`);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
return lines.join("\n");
|
|
880
1192
|
}
|
|
881
1193
|
function buildWorkerService(options) {
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
lines.push(` ${service}:`, ` condition: ${condition}`);
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
return lines.join("\n");
|
|
1194
|
+
const lines = ["worker:"];
|
|
1195
|
+
if (options.dev) lines.push(" build:", ` context: ${options.repoRoot}`, ` dockerfile: ${options.dockerfilePath}`, " target: worker", " args:", " DEPLOYMENT_MODE: local");
|
|
1196
|
+
else lines.push(` image: elmohq/elmo-worker:${options.version}`);
|
|
1197
|
+
lines.push(" env_file:", " - path: .env", " required: true");
|
|
1198
|
+
if (options.dependsOn.length > 0) {
|
|
1199
|
+
lines.push(" depends_on:");
|
|
1200
|
+
for (const service of options.dependsOn) {
|
|
1201
|
+
const condition = options.dependencyConditions[service] ?? "service_started";
|
|
1202
|
+
lines.push(` ${service}:`, ` condition: ${condition}`);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
return lines.join("\n");
|
|
898
1206
|
}
|
|
899
1207
|
function indentBlock(block, spaces) {
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
.split("\n")
|
|
903
|
-
.map((line) => `${indent}${line}`)
|
|
904
|
-
.join("\n");
|
|
1208
|
+
const indent = " ".repeat(spaces);
|
|
1209
|
+
return block.split("\n").map((line) => `${indent}${line}`).join("\n");
|
|
905
1210
|
}
|
|
906
|
-
// ── Docker Helpers ───────────────────────────────────────────────────────────
|
|
907
1211
|
async function getComposeServices(configDir) {
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
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
|
-
}
|
|
1212
|
+
const output = await runDockerComposeCapture(configDir, [
|
|
1213
|
+
"ps",
|
|
1214
|
+
"--format",
|
|
1215
|
+
"json"
|
|
1216
|
+
]);
|
|
1217
|
+
if (!output.trim()) return [];
|
|
1218
|
+
try {
|
|
1219
|
+
const trimmed = output.trim();
|
|
1220
|
+
const parsed = JSON.parse(trimmed);
|
|
1221
|
+
if (Array.isArray(parsed)) return parsed;
|
|
1222
|
+
if (typeof parsed === "object" && parsed !== null) return [parsed];
|
|
1223
|
+
return [];
|
|
1224
|
+
} catch {
|
|
1225
|
+
try {
|
|
1226
|
+
return output.trim().split("\n").filter((line) => line.trim()).map((line) => JSON.parse(line));
|
|
1227
|
+
} catch {
|
|
1228
|
+
log.warn("Unable to parse docker compose status.");
|
|
1229
|
+
return [];
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
936
1232
|
}
|
|
937
1233
|
function isServiceReady(service) {
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
if (service.State?.startsWith("running")) {
|
|
942
|
-
return true;
|
|
943
|
-
}
|
|
944
|
-
return false;
|
|
1234
|
+
if (service.Health) return service.Health === "healthy";
|
|
1235
|
+
if (service.State?.startsWith("running")) return true;
|
|
1236
|
+
return false;
|
|
945
1237
|
}
|
|
946
1238
|
async function waitForHealthy(configDir, timeoutMs) {
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
}
|
|
955
|
-
return false;
|
|
1239
|
+
const start = Date.now();
|
|
1240
|
+
while (Date.now() - start < timeoutMs) {
|
|
1241
|
+
const services = await getComposeServices(configDir);
|
|
1242
|
+
if (services.length > 0 && services.every(isServiceReady)) return true;
|
|
1243
|
+
await sleep(3e3);
|
|
1244
|
+
}
|
|
1245
|
+
return false;
|
|
956
1246
|
}
|
|
957
1247
|
function sleep(ms) {
|
|
958
|
-
|
|
1248
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
959
1249
|
}
|
|
960
1250
|
function runDockerCompose(configDir, args) {
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
reject(new Error(`docker compose exited with code ${code}`));
|
|
973
|
-
}
|
|
974
|
-
});
|
|
975
|
-
});
|
|
1251
|
+
return new Promise((resolve, reject) => {
|
|
1252
|
+
spawn("docker", [
|
|
1253
|
+
"compose",
|
|
1254
|
+
"-f",
|
|
1255
|
+
path.join(configDir, "elmo.yaml"),
|
|
1256
|
+
...args
|
|
1257
|
+
], { stdio: "inherit" }).on("close", (code) => {
|
|
1258
|
+
if (code === 0) resolve();
|
|
1259
|
+
else reject(/* @__PURE__ */ new Error(`docker compose exited with code ${code}`));
|
|
1260
|
+
});
|
|
1261
|
+
});
|
|
976
1262
|
}
|
|
977
1263
|
function runDockerComposeCapture(configDir, args) {
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
});
|
|
1264
|
+
return new Promise((resolve, reject) => {
|
|
1265
|
+
const child = spawn("docker", [
|
|
1266
|
+
"compose",
|
|
1267
|
+
"-f",
|
|
1268
|
+
path.join(configDir, "elmo.yaml"),
|
|
1269
|
+
...args
|
|
1270
|
+
]);
|
|
1271
|
+
let stdout = "";
|
|
1272
|
+
let stderr = "";
|
|
1273
|
+
child.stdout.on("data", (data) => {
|
|
1274
|
+
stdout += data.toString();
|
|
1275
|
+
});
|
|
1276
|
+
child.stderr.on("data", (data) => {
|
|
1277
|
+
stderr += data.toString();
|
|
1278
|
+
});
|
|
1279
|
+
child.on("close", (code) => {
|
|
1280
|
+
if (code === 0) resolve(stdout);
|
|
1281
|
+
else reject(new Error(stderr || `docker compose exited with code ${code}`));
|
|
1282
|
+
});
|
|
1283
|
+
});
|
|
999
1284
|
}
|
|
1000
1285
|
function assertDockerRunning() {
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
});
|
|
1004
|
-
if (result.status !== 0) {
|
|
1005
|
-
throw new Error("Docker does not appear to be running. Start Docker and try again.");
|
|
1006
|
-
}
|
|
1007
|
-
}
|
|
1008
|
-
// ── Docker Dir Resolution ────────────────────────────────────────────────────
|
|
1286
|
+
if (spawnSync("docker", ["info"], { stdio: "ignore" }).status !== 0) throw new Error("Docker does not appear to be running. Start Docker and try again.");
|
|
1287
|
+
}
|
|
1009
1288
|
async function resolveDockerDirInteractive(cwd) {
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
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.");
|
|
1041
|
-
}
|
|
1042
|
-
// ── Config Dir Resolution ────────────────────────────────────────────────────
|
|
1289
|
+
const inCwd = await fileExists(path.join(cwd, "Dockerfile"));
|
|
1290
|
+
const inDockerDir = await fileExists(path.join(cwd, "docker", "Dockerfile"));
|
|
1291
|
+
const defaultDir = inCwd ? "." : inDockerDir ? "docker" : ".";
|
|
1292
|
+
const dir = await p.text({
|
|
1293
|
+
message: "Path to docker directory (contains Dockerfile)",
|
|
1294
|
+
defaultValue: defaultDir
|
|
1295
|
+
});
|
|
1296
|
+
assertNotCancelled(dir);
|
|
1297
|
+
const resolved = path.resolve(cwd, dir);
|
|
1298
|
+
if (!await fileExists(path.join(resolved, "Dockerfile"))) {
|
|
1299
|
+
p.log.error(`Dockerfile not found in ${resolved}. Provide the directory that contains Dockerfile.`);
|
|
1300
|
+
process.exit(1);
|
|
1301
|
+
}
|
|
1302
|
+
return resolved;
|
|
1303
|
+
}
|
|
1043
1304
|
async function resolveConfigDir(explicitDir) {
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
throw new Error(`No config found at ${resolved}. Run \`elmo init\` to create one, or specify --dir.`);
|
|
1051
|
-
}
|
|
1052
|
-
return resolved;
|
|
1053
|
-
}
|
|
1054
|
-
// ── File & Config Helpers ────────────────────────────────────────────────────
|
|
1055
|
-
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");
|
|
1305
|
+
const resolved = explicitDir ? path.resolve(process.cwd(), explicitDir) : CONFIG_HOME;
|
|
1306
|
+
if (!await fileExists(path.join(resolved, "elmo.yaml"))) {
|
|
1307
|
+
if (explicitDir) throw new Error(`Config directory does not contain elmo.yaml: ${resolved}\nRun \`elmo init --dir ${explicitDir}\` to create it.`);
|
|
1308
|
+
throw new Error(`No config found at ${resolved}. Run \`elmo init\` to create one, or specify --dir.`);
|
|
1309
|
+
}
|
|
1310
|
+
return resolved;
|
|
1061
1311
|
}
|
|
1062
|
-
function
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1312
|
+
async function writeConfigFiles(configDir, initConfig) {
|
|
1313
|
+
const envPath = path.join(configDir, ".env");
|
|
1314
|
+
const composePath = path.join(configDir, "elmo.yaml");
|
|
1315
|
+
await ensureDir(configDir);
|
|
1316
|
+
await fs.writeFile(envPath, buildEnvFile(initConfig.env, initConfig.version), "utf8");
|
|
1317
|
+
await fs.writeFile(composePath, initConfig.composeYaml, "utf8");
|
|
1067
1318
|
}
|
|
1068
1319
|
function buildEnvFile(env, version) {
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1320
|
+
const lines = [
|
|
1321
|
+
renderedByHeader(version),
|
|
1322
|
+
"# WARNING: contains secrets. Do not commit.",
|
|
1323
|
+
""
|
|
1324
|
+
];
|
|
1325
|
+
for (const [key, rawValue] of Object.entries(env)) {
|
|
1326
|
+
if (rawValue === void 0) continue;
|
|
1327
|
+
lines.push(`${key}=${formatEnvValue(rawValue)}`);
|
|
1328
|
+
}
|
|
1329
|
+
return `${lines.join("\n")}\n`;
|
|
1077
1330
|
}
|
|
1078
1331
|
function formatEnvValue(value) {
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
if (/[\s#"']/u.test(value)) {
|
|
1083
|
-
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
1084
|
-
return `"${escaped}"`;
|
|
1085
|
-
}
|
|
1086
|
-
return value;
|
|
1332
|
+
if (value === "") return "\"\"";
|
|
1333
|
+
if (/[\s#"']/u.test(value)) return `"${value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
|
|
1334
|
+
return value;
|
|
1087
1335
|
}
|
|
1088
1336
|
async function fileExists(target) {
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
}
|
|
1337
|
+
try {
|
|
1338
|
+
await fs.access(target);
|
|
1339
|
+
return true;
|
|
1340
|
+
} catch {
|
|
1341
|
+
return false;
|
|
1342
|
+
}
|
|
1096
1343
|
}
|
|
1097
1344
|
async function ensureDir(dir) {
|
|
1098
|
-
|
|
1345
|
+
await fs.mkdir(dir, { recursive: true });
|
|
1099
1346
|
}
|
|
1100
|
-
// ── Version Helpers ──────────────────────────────────────────────────────────
|
|
1101
1347
|
async function getPackageVersion() {
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1348
|
+
const selfDir = path.dirname(fileURLToPath(import.meta.url));
|
|
1349
|
+
const packagePath = path.resolve(selfDir, "..", "package.json");
|
|
1350
|
+
const contents = await fs.readFile(packagePath, "utf8");
|
|
1351
|
+
return JSON.parse(contents).version;
|
|
1352
|
+
}
|
|
1353
|
+
async function fetchLatestCliVersion() {
|
|
1354
|
+
try {
|
|
1355
|
+
const response = await fetch("https://registry.npmjs.org/@elmohq/cli/latest");
|
|
1356
|
+
if (!response.ok) return null;
|
|
1357
|
+
return (await response.json()).version ?? null;
|
|
1358
|
+
} catch {
|
|
1359
|
+
return null;
|
|
1360
|
+
}
|
|
1107
1361
|
}
|
|
1108
1362
|
async function maybeNotifyNewVersion(currentVersion) {
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
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
|
-
}
|
|
1125
|
-
}
|
|
1126
|
-
// ── Entry Point ──────────────────────────────────────────────────────────────
|
|
1363
|
+
const latest = await fetchLatestCliVersion();
|
|
1364
|
+
if (!latest) return;
|
|
1365
|
+
if (semver.valid(currentVersion) && semver.lt(currentVersion, latest)) log.warn(`New CLI version available (${latest}). Run: npm install -g @elmohq/cli@latest`);
|
|
1366
|
+
}
|
|
1127
1367
|
main().catch((error) => {
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1368
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
1369
|
+
console.error(`\n${pc.red("Error:")} ${msg}`);
|
|
1370
|
+
process.exit(1);
|
|
1131
1371
|
});
|
|
1372
|
+
//#endregion
|
|
1373
|
+
export {};
|