cartethyia 2.3.1 → 2.3.3

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 (22) hide show
  1. package/dist/dashboard/assets/{BannedPage-BZQBIhxF.js → BannedPage-B8MQBL1Y.js} +1 -1
  2. package/dist/dashboard/assets/{CliToolDetailPage-ChtDDIFA.js → CliToolDetailPage-DAAUVo7w.js} +1 -1
  3. package/dist/dashboard/assets/{CliToolsPage-CfmolGV7.js → CliToolsPage--hLkg7LU.js} +1 -1
  4. package/dist/dashboard/assets/{ConsoleLogPage-ZCwKyxxl.js → ConsoleLogPage-CRaToMW5.js} +1 -1
  5. package/dist/dashboard/assets/{CustomizationPage-WJejmVl_.js → CustomizationPage-CYiaepa1.js} +1 -1
  6. package/dist/dashboard/assets/{HealthEventsModal-URh6wOhb.js → HealthEventsModal-CjsAm6Gm.js} +1 -1
  7. package/dist/dashboard/assets/{LoginPage-C5ib2ujx.js → LoginPage-DVL5oW3v.js} +1 -1
  8. package/dist/dashboard/assets/{OverviewPage-BwOb4p9C.js → OverviewPage-DJ8WM59t.js} +1 -1
  9. package/dist/dashboard/assets/{ProviderDetailPage-w4o_xJlP.js → ProviderDetailPage-CK0q-HYZ.js} +1 -1
  10. package/dist/dashboard/assets/{ProvidersPage-6_WFzAGN.js → ProvidersPage-BdVj-Vab.js} +1 -1
  11. package/dist/dashboard/assets/{ProxyPage-DaGQsvht.js → ProxyPage-CTohaici.js} +1 -1
  12. package/dist/dashboard/assets/{QuotaPage-BPPN7W5b.js → QuotaPage-CkHjJoim.js} +1 -1
  13. package/dist/dashboard/assets/{SetupPage-CDqTXPe7.js → SetupPage-CRahC2bj.js} +1 -1
  14. package/dist/dashboard/assets/{StudioPage-CiGmv9Ju.js → StudioPage-CxDqECuN.js} +1 -1
  15. package/dist/dashboard/assets/{UsagePage-CxrGngrm.js → UsagePage-TzfUYdQD.js} +1 -1
  16. package/dist/dashboard/assets/{entry-C3M9aRLH.js → entry-KOZCZ9VG.js} +3 -3
  17. package/dist/dashboard/assets/{index-BS-k3kIz.js → index-Bj96PK8B.js} +2 -2
  18. package/dist/dashboard/index.html +1 -1
  19. package/package.json +1 -1
  20. package/scripts/cli/carte-launcher.cjs +5 -1
  21. package/scripts/cli/cli-launcher.cjs +32 -11
  22. package/scripts/cli/cli.ts +223 -5
@@ -21,17 +21,34 @@ const startupSignalPath = trayMode
21
21
  ? path.join(os.tmpdir(), `cartethyia-tray-${process.pid}-${randomUUID()}.json`)
22
22
  : null;
23
23
  let spawnError;
24
- const child = spawn("bun", ["run", cliPath, ...process.argv.slice(2)], {
25
- cwd: packageRoot,
26
- detached: trayMode,
27
- stdio: trayMode ? "ignore" : "inherit",
28
- windowsHide: trayMode,
29
- env: {
30
- ...process.env,
31
- CARTETHYIA_CONFIG_DIR: configDir,
32
- ...(trayMode ? { CARTETHYIA_TRAY_STARTUP_SIGNAL: startupSignalPath } : {}),
33
- },
34
- });
24
+ // On Windows, `cmd /c start "" /b` gives bun its own console group so the
25
+ // launching terminal can close without signalling the gateway. Elsewhere
26
+ // `detached: true` is enough. Either way the child is unref'd below so the
27
+ // launcher can exit without taking the gateway down with it.
28
+ const useWindowsDetach = process.platform === "win32" && trayMode;
29
+ const child = useWindowsDetach
30
+ ? spawn("cmd", ["/c", "start", "", "/b", "bun", "run", cliPath, ...process.argv.slice(2)], {
31
+ cwd: packageRoot,
32
+ detached: true,
33
+ stdio: "ignore",
34
+ windowsHide: true,
35
+ env: {
36
+ ...process.env,
37
+ CARTETHYIA_CONFIG_DIR: configDir,
38
+ CARTETHYIA_TRAY_STARTUP_SIGNAL: startupSignalPath,
39
+ },
40
+ })
41
+ : spawn("bun", ["run", cliPath, ...process.argv.slice(2)], {
42
+ cwd: packageRoot,
43
+ detached: trayMode,
44
+ stdio: trayMode ? "ignore" : "inherit",
45
+ windowsHide: trayMode,
46
+ env: {
47
+ ...process.env,
48
+ CARTETHYIA_CONFIG_DIR: configDir,
49
+ ...(trayMode ? { CARTETHYIA_TRAY_STARTUP_SIGNAL: startupSignalPath } : {}),
50
+ },
51
+ });
35
52
 
