cartethyia 2.3.2 → 2.3.4

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 (23) hide show
  1. package/dist/dashboard/assets/{BannedPage-BprrKF3t.js → BannedPage-Dc8V-nxc.js} +1 -1
  2. package/dist/dashboard/assets/{CliToolDetailPage-CeeIxvCm.js → CliToolDetailPage-ifAh7wbK.js} +1 -1
  3. package/dist/dashboard/assets/{CliToolsPage-BQjMUpJJ.js → CliToolsPage-2A1c_dFb.js} +1 -1
  4. package/dist/dashboard/assets/{ConsoleLogPage-CAzWm-tU.js → ConsoleLogPage-FfxY8_yn.js} +1 -1
  5. package/dist/dashboard/assets/{CustomizationPage-B3SKxD3y.js → CustomizationPage-CZ5H40Ha.js} +1 -1
  6. package/dist/dashboard/assets/{HealthEventsModal-DQNKf-5H.js → HealthEventsModal-CZKQak_s.js} +1 -1
  7. package/dist/dashboard/assets/{LoginPage-OXNbMIZC.js → LoginPage-sqFYY4uv.js} +1 -1
  8. package/dist/dashboard/assets/{OverviewPage-UKI1t9nU.js → OverviewPage-BOZJaauG.js} +1 -1
  9. package/dist/dashboard/assets/{ProviderDetailPage-Bh_MZ03h.js → ProviderDetailPage-CNUCsNFg.js} +1 -1
  10. package/dist/dashboard/assets/{ProvidersPage-sP8nDPZN.js → ProvidersPage-CsXoemJ1.js} +1 -1
  11. package/dist/dashboard/assets/{ProxyPage-Ci1Ps9K3.js → ProxyPage-DvRt3aZP.js} +1 -1
  12. package/dist/dashboard/assets/{QuotaPage-A1jKKb0S.js → QuotaPage-DNYJWw7s.js} +1 -1
  13. package/dist/dashboard/assets/{SetupPage-Ce4ldWt7.js → SetupPage-Cz1DWkcr.js} +1 -1
  14. package/dist/dashboard/assets/{StudioPage-CFDyo2dT.js → StudioPage-CsKD9sy-.js} +1 -1
  15. package/dist/dashboard/assets/{UsagePage-BLCXYWis.js → UsagePage-_Zu6460J.js} +1 -1
  16. package/dist/dashboard/assets/{entry-B2Cv6U9r.js → entry-COuKgXuV.js} +3 -3
  17. package/dist/dashboard/assets/{index-C7EcMumQ.js → index-CF94-Gob.js} +2 -2
  18. package/dist/dashboard/index.html +1 -1
  19. package/package.json +1 -1
  20. package/scripts/cli/cli.ts +60 -107
  21. package/scripts/cli/menu.ts +53 -0
  22. package/scripts/cli/runtime-state.ts +62 -0
  23. package/scripts/cli/version.ts +66 -0
@@ -8,6 +8,13 @@ import { isNewerRelease } from "../../src/console/updates/version-compare";
8
8
  import { PACKAGE_ROOT, resolvePackagePath, resolveUserConfigDir } from "../../src/config/package-paths";
9
9
  import { resolvePort } from "../../src/config/runtime";
10
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";
11
18
 
12
19
  process.env.CARTETHYIA_CONFIG_DIR ??= resolveUserConfigDir();
13
20
 
@@ -19,7 +26,7 @@ Usage:
19
26
  cartethyia setup [--non-interactive]
20
27
  cartethyia start
21
28
  cartethyia menu
22
- cartethyia tray
29
+ cartethyia stop stop a gateway left running in the background
23
30
  cartethyia update
24
31
 
25
32
  carte starts the gateway and opens a menu driven by the arrow keys and Enter:
@@ -48,6 +55,7 @@ async function loadUserConfig(): Promise<void> {
48
55
  }
49
56
  }
50
57
 
58
+
51
59
  type TrayStartupSignal =
52
60
  | { readonly status: "ready"; readonly url: string }
53
61
  | { readonly status: "failed"; readonly message: string };
@@ -93,29 +101,6 @@ async function waitForTrayStartupSignal(
93
101
  throw new Error("Cartethyia did not finish tray and gateway startup within 45 seconds");
94
102
  }
95
103
 
