cartethyia 2.5.2 → 2.5.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.
- package/dist/dashboard/assets/{BannedPage-MsQaeQTs.js → BannedPage-hfCzi-Ux.js} +1 -1
- package/dist/dashboard/assets/BannedPage-hfCzi-Ux.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-BO9nKg9w.js → CliToolDetailPage-DLGqYEwI.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-DfWHTJ7v.js → CliToolsPage-DEDyKBlC.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-DEDyKBlC.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-BrCBQDAc.js → CombosPage-CfuYiQ8Z.js} +1 -1
- package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-C6Uh33er.js → ConsoleLogPage-bsLuZmYH.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-bsLuZmYH.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-8aS9hyJu.js → CustomizationPage-_UWhwziJ.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-_UWhwziJ.js.gz +0 -0
- package/dist/dashboard/assets/{HealthEventsModal-qomXThxX.js → HealthEventsModal-Cjfc5WFS.js} +1 -1
- package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-C8hKyXBe.js → LoginPage-DDVIfZGW.js} +1 -1
- package/dist/dashboard/assets/LoginPage-DDVIfZGW.js.gz +0 -0
- package/dist/dashboard/assets/{OverviewPage-C-ExCy1V.js → OverviewPage-B53YYMng.js} +1 -1
- package/dist/dashboard/assets/OverviewPage-B53YYMng.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js.gz +0 -0
- package/dist/dashboard/assets/{ProvidersPage-BS9IEpvv.js → ProvidersPage-Dx31DgIO.js} +1 -1
- package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js.gz +0 -0
- package/dist/dashboard/assets/{ProxyPage-Rn_qXScb.js → ProxyPage-BpnR4kXT.js} +1 -1
- package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js.gz +0 -0
- package/dist/dashboard/assets/{QuotaPage-BihkgQSq.js → QuotaPage-BpOQCnun.js} +1 -1
- package/dist/dashboard/assets/QuotaPage-BpOQCnun.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js +1 -0
- package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-CXjUlfqr.js → SetupPage-D6pmQ86L.js} +1 -1
- package/dist/dashboard/assets/SetupPage-D6pmQ86L.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-C-_oD3F2.js → SortableList-DWvKiuSl.js} +1 -1
- package/dist/dashboard/assets/SortableList-DWvKiuSl.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-u9IxmYOI.js → StudioPage-CmkUrgdj.js} +1 -1
- package/dist/dashboard/assets/StudioPage-CmkUrgdj.js.gz +0 -0
- package/dist/dashboard/assets/{UsagePage-BEVLVrwP.js → UsagePage-B47uOydr.js} +1 -1
- package/dist/dashboard/assets/UsagePage-B47uOydr.js.gz +0 -0
- package/dist/dashboard/assets/{entry-Dj9AR0gY.js → entry-CkryrIea.js} +3 -3
- package/dist/dashboard/assets/entry-CkryrIea.js.gz +0 -0
- package/dist/dashboard/assets/{index-DCBtROoK.js → index-BydyKaDz.js} +2 -2
- package/dist/dashboard/assets/index-BydyKaDz.js.gz +0 -0
- package/dist/dashboard/assets/{select-DsX5QT8K.js → select-BO6h8yNv.js} +1 -1
- package/dist/dashboard/assets/select-BO6h8yNv.js.gz +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +21 -21
- package/package.json +1 -1
- package/scripts/cli/command/console-menu.ts +248 -246
- package/scripts/cli/command/tray.ts +208 -204
- package/src/config/spec.ts +26 -2
- package/src/console/backup/routes.ts +52 -1
- package/src/console/backup/service.ts +60 -4
- package/src/console/share/share-router.ts +6 -9
- package/src/observability/payload-capture.ts +366 -344
- package/src/observability/payload-store.ts +12 -2
- package/src/observability/telemetry-status.ts +28 -16
- package/src/persistence/stores/api-key-store.ts +6 -0
- package/src/persistence/stores/share-store.ts +583 -556
- package/src/persistence/stores/telemetry-store.ts +1 -1
- package/src/protocol/response/gemini.ts +18 -4
- package/src/providers/integrations/antigravity/antigravity-quota.ts +11 -3
- package/src/providers/integrations/buddy/buddy-catalog-shared.ts +4 -4
- package/src/providers/integrations/buddy/workbuddy/shared.ts +201 -200
- package/src/providers/integrations/cline/cline-quota.ts +8 -11
- package/src/providers/integrations/opencode/provider.ts +532 -532
- package/src/providers/integrations/tokenharbor.ts +2 -2
- package/src/providers/integrations/trae/trae-catalog.ts +5 -3
- package/src/providers/operations/account-health-service.ts +23 -0
- package/src/providers/operations/client-versions.ts +9 -2
- package/src/providers/provider-metadata.ts +7 -1
- package/src/transport/dispatch/attempt-finalize.ts +46 -5
- package/src/transport/dispatch/proxy-request.ts +9 -0
- package/src/transport/dispatch/streaming/stream.ts +16 -2
- package/src/transport/routing/route-catalog.ts +806 -799
- package/dist/dashboard/assets/BannedPage-MsQaeQTs.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-BO9nKg9w.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-DfWHTJ7v.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-BrCBQDAc.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-C6Uh33er.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-8aS9hyJu.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-qomXThxX.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-C8hKyXBe.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-C-ExCy1V.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-tr811uaF.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-tr811uaF.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-BS9IEpvv.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-Rn_qXScb.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BihkgQSq.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-DhiEjm6T.js +0 -1
- package/dist/dashboard/assets/SettingsPage-DhiEjm6T.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-CXjUlfqr.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-C-_oD3F2.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-u9IxmYOI.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-BEVLVrwP.js.gz +0 -0
- package/dist/dashboard/assets/entry-Dj9AR0gY.js.gz +0 -0
- package/dist/dashboard/assets/index-DCBtROoK.js.gz +0 -0
- package/dist/dashboard/assets/select-DsX5QT8K.js.gz +0 -0
|
@@ -1,204 +1,208 @@
|
|
|
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 "../gateway/drain-token";
|
|
5
|
-
import { startGateway } from "../gateway/spawn";
|
|
6
|
-
import { stopGateway } from "../gateway/stop";
|
|
7
|
-
import { readInstalledVersion, readLatestPublishedVersion } from "../support/version";
|
|
8
|
-
import { reportTrayStartup } from "../support/startup-signal";
|
|
9
|
-
import { createTray } from "../tray";
|
|
10
|
-
import { openUrl } from "../ui/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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
}
|
|
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 "../gateway/drain-token";
|
|
5
|
+
import { startGateway } from "../gateway/spawn";
|
|
6
|
+
import { stopGateway } from "../gateway/stop";
|
|
7
|
+
import { readInstalledVersion, readLatestPublishedVersion } from "../support/version";
|
|
8
|
+
import { reportTrayStartup } from "../support/startup-signal";
|
|
9
|
+
import { createTray } from "../tray";
|
|
10
|
+
import { openUrl } from "../ui/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
|
+
}
|
package/src/config/spec.ts
CHANGED
|
@@ -157,7 +157,14 @@ export const CONFIG_SPEC = {
|
|
|
157
157
|
},
|
|
158
158
|
|
|
159
159
|
// Upstream request timeout + retry backoff
|
|
160
|
-
|
|
160
|
+
//
|
|
161
|
+
// Strictly under 90s: a client whose own deadline is 90s must never be
|
|
162
|
+
// waiting on a gateway that is still holding a request open past it, and a
|
|
163
|
+
// provider that has not answered within 75s is not answering — the request
|
|
164
|
+
// is queued upstream or the egress is stuck, and the honest move is to fail
|
|
165
|
+
// over to another account rather than burn the caller's whole budget on one.
|
|
166
|
+
// 120s outlived the clients that call this, which cap lower.
|
|
167
|
+
CARTETHYIA_UPSTREAM_TIMEOUT_MS: { kind: "int", default: 75_000, min: 5_000, max: 600_000 },
|
|
161
168
|
CARTETHYIA_STREAM_STALL_TIMEOUT_MS: {
|
|
162
169
|
kind: "int",
|
|
163
170
|
default: 360_000,
|
|
@@ -181,7 +188,24 @@ export const CONFIG_SPEC = {
|
|
|
181
188
|
CARTETHYIA_ACCOUNT_QUOTA_COOLDOWN_MS: { kind: "int", default: 3_600_000, min: 0, max: 86_400_000 },
|
|
182
189
|
CARTETHYIA_ACCOUNT_MODEL_CAPACITY_COOLDOWN_MS: { kind: "int", default: 120_000, min: 0, max: 86_400_000 },
|
|
183
190
|
CARTETHYIA_ACCOUNT_TRANSIENT_COOLDOWN_MS: { kind: "int", default: 30_000, min: 0, max: 86_400_000 },
|
|
184
|
-
|
|
191
|
+
/**
|
|
192
|
+
* An unmapped upstream failure cools the account down for 15 minutes.
|
|
193
|
+
*
|
|
194
|
+
* It used to be 60s, which put the account straight back into rotation and
|
|
195
|
+
* re-dialled an upstream that had just answered something this gateway does
|
|
196
|
+
* not understand — so the same unknown error was retried every minute, and
|
|
197
|
+
* an account that was actually broken contributed a steady stream of failed
|
|
198
|
+
* attempts instead of one. Fifteen minutes is long enough for a transient
|
|
199
|
+
* provider condition to clear on its own and short enough that no operator
|
|
200
|
+
* action is needed to recover it. It is still a cooldown with a deadline,
|
|
201
|
+
* never `disabled`, so nothing here can strand an account.
|
|
202
|
+
*/
|
|
203
|
+
CARTETHYIA_ACCOUNT_UNCLASSIFIED_COOLDOWN_MS: {
|
|
204
|
+
kind: "int",
|
|
205
|
+
default: 900_000,
|
|
206
|
+
min: 0,
|
|
207
|
+
max: 86_400_000,
|
|
208
|
+
},
|
|
185
209
|
CARTETHYIA_ACCOUNT_COOLDOWN_ESCALATION_MAX_MS: { kind: "int", default: 3_600_000, min: 0, max: 86_400_000 },
|
|
186
210
|
CARTETHYIA_POOL_COOLDOWN_MS: { kind: "int", default: 120_000, min: 0, max: 86_400_000 },
|
|
187
211
|
CARTETHYIA_ACCOUNT_AUTH_REJECTED_COOLDOWN_MS: { kind: "int", default: 300_000, min: 0, max: 86_400_000 },
|
|
@@ -78,6 +78,24 @@ const BACKUP_SCOPES = ["dashboard:write"] as const;
|
|
|
78
78
|
const importBody = t.Object({
|
|
79
79
|
password: t.String(),
|
|
80
80
|
backup: t.Unknown(),
|
|
81
|
+
/**
|
|
82
|
+
* Table names to restore. Absent or empty restores every table in the file,
|
|
83
|
+
* which is what the dashboard's checklist defaults to and what the API did
|
|
84
|
+
* before selection existed.
|
|
85
|
+
*/
|
|
86
|
+
tables: t.Optional(t.Array(t.String())),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The inspect body: a file, no password.
|
|
91
|
+
*
|
|
92
|
+
* Inspection writes nothing and reads no secret — it only reports which tables
|
|
93
|
+
* the file carries and which of them the live schema has — so it needs no
|
|
94
|
+
* re-authentication. Requiring the console password here would mean typing it
|
|
95
|
+
* twice for one restore.
|
|
96
|
+
*/
|
|
97
|
+
const inspectBody = t.Object({
|
|
98
|
+
backup: t.Unknown(),
|
|
81
99
|
});
|
|
82
100
|
|
|
83
101
|
const exportQuery = t.Object({
|
|
@@ -164,7 +182,12 @@ export function createBackupRoutes(config: BackupRoutesConfig): Elysia {
|
|
|
164
182
|
// from nowhere else. A request body may not name the tenant it writes
|
|
165
183
|
// into: that would let any holder of the scope import into, and
|
|
166
184
|
// overwrite, a tenant they are not.
|
|
167
|
-
const result = await config.backupFor(request).restore(
|
|
185
|
+
const result = await config.backupFor(request).restore(
|
|
186
|
+
body.password,
|
|
187
|
+
body.backup,
|
|
188
|
+
access.tenantId,
|
|
189
|
+
body.tables === undefined ? undefined : { tables: body.tables },
|
|
190
|
+
);
|
|
168
191
|
// Routing-visible write: the restore has committed, so the cached
|
|
169
192
|
// snapshot must be rebuilt before the next `/v1/*` dispatch.
|
|
170
193
|
await config.snapshotInvalidator?.invalidate();
|
|
@@ -214,5 +237,33 @@ export function createBackupRoutes(config: BackupRoutesConfig): Elysia {
|
|
|
214
237
|
} catch (error) {
|
|
215
238
|
return errorResponse(error, set, "Delete all failed");
|
|
216
239
|
}
|
|
240
|
+
})
|
|
241
|
+
.post("/inspect", { body: inspectBody }, async ({ request, body, set }) => {
|
|
242
|
+
try {
|
|
243
|
+
const access = requireAnyScope(config.accessResolver(request), BACKUP_SCOPES);
|
|
244
|
+
if (access.tenantId === null) {
|
|
245
|
+
throw new ConsoleDomainError("tenant_required", 403, "Tenant isolation required");
|
|
246
|
+
}
|
|
247
|
+
// Same byte bound as the import, for the same reason: the file is
|
|
248
|
+
// parsed here too, and an unbounded one would be buffered before any
|
|
249
|
+
// validation could refuse it.
|
|
250
|
+
const declared = Number(request.headers.get("content-length") ?? "0");
|
|
251
|
+
const actualBytes = JSON.stringify(body ?? null).length;
|
|
252
|
+
if (
|
|
253
|
+
(Number.isFinite(declared) && declared > MAX_BACKUP_BYTES) ||
|
|
254
|
+
actualBytes > MAX_BACKUP_BYTES
|
|
255
|
+
) {
|
|
256
|
+
set.status = 413;
|
|
257
|
+
return {
|
|
258
|
+
error: `backup exceeds the ${MAX_BACKUP_BYTES} byte limit`,
|
|
259
|
+
code: "request_too_large",
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
// Reads nothing and writes nothing: the checklist the restore wizard
|
|
263
|
+
// shows, so an operator can pick sections before typing a password.
|
|
264
|
+
return config.backupFor(request).inspect(body.backup);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
return errorResponse(error, set, "Backup inspection failed");
|
|
267
|
+
}
|
|
217
268
|
}) as unknown as Elysia;
|
|
218
269
|
}
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* re-authentication.
|
|
20
20
|
*/
|
|
21
21
|
import type { CartethyiaDatabase } from "../../persistence/db-handle";
|
|
22
|
+
import type { Table } from "drizzle-orm";
|
|
22
23
|
import {
|
|
23
24
|
models,
|
|
24
25
|
providerAccounts,
|
|
@@ -29,7 +30,12 @@ import {
|
|
|
29
30
|
} from "../../persistence/schema";
|
|
30
31
|
import { ConsoleDomainError } from "../shared/errors";
|
|
31
32
|
import { tablesForSection, tableName } from "./contracts";
|
|
32
|
-
import type {
|
|
33
|
+
import type {
|
|
34
|
+
BackupSection,
|
|
35
|
+
RestoreWarning,
|
|
36
|
+
ValidatedRestore,
|
|
37
|
+
ValidatedTable,
|
|
38
|
+
} from "./contracts";
|
|
33
39
|
import {
|
|
34
40
|
applyRestore,
|
|
35
41
|
decompressBackup,
|
|
@@ -90,6 +96,53 @@ export interface ImportResult {
|
|
|
90
96
|
readonly format: "native" | "nine_router";
|
|
91
97
|
}
|
|
92
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Which parts of a backup file a restore should actually write.
|
|
101
|
+
*
|
|
102
|
+
* Absent or empty means "everything", which is the historical behavior and
|
|
103
|
+
* what the dashboard's checklist defaults to. A table named here but absent
|
|
104
|
+
* from the file is simply not restored — it is not an error, because the
|
|
105
|
+
* checklist is built from an inspection of *this* file and a selection never
|
|
106
|
+
* invents a table.
|
|
107
|
+
*/
|
|
108
|
+
export interface BackupRestoreSelection {
|
|
109
|
+
/** Table names to restore; absent or empty restores every table present. */
|
|
110
|
+
readonly tables?: readonly string[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Narrows a validated backup to the tables the operator selected.
|
|
115
|
+
*
|
|
116
|
+
* The tenant row is the one thing that is not independently selectable: it is
|
|
117
|
+
* not a table in either section (`BACKUP_SECTIONS` are config and telemetry),
|
|
118
|
+
* so it never appears on the checklist, and every tenant-scoped table
|
|
119
|
+
* cascades from it. Dropping it because it was not named would make a partial
|
|
120
|
+
* restore fail on a foreign key for a table the operator *did* ask for. So it
|
|
121
|
+
* rides along with any config selection and is written only when one is made.
|
|
122
|
+
*
|
|
123
|
+
* A table named in the selection but missing from the file is a no-op rather
|
|
124
|
+
* than an error: the checklist is built from an inspection of this file, but
|
|
125
|
+
* the selection is validated as names only, and refusing to restore anything
|
|
126
|
+
* over one stale name would be worse than ignoring it.
|
|
127
|
+
*/
|
|
128
|
+
function restrictToSelection(
|
|
129
|
+
validation: ValidatedRestore,
|
|
130
|
+
selection: BackupRestoreSelection | undefined,
|
|
131
|
+
): ValidatedRestore {
|
|
132
|
+
const wanted = selection?.tables;
|
|
133
|
+
if (wanted === undefined || wanted.length === 0) return validation;
|
|
134
|
+
const selected = new Set(wanted);
|
|
135
|
+
const tables = new Map<Table, ValidatedTable>();
|
|
136
|
+
for (const [table, entry] of validation.tables) {
|
|
137
|
+
if (selected.has(tableName(table))) tables.set(table, entry);
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
...validation,
|
|
141
|
+
tables,
|
|
142
|
+
tenantRows: tables.size > 0 ? validation.tenantRows : [],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
93
146
|
function sectionsOrDefault(sections: readonly BackupSection[] | undefined): readonly BackupSection[] {
|
|
94
147
|
if (sections === undefined || sections.length === 0) return ["config"];
|
|
95
148
|
for (const section of sections) {
|
|
@@ -246,6 +299,7 @@ export class BackupService {
|
|
|
246
299
|
password: unknown,
|
|
247
300
|
payload: unknown,
|
|
248
301
|
tenantId: string,
|
|
302
|
+
selection?: BackupRestoreSelection,
|
|
249
303
|
): Promise<ImportResult> {
|
|
250
304
|
if (typeof password !== "string" || !(await this.options.verifyPassword(password))) {
|
|
251
305
|
throw new ConsoleDomainError("unauthorized", 401, "password is incorrect");
|
|
@@ -258,11 +312,11 @@ export class BackupService {
|
|
|
258
312
|
|
|
259
313
|
if (detected.kind === "nine_router") {
|
|
260
314
|
const { payload: converted, report } = convert9RouterBackup(detected.payload, tenantId);
|
|
261
|
-
const result = await this.apply(converted, tenantId);
|
|
315
|
+
const result = await this.apply(converted, tenantId, selection);
|
|
262
316
|
return { ...result, report, format: "nine_router" };
|
|
263
317
|
}
|
|
264
318
|
|
|
265
|
-
const result = await this.apply(detected.payload, tenantId);
|
|
319
|
+
const result = await this.apply(detected.payload, tenantId, selection);
|
|
266
320
|
return { ...result, format: "native" };
|
|
267
321
|
}
|
|
268
322
|
|
|
@@ -348,13 +402,15 @@ export class BackupService {
|
|
|
348
402
|
private async apply(
|
|
349
403
|
payload: unknown,
|
|
350
404
|
tenantId: string,
|
|
405
|
+
selection?: BackupRestoreSelection,
|
|
351
406
|
): Promise<{ restored: Record<string, number>; skipped: Record<string, number>; warnings: readonly RestoreWarning[] }> {
|
|
352
407
|
const validation = validateRestorePayload(payload, tenantId);
|
|
353
408
|
if (!validation.ok) {
|
|
354
409
|
throw new ConsoleDomainError("invalid_request", 400, validation.error);
|
|
355
410
|
}
|
|
356
411
|
try {
|
|
357
|
-
const
|
|
412
|
+
const scoped = restrictToSelection(validation.value, selection);
|
|
413
|
+
const compatible = await omitRowsForUnavailableProviders(this.options.db, scoped, tenantId);
|
|
358
414
|
const applied = await applyRestore(this.options.db, compatible, restoreOrder(), tenantId);
|
|
359
415
|
return { ...applied, warnings: compatible.warnings };
|
|
360
416
|
} catch (error) {
|