@bakapiano/ccsm 0.14.0 → 0.15.1
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/CLAUDE.md +474 -475
- package/README.md +189 -190
- package/bin/ccsm.js +194 -194
- package/lib/cliActivity.js +118 -0
- package/lib/codexSeed.js +147 -0
- package/lib/config.js +205 -188
- package/lib/folders.js +105 -105
- package/lib/localCliSessions.js +489 -489
- package/lib/persistedSessions.js +144 -142
- package/lib/webTerminal.js +224 -224
- package/lib/workspace.js +230 -230
- package/package.json +57 -57
- package/public/css/base.css +99 -99
- package/public/css/cards.css +183 -183
- package/public/css/feedback.css +303 -303
- package/public/css/forms.css +405 -405
- package/public/css/layout.css +160 -160
- package/public/css/modal.css +190 -190
- package/public/css/responsive.css +10 -10
- package/public/css/sidebar.css +613 -608
- package/public/css/terminals.css +294 -294
- package/public/css/tokens.css +81 -81
- package/public/css/wco.css +98 -98
- package/public/css/widgets.css +1628 -1628
- package/public/index.html +111 -105
- package/public/js/api.js +296 -280
- package/public/js/components/AdoptModal.js +343 -343
- package/public/js/components/App.js +35 -35
- package/public/js/components/DirectoryPicker.js +203 -203
- package/public/js/components/EntityFormModal.js +141 -141
- package/public/js/components/Modal.js +51 -51
- package/public/js/components/OfflineBanner.js +93 -93
- package/public/js/components/PageTitleBar.js +13 -13
- package/public/js/components/Picker.js +179 -179
- package/public/js/components/Popover.js +55 -55
- package/public/js/components/Sidebar.js +299 -299
- package/public/js/components/TerminalView.js +314 -314
- package/public/js/components/useDragSort.js +67 -67
- package/public/js/dialog.js +67 -67
- package/public/js/icons.js +177 -177
- package/public/js/main.js +132 -132
- package/public/js/pages/AboutPage.js +173 -165
- package/public/js/pages/ConfigurePage.js +513 -475
- package/public/js/pages/LaunchPage.js +369 -369
- package/public/js/pages/SessionsPage.js +101 -97
- package/public/js/state.js +231 -231
- package/scripts/dev.js +44 -11
- package/scripts/install.js +158 -158
- package/scripts/restart-helper.js +96 -0
- package/scripts/upgrade-helper.js +6 -1
- package/server.js +1282 -1254
- package/lib/cliSessionWatcher.js +0 -275
- package/public/manifest.webmanifest +0 -15
package/server.js
CHANGED
|
@@ -1,1254 +1,1282 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
const path = require('node:path');
|
|
5
|
-
const os = require('node:os');
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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
|
-
|
|
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
|
-
const
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
const
|
|
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
|
-
|
|
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
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
if (
|
|
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
|
-
const
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
const
|
|
480
|
-
const
|
|
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
|
-
res.json({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
const
|
|
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
|
-
repos:
|
|
711
|
-
|
|
712
|
-
|
|
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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
}
|
|
770
|
-
});
|
|
771
|
-
|
|
772
|
-
// ----
|
|
773
|
-
//
|
|
774
|
-
//
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
const
|
|
875
|
-
|
|
876
|
-
if (
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
//
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
//
|
|
920
|
-
|
|
921
|
-
let
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
//
|
|
945
|
-
//
|
|
946
|
-
//
|
|
947
|
-
//
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
const
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
//
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
})
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
return
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
return { kind: '
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
}
|
|
1196
|
-
}
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
}
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
}
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const crypto = require('node:crypto');
|
|
7
|
+
const express = require('express');
|
|
8
|
+
|
|
9
|
+
const { loadConfig, saveConfig, DATA_DIR } = require('./lib/config');
|
|
10
|
+
const {
|
|
11
|
+
listWorkspaces,
|
|
12
|
+
findOrCreateWorkspace,
|
|
13
|
+
ensureReposInWorkspace,
|
|
14
|
+
isInside,
|
|
15
|
+
} = require('./lib/workspace');
|
|
16
|
+
const webTerminal = require('./lib/webTerminal');
|
|
17
|
+
const persistedSessions = require('./lib/persistedSessions');
|
|
18
|
+
const folders = require('./lib/folders');
|
|
19
|
+
// Upstream CLI session-id capture used to live in lib/cliSessionWatcher
|
|
20
|
+
// (poll the CLI's transcript dir, match by cwd). It's gone now — for
|
|
21
|
+
// CLIs that expose a "set the UUID for a new session" flag (claude +
|
|
22
|
+
// copilot both have --session-id <uuid>) we pre-generate the id in
|
|
23
|
+
// /api/sessions/new and pass it via cli.newSessionIdArgs. For CLIs
|
|
24
|
+
// without that flag (codex) we just don't capture an id; the user
|
|
25
|
+
// gets cli.resumeArgs (--continue / resume --last) on relaunch.
|
|
26
|
+
const localCliSessions = require('./lib/localCliSessions');
|
|
27
|
+
|
|
28
|
+
// One unified exit path: kill PTY children, then exit. v1.0 dropped the
|
|
29
|
+
// snapshot-on-exit behaviour because the new persistedSessions store is
|
|
30
|
+
// the source of truth (and is always on disk, not in memory).
|
|
31
|
+
let shuttingDown = false;
|
|
32
|
+
async function gracefulShutdown(reason) {
|
|
33
|
+
if (shuttingDown) return;
|
|
34
|
+
shuttingDown = true;
|
|
35
|
+
console.log(`[ccsm] shutting down · ${reason}`);
|
|
36
|
+
// Mark all running sessions as exited (best-effort) so the next launch
|
|
37
|
+
// doesn't show stale "running" rows.
|
|
38
|
+
try {
|
|
39
|
+
const all = await persistedSessions.loadAll();
|
|
40
|
+
for (const s of all) {
|
|
41
|
+
if (s.status === 'running') {
|
|
42
|
+
await persistedSessions.markExited(s.id, null).catch(() => {});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
} catch {}
|
|
46
|
+
try { webTerminal.killAll(); } catch {}
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const app = express();
|
|
51
|
+
app.use(express.json({ limit: '1mb' }));
|
|
52
|
+
|
|
53
|
+
// CORS · allow the hosted-frontend (GH Pages) origin to call /api/* and
|
|
54
|
+
// open WebSockets. Listed explicitly — never reflect Origin or use '*' so
|
|
55
|
+
// random web pages can't reach the local backend. Localhost dev calls
|
|
56
|
+
// stay same-origin (browser doesn't add Origin header → middleware is a
|
|
57
|
+
// no-op for them).
|
|
58
|
+
const ALLOWED_ORIGINS = new Set([
|
|
59
|
+
'https://bakapiano.github.io',
|
|
60
|
+
]);
|
|
61
|
+
app.use((req, res, next) => {
|
|
62
|
+
const origin = req.headers.origin;
|
|
63
|
+
if (origin && ALLOWED_ORIGINS.has(origin)) {
|
|
64
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
65
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
66
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
67
|
+
res.setHeader('Vary', 'Origin');
|
|
68
|
+
}
|
|
69
|
+
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
|
70
|
+
next();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Dev mode = running from a checkout (not from an npm-install location).
|
|
74
|
+
// Used to gate two things: (a) serving static frontend from local public/
|
|
75
|
+
// so a contributor can iterate without pushing to GH Pages; (b) hot-reload
|
|
76
|
+
// SSE endpoint that watches public/ for changes. CCSM_NO_DEV=1 disables
|
|
77
|
+
// both explicitly. In production (npm-installed), backend is API-only —
|
|
78
|
+
// frontend lives at https://bakapiano.github.io/ccsm/ (router → per-version).
|
|
79
|
+
const IS_DEV = !__dirname.includes(`${path.sep}node_modules${path.sep}`) && process.env.CCSM_NO_DEV !== '1';
|
|
80
|
+
|
|
81
|
+
if (IS_DEV) {
|
|
82
|
+
app.use(express.static(path.join(__dirname, 'public')));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const reloadClients = new Set();
|
|
86
|
+
if (IS_DEV) {
|
|
87
|
+
app.get('/api/dev/ping', (_req, res) => res.json({ dev: true }));
|
|
88
|
+
app.get('/api/dev/reload', (req, res) => {
|
|
89
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
90
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
91
|
+
res.setHeader('Connection', 'keep-alive');
|
|
92
|
+
res.flushHeaders();
|
|
93
|
+
res.write(': connected\n\n');
|
|
94
|
+
reloadClients.add(res);
|
|
95
|
+
const hb = setInterval(() => { try { res.write(': ping\n\n'); } catch {} }, 25000);
|
|
96
|
+
req.on('close', () => { clearInterval(hb); reloadClients.delete(res); });
|
|
97
|
+
});
|
|
98
|
+
const publicDir = path.join(__dirname, 'public');
|
|
99
|
+
const fs = require('node:fs');
|
|
100
|
+
let debounce = null;
|
|
101
|
+
fs.watch(publicDir, { recursive: true }, (_event, filename) => {
|
|
102
|
+
clearTimeout(debounce);
|
|
103
|
+
debounce = setTimeout(() => {
|
|
104
|
+
if (reloadClients.size === 0) return;
|
|
105
|
+
console.log(`[dev] reload · ${filename || '?'} → ${reloadClients.size} client(s)`);
|
|
106
|
+
for (const r of reloadClients) {
|
|
107
|
+
try { r.write(`event: reload\ndata: ${Date.now()}\n\n`); } catch {}
|
|
108
|
+
}
|
|
109
|
+
}, 80);
|
|
110
|
+
});
|
|
111
|
+
console.log('[dev] hot-reload watching public/');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function asyncH(fn) {
|
|
115
|
+
return (req, res) => {
|
|
116
|
+
Promise.resolve(fn(req, res)).catch((err) => {
|
|
117
|
+
console.error('[api error]', err);
|
|
118
|
+
res.status(500).json({ error: String(err && err.message || err) });
|
|
119
|
+
});
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---- helpers ----
|
|
124
|
+
|
|
125
|
+
function pickCli(cfg, requestedId) {
|
|
126
|
+
const wanted = requestedId || cfg.defaultCliId;
|
|
127
|
+
return cfg.clis.find((c) => c.id === wanted) || cfg.clis[0];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Resolve how to spawn a CLI command. Windows quirks:
|
|
131
|
+
// v1.1 — spawn strategy is now caller-controlled via cli.shell:
|
|
132
|
+
// 'direct' — pty.spawn(command, args). Real .exe / absolute paths only.
|
|
133
|
+
// Won't find pwsh aliases / functions.
|
|
134
|
+
// 'pwsh' — wrap in `pwsh.exe -NoLogo -NoExit -Command "& { cmd args }"`.
|
|
135
|
+
// Loads $PROFILE → pwsh aliases / functions (`ccp`, `cxp`) work.
|
|
136
|
+
// Falls back to powershell.exe (5.x) if pwsh.exe absent.
|
|
137
|
+
// 'cmd' — wrap in `cmd.exe /d /s /c "cmd args"`. Resolves doskey aliases
|
|
138
|
+
// and PATH-only names without pwsh dependency.
|
|
139
|
+
function resolveCommand(commandRaw, userArgs = [], shell = 'direct') {
|
|
140
|
+
if (!commandRaw) throw new Error('cli.command is empty');
|
|
141
|
+
const cmd = commandRaw.replace(/^\.[\\\/]/, '');
|
|
142
|
+
|
|
143
|
+
if (shell === 'pwsh') {
|
|
144
|
+
// Build a single -Command string so pwsh tokenizes args itself. The
|
|
145
|
+
// `& { ... }` wrapper makes pwsh execute the line as a script block —
|
|
146
|
+
// critical for functions (which aren't visible without invocation).
|
|
147
|
+
const joined = [cmd, ...userArgs.map(quoteForPwsh)].join(' ');
|
|
148
|
+
return {
|
|
149
|
+
exe: 'pwsh.exe',
|
|
150
|
+
prefixArgs: ['-NoLogo', '-NoExit', '-Command', `& { ${joined} }`],
|
|
151
|
+
fallbackExe: 'powershell.exe',
|
|
152
|
+
consumesUserArgs: true,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (shell === 'cmd') {
|
|
157
|
+
// /d skips AutoRun, /s preserves quoting, /c runs and exits.
|
|
158
|
+
const joined = [cmd, ...userArgs.map(quoteForCmd)].join(' ');
|
|
159
|
+
return {
|
|
160
|
+
exe: process.env.ComSpec || 'cmd.exe',
|
|
161
|
+
prefixArgs: ['/d', '/s', '/c', joined],
|
|
162
|
+
consumesUserArgs: true,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// shell === 'direct' — bare pty.spawn. Honour .cmd/.bat/.ps1 extensions
|
|
167
|
+
// when an absolute path was provided so they still work without an
|
|
168
|
+
// explicit shell choice.
|
|
169
|
+
if (path.isAbsolute(cmd)) {
|
|
170
|
+
const ext = path.extname(cmd).toLowerCase();
|
|
171
|
+
if (ext === '.cmd' || ext === '.bat') {
|
|
172
|
+
return { exe: process.env.ComSpec || 'cmd.exe', prefixArgs: ['/d', '/s', '/c', cmd], consumesUserArgs: false };
|
|
173
|
+
}
|
|
174
|
+
if (ext === '.ps1') {
|
|
175
|
+
return { exe: 'powershell.exe', prefixArgs: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', cmd], consumesUserArgs: false };
|
|
176
|
+
}
|
|
177
|
+
return { exe: cmd, prefixArgs: [], consumesUserArgs: false };
|
|
178
|
+
}
|
|
179
|
+
// Bare name with shell=direct: defer to cmd.exe so Windows resolves
|
|
180
|
+
// against PATH. Same behavior as before — preserves user expectations
|
|
181
|
+
// for `claude` / `codex` configs that don't set shell.
|
|
182
|
+
return { exe: process.env.ComSpec || 'cmd.exe', prefixArgs: ['/d', '/s', '/c', cmd], consumesUserArgs: false };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function quoteForPwsh(s) {
|
|
186
|
+
if (s === '' || /[\s'"`$]/.test(s)) return `'${String(s).replace(/'/g, "''")}'`;
|
|
187
|
+
return s;
|
|
188
|
+
}
|
|
189
|
+
function quoteForCmd(s) {
|
|
190
|
+
if (s === '' || /[\s"&|<>^]/.test(s)) return `"${String(s).replace(/"/g, '""')}"`;
|
|
191
|
+
return s;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function spawnCliSession({ cli, cwd, sessionId, meta, extraArgs = [] }) {
|
|
195
|
+
if (!webTerminal.available) {
|
|
196
|
+
const e = new Error('node-pty unavailable · cannot spawn web terminal');
|
|
197
|
+
e.code = 'PTY_UNAVAILABLE';
|
|
198
|
+
throw e;
|
|
199
|
+
}
|
|
200
|
+
// For shell wrappers (pwsh/cmd) we need to bake BOTH cli.args and
|
|
201
|
+
// extraArgs into the single quoted command string — otherwise extraArgs
|
|
202
|
+
// would become args to the shell itself, not the wrapped command.
|
|
203
|
+
// Re-resolve here when extraArgs is present so the quoting is correct.
|
|
204
|
+
const resolved = resolveCommand(
|
|
205
|
+
cli.command,
|
|
206
|
+
[...(cli.args || []), ...extraArgs],
|
|
207
|
+
cli.shell || 'direct',
|
|
208
|
+
);
|
|
209
|
+
const { exe, prefixArgs, fallbackExe, consumesUserArgs } = resolved;
|
|
210
|
+
const args = consumesUserArgs
|
|
211
|
+
? prefixArgs
|
|
212
|
+
: [...prefixArgs, ...(cli.args || []), ...extraArgs];
|
|
213
|
+
// Merge user-scope PATH from registry into the env we hand the PTY.
|
|
214
|
+
// spawnEnv() also strips duplicate path-case keys so our override
|
|
215
|
+
// doesn't get shadowed by the inherited `Path` from process.env.
|
|
216
|
+
const env = spawnEnv(cli.env);
|
|
217
|
+
const trySpawn = (executable) => webTerminal.spawn({
|
|
218
|
+
id: sessionId,
|
|
219
|
+
command: executable,
|
|
220
|
+
args,
|
|
221
|
+
cwd,
|
|
222
|
+
env,
|
|
223
|
+
meta: { ...meta, cliId: cli.id, cliName: cli.name },
|
|
224
|
+
onData: () => { persistedSessions.touch(sessionId).catch(() => {}); },
|
|
225
|
+
onExit: ({ exitCode }) => {
|
|
226
|
+
persistedSessions.markExited(sessionId, exitCode).catch(() => {});
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
try {
|
|
230
|
+
const entry = trySpawn(exe);
|
|
231
|
+
return entry;
|
|
232
|
+
} catch (e) {
|
|
233
|
+
if (fallbackExe && /ENOENT|cannot find|not recognized/i.test(String(e && e.message || e))) {
|
|
234
|
+
const entry = trySpawn(fallbackExe);
|
|
235
|
+
return entry;
|
|
236
|
+
}
|
|
237
|
+
throw e;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Read user PATH from registry once at boot, prepend to process PATH.
|
|
242
|
+
// On platforms other than Windows or if the read fails, fall back to
|
|
243
|
+
// process.env.PATH unchanged.
|
|
244
|
+
let mergedUserPath = null;
|
|
245
|
+
function buildMergedUserPath() {
|
|
246
|
+
if (process.platform !== 'win32') return process.env.PATH;
|
|
247
|
+
try {
|
|
248
|
+
const { spawnSync } = require('node:child_process');
|
|
249
|
+
const r = spawnSync('reg.exe', ['query', 'HKCU\\Environment', '/v', 'PATH'], { encoding: 'utf8', windowsHide: true });
|
|
250
|
+
if (r.status !== 0 || !r.stdout) return process.env.PATH;
|
|
251
|
+
const line = r.stdout.split(/\r?\n/).find((l) => /\bPATH\b/i.test(l) && /REG_(EXPAND_)?SZ/i.test(l));
|
|
252
|
+
if (!line) return process.env.PATH;
|
|
253
|
+
const m = line.match(/REG_(?:EXPAND_)?SZ\s+(.+)$/);
|
|
254
|
+
if (!m) return process.env.PATH;
|
|
255
|
+
// Expand %VAR% references manually (REG_EXPAND_SZ keeps them literal).
|
|
256
|
+
const userPath = m[1].replace(/%([^%]+)%/g, (_, name) => process.env[name] || '');
|
|
257
|
+
const existing = (process.env.PATH || '').split(';').map((s) => s.trim()).filter(Boolean);
|
|
258
|
+
const adds = userPath.split(';').map((s) => s.trim()).filter(Boolean);
|
|
259
|
+
const merged = [];
|
|
260
|
+
const seen = new Set();
|
|
261
|
+
for (const p of [...adds, ...existing]) {
|
|
262
|
+
const k = p.toLowerCase();
|
|
263
|
+
if (seen.has(k)) continue;
|
|
264
|
+
seen.add(k);
|
|
265
|
+
merged.push(p);
|
|
266
|
+
}
|
|
267
|
+
return merged.join(';');
|
|
268
|
+
} catch {
|
|
269
|
+
return process.env.PATH;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
mergedUserPath = buildMergedUserPath();
|
|
273
|
+
|
|
274
|
+
// Hand back a fresh env for spawning a child, with PATH overridden by
|
|
275
|
+
// our merged user PATH and any duplicate case variants of "path"
|
|
276
|
+
// stripped first. Windows env lookup is case-insensitive but the env
|
|
277
|
+
// block we hand CreateProcess is an ordered byte buffer — if both
|
|
278
|
+
// `Path` (inherited from process.env, OS canonical case) and `PATH`
|
|
279
|
+
// (our override) are present, Windows resolves to whichever comes
|
|
280
|
+
// first in the block. Node's Object.keys preserves insertion order,
|
|
281
|
+
// so the inherited `Path` would win and our merged override silently
|
|
282
|
+
// disappear. Strip all path-shaped keys first, then add the merge.
|
|
283
|
+
function spawnEnv(extraEnv = {}) {
|
|
284
|
+
const env = { ...process.env, ...extraEnv };
|
|
285
|
+
if (process.platform === 'win32') {
|
|
286
|
+
for (const k of Object.keys(env)) {
|
|
287
|
+
if (k.toLowerCase() === 'path') delete env[k];
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (mergedUserPath) env.PATH = mergedUserPath;
|
|
291
|
+
return env;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---- config ----
|
|
295
|
+
|
|
296
|
+
// Per-CLI install probe. Looks up the command on PATH using `where` (win)
|
|
297
|
+
// or `which` (posix). Result is cached forever — restart ccsm after
|
|
298
|
+
// installing/uninstalling a CLI to refresh. Cheap (10ms cold, 0ms cached).
|
|
299
|
+
const cliProbeCache = new Map();
|
|
300
|
+
function probeCli(command) {
|
|
301
|
+
if (!command) return null;
|
|
302
|
+
if (cliProbeCache.has(command)) return cliProbeCache.get(command);
|
|
303
|
+
const { spawnSync } = require('node:child_process');
|
|
304
|
+
let resolvedPath = null;
|
|
305
|
+
try {
|
|
306
|
+
const isWin = process.platform === 'win32';
|
|
307
|
+
const cmd = isWin ? 'where.exe' : 'which';
|
|
308
|
+
const env = { ...process.env };
|
|
309
|
+
if (mergedUserPath) env.PATH = mergedUserPath;
|
|
310
|
+
const r = spawnSync(cmd, [command], { encoding: 'utf8', windowsHide: true, env });
|
|
311
|
+
if (r.status === 0 && r.stdout) {
|
|
312
|
+
resolvedPath = r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0] || null;
|
|
313
|
+
}
|
|
314
|
+
} catch {}
|
|
315
|
+
cliProbeCache.set(command, resolvedPath);
|
|
316
|
+
return resolvedPath;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function decorateConfigWithProbes(cfg) {
|
|
320
|
+
return {
|
|
321
|
+
...cfg,
|
|
322
|
+
clis: (cfg.clis || []).map((c) => {
|
|
323
|
+
const path = probeCli(c.command);
|
|
324
|
+
return { ...c, installed: !!path, installPath: path };
|
|
325
|
+
}),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
app.get('/api/config', asyncH(async (_req, res) => {
|
|
330
|
+
res.json(decorateConfigWithProbes(await loadConfig()));
|
|
331
|
+
}));
|
|
332
|
+
|
|
333
|
+
app.put('/api/config', asyncH(async (req, res) => {
|
|
334
|
+
const cfg = await saveConfig(req.body || {});
|
|
335
|
+
res.json(decorateConfigWithProbes(cfg));
|
|
336
|
+
}));
|
|
337
|
+
|
|
338
|
+
// ---- CLI probe / test ----
|
|
339
|
+
//
|
|
340
|
+
// Run the user's configured command with `--version` and report back
|
|
341
|
+
// stdout/stderr + whether the output looks like the claimed CLI type.
|
|
342
|
+
// Used by the Configure page "Test" button so the user can verify the
|
|
343
|
+
// command resolves + actually launches the right tool BEFORE saving.
|
|
344
|
+
// Body: { command, args?, shell?, type? }. args is ignored for the
|
|
345
|
+
// version probe — we always append `--version` directly so the user's
|
|
346
|
+
// runtime args (e.g. --dangerously-skip-permissions) don't perturb the
|
|
347
|
+
// quick probe.
|
|
348
|
+
app.post('/api/clis/test', asyncH(async (req, res) => {
|
|
349
|
+
const { spawn } = require('node:child_process');
|
|
350
|
+
const body = req.body || {};
|
|
351
|
+
const command = String(body.command || '').trim();
|
|
352
|
+
const shell = ['direct', 'pwsh', 'cmd'].includes(body.shell) ? body.shell : 'direct';
|
|
353
|
+
const type = ['claude', 'codex', 'copilot', 'other'].includes(body.type) ? body.type : 'other';
|
|
354
|
+
if (!command) return res.status(400).json({ error: 'command required' });
|
|
355
|
+
|
|
356
|
+
// Build the test exec. Same shell-wrapping rules as resolveCommand,
|
|
357
|
+
// but we force `--version` as the only arg and we DROP `-NoExit`
|
|
358
|
+
// from the pwsh wrapper so pwsh terminates after printing.
|
|
359
|
+
let exe, args;
|
|
360
|
+
const cmd = command.replace(/^\.[\\\/]/, '');
|
|
361
|
+
const versionArg = '--version';
|
|
362
|
+
if (shell === 'pwsh') {
|
|
363
|
+
const joined = `& ${/[\s'"\`$]/.test(cmd) ? `'${cmd.replace(/'/g, "''")}'` : cmd} ${versionArg}`;
|
|
364
|
+
exe = 'pwsh.exe';
|
|
365
|
+
args = ['-NoLogo', '-Command', joined];
|
|
366
|
+
} else if (shell === 'cmd') {
|
|
367
|
+
exe = process.env.ComSpec || 'cmd.exe';
|
|
368
|
+
args = ['/d', '/s', '/c', `${cmd} ${versionArg}`];
|
|
369
|
+
} else if (path.isAbsolute(cmd)) {
|
|
370
|
+
const ext = path.extname(cmd).toLowerCase();
|
|
371
|
+
if (ext === '.cmd' || ext === '.bat') {
|
|
372
|
+
exe = process.env.ComSpec || 'cmd.exe';
|
|
373
|
+
args = ['/d', '/s', '/c', cmd, versionArg];
|
|
374
|
+
} else if (ext === '.ps1') {
|
|
375
|
+
exe = 'powershell.exe';
|
|
376
|
+
args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', cmd, versionArg];
|
|
377
|
+
} else {
|
|
378
|
+
exe = cmd;
|
|
379
|
+
args = [versionArg];
|
|
380
|
+
}
|
|
381
|
+
} else {
|
|
382
|
+
exe = process.env.ComSpec || 'cmd.exe';
|
|
383
|
+
args = ['/d', '/s', '/c', cmd, versionArg];
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const t0 = Date.now();
|
|
387
|
+
let stdout = '';
|
|
388
|
+
let stderr = '';
|
|
389
|
+
let exitCode = null;
|
|
390
|
+
let timedOut = false;
|
|
391
|
+
let spawnError = null;
|
|
392
|
+
try {
|
|
393
|
+
const child = spawn(exe, args, { env: spawnEnv(), windowsHide: true });
|
|
394
|
+
const killer = setTimeout(() => { timedOut = true; try { child.kill(); } catch {} }, 5000);
|
|
395
|
+
child.stdout.on('data', (d) => { stdout += d.toString(); if (stdout.length > 8192) stdout = stdout.slice(0, 8192); });
|
|
396
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); if (stderr.length > 8192) stderr = stderr.slice(0, 8192); });
|
|
397
|
+
exitCode = await new Promise((resolve, reject) => {
|
|
398
|
+
child.on('exit', (code) => { clearTimeout(killer); resolve(code); });
|
|
399
|
+
child.on('error', (err) => { clearTimeout(killer); reject(err); });
|
|
400
|
+
});
|
|
401
|
+
} catch (e) {
|
|
402
|
+
spawnError = String(e && e.message || e);
|
|
403
|
+
}
|
|
404
|
+
const durationMs = Date.now() - t0;
|
|
405
|
+
|
|
406
|
+
const out = (stdout + '\n' + stderr).toLowerCase();
|
|
407
|
+
const PATTERNS = {
|
|
408
|
+
claude: /claude/,
|
|
409
|
+
codex: /codex|openai/,
|
|
410
|
+
copilot: /copilot/,
|
|
411
|
+
};
|
|
412
|
+
const matchedType = type === 'other' ? null : (PATTERNS[type] ? PATTERNS[type].test(out) : null);
|
|
413
|
+
const ok = !spawnError && !timedOut && exitCode === 0;
|
|
414
|
+
res.json({
|
|
415
|
+
ok, exitCode, durationMs, timedOut, spawnError,
|
|
416
|
+
stdout: stdout.trim(),
|
|
417
|
+
stderr: stderr.trim(),
|
|
418
|
+
matchedType,
|
|
419
|
+
expectedType: type,
|
|
420
|
+
spawned: { exe, args },
|
|
421
|
+
});
|
|
422
|
+
}));
|
|
423
|
+
|
|
424
|
+
// ---- folders ----
|
|
425
|
+
|
|
426
|
+
app.get('/api/folders', asyncH(async (_req, res) => {
|
|
427
|
+
const list = await folders.loadAll();
|
|
428
|
+
list.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
429
|
+
res.json({ folders: list });
|
|
430
|
+
}));
|
|
431
|
+
|
|
432
|
+
app.post('/api/folders', asyncH(async (req, res) => {
|
|
433
|
+
const name = req.body && req.body.name;
|
|
434
|
+
if (!name) return res.status(400).json({ error: 'name required' });
|
|
435
|
+
res.json({ folder: await folders.create({ name }) });
|
|
436
|
+
}));
|
|
437
|
+
|
|
438
|
+
app.put('/api/folders/:id', asyncH(async (req, res) => {
|
|
439
|
+
const updated = await folders.update(req.params.id, req.body || {});
|
|
440
|
+
if (!updated) return res.status(404).json({ error: 'not found' });
|
|
441
|
+
res.json({ folder: updated });
|
|
442
|
+
}));
|
|
443
|
+
|
|
444
|
+
app.delete('/api/folders/:id', asyncH(async (req, res) => {
|
|
445
|
+
// Move all sessions in this folder to Unsorted before delete.
|
|
446
|
+
const all = await persistedSessions.loadAll();
|
|
447
|
+
for (const s of all) {
|
|
448
|
+
if (s.folderId === req.params.id) {
|
|
449
|
+
await persistedSessions.setFolder(s.id, null);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
const removed = await folders.remove(req.params.id);
|
|
453
|
+
res.json({ removed });
|
|
454
|
+
}));
|
|
455
|
+
|
|
456
|
+
app.post('/api/folders/reorder', asyncH(async (req, res) => {
|
|
457
|
+
const ids = req.body && req.body.ids;
|
|
458
|
+
if (!Array.isArray(ids)) return res.status(400).json({ error: 'ids array required' });
|
|
459
|
+
const next = await folders.reorder(ids);
|
|
460
|
+
res.json({ folders: next });
|
|
461
|
+
}));
|
|
462
|
+
|
|
463
|
+
// ---- sessions (persisted, ccsm-owned) ----
|
|
464
|
+
|
|
465
|
+
app.get('/api/sessions', asyncH(async (_req, res) => {
|
|
466
|
+
const list = await persistedSessions.loadAll();
|
|
467
|
+
// Cross-check status against live PTY pool so a stale "running" record
|
|
468
|
+
// doesn't survive a server restart.
|
|
469
|
+
const live = new Set(webTerminal.list().filter((t) => !t.exitedAt).map((t) => t.id));
|
|
470
|
+
for (const s of list) {
|
|
471
|
+
if (s.status === 'running' && !live.has(s.id)) {
|
|
472
|
+
s.status = 'exited';
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
// Per-session activity probe (transcript mtime → working/idle). Cheap
|
|
476
|
+
// when cached — most calls are a single fs.stat(). Only runs for
|
|
477
|
+
// running sessions; exited ones get 'unknown'.
|
|
478
|
+
const cfg = await loadConfig();
|
|
479
|
+
const cliById = new Map((cfg.clis || []).map((c) => [c.id, c]));
|
|
480
|
+
const { probeActivity } = require('./lib/cliActivity');
|
|
481
|
+
await Promise.all(list.map(async (s) => {
|
|
482
|
+
if (s.status !== 'running') { s.activity = 'unknown'; return; }
|
|
483
|
+
try { s.activity = await probeActivity(s, cliById.get(s.cliId)); }
|
|
484
|
+
catch { s.activity = 'unknown'; }
|
|
485
|
+
}));
|
|
486
|
+
res.json({ sessions: list, takenAt: Date.now() });
|
|
487
|
+
}));
|
|
488
|
+
|
|
489
|
+
app.put('/api/sessions/:id', asyncH(async (req, res) => {
|
|
490
|
+
const patch = {};
|
|
491
|
+
if (typeof req.body.title === 'string') patch.title = req.body.title;
|
|
492
|
+
if ('folderId' in (req.body || {})) patch.folderId = req.body.folderId || null;
|
|
493
|
+
const updated = await persistedSessions.update(req.params.id, patch);
|
|
494
|
+
if (!updated) return res.status(404).json({ error: 'not found' });
|
|
495
|
+
res.json({ session: updated });
|
|
496
|
+
}));
|
|
497
|
+
|
|
498
|
+
app.delete('/api/sessions/:id', asyncH(async (req, res) => {
|
|
499
|
+
// Kill PTY first if it's still alive, then drop the record.
|
|
500
|
+
try { webTerminal.kill(req.params.id); } catch {}
|
|
501
|
+
const removed = await persistedSessions.remove(req.params.id);
|
|
502
|
+
try { require('./lib/cliActivity').releaseSession(req.params.id); } catch {}
|
|
503
|
+
res.json({ removed });
|
|
504
|
+
}));
|
|
505
|
+
|
|
506
|
+
// ---- workspaces ----
|
|
507
|
+
|
|
508
|
+
// ---- directory browser ----
|
|
509
|
+
// Lets the launch picker walk the filesystem so users can pick any
|
|
510
|
+
// existing directory as the session cwd. Returns the immediate child
|
|
511
|
+
// dirs of `path` (defaults to home), plus a few hardcoded "starts"
|
|
512
|
+
// (home, workDir, drive roots on Windows).
|
|
513
|
+
app.get('/api/browse', asyncH(async (req, res) => {
|
|
514
|
+
const fs = require('node:fs/promises');
|
|
515
|
+
const os = require('node:os');
|
|
516
|
+
const target = req.query.path ? path.resolve(String(req.query.path)) : os.homedir();
|
|
517
|
+
let entries = [];
|
|
518
|
+
let exists = true;
|
|
519
|
+
try {
|
|
520
|
+
const list = await fs.readdir(target, { withFileTypes: true });
|
|
521
|
+
entries = list
|
|
522
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith('.'))
|
|
523
|
+
.map((d) => ({ name: d.name, path: path.join(target, d.name) }))
|
|
524
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
525
|
+
} catch (e) {
|
|
526
|
+
exists = false;
|
|
527
|
+
}
|
|
528
|
+
const parent = path.dirname(target);
|
|
529
|
+
const cfg = await loadConfig();
|
|
530
|
+
const starts = [
|
|
531
|
+
{ label: 'Home', path: os.homedir() },
|
|
532
|
+
{ label: 'Work dir', path: cfg.workDir },
|
|
533
|
+
];
|
|
534
|
+
if (process.platform === 'win32') {
|
|
535
|
+
// Best-effort drive enumeration so users on D:\ etc can hop roots.
|
|
536
|
+
for (const letter of ['C', 'D', 'E', 'F', 'G', 'H']) {
|
|
537
|
+
const root = `${letter}:\\`;
|
|
538
|
+
try { await fs.access(root); starts.push({ label: `${letter}:\\`, path: root }); }
|
|
539
|
+
catch {}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
res.json({
|
|
543
|
+
path: target,
|
|
544
|
+
parent: parent === target ? null : parent,
|
|
545
|
+
exists,
|
|
546
|
+
entries,
|
|
547
|
+
starts,
|
|
548
|
+
});
|
|
549
|
+
}));
|
|
550
|
+
|
|
551
|
+
app.get('/api/workspaces', asyncH(async (req, res) => {
|
|
552
|
+
const cfg = await loadConfig();
|
|
553
|
+
const workspaces = await listWorkspaces({
|
|
554
|
+
workDir: cfg.workDir,
|
|
555
|
+
repos: cfg.repos,
|
|
556
|
+
});
|
|
557
|
+
// Recompute inUse based on persistedSessions: a workspace is in use
|
|
558
|
+
// iff any RUNNING ccsm session lives at-or-inside it.
|
|
559
|
+
const allSess = await persistedSessions.loadAll();
|
|
560
|
+
const busy = new Set(
|
|
561
|
+
allSess.filter((s) => s.status === 'running').map((s) => path.resolve(s.cwd).toLowerCase())
|
|
562
|
+
);
|
|
563
|
+
for (const w of workspaces) {
|
|
564
|
+
w.inUse = busy.has(path.resolve(w.path).toLowerCase());
|
|
565
|
+
w.sessionsHere = allSess
|
|
566
|
+
.filter((s) => s.status === 'running' && path.resolve(s.cwd).toLowerCase() === path.resolve(w.path).toLowerCase())
|
|
567
|
+
.map((s) => s.id);
|
|
568
|
+
}
|
|
569
|
+
res.json({ workDir: cfg.workDir, repos: cfg.repos, workspaces });
|
|
570
|
+
}));
|
|
571
|
+
|
|
572
|
+
// Delete a workspace directory. Refuses if any RUNNING session lives
|
|
573
|
+
// inside it, or if the resolved path escapes workDir. The name comes
|
|
574
|
+
// from the URL — we resolve it against workDir and verify containment.
|
|
575
|
+
app.delete('/api/workspaces/:name', asyncH(async (req, res) => {
|
|
576
|
+
const fsp = require('node:fs/promises');
|
|
577
|
+
const cfg = await loadConfig();
|
|
578
|
+
const name = String(req.params.name || '');
|
|
579
|
+
// Reject anything that tries to escape via separators / traversal.
|
|
580
|
+
if (!name || /[\\/]|^\.\.$|^\.$/.test(name)) {
|
|
581
|
+
return res.status(400).json({ error: 'invalid workspace name' });
|
|
582
|
+
}
|
|
583
|
+
const target = path.resolve(cfg.workDir, name);
|
|
584
|
+
if (!isInside(target, cfg.workDir) || path.resolve(target) === path.resolve(cfg.workDir)) {
|
|
585
|
+
return res.status(400).json({ error: 'workspace must live under workDir' });
|
|
586
|
+
}
|
|
587
|
+
try {
|
|
588
|
+
const st = await fsp.stat(target);
|
|
589
|
+
if (!st.isDirectory()) return res.status(400).json({ error: 'not a directory' });
|
|
590
|
+
} catch {
|
|
591
|
+
return res.status(404).json({ error: 'workspace not found' });
|
|
592
|
+
}
|
|
593
|
+
const allSess = await persistedSessions.loadAll();
|
|
594
|
+
const inUse = allSess.some((s) =>
|
|
595
|
+
s.status === 'running' && isInside(s.cwd, target)
|
|
596
|
+
);
|
|
597
|
+
if (inUse) return res.status(409).json({ error: 'workspace is in use by a running session' });
|
|
598
|
+
await fsp.rm(target, { recursive: true, force: true });
|
|
599
|
+
res.json({ ok: true });
|
|
600
|
+
}));
|
|
601
|
+
|
|
602
|
+
// ---- new session ----
|
|
603
|
+
// body: { cliId?, repos?, workspace?, folderId?, launch?: true }
|
|
604
|
+
// Streams NDJSON: workspace / clone-* / launched / done.
|
|
605
|
+
app.post('/api/sessions/new', async (req, res) => {
|
|
606
|
+
res.setHeader('Content-Type', 'application/x-ndjson');
|
|
607
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
608
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
609
|
+
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
|
610
|
+
|
|
611
|
+
const emit = (obj) => { res.write(JSON.stringify(obj) + '\n'); };
|
|
612
|
+
const fail = (msg, extra) => {
|
|
613
|
+
emit({ type: 'done', success: false, error: msg, ...extra });
|
|
614
|
+
res.end();
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
try {
|
|
618
|
+
const cfg = await loadConfig();
|
|
619
|
+
const cli = pickCli(cfg, req.body && req.body.cliId);
|
|
620
|
+
if (!cli) return fail('No CLI configured. Add one in Configure → CLIs.');
|
|
621
|
+
|
|
622
|
+
const explicitRepos = Array.isArray(req.body && req.body.repos);
|
|
623
|
+
const wantedNames = explicitRepos
|
|
624
|
+
? req.body.repos
|
|
625
|
+
: cfg.repos.filter((r) => r.defaultSelected).map((r) => r.name);
|
|
626
|
+
const wantedRepos = cfg.repos.filter((r) => wantedNames.includes(r.name));
|
|
627
|
+
if (wantedRepos.length === 0 && !explicitRepos && wantedNames.length > 0) {
|
|
628
|
+
return fail('No matching repos found');
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
let workspace;
|
|
632
|
+
let created = false;
|
|
633
|
+
// Three cwd modes:
|
|
634
|
+
// 1. body.cwd — user picked an existing directory; skip clone.
|
|
635
|
+
// 2. body.workspace — reuse a named workspace under workDir.
|
|
636
|
+
// 3. (neither) — auto-allocate a fresh ws-N.
|
|
637
|
+
if (req.body && req.body.cwd) {
|
|
638
|
+
const fsmod = require('node:fs/promises');
|
|
639
|
+
const cwd = path.resolve(String(req.body.cwd));
|
|
640
|
+
try {
|
|
641
|
+
const st = await fsmod.stat(cwd);
|
|
642
|
+
if (!st.isDirectory()) return fail(`${cwd} is not a directory`);
|
|
643
|
+
} catch {
|
|
644
|
+
return fail(`directory not found: ${cwd}`);
|
|
645
|
+
}
|
|
646
|
+
workspace = { name: path.basename(cwd) || cwd, path: cwd };
|
|
647
|
+
} else if (req.body && req.body.workspace) {
|
|
648
|
+
const all = await listWorkspaces({ workDir: cfg.workDir, repos: cfg.repos });
|
|
649
|
+
workspace = all.find((w) => w.name === req.body.workspace);
|
|
650
|
+
if (!workspace) return fail(`workspace ${req.body.workspace} not found`);
|
|
651
|
+
} else {
|
|
652
|
+
const r = await findOrCreateWorkspace({
|
|
653
|
+
workDir: cfg.workDir,
|
|
654
|
+
repos: cfg.repos,
|
|
655
|
+
requireUnused: true,
|
|
656
|
+
});
|
|
657
|
+
workspace = r.workspace;
|
|
658
|
+
created = r.created;
|
|
659
|
+
}
|
|
660
|
+
emit({ type: 'workspace', workspace, created });
|
|
661
|
+
|
|
662
|
+
// Skip clone entirely when user picked an existing directory — we
|
|
663
|
+
// don't want to dump random repos into someone's project.
|
|
664
|
+
const cloneResults = (req.body && req.body.cwd) ? [] : await ensureReposInWorkspace({
|
|
665
|
+
workspacePath: workspace.path,
|
|
666
|
+
repos: wantedRepos,
|
|
667
|
+
onRepoStart: (repo) =>
|
|
668
|
+
emit({ type: 'clone-start', repo: repo.name, url: repo.url }),
|
|
669
|
+
onProgress: (repo, p) =>
|
|
670
|
+
emit({ type: 'clone-progress', repo: repo.name, ...p }),
|
|
671
|
+
onLine: (repo, line) =>
|
|
672
|
+
emit({ type: 'clone-line', repo: repo.name, line }),
|
|
673
|
+
onRepoEnd: (repo, result) =>
|
|
674
|
+
emit({ type: 'clone-end', repo: repo.name, ...result }),
|
|
675
|
+
});
|
|
676
|
+
const failed = cloneResults.filter((r) => !r.ok);
|
|
677
|
+
if (failed.length > 0) return fail('Some repos failed to clone', { cloneResults });
|
|
678
|
+
|
|
679
|
+
const shouldLaunch = req.body && req.body.launch !== false;
|
|
680
|
+
let launched = null;
|
|
681
|
+
if (shouldLaunch) {
|
|
682
|
+
// Pre-assign the upstream CLI session UUID so we never have to
|
|
683
|
+
// poll/scan the transcript dir to find out what id the CLI picked.
|
|
684
|
+
// - claude / copilot expose `--session-id <uuid>` natively.
|
|
685
|
+
// - codex has no flag, but accepts `resume <uuid>` against a
|
|
686
|
+
// pre-existing rollout file. We seed a fake file (see
|
|
687
|
+
// lib/codexSeed.js) so the first launch is a resume against
|
|
688
|
+
// our seed; codex then appends to the same file.
|
|
689
|
+
const newIdTpl = Array.isArray(cli.newSessionIdArgs) ? cli.newSessionIdArgs : [];
|
|
690
|
+
const preAssignedId = newIdTpl.length > 0 ? crypto.randomUUID() : null;
|
|
691
|
+
const newSessionArgs = preAssignedId
|
|
692
|
+
? newIdTpl.map((a) => (typeof a === 'string' ? a.replace(/<id>/g, preAssignedId) : a))
|
|
693
|
+
: [];
|
|
694
|
+
|
|
695
|
+
if (preAssignedId && cli.type === 'codex') {
|
|
696
|
+
try {
|
|
697
|
+
const { seedCodexSession } = require('./lib/codexSeed');
|
|
698
|
+
await seedCodexSession({ id: preAssignedId, cwd: workspace.path, cli });
|
|
699
|
+
} catch (e) {
|
|
700
|
+
return fail(`codex seed failed: ${e.message}`);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Create the persistedSessions record FIRST so spawnCliSession can
|
|
705
|
+
// use its id as the PTY id (matching ids simplify resume/attach).
|
|
706
|
+
const record = await persistedSessions.create({
|
|
707
|
+
cliId: cli.id,
|
|
708
|
+
cwd: workspace.path,
|
|
709
|
+
workspace: workspace.name,
|
|
710
|
+
repos: wantedRepos.map((r) => r.name),
|
|
711
|
+
folderId: (req.body && req.body.folderId) || null,
|
|
712
|
+
title: '',
|
|
713
|
+
cliSessionId: preAssignedId || undefined,
|
|
714
|
+
});
|
|
715
|
+
try {
|
|
716
|
+
const entry = spawnCliSession({
|
|
717
|
+
cli,
|
|
718
|
+
cwd: workspace.path,
|
|
719
|
+
sessionId: record.id,
|
|
720
|
+
meta: { title: workspace.name, workspace: workspace.name, cwd: workspace.path },
|
|
721
|
+
extraArgs: newSessionArgs,
|
|
722
|
+
});
|
|
723
|
+
await persistedSessions.markRunning(record.id, entry.meta.pid);
|
|
724
|
+
launched = { id: record.id, pid: entry.meta.pid, cliId: cli.id };
|
|
725
|
+
emit({ type: 'launched', launched });
|
|
726
|
+
} catch (e) {
|
|
727
|
+
await persistedSessions.markExited(record.id, null);
|
|
728
|
+
return fail(`spawn failed: ${e.message}`);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
emit({ type: 'done', success: true, workspace, created, cloneResults, launched });
|
|
733
|
+
res.end();
|
|
734
|
+
} catch (e) {
|
|
735
|
+
console.error('[/api/sessions/new]', e);
|
|
736
|
+
fail(String(e && e.message || e));
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
// ---- list local CLI sessions discovered on disk (for "adopt") ----
|
|
741
|
+
// Returns sessions found in ~/.claude / ~/.codex / ~/.copilot that
|
|
742
|
+
// aren't yet adopted by ccsm. Frontend uses this in the Import modal.
|
|
743
|
+
app.get('/api/cli-sessions/:cliType', asyncH(async (req, res) => {
|
|
744
|
+
const type = String(req.params.cliType || '').toLowerCase();
|
|
745
|
+
if (!['claude', 'codex', 'copilot'].includes(type)) {
|
|
746
|
+
return res.status(400).json({ error: `unsupported cli type: ${type}` });
|
|
747
|
+
}
|
|
748
|
+
const offset = Math.max(0, Number(req.query.offset) || 0);
|
|
749
|
+
const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 30));
|
|
750
|
+
|
|
751
|
+
const [page, adopted] = await Promise.all([
|
|
752
|
+
localCliSessions.listPaginated(type, { offset, limit }),
|
|
753
|
+
persistedSessions.loadAll(),
|
|
754
|
+
]);
|
|
755
|
+
|
|
756
|
+
const adoptedIds = new Set(adopted.map((s) => s.cliSessionId).filter(Boolean));
|
|
757
|
+
const sessions = page.sessions.map((s) => ({
|
|
758
|
+
...s,
|
|
759
|
+
adopted: adoptedIds.has(s.cliSessionId),
|
|
760
|
+
}));
|
|
761
|
+
res.json({
|
|
762
|
+
sessions,
|
|
763
|
+
totalActive: page.totalActive,
|
|
764
|
+
totalNonActive: page.totalNonActive,
|
|
765
|
+
total: page.totalActive + page.totalNonActive,
|
|
766
|
+
offset: page.offset,
|
|
767
|
+
limit: page.limit,
|
|
768
|
+
hasMore: page.hasMore,
|
|
769
|
+
});
|
|
770
|
+
}));
|
|
771
|
+
|
|
772
|
+
// ---- adopt: create a ccsm record pointing at an existing CLI session ----
|
|
773
|
+
// Body: { cliId, cliSessionId, cwd, title?, folderId? }
|
|
774
|
+
// Doesn't spawn — the new entry shows up as "exited" in the sidebar;
|
|
775
|
+
// clicking it kicks off the regular resume flow which uses
|
|
776
|
+
// `cli.resumeIdArgs` ('--resume <id>') so the upstream session reattaches.
|
|
777
|
+
app.post('/api/sessions/adopt', asyncH(async (req, res) => {
|
|
778
|
+
const { cliId, cliSessionId, cwd, title, folderId } = req.body || {};
|
|
779
|
+
if (!cliId || !cliSessionId || !cwd) {
|
|
780
|
+
return res.status(400).json({ error: 'cliId, cliSessionId and cwd required' });
|
|
781
|
+
}
|
|
782
|
+
const cfg = await loadConfig();
|
|
783
|
+
const cli = pickCli(cfg, cliId);
|
|
784
|
+
if (!cli) return res.status(400).json({ error: `CLI ${cliId} not configured` });
|
|
785
|
+
|
|
786
|
+
// Normalize the cwd up front. /api/sessions/new also resolves cwd, and
|
|
787
|
+
// the workspaces "in use" check (GET /api/workspaces) does
|
|
788
|
+
// path.resolve(s.cwd).toLowerCase() — adopted records must match the
|
|
789
|
+
// same shape, otherwise an adopted+running session leaves its
|
|
790
|
+
// workspace falsely marked as free and a fresh launch could collide.
|
|
791
|
+
const resolvedCwd = path.resolve(cwd);
|
|
792
|
+
try {
|
|
793
|
+
const fsmod = require('node:fs/promises');
|
|
794
|
+
const st = await fsmod.stat(resolvedCwd);
|
|
795
|
+
if (!st.isDirectory()) {
|
|
796
|
+
return res.status(400).json({ error: `cwd is not a directory: ${resolvedCwd}` });
|
|
797
|
+
}
|
|
798
|
+
} catch (e) {
|
|
799
|
+
return res.status(400).json({ error: `cwd not found: ${resolvedCwd}` });
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Refuse duplicates: if any ccsm record already owns this upstream
|
|
803
|
+
// session id, return it so the caller can jump to it.
|
|
804
|
+
const all = await persistedSessions.loadAll();
|
|
805
|
+
const dup = all.find((s) => s.cliSessionId === cliSessionId);
|
|
806
|
+
if (dup) return res.json({ session: dup, alreadyAdopted: true });
|
|
807
|
+
|
|
808
|
+
const workspace = path.basename(resolvedCwd) || resolvedCwd;
|
|
809
|
+
// Create directly with status='exited' + cliSessionId set, so a
|
|
810
|
+
// concurrent GET /api/sessions can never observe a "running but no
|
|
811
|
+
// PTY" intermediate state.
|
|
812
|
+
const record = await persistedSessions.create({
|
|
813
|
+
cliId,
|
|
814
|
+
cwd: resolvedCwd,
|
|
815
|
+
workspace,
|
|
816
|
+
folderId: folderId || null,
|
|
817
|
+
title: title || '',
|
|
818
|
+
repos: [],
|
|
819
|
+
status: 'exited',
|
|
820
|
+
cliSessionId,
|
|
821
|
+
});
|
|
822
|
+
res.json({ session: record, alreadyAdopted: false });
|
|
823
|
+
}));
|
|
824
|
+
|
|
825
|
+
// ---- resume a previous session in the same cwd / cli ----
|
|
826
|
+
app.post('/api/sessions/:id/resume', asyncH(async (req, res) => {
|
|
827
|
+
const record = await persistedSessions.get(req.params.id);
|
|
828
|
+
if (!record) return res.status(404).json({ error: 'session not found' });
|
|
829
|
+
// Already running and attached → no-op, just return its id.
|
|
830
|
+
const live = webTerminal.get(record.id);
|
|
831
|
+
if (live && !live.exitedAt) {
|
|
832
|
+
// Pool says we're alive but the record may be stale (e.g. a prior
|
|
833
|
+
// markRunning got clobbered by an OLD entry's onExit before the
|
|
834
|
+
// respawn-guard landed, or boot mark-exited ran after a pool entry
|
|
835
|
+
// was already wired). Reconcile the file to match the pool so the
|
|
836
|
+
// frontend doesn't get stuck on "Resuming session…" forever.
|
|
837
|
+
if (record.status !== 'running' || record.pid !== live.meta.pid) {
|
|
838
|
+
try { await persistedSessions.markRunning(record.id, live.meta.pid); } catch {}
|
|
839
|
+
}
|
|
840
|
+
return res.json({ launched: { id: record.id, pid: live.meta.pid, cliId: record.cliId } });
|
|
841
|
+
}
|
|
842
|
+
const cfg = await loadConfig();
|
|
843
|
+
const cli = pickCli(cfg, record.cliId);
|
|
844
|
+
if (!cli) return res.status(400).json({ error: `CLI ${record.cliId} no longer configured` });
|
|
845
|
+
try {
|
|
846
|
+
// Resume always uses the captured upstream session UUID. With the
|
|
847
|
+
// pre-assignment refactor every ccsm-launched session has one (via
|
|
848
|
+
// newSessionIdArgs flag or the codex seed trick), and adopted
|
|
849
|
+
// sessions inherit theirs from the disk scan.
|
|
850
|
+
const extraArgs = buildResumeArgs(cli, record);
|
|
851
|
+
const entry = spawnCliSession({
|
|
852
|
+
cli,
|
|
853
|
+
cwd: record.cwd,
|
|
854
|
+
sessionId: record.id,
|
|
855
|
+
meta: { title: record.title || record.workspace, workspace: record.workspace, cwd: record.cwd },
|
|
856
|
+
extraArgs,
|
|
857
|
+
});
|
|
858
|
+
await persistedSessions.markRunning(record.id, entry.meta.pid);
|
|
859
|
+
res.json({ launched: { id: record.id, pid: entry.meta.pid, cliId: cli.id } });
|
|
860
|
+
} catch (e) {
|
|
861
|
+
res.status(500).json({ error: e.message });
|
|
862
|
+
}
|
|
863
|
+
}));
|
|
864
|
+
|
|
865
|
+
// Build the args appended on resume: substitute the captured upstream
|
|
866
|
+
// session UUID into cli.resumeIdArgs (e.g. ['--resume', '<id>'] →
|
|
867
|
+
// ['--resume', '7c28...']). Throws if either piece is missing — by
|
|
868
|
+
// design every ccsm session has a pre-assigned id, so missing one means
|
|
869
|
+
// something upstream is misconfigured (adopt without id, user-added CLI
|
|
870
|
+
// without resumeIdArgs, etc.) and we surface that instead of silently
|
|
871
|
+
// re-launching without the id.
|
|
872
|
+
function buildResumeArgs(cli, record) {
|
|
873
|
+
const id = record.cliSessionId;
|
|
874
|
+
const tpl = Array.isArray(cli.resumeIdArgs) ? cli.resumeIdArgs : [];
|
|
875
|
+
if (!id) throw new Error(`session ${record.id} has no cliSessionId — cannot resume`);
|
|
876
|
+
if (tpl.length === 0) throw new Error(`CLI ${cli.id} has no resumeIdArgs configured`);
|
|
877
|
+
return tpl.map((a) => (typeof a === 'string' ? a.replace(/<id>/g, id) : a));
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// ---- capabilities probe ----
|
|
881
|
+
app.get('/api/capabilities', (_req, res) => res.json({
|
|
882
|
+
webTerminal: webTerminal.available,
|
|
883
|
+
webTerminalError: webTerminal.available ? null : String(webTerminal.loadError?.message || 'unavailable'),
|
|
884
|
+
}));
|
|
885
|
+
|
|
886
|
+
// ---- health ----
|
|
887
|
+
const pkg = require('./package.json');
|
|
888
|
+
app.get('/api/health', (_req, res) => res.json({ ok: true, pid: process.pid, version: pkg.version, name: pkg.name }));
|
|
889
|
+
|
|
890
|
+
// ---- lifecycle ----
|
|
891
|
+
let currentPort = 0;
|
|
892
|
+
let frontendUrl = '';
|
|
893
|
+
let lastHeartbeat = Date.now();
|
|
894
|
+
let heartbeatSeen = false;
|
|
895
|
+
const HEARTBEAT_TIMEOUT_MS = 90_000;
|
|
896
|
+
|
|
897
|
+
app.post('/api/heartbeat', (_req, res) => {
|
|
898
|
+
lastHeartbeat = Date.now();
|
|
899
|
+
heartbeatSeen = true;
|
|
900
|
+
res.json({ ok: true });
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
app.post('/api/spawn-browser', asyncH(async (_req, res) => {
|
|
904
|
+
const opened = openInBrowser(frontendUrl || `http://localhost:${currentPort}`);
|
|
905
|
+
res.json({ ok: true, mode: opened.kind, url: frontendUrl });
|
|
906
|
+
}));
|
|
907
|
+
|
|
908
|
+
app.post('/api/shutdown', (_req, res) => {
|
|
909
|
+
res.json({ ok: true, bye: 'shutting down' });
|
|
910
|
+
setImmediate(() => gracefulShutdown('/api/shutdown'));
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
// Restart: in production, spawn the restart-helper detached then
|
|
914
|
+
// gracefulShutdown — the helper waits for the port to free and respawns
|
|
915
|
+
// `ccsm.cmd` (with CCSM_NO_BROWSER so we don't pop a new window — the
|
|
916
|
+
// frontend bounces through OfflineBanner / version router back into the
|
|
917
|
+
// new backend). In dev (CCSM_DEV=1, set by scripts/dev.js), we skip the
|
|
918
|
+
// helper entirely: just gracefulShutdown. scripts/dev.js sees its child
|
|
919
|
+
// exit and respawns `node --watch server.js` from the checkout, picking
|
|
920
|
+
// up any code changes.
|
|
921
|
+
let restartInFlight = false;
|
|
922
|
+
app.post('/api/restart', asyncH(async (_req, res) => {
|
|
923
|
+
if (restartInFlight) {
|
|
924
|
+
return res.status(409).json({ error: 'restart already in progress' });
|
|
925
|
+
}
|
|
926
|
+
restartInFlight = true;
|
|
927
|
+
|
|
928
|
+
if (process.env.CCSM_DEV === '1') {
|
|
929
|
+
res.json({ ok: true, started: true, mode: 'dev', closeFrontend: false });
|
|
930
|
+
setImmediate(() => gracefulShutdown('restart (dev)'));
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const fsp = require('node:fs/promises');
|
|
935
|
+
const helperSrc = path.join(__dirname, 'scripts', 'restart-helper.js');
|
|
936
|
+
const helperTmp = path.join(os.tmpdir(), `ccsm-restart-${process.pid}-${Date.now()}.js`);
|
|
937
|
+
try {
|
|
938
|
+
await fsp.copyFile(helperSrc, helperTmp);
|
|
939
|
+
} catch (e) {
|
|
940
|
+
restartInFlight = false;
|
|
941
|
+
return res.status(500).json({ error: `helper copy failed: ${e.message}` });
|
|
942
|
+
}
|
|
943
|
+
const args = [helperTmp, String(currentPort), String(process.pid)];
|
|
944
|
+
// closeFrontend asks the calling tab to window.close() itself — the
|
|
945
|
+
// helper will respawn ccsm WITHOUT CCSM_NO_BROWSER, so a fresh window
|
|
946
|
+
// pops up once the new backend is listening. Net effect: the user
|
|
947
|
+
// never sees the OfflineBanner during a restart.
|
|
948
|
+
res.json({ ok: true, started: true, helper: helperTmp, closeFrontend: true });
|
|
949
|
+
|
|
950
|
+
setImmediate(() => {
|
|
951
|
+
const { spawn } = require('node:child_process');
|
|
952
|
+
try {
|
|
953
|
+
const child = spawn(process.execPath, args, {
|
|
954
|
+
detached: true,
|
|
955
|
+
stdio: 'ignore',
|
|
956
|
+
windowsHide: true,
|
|
957
|
+
shell: false,
|
|
958
|
+
});
|
|
959
|
+
child.unref();
|
|
960
|
+
console.log(`[restart] helper pid=${child.pid}, shutting down`);
|
|
961
|
+
} catch (e) {
|
|
962
|
+
console.error('[restart] helper spawn failed:', e.message);
|
|
963
|
+
restartInFlight = false;
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
setTimeout(() => gracefulShutdown('restart'), 500);
|
|
967
|
+
});
|
|
968
|
+
}));
|
|
969
|
+
|
|
970
|
+
// ---- version / upgrade ----
|
|
971
|
+
// `/api/version` reports the installed version (= pkg.version) and, if
|
|
972
|
+
// reachable, the latest published on the npm registry. The result is
|
|
973
|
+
// cached for 30 minutes in memory so the AboutPage poll doesn't hit the
|
|
974
|
+
// registry on every render.
|
|
975
|
+
//
|
|
976
|
+
// `/api/upgrade` kicks off `npm i -g @bakapiano/ccsm@latest` as a
|
|
977
|
+
// detached child. When the install completes, the child re-spawns `ccsm`
|
|
978
|
+
// (also detached) so the launcher comes back up on the new version, and
|
|
979
|
+
// the current server gracefulShutdowns. The frontend's OfflineBanner
|
|
980
|
+
// covers the gap; the version router picks up the new version on the
|
|
981
|
+
// next probe.
|
|
982
|
+
const VERSION_CACHE_MS = 30 * 60_000;
|
|
983
|
+
let versionCache = null; // { latest, fetchedAt }
|
|
984
|
+
let upgradeInFlight = false;
|
|
985
|
+
|
|
986
|
+
async function fetchLatestFromNpm() {
|
|
987
|
+
// Node 18+ has a global fetch. Time out the registry call to avoid
|
|
988
|
+
// hanging the response when the user is offline / behind a captive
|
|
989
|
+
// portal.
|
|
990
|
+
const ctrl = new AbortController();
|
|
991
|
+
const t = setTimeout(() => ctrl.abort(), 4000);
|
|
992
|
+
try {
|
|
993
|
+
const r = await fetch('https://registry.npmjs.org/@bakapiano%2Fccsm/latest', {
|
|
994
|
+
headers: { 'Accept': 'application/json' },
|
|
995
|
+
signal: ctrl.signal,
|
|
996
|
+
});
|
|
997
|
+
if (!r.ok) throw new Error(`registry HTTP ${r.status}`);
|
|
998
|
+
const j = await r.json();
|
|
999
|
+
return String(j.version || '');
|
|
1000
|
+
} finally {
|
|
1001
|
+
clearTimeout(t);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function cmpSemver(a, b) {
|
|
1006
|
+
const pa = String(a || '').split('.').map(Number);
|
|
1007
|
+
const pb = String(b || '').split('.').map(Number);
|
|
1008
|
+
for (let i = 0; i < 3; i++) {
|
|
1009
|
+
const x = pa[i] || 0, y = pb[i] || 0;
|
|
1010
|
+
if (x > y) return 1;
|
|
1011
|
+
if (x < y) return -1;
|
|
1012
|
+
}
|
|
1013
|
+
return 0;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
app.get('/api/version', asyncH(async (req, res) => {
|
|
1017
|
+
const force = String(req.query.refresh || '') === '1';
|
|
1018
|
+
const now = Date.now();
|
|
1019
|
+
if (!force && versionCache && (now - versionCache.fetchedAt) < VERSION_CACHE_MS) {
|
|
1020
|
+
return res.json({
|
|
1021
|
+
current: pkg.version,
|
|
1022
|
+
latest: versionCache.latest,
|
|
1023
|
+
updateAvailable: cmpSemver(versionCache.latest, pkg.version) > 0,
|
|
1024
|
+
fetchedAt: versionCache.fetchedAt,
|
|
1025
|
+
cached: true,
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
try {
|
|
1029
|
+
const latest = await fetchLatestFromNpm();
|
|
1030
|
+
versionCache = { latest, fetchedAt: now };
|
|
1031
|
+
res.json({
|
|
1032
|
+
current: pkg.version,
|
|
1033
|
+
latest,
|
|
1034
|
+
updateAvailable: cmpSemver(latest, pkg.version) > 0,
|
|
1035
|
+
fetchedAt: now,
|
|
1036
|
+
cached: false,
|
|
1037
|
+
});
|
|
1038
|
+
} catch (e) {
|
|
1039
|
+
// Swallow: surface "unknown" so the UI doesn't keep showing a stale
|
|
1040
|
+
// "update available" badge based on a 6-hour-old cached value.
|
|
1041
|
+
res.json({
|
|
1042
|
+
current: pkg.version,
|
|
1043
|
+
latest: null,
|
|
1044
|
+
updateAvailable: false,
|
|
1045
|
+
fetchedAt: now,
|
|
1046
|
+
error: String(e.message || e),
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
}));
|
|
1050
|
+
|
|
1051
|
+
app.post('/api/upgrade', asyncH(async (req, res) => {
|
|
1052
|
+
if (upgradeInFlight) {
|
|
1053
|
+
return res.status(409).json({ error: 'upgrade already in progress' });
|
|
1054
|
+
}
|
|
1055
|
+
const body = req.body || {};
|
|
1056
|
+
const target = String(body.target || 'latest');
|
|
1057
|
+
// Refuse anything that doesn't look like a semver dist-tag or version
|
|
1058
|
+
// — defends against `;` etc. winding up in the spawn argv even though
|
|
1059
|
+
// we don't shell out.
|
|
1060
|
+
if (!/^[a-z0-9.+\-^~]+$/i.test(target)) {
|
|
1061
|
+
return res.status(400).json({ error: `invalid target: ${target}` });
|
|
1062
|
+
}
|
|
1063
|
+
// Optional sandbox install prefix (for testing without disturbing the
|
|
1064
|
+
// user's real global ccsm). Validated as a plain absolute path so it
|
|
1065
|
+
// can't be a flag injection.
|
|
1066
|
+
const installPrefix = body.installPrefix ? String(body.installPrefix) : '';
|
|
1067
|
+
if (installPrefix && (installPrefix.startsWith('-') || !path.isAbsolute(installPrefix))) {
|
|
1068
|
+
return res.status(400).json({ error: 'installPrefix must be an absolute path' });
|
|
1069
|
+
}
|
|
1070
|
+
const respawn = body.respawn === false ? '0' : '1';
|
|
1071
|
+
upgradeInFlight = true;
|
|
1072
|
+
console.log(`[upgrade] target=${target}${installPrefix ? ` prefix=${installPrefix}` : ''}${respawn === '0' ? ' (no respawn)' : ''}`);
|
|
1073
|
+
|
|
1074
|
+
// The helper runs OUTSIDE the package dir so npm can rename it
|
|
1075
|
+
// without fighting open file handles. Copy the script to os.tmpdir()
|
|
1076
|
+
// and spawn from there.
|
|
1077
|
+
const fsp = require('node:fs/promises');
|
|
1078
|
+
const helperSrc = path.join(__dirname, 'scripts', 'upgrade-helper.js');
|
|
1079
|
+
const helperTmp = path.join(os.tmpdir(), `ccsm-upgrade-${process.pid}-${Date.now()}.js`);
|
|
1080
|
+
try {
|
|
1081
|
+
await fsp.copyFile(helperSrc, helperTmp);
|
|
1082
|
+
} catch (e) {
|
|
1083
|
+
upgradeInFlight = false;
|
|
1084
|
+
return res.status(500).json({ error: `helper copy failed: ${e.message}` });
|
|
1085
|
+
}
|
|
1086
|
+
const args = [helperTmp, target, String(currentPort), String(process.pid), installPrefix, respawn];
|
|
1087
|
+
|
|
1088
|
+
res.json({ ok: true, started: true, target, helper: helperTmp, closeFrontend: true });
|
|
1089
|
+
|
|
1090
|
+
// Flush response, then spawn helper detached and gracefulShutdown so
|
|
1091
|
+
// the helper's npm install isn't fighting our open file handles.
|
|
1092
|
+
setImmediate(() => {
|
|
1093
|
+
const { spawn } = require('node:child_process');
|
|
1094
|
+
try {
|
|
1095
|
+
const child = spawn(process.execPath, args, {
|
|
1096
|
+
detached: true,
|
|
1097
|
+
stdio: 'ignore',
|
|
1098
|
+
windowsHide: true,
|
|
1099
|
+
shell: false,
|
|
1100
|
+
});
|
|
1101
|
+
child.unref();
|
|
1102
|
+
console.log(`[upgrade] helper pid=${child.pid}, shutting down`);
|
|
1103
|
+
} catch (e) {
|
|
1104
|
+
console.error('[upgrade] helper spawn failed:', e.message);
|
|
1105
|
+
upgradeInFlight = false;
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
setTimeout(() => gracefulShutdown('upgrade'), 500);
|
|
1109
|
+
});
|
|
1110
|
+
}));
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
function listenWithFallback(preferred) {
|
|
1114
|
+
return new Promise((resolve, reject) => {
|
|
1115
|
+
const attempt = (port, tries) => {
|
|
1116
|
+
const server = app.listen(port);
|
|
1117
|
+
server.once('listening', () => resolve({ server, port: server.address().port }));
|
|
1118
|
+
server.once('error', (err) => {
|
|
1119
|
+
if (err.code !== 'EADDRINUSE') return reject(err);
|
|
1120
|
+
if (tries < 9) attempt(port + 1, tries + 1);
|
|
1121
|
+
else if (tries === 9) attempt(0, tries + 1);
|
|
1122
|
+
else reject(err);
|
|
1123
|
+
});
|
|
1124
|
+
};
|
|
1125
|
+
attempt(preferred, 0);
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function findAppModeBrowser() {
|
|
1130
|
+
const candidates = [
|
|
1131
|
+
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
1132
|
+
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
1133
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
1134
|
+
process.env.LOCALAPPDATA &&
|
|
1135
|
+
path.join(process.env.LOCALAPPDATA, 'Google\\Chrome\\Application\\chrome.exe'),
|
|
1136
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
1137
|
+
].filter(Boolean);
|
|
1138
|
+
const fs = require('node:fs');
|
|
1139
|
+
for (const p of candidates) {
|
|
1140
|
+
if (fs.existsSync(p)) return p;
|
|
1141
|
+
}
|
|
1142
|
+
return null;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// Auto-open the frontend in a browser when ccsm boots. Strategy: try a
|
|
1146
|
+
// chromeless app window first (Edge/Chrome --app=); if neither is
|
|
1147
|
+
// installed, fall back to the OS default browser as a regular tab. On
|
|
1148
|
+
// non-Windows we skip — the bundled launcher isn't ported yet.
|
|
1149
|
+
function openInBrowser(url) {
|
|
1150
|
+
if (process.platform !== 'win32') return { kind: 'none', child: null };
|
|
1151
|
+
const { spawn } = require('node:child_process');
|
|
1152
|
+
const fs = require('node:fs');
|
|
1153
|
+
const exe = findAppModeBrowser();
|
|
1154
|
+
if (exe) {
|
|
1155
|
+
const profileDir = path.join(DATA_DIR, 'browser-profile');
|
|
1156
|
+
fs.mkdirSync(profileDir, { recursive: true });
|
|
1157
|
+
const child = spawn(
|
|
1158
|
+
exe,
|
|
1159
|
+
[
|
|
1160
|
+
`--app=${url}`,
|
|
1161
|
+
`--user-data-dir=${profileDir}`,
|
|
1162
|
+
'--window-size=1500,1100',
|
|
1163
|
+
'--no-first-run',
|
|
1164
|
+
'--no-default-browser-check',
|
|
1165
|
+
],
|
|
1166
|
+
{ detached: true, stdio: 'ignore' }
|
|
1167
|
+
);
|
|
1168
|
+
child.unref();
|
|
1169
|
+
return { kind: 'app', child };
|
|
1170
|
+
}
|
|
1171
|
+
console.log('[ccsm] no Edge/Chrome found, opening default browser');
|
|
1172
|
+
const child = spawn('cmd.exe', ['/c', 'start', '', url], {
|
|
1173
|
+
detached: true,
|
|
1174
|
+
stdio: 'ignore',
|
|
1175
|
+
windowsHide: true,
|
|
1176
|
+
});
|
|
1177
|
+
child.unref();
|
|
1178
|
+
return { kind: 'tab', child: null };
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
(async () => {
|
|
1182
|
+
const cfg = await loadConfig();
|
|
1183
|
+
const preferredPort = process.env.CCSM_PORT ? Number(process.env.CCSM_PORT) : cfg.port;
|
|
1184
|
+
const { server, port } = await listenWithFallback(preferredPort);
|
|
1185
|
+
currentPort = port;
|
|
1186
|
+
|
|
1187
|
+
// On boot, mark any persisted "running" sessions as exited — they
|
|
1188
|
+
// belong to a previous server process whose PTYs are gone.
|
|
1189
|
+
try {
|
|
1190
|
+
const all = await persistedSessions.loadAll();
|
|
1191
|
+
for (const s of all) {
|
|
1192
|
+
if (s.status === 'running') {
|
|
1193
|
+
await persistedSessions.markExited(s.id, null);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
} catch (e) {
|
|
1197
|
+
console.error('[ccsm] could not reconcile persisted sessions:', e.message);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// Prewarm `tasklist` cache used by the import modal's "live" markers —
|
|
1201
|
+
// it takes ~500ms on Windows and is the single biggest contributor to
|
|
1202
|
+
// a slow Import dialog cold-open. Fire in the background; the lib also
|
|
1203
|
+
// starts its own 15s refresh loop.
|
|
1204
|
+
try { localCliSessions.prewarmLivePids(['claude.exe']); } catch {}
|
|
1205
|
+
|
|
1206
|
+
if (webTerminal.available) {
|
|
1207
|
+
let WebSocketServer;
|
|
1208
|
+
try { ({ WebSocketServer } = require('ws')); } catch {}
|
|
1209
|
+
if (WebSocketServer) {
|
|
1210
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
1211
|
+
server.on('upgrade', (req, socket, head) => {
|
|
1212
|
+
const origin = req.headers.origin;
|
|
1213
|
+
if (origin && !ALLOWED_ORIGINS.has(origin) && !/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin)) {
|
|
1214
|
+
socket.destroy();
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
const m = req.url && req.url.match(/^\/ws\/terminal\/([^\/?#]+)/);
|
|
1218
|
+
if (!m) { socket.destroy(); return; }
|
|
1219
|
+
const id = decodeURIComponent(m[1]);
|
|
1220
|
+
wss.handleUpgrade(req, socket, head, (ws) => webTerminal.attach(id, ws));
|
|
1221
|
+
});
|
|
1222
|
+
console.log('[ccsm] web terminal bridge active (WebSocket /ws/terminal/:id)');
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
1227
|
+
process.on(sig, () => gracefulShutdown(sig));
|
|
1228
|
+
}
|
|
1229
|
+
process.on('exit', () => { try { webTerminal.killAll(); } catch {} });
|
|
1230
|
+
|
|
1231
|
+
const apiUrl = `http://localhost:${port}`;
|
|
1232
|
+
const FRONTEND_URL = IS_DEV
|
|
1233
|
+
? apiUrl
|
|
1234
|
+
: 'https://bakapiano.github.io/ccsm/';
|
|
1235
|
+
frontendUrl = FRONTEND_URL;
|
|
1236
|
+
console.log(`ccsm listening on ${apiUrl}${port !== preferredPort ? ` (requested ${preferredPort}, was taken)` : ''}`);
|
|
1237
|
+
console.log(`frontend at ${FRONTEND_URL}`);
|
|
1238
|
+
console.log(`data dir: ${DATA_DIR}`);
|
|
1239
|
+
console.log(`work dir: ${cfg.workDir}`);
|
|
1240
|
+
console.log(`clis: ${cfg.clis.map((c) => c.id).join(', ')} (default: ${cfg.defaultCliId})`);
|
|
1241
|
+
|
|
1242
|
+
// CCSM_NO_BROWSER=1 (set by the ccsm:// protocol launcher) suppresses
|
|
1243
|
+
// the auto-open entirely. Otherwise try app-mode (chromeless Edge/Chrome
|
|
1244
|
+
// window); if no such browser is installed, openInBrowser falls back to
|
|
1245
|
+
// the OS default browser on its own.
|
|
1246
|
+
const opened = process.env.CCSM_NO_BROWSER === '1'
|
|
1247
|
+
? { kind: 'none', child: null }
|
|
1248
|
+
: openInBrowser(FRONTEND_URL);
|
|
1249
|
+
|
|
1250
|
+
if (opened.kind === 'app' && opened.child && process.env.CCSM_KEEP_ALIVE !== '1') {
|
|
1251
|
+
const launchedAt = Date.now();
|
|
1252
|
+
opened.child.on('exit', () => {
|
|
1253
|
+
const alive = Date.now() - launchedAt;
|
|
1254
|
+
if (alive < 5000) {
|
|
1255
|
+
console.log(`[ccsm] spawned browser child exited in ${alive}ms · handed off to an existing Edge instance, staying alive`);
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
const closedAt = Date.now();
|
|
1259
|
+
setTimeout(() => {
|
|
1260
|
+
if (lastHeartbeat > closedAt + 100) {
|
|
1261
|
+
console.log('[ccsm] browser closed but another client is heartbeating · staying alive');
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
gracefulShutdown('browser window closed');
|
|
1265
|
+
}, 12_000);
|
|
1266
|
+
});
|
|
1267
|
+
console.log('[ccsm] tied to browser window — close it to stop ccsm');
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
if (process.env.CCSM_LAUNCHER === '1' && process.env.CCSM_KEEP_ALIVE !== '1') {
|
|
1271
|
+
setInterval(() => {
|
|
1272
|
+
if (!heartbeatSeen) return;
|
|
1273
|
+
if (Date.now() - lastHeartbeat > HEARTBEAT_TIMEOUT_MS) {
|
|
1274
|
+
gracefulShutdown(`no heartbeat for ${HEARTBEAT_TIMEOUT_MS / 1000}s`);
|
|
1275
|
+
}
|
|
1276
|
+
}, 30_000);
|
|
1277
|
+
console.log('[ccsm] heartbeat watchdog active');
|
|
1278
|
+
}
|
|
1279
|
+
})().catch((err) => {
|
|
1280
|
+
console.error('startup failed:', err);
|
|
1281
|
+
process.exit(1);
|
|
1282
|
+
});
|