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