36
53
  child.once("error", (error) => {
37
54
  spawnError = error;
@@ -61,6 +78,10 @@ if (trayMode) {
61
78
  return;
62
79
  }
63
80
  console.log(`Cartethyia is ready at ${url}`);
81
+ // The gateway child is already detached and unref'd; this Node process is
82
+ // the only thing still holding the launching terminal open. Exit once the
83
+ // URL has been shown so the shell prompt comes back.
84
+ setTimeout(() => process.exit(0), 1_500);
64
85
  };
65
86
  const poll = () => {
66
87
  if (settled) return;
@@ -6,6 +6,7 @@ import { join } from "node:path";
6
6
  import SysTray from "systray2";
7
7
  import { isNewerRelease } from "../../src/console/updates/version-compare";
8
8
  import { PACKAGE_ROOT, resolvePackagePath, resolveUserConfigDir } from "../../src/config/package-paths";
9
+ import { resolvePort } from "../../src/config/runtime";
9
10
  import { MINIMUM_BUN_VERSION, bunVersionAtLeast } from "../shared/env";
10
11
 
11
12
  process.env.CARTETHYIA_CONFIG_DIR ??= resolveUserConfigDir();
@@ -13,13 +14,18 @@ process.env.CARTETHYIA_CONFIG_DIR ??= resolveUserConfigDir();
13
14
  const HELP = `Cartethyia ${MINIMUM_BUN_VERSION}+ (Bun is required)
14
15
 
15
16
  Usage:
16
- carte
17
+ carte start, then show an arrow-key menu (no GUI needed)
18
+ carte --tray start with a system-tray icon instead
17
19
  cartethyia setup [--non-interactive]
18
20
  cartethyia start
19
- cartethyia tray
21
+ cartethyia menu
22
+ cartethyia stop stop a gateway left running in the background
20
23
  cartethyia update
21
24
 
22
- The short carte command starts Cartethyia in the system tray.
25
+ carte starts the gateway and opens a menu driven by the arrow keys and Enter:
26
+ open the console, open settings, hide to the background, check for updates, or
27
+ exit. It needs no desktop environment, so it works over SSH and headless.
28
+ carte --tray uses a system-tray icon instead, which needs a graphical session.
23
29
  Setup explains Lite (embedded SQLite) and Full (external PostgreSQL), then saves
24
30
  configuration under the current user's Cartethyia config directory.
25
31
  `;
@@ -42,6 +48,54 @@ async function loadUserConfig(): Promise<void> {
42
48
  }
43
49
  }
44
50
 
51
+ /**
52
+ * Runtime state for a gateway left running by "Hide to background".
53
+ *
54
+ * Kept out of `.env` on purpose: the drain token authorises a shutdown, so it
55
+ * belongs in a machine-written file the operator never hand-edits and never
56
+ * commits. It is written when the menu detaches and removed when the gateway
57
+ * is drained, so a stale file never outlives the process it refers to.
58
+ */
59
+ const RUNTIME_STATE_FILE = "runtime.json";
60
+
61
+ function writePersistedDrainToken(token: string): void {
62
+ const configDir = process.env.CARTETHYIA_CONFIG_DIR;
63
+ if (!configDir) return;
64
+ try {
65
+ writeFileSync(join(configDir, RUNTIME_STATE_FILE), JSON.stringify({ drainToken: token }), {
66
+ encoding: "utf8",
67
+ mode: 0o600,
68
+ });
69
+ } catch {
70
+ // A read-only config directory costs only the `stop` command, not startup.
71
+ }
72
+ }
73
+
74
+ function readPersistedDrainToken(): string | null {
75
+ const configDir = process.env.CARTETHYIA_CONFIG_DIR;
76
+ if (!configDir) return null;
77
+ const statePath = join(configDir, RUNTIME_STATE_FILE);
78
+ if (!existsSync(statePath)) return null;
79
+ try {
80
+ const parsed: unknown = JSON.parse(readFileSync(statePath, "utf8"));
81
+ if (isRecord(parsed) && typeof parsed["drainToken"] === "string") return parsed["drainToken"];
82
+ } catch {
83
+ // A corrupt or stale state file reads as "no background gateway".
84
+ }
85
+ return null;
86
+ }
87
+
88
+ function clearPersistedDrainToken(): void {
89
+ const configDir = process.env.CARTETHYIA_CONFIG_DIR;
90
+ if (!configDir) return;
91
+ try {
92
+ unlinkSync(join(configDir, RUNTIME_STATE_FILE));
93
+ } catch {
94
+ // Best effort: the drain request already succeeded.
95
+ }
96
+ }
97
+
98
+
45
99
  type TrayStartupSignal =
46
100
  | { readonly status: "ready"; readonly url: string }
47
101
  | { readonly status: "failed"; readonly message: string };
@@ -125,6 +179,60 @@ function openUrl(url: string): Promise<void> {
125
179
  return child.exited.then(() => undefined);
126
180
  }
127
181
 
182
+ /**
183
+ * A single-select menu driven by the arrow keys and Enter, built on stdin raw
184
+ * mode so no menu dependency is added. It only reads keys and returns an
185
+ * index; the caller keeps owning the gateway process.
186
+ *
187
+ * Returns -1 when stdin is not a TTY (piped, CI, or a non-interactive run) so
188
+ * callers can fall back without blocking on input that will never arrive.
189
+ */
190
+ function chooseFromMenu(title: string, options: readonly string[]): Promise<number> {
191
+ const stdin = process.stdin;
192
+ if (!stdin.isTTY) return Promise.resolve(-1);
193
+ const { promise, resolve } = Promise.withResolvers<number>();
194
+ let index = 0;
195
+
196
+ const render = (): void => {
197
+ process.stdout.write(`\u001b[2J\u001b[H${title}\n\n`);
198
+ options.forEach((option, position) => {
199
+ const marker = position === index ? "\u2192" : " ";
200
+ process.stdout.write(` ${marker} ${option}\n`);
201
+ });
202
+ process.stdout.write("\n\u2191\u2193 move \u00b7 Enter choose \u00b7 Esc/q exit\n");
203
+ };
204
+ const cleanup = (choice: number): void => {
205
+ stdin.setRawMode(false);
206
+ stdin.off("keypress", onKey);
207
+ stdin.pause();
208
+ process.stdout.write("\u001b[2J\u001b[H");
209
+ resolve(choice);
210
+ };
211
+ const onKey = (_chunk: unknown, key: { name?: string }): void => {
212
+ if (key.name === "up") {
213
+ index = (index - 1 + options.length) % options.length;
214
+ render();
215
+ return;
216
+ }
217
+ if (key.name === "down") {
218
+ index = (index + 1) % options.length;
219
+ render();
220
+ return;
221
+ }
222
+ if (key.name === "return") {
223
+ cleanup(index);
224
+ return;
225
+ }
226
+ if (key.name === "escape" || key.name === "q") cleanup(-1);
227
+ };
228
+
229
+ stdin.setRawMode(true);
230
+ stdin.resume();
231
+ render();
232
+ stdin.on("keypress", onKey);
233
+ return promise;
234
+ }
235
+
128
236
  function isRecord(value: unknown): value is Record<string, unknown> {
129
237
  return value !== null && typeof value === "object";
130
238
  }
@@ -289,8 +397,112 @@ async function stopTray(tray: SysTray, ready: boolean, exited: boolean): Promise
289
397
  if (!tray.killed) tray.process.kill();
290
398
  }