96
- function npmUpdate(): Promise<number> {
97
- return new Promise((resolveExit) => {
98
- const command = process.platform === "win32"
99
- ? "cmd.exe"
100
- : "npm";
101
- const args = process.platform === "win32"
102
- ? ["/d", "/s", "/c", "npm install --global cartethyia@latest"]
103
- : ["install", "--global", "cartethyia@latest"];
104
- let child;
105
- try {
106
- child = spawn(command, args, { cwd: tmpdir(), stdio: "inherit" });
107
- } catch {
108
- console.error("Could not start npm. Install Node.js with npm, then retry the update.");
109
- resolveExit(1);
110
- return;
111
- }
112
- child.once("error", () => {
113
- console.error("Could not start npm. Install Node.js with npm, then retry the update.");
114
- resolveExit(1);
115
- });
116
- child.once("exit", (code) => resolveExit(code ?? 1));
117
- });
118
- }
119
104
 
120
105
  function openUrl(url: string): Promise<void> {
121
106
  const command = process.platform === "win32"
@@ -131,84 +116,6 @@ function openUrl(url: string): Promise<void> {
131
116
  return child.exited.then(() => undefined);
132
117
  }
133
118
 
134
- /**
135
- * A single-select menu driven by the arrow keys and Enter, built on stdin raw
136
- * mode so no menu dependency is added. It only reads keys and returns an
137
- * index; the caller keeps owning the gateway process.
138
- *
139
- * Returns -1 when stdin is not a TTY (piped, CI, or a non-interactive run) so
140
- * callers can fall back without blocking on input that will never arrive.
141
- */
142
- function chooseFromMenu(title: string, options: readonly string[]): Promise<number> {
143
- const stdin = process.stdin;
144
- if (!stdin.isTTY) return Promise.resolve(-1);
145
- const { promise, resolve } = Promise.withResolvers<number>();
146
- let index = 0;
147
-
148
- const render = (): void => {
149
- process.stdout.write(`\u001b[2J\u001b[H${title}\n\n`);
150
- options.forEach((option, position) => {
151
- const marker = position === index ? "\u2192" : " ";
152
- process.stdout.write(` ${marker} ${option}\n`);
153
- });
154
- process.stdout.write("\n\u2191\u2193 move \u00b7 Enter choose \u00b7 Esc/q exit\n");
155
- };
156
- const cleanup = (choice: number): void => {
157
- stdin.setRawMode(false);
158
- stdin.off("keypress", onKey);
159
- stdin.pause();
160
- process.stdout.write("\u001b[2J\u001b[H");
161
- resolve(choice);
162
- };
163
- const onKey = (_chunk: unknown, key: { name?: string }): void => {
164
- if (key.name === "up") {
165
- index = (index - 1 + options.length) % options.length;
166
- render();
167
- return;
168
- }
169
- if (key.name === "down") {
170
- index = (index + 1) % options.length;
171
- render();
172
- return;
173
- }
174
- if (key.name === "return") {
175
- cleanup(index);
176
- return;
177
- }
178
- if (key.name === "escape" || key.name === "q") cleanup(-1);
179
- };
180
-
181
- stdin.setRawMode(true);
182
- stdin.resume();
183
- render();
184
- stdin.on("keypress", onKey);
185
- return promise;
186
- }
187
-
188
- function isRecord(value: unknown): value is Record<string, unknown> {
189
- return value !== null && typeof value === "object";
190
- }
191
-
192
- async function readInstalledVersion(): Promise<string> {
193
- const metadata: unknown = await Bun.file(resolvePackagePath("package.json")).json();
194
- if (!isRecord(metadata) || typeof metadata["version"] !== "string") {
195
- throw new Error("Cartethyia package version is unavailable");
196
- }
197
- return metadata["version"];
198
- }
199
-
200
- async function readLatestPublishedVersion(): Promise<string | null> {
201
- try {
202
- const response = await fetch("https://registry.npmjs.org/cartethyia/latest", {
203
- signal: AbortSignal.timeout(4_000),
204
- });
205
- if (!response.ok) return null;
206
- const metadata: unknown = await response.json();
207
- return isRecord(metadata) && typeof metadata["version"] === "string" ? metadata["version"] : null;
208
- } catch {
209
- return null;
210
- }
211
- }
212
119
 
213
120
  function launchUpdatedTray(signalPath: string): ChildProcess {
214
121
  const env = { ...process.env, CARTETHYIA_TRAY_STARTUP_SIGNAL: signalPath };
@@ -227,7 +134,11 @@ async function startGateway(port: number, drainToken: string): Promise<ReturnTyp
227
134
  {
228
135
  cwd: PACKAGE_ROOT,
229
136
  env: { ...process.env, CARTETHYIA_DRAIN_TOKEN: drainToken },
230
- stdin: "inherit",
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",
231
142
  stdout: "inherit",
232
143
  stderr: "inherit",
233
144
  },
@@ -402,9 +313,13 @@ async function startMenu(): Promise<void> {
402
313
  continue;
403
314
  }
404
315
  if (choice === 2) {
405
- // The gateway is already a detached child, so returning here leaves it
406
- // running while this process lets go of the terminal.
407
- console.log(`Cartethyia keeps running at ${dashboardUrl}. Stop it with: cartethyia stop`);
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");
408
323
  return;
409
324
  }
410
325
  if (choice === 3) {
@@ -412,9 +327,44 @@ async function startMenu(): Promise<void> {
412
327
  console.log(code === 0 ? "Update finished." : "Update failed.");
413
328
  }
414
329
  }
330
+ clearPersistedDrainToken();
415
331
  await shutDown();
416
332
  }
417
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
+
418
368
  async function startTray(): Promise<void> {
419
369
  const port = resolvePort();
420
370
  const drainToken = randomBytes(32).toString("hex");
@@ -660,7 +610,7 @@ async function startTray(): Promise<void> {
660
610
  async function main(): Promise<void> {
661
611
  assertBunVersion();
662
612
  const [command] = process.argv.slice(2);
663
- if (command === "setup" || command === "start" || command === "tray" || command === "menu") {
613
+ if (command === "setup" || command === "start" || command === "tray" || command === "menu" || command === "stop") {
664
614
  await loadUserConfig();
665
615
  }
666
616
 
@@ -679,6 +629,9 @@ async function main(): Promise<void> {
679
629
  case "tray":
680
630
  await startTray();
681
631
  return;
632
+ case "stop":
633
+ process.exitCode = await stopRunningGateway();
634
+ return;
682
635
  case "update": {
683
636
  const code = await npmUpdate();
684
637
  if (code !== 0) process.exitCode = code;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * A single-select menu driven by the arrow keys and Enter, built on stdin raw
3
+ * mode so no menu dependency is added. It only reads keys and returns an
4
+ * index; the caller keeps owning the gateway process.
5
+ *
6
+ * Returns -1 when stdin is not a TTY (piped, CI, or a non-interactive run) so
7
+ * callers can fall back without blocking on input that will never arrive.
8
+ */
9
+ export function chooseFromMenu(title: string, options: readonly string[]): Promise<number> {
10
+ const stdin = process.stdin;
11
+ if (!stdin.isTTY) return Promise.resolve(-1);
12
+ const { promise, resolve } = Promise.withResolvers<number>();
13
+ let index = 0;
14
+
15
+ const render = (): void => {
16
+ process.stdout.write(`\u001b[2J\u001b[H${title}\n\n`);
17
+ options.forEach((option, position) => {
18
+ const marker = position === index ? "\u2192" : " ";
19
+ process.stdout.write(` ${marker} ${option}\n`);
20
+ });
21
+ process.stdout.write("\n\u2191\u2193 move \u00b7 Enter choose \u00b7 Esc/q exit\n");
22
+ };
23
+ const cleanup = (choice: number): void => {
24
+ stdin.setRawMode(false);
25
+ stdin.off("keypress", onKey);
26
+ stdin.pause();
27
+ process.stdout.write("\u001b[2J\u001b[H");
28
+ resolve(choice);
29
+ };
30
+ const onKey = (_chunk: unknown, key: { name?: string }): void => {
31
+ if (key.name === "up") {
32
+ index = (index - 1 + options.length) % options.length;
33
+ render();
34
+ return;
35
+ }
36
+ if (key.name === "down") {
37
+ index = (index + 1) % options.length;
38
+ render();
39
+ return;
40
+ }
41
+ if (key.name === "return") {
42
+ cleanup(index);
43
+ return;
44
+ }
45
+ if (key.name === "escape" || key.name === "q") cleanup(-1);
46
+ };
47
+
48
+ stdin.setRawMode(true);
49
+ stdin.resume();
50
+ render();
51
+ stdin.on("keypress", onKey);
52
+ return promise;
53
+ }
@@ -0,0 +1,62 @@
1
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * Runtime state for a gateway left running by "Hide to background".
6
+ *
7
+ * Kept out of `.env` on purpose: the drain token authorises a shutdown, so it
8
+ * belongs in a machine-written file the operator never hand-edits and never
9
+ * commits. It is written when the menu detaches and removed when the gateway
10
+ * is drained, so a stale file never outlives the process it refers to.
11
+ */
12
+ const RUNTIME_STATE_FILE = "runtime.json";
13
+
14
+ function statePath(): string | null {
15
+ const configDir = process.env.CARTETHYIA_CONFIG_DIR;
16
+ return configDir ? join(configDir, RUNTIME_STATE_FILE) : null;
17
+ }
18
+
19
+ /** Persists the drain token so a later `cartethyia stop` can reach the gateway. */
20
+ export function writePersistedDrainToken(token: string): void {
21
+ const path = statePath();
22
+ if (path === null) return;
23
+ try {
24
+ writeFileSync(path, JSON.stringify({ drainToken: token }), {
25
+ encoding: "utf8",
26
+ mode: 0o600,
27
+ });
28
+ } catch {
29
+ // A read-only config directory costs only the `stop` command, not startup.
30
+ }
31
+ }
32
+
33
+ /** Returns the token for a background gateway, or null when there is none. */
34
+ export function readPersistedDrainToken(): string | null {
35
+ const path = statePath();
36
+ if (path === null || !existsSync(path)) return null;
37
+ try {
38
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
39
+ if (
40
+ parsed !== null &&
41
+ typeof parsed === "object" &&
42
+ "drainToken" in parsed &&
43
+ typeof parsed.drainToken === "string"
44
+ ) {
45
+ return parsed.drainToken;
46
+ }
47
+ } catch {
48
+ // A corrupt or stale state file reads as "no background gateway".
49
+ }
50
+ return null;
51
+ }
52
+
53
+ /** Removes the token once the drain has been accepted. */
54
+ export function clearPersistedDrainToken(): void {
55
+ const path = statePath();
56
+ if (path === null) return;
57
+ try {
58
+ unlinkSync(path);
59
+ } catch {
60
+ // Best effort: the drain request already succeeded.
61
+ }
62
+ }
@@ -0,0 +1,66 @@
1
+ import { spawn } from "node:child_process";
2
+ import { tmpdir } from "node:os";
3
+ import { resolvePackagePath } from "../../src/config/package-paths";
4
+
5
+ /** Reads the version from the installed package manifest. */
6
+ export async function readInstalledVersion(): Promise<string> {
7
+ const metadata: unknown = await Bun.file(resolvePackagePath("package.json")).json();
8
+ if (
9
+ metadata === null ||
10
+ typeof metadata !== "object" ||
11
+ !("version" in metadata) ||
12
+ typeof metadata.version !== "string"
13
+ ) {
14
+ throw new Error("Cartethyia package version is unavailable");
15
+ }
16
+ return metadata.version;
17
+ }
18
+
19
+ /** Asks the npm registry for the newest published version, or null on failure. */
20
+ export async function readLatestPublishedVersion(): Promise<string | null> {
21
+ try {
22
+ const response = await fetch("https://registry.npmjs.org/cartethyia/latest", {
23
+ signal: AbortSignal.timeout(4_000),
24
+ });
25
+ if (!response.ok) return null;
26
+ const metadata: unknown = await response.json();
27
+ if (
28
+ metadata !== null &&
29
+ typeof metadata === "object" &&
30
+ "version" in metadata &&
31
+ typeof metadata.version === "string"
32
+ ) {
33
+ return metadata.version;
34
+ }
35
+ return null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Runs `npm install --global cartethyia@latest`. On Windows npm is a shell
43
+ * script, so it has to go through cmd.exe rather than being spawned directly.
44
+ */
45
+ export function npmUpdate(): Promise<number> {
46
+ return new Promise((resolveExit) => {
47
+ const command = process.platform === "win32" ? "cmd.exe" : "npm";
48
+ const args =
49
+ process.platform === "win32"
50
+ ? ["/d", "/s", "/c", "npm install --global cartethyia@latest"]
51
+ : ["install", "--global", "cartethyia@latest"];
52
+ let child;
53
+ try {
54
+ child = spawn(command, args, { cwd: tmpdir(), stdio: "inherit" });
55
+ } catch {
56
+ console.error("Could not start npm. Install Node.js with npm, then retry the update.");
57
+ resolveExit(1);
58
+ return;
59
+ }
60
+ child.once("error", () => {
61
+ console.error("Could not start npm. Install Node.js with npm, then retry the update.");
62
+ resolveExit(1);
63
+ });
64
+ child.once("exit", (code) => resolveExit(code ?? 1));
65
+ });
66
+ }