cartethyia 2.3.4 → 2.4.1
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/dashboard/assets/{BannedPage-Dc8V-nxc.js → BannedPage-hGt6sYfC.js} +1 -1
- package/dist/dashboard/assets/{CliToolDetailPage-ifAh7wbK.js → CliToolDetailPage-C-v5GoF-.js} +1 -1
- package/dist/dashboard/assets/{CliToolsPage-2A1c_dFb.js → CliToolsPage-Da2XCM9A.js} +1 -1
- package/dist/dashboard/assets/{ConsoleLogPage-FfxY8_yn.js → ConsoleLogPage-xT-LP8xZ.js} +1 -1
- package/dist/dashboard/assets/{CustomizationPage-CZ5H40Ha.js → CustomizationPage-D0PQsW3f.js} +1 -1
- package/dist/dashboard/assets/{HealthEventsModal-CZKQak_s.js → HealthEventsModal-DTiQ4ulO.js} +1 -1
- package/dist/dashboard/assets/{LoginPage-sqFYY4uv.js → LoginPage-jMx71m_m.js} +1 -1
- package/dist/dashboard/assets/{OverviewPage-BOZJaauG.js → OverviewPage-BQpanEmj.js} +1 -1
- package/dist/dashboard/assets/{ProviderDetailPage-CNUCsNFg.js → ProviderDetailPage-DMh9bfu0.js} +1 -1
- package/dist/dashboard/assets/{ProvidersPage-CsXoemJ1.js → ProvidersPage-Bfg0qSLE.js} +1 -1
- package/dist/dashboard/assets/{ProxyPage-DvRt3aZP.js → ProxyPage-CSSN-6vg.js} +1 -1
- package/dist/dashboard/assets/{QuotaPage-DNYJWw7s.js → QuotaPage-BPksahOs.js} +1 -1
- package/dist/dashboard/assets/{SetupPage-Cz1DWkcr.js → SetupPage-DxKaH8LS.js} +1 -1
- package/dist/dashboard/assets/{StudioPage-CsKD9sy-.js → StudioPage-B9WEa99R.js} +1 -1
- package/dist/dashboard/assets/{UsagePage-_Zu6460J.js → UsagePage-BOSJhVq0.js} +1 -1
- package/dist/dashboard/assets/{entry-COuKgXuV.js → entry-C1Dn2nQu.js} +3 -3
- package/dist/dashboard/assets/{index-CF94-Gob.js → index-TZNbg9dv.js} +2 -2
- package/dist/dashboard/index.html +1 -1
- package/package.json +2 -1
- package/scripts/cli/cli-launcher.cjs +12 -1
- package/scripts/cli/cli.ts +948 -657
- package/scripts/cli/menu.ts +107 -53
- package/scripts/cli/runtime-state.ts +48 -32
- package/scripts/cli/setup.ts +448 -443
- package/scripts/cli/wordmark.ts +58 -0
- package/src/boot/app.ts +39 -0
- package/src/console/auth/account-reset.ts +67 -0
- package/src/console/backup/export-endpoint.ts +111 -0
package/scripts/cli/setup.ts
CHANGED
|
@@ -1,443 +1,448 @@
|
|
|
1
|
-
import { createInterface } from "node:readline/promises";
|
|
2
|
-
import { stdin as input, stdout as output } from "node:process";
|
|
3
|
-
import { existsSync, mkdirSync } from "node:fs";
|
|
4
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
5
|
-
import { dirname, resolve } from "node:path";
|
|
6
|
-
import { createServer } from "node:net";
|
|
7
|
-
import {
|
|
8
|
-
MINIMUM_BUN_VERSION,
|
|
9
|
-
bunVersionAtLeast,
|
|
10
|
-
detectLocalPostgres,
|
|
11
|
-
generateCartethyiaEncryptionKey,
|
|
12
|
-
getLocalPlatform,
|
|
13
|
-
getOsHints,
|
|
14
|
-
hasPlaceholderSecrets,
|
|
15
|
-
mandatoryEnvBody,
|
|
16
|
-
parseServiceUrl,
|
|
17
|
-
probeTcpService,
|
|
18
|
-
readEnvFile,
|
|
19
|
-
ENV_PATH,
|
|
20
|
-
PROJECT_ROOT,
|
|
21
|
-
} from "../shared/env";
|
|
22
|
-
import type { ProbeResult } from "../shared/env";
|
|
23
|
-
import { resolveDataDir, resolveDbMode } from "../../src/persistence/db-mode";
|
|
24
|
-
|
|
25
|
-
async function ask(question: string, defaultValue = ""): Promise<string> {
|
|
26
|
-
const rl = createInterface({ input, output });
|
|
27
|
-
try {
|
|
28
|
-
const suffix = defaultValue ? ` [${defaultValue}]` : "";
|
|
29
|
-
const answer = await rl.question(`${question}${suffix}: `);
|
|
30
|
-
return answer.trim() || defaultValue;
|
|
31
|
-
} finally {
|
|
32
|
-
await rl.close();
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async function updateEnv(values: Readonly<Record<string, string>>): Promise<void> {
|
|
37
|
-
const envPath = ENV_PATH;
|
|
38
|
-
const lines = (await readFile(envPath, "utf8")).split(/\r?\n/);
|
|
39
|
-
const replaced = new Set<string>();
|
|
40
|
-
const output = lines.map((line) => {
|
|
41
|
-
const match = /^(\s*)([A-Z][A-Z0-9_]*)\s*=/.exec(line);
|
|
42
|
-
if (!match) return line;
|
|
43
|
-
const key = match[2];
|
|
44
|
-
if (!key || values[key] === undefined) return line;
|
|
45
|
-
replaced.add(key);
|
|
46
|
-
return `${match[1]}${key}=${values[key]}`;
|
|
47
|
-
});
|
|
48
|
-
for (const [key, value] of Object.entries(values)) {
|
|
49
|
-
if (!replaced.has(key)) output.push(`${key}=${value}`);
|
|
50
|
-
}
|
|
51
|
-
await writeFile(envPath, `${output.join("\n").replace(/\n+$/, "")}\n`, "utf8");
|
|
52
|
-
}
|
|
53
|
-
type SetupMode = "auto" | "native" | "docker";
|
|
54
|
-
|
|
55
|
-
export function resolveSetupMode(): SetupMode {
|
|
56
|
-
const raw = process.env.CARTETHYIA_SETUP_MODE ?? "auto";
|
|
57
|
-
if (raw === "auto" || raw === "native" || raw === "docker") return raw;
|
|
58
|
-
throw new Error(
|
|
59
|
-
`CARTETHYIA_SETUP_MODE must be auto, native, or docker (got "${raw}")`,
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
interface SetupConfig {
|
|
64
|
-
envPath: string;
|
|
65
|
-
dbUrl: string;
|
|
66
|
-
redisUrl: string;
|
|
67
|
-
probeTimeoutMs: number;
|
|
68
|
-
dockerProbeTimeoutMs: number;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function loadConfig(): SetupConfig {
|
|
72
|
-
return {
|
|
73
|
-
envPath: ENV_PATH,
|
|
74
|
-
dbUrl: process.env.DATABASE_URL ?? "",
|
|
75
|
-
redisUrl: process.env.REDIS_URL ?? "",
|
|
76
|
-
probeTimeoutMs: 3000,
|
|
77
|
-
dockerProbeTimeoutMs: 2000,
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
async function setupEnvFile(config: SetupConfig): Promise<boolean> {
|
|
82
|
-
const examplePath = resolve(PROJECT_ROOT, ".env.example");
|
|
83
|
-
|
|
84
|
-
if (existsSync(config.envPath)) {
|
|
85
|
-
const fileEnv: Record<string, string> = await readEnvFile(config.envPath).catch(() => ({}) as Record<string, string>);
|
|
86
|
-
const key = fileEnv.CARTETHYIA_ENCRYPTION_KEY?.trim();
|
|
87
|
-
if (!key || hasPlaceholderSecrets(key)) {
|
|
88
|
-
const generated = generateCartethyiaEncryptionKey();
|
|
89
|
-
const raw = await readFile(config.envPath, "utf8");
|
|
90
|
-
const lines = raw.split(/\r?\n/);
|
|
91
|
-
let patched = false;
|
|
92
|
-
const out = lines.map((line) => {
|
|
93
|
-
const m = /^(\s*)(CARTETHYIA_ENCRYPTION_KEY)\s*=/.exec(line);
|
|
94
|
-
if (!m) return line;
|
|
95
|
-
patched = true;
|
|
96
|
-
return `${m[1]}${m[2]}=${generated}`;
|
|
97
|
-
});
|
|
98
|
-
if (!patched) out.push(`CARTETHYIA_ENCRYPTION_KEY=${generated}`);
|
|
99
|
-
await writeFile(config.envPath, `${out.join("\n")}\n`, "utf8");
|
|
100
|
-
console.warn("🔑 Generated CARTETHYIA_ENCRYPTION_KEY and patched .env (key was placeholder/empty)");
|
|
101
|
-
}
|
|
102
|
-
return false;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
if (!existsSync(examplePath)) {
|
|
106
|
-
throw new Error(`${examplePath} not found`);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const generated = generateCartethyiaEncryptionKey();
|
|
110
|
-
const body = await mandatoryEnvBody(examplePath, {
|
|
111
|
-
CARTETHYIA_ENCRYPTION_KEY: generated,
|
|
112
|
-
});
|
|
113
|
-
await writeFile(config.envPath, body, { encoding: "utf8", mode: 0o600 });
|
|
114
|
-
console.warn("✨ Created .env from .env.example (mandatory entries only)");
|
|
115
|
-
console.warn("🔑 Auto-generated CARTETHYIA_ENCRYPTION_KEY in .env — keep it safe");
|
|
116
|
-
|
|
117
|
-
return true;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
async function probeDocker(timeoutMs: number): Promise<ProbeResult> {
|
|
121
|
-
const proc = Bun.spawn(["docker", "compose", "version"], { stdout: "ignore", stderr: "ignore" });
|
|
122
|
-
const exited = await Promise.race([
|
|
123
|
-
proc.exited,
|
|
124
|
-
new Promise<number>((_, rej) =>
|
|
125
|
-
setTimeout(() => {
|
|
126
|
-
try {
|
|
127
|
-
proc.kill();
|
|
128
|
-
} catch {
|
|
129
|
-
// already exited by the time we try to kill it
|
|
130
|
-
}
|
|
131
|
-
rej(new Error("probe timeout"));
|
|
132
|
-
}, timeoutMs),
|
|
133
|
-
),
|
|
134
|
-
] as const);
|
|
135
|
-
if (typeof exited === "number" && exited === 0) return { success: true };
|
|
136
|
-
return { success: false, error: "unavailable" };
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** True when the operator asked the wizard not to prompt. */
|
|
140
|
-
function nonInteractive(): boolean {
|
|
141
|
-
return process.argv.includes("--non-interactive") || !process.stdin.isTTY;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function setupCommand(): string {
|
|
145
|
-
return process.env.CARTETHYIA_CONFIG_DIR ? "cartethyia setup" : "bun setup";
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Refuses to continue on a Bun older than the repository declares. Printed as
|
|
150
|
-
* one sentence: a stack trace here tells a first-time installer nothing.
|
|
151
|
-
*/
|
|
152
|
-
function assertBunVersion(): void {
|
|
153
|
-
const actual = Bun.version;
|
|
154
|
-
if (bunVersionAtLeast(actual, MINIMUM_BUN_VERSION)) return;
|
|
155
|
-
throw new Error(
|
|
156
|
-
`Bun ${MINIMUM_BUN_VERSION} or newer is required (this is ${actual}). Install it from https://bun.sh and run this again.`,
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Asks for the database mode on a fresh install even when `.env` already
|
|
162
|
-
* carries the example default. An existing choice (the variable was set before
|
|
163
|
-
* this run, or the file predates the example default) is kept. Non-interactive
|
|
164
|
-
* runs never prompt and keep whatever the file says.
|
|
165
|
-
*/
|
|
166
|
-
async function chooseDbMode(envExisted: boolean): Promise<void> {
|
|
167
|
-
const alreadyChosen = envExisted && Boolean(process.env.CARTETHYIA_DB_MODE?.trim());
|
|
168
|
-
if (alreadyChosen || nonInteractive()) return;
|
|
169
|
-
console.log("Lite uses embedded SQLite and needs no database server; it suits a single instance.");
|
|
170
|
-
console.log("Full uses PostgreSQL for server-backed deployments; you must provide a reachable PostgreSQL server. Redis is optional unless you need cross-process coordination.");
|
|
171
|
-
const answer = (await ask(
|
|
172
|
-
"Database mode? lite / full",
|
|
173
|
-
"lite",
|
|
174
|
-
)).toLowerCase();
|
|
175
|
-
if (answer !== "lite" && answer !== "full") {
|
|
176
|
-
throw new Error(`Database mode must be lite or full (got "${answer}")`);
|
|
177
|
-
}
|
|
178
|
-
await updateEnv({ CARTETHYIA_DB_MODE: answer });
|
|
179
|
-
process.env.CARTETHYIA_DB_MODE = answer;
|
|
180
|
-
console.log(`✓ Database mode: ${answer}\n`);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/** Port the example ships with. A VPS NAT often does not forward it. */
|
|
184
|
-
const DEFAULT_PORT = "12800";
|
|
185
|
-
|
|
186
|
-
/** Ports this installer suggests when 12800 is taken. */
|
|
187
|
-
const PORT_SUGGESTIONS: readonly string[] = ["8080", "3000", "8081"];
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* True when nothing is listening on the port yet.
|
|
191
|
-
*
|
|
192
|
-
* Binding is the test, not connecting: only a real bind proves the port is
|
|
193
|
-
* free, and the socket is closed immediately so the probe never holds it.
|
|
194
|
-
*/
|
|
195
|
-
function isPortFree(host: string, port: number): Promise<boolean> {
|
|
196
|
-
return new Promise((resolve) => {
|
|
197
|
-
const server = createServer();
|
|
198
|
-
server.once("error", () => resolve(false));
|
|
199
|
-
server.listen(port, host, () => {
|
|
200
|
-
server.close(() => resolve(true));
|
|
201
|
-
});
|
|
202
|
-
});
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Asks which port the gateway should listen on. Enter keeps 12800. The
|
|
207
|
-
* candidates are probed before the prompt so the operator is told what is
|
|
208
|
-
* actually free instead of guessing. The same PORT serves `bun run dev` and the
|
|
209
|
-
* production build; the dashboard dev server stays on 5173 and proxies to it.
|
|
210
|
-
*/
|
|
211
|
-
async function choosePort(envExisted: boolean): Promise<void> {
|
|
212
|
-
const alreadyChosen = envExisted && Boolean(process.env.PORT?.trim());
|
|
213
|
-
if (alreadyChosen || nonInteractive()) return;
|
|
214
|
-
|
|
215
|
-
const candidates = [DEFAULT_PORT, ...PORT_SUGGESTIONS];
|
|
216
|
-
const states = await Promise.all(
|
|
217
|
-
candidates.map(async (port) => ({
|
|
218
|
-
port,
|
|
219
|
-
free: await isPortFree("127.0.0.1", Number(port)),
|
|
220
|
-
})),
|
|
221
|
-
);
|
|
222
|
-
const free = states.filter((s) => s.free).map((s) => s.port);
|
|
223
|
-
const taken = states.filter((s) => !s.free).map((s) => s.port);
|
|
224
|
-
const defaultFree = states[0]?.free === true;
|
|
225
|
-
|
|
226
|
-
console.log("Which port should the gateway listen on?");
|
|
227
|
-
console.log(`Cartethyia defaults to ${DEFAULT_PORT} — press Enter to keep it.`);
|
|
228
|
-
if (defaultFree) {
|
|
229
|
-
console.log(`✓ ${DEFAULT_PORT} is free, so it will not clash with anything else on this machine.`);
|
|
230
|
-
} else {
|
|
231
|
-
console.log(`✗ ${DEFAULT_PORT} is already in use, so keeping it would fail to start.`);
|
|
232
|
-
}
|
|
233
|
-
if (free.length > 0) console.log(`Available: ${free.join(", ")}`);
|
|
234
|
-
if (taken.length > 0) console.log(`In use: ${taken.join(", ")}`);
|
|
235
|
-
console.log("Pick 8080 (or any open port) when a VPS does not forward 12800.");
|
|
236
|
-
|
|
237
|
-
const answer = await ask("Port", DEFAULT_PORT);
|
|
238
|
-
if (!/^[1-9][0-9]{0,4}$/.test(answer) || Number(answer) > 65535) {
|
|
239
|
-
throw new Error(`Port must be a number from 1 to 65535 (got "${answer}")`);
|
|
240
|
-
}
|
|
241
|
-
if (!defaultFree && answer === DEFAULT_PORT) {
|
|
242
|
-
console.log(`⚠ ${DEFAULT_PORT} is in use — starting will fail unless whatever holds it is stopped first.`);
|
|
243
|
-
}
|
|
244
|
-
const values: Record<string, string> = { PORT: answer };
|
|
245
|
-
const origin = process.env.CARTETHYIA_PUBLIC_ORIGIN?.trim() ?? "";
|
|
246
|
-
if (origin === `http://localhost:${DEFAULT_PORT}`) {
|
|
247
|
-
values.CARTETHYIA_PUBLIC_ORIGIN = `http://localhost:${answer}`;
|
|
248
|
-
}
|
|
249
|
-
await updateEnv(values);
|
|
250
|
-
process.env.PORT = answer;
|
|
251
|
-
if (values.CARTETHYIA_PUBLIC_ORIGIN) {
|
|
252
|
-
process.env.CARTETHYIA_PUBLIC_ORIGIN = values.CARTETHYIA_PUBLIC_ORIGIN;
|
|
253
|
-
}
|
|
254
|
-
console.log(`✓ Port: ${answer}\n`);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* Makes sure Full mode has a reachable DATABASE_URL. An existing reachable URL
|
|
259
|
-
* is kept. Otherwise a local server on 5432/5433 is offered, and a typed URL is
|
|
260
|
-
* accepted only after it answers. Never installs PostgreSQL.
|
|
261
|
-
*/
|
|
262
|
-
async function ensureFullDatabase(timeoutMs: number): Promise<void> {
|
|
263
|
-
const existing = process.env.DATABASE_URL?.trim() ?? "";
|
|
264
|
-
const parsed = existing ? parseServiceUrl(existing) : null;
|
|
265
|
-
if (parsed) {
|
|
266
|
-
const probe = await probeTcpService(parsed.host, parsed.port, timeoutMs);
|
|
267
|
-
if (probe.success) {
|
|
268
|
-
console.log(`✓ PostgreSQL is reachable at ${parsed.host}:${parsed.port}\n`);
|
|
269
|
-
return;
|
|
270
|
-
}
|
|
271
|
-
console.log(`✗ PostgreSQL at ${parsed.host}:${parsed.port} did not answer.`);
|
|
272
|
-
}
|
|
273
|
-
const detected = await detectLocalPostgres(timeoutMs);
|
|
274
|
-
if (nonInteractive()) {
|
|
275
|
-
if (!parsed) {
|
|
276
|
-
throw new Error(
|
|
277
|
-
`Full mode needs DATABASE_URL. Set it in ${ENV_PATH} and run ${setupCommand()} again, or run without --non-interactive.`,
|
|
278
|
-
);
|
|
279
|
-
}
|
|
280
|
-
for (const hint of getOsHints("postgres")) console.log(` ${hint}`);
|
|
281
|
-
throw new Error("Configured PostgreSQL is not reachable");
|
|
282
|
-
}
|
|
283
|
-
if (detected) {
|
|
284
|
-
console.log(`Found a PostgreSQL server at ${detected.host}:${detected.port}.`);
|
|
285
|
-
} else {
|
|
286
|
-
console.log("No local PostgreSQL answered on port 5432 or 5433.");
|
|
287
|
-
for (const hint of getOsHints("postgres")) console.log(` ${hint}`);
|
|
288
|
-
}
|
|
289
|
-
const suggestion = detected
|
|
290
|
-
? `postgres://postgres@${detected.host}:${detected.port}/cartethyia`
|
|
291
|
-
: "";
|
|
292
|
-
const typed = await ask("DATABASE_URL", suggestion);
|
|
293
|
-
const chosen = parseServiceUrl(typed);
|
|
294
|
-
if (!chosen) {
|
|
295
|
-
throw new Error("DATABASE_URL must include a host and a port, for example postgres://postgres@127.0.0.1:5432/cartethyia");
|
|
296
|
-
}
|
|
297
|
-
const confirmed = await probeTcpService(chosen.host, chosen.port, timeoutMs);
|
|
298
|
-
if (!confirmed.success) {
|
|
299
|
-
for (const hint of getOsHints("postgres")) console.log(` ${hint}`);
|
|
300
|
-
throw new Error(`PostgreSQL at ${chosen.host}:${chosen.port} did not answer. Start it, then run bun setup again.`);
|
|
301
|
-
}
|
|
302
|
-
await updateEnv({ DATABASE_URL: typed });
|
|
303
|
-
process.env.DATABASE_URL = typed;
|
|
304
|
-
console.log(`✓ PostgreSQL is reachable at ${chosen.host}:${chosen.port}\n`);
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
export async function setup(): Promise<void> {
|
|
308
|
-
console.log("🚀 Setting up Cartethyia...\n");
|
|
309
|
-
assertBunVersion();
|
|
310
|
-
|
|
311
|
-
// Step 1: Setup the user config file for npm installs, repo .env for dev.
|
|
312
|
-
const envPath = ENV_PATH;
|
|
313
|
-
mkdirSync(dirname(envPath), { recursive: true });
|
|
314
|
-
const envCreated = await setupEnvFile({
|
|
315
|
-
envPath,
|
|
316
|
-
dbUrl: "",
|
|
317
|
-
redisUrl: "",
|
|
318
|
-
probeTimeoutMs: 3000,
|
|
319
|
-
dockerProbeTimeoutMs: 2000,
|
|
320
|
-
});
|
|
321
|
-
if (envCreated) {
|
|
322
|
-
console.log("✓ .env created\n");
|
|
323
|
-
} else {
|
|
324
|
-
console.log("✓ .env already exists\n");
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// Step 2: Load the file, then choose the mode. A fresh file carries the
|
|
328
|
-
// example default, which is not a choice the operator made, so it is asked.
|
|
329
|
-
const fileEnv = await readEnvFile(envPath);
|
|
330
|
-
for (const [key, value] of Object.entries(fileEnv)) {
|
|
331
|
-
if (!process.env[key]) process.env[key] = value;
|
|
332
|
-
}
|
|
333
|
-
await chooseDbMode(!envCreated);
|
|
334
|
-
await choosePort(!envCreated);
|
|
335
|
-
const config = loadConfig();
|
|
336
|
-
|
|
337
|
-
// Step 3: Resolve the cross-platform setup mode. Native/auto is the
|
|
338
|
-
// default; Docker is opt-in because PostgreSQL is external to this stack.
|
|
339
|
-
const setupMode = resolveSetupMode();
|
|
340
|
-
const platform = getLocalPlatform();
|
|
341
|
-
console.log(`🧭 Platform: ${platform}; setup mode: ${setupMode}`);
|
|
342
|
-
|
|
343
|
-
if (setupMode === "docker") {
|
|
344
|
-
console.log("🐳 Checking Docker Compose...");
|
|
345
|
-
const dockerProbe = await probeDocker(config.dockerProbeTimeoutMs);
|
|
346
|
-
if (!dockerProbe.success) {
|
|
347
|
-
throw new Error(
|
|
348
|
-
"Docker setup requested but Docker Compose is unavailable; use native services or install Docker",
|
|
349
|
-
);
|
|
350
|
-
}
|
|
351
|
-
console.log("✓ Docker Compose is available");
|
|
352
|
-
// Redis is optional: only start the compose service when a URL is set.
|
|
353
|
-
if (config.redisUrl.trim() !== "") {
|
|
354
|
-
console.log("📦 Starting Docker Compose Redis service...");
|
|
355
|
-
const proc = Bun.spawn(["docker", "compose", "up", "-d", "redis"], {
|
|
356
|
-
cwd: PROJECT_ROOT,
|
|
357
|
-
stdio: ["inherit", "inherit", "inherit"],
|
|
358
|
-
timeout: 30_000,
|
|
359
|
-
});
|
|
360
|
-
const exitCode = await proc.exited;
|
|
361
|
-
if (exitCode !== 0) throw new Error(`Docker Compose failed with exit code ${exitCode}`);
|
|
362
|
-
console.log("✓ Docker Compose Redis started; PostgreSQL remains external\n");
|
|
363
|
-
} else {
|
|
364
|
-
console.log("ℹ️ REDIS_URL unset — skipping Compose Redis (in-memory backend)\n");
|
|
365
|
-
}
|
|
366
|
-
} else {
|
|
367
|
-
console.log("🌱 Native-first mode; using configured local or external services\n");
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// Step 4: Probe Postgres in full mode; lite verifies the data dir instead.
|
|
371
|
-
const dbMode = resolveDbMode();
|
|
372
|
-
if (process.env.CARTETHYIA_CONFIG_DIR && nonInteractive() && dbMode === "full") {
|
|
373
|
-
if (fileEnv.CARTETHYIA_DB_MODE !== "full" || !fileEnv.DATABASE_URL?.trim()) {
|
|
374
|
-
throw new Error(
|
|
375
|
-
`Non-interactive Full setup requires CARTETHYIA_DB_MODE=full and DATABASE_URL already saved in ${envPath}; shell-only values are not persisted.`,
|
|
376
|
-
);
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
if (dbMode === "lite") {
|
|
380
|
-
console.log("🗂️ Lite mode: embedded SQLite, no PostgreSQL to install");
|
|
381
|
-
const dataDir = resolveDataDir();
|
|
382
|
-
mkdirSync(dataDir, { recursive: true });
|
|
383
|
-
console.log(`✓ Data directory ready at ${dataDir}\n`);
|
|
384
|
-
} else {
|
|
385
|
-
console.log("🐘 Checking PostgreSQL...");
|
|
386
|
-
await ensureFullDatabase(config.probeTimeoutMs);
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
// Step 5: Probe Redis when a URL is configured; otherwise the in-memory
|
|
390
|
-
// backend applies and there is nothing to check.
|
|
391
|
-
console.log("🔴 Probing Redis...");
|
|
392
|
-
if (config.redisUrl.trim() === "") {
|
|
393
|
-
console.log("✓ Redis not configured — one process uses in-memory coordination\n");
|
|
394
|
-
} else {
|
|
395
|
-
const redisParsed = parseServiceUrl(config.redisUrl);
|
|
396
|
-
if (!redisParsed) {
|
|
397
|
-
throw new Error("Invalid REDIS_URL");
|
|
398
|
-
}
|
|
399
|
-
const redisResult = await probeTcpService(
|
|
400
|
-
redisParsed.host,
|
|
401
|
-
redisParsed.port,
|
|
402
|
-
config.probeTimeoutMs,
|
|
403
|
-
);
|
|
404
|
-
if (redisResult.success) {
|
|
405
|
-
console.log("✓ Redis is reachable\n");
|
|
406
|
-
} else {
|
|
407
|
-
console.log("✗ Configured Redis is not reachable");
|
|
408
|
-
for (const hint of getOsHints("redis")) console.log(` ${hint}`);
|
|
409
|
-
throw new Error("Configured Redis is not reachable (or unset REDIS_URL for in-memory)");
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
console.log("✅ Setup complete.");
|
|
414
|
-
console.log("");
|
|
415
|
-
const port = process.env.PORT?.trim() || DEFAULT_PORT;
|
|
416
|
-
if (process.env.CARTETHYIA_CONFIG_DIR) {
|
|
417
|
-
console.log("Next — start Cartethyia:");
|
|
418
|
-
console.log("");
|
|
419
|
-
console.log("
|
|
420
|
-
console.log("
|
|
421
|
-
console.log("");
|
|
422
|
-
console.log("
|
|
423
|
-
console.log("
|
|
424
|
-
|
|
425
|
-
console.log("
|
|
426
|
-
console.log("");
|
|
427
|
-
console.log("
|
|
428
|
-
console.log("
|
|
429
|
-
|
|
430
|
-
console.log("
|
|
431
|
-
console.log("
|
|
432
|
-
console.log(
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
3
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
4
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, resolve } from "node:path";
|
|
6
|
+
import { createServer } from "node:net";
|
|
7
|
+
import {
|
|
8
|
+
MINIMUM_BUN_VERSION,
|
|
9
|
+
bunVersionAtLeast,
|
|
10
|
+
detectLocalPostgres,
|
|
11
|
+
generateCartethyiaEncryptionKey,
|
|
12
|
+
getLocalPlatform,
|
|
13
|
+
getOsHints,
|
|
14
|
+
hasPlaceholderSecrets,
|
|
15
|
+
mandatoryEnvBody,
|
|
16
|
+
parseServiceUrl,
|
|
17
|
+
probeTcpService,
|
|
18
|
+
readEnvFile,
|
|
19
|
+
ENV_PATH,
|
|
20
|
+
PROJECT_ROOT,
|
|
21
|
+
} from "../shared/env";
|
|
22
|
+
import type { ProbeResult } from "../shared/env";
|
|
23
|
+
import { resolveDataDir, resolveDbMode } from "../../src/persistence/db-mode";
|
|
24
|
+
|
|
25
|
+
async function ask(question: string, defaultValue = ""): Promise<string> {
|
|
26
|
+
const rl = createInterface({ input, output });
|
|
27
|
+
try {
|
|
28
|
+
const suffix = defaultValue ? ` [${defaultValue}]` : "";
|
|
29
|
+
const answer = await rl.question(`${question}${suffix}: `);
|
|
30
|
+
return answer.trim() || defaultValue;
|
|
31
|
+
} finally {
|
|
32
|
+
await rl.close();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function updateEnv(values: Readonly<Record<string, string>>): Promise<void> {
|
|
37
|
+
const envPath = ENV_PATH;
|
|
38
|
+
const lines = (await readFile(envPath, "utf8")).split(/\r?\n/);
|
|
39
|
+
const replaced = new Set<string>();
|
|
40
|
+
const output = lines.map((line) => {
|
|
41
|
+
const match = /^(\s*)([A-Z][A-Z0-9_]*)\s*=/.exec(line);
|
|
42
|
+
if (!match) return line;
|
|
43
|
+
const key = match[2];
|
|
44
|
+
if (!key || values[key] === undefined) return line;
|
|
45
|
+
replaced.add(key);
|
|
46
|
+
return `${match[1]}${key}=${values[key]}`;
|
|
47
|
+
});
|
|
48
|
+
for (const [key, value] of Object.entries(values)) {
|
|
49
|
+
if (!replaced.has(key)) output.push(`${key}=${value}`);
|
|
50
|
+
}
|
|
51
|
+
await writeFile(envPath, `${output.join("\n").replace(/\n+$/, "")}\n`, "utf8");
|
|
52
|
+
}
|
|
53
|
+
type SetupMode = "auto" | "native" | "docker";
|
|
54
|
+
|
|
55
|
+
export function resolveSetupMode(): SetupMode {
|
|
56
|
+
const raw = process.env.CARTETHYIA_SETUP_MODE ?? "auto";
|
|
57
|
+
if (raw === "auto" || raw === "native" || raw === "docker") return raw;
|
|
58
|
+
throw new Error(
|
|
59
|
+
`CARTETHYIA_SETUP_MODE must be auto, native, or docker (got "${raw}")`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface SetupConfig {
|
|
64
|
+
envPath: string;
|
|
65
|
+
dbUrl: string;
|
|
66
|
+
redisUrl: string;
|
|
67
|
+
probeTimeoutMs: number;
|
|
68
|
+
dockerProbeTimeoutMs: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function loadConfig(): SetupConfig {
|
|
72
|
+
return {
|
|
73
|
+
envPath: ENV_PATH,
|
|
74
|
+
dbUrl: process.env.DATABASE_URL ?? "",
|
|
75
|
+
redisUrl: process.env.REDIS_URL ?? "",
|
|
76
|
+
probeTimeoutMs: 3000,
|
|
77
|
+
dockerProbeTimeoutMs: 2000,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function setupEnvFile(config: SetupConfig): Promise<boolean> {
|
|
82
|
+
const examplePath = resolve(PROJECT_ROOT, ".env.example");
|
|
83
|
+
|
|
84
|
+
if (existsSync(config.envPath)) {
|
|
85
|
+
const fileEnv: Record<string, string> = await readEnvFile(config.envPath).catch(() => ({}) as Record<string, string>);
|
|
86
|
+
const key = fileEnv.CARTETHYIA_ENCRYPTION_KEY?.trim();
|
|
87
|
+
if (!key || hasPlaceholderSecrets(key)) {
|
|
88
|
+
const generated = generateCartethyiaEncryptionKey();
|
|
89
|
+
const raw = await readFile(config.envPath, "utf8");
|
|
90
|
+
const lines = raw.split(/\r?\n/);
|
|
91
|
+
let patched = false;
|
|
92
|
+
const out = lines.map((line) => {
|
|
93
|
+
const m = /^(\s*)(CARTETHYIA_ENCRYPTION_KEY)\s*=/.exec(line);
|
|
94
|
+
if (!m) return line;
|
|
95
|
+
patched = true;
|
|
96
|
+
return `${m[1]}${m[2]}=${generated}`;
|
|
97
|
+
});
|
|
98
|
+
if (!patched) out.push(`CARTETHYIA_ENCRYPTION_KEY=${generated}`);
|
|
99
|
+
await writeFile(config.envPath, `${out.join("\n")}\n`, "utf8");
|
|
100
|
+
console.warn("🔑 Generated CARTETHYIA_ENCRYPTION_KEY and patched .env (key was placeholder/empty)");
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!existsSync(examplePath)) {
|
|
106
|
+
throw new Error(`${examplePath} not found`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const generated = generateCartethyiaEncryptionKey();
|
|
110
|
+
const body = await mandatoryEnvBody(examplePath, {
|
|
111
|
+
CARTETHYIA_ENCRYPTION_KEY: generated,
|
|
112
|
+
});
|
|
113
|
+
await writeFile(config.envPath, body, { encoding: "utf8", mode: 0o600 });
|
|
114
|
+
console.warn("✨ Created .env from .env.example (mandatory entries only)");
|
|
115
|
+
console.warn("🔑 Auto-generated CARTETHYIA_ENCRYPTION_KEY in .env — keep it safe");
|
|
116
|
+
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function probeDocker(timeoutMs: number): Promise<ProbeResult> {
|
|
121
|
+
const proc = Bun.spawn(["docker", "compose", "version"], { stdout: "ignore", stderr: "ignore" });
|
|
122
|
+
const exited = await Promise.race([
|
|
123
|
+
proc.exited,
|
|
124
|
+
new Promise<number>((_, rej) =>
|
|
125
|
+
setTimeout(() => {
|
|
126
|
+
try {
|
|
127
|
+
proc.kill();
|
|
128
|
+
} catch {
|
|
129
|
+
// already exited by the time we try to kill it
|
|
130
|
+
}
|
|
131
|
+
rej(new Error("probe timeout"));
|
|
132
|
+
}, timeoutMs),
|
|
133
|
+
),
|
|
134
|
+
] as const);
|
|
135
|
+
if (typeof exited === "number" && exited === 0) return { success: true };
|
|
136
|
+
return { success: false, error: "unavailable" };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** True when the operator asked the wizard not to prompt. */
|
|
140
|
+
function nonInteractive(): boolean {
|
|
141
|
+
return process.argv.includes("--non-interactive") || !process.stdin.isTTY;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function setupCommand(): string {
|
|
145
|
+
return process.env.CARTETHYIA_CONFIG_DIR ? "cartethyia setup" : "bun setup";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Refuses to continue on a Bun older than the repository declares. Printed as
|
|
150
|
+
* one sentence: a stack trace here tells a first-time installer nothing.
|
|
151
|
+
*/
|
|
152
|
+
function assertBunVersion(): void {
|
|
153
|
+
const actual = Bun.version;
|
|
154
|
+
if (bunVersionAtLeast(actual, MINIMUM_BUN_VERSION)) return;
|
|
155
|
+
throw new Error(
|
|
156
|
+
`Bun ${MINIMUM_BUN_VERSION} or newer is required (this is ${actual}). Install it from https://bun.sh and run this again.`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Asks for the database mode on a fresh install even when `.env` already
|
|
162
|
+
* carries the example default. An existing choice (the variable was set before
|
|
163
|
+
* this run, or the file predates the example default) is kept. Non-interactive
|
|
164
|
+
* runs never prompt and keep whatever the file says.
|
|
165
|
+
*/
|
|
166
|
+
async function chooseDbMode(envExisted: boolean): Promise<void> {
|
|
167
|
+
const alreadyChosen = envExisted && Boolean(process.env.CARTETHYIA_DB_MODE?.trim());
|
|
168
|
+
if (alreadyChosen || nonInteractive()) return;
|
|
169
|
+
console.log("Lite uses embedded SQLite and needs no database server; it suits a single instance.");
|
|
170
|
+
console.log("Full uses PostgreSQL for server-backed deployments; you must provide a reachable PostgreSQL server. Redis is optional unless you need cross-process coordination.");
|
|
171
|
+
const answer = (await ask(
|
|
172
|
+
"Database mode? lite / full",
|
|
173
|
+
"lite",
|
|
174
|
+
)).toLowerCase();
|
|
175
|
+
if (answer !== "lite" && answer !== "full") {
|
|
176
|
+
throw new Error(`Database mode must be lite or full (got "${answer}")`);
|
|
177
|
+
}
|
|
178
|
+
await updateEnv({ CARTETHYIA_DB_MODE: answer });
|
|
179
|
+
process.env.CARTETHYIA_DB_MODE = answer;
|
|
180
|
+
console.log(`✓ Database mode: ${answer}\n`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Port the example ships with. A VPS NAT often does not forward it. */
|
|
184
|
+
const DEFAULT_PORT = "12800";
|
|
185
|
+
|
|
186
|
+
/** Ports this installer suggests when 12800 is taken. */
|
|
187
|
+
const PORT_SUGGESTIONS: readonly string[] = ["8080", "3000", "8081"];
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* True when nothing is listening on the port yet.
|
|
191
|
+
*
|
|
192
|
+
* Binding is the test, not connecting: only a real bind proves the port is
|
|
193
|
+
* free, and the socket is closed immediately so the probe never holds it.
|
|
194
|
+
*/
|
|
195
|
+
function isPortFree(host: string, port: number): Promise<boolean> {
|
|
196
|
+
return new Promise((resolve) => {
|
|
197
|
+
const server = createServer();
|
|
198
|
+
server.once("error", () => resolve(false));
|
|
199
|
+
server.listen(port, host, () => {
|
|
200
|
+
server.close(() => resolve(true));
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Asks which port the gateway should listen on. Enter keeps 12800. The
|
|
207
|
+
* candidates are probed before the prompt so the operator is told what is
|
|
208
|
+
* actually free instead of guessing. The same PORT serves `bun run dev` and the
|
|
209
|
+
* production build; the dashboard dev server stays on 5173 and proxies to it.
|
|
210
|
+
*/
|
|
211
|
+
async function choosePort(envExisted: boolean): Promise<void> {
|
|
212
|
+
const alreadyChosen = envExisted && Boolean(process.env.PORT?.trim());
|
|
213
|
+
if (alreadyChosen || nonInteractive()) return;
|
|
214
|
+
|
|
215
|
+
const candidates = [DEFAULT_PORT, ...PORT_SUGGESTIONS];
|
|
216
|
+
const states = await Promise.all(
|
|
217
|
+
candidates.map(async (port) => ({
|
|
218
|
+
port,
|
|
219
|
+
free: await isPortFree("127.0.0.1", Number(port)),
|
|
220
|
+
})),
|
|
221
|
+
);
|
|
222
|
+
const free = states.filter((s) => s.free).map((s) => s.port);
|
|
223
|
+
const taken = states.filter((s) => !s.free).map((s) => s.port);
|
|
224
|
+
const defaultFree = states[0]?.free === true;
|
|
225
|
+
|
|
226
|
+
console.log("Which port should the gateway listen on?");
|
|
227
|
+
console.log(`Cartethyia defaults to ${DEFAULT_PORT} — press Enter to keep it.`);
|
|
228
|
+
if (defaultFree) {
|
|
229
|
+
console.log(`✓ ${DEFAULT_PORT} is free, so it will not clash with anything else on this machine.`);
|
|
230
|
+
} else {
|
|
231
|
+
console.log(`✗ ${DEFAULT_PORT} is already in use, so keeping it would fail to start.`);
|
|
232
|
+
}
|
|
233
|
+
if (free.length > 0) console.log(`Available: ${free.join(", ")}`);
|
|
234
|
+
if (taken.length > 0) console.log(`In use: ${taken.join(", ")}`);
|
|
235
|
+
console.log("Pick 8080 (or any open port) when a VPS does not forward 12800.");
|
|
236
|
+
|
|
237
|
+
const answer = await ask("Port", DEFAULT_PORT);
|
|
238
|
+
if (!/^[1-9][0-9]{0,4}$/.test(answer) || Number(answer) > 65535) {
|
|
239
|
+
throw new Error(`Port must be a number from 1 to 65535 (got "${answer}")`);
|
|
240
|
+
}
|
|
241
|
+
if (!defaultFree && answer === DEFAULT_PORT) {
|
|
242
|
+
console.log(`⚠ ${DEFAULT_PORT} is in use — starting will fail unless whatever holds it is stopped first.`);
|
|
243
|
+
}
|
|
244
|
+
const values: Record<string, string> = { PORT: answer };
|
|
245
|
+
const origin = process.env.CARTETHYIA_PUBLIC_ORIGIN?.trim() ?? "";
|
|
246
|
+
if (origin === `http://localhost:${DEFAULT_PORT}`) {
|
|
247
|
+
values.CARTETHYIA_PUBLIC_ORIGIN = `http://localhost:${answer}`;
|
|
248
|
+
}
|
|
249
|
+
await updateEnv(values);
|
|
250
|
+
process.env.PORT = answer;
|
|
251
|
+
if (values.CARTETHYIA_PUBLIC_ORIGIN) {
|
|
252
|
+
process.env.CARTETHYIA_PUBLIC_ORIGIN = values.CARTETHYIA_PUBLIC_ORIGIN;
|
|
253
|
+
}
|
|
254
|
+
console.log(`✓ Port: ${answer}\n`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Makes sure Full mode has a reachable DATABASE_URL. An existing reachable URL
|
|
259
|
+
* is kept. Otherwise a local server on 5432/5433 is offered, and a typed URL is
|
|
260
|
+
* accepted only after it answers. Never installs PostgreSQL.
|
|
261
|
+
*/
|
|
262
|
+
async function ensureFullDatabase(timeoutMs: number): Promise<void> {
|
|
263
|
+
const existing = process.env.DATABASE_URL?.trim() ?? "";
|
|
264
|
+
const parsed = existing ? parseServiceUrl(existing) : null;
|
|
265
|
+
if (parsed) {
|
|
266
|
+
const probe = await probeTcpService(parsed.host, parsed.port, timeoutMs);
|
|
267
|
+
if (probe.success) {
|
|
268
|
+
console.log(`✓ PostgreSQL is reachable at ${parsed.host}:${parsed.port}\n`);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
console.log(`✗ PostgreSQL at ${parsed.host}:${parsed.port} did not answer.`);
|
|
272
|
+
}
|
|
273
|
+
const detected = await detectLocalPostgres(timeoutMs);
|
|
274
|
+
if (nonInteractive()) {
|
|
275
|
+
if (!parsed) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
`Full mode needs DATABASE_URL. Set it in ${ENV_PATH} and run ${setupCommand()} again, or run without --non-interactive.`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
for (const hint of getOsHints("postgres")) console.log(` ${hint}`);
|
|
281
|
+
throw new Error("Configured PostgreSQL is not reachable");
|
|
282
|
+
}
|
|
283
|
+
if (detected) {
|
|
284
|
+
console.log(`Found a PostgreSQL server at ${detected.host}:${detected.port}.`);
|
|
285
|
+
} else {
|
|
286
|
+
console.log("No local PostgreSQL answered on port 5432 or 5433.");
|
|
287
|
+
for (const hint of getOsHints("postgres")) console.log(` ${hint}`);
|
|
288
|
+
}
|
|
289
|
+
const suggestion = detected
|
|
290
|
+
? `postgres://postgres@${detected.host}:${detected.port}/cartethyia`
|
|
291
|
+
: "";
|
|
292
|
+
const typed = await ask("DATABASE_URL", suggestion);
|
|
293
|
+
const chosen = parseServiceUrl(typed);
|
|
294
|
+
if (!chosen) {
|
|
295
|
+
throw new Error("DATABASE_URL must include a host and a port, for example postgres://postgres@127.0.0.1:5432/cartethyia");
|
|
296
|
+
}
|
|
297
|
+
const confirmed = await probeTcpService(chosen.host, chosen.port, timeoutMs);
|
|
298
|
+
if (!confirmed.success) {
|
|
299
|
+
for (const hint of getOsHints("postgres")) console.log(` ${hint}`);
|
|
300
|
+
throw new Error(`PostgreSQL at ${chosen.host}:${chosen.port} did not answer. Start it, then run bun setup again.`);
|
|
301
|
+
}
|
|
302
|
+
await updateEnv({ DATABASE_URL: typed });
|
|
303
|
+
process.env.DATABASE_URL = typed;
|
|
304
|
+
console.log(`✓ PostgreSQL is reachable at ${chosen.host}:${chosen.port}\n`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export async function setup(): Promise<void> {
|
|
308
|
+
console.log("🚀 Setting up Cartethyia...\n");
|
|
309
|
+
assertBunVersion();
|
|
310
|
+
|
|
311
|
+
// Step 1: Setup the user config file for npm installs, repo .env for dev.
|
|
312
|
+
const envPath = ENV_PATH;
|
|
313
|
+
mkdirSync(dirname(envPath), { recursive: true });
|
|
314
|
+
const envCreated = await setupEnvFile({
|
|
315
|
+
envPath,
|
|
316
|
+
dbUrl: "",
|
|
317
|
+
redisUrl: "",
|
|
318
|
+
probeTimeoutMs: 3000,
|
|
319
|
+
dockerProbeTimeoutMs: 2000,
|
|
320
|
+
});
|
|
321
|
+
if (envCreated) {
|
|
322
|
+
console.log("✓ .env created\n");
|
|
323
|
+
} else {
|
|
324
|
+
console.log("✓ .env already exists\n");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Step 2: Load the file, then choose the mode. A fresh file carries the
|
|
328
|
+
// example default, which is not a choice the operator made, so it is asked.
|
|
329
|
+
const fileEnv = await readEnvFile(envPath);
|
|
330
|
+
for (const [key, value] of Object.entries(fileEnv)) {
|
|
331
|
+
if (!process.env[key]) process.env[key] = value;
|
|
332
|
+
}
|
|
333
|
+
await chooseDbMode(!envCreated);
|
|
334
|
+
await choosePort(!envCreated);
|
|
335
|
+
const config = loadConfig();
|
|
336
|
+
|
|
337
|
+
// Step 3: Resolve the cross-platform setup mode. Native/auto is the
|
|
338
|
+
// default; Docker is opt-in because PostgreSQL is external to this stack.
|
|
339
|
+
const setupMode = resolveSetupMode();
|
|
340
|
+
const platform = getLocalPlatform();
|
|
341
|
+
console.log(`🧭 Platform: ${platform}; setup mode: ${setupMode}`);
|
|
342
|
+
|
|
343
|
+
if (setupMode === "docker") {
|
|
344
|
+
console.log("🐳 Checking Docker Compose...");
|
|
345
|
+
const dockerProbe = await probeDocker(config.dockerProbeTimeoutMs);
|
|
346
|
+
if (!dockerProbe.success) {
|
|
347
|
+
throw new Error(
|
|
348
|
+
"Docker setup requested but Docker Compose is unavailable; use native services or install Docker",
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
console.log("✓ Docker Compose is available");
|
|
352
|
+
// Redis is optional: only start the compose service when a URL is set.
|
|
353
|
+
if (config.redisUrl.trim() !== "") {
|
|
354
|
+
console.log("📦 Starting Docker Compose Redis service...");
|
|
355
|
+
const proc = Bun.spawn(["docker", "compose", "up", "-d", "redis"], {
|
|
356
|
+
cwd: PROJECT_ROOT,
|
|
357
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
358
|
+
timeout: 30_000,
|
|
359
|
+
});
|
|
360
|
+
const exitCode = await proc.exited;
|
|
361
|
+
if (exitCode !== 0) throw new Error(`Docker Compose failed with exit code ${exitCode}`);
|
|
362
|
+
console.log("✓ Docker Compose Redis started; PostgreSQL remains external\n");
|
|
363
|
+
} else {
|
|
364
|
+
console.log("ℹ️ REDIS_URL unset — skipping Compose Redis (in-memory backend)\n");
|
|
365
|
+
}
|
|
366
|
+
} else {
|
|
367
|
+
console.log("🌱 Native-first mode; using configured local or external services\n");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Step 4: Probe Postgres in full mode; lite verifies the data dir instead.
|
|
371
|
+
const dbMode = resolveDbMode();
|
|
372
|
+
if (process.env.CARTETHYIA_CONFIG_DIR && nonInteractive() && dbMode === "full") {
|
|
373
|
+
if (fileEnv.CARTETHYIA_DB_MODE !== "full" || !fileEnv.DATABASE_URL?.trim()) {
|
|
374
|
+
throw new Error(
|
|
375
|
+
`Non-interactive Full setup requires CARTETHYIA_DB_MODE=full and DATABASE_URL already saved in ${envPath}; shell-only values are not persisted.`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (dbMode === "lite") {
|
|
380
|
+
console.log("🗂️ Lite mode: embedded SQLite, no PostgreSQL to install");
|
|
381
|
+
const dataDir = resolveDataDir();
|
|
382
|
+
mkdirSync(dataDir, { recursive: true });
|
|
383
|
+
console.log(`✓ Data directory ready at ${dataDir}\n`);
|
|
384
|
+
} else {
|
|
385
|
+
console.log("🐘 Checking PostgreSQL...");
|
|
386
|
+
await ensureFullDatabase(config.probeTimeoutMs);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Step 5: Probe Redis when a URL is configured; otherwise the in-memory
|
|
390
|
+
// backend applies and there is nothing to check.
|
|
391
|
+
console.log("🔴 Probing Redis...");
|
|
392
|
+
if (config.redisUrl.trim() === "") {
|
|
393
|
+
console.log("✓ Redis not configured — one process uses in-memory coordination\n");
|
|
394
|
+
} else {
|
|
395
|
+
const redisParsed = parseServiceUrl(config.redisUrl);
|
|
396
|
+
if (!redisParsed) {
|
|
397
|
+
throw new Error("Invalid REDIS_URL");
|
|
398
|
+
}
|
|
399
|
+
const redisResult = await probeTcpService(
|
|
400
|
+
redisParsed.host,
|
|
401
|
+
redisParsed.port,
|
|
402
|
+
config.probeTimeoutMs,
|
|
403
|
+
);
|
|
404
|
+
if (redisResult.success) {
|
|
405
|
+
console.log("✓ Redis is reachable\n");
|
|
406
|
+
} else {
|
|
407
|
+
console.log("✗ Configured Redis is not reachable");
|
|
408
|
+
for (const hint of getOsHints("redis")) console.log(` ${hint}`);
|
|
409
|
+
throw new Error("Configured Redis is not reachable (or unset REDIS_URL for in-memory)");
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
console.log("✅ Setup complete.");
|
|
414
|
+
console.log("");
|
|
415
|
+
const port = process.env.PORT?.trim() || DEFAULT_PORT;
|
|
416
|
+
if (process.env.CARTETHYIA_CONFIG_DIR) {
|
|
417
|
+
console.log("Next — start Cartethyia:");
|
|
418
|
+
console.log("");
|
|
419
|
+
console.log(" carte");
|
|
420
|
+
console.log(" Starts the gateway in the background and opens the console menu");
|
|
421
|
+
console.log(" (Open Console, Reset password, Backup, Visit repository, Update, Exit).");
|
|
422
|
+
console.log(" Update, Exit). Arrow keys and Enter; no desktop environment needed.");
|
|
423
|
+
console.log("");
|
|
424
|
+
console.log(" cartethyia start");
|
|
425
|
+
console.log(" Runs the gateway in this terminal, logs in the foreground.");
|
|
426
|
+
console.log("");
|
|
427
|
+
console.log(" cartethyia tray");
|
|
428
|
+
console.log(" Same gateway, but with a system-tray icon instead of the menu.");
|
|
429
|
+
} else {
|
|
430
|
+
console.log("Next — pick one:");
|
|
431
|
+
console.log("");
|
|
432
|
+
console.log(" bun run build && bun run start");
|
|
433
|
+
console.log(" Production. Builds the dashboard, AOT manifest, and binary first.");
|
|
434
|
+
console.log("");
|
|
435
|
+
console.log(" bun run dev");
|
|
436
|
+
console.log(" Development. No build; hot reload, dashboard on 5173, API on");
|
|
437
|
+
console.log(` ${port}. Use this while changing source.`);
|
|
438
|
+
}
|
|
439
|
+
console.log("");
|
|
440
|
+
console.log(`Then open http://localhost:${port}/console — the first visit asks you to create the admin user.`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (import.meta.main) {
|
|
444
|
+
setup().catch((err) => {
|
|
445
|
+
console.error("❌ Setup failed:", err);
|
|
446
|
+
process.exit(1);
|
|
447
|
+
});
|
|
448
|
+
}
|