291
399
 
400
+ /**
401
+ * The default `carte` experience: start the gateway, then offer an arrow-key
402
+ * menu. This needs no desktop environment, so it works over SSH, on a headless
403
+ * box, and on Windows where the tray icon lands in the hidden-icons overflow
404
+ * and is never seen. `carte --tray` opts into the icon instead.
405
+ */
406
+ async function startMenu(): Promise<void> {
407
+ const port = resolvePort();
408
+ const drainToken = randomBytes(32).toString("hex");
409
+ const dashboardUrl = `http://127.0.0.1:${port}/console`;
410
+ const settingsUrl = `${dashboardUrl}/settings`;
411
+ const currentVersion = await readInstalledVersion();
412
+
413
+ const gateway = await startGateway(port, drainToken);
414
+ let stopping = false;
415
+ const shutDown = async (): Promise<void> => {
416
+ if (stopping) return;
417
+ stopping = true;
418
+ await stopGateway(gateway, port, drainToken);
419
+ };
420
+ process.once("SIGINT", () => void shutDown());
421
+ process.once("SIGTERM", () => void shutDown());
422
+
423
+ const latest = await readLatestPublishedVersion();
424
+ const updateLabel =
425
+ latest !== null && isNewerRelease(currentVersion, latest)
426
+ ? `Update to ${latest}`
427
+ : "Check for updates";
428
+
429
+ const options = [
430
+ `Open Console \u00b7 ${dashboardUrl}`,
431
+ "Settings \u00b7 backup & restore, reset password",
432
+ "Hide to background and keep running",
433
+ updateLabel,
434
+ "Exit",
435
+ ] as const;
436
+
437
+ console.log(`Cartethyia ${currentVersion} is running at ${dashboardUrl}`);
438
+ for (;;) {
439
+ const choice = await chooseFromMenu(
440
+ `Cartethyia ${currentVersion} \u2014 running at ${dashboardUrl}`,
441
+ options,
442
+ );
443
+ if (choice === -1 || choice === 4) break;
444
+ if (choice === 0) {
445
+ await openUrl(dashboardUrl);
446
+ continue;
447
+ }
448
+ if (choice === 1) {
449
+ await openUrl(settingsUrl);
450
+ continue;
451
+ }
452
+ if (choice === 2) {
453
+ // The gateway is a detached child, so returning here leaves it running
454
+ // while this process lets go of the terminal. Persist the drain token
455
+ // now, because `cartethyia stop` runs in a later shell and has no other
456
+ // way to reach this process.
457
+ writePersistedDrainToken(drainToken);
458
+ console.log(`Cartethyia keeps running at ${dashboardUrl}.`);
459
+ console.log("Stop it later with: cartethyia stop");
460
+ return;
461
+ }
462
+ if (choice === 3) {
463
+ const code = await npmUpdate();
464
+ console.log(code === 0 ? "Update finished." : "Update failed.");
465
+ }
466
+ }
467
+ clearPersistedDrainToken();
468
+ await shutDown();
469
+ }
470
+
471
+ /**
472
+ * Stops a gateway left running by "Hide to background". That process is not a
473
+ * child of this one, so it cannot be signalled directly — the only handle is
474
+ * the loopback drain endpoint, which needs the drain token the start wrote.
475
+ */
476
+ async function stopRunningGateway(): Promise<number> {
477
+ const port = resolvePort();
478
+ const token = readPersistedDrainToken();
479
+ if (token === null) {
480
+ console.error(
481
+ "No background Cartethyia gateway was found for this install. Start one with `carte`, or stop a foreground run with Ctrl-C.",
482
+ );
483
+ return 1;
484
+ }
485
+ try {
486
+ const response = await fetch(`http://127.0.0.1:${port}/admin/drain`, {
487
+ method: "POST",
488
+ headers: { "x-drain-token": token },
489
+ signal: AbortSignal.timeout(3_000),
490
+ });
491
+ if (response.status !== 202) {
492
+ console.error(`The gateway refused the drain request (HTTP ${response.status}).`);
493
+ return 1;
494
+ }
495
+ } catch {
496
+ console.error("No gateway is listening on the configured port. It may already be stopped.");
497
+ return 1;
498
+ }
499
+ clearPersistedDrainToken();
500
+ console.log("Cartethyia is draining and will stop once in-flight requests finish.");
501
+ return 0;
502
+ }
503
+
504
+
292
505
  async function startTray(): Promise<void> {
293
- const { resolvePort } = await import("../../src/config/runtime");
294
506
  const port = resolvePort();
295
507
  const drainToken = randomBytes(32).toString("hex");
296
508
  const currentVersion = await readInstalledVersion();
@@ -535,7 +747,7 @@ async function startTray(): Promise<void> {
535
747
  async function main(): Promise<void> {
536
748
  assertBunVersion();
537
749
  const [command] = process.argv.slice(2);
538
- if (command === "setup" || command === "start" || command === "tray") {
750
+ if (command === "setup" || command === "start" || command === "tray" || command === "menu" || command === "stop") {
539
751
  await loadUserConfig();
540
752
  }
541
753
 
@@ -548,9 +760,15 @@ async function main(): Promise<void> {
548
760
  case "start":
549
761
  await import("../../src/main");
550
762
  return;
763
+ case "menu":
764
+ await startMenu();
765
+ return;
551
766
  case "tray":
552
767
  await startTray();
553
768
  return;
769
+ case "stop":
770
+ process.exitCode = await stopRunningGateway();
771
+ return;
554
772
  case "update": {
555
773
  const code = await npmUpdate();
556
774
  if (code !== 0) process.exitCode = code;