cartethyia 2.5.8 → 2.5.10

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 (157) hide show
  1. package/.env.example +7 -42
  2. package/README.md +11 -10
  3. package/dist/dashboard/assets/{BannedPage-BKAI54IA.js → BannedPage-Cd30Z0b9.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-Cd30Z0b9.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolDetailPage-CkGvxrHu.js → CliToolDetailPage-wf5u-ww6.js} +1 -1
  6. package/dist/dashboard/assets/CliToolDetailPage-wf5u-ww6.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-Cj8MYpeY.js → CliToolsPage-DykPvuck.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-DykPvuck.js.gz +0 -0
  9. package/dist/dashboard/assets/{CombosPage-BDXBEbWT.js → CombosPage-DwvSD3vs.js} +1 -1
  10. package/dist/dashboard/assets/CombosPage-DwvSD3vs.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-49KH4z3l.js → ConsoleLogPage-CIr7KDex.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-CIr7KDex.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-EHjBmNBZ.js → CustomizationPage-82X0DDdH.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-82X0DDdH.js.gz +0 -0
  15. package/dist/dashboard/assets/{HealthEventsModal-ByUTtj7j.js → HealthEventsModal-BYPucTTn.js} +1 -1
  16. package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-CYSCKT8S.js → LoginPage-CS5mPuVF.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-CS5mPuVF.js.gz +0 -0
  19. package/dist/dashboard/assets/{ModelPicker-C7pDb41R.js → ModelPicker-C0UCUsVJ.js} +1 -1
  20. package/dist/dashboard/assets/ModelPicker-C0UCUsVJ.js.gz +0 -0
  21. package/dist/dashboard/assets/{OverviewPage-Bu3iqBMm.js → OverviewPage-Dk_ew3YU.js} +1 -1
  22. package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js.gz +0 -0
  23. package/dist/dashboard/assets/{ProviderDetailPage-FHdgmgjU.js → ProviderDetailPage-CAiRn2Q0.js} +1 -1
  24. package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js.gz +0 -0
  25. package/dist/dashboard/assets/{ProvidersPage-CCvYS9CF.js → ProvidersPage-vdja5ZPK.js} +1 -1
  26. package/dist/dashboard/assets/ProvidersPage-vdja5ZPK.js.gz +0 -0
  27. package/dist/dashboard/assets/{ProxyPage-HOl9cjxl.js → ProxyPage-BB4CNm4-.js} +1 -1
  28. package/dist/dashboard/assets/ProxyPage-BB4CNm4-.js.gz +0 -0
  29. package/dist/dashboard/assets/{QuotaPage-MUGzIpTK.js → QuotaPage-FEc4iNyq.js} +1 -1
  30. package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js.gz +0 -0
  31. package/dist/dashboard/assets/{SettingsPage-CTdF5TCa.js → SettingsPage-Dlfi5L4N.js} +1 -1
  32. package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-Bra7rkKx.js → SetupPage-FadUOl9S.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-FadUOl9S.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-BRscd-vl.js → SortableList-Cfy6_Khv.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-Cfy6_Khv.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-BeevnFtM.js → StudioPage-CM92fgJf.js} +1 -1
  38. package/dist/dashboard/assets/StudioPage-CM92fgJf.js.gz +0 -0
  39. package/dist/dashboard/assets/{UsagePage-BWik9fpu.js → UsagePage-BT4MyQeT.js} +1 -1
  40. package/dist/dashboard/assets/UsagePage-BT4MyQeT.js.gz +0 -0
  41. package/dist/dashboard/assets/{app-BIt446yE.js → app-B7pVRhJ1.js} +1 -1
  42. package/dist/dashboard/assets/{app-BIt446yE.js.gz → app-B7pVRhJ1.js.gz} +0 -0
  43. package/dist/dashboard/assets/{badge-CR72dasT.js → badge-Bvyr7D0u.js} +1 -1
  44. package/dist/dashboard/assets/badge-Bvyr7D0u.js.gz +0 -0
  45. package/dist/dashboard/assets/{banner-CI9_eSfO.js → banner-DIvKAZih.js} +1 -1
  46. package/dist/dashboard/assets/banner-DIvKAZih.js.gz +0 -0
  47. package/dist/dashboard/assets/{card-DS7CQEsO.js → card-D5_gYIS_.js} +1 -1
  48. package/dist/dashboard/assets/card-D5_gYIS_.js.gz +0 -0
  49. package/dist/dashboard/assets/{clipboard-button-611pSsaX.js → clipboard-button-B6vDBtbm.js} +1 -1
  50. package/dist/dashboard/assets/clipboard-button-B6vDBtbm.js.gz +0 -0
  51. package/dist/dashboard/assets/{dialog-k37exjNt.js → dialog-CpjrgdwJ.js} +1 -1
  52. package/dist/dashboard/assets/dialog-CpjrgdwJ.js.gz +0 -0
  53. package/dist/dashboard/assets/{drawer-CX7N8ucR.js → drawer-BWmt3b5y.js} +1 -1
  54. package/dist/dashboard/assets/drawer-BWmt3b5y.js.gz +0 -0
  55. package/dist/dashboard/assets/{entry-B9Xvtnfj.js → entry-B-gvimrg.js} +1 -1
  56. package/dist/dashboard/assets/entry-B-gvimrg.js.gz +0 -0
  57. package/dist/dashboard/assets/{entry-CTP8AyiS.js → entry-C_oakV3z.js} +1 -1
  58. package/dist/dashboard/assets/entry-C_oakV3z.js.gz +0 -0
  59. package/dist/dashboard/assets/{entry-BqZ5vrM7.js → entry-Dj0cHfEc.js} +3 -3
  60. package/dist/dashboard/assets/entry-Dj0cHfEc.js.gz +0 -0
  61. package/dist/dashboard/assets/{github-badge-sltk1qUi.js → github-badge-RRHHWGxh.js} +1 -1
  62. package/dist/dashboard/assets/github-badge-RRHHWGxh.js.gz +0 -0
  63. package/dist/dashboard/assets/{index-9ARd3FIs.js → index-Bhey_rg_.js} +2 -2
  64. package/dist/dashboard/assets/index-Bhey_rg_.js.gz +0 -0
  65. package/dist/dashboard/assets/{inline-BhY2sSnO.js → inline-C2_EQ5Uf.js} +1 -1
  66. package/dist/dashboard/assets/inline-C2_EQ5Uf.js.gz +0 -0
  67. package/dist/dashboard/assets/{input-V1umFiBk.js → input-BMRlQ450.js} +1 -1
  68. package/dist/dashboard/assets/input-BMRlQ450.js.gz +0 -0
  69. package/dist/dashboard/assets/{layout-BBOYycyS.js → layout-XR5xP8ew.js} +1 -1
  70. package/dist/dashboard/assets/layout-XR5xP8ew.js.gz +0 -0
  71. package/dist/dashboard/assets/{list-row-1zzZYM0B.js → list-row-Cw07dioc.js} +1 -1
  72. package/dist/dashboard/assets/list-row-Cw07dioc.js.gz +0 -0
  73. package/dist/dashboard/assets/{numbers-DkfMR5Qs.js → numbers-DrdA9oSM.js} +1 -1
  74. package/dist/dashboard/assets/numbers-DrdA9oSM.js.gz +0 -0
  75. package/dist/dashboard/assets/{select-D_BBhBEF.js → select-CxVVB__N.js} +1 -1
  76. package/dist/dashboard/assets/select-CxVVB__N.js.gz +0 -0
  77. package/dist/dashboard/assets/{stack-BHTfXdz_.js → stack-uPOwM9Hs.js} +1 -1
  78. package/dist/dashboard/assets/stack-uPOwM9Hs.js.gz +0 -0
  79. package/dist/dashboard/assets/{state-mPDRlQkl.js → state-D1Yz79iL.js} +1 -1
  80. package/dist/dashboard/assets/state-D1Yz79iL.js.gz +0 -0
  81. package/dist/dashboard/assets/{switch-DOmJxg7H.js → switch-sevsRrRo.js} +1 -1
  82. package/dist/dashboard/assets/switch-sevsRrRo.js.gz +0 -0
  83. package/dist/dashboard/index.html +1 -1
  84. package/dist/dashboard/index.html.gz +0 -0
  85. package/dist/dashboard/precompressed-assets.json +40 -40
  86. package/package.json +1 -3
  87. package/scripts/cli/admin.ts +3 -0
  88. package/scripts/cli/console-menu.ts +299 -275
  89. package/scripts/cli/doctor.ts +1 -1
  90. package/scripts/cli/gateway.ts +1 -2
  91. package/scripts/{health.ts → cli/health.ts} +4 -4
  92. package/scripts/cli/main.ts +3 -0
  93. package/scripts/cli/prompt.ts +137 -107
  94. package/scripts/cli/start.ts +3 -0
  95. package/scripts/cli/tray.ts +167 -208
  96. package/src/config/package-paths.ts +24 -0
  97. package/src/console/backup/nine-router.ts +43 -9
  98. package/src/console/remote-support/service.ts +270 -114
  99. package/src/console/routing/pools/store.ts +1 -1
  100. package/src/console/updates/repository-updates.ts +40 -39
  101. package/src/observability/event-day-index.ts +1 -2
  102. package/src/protocol/registry.ts +2 -0
  103. package/src/providers/default-registry.ts +4 -0
  104. package/src/providers/discovery/openrouter-free-roster.ts +0 -1
  105. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +3 -2
  106. package/src/providers/integrations/buddy/capabilities.ts +4 -0
  107. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +2 -1
  108. package/src/providers/integrations/github/github.ts +3 -1
  109. package/src/providers/integrations/opencode/provider.ts +2 -3
  110. package/src/providers/integrations/trae/trae-headers.ts +17 -48
  111. package/src/providers/integrations/trae/trae.ts +22 -24
  112. package/src/providers/operations/provider-credential-service.ts +29 -3
  113. package/src/providers/provider-probe.ts +3 -0
  114. package/dist/dashboard/assets/BannedPage-BKAI54IA.js.gz +0 -0
  115. package/dist/dashboard/assets/CliToolDetailPage-CkGvxrHu.js.gz +0 -0
  116. package/dist/dashboard/assets/CliToolsPage-Cj8MYpeY.js.gz +0 -0
  117. package/dist/dashboard/assets/CombosPage-BDXBEbWT.js.gz +0 -0
  118. package/dist/dashboard/assets/ConsoleLogPage-49KH4z3l.js.gz +0 -0
  119. package/dist/dashboard/assets/CustomizationPage-EHjBmNBZ.js.gz +0 -0
  120. package/dist/dashboard/assets/HealthEventsModal-ByUTtj7j.js.gz +0 -0
  121. package/dist/dashboard/assets/LoginPage-CYSCKT8S.js.gz +0 -0
  122. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
  123. package/dist/dashboard/assets/OverviewPage-Bu3iqBMm.js.gz +0 -0
  124. package/dist/dashboard/assets/ProviderDetailPage-FHdgmgjU.js.gz +0 -0
  125. package/dist/dashboard/assets/ProvidersPage-CCvYS9CF.js.gz +0 -0
  126. package/dist/dashboard/assets/ProxyPage-HOl9cjxl.js.gz +0 -0
  127. package/dist/dashboard/assets/QuotaPage-MUGzIpTK.js.gz +0 -0
  128. package/dist/dashboard/assets/SettingsPage-CTdF5TCa.js.gz +0 -0
  129. package/dist/dashboard/assets/SetupPage-Bra7rkKx.js.gz +0 -0
  130. package/dist/dashboard/assets/SortableList-BRscd-vl.js.gz +0 -0
  131. package/dist/dashboard/assets/StudioPage-BeevnFtM.js.gz +0 -0
  132. package/dist/dashboard/assets/UsagePage-BWik9fpu.js.gz +0 -0
  133. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  134. package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
  135. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  136. package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
  137. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  138. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  139. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  140. package/dist/dashboard/assets/entry-BqZ5vrM7.js.gz +0 -0
  141. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  142. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  143. package/dist/dashboard/assets/index-9ARd3FIs.js.gz +0 -0
  144. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  145. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  146. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  147. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  148. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  149. package/dist/dashboard/assets/select-D_BBhBEF.js.gz +0 -0
  150. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  151. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  152. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  153. package/scripts/build/aot.ts +0 -143
  154. package/scripts/build/build.ts +0 -24
  155. package/scripts/build/compile.ts +0 -88
  156. package/scripts/build/restart.ts +0 -120
  157. package/scripts/build/start.ts +0 -25
