cartethyia 2.3.4 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/dashboard/assets/{BannedPage-Dc8V-nxc.js → BannedPage-CvnNj-tj.js} +1 -1
  2. package/dist/dashboard/assets/{CliToolDetailPage-ifAh7wbK.js → CliToolDetailPage-DdYuTZKs.js} +1 -1
  3. package/dist/dashboard/assets/{CliToolsPage-2A1c_dFb.js → CliToolsPage-Ba55GO-L.js} +1 -1
  4. package/dist/dashboard/assets/{ConsoleLogPage-FfxY8_yn.js → ConsoleLogPage-D-OQwsYE.js} +1 -1
  5. package/dist/dashboard/assets/{CustomizationPage-CZ5H40Ha.js → CustomizationPage-BMqN7mc4.js} +1 -1
  6. package/dist/dashboard/assets/{HealthEventsModal-CZKQak_s.js → HealthEventsModal-C5cygmiy.js} +1 -1
  7. package/dist/dashboard/assets/{LoginPage-sqFYY4uv.js → LoginPage-lI7CDb07.js} +1 -1
  8. package/dist/dashboard/assets/{OverviewPage-BOZJaauG.js → OverviewPage-HaMJcZaC.js} +1 -1
  9. package/dist/dashboard/assets/{ProviderDetailPage-CNUCsNFg.js → ProviderDetailPage-Pb-0zXVA.js} +1 -1
  10. package/dist/dashboard/assets/{ProvidersPage-CsXoemJ1.js → ProvidersPage-KifbSUkV.js} +1 -1
  11. package/dist/dashboard/assets/{ProxyPage-DvRt3aZP.js → ProxyPage-DKLnIcne.js} +1 -1
  12. package/dist/dashboard/assets/{QuotaPage-DNYJWw7s.js → QuotaPage-BzG3XTSg.js} +1 -1
  13. package/dist/dashboard/assets/{SetupPage-Cz1DWkcr.js → SetupPage-Cu42oonZ.js} +1 -1
  14. package/dist/dashboard/assets/{StudioPage-CsKD9sy-.js → StudioPage-DIglSNvf.js} +1 -1
  15. package/dist/dashboard/assets/{UsagePage-_Zu6460J.js → UsagePage-DQh2rUsY.js} +1 -1
  16. package/dist/dashboard/assets/{entry-COuKgXuV.js → entry-6PSJYbzI.js} +3 -3
  17. package/dist/dashboard/assets/{index-CF94-Gob.js → index-Bysq3XgY.js} +2 -2
  18. package/dist/dashboard/index.html +1 -1
  19. package/package.json +2 -1
  20. package/scripts/cli/cli-launcher.cjs +12 -1
  21. package/scripts/cli/cli.ts +923 -657
  22. package/scripts/cli/menu.ts +107 -53
  23. package/scripts/cli/runtime-state.ts +48 -32
  24. package/scripts/cli/setup.ts +448 -443
  25. package/scripts/cli/wordmark.ts +58 -0
  26. package/src/boot/app.ts +39 -0
  27. package/src/console/auth/account-reset.ts +67 -0
  28. package/src/console/backup/export-endpoint.ts +111 -0
@@ -1,657 +1,923 @@
1
- import { randomBytes } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
- import { spawn, type ChildProcess } from "node:child_process";
4
- import { tmpdir } from "node:os";
5
- import { join } from "node:path";
6
- import SysTray from "systray2";
7
- import { isNewerRelease } from "../../src/console/updates/version-compare";
8
- import { PACKAGE_ROOT, resolvePackagePath, resolveUserConfigDir } from "../../src/config/package-paths";
9
- import { resolvePort } from "../../src/config/runtime";
10
- import { MINIMUM_BUN_VERSION, bunVersionAtLeast } from "../shared/env";
11
- import { chooseFromMenu } from "./menu";
12
- import {
13
- clearPersistedDrainToken,
14
- readPersistedDrainToken,
15
- writePersistedDrainToken,
16
- } from "./runtime-state";
17
- import { npmUpdate, readInstalledVersion, readLatestPublishedVersion } from "./version";
18
-
19
- process.env.CARTETHYIA_CONFIG_DIR ??= resolveUserConfigDir();
20
-
21
- const HELP = `Cartethyia ${MINIMUM_BUN_VERSION}+ (Bun is required)
22
-
23
- Usage:
24
- carte start, then show an arrow-key menu (no GUI needed)
25
- carte --tray start with a system-tray icon instead
26
- cartethyia setup [--non-interactive]
27
- cartethyia start
28
- cartethyia menu
29
- cartethyia stop stop a gateway left running in the background
30
- cartethyia update
31
-
32
- carte starts the gateway and opens a menu driven by the arrow keys and Enter:
33
- open the console, open settings, hide to the background, check for updates, or
34
- exit. It needs no desktop environment, so it works over SSH and headless.
35
- carte --tray uses a system-tray icon instead, which needs a graphical session.
36
- Setup explains Lite (embedded SQLite) and Full (external PostgreSQL), then saves
37
- configuration under the current user's Cartethyia config directory.
38
- `;
39
-
40
- function assertBunVersion(): void {
41
- if (bunVersionAtLeast(Bun.version, MINIMUM_BUN_VERSION)) return;
42
- throw new Error(
43
- `Bun ${MINIMUM_BUN_VERSION} or newer is required (found ${Bun.version}). Install it from https://bun.sh and run this command again.`,
44
- );
45
- }
46
-
47
- async function loadUserConfig(): Promise<void> {
48
- const configDir = process.env.CARTETHYIA_CONFIG_DIR;
49
- if (!configDir) throw new Error("Cartethyia config directory could not be resolved");
50
- mkdirSync(configDir, { recursive: true });
51
- const { ENV_PATH, readEnvFile } = await import("../shared/env");
52
- const values = await readEnvFile(ENV_PATH);
53
- for (const [key, value] of Object.entries(values)) {
54
- if (!process.env[key]) process.env[key] = value;
55
- }
56
- }
57
-
58
-
59
- type TrayStartupSignal =
60
- | { readonly status: "ready"; readonly url: string }
61
- | { readonly status: "failed"; readonly message: string };
62
-
63
- function reportTrayStartup(signal: TrayStartupSignal): void {
64
- const signalPath = process.env.CARTETHYIA_TRAY_STARTUP_SIGNAL;
65
- if (!signalPath) return;
66
- try {
67
- writeFileSync(signalPath, JSON.stringify(signal), { encoding: "utf8", flag: "wx" });
68
- } catch {
69
- // The launcher may have timed out and removed the one-shot signal file.
70
- }
71
- }
72
-
73
- async function waitForTrayStartupSignal(
74
- signalPath: string,
75
- child: ChildProcess,
76
- timeoutMs: number,
77
- ): Promise<string> {
78
- let spawnError: Error | undefined;
79
- child.once("error", (error) => {
80
- spawnError = error;
81
- });
82
- const deadline = Date.now() + timeoutMs;
83
- while (Date.now() < deadline) {
84
- if (existsSync(signalPath)) {
85
- try {
86
- const parsed: unknown = JSON.parse(readFileSync(signalPath, "utf8"));
87
- if (parsed !== null && typeof parsed === "object" && "status" in parsed) {
88
- if (parsed.status === "ready" && "url" in parsed && typeof parsed.url === "string") return parsed.url;
89
- if (parsed.status === "failed" && "message" in parsed && typeof parsed.message === "string") {
90
- throw new Error(parsed.message);
91
- }
92
- }
93
- } catch (error) {
94
- if (!(error instanceof SyntaxError)) throw error;
95
- }
96
- }
97
- if (spawnError) throw spawnError;
98
- if (child.exitCode !== null) throw new Error(`updated Cartethyia exited during startup (exit code ${child.exitCode})`);
99
- await Bun.sleep(150);
100
- }
101
- throw new Error("Cartethyia did not finish tray and gateway startup within 45 seconds");
102
- }
103
-
104
-
105
- function openUrl(url: string): Promise<void> {
106
- const command = process.platform === "win32"
107
- ? "explorer.exe"
108
- : process.platform === "darwin"
109
- ? "open"
110
- : "xdg-open";
111
- const child = Bun.spawn([command, url], {
112
- stdin: "ignore",
113
- stdout: "ignore",
114
- stderr: "ignore",
115
- });
116
- return child.exited.then(() => undefined);
117
- }
118
-
119
-
120
- function launchUpdatedTray(signalPath: string): ChildProcess {
121
- const env = { ...process.env, CARTETHYIA_TRAY_STARTUP_SIGNAL: signalPath };
122
- const child = spawn(
123
- process.execPath,
124
- ["run", resolvePackagePath("scripts", "cli", "cli.ts"), "tray"],
125
- { cwd: tmpdir(), detached: true, stdio: "ignore", windowsHide: true, env },
126
- );
127
- child.unref();
128
- return child;
129
- }
130
-
131
- async function startGateway(port: number, drainToken: string): Promise<ReturnType<typeof Bun.spawn>> {
132
- const child = Bun.spawn(
133
- [process.execPath, "run", resolvePackagePath("src", "main.ts")],
134
- {
135
- cwd: PACKAGE_ROOT,
136
- env: { ...process.env, CARTETHYIA_DRAIN_TOKEN: drainToken },
137
- // "ignore", not "inherit": the gateway does not read stdin, and in menu
138
- // mode the menu is the only stdin consumer. With "inherit" the gateway
139
- // held the fd and swallowed every keypress after the first, so the menu
140
- // rendered but never received Enter.
141
- stdin: "ignore",
142
- stdout: "inherit",
143
- stderr: "inherit",
144
- },
145
- );
146
- const startupController = new AbortController();
147
- const ready = (async (): Promise<void> => {
148
- const deadline = Date.now() + 30_000;
149
- while (!startupController.signal.aborted && Date.now() < deadline) {
150
- try {
151
- const response = await fetch(`http://127.0.0.1:${port}/health`, {
152
- signal: AbortSignal.timeout(500),
153
- });
154
- if (response.ok) {
155
- const body: unknown = await response.json();
156
- if (body !== null && typeof body === "object" && "status" in body && body.status === "ok") return;
157
- }
158
- } catch {
159
- // The gateway is not accepting health requests yet.
160
- }
161
- await Bun.sleep(250);
162
- }
163
- if (startupController.signal.aborted) throw new Error("Cartethyia exited before becoming ready");
164
- throw new Error("Cartethyia did not open its configured port within 30 seconds");
165
- })();
166
- const exitedEarly = child.exited.then((code) => {
167
- startupController.abort();
168
- return { status: "exit" as const, code };
169
- });
170
- const readyResult = ready.then(
171
- () => ({ status: "ready" as const }),
172
- (error: unknown) => ({ status: "failure" as const, error }),
173
- );
174
- try {
175
- const result = await Promise.race([readyResult, exitedEarly]);
176
- if (result.status === "exit") {
177
- throw new Error(`Cartethyia stopped before becoming ready (exit code ${result.code})`);
178
- }
179
- if (result.status === "failure") throw result.error;
180
- return child;
181
- } catch (error) {
182
- child.kill();
183
- await child.exited;
184
- throw error;
185
- }
186
- }
187
-
188
- async function stopGateway(
189
- current: ReturnType<typeof Bun.spawn>,
190
- port: number,
191
- drainToken: string,
192
- ): Promise<void> {
193
- let drainAccepted = false;
194
- try {
195
- const response = await fetch(`http://127.0.0.1:${port}/admin/drain`, {
196
- method: "POST",
197
- headers: { "x-drain-token": drainToken },
198
- signal: AbortSignal.timeout(2_000),
199
- });
200
- drainAccepted = response.status === 202;
201
- } catch {
202
- // The process may already be exiting; fall back to its OS-level termination.
203
- }
204
- if (!drainAccepted) {
205
- console.error("Could not request a graceful gateway drain; terminating the gateway process.");
206
- current.kill();
207
- await current.exited;
208
- return;
209
- }
210
-
211
- let timer: ReturnType<typeof setTimeout> | undefined;
212
- const exit = await Promise.race([
213
- current.exited,
214
- new Promise<null>((resolveExit) => {
215
- timer = setTimeout(() => resolveExit(null), 30_000);
216
- }),
217
- ]);
218
- if (timer !== undefined) clearTimeout(timer);
219
- if (exit === null) {
220
- console.error("Graceful drain timed out; terminating the gateway process.");
221
- current.kill();
222
- await current.exited;
223
- }
224
- }
225
-
226
- async function waitForTray(tray: SysTray): Promise<void> {
227
- let timer: ReturnType<typeof setTimeout> | undefined;
228
- try {
229
- await Promise.race([
230
- tray.ready(),
231
- new Promise<never>((_, reject) => {
232
- timer = setTimeout(() => reject(new Error("System tray startup timed out")), 15_000);
233
- }),
234
- ]);
235
- } finally {
236
- if (timer !== undefined) clearTimeout(timer);
237
- }
238
- }
239
-
240
- async function stopTray(tray: SysTray, ready: boolean, exited: boolean): Promise<void> {
241
- if (exited) return;
242
- if (!ready) {
243
- tray.process.kill();
244
- return;
245
- }
246
-
247
- let timer: ReturnType<typeof setTimeout> | undefined;
248
- try {
249
- await Promise.race([
250
- tray.kill(false),
251
- new Promise<void>((resolveExit) => {
252
- timer = setTimeout(() => resolveExit(), 5_000);
253
- }),
254
- ]);
255
- } catch {
256
- // Fall through to a direct child termination below.
257
- } finally {
258
- if (timer !== undefined) clearTimeout(timer);
259
- }
260
- if (!tray.killed) tray.process.kill();
261
- }
262
-
263
- /**
264
- * The default `carte` experience: start the gateway, then offer an arrow-key
265
- * menu. This needs no desktop environment, so it works over SSH, on a headless
266
- * box, and on Windows where the tray icon lands in the hidden-icons overflow
267
- * and is never seen. `carte --tray` opts into the icon instead.
268
- */
269
- async function startMenu(): Promise<void> {
270
- const port = resolvePort();
271
- const drainToken = randomBytes(32).toString("hex");
272
- const dashboardUrl = `http://127.0.0.1:${port}/console`;
273
- const settingsUrl = `${dashboardUrl}/settings`;
274
- const currentVersion = await readInstalledVersion();
275
-
276
- const gateway = await startGateway(port, drainToken);
277
- let stopping = false;
278
- const shutDown = async (): Promise<void> => {
279
- if (stopping) return;
280
- stopping = true;
281
- await stopGateway(gateway, port, drainToken);
282
- };
283
- process.once("SIGINT", () => void shutDown());
284
- process.once("SIGTERM", () => void shutDown());
285
-
286
- const latest = await readLatestPublishedVersion();
287
- const updateLabel =
288
- latest !== null && isNewerRelease(currentVersion, latest)
289
- ? `Update to ${latest}`
290
- : "Check for updates";
291
-
292
- const options = [
293
- `Open Console \u00b7 ${dashboardUrl}`,
294
- "Settings \u00b7 backup & restore, reset password",
295
- "Hide to background and keep running",
296
- updateLabel,
297
- "Exit",
298
- ] as const;
299
-
300
- console.log(`Cartethyia ${currentVersion} is running at ${dashboardUrl}`);
301
- for (;;) {
302
- const choice = await chooseFromMenu(
303
- `Cartethyia ${currentVersion} \u2014 running at ${dashboardUrl}`,
304
- options,
305
- );
306
- if (choice === -1 || choice === 4) break;
307
- if (choice === 0) {
308
- await openUrl(dashboardUrl);
309
- continue;
310
- }
311
- if (choice === 1) {
312
- await openUrl(settingsUrl);
313
- continue;
314
- }
315
- if (choice === 2) {
316
- // The gateway is a detached child, so returning here leaves it running
317
- // while this process lets go of the terminal. Persist the drain token
318
- // now, because `cartethyia stop` runs in a later shell and has no other
319
- // way to reach this process.
320
- writePersistedDrainToken(drainToken);
321
- console.log(`Cartethyia keeps running at ${dashboardUrl}.`);
322
- console.log("Stop it later with: cartethyia stop");
323
- return;
324
- }
325
- if (choice === 3) {
326
- const code = await npmUpdate();
327
- console.log(code === 0 ? "Update finished." : "Update failed.");
328
- }
329
- }
330
- clearPersistedDrainToken();
331
- await shutDown();
332
- }
333
-
334
- /**
335
- * Stops a gateway left running by "Hide to background". That process is not a
336
- * child of this one, so it cannot be signalled directly — the only handle is
337
- * the loopback drain endpoint, which needs the drain token the start wrote.
338
- */
339
- async function stopRunningGateway(): Promise<number> {
340
- const port = resolvePort();
341
- const token = readPersistedDrainToken();
342
- if (token === null) {
343
- console.error(
344
- "No background Cartethyia gateway was found for this install. Start one with `carte`, or stop a foreground run with Ctrl-C.",
345
- );
346
- return 1;
347
- }
348
- try {
349
- const response = await fetch(`http://127.0.0.1:${port}/admin/drain`, {
350
- method: "POST",
351
- headers: { "x-drain-token": token },
352
- signal: AbortSignal.timeout(3_000),
353
- });
354
- if (response.status !== 202) {
355
- console.error(`The gateway refused the drain request (HTTP ${response.status}).`);
356
- return 1;
357
- }
358
- } catch {
359
- console.error("No gateway is listening on the configured port. It may already be stopped.");
360
- return 1;
361
- }
362
- clearPersistedDrainToken();
363
- console.log("Cartethyia is draining and will stop once in-flight requests finish.");
364
- return 0;
365
- }
366
-
367
-
368
- async function startTray(): Promise<void> {
369
- const port = resolvePort();
370
- const drainToken = randomBytes(32).toString("hex");
371
- const currentVersion = await readInstalledVersion();
372
- const dashboardUrl = `http://127.0.0.1:${port}/console`;
373
- const settingsUrl = `${dashboardUrl}/settings`;
374
- const icon = resolvePackagePath(
375
- "tray-assets",
376
- process.platform === "win32" ? "cartethyia.ico" : "cartethyia.png",
377
- );
378
- const version = {
379
- title: `Current Cartethyia version: ${currentVersion}`,
380
- tooltip: `Running Cartethyia ${currentVersion}`,
381
- enabled: false,
382
- };
383
- const repository = {
384
- title: "Repository: risunCode/cartethyia",
385
- tooltip: "Open the Cartethyia GitHub repository",
386
- enabled: true,
387
- };
388
- const updateStatus = {
389
- title: "Version tracker: checking npm…",
390
- tooltip: "Latest published Cartethyia version on npm",
391
- enabled: false,
392
- };
393
- const update = {
394
- title: "Checking for updates…",
395
- tooltip: "Update Cartethyia and restart the tray process",
396
- enabled: false,
397
- };
398
- const openConsole = {
399
- title: `Open Console · ${dashboardUrl}`,
400
- tooltip: `Open the local Console at ${dashboardUrl}`,
401
- enabled: true,
402
- };
403
- const resetPassword = {
404
- title: "Reset password",
405
- tooltip: "Open Console settings to change the administrator password",
406
- enabled: true,
407
- };
408
- const backupRestore = {
409
- title: "Backup & Restore",
410
- tooltip: "Open Console settings to export or restore configuration",
411
- enabled: true,
412
- };
413
- const exit = {
414
- title: "Exit",
415
- tooltip: "Stop Cartethyia gracefully and close the tray",
416
- enabled: true,
417
- };
418
- const tray = new SysTray({
419
- menu: {
420
- icon,
421
- title: "Cartethyia",
422
- tooltip: "Cartethyia AI gateway",
423
- items: [
424
- version,
425
- repository,
426
- updateStatus,
427
- update,
428
- SysTray.separator,
429
- openConsole,
430
- resetPassword,
431
- backupRestore,
432
- SysTray.separator,
433
- exit,
434
- ],
435
- },
436
- copyDir: true,
437
- });
438
-
439
- let gateway: ReturnType<typeof Bun.spawn> | null = null;
440
- let trayReady = false;
441
- let trayExited = false;
442
- let closing = false;
443
- let busy = false;
444
- let latestVersion: string | null = null;
445
- let closePromise: Promise<void> | undefined;
446
-
447
- const close = (exitCode = 0): Promise<void> => {
448
- if (closePromise) return closePromise;
449
- closing = true;
450
- closePromise = (async () => {
451
- const current: ReturnType<typeof Bun.spawn> | null = gateway;
452
- gateway = null;
453
- if (current) await stopGateway(current, port, drainToken);
454
- await stopTray(tray, trayReady, trayExited);
455
- process.exitCode = exitCode;
456
- })();
457
- return closePromise;
458
- };
459
-
460
- const refreshVersion = async (): Promise<void> => {
461
- const fetchedVersion = await readLatestPublishedVersion();
462
- if (closing) return;
463
- latestVersion = fetchedVersion;
464
- if (fetchedVersion === null) {
465
- updateStatus.title = "Version tracker: unavailable";
466
- update.title = "Check for updates";
467
- update.enabled = true;
468
- } else {
469
- updateStatus.title = `Latest published version: ${fetchedVersion}`;
470
- if (isNewerRelease(currentVersion, fetchedVersion)) {
471
- update.title = `Update to ${fetchedVersion}`;
472
- update.enabled = true;
473
- } else {
474
- update.title = `Up to date (${currentVersion})`;
475
- update.enabled = false;
476
- }
477
- }
478
- try {
479
- await tray.sendAction({ type: "update-item", item: updateStatus });
480
- await tray.sendAction({ type: "update-item", item: update });
481
- } catch {
482
- // The tray may be closing while the background version check completes.
483
- }
484
- };
485
-
486
- const handleClick = async (title: string): Promise<void> => {
487
- if (closing) return;
488
- if (title === "Exit") {
489
- await close();
490
- return;
491
- }
492
- if (title === openConsole.title) {
493
- await openUrl(dashboardUrl);
494
- return;
495
- }
496
- if (title === resetPassword.title || title === backupRestore.title) {
497
- await openUrl(settingsUrl);
498
- return;
499
- }
500
- if (title === repository.title) {
501
- await openUrl("https://github.com/risunCode/cartethyia");
502
- return;
503
- }
504
- if (title === "Check for updates" && !busy) {
505
- await refreshVersion();
506
- return;
507
- }
508
- if (title !== update.title || !update.enabled || busy) return;
509
- if (latestVersion === null || !isNewerRelease(currentVersion, latestVersion)) {
510
- await refreshVersion();
511
- return;
512
- }
513
-
514
- busy = true;
515
- update.title = "Updating Cartethyia…";
516
- update.enabled = false;
517
- try {
518
- await tray.sendAction({ type: "update-item", item: update });
519
- const current = gateway;
520
- if (!current) throw new Error("Cartethyia gateway is not running");
521
- gateway = null;
522
- await stopGateway(current, port, drainToken);
523
- if (closing) return;
524
-
525
- console.log("Updating Cartethyia through npm...");
526
- const code = await npmUpdate();
527
- if (closing) return;
528
- if (code !== 0) {
529
- console.error(`npm update failed with exit code ${code}; restarting the current installation.`);
530
- gateway = await startGateway(port, drainToken);
531
- await refreshVersion();
532
- return;
533
- }
534
-
535
- const signalPath = join(tmpdir(), `cartethyia-tray-update-${randomBytes(16).toString("hex")}.json`);
536
- try {
537
- const updatedProcess = launchUpdatedTray(signalPath);
538
- const updatedUrl = await waitForTrayStartupSignal(signalPath, updatedProcess, 60_000);
539
- console.log(`Cartethyia updated to ${await readInstalledVersion()} and restarted at ${updatedUrl}`);
540
- await close();
541
- } finally {
542
- if (existsSync(signalPath)) unlinkSync(signalPath);
543
- }
544
- } catch (error) {
545
- const reason = error instanceof Error ? error.message : "unknown error";
546
- console.error(`Cartethyia update or restart failed: ${reason}`);
547
- if (!closing && gateway === null) {
548
- try {
549
- gateway = await startGateway(port, drainToken);
550
- } catch {
551
- await close(1);
552
- }
553
- }
554
- if (!closing) {
555
- update.title = "Update failed — retry";
556
- update.enabled = true;
557
- try {
558
- await tray.sendAction({ type: "update-item", item: update });
559
- } catch {
560
- // The tray may have failed while the update was being recovered.
561
- }
562
- }
563
- } finally {
564
- busy = false;
565
- }
566
- };
567
-
568
- await tray.onClick((event) => {
569
- void handleClick(event.item.title).catch(() => {
570
- console.error("The selected system-tray action failed.");
571
- });
572
- });
573
- tray.onExit(() => {
574
- trayExited = true;
575
- if (!closing) void close(1);
576
- });
577
- tray.onError(() => {
578
- console.error("The system-tray process failed; stopping Cartethyia.");
579
- if (!closing) void close(1);
580
- });
581
- process.once("SIGINT", () => void close());
582
- process.once("SIGTERM", () => void close());
583
-
584
- try {
585
- await waitForTray(tray);
586
- trayReady = true;
587
- gateway = await startGateway(port, drainToken);
588
- reportTrayStartup({ status: "ready", url: dashboardUrl });
589
- console.log(`Cartethyia is running at ${dashboardUrl}`);
590
- void refreshVersion();
591
-
592
- while (!closing) {
593
- const current: ReturnType<typeof Bun.spawn> | null = gateway;
594
- if (!current) {
595
- await Bun.sleep(100);
596
- continue;
597
- }
598
- const code = await current.exited;
599
- if (gateway !== current || closing) continue;
600
- gateway = null;
601
- console.error(`Cartethyia exited with code ${code}.`);
602
- await close(code ?? 1);
603
- }
604
- } catch (error) {
605
- await close(1);
606
- throw error;
607
- }
608
- }
609
-
610
- async function main(): Promise<void> {
611
- assertBunVersion();
612
- const [command] = process.argv.slice(2);
613
- if (command === "setup" || command === "start" || command === "tray" || command === "menu" || command === "stop") {
614
- await loadUserConfig();
615
- }
616
-
617
- switch (command) {
618
- case "setup": {
619
- const { setup } = await import("./setup");
620
- await setup();
621
- return;
622
- }
623
- case "start":
624
- await import("../../src/main");
625
- return;
626
- case "menu":
627
- await startMenu();
628
- return;
629
- case "tray":
630
- await startTray();
631
- return;
632
- case "stop":
633
- process.exitCode = await stopRunningGateway();
634
- return;
635
- case "update": {
636
- const code = await npmUpdate();
637
- if (code !== 0) process.exitCode = code;
638
- else console.log("Cartethyia is up to date.");
639
- return;
640
- }
641
- case "--help":
642
- case "-h":
643
- case undefined:
644
- console.log(HELP);
645
- return;
646
- default:
647
- console.error(`Unknown command: ${command}\n\n${HELP}`);
648
- process.exitCode = 2;
649
- }
650
- }
651
-
652
- main().catch((error: unknown) => {
653
- const message = error instanceof Error ? error.message : "unknown error";
654
- if (process.argv[2] === "tray") reportTrayStartup({ status: "failed", message });
655
- console.error(`Cartethyia failed: ${message}`);
656
- process.exitCode = 1;
657
- });
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { spawn, type ChildProcess } from "node:child_process";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import SysTray from "systray2";
7
+ import { isNewerRelease } from "../../src/console/updates/version-compare";
8
+ import { PACKAGE_ROOT, resolvePackagePath, resolveUserConfigDir } from "../../src/config/package-paths";
9
+ import { resolvePort } from "../../src/config/runtime";
10
+ import { MINIMUM_BUN_VERSION, bunVersionAtLeast } from "../shared/env";
11
+ import { chooseFromMenu, confirmByTyping, confirmStep } from "./menu";
12
+ import { ensureDrainToken, readPersistedDrainToken } from "./runtime-state";
13
+ import { npmUpdate, readInstalledVersion, readLatestPublishedVersion } from "./version";
14
+ import { renderWordmark } from "./wordmark";
15
+
16
+ process.env.CARTETHYIA_CONFIG_DIR ??= resolveUserConfigDir();
17
+
18
+ const HELP = `Cartethyia ${MINIMUM_BUN_VERSION}+ (Bun is required)
19
+
20
+ Usage:
21
+ carte start, then show an arrow-key menu (no GUI needed)
22
+ carte --tray start with a system-tray icon instead
23
+ cartethyia setup [--non-interactive]
24
+ cartethyia start
25
+ cartethyia menu
26
+ cartethyia stop stop a gateway left running in the background
27
+ cartethyia update
28
+
29
+ carte starts the gateway in the background and leaves only the console menu on
30
+ your terminal — Open Console, Reset password, Backup, Visit
31
+ repository, Update, Exit. Arrow keys and Enter; no desktop environment needed,
32
+ so it works over SSH and headless. Exiting the menu drains the gateway.
33
+ carte --tray uses a system-tray icon instead, which needs a graphical session.
34
+ Stop a gateway whose terminal is gone with "cartethyia stop".
35
+ Setup explains Lite (embedded SQLite) and Full (external PostgreSQL), then saves
36
+ configuration under the current user's Cartethyia config directory.
37
+ `;
38
+
39
+ function assertBunVersion(): void {
40
+ if (bunVersionAtLeast(Bun.version, MINIMUM_BUN_VERSION)) return;
41
+ throw new Error(
42
+ `Bun ${MINIMUM_BUN_VERSION} or newer is required (found ${Bun.version}). Install it from https://bun.sh and run this command again.`,
43
+ );
44
+ }
45
+
46
+ async function loadUserConfig(): Promise<void> {
47
+ const configDir = process.env.CARTETHYIA_CONFIG_DIR;
48
+ if (!configDir) throw new Error("Cartethyia config directory could not be resolved");
49
+ mkdirSync(configDir, { recursive: true });
50
+ const { ENV_PATH, readEnvFile } = await import("../shared/env");
51
+ const values = await readEnvFile(ENV_PATH);
52
+ for (const [key, value] of Object.entries(values)) {
53
+ if (!process.env[key]) process.env[key] = value;
54
+ }
55
+ }
56
+
57
+
58
+ type TrayStartupSignal =
59
+ | { readonly status: "ready"; readonly url: string }
60
+ | { readonly status: "failed"; readonly message: string };
61
+
62
+ function reportTrayStartup(signal: TrayStartupSignal): void {
63
+ const signalPath = process.env.CARTETHYIA_TRAY_STARTUP_SIGNAL;
64
+ if (!signalPath) return;
65
+ try {
66
+ writeFileSync(signalPath, JSON.stringify(signal), { encoding: "utf8", flag: "wx" });
67
+ } catch {
68
+ // The launcher may have timed out and removed the one-shot signal file.
69
+ }
70
+ }
71
+
72
+ async function waitForTrayStartupSignal(
73
+ signalPath: string,
74
+ child: ChildProcess,
75
+ timeoutMs: number,
76
+ ): Promise<string> {
77
+ let spawnError: Error | undefined;
78
+ child.once("error", (error) => {
79
+ spawnError = error;
80
+ });
81
+ const deadline = Date.now() + timeoutMs;
82
+ while (Date.now() < deadline) {
83
+ if (existsSync(signalPath)) {
84
+ try {
85
+ const parsed: unknown = JSON.parse(readFileSync(signalPath, "utf8"));
86
+ if (parsed !== null && typeof parsed === "object" && "status" in parsed) {
87
+ if (parsed.status === "ready" && "url" in parsed && typeof parsed.url === "string") return parsed.url;
88
+ if (parsed.status === "failed" && "message" in parsed && typeof parsed.message === "string") {
89
+ throw new Error(parsed.message);
90
+ }
91
+ }
92
+ } catch (error) {
93
+ if (!(error instanceof SyntaxError)) throw error;
94
+ }
95
+ }
96
+ if (spawnError) throw spawnError;
97
+ if (child.exitCode !== null) throw new Error(`updated Cartethyia exited during startup (exit code ${child.exitCode})`);
98
+ await Bun.sleep(150);
99
+ }
100
+ throw new Error("Cartethyia did not finish tray and gateway startup within 45 seconds");
101
+ }
102
+
103
+
104
+ function openUrl(url: string): Promise<void> {
105
+ const command = process.platform === "win32"
106
+ ? "explorer.exe"
107
+ : process.platform === "darwin"
108
+ ? "open"
109
+ : "xdg-open";
110
+ const child = Bun.spawn([command, url], {
111
+ stdin: "ignore",
112
+ stdout: "ignore",
113
+ stderr: "ignore",
114
+ });
115
+ return child.exited.then(() => undefined);
116
+ }
117
+
118
+
119
+ function launchUpdatedTray(signalPath: string): ChildProcess {
120
+ const env = { ...process.env, CARTETHYIA_TRAY_STARTUP_SIGNAL: signalPath };
121
+ const child = spawn(
122
+ process.execPath,
123
+ ["run", resolvePackagePath("scripts", "cli", "cli.ts"), "tray"],
124
+ { cwd: tmpdir(), detached: true, stdio: "ignore", windowsHide: true, env },
125
+ );
126
+ child.unref();
127
+ return child;
128
+ }
129
+
130
+ async function startGateway(
131
+ port: number,
132
+ drainToken: string,
133
+ options: { readonly detached?: boolean } = {},
134
+ ): Promise<Bun.Subprocess> {
135
+ // Starting a second gateway on the same port used to fail with "stopped
136
+ // before becoming ready (exit code 1)", which says nothing about the real
137
+ // cause. Probe the port first and say what is actually there.
138
+ const existing = await fetch(`http://127.0.0.1:${port}/health`, {
139
+ signal: AbortSignal.timeout(1_000),
140
+ }).catch(() => null);
141
+ if (existing !== null) {
142
+ throw new Error(
143
+ `Cartethyia is already running on port ${port}. Use \`cartethyia stop\` to stop it, or open http://127.0.0.1:${port}/console.`,
144
+ );
145
+ }
146
+ // The gateway logs a startup phase line per stage. In menu mode those lines
147
+ // would scroll through the same terminal the menu is drawing on, so they go
148
+ // to a file instead and the operator sees only the menu.
149
+ const configDir = process.env.CARTETHYIA_CONFIG_DIR;
150
+ let logFd: number | null = null;
151
+ if (configDir) {
152
+ try {
153
+ logFd = openSync(join(configDir, "gateway.log"), "a");
154
+ } catch {
155
+ // A read-only config directory costs only the log, not startup.
156
+ }
157
+ }
158
+ const child = Bun.spawn(
159
+ [process.execPath, "run", resolvePackagePath("src", "main.ts")],
160
+ {
161
+ cwd: PACKAGE_ROOT,
162
+ env: { ...process.env, CARTETHYIA_DRAIN_TOKEN: drainToken },
163
+ // "ignore", not "inherit": the gateway does not read stdin, and in menu
164
+ // mode the menu is the only stdin consumer. With "inherit" the gateway
165
+ // held the fd and swallowed every keypress after the first, so the menu
166
+ // rendered but never received Enter.
167
+ stdin: "ignore",
168
+ stdout: logFd ?? "ignore",
169
+ // stderr goes to the same file: "inherit" ties the gateway to the
170
+ // terminal, so closing that terminal took the gateway down with it and
171
+ // "Hide to tray" left nothing running.
172
+ stderr: logFd ?? "ignore",
173
+ // Attached by default. A detached child cannot be killed from an `exit`
174
+ // handler on Windows (measured: kill() runs, the gateway keeps serving),
175
+ // and a terminal's Ctrl-C only reaches children in the same process
176
+ // group. Staying attached means Ctrl-C and the `exit` backstop both stop
177
+ // the gateway. "Hide to tray" passes detached: true because that gateway
178
+ // must outlive this process.
179
+ detached: options.detached === true,
180
+ },
181
+ );
182
+ const startupController = new AbortController();
183
+ const ready = (async (): Promise<void> => {
184
+ const deadline = Date.now() + 30_000;
185
+ while (!startupController.signal.aborted && Date.now() < deadline) {
186
+ try {
187
+ const response = await fetch(`http://127.0.0.1:${port}/health`, {
188
+ signal: AbortSignal.timeout(500),
189
+ });
190
+ if (response.ok) {
191
+ const body: unknown = await response.json();
192
+ if (body !== null && typeof body === "object" && "status" in body && body.status === "ok") return;
193
+ }
194
+ } catch {
195
+ // The gateway is not accepting health requests yet.
196
+ }
197
+ await Bun.sleep(250);
198
+ }
199
+ if (startupController.signal.aborted) throw new Error("Cartethyia exited before becoming ready");
200
+ throw new Error("Cartethyia did not open its configured port within 30 seconds");
201
+ })();
202
+ const exitedEarly = child.exited.then((code) => {
203
+ startupController.abort();
204
+ return { status: "exit" as const, code };
205
+ });
206
+ const readyResult = ready.then(
207
+ () => ({ status: "ready" as const }),
208
+ (error: unknown) => ({ status: "failure" as const, error }),
209
+ );
210
+ try {
211
+ const result = await Promise.race([readyResult, exitedEarly]);
212
+ if (result.status === "exit") {
213
+ throw new Error(`Cartethyia stopped before becoming ready (exit code ${result.code})`);
214
+ }
215
+ if (result.status === "failure") throw result.error;
216
+ return child;
217
+ } catch (error) {
218
+ child.kill();
219
+ await child.exited;
220
+ throw error;
221
+ }
222
+ }
223
+
224
+ async function stopGateway(
225
+ current: ReturnType<typeof Bun.spawn>,
226
+ port: number,
227
+ drainToken: string,
228
+ ): Promise<void> {
229
+ let drainAccepted = false;
230
+ try {
231
+ const response = await fetch(`http://127.0.0.1:${port}/admin/drain`, {
232
+ method: "POST",
233
+ headers: { "x-drain-token": drainToken },
234
+ signal: AbortSignal.timeout(2_000),
235
+ });
236
+ drainAccepted = response.status === 202;
237
+ } catch {
238
+ // The process may already be exiting; fall back to its OS-level termination.
239
+ }
240
+ if (!drainAccepted) {
241
+ console.error("Could not request a graceful gateway drain; terminating the gateway process.");
242
+ current.kill();
243
+ await current.exited;
244
+ return;
245
+ }
246
+
247
+ let timer: ReturnType<typeof setTimeout> | undefined;
248
+ const exit = await Promise.race([
249
+ current.exited,
250
+ new Promise<null>((resolveExit) => {
251
+ timer = setTimeout(() => resolveExit(null), 30_000);
252
+ }),
253
+ ]);
254
+ if (timer !== undefined) clearTimeout(timer);
255
+ if (exit === null) {
256
+ console.error("Graceful drain timed out; terminating the gateway process.");
257
+ current.kill();
258
+ await current.exited;
259
+ }
260
+ }
261
+
262
+ async function waitForTray(tray: SysTray): Promise<void> {
263
+ let timer: ReturnType<typeof setTimeout> | undefined;
264
+ try {
265
+ await Promise.race([
266
+ tray.ready(),
267
+ new Promise<never>((_, reject) => {
268
+ timer = setTimeout(() => reject(new Error("System tray startup timed out")), 15_000);
269
+ }),
270
+ ]);
271
+ } finally {
272
+ if (timer !== undefined) clearTimeout(timer);
273
+ }
274
+ }
275
+
276
+ async function stopTray(tray: SysTray, ready: boolean, exited: boolean): Promise<void> {
277
+ if (exited) return;
278
+ if (!ready) {
279
+ tray.process.kill();
280
+ return;
281
+ }
282
+
283
+ let timer: ReturnType<typeof setTimeout> | undefined;
284
+ try {
285
+ await Promise.race([
286
+ tray.kill(false),
287
+ new Promise<void>((resolveExit) => {
288
+ timer = setTimeout(() => resolveExit(), 5_000);
289
+ }),
290
+ ]);
291
+ } catch {
292
+ // Fall through to a direct child termination below.
293
+ } finally {
294
+ if (timer !== undefined) clearTimeout(timer);
295
+ }
296
+ if (!tray.killed) tray.process.kill();
297
+ }
298
+
299
+ /**
300
+ * The default `carte` experience: start the gateway, then offer an arrow-key
301
+ * menu. This needs no desktop environment, so it works over SSH, on a headless
302
+ * box, and on Windows where the tray icon lands in the hidden-icons overflow
303
+ * and is never seen. `carte --tray` opts into the icon instead.
304
+ */
305
+ async function startMenu(): Promise<void> {
306
+ const port = resolvePort();
307
+ const drainToken = ensureDrainToken();
308
+ const dashboardUrl = `http://127.0.0.1:${port}/console`;
309
+ const repositoryUrl = "https://github.com/risunCode/cartethyia";
310
+ const currentVersion = await readInstalledVersion();
311
+
312
+ // The gateway is detached and holds no stdio, so this process owns the
313
+ // terminal outright: nothing else can consume the keys the menu needs. The
314
+ // drain token is persisted before the menu opens because `cartethyia stop`
315
+ // runs in a later shell and has no other handle on this process.
316
+ const gateway = await startGateway(port, drainToken);
317
+ gateway.unref();
318
+
319
+ let stopping = false;
320
+ let handedOff = false;
321
+ const shutDown = async (): Promise<void> => {
322
+ if (stopping) return;
323
+ stopping = true;
324
+ await stopGateway(gateway, port, drainToken);
325
+ };
326
+ // Ctrl-C has to stop the gateway, and the drain endpoint cannot be called
327
+ // from an exit path: it is async, and enquirer's own Ctrl-C handler calls
328
+ // process.exit(130) while a prompt is live. So the gateway is a child in this
329
+ // process group — a terminal's Ctrl-C signals the whole group, and the
330
+ // gateway drains through the SIGINT handler it already has in src/main.ts —
331
+ // and `process.on("exit")` is the synchronous backstop for every other way
332
+ // this process can end (a closed terminal, a crash, a signal Bun cannot
333
+ // deliver on Windows). That handler can only call synchronous APIs, which is
334
+ // why it signals the child rather than awaiting the drain endpoint.
335
+ // `handedOff` is set by "Hide to tray": the gateway is being given to a tray
336
+ // that will own it, so this process must not kill it on the way out.
337
+ process.on("exit", () => {
338
+ if (stopping || handedOff) return;
339
+ try {
340
+ gateway.kill();
341
+ } catch {
342
+ // Already gone; nothing to stop.
343
+ }
344
+ });
345
+ const interrupt = async (): Promise<void> => {
346
+ process.stdout.write("\u001b[2J\u001b[H");
347
+ console.log("Stopping Cartethyia…");
348
+ await shutDown();
349
+ process.exit(0);
350
+ };
351
+ process.on("SIGTERM", () => void interrupt());
352
+ process.on("SIGINT", () => void interrupt());
353
+
354
+ let latestVersion = await readLatestPublishedVersion();
355
+ let running = true;
356
+
357
+ const header = [...renderWordmark(), `Cartethyia ${currentVersion} \u00b7 ${dashboardUrl}`];
358
+
359
+ while (running) {
360
+ const upToDate = latestVersion === null || !isNewerRelease(currentVersion, latestVersion);
361
+ const updateLabel =
362
+ latestVersion === null
363
+ ? "Check for updates"
364
+ : upToDate
365
+ ? `Up to date (${currentVersion})`
366
+ : `Update to ${latestVersion}`;
367
+
368
+ const actions: readonly (() => Promise<void>)[] = [
369
+ async () => openUrl(dashboardUrl),
370
+ async () => {
371
+ // Deleting the console account is the only real password recovery:
372
+ // /change-password needs the current password, and first-boot setup
373
+ // only runs when no user exists. Removing the user puts the wizard
374
+ // back. Providers, keys, and routing are untouched.
375
+ console.log("");
376
+ console.log("This deletes every console account and its sessions.");
377
+ console.log("The next visit to /console asks you to create a new one.");
378
+ console.log("Providers, API keys, and routing are not touched.");
379
+ console.log("");
380
+ if (!(await confirmStep("Continue with the reset?"))) {
381
+ console.log("Cancelled.");
382
+ return;
383
+ }
384
+ if (!(await confirmByTyping("RESET", "Confirm deleting the console account"))) {
385
+ console.log("Cancelled.");
386
+ return;
387
+ }
388
+ const status = await resetConsoleAccount(port, drainToken);
389
+ console.log(
390
+ status === 0
391
+ ? `Console account removed. Open ${dashboardUrl} to create a new one.`
392
+ : "Reset failed; see the gateway log.",
393
+ );
394
+ },
395
+ async () => {
396
+ // Export straight to the download directory. The console page asks for
397
+ // the operator password, which is exactly the thing a locked-out
398
+ // operator does not have; the CLI already runs as the OS user who owns
399
+ // the database, so the loopback token is the whole gate.
400
+ const result = await exportBackup(port, drainToken);
401
+ if (result === null) return;
402
+ console.log(
403
+ `Backup written to ${result.path} (${(result.bytes / 1024).toFixed(1)} KiB, ${Object.keys(result.counts).length} tables).`,
404
+ );
405
+ console.log("Telemetry is not included: it is per-request history, not configuration.");
406
+ },
407
+ async () => openUrl(repositoryUrl),
408
+ async () => {
409
+ if (upToDate) return;
410
+ const code = await npmUpdate();
411
+ console.log(code === 0 ? "Update finished." : "Update failed.");
412
+ latestVersion = await readLatestPublishedVersion();
413
+ },
414
+ async () => {
415
+ // Hand the gateway to a tray. This process's gateway is attached, so it
416
+ // would die with the menu; the handoff therefore drains it and starts a
417
+ // detached one that the tray adopts. The port is briefly empty between
418
+ // the two, which is the cost of having Ctrl-C reliably stop a gateway
419
+ // while a hidden one still outlives the terminal.
420
+ running = false;
421
+ stopping = true;
422
+ handedOff = true;
423
+ console.log("Handing the gateway to the tray…");
424
+ await shutDown();
425
+ const replacement = await startGateway(port, drainToken, { detached: true });
426
+ replacement.unref();
427
+ const tray = spawn(
428
+ process.execPath,
429
+ ["run", resolvePackagePath("scripts", "cli", "cli.ts"), "tray"],
430
+ {
431
+ cwd: tmpdir(),
432
+ detached: true,
433
+ stdio: "ignore",
434
+ windowsHide: true,
435
+ env: { ...process.env, CARTETHYIA_TRAY_ADOPT_RUNNING: "1" },
436
+ },
437
+ );
438
+ tray.unref();
439
+ console.log(`Cartethyia keeps running at ${dashboardUrl}.`);
440
+ console.log("The tray icon now owns it. Stop it with: cartethyia stop");
441
+ },
442
+ async () => {
443
+ running = false;
444
+ },
445
+ ];
446
+
447
+ const choice = await chooseFromMenu(header, [
448
+ { kind: "action", label: "Open Console", hint: `browser \u00b7 ${dashboardUrl}` },
449
+ { kind: "action", label: "Reset console password", hint: "deletes the account" },
450
+ { kind: "action", label: "Backup", hint: "export config to Downloads" },
451
+ { kind: "action", label: "Visit repository", hint: "github.com/risunCode/cartethyia" },
452
+ { kind: "action", label: updateLabel, hint: upToDate ? "no update available" : "installs now" },
453
+ { kind: "action", label: "Hide to tray", hint: "keep the gateway running" },
454
+ { kind: "rule" },
455
+ { kind: "action", label: "Exit", hint: "stop the gateway" },
456
+ ],
457
+ );
458
+ if (choice === -1) break;
459
+ const action = actions[choice];
460
+ if (action) await action();
461
+ }
462
+ // "Hide to tray" has already given the gateway to the tray. Draining here
463
+ // would stop the very process the tray just adopted, which is why the menu
464
+ // appeared to hand over a gateway that was not listening.
465
+ if (handedOff) return;
466
+ await shutDown();
467
+ }
468
+
469
+ /**
470
+ * Asks the gateway to write a gzipped config backup into the download
471
+ * directory. Returns the written path and counts, or null when it failed.
472
+ *
473
+ * The file is produced by the gateway, not here: the gateway owns the database
474
+ * connection and the schema is dialect-dependent, so reading the tables from
475
+ * this process would need a second implementation of both.
476
+ */
477
+ async function exportBackup(
478
+ port: number,
479
+ token: string,
480
+ ): Promise<{ path: string; bytes: number; counts: Record<string, number> } | null> {
481
+ try {
482
+ const response = await fetch(`http://127.0.0.1:${port}/admin/export-backup`, {
483
+ method: "POST",
484
+ headers: { "content-type": "application/json", "x-drain-token": token },
485
+ body: JSON.stringify({}),
486
+ signal: AbortSignal.timeout(60_000),
487
+ });
488
+ if (!response.ok) {
489
+ const detail = await response.text().catch(() => "");
490
+ console.error(`The gateway refused the backup (HTTP ${response.status}). ${detail}`.trim());
491
+ return null;
492
+ }
493
+ return (await response.json()) as {
494
+ path: string;
495
+ bytes: number;
496
+ counts: Record<string, number>;
497
+ };
498
+ } catch {
499
+ console.error("Could not reach the gateway. Is it still running?");
500
+ return null;
501
+ }
502
+ }
503
+
504
+ /**
505
+ * Posts the console-account reset to the running gateway.
506
+ *
507
+ * The reset has to go through the gateway, not the database file, because the
508
+ * gateway holds the connection and the schema is dialect-dependent. Returns 0
509
+ * on success and 1 on any failure so the caller can print one line either way.
510
+ */
511
+ async function resetConsoleAccount(port: number, token: string): Promise<number> {
512
+ try {
513
+ const response = await fetch(`http://127.0.0.1:${port}/admin/reset-console-account`, {
514
+ method: "POST",
515
+ headers: { "content-type": "application/json", "x-drain-token": token },
516
+ body: JSON.stringify({ confirm: "RESET" }),
517
+ signal: AbortSignal.timeout(10_000),
518
+ });
519
+ if (!response.ok) {
520
+ const detail = await response.text().catch(() => "");
521
+ console.error(`The gateway refused the reset (HTTP ${response.status}). ${detail}`.trim());
522
+ return 1;
523
+ }
524
+ return 0;
525
+ } catch {
526
+ console.error("Could not reach the gateway. Is it still running?");
527
+ return 1;
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Asks a gateway we do not own to drain, then waits for its port to close.
533
+ *
534
+ * Used by `cartethyia stop` and by an adopted tray's Exit: in both cases the
535
+ * gateway is a separate process, so the loopback endpoint is the only handle.
536
+ * Returns 0 only once the port stops answering — a drain that is accepted but
537
+ * leaves the listener up is not a stop.
538
+ */
539
+ async function drainThroughEndpoint(port: number, token: string): Promise<number> {
540
+ try {
541
+ const response = await fetch(`http://127.0.0.1:${port}/admin/drain`, {
542
+ method: "POST",
543
+ headers: { "x-drain-token": token },
544
+ signal: AbortSignal.timeout(3_000),
545
+ });
546
+ if (response.status !== 202) {
547
+ console.error(
548
+ response.status === 401
549
+ ? `The gateway on port ${port} rejected the drain token. It was started by a different install; stop it where it was started.`
550
+ : `The gateway refused the drain request (HTTP ${response.status}).`,
551
+ );
552
+ return 1;
553
+ }
554
+ } catch {
555
+ console.error(`No gateway is listening on port ${port}. It may already be stopped.`);
556
+ return 1;
557
+ }
558
+
559
+ const deadline = Date.now() + 20_000;
560
+ while (Date.now() < deadline) {
561
+ const alive = await fetch(`http://127.0.0.1:${port}/health`, {
562
+ signal: AbortSignal.timeout(500),
563
+ })
564
+ .then(() => true)
565
+ .catch(() => false);
566
+ if (!alive) return 0;
567
+ await Bun.sleep(250);
568
+ }
569
+ console.error(
570
+ `The gateway accepted the drain but is still answering on port ${port} after 20 seconds.`,
571
+ );
572
+ return 1;
573
+ }
574
+
575
+ /**
576
+ * Stops a running gateway through its loopback drain endpoint.
577
+ *
578
+ * That process is not a child of this one, so it cannot be signalled directly —
579
+ * the token in the config directory is the only handle. A 202 means the drain
580
+ * was accepted, not that the process is gone, so this waits for the port to
581
+ * stop answering before reporting success; a `stop` that returns while the
582
+ * gateway is still serving is worse than one that fails.
583
+ */
584
+ async function stopRunningGateway(): Promise<number> {
585
+ const port = resolvePort();
586
+ const token = readPersistedDrainToken();
587
+ if (token === null) {
588
+ console.error(
589
+ "This install has no saved drain token, so there is no background gateway to stop. Start one with `carte`, or stop a foreground run with Ctrl-C.",
590
+ );
591
+ return 1;
592
+ }
593
+ const code = await drainThroughEndpoint(port, token);
594
+ if (code === 0) console.log("Cartethyia stopped.");
595
+ return code;
596
+ }
597
+
598
+
599
+ async function startTray(): Promise<void> {
600
+ const port = resolvePort();
601
+ const drainToken = ensureDrainToken();
602
+ const currentVersion = await readInstalledVersion();
603
+ const dashboardUrl = `http://127.0.0.1:${port}/console`;
604
+ const icon = resolvePackagePath(
605
+ "tray-assets",
606
+ process.platform === "win32" ? "cartethyia.ico" : "cartethyia.png",
607
+ );
608
+ const repository = {
609
+ title: "Repository: risunCode/cartethyia",
610
+ tooltip: "Open the Cartethyia GitHub repository",
611
+ enabled: true,
612
+ };
613
+ const update = {
614
+ title: "Checking for updates…",
615
+ tooltip: "Update Cartethyia and restart the tray process",
616
+ enabled: false,
617
+ };
618
+ const openConsole = {
619
+ title: `Open Console · ${dashboardUrl}`,
620
+ tooltip: `Open the local Console at ${dashboardUrl}`,
621
+ enabled: true,
622
+ };
623
+ const resetPassword = {
624
+ title: "Reset console password",
625
+ tooltip: "Delete the console account so /console asks for a new one",
626
+ enabled: true,
627
+ };
628
+ const backupRestore = {
629
+ title: "Backup",
630
+ tooltip: "Write a gzipped config backup to your download directory",
631
+ enabled: true,
632
+ };
633
+ const exit = {
634
+ title: "Exit",
635
+ tooltip: "Stop Cartethyia gracefully and close the tray",
636
+ enabled: true,
637
+ };
638
+ const tray = new SysTray({
639
+ menu: {
640
+ icon,
641
+ title: "Cartethyia",
642
+ tooltip: "Cartethyia AI gateway",
643
+ items: [
644
+ repository,
645
+ update,
646
+ SysTray.separator,
647
+ openConsole,
648
+ resetPassword,
649
+ backupRestore,
650
+ SysTray.separator,
651
+ exit,
652
+ ],
653
+ },
654
+ copyDir: true,
655
+ });
656
+
657
+ let gateway: ReturnType<typeof Bun.spawn> | null = null;
658
+ let trayReady = false;
659
+ let trayExited = false;
660
+ let closing = false;
661
+ let busy = false;
662
+ let resetArmed = false;
663
+ const adopted = process.env.CARTETHYIA_TRAY_ADOPT_RUNNING === "1";
664
+ let latestVersion: string | null = null;
665
+ let closePromise: Promise<void> | undefined;
666
+
667
+ const close = (exitCode = 0): Promise<void> => {
668
+ if (closePromise) return closePromise;
669
+ closing = true;
670
+ closePromise = (async () => {
671
+ const current: ReturnType<typeof Bun.spawn> | null = gateway;
672
+ gateway = null;
673
+ if (current) {
674
+ await stopGateway(current, port, drainToken);
675
+ } else if (adopted) {
676
+ // An adopted tray never held the gateway as a child, so there is no
677
+ // process to signal. Draining through the loopback endpoint is the only
678
+ // way Exit stops it — without this, Exit closed the icon and left the
679
+ // gateway listening, which looked like a phantom process.
680
+ await drainThroughEndpoint(port, drainToken);
681
+ }
682
+ await stopTray(tray, trayReady, trayExited);
683
+ process.exitCode = exitCode;
684
+ })();
685
+ return closePromise;
686
+ };
687
+
688
+ const refreshVersion = async (): Promise<void> => {
689
+ const fetchedVersion = await readLatestPublishedVersion();
690
+ if (closing) return;
691
+ latestVersion = fetchedVersion;
692
+ if (fetchedVersion === null) {
693
+ update.title = `Cartethyia ${currentVersion} · check for updates`;
694
+ update.enabled = true;
695
+ } else {
696
+ if (isNewerRelease(currentVersion, fetchedVersion)) {
697
+ update.title = `Cartethyia ${currentVersion} · update to ${fetchedVersion}`;
698
+ update.enabled = true;
699
+ } else {
700
+ update.title = `Cartethyia ${currentVersion} · up to date`;
701
+ update.enabled = false;
702
+ }
703
+ }
704
+ try {
705
+ await tray.sendAction({ type: "update-item", item: update });
706
+ } catch {
707
+ // The tray may be closing while the background version check completes.
708
+ }
709
+ };
710
+
711
+ const handleClick = async (title: string): Promise<void> => {
712
+ if (closing) return;
713
+ if (title === "Exit") {
714
+ await close();
715
+ return;
716
+ }
717
+ if (title === openConsole.title) {
718
+ await openUrl(dashboardUrl);
719
+ return;
720
+ }
721
+ if (title === resetPassword.title) {
722
+ // Same recovery the menu offers: /change-password needs the current
723
+ // password, so deleting the account is the only way back in. A tray click
724
+ // cannot ask for a typed confirmation, so the first click arms the action
725
+ // and the second one runs it.
726
+ if (!resetArmed) {
727
+ resetArmed = true;
728
+ console.log(
729
+ "Click \"Reset console password\" again to delete the console account. This removes every operator login; providers, API keys, and routing are kept.",
730
+ );
731
+ return;
732
+ }
733
+ resetArmed = false;
734
+ const removed = await resetConsoleAccount(port, drainToken);
735
+ if (removed === 0) {
736
+ console.log(`Console account removed. Open ${dashboardUrl} to create a new one.`);
737
+ await openUrl(dashboardUrl);
738
+ }
739
+ return;
740
+ }
741
+ if (title === backupRestore.title) {
742
+ // Export straight to disk, like the menu does. Restore stays in the
743
+ // console, where an operator picks the file and confirms the overwrite.
744
+ const result = await exportBackup(port, drainToken);
745
+ if (result !== null)
746
+ console.log(
747
+ `Backup written to ${result.path} (${(result.bytes / 1024).toFixed(1)} KiB). Telemetry is not included.`,
748
+ );
749
+ return;
750
+ }
751
+ if (title === repository.title) {
752
+ await openUrl("https://github.com/risunCode/cartethyia");
753
+ return;
754
+ }
755
+ if (title === "Check for updates" && !busy) {
756
+ await refreshVersion();
757
+ return;
758
+ }
759
+ if (title !== update.title || !update.enabled || busy) return;
760
+ if (latestVersion === null || !isNewerRelease(currentVersion, latestVersion)) {
761
+ await refreshVersion();
762
+ return;
763
+ }
764
+
765
+ busy = true;
766
+ update.title = "Updating Cartethyia…";
767
+ update.enabled = false;
768
+ try {
769
+ await tray.sendAction({ type: "update-item", item: update });
770
+ const current = gateway;
771
+ if (!current) throw new Error("Cartethyia gateway is not running");
772
+ gateway = null;
773
+ await stopGateway(current, port, drainToken);
774
+ if (closing) return;
775
+
776
+ console.log("Updating Cartethyia through npm...");
777
+ const code = await npmUpdate();
778
+ if (closing) return;
779
+ if (code !== 0) {
780
+ console.error(`npm update failed with exit code ${code}; restarting the current installation.`);
781
+ gateway = await startGateway(port, drainToken);
782
+ await refreshVersion();
783
+ return;
784
+ }
785
+
786
+ const signalPath = join(tmpdir(), `cartethyia-tray-update-${randomBytes(16).toString("hex")}.json`);
787
+ try {
788
+ const updatedProcess = launchUpdatedTray(signalPath);
789
+ const updatedUrl = await waitForTrayStartupSignal(signalPath, updatedProcess, 60_000);
790
+ console.log(`Cartethyia updated to ${await readInstalledVersion()} and restarted at ${updatedUrl}`);
791
+ await close();
792
+ } finally {
793
+ if (existsSync(signalPath)) unlinkSync(signalPath);
794
+ }
795
+ } catch (error) {
796
+ const reason = error instanceof Error ? error.message : "unknown error";
797
+ console.error(`Cartethyia update or restart failed: ${reason}`);
798
+ if (!closing && gateway === null) {
799
+ try {
800
+ gateway = await startGateway(port, drainToken);
801
+ } catch {
802
+ await close(1);
803
+ }
804
+ }
805
+ if (!closing) {
806
+ update.title = "Update failed — retry";
807
+ update.enabled = true;
808
+ try {
809
+ await tray.sendAction({ type: "update-item", item: update });
810
+ } catch {
811
+ // The tray may have failed while the update was being recovered.
812
+ }
813
+ }
814
+ } finally {
815
+ busy = false;
816
+ }
817
+ };
818
+
819
+ await tray.onClick((event) => {
820
+ void handleClick(event.item.title).catch(() => {
821
+ console.error("The selected system-tray action failed.");
822
+ });
823
+ });
824
+ tray.onExit(() => {
825
+ trayExited = true;
826
+ if (!closing) void close(1);
827
+ });
828
+ tray.onError(() => {
829
+ console.error("The system-tray process failed; stopping Cartethyia.");
830
+ if (!closing) void close(1);
831
+ });
832
+ process.once("SIGINT", () => void close());
833
+ process.once("SIGTERM", () => void close());
834
+
835
+ try {
836
+ await waitForTray(tray);
837
+ trayReady = true;
838
+ // "Hide to tray" hands over a gateway that is already listening. Spawning a
839
+ // second one would fail on the port, so an adopted tray only watches the
840
+ // existing process and stops owning it when the menu process exits.
841
+ if (process.env.CARTETHYIA_TRAY_ADOPT_RUNNING === "1") {
842
+ reportTrayStartup({ status: "ready", url: dashboardUrl });
843
+ console.log(`Cartethyia is running at ${dashboardUrl}`);
844
+ void refreshVersion();
845
+ while (!closing) await Bun.sleep(500);
846
+ return;
847
+ }
848
+ gateway = await startGateway(port, drainToken);
849
+ reportTrayStartup({ status: "ready", url: dashboardUrl });
850
+ console.log(`Cartethyia is running at ${dashboardUrl}`);
851
+ void refreshVersion();
852
+
853
+ while (!closing) {
854
+ const current: ReturnType<typeof Bun.spawn> | null = gateway;
855
+ if (!current) {
856
+ await Bun.sleep(100);
857
+ continue;
858
+ }
859
+ const code = await current.exited;
860
+ if (gateway !== current || closing) continue;
861
+ gateway = null;
862
+ console.error(`Cartethyia exited with code ${code}.`);
863
+ await close(code ?? 1);
864
+ }
865
+ } catch (error) {
866
+ await close(1);
867
+ throw error;
868
+ }
869
+ }
870
+
871
+ async function main(): Promise<void> {
872
+ assertBunVersion();
873
+ const [command] = process.argv.slice(2);
874
+ if (command === "setup" || command === "start" || command === "tray" || command === "menu" || command === "stop") {
875
+ await loadUserConfig();
876
+ }
877
+
878
+ switch (command) {
879
+ case "setup": {
880
+ const { setup } = await import("./setup");
881
+ await setup();
882
+ return;
883
+ }
884
+ case "start": {
885
+ // A foreground run still needs a drain token on disk: `cartethyia stop`
886
+ // in another shell has no other handle on it, and without the token the
887
+ // gateway has no /admin/drain route at all.
888
+ process.env.CARTETHYIA_DRAIN_TOKEN ??= ensureDrainToken();
889
+ await import("../../src/main");
890
+ return;
891
+ }
892
+ case "menu":
893
+ await startMenu();
894
+ return;
895
+ case "tray":
896
+ await startTray();
897
+ return;
898
+ case "stop":
899
+ process.exitCode = await stopRunningGateway();
900
+ return;
901
+ case "update": {
902
+ const code = await npmUpdate();
903
+ if (code !== 0) process.exitCode = code;
904
+ else console.log("Cartethyia is up to date.");
905
+ return;
906
+ }
907
+ case "--help":
908
+ case "-h":
909
+ case undefined:
910
+ console.log(HELP);
911
+ return;
912
+ default:
913
+ console.error(`Unknown command: ${command}\n\n${HELP}`);
914
+ process.exitCode = 2;
915
+ }
916
+ }
917
+
918
+ main().catch((error: unknown) => {
919
+ const message = error instanceof Error ? error.message : "unknown error";
920
+ if (process.argv[2] === "tray") reportTrayStartup({ status: "failed", message });
921
+ console.error(`Cartethyia failed: ${message}`);
922
+ process.exitCode = 1;
923
+ });