@@ -1,107 +1,137 @@
1
- import Enquirer from "enquirer";
2
-
3
- /**
4
- * One row in a menu: an action the caller can run, or a rule between groups.
5
- *
6
- * Rules are rendered by `enquirer`'s own separator role and are never
7
- * selectable, so the caller's action list stays a plain array indexed by the
8
- * rows the operator can actually pick.
9
- */
10
- export type MenuRow =
11
- | { readonly kind: "action"; readonly label: string; readonly hint?: string }
12
- | { readonly kind: "rule" };
13
-
14
- /**
15
- * A single-select menu driven by the arrow keys and Enter.
16
- *
17
- * Built on `enquirer` rather than a hand-rolled raw-mode listener: a raw
18
- * `process.stdin.on("keypress")` handler never fires unless something first
19
- * calls `readline.emitKeypressEvents`, and the menu rendered as inert text for
20
- * exactly that reason. `enquirer` owns the keypress plumbing, including Ctrl-C.
21
- *
22
- * `header` is printed once here and never handed to `enquirer`: enquirer
23
- * redraws its whole frame on every keypress, so a header inside the prompt
24
- * repeated once per keystroke and stacked the wordmark down the terminal.
25
- *
26
- * Returns -1 when stdin is not a TTY (piped, CI, or a non-interactive run) or
27
- * when the operator cancels, so the caller can drain and exit either way.
28
- */
29
- export async function chooseFromMenu(
30
- header: string | readonly string[],
31
- rows: readonly MenuRow[],
32
- ): Promise<number> {
33
- if (!process.stdin.isTTY) return -1;
34
-
35
- const headerLines = typeof header === "string" ? [header] : header;
36
- process.stdout.write("\u001b[2J\u001b[H");
37
- for (const line of headerLines) process.stdout.write(`${line}\n`);
38
- process.stdout.write("\n");
39
-
40
- // Enquirer indexes the rows it renders, rules included, so the action index
41
- // has to be recovered from the label rather than from the position.
42
- const labels = rows.flatMap((row) => (row.kind === "action" ? [row.label] : []));
43
-
44
-
45
- try {
46
- const answer = await Enquirer.prompt<{ choice: string }>({
47
- type: "select",
48
- name: "choice",
49
- message: "Select an action",
50
- choices: rows.map((row) =>
51
- row.kind === "rule"
52
- ? { name: "rule", message: "", role: "separator" }
53
- : {
54
- name: row.label,
55
- message: row.label,
56
- ...(row.hint === undefined ? {} : { hint: row.hint }),
57
- },
58
- ),
59
- initial: 0,
60
- });
61
- return labels.indexOf(answer.choice);
62
- } catch {
63
- // Enquirer rejects on Esc and on its own Ctrl-C handling. Return -1 so the
64
- // caller drains the gateway rather than leaving a detached process behind.
65
- process.stdout.write("\u001b[2J\u001b[H");
66
- return -1;
67
- }
68
- }
69
-
70
- /**
71
- * Asks the operator to type an exact word before a destructive action runs.
72
- *
73
- * Enter alone must not be enough: the reset deletes every console credential,
74
- * so the operator types the word rather than confirming a highlight. Returns
75
- * false on cancel, on a mismatch, or when stdin is not a TTY.
76
- */
77
- export async function confirmByTyping(word: string, message: string): Promise<boolean> {
78
- if (!process.stdin.isTTY) return false;
79
- try {
80
- const answer = await Enquirer.prompt<{ confirmation: string }>({
81
- type: "input",
82
- name: "confirmation",
83
- message: `${message} (type ${word})`,
84
- validate: (value: string) =>
85
- value.trim() === word ? true : `type ${word} exactly to confirm, or Ctrl-C to cancel`,
86
- });
87
- return answer.confirmation.trim() === word;
88
- } catch {
89
- return false;
90
- }
91
- }
92
-
93
- /** A yes/no gate for the step before the typed confirmation. */
94
- export async function confirmStep(message: string): Promise<boolean> {
95
- if (!process.stdin.isTTY) return false;
96
- try {
97
- const answer = await Enquirer.prompt<{ proceed: boolean }>({
98
- type: "confirm",
99
- name: "proceed",
100
- message,
101
- initial: false,
102
- });
103
- return answer.proceed;
104
- } catch {
105
- return false;
106
- }
107
- }
1
+ import Enquirer from "enquirer";
2
+
3
+ /**
4
+ * One row in a menu: an action the caller can run, or a rule between groups.
5
+ *
6
+ * Rules are rendered by `enquirer`'s own separator role and are never
7
+ * selectable, so the caller's action list stays a plain array indexed by the
8
+ * rows the operator can actually pick.
9
+ */
10
+ export type MenuRow =
11
+ | { readonly kind: "action"; readonly label: string; readonly hint?: string }
12
+ | { readonly kind: "rule" };
13
+
14
+ /**
15
+ * A single-select menu driven by the arrow keys and Enter.
16
+ *
17
+ * Built on `enquirer` rather than a hand-rolled raw-mode listener: a raw
18
+ * `process.stdin.on("keypress")` handler never fires unless something first
19
+ * calls `readline.emitKeypressEvents`, and the menu rendered as inert text for
20
+ * exactly that reason. `enquirer` owns the keypress plumbing, including Ctrl-C.
21
+ *
22
+ * `header` is printed once here and never handed to `enquirer`: enquirer
23
+ * redraws its whole frame on every keypress, so a header inside the prompt
24
+ * repeated once per keystroke and stacked the wordmark down the terminal.
25
+ *
26
+ * Returns -1 when stdin is not a TTY (piped, CI, or a non-interactive run) or
27
+ * when the operator cancels, so the caller can drain and exit either way.
28
+ */
29
+ /**
30
+ * Options for `chooseFromMenu`. `initialIndex` is an **action** index, not a
31
+ * row index — the caller numbers from the actions it exposes, which is also
32
+ * what the function returns. Rules (separators) are not indexed.
33
+ */
34
+ export interface MenuOptions {
35
+ readonly initialIndex?: number;
36
+ }
37
+
38
+ export async function chooseFromMenu(
39
+ header: string | readonly string[],
40
+ rows: readonly MenuRow[],
41
+ options: MenuOptions = {},
42
+ ): Promise<number> {
43
+ if (!process.stdin.isTTY) return -1;
44
+
45
+ const headerLines = typeof header === "string" ? [header] : header;
46
+ process.stdout.write("\u001b[2J\u001b[H");
47
+ for (const line of headerLines) process.stdout.write(`${line}\n`);
48
+ process.stdout.write("\n");
49
+
50
+ // Enquirer indexes the rows it renders, rules included, so the action index
51
+ // has to be recovered from the label rather than from the position.
52
+ const labels = rows.flatMap((row) => (row.kind === "action" ? [row.label] : []));
53
+ // Translate the action-index the caller passed into the row-index enquirer
54
+ // wants (which counts rules). An out-of-range or unspecified initial index
55
+ // falls back to the first action.
56
+ const clampedActionIndex = (() => {
57
+ if (options.initialIndex === undefined) return 0;
58
+ if (options.initialIndex < 0) return 0;
59
+ if (options.initialIndex >= labels.length) return labels.length - 1;
60
+ return options.initialIndex;
61
+ })();
62
+ let seenActions = 0;
63
+ let rowInitial = 0;
64
+ for (let i = 0; i < rows.length; i += 1) {
65
+ const row = rows[i];
66
+ if (row === undefined || row.kind !== "action") continue;
67
+ if (seenActions === clampedActionIndex) {
68
+ rowInitial = i;
69
+ break;
70
+ }
71
+ seenActions += 1;
72
+ }
73
+
74
+
75
+ try {
76
+ const answer = await Enquirer.prompt<{ choice: string }>({
77
+ type: "select",
78
+ name: "choice",
79
+ message: "Select an action",
80
+ choices: rows.map((row) =>
81
+ row.kind === "rule"
82
+ ? { name: "rule", message: "", role: "separator" }
83
+ : {
84
+ name: row.label,
85
+ message: row.label,
86
+ ...(row.hint === undefined ? {} : { hint: row.hint }),
87
+ },
88
+ ),
89
+ initial: rowInitial,
90
+ });
91
+ return labels.indexOf(answer.choice);
92
+ } catch {
93
+ // Enquirer rejects on Esc and on its own Ctrl-C handling. Return -1 so the
94
+ // caller drains the gateway rather than leaving a detached process behind.
95
+ process.stdout.write("\u001b[2J\u001b[H");
96
+ return -1;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Asks the operator to type an exact word before a destructive action runs.
102
+ *
103
+ * Enter alone must not be enough: the reset deletes every console credential,
104
+ * so the operator types the word rather than confirming a highlight. Returns
105
+ * false on cancel, on a mismatch, or when stdin is not a TTY.
106
+ */
107
+ export async function confirmByTyping(word: string, message: string): Promise<boolean> {
108
+ if (!process.stdin.isTTY) return false;
109
+ try {
110
+ const answer = await Enquirer.prompt<{ confirmation: string }>({
111
+ type: "input",
112
+ name: "confirmation",
113
+ message: `${message} (type ${word})`,
114
+ validate: (value: string) =>
115
+ value.trim() === word ? true : `type ${word} exactly to confirm, or Ctrl-C to cancel`,
116
+ });
117
+ return answer.confirmation.trim() === word;
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+
123
+ /** A yes/no gate for the step before the typed confirmation. */
124
+ export async function confirmStep(message: string): Promise<boolean> {
125
+ if (!process.stdin.isTTY) return false;
126
+ try {
127
+ const answer = await Enquirer.prompt<{ proceed: boolean }>({
128
+ type: "confirm",
129
+ name: "proceed",
130
+ message,
131
+ initial: false,
132
+ });
133
+ return answer.proceed;
134
+ } catch {
135
+ return false;
136
+ }
137
+ }
@@ -26,6 +26,9 @@ export async function startForeground(): Promise<void> {
26
26
  const blocker = await inspectPortHolder(port, token);
27
27
  if (blocker !== null) throw new Error(blocker);
28
28
  writeInstanceLock(port, process.pid, token);
29
+ // dynamic-import: `src/main` boots the gateway as a side effect of module
30
+ // evaluation. Importing it statically would start the server before the
31
+ // port probe + instance-lock sequence above has a chance to refuse the run.
29
32
  await import("../../src/main");
30
33
  }
31
34
 
@@ -1,208 +1,167 @@
1
- import { isNewerRelease } from "../../src/console/updates/version-compare";
2
- import { resolvePackagePath } from "../../src/config/package-paths";
3
- import { resolvePort } from "../../src/config/runtime";
4
- import { ensureDrainToken } from "./instance";
5
- import { startGateway } from "./gateway";
6
- import { stopGateway } from "./gateway";
7
- import { readInstalledVersion, readLatestPublishedVersion } from "./version";
8
- import { reportTrayStartup } from "./startup-signal";
9
- import { createTray } from "./tray/index";
10
- import { openUrl } from "./background";
11
- import { exportBackup, resetConsoleAccount } from "./admin";
12
- import { printUpdateInstructions } from "./update";
13
-
14
- /**
15
- * Runs the gateway with a tray icon. The tray is platform-specific (`tray/`):
16
- * on Windows it is a PowerShell NotifyIcon, because a Node tray library leaves
17
- * an empty console tab open for the tray's whole lifetime, and that window is
18
- * owned by Windows Terminal — not by this process, so it cannot be found and
19
- * hidden from inside.
20
- *
21
- * The gateway it starts is detached, which is what makes the health poll below
22
- * necessary — see {@link TRAY_HEALTH_POLL_MS}.
23
- */
24
-
25
- /**
26
- * How often the tray asks whether the gateway is still answering.
27
- *
28
- * 10 s: often enough that an operator who stops the gateway by hand sees the
29
- * icon go away, and far below the cost of a request. A detached gateway can no
30
- * longer be waited on, so this poll is the only signal left.
31
- */
32
- const TRAY_HEALTH_POLL_MS = 10_000;
33
-
34
- /**
35
- * Consecutive misses to forgive before a dead gateway closes the tray.
36
- *
37
- * One refusal is not a stopped gateway — a probe can lose a race with startup
38
- * or a slow request — and closing the icon on one would make startup feel
39
- * random.
40
- */
41
- const TRAY_HEALTH_MISSES_BEFORE_CLOSE = 3;
42
-
43
- export async function startTray(): Promise<void> {
44
- const port = resolvePort();
45
- const drainToken = ensureDrainToken();
46
- const dashboardUrl = `http://127.0.0.1:${port}/console`;
47
- const repositoryUrl = "https://github.com/risunCode/cartethyia";
48
- const currentVersion = await readInstalledVersion();
49
-
50
- let gateway: Bun.Subprocess | null = null;
51
- let closing = false;
52
- let busy = false;
53
- let resetArmed = false;
54
- let latestVersion: string | null = null;
55
- let closePromise: Promise<void> | undefined;
56
-
57
- const tray = createTray({
58
- iconPath: resolvePackagePath(
59
- "scripts",
60
- "cli",
61
- "tray",
62
- "assets",
63
- process.platform === "win32" ? "cartethyia.ico" : "cartethyia.png",
64
- ),
65
- tooltip: `Cartethyia ${currentVersion}`,
66
- items: [
67
- { kind: "action", id: "update", title: `Cartethyia ${currentVersion} \u00b7 checking\u2026`, enabled: true },
68
- { kind: "separator" },
69
- { kind: "action", id: "console", title: `Open Console \u00b7 ${dashboardUrl}` },
70
- { kind: "action", id: "reset", title: "Reset console password" },
71
- { kind: "action", id: "backup", title: "Backup" },
72
- { kind: "separator" },
73
- { kind: "action", id: "exit", title: "Exit" },
74
- ],
75
- onSelect: (id: string) => void handleSelect(id),
76
- onExit: () => void close(1),
77
- });
78
-
79
- const close = (exitCode = 0): Promise<void> => {
80
- if (closePromise) return closePromise;
81
- closing = true;
82
- closePromise = (async () => {
83
- const current: Bun.Subprocess | null = gateway;
84
- gateway = null;
85
- if (current) await stopGateway(current, port, drainToken);
86
- await tray.close();
87
- process.exitCode = exitCode;
88
- })();
89
- return closePromise;
90
- };
91
-
92
- const refreshVersion = async (): Promise<void> => {
93
- const fetched = await readLatestPublishedVersion();
94
- if (closing) return;
95
- latestVersion = fetched;
96
- if (fetched === null) {
97
- tray.update("update", `Cartethyia ${currentVersion} \u00b7 check for updates`, true);
98
- return;
99
- }
100
- const newer = isNewerRelease(currentVersion, fetched);
101
- tray.update(
102
- "update",
103
- newer
104
- ? `Cartethyia ${currentVersion} \u00b7 update to ${fetched}`
105
- : `Cartethyia ${currentVersion} \u00b7 up to date`,
106
- // Always clickable: the row opens the repository, and only offers the
107
- // update command when there is something newer.
108
- true,
109
- );
110
- };
111
-
112
- const runUpdate = async (): Promise<void> => {
113
- if (busy) return;
114
- busy = true;
115
- const latest = await readLatestPublishedVersion();
116
- await close();
117
- printUpdateInstructions(currentVersion, latest);
118
- // The tray has no terminal of its own, so these instructions are only
119
- // visible when it was started from one. Hold that terminal open long
120
- // enough to copy the command, then exit.
121
- await new Promise<void>((resolve) => setTimeout(resolve, 180_000));
122
- process.exit(0);
123
- };
124
-
125
- async function handleSelect(id: string): Promise<void> {
126
- if (closing) return;
127
- if (id === "exit") return void (await close());
128
- if (id === "console") return void (await openUrl(dashboardUrl));
129
- if (id === "backup") {
130
- const result = await exportBackup(port, drainToken);
131
- if (result !== null) {
132
- console.log(
133
- `Backup written to ${result.path} (${(result.bytes / 1024).toFixed(1)} KiB). Telemetry is not included.`,
134
- );
135
- }
136
- return;
137
- }
138
- if (id === "reset") {
139
- // A tray click cannot ask for the typed confirmation the menu uses, so
140
- // the first click arms the action and the second one runs it.
141
- if (!resetArmed) {
142
- resetArmed = true;
143
- tray.update("reset", "Reset console password \u00b7 click again to delete", true);
144
- return;
145
- }
146
- resetArmed = false;
147
- tray.update("reset", "Reset console password", true);
148
- await resetConsoleAccount(port, drainToken);
149
- return;
150
- }
151
- if (id === "update") {
152
- // Nothing to install: the row is the way to the repository instead.
153
- if (latestVersion === null || !isNewerRelease(currentVersion, latestVersion)) {
154
- await openUrl(repositoryUrl);
155
- return;
156
- }
157
- await runUpdate();
158
- }
159
- }
160
-
161
- process.once("SIGINT", () => void close());
162
- process.once("SIGTERM", () => void close());
163
-
164
- try {
165
- await tray.ready();
166
- // Detached, unlike every other caller: the gateway must outlive this
167
- // process. Attached, it dies with the tray — and the tray dies with the
168
- // terminal that launched it, so closing that terminal took the gateway
169
- // down too and "Hide to tray" left nothing serving. `stopGateway` drains
170
- // over HTTP before it ever reaches for `kill()`, so detaching costs the
171
- // exit path nothing: the tray can still stop the gateway on Exit.
172
- gateway = await startGateway(port, drainToken, { detached: true });
173
- // The launcher that spawned this tray is blocked on a signal file: it
174
- // cannot read our stdio, because the tray is detached. Without this write
175
- // it waits out its 60 s deadline and reports a failure for a tray that is
176
- // running fine.
177
- reportTrayStartup({ status: "ready", url: dashboardUrl });
178
- console.log(`Cartethyia is running at ${dashboardUrl}`);
179
- void refreshVersion();
180
-
181
- let misses = 0;
182
- // A detached child's `exited` never settles on Windows, so the old
183
- // `await current.exited` would have parked here forever and the tray would
184
- // never notice the gateway going away. Poll its health endpoint instead:
185
- // that answers for a process we no longer own, which is the whole point of
186
- // detaching it. The probe is the same one startup used, so a gateway that
187
- // is up reads as up regardless of who spawned it.
188
- while (!closing) {
189
- await Bun.sleep(TRAY_HEALTH_POLL_MS);
190
- if (closing || gateway === null) continue;
191
- const alive = await fetch(`http://127.0.0.1:${port}/health`, {
192
- signal: AbortSignal.timeout(2_000),
193
- }).then((response) => response.ok).catch(() => false);
194
- if (alive) {
195
- misses = 0;
196
- continue;
197
- }
198
- misses += 1;
199
- if (misses < TRAY_HEALTH_MISSES_BEFORE_CLOSE) continue;
200
- gateway = null;
201
- console.error("Cartethyia stopped answering; the tray is closing.");
202
- await close(1);
203
- }
204
- } catch (error) {
205
- await close(1);
206
- throw error;
207
- }
208
- }
1
+ import { resolvePackagePath } from "../../src/config/package-paths";
2
+ import { resolvePort } from "../../src/config/runtime";
3
+ import { ensureDrainToken } from "./instance";
4
+ import { startGateway } from "./gateway";
5
+ import { stopGateway } from "./gateway";
6
+ import { readInstalledVersion } from "./version";
7
+ import { reportTrayStartup } from "./startup-signal";
8
+ import { createTray } from "./tray/index";
9
+ import { openUrl } from "./background";
10
+ import { exportBackup, resetConsoleAccount } from "./admin";
11
+
12
+ /**
13
+ * Runs the gateway with a tray icon. The tray is platform-specific (`tray/`):
14
+ * on Windows it is a PowerShell NotifyIcon, because a Node tray library leaves
15
+ * an empty console tab open for the tray's whole lifetime, and that window is
16
+ * owned by Windows Terminal — not by this process, so it cannot be found and
17
+ * hidden from inside.
18
+ *
19
+ * The gateway it starts is detached, which is what makes the health poll below
20
+ * necessary — see {@link TRAY_HEALTH_POLL_MS}.
21
+ */
22
+
23
+ /**
24
+ * How often the tray asks whether the gateway is still answering.
25
+ *
26
+ * 10 s: often enough that an operator who stops the gateway by hand sees the
27
+ * icon go away, and far below the cost of a request. A detached gateway can no
28
+ * longer be waited on, so this poll is the only signal left.
29
+ */
30
+ const TRAY_HEALTH_POLL_MS = 10_000;
31
+
32
+ /**
33
+ * Consecutive misses to forgive before a dead gateway closes the tray.
34
+ *
35
+ * One refusal is not a stopped gateway — a probe can lose a race with startup
36
+ * or a slow request — and closing the icon on one would make startup feel
37
+ * random.
38
+ */
39
+ const TRAY_HEALTH_MISSES_BEFORE_CLOSE = 3;
40
+
41
+ export async function startTray(): Promise<void> {
42
+ const port = resolvePort();
43
+ const drainToken = ensureDrainToken();
44
+ const dashboardUrl = `http://127.0.0.1:${port}/console`;
45
+ const releasesUrl = "https://github.com/risunCodex/Cartethyia/releases";
46
+ const currentVersion = await readInstalledVersion();
47
+
48
+ let gateway: Bun.Subprocess | null = null;
49
+ let closing = false;
50
+ let resetArmed = false;
51
+ let closePromise: Promise<void> | undefined;
52
+
53
+ const tray = createTray({
54
+ iconPath: resolvePackagePath(
55
+ "scripts",
56
+ "cli",
57
+ "tray",
58
+ "assets",
59
+ process.platform === "win32" ? "cartethyia.ico" : "cartethyia.png",
60
+ ),
61
+ tooltip: `Cartethyia ${currentVersion}`,
62
+ items: [
63
+ // Version row is informational; clicking it opens the releases page so
64
+ // the operator can decide for themselves. No background check: the tray
65
+ // used to poll the registry on open and the result was a cue the user
66
+ // did not ask for.
67
+ { kind: "action", id: "version", title: `Cartethyia ${currentVersion}`, enabled: true },
68
+ { kind: "separator" },
69
+ { kind: "action", id: "console", title: `Open Console \u00b7 ${dashboardUrl}` },
70
+ { kind: "action", id: "reset", title: "Reset console password" },
71
+ { kind: "action", id: "backup", title: "Backup" },
72
+ { kind: "separator" },
73
+ { kind: "action", id: "exit", title: "Exit" },
74
+ ],
75
+ onSelect: (id: string) => void handleSelect(id),
76
+ onExit: () => void close(1),
77
+ });
78
+
79
+ const close = (exitCode = 0): Promise<void> => {
80
+ if (closePromise) return closePromise;
81
+ closing = true;
82
+ closePromise = (async () => {
83
+ const current: Bun.Subprocess | null = gateway;
84
+ gateway = null;
85
+ if (current) await stopGateway(current, port, drainToken);
86
+ await tray.close();
87
+ process.exitCode = exitCode;
88
+ })();
89
+ return closePromise;
90
+ };
91
+
92
+ async function handleSelect(id: string): Promise<void> {
93
+ if (closing) return;
94
+ if (id === "exit") return void (await close());
95
+ if (id === "console") return void (await openUrl(dashboardUrl));
96
+ if (id === "version") return void (await openUrl(releasesUrl));
97
+ if (id === "backup") {
98
+ const result = await exportBackup(port, drainToken);
99
+ if (result !== null) {
100
+ console.log(
101
+ `Backup written to ${result.path} (${(result.bytes / 1024).toFixed(1)} KiB). Telemetry is not included.`,
102
+ );
103
+ }
104
+ return;
105
+ }
106
+ if (id === "reset") {
107
+ // A tray click cannot ask for the typed confirmation the menu uses, so
108
+ // the first click arms the action and the second one runs it.
109
+ if (!resetArmed) {
110
+ resetArmed = true;
111
+ tray.update("reset", "Reset console password \u00b7 click again to delete", true);
112
+ return;
113
+ }
114
+ resetArmed = false;
115
+ tray.update("reset", "Reset console password", true);
116
+ await resetConsoleAccount(port, drainToken);
117
+ return;
118
+ }
119
+ }
120
+
121
+ process.once("SIGINT", () => void close());
122
+ process.once("SIGTERM", () => void close());
123
+
124
+ try {
125
+ await tray.ready();
126
+ // Detached, unlike every other caller: the gateway must outlive this
127
+ // process. Attached, it dies with the tray — and the tray dies with the
128
+ // terminal that launched it, so closing that terminal took the gateway
129
+ // down too and "Hide to tray" left nothing serving. `stopGateway` drains
130
+ // over HTTP before it ever reaches for `kill()`, so detaching costs the
131
+ // exit path nothing: the tray can still stop the gateway on Exit.
132
+ gateway = await startGateway(port, drainToken, { detached: true });
133
+ // The launcher that spawned this tray is blocked on a signal file: it
134
+ // cannot read our stdio, because the tray is detached. Without this write
135
+ // it waits out its 60 s deadline and reports a failure for a tray that is
136
+ // running fine.
137
+ reportTrayStartup({ status: "ready", url: dashboardUrl });
138
+ console.log(`Cartethyia is running at ${dashboardUrl}`);
139
+
140
+ let misses = 0;
141
+ // A detached child's `exited` never settles on Windows, so the old
142
+ // `await current.exited` would have parked here forever and the tray would
143
+ // never notice the gateway going away. Poll its health endpoint instead:
144
+ // that answers for a process we no longer own, which is the whole point of
145
+ // detaching it. The probe is the same one startup used, so a gateway that
146
+ // is up reads as up regardless of who spawned it.
147
+ while (!closing) {
148
+ await Bun.sleep(TRAY_HEALTH_POLL_MS);
149
+ if (closing || gateway === null) continue;
150
+ const alive = await fetch(`http://127.0.0.1:${port}/health`, {
151
+ signal: AbortSignal.timeout(2_000),
152
+ }).then((response) => response.ok).catch(() => false);
153
+ if (alive) {
154
+ misses = 0;
155
+ continue;
156
+ }
157
+ misses += 1;
158
+ if (misses < TRAY_HEALTH_MISSES_BEFORE_CLOSE) continue;
159
+ gateway = null;
160
+ console.error("Cartethyia stopped answering; the tray is closing.");
161
+ await close(1);
162
+ }
163
+ } catch (error) {
164
+ await close(1);
165
+ throw error;
166
+ }
167
+ }