@bakapiano/ccsm 0.22.6 → 0.22.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CLAUDE.md +521 -540
  2. package/README.md +186 -189
  3. package/bin/ccsm.js +235 -235
  4. package/lib/cliActivity.js +36 -139
  5. package/lib/codexSeed.js +126 -183
  6. package/lib/config.js +277 -274
  7. package/lib/devices.js +229 -229
  8. package/lib/folders.js +124 -124
  9. package/lib/persistedSessions.js +179 -139
  10. package/lib/tunnel.js +621 -621
  11. package/lib/webTerminal.js +225 -225
  12. package/lib/winPath.js +1 -1
  13. package/lib/workspace.js +233 -233
  14. package/package.json +57 -57
  15. package/public/css/base.css +99 -99
  16. package/public/css/cards.css +183 -183
  17. package/public/css/feedback.css +504 -504
  18. package/public/css/forms.css +453 -453
  19. package/public/css/layout.css +154 -154
  20. package/public/css/modal.css +190 -190
  21. package/public/css/responsive.css +176 -176
  22. package/public/css/sidebar.css +707 -707
  23. package/public/css/terminals.css +546 -546
  24. package/public/css/tokens.css +81 -81
  25. package/public/css/wco.css +196 -196
  26. package/public/css/widgets.css +2347 -2725
  27. package/public/index.html +152 -152
  28. package/public/js/api.js +349 -371
  29. package/public/js/backend.js +149 -149
  30. package/public/js/components/App.js +73 -73
  31. package/public/js/components/DirectoryPicker.js +203 -203
  32. package/public/js/components/EntityFormModal.js +153 -153
  33. package/public/js/components/Modal.js +57 -57
  34. package/public/js/components/OfflineBanner.js +67 -67
  35. package/public/js/components/PageTitleBar.js +13 -13
  36. package/public/js/components/PendingApprovalOverlay.js +128 -128
  37. package/public/js/components/Picker.js +179 -179
  38. package/public/js/components/Popover.js +55 -55
  39. package/public/js/components/RestartOverlay.js +36 -36
  40. package/public/js/components/Sidebar.js +380 -380
  41. package/public/js/components/TerminalInstance.js +28 -0
  42. package/public/js/components/useDragSort.js +67 -67
  43. package/public/js/dialog.js +67 -67
  44. package/public/js/icons.js +212 -212
  45. package/public/js/main.js +296 -296
  46. package/public/js/pages/AboutPage.js +90 -90
  47. package/public/js/pages/ConfigurePage.js +730 -713
  48. package/public/js/pages/LaunchPage.js +403 -421
  49. package/public/js/pages/RemotePage.js +743 -743
  50. package/public/js/pages/SessionsPage.js +54 -54
  51. package/public/js/state.js +335 -335
  52. package/public/js/util.js +1 -1
  53. package/scripts/dev.js +149 -149
  54. package/scripts/install.js +153 -153
  55. package/scripts/restart-helper.js +96 -96
  56. package/scripts/upgrade-helper.js +687 -687
  57. package/server.js +1748 -1817
  58. package/lib/localCliSessions.js +0 -519
  59. package/public/js/components/AdoptModal.js +0 -261
  60. package/public/manifest.webmanifest +0 -25
  61. package/public/setup/index.html +0 -567
package/server.js CHANGED
@@ -1,217 +1,208 @@
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
- const tunnel = require('./lib/tunnel');
20
- const devices = require('./lib/devices');
21
- // Upstream CLI session-id capture used to live in lib/cliSessionWatcher
22
- // (poll the CLI's transcript dir, match by cwd). It's gone now — for
23
- // CLIs that expose a "set the UUID for a new session" flag (claude +
24
- // copilot both have --session-id <uuid>) we pre-generate the id in
25
- // /api/sessions/new and pass it via cli.newSessionIdArgs. For CLIs
26
- // without that flag (codex) we just don't capture an id; the user
27
- // gets cli.resumeArgs (--continue / resume --last) on relaunch.
28
- const localCliSessions = require('./lib/localCliSessions');
29
-
30
- // One unified exit path: kill PTY children, then exit. v1.0 dropped the
31
- // snapshot-on-exit behaviour because the new persistedSessions store is
32
- // the source of truth (and is always on disk, not in memory).
33
- let shuttingDown = false;
34
- async function gracefulShutdown(reason) {
35
- if (shuttingDown) return;
36
- shuttingDown = true;
37
- console.log(`[ccsm] shutting down · ${reason}`);
38
- // Mark all running sessions as exited (best-effort) so the next launch
39
- // doesn't show stale "running" rows.
40
- try {
41
- const all = await persistedSessions.loadAll();
42
- for (const s of all) {
43
- if (s.status === 'running') {
44
- await persistedSessions.markExited(s.id, null).catch(() => {});
45
- }
46
- }
47
- } catch {}
48
- try { webTerminal.killAll(); } catch {}
49
- try { tunnel.stop(); } catch {}
50
- process.exit(0);
51
- }
52
-
53
- const app = express();
54
- app.use(express.json({ limit: '1mb' }));
55
-
56
- // CORS · allow the hosted-frontend (GH Pages) origin to call /api/* and
57
- // open WebSockets. Listed explicitly — never reflect Origin or use '*' so
58
- // random web pages can't reach the local backend. Localhost dev calls
59
- // stay same-origin (browser doesn't add Origin header → middleware is a
60
- // no-op for them).
61
- const ALLOWED_ORIGINS = new Set([
62
- 'https://bakapiano.github.io',
63
- ]);
64
- app.use((req, res, next) => {
65
- const origin = req.headers.origin;
66
- if (origin && ALLOWED_ORIGINS.has(origin)) {
67
- res.setHeader('Access-Control-Allow-Origin', origin);
68
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
69
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Device-Id, X-Device-Code');
70
- res.setHeader('Vary', 'Origin');
71
- }
72
- if (req.method === 'OPTIONS') return res.sendStatus(204);
73
- next();
74
- });
75
-
76
- // Remote-access token guard. Once a token is set (via the Remote page
77
- // POST /api/tunnel/start), any /api/* request that wasn't direct
78
- // loopback must present the token either as `Authorization: Bearer
79
- // <token>` or `?token=<token>`.
80
- // "Direct loopback" = the Host header is loopback-shaped AND no
81
- // proxy injected an X-Forwarded-* header. devtunnel rewrites Host
82
- // to `localhost:7788` (it's reverse-proxying via a local socket) but
83
- // adds `x-forwarded-host` / `x-forwarded-for`; cloudflared does the
84
- // same with `cf-connecting-ip` etc. Either header's mere presence
85
- // flips us into "treat as remote" mode regardless of Host. Real
86
- // browsers on the host machine set neither.
87
- // /api/health is exempt so tunnel URL probes keep working without
88
- // leaking the token.
89
- function isDirectLoopback(req) {
90
- if (req.headers['x-forwarded-host']) return false;
91
- if (req.headers['x-forwarded-for']) return false;
92
- if (req.headers['cf-connecting-ip']) return false;
93
- const host = String(req.headers.host || '').toLowerCase();
94
- return /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(host);
95
- }
96
- // Device-approval gate.
97
- //
98
- // Two-stage trust model:
99
- // 1. NEW device wants in → must hit /api/devices/me with a valid
100
- // token. That's the only place new pending records get created
101
- // (see the handler below). Without the token, a stranger can't
102
- // flood the host's pending queue with random device ids.
103
- // 2. ALREADY-known device its UUID is the credential. The host
104
- // Approved it once; subsequent calls go through with just the
105
- // X-Device-Id header (no token needed). Rotating the host token
106
- // doesn't break existing approved devices — they keep working
107
- // until the host explicitly Revokes them.
108
- //
109
- // This middleware reads-only: it never inserts. Unknown ids 401 to
110
- // nudge the caller to re-arrive via the share URL (which lands them
111
- // on /api/devices/me with the embedded token and registers them).
112
- const DEVICE_EXEMPT_PATHS = new Set(['/api/health', '/api/devices/me']);
113
- async function deviceGate(req, res, next) {
114
- if (DEVICE_EXEMPT_PATHS.has(req.path)) return next();
115
- if (!req.path.startsWith('/api/')) return next();
116
- if (isDirectLoopback(req)) return next();
117
- const id = String(req.headers['x-device-id'] || (req.query && req.query.device) || '');
118
- if (!id) return res.status(400).json({ error: 'device id required' });
119
- const d = await devices.get(id);
120
- if (!d) return res.status(401).json({ error: 'unknown device · open the share URL to register' });
121
- // Bump lastSeen via record() it short-circuits the write when the
122
- // last flush was recent (see MIN_FLUSH_MS in lib/devices.js).
123
- try { await devices.record(id, {
124
- userAgent: req.headers['user-agent'] || '',
125
- ip: String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim(),
126
- code: req.headers['x-device-code'] || '',
127
- }); } catch { /* lastSeen bump is best-effort */ }
128
- if (d.status === 'approved') return next();
129
- return res.status(403).json({
130
- error: d.status === 'rejected' ? 'device rejected by host' : 'pending host approval',
131
- pending: d.status === 'pending',
132
- rejected: d.status === 'rejected',
133
- deviceId: d.id,
134
- });
135
- }
136
- app.use(deviceGate);
137
-
138
- // Final admin lock — all device management + tunnel-mutating routes are
139
- // loopback-only. The remote browser already only sees /api/health and
140
- // /api/devices/me through the gates above; this stops a remote from
141
- // trying to enumerate or manipulate the approval list even if they
142
- // somehow got past everything.
143
- const HOST_ONLY_PREFIXES = ['/api/devices', '/api/tunnel'];
144
- app.use((req, res, next) => {
145
- if (!HOST_ONLY_PREFIXES.some((p) => req.path === p || req.path.startsWith(p + '/'))) return next();
146
- if (req.path === '/api/devices/me') return next();
147
- if (isDirectLoopback(req)) return next();
148
- res.status(403).json({ error: 'host-only endpoint' });
149
- });
150
-
151
- // Dev mode = running from a checkout (not from an npm-install location).
152
- // Used to gate two things: (a) serving static frontend from local public/
153
- // so a contributor can iterate without pushing to GH Pages; (b) hot-reload
154
- // SSE endpoint that watches public/ for changes. CCSM_NO_DEV=1 disables
155
- // both explicitly. In production (npm-installed), backend is API-only
156
- // frontend lives at https://bakapiano.github.io/ccsm/ (router per-version).
157
- const IS_DEV = !__dirname.includes(`${path.sep}node_modules${path.sep}`) && process.env.CCSM_NO_DEV !== '1';
158
-
159
- // Always serve public/ when it exists alongside server.js. In a
160
- // checkout this is the live frontend used during dev. In an npm
161
- // install this lets a tunneled session (Remote page) reach the
162
- // frontend at the tunnel URL — the GH Pages hosted frontend is
163
- // unreachable to a phone on cellular, but the locally-bundled
164
- // public/ shipped in the package IS, via the tunnel. Same files
165
- // either way; just no version router in front.
166
- {
167
- const publicDir = path.join(__dirname, 'public');
168
- try {
169
- if (require('node:fs').statSync(publicDir).isDirectory()) {
170
- app.use(express.static(publicDir));
171
- }
172
- } catch { /* not bundled · API-only mode */ }
173
- }
174
-
175
- const reloadClients = new Set();
176
- if (IS_DEV) {
177
- app.get('/api/dev/ping', (_req, res) => res.json({ dev: true }));
178
- app.get('/api/dev/reload', (req, res) => {
179
- res.setHeader('Content-Type', 'text/event-stream');
180
- res.setHeader('Cache-Control', 'no-cache, no-transform');
181
- res.setHeader('Connection', 'keep-alive');
182
- res.flushHeaders();
183
- res.write(': connected\n\n');
184
- reloadClients.add(res);
185
- const hb = setInterval(() => { try { res.write(': ping\n\n'); } catch {} }, 25000);
186
- req.on('close', () => { clearInterval(hb); reloadClients.delete(res); });
187
- });
188
- const publicDir = path.join(__dirname, 'public');
189
- const fs = require('node:fs');
190
- let debounce = null;
191
- fs.watch(publicDir, { recursive: true }, (_event, filename) => {
192
- clearTimeout(debounce);
193
- debounce = setTimeout(() => {
194
- if (reloadClients.size === 0) return;
195
- console.log(`[dev] reload · ${filename || '?'} → ${reloadClients.size} client(s)`);
196
- for (const r of reloadClients) {
197
- try { r.write(`event: reload\ndata: ${Date.now()}\n\n`); } catch {}
198
- }
199
- }, 80);
200
- });
201
- console.log('[dev] hot-reload watching public/');
202
- }
203
-
204
- function asyncH(fn) {
205
- return (req, res) => {
206
- Promise.resolve(fn(req, res)).catch((err) => {
207
- console.error('[api error]', err);
208
- res.status(500).json({ error: String(err && err.message || err) });
209
- });
210
- };
211
- }
212
-
213
- // ---- helpers ----
214
-
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const path = require('node:path');
5
+ const os = require('node:os');
6
+ const express = require('express');
7
+
8
+ const { loadConfig, saveConfig, DATA_DIR } = require('./lib/config');
9
+ const {
10
+ listWorkspaces,
11
+ findOrCreateWorkspace,
12
+ ensureReposInWorkspace,
13
+ isInside,
14
+ } = require('./lib/workspace');
15
+ const webTerminal = require('./lib/webTerminal');
16
+ const persistedSessions = require('./lib/persistedSessions');
17
+ const folders = require('./lib/folders');
18
+ const tunnel = require('./lib/tunnel');
19
+ const devices = require('./lib/devices');
20
+
21
+ // One unified exit path: kill PTY children, then exit. v1.0 dropped the
22
+ // snapshot-on-exit behaviour because the new persistedSessions store is
23
+ // the source of truth (and is always on disk, not in memory).
24
+ let shuttingDown = false;
25
+ async function gracefulShutdown(reason) {
26
+ if (shuttingDown) return;
27
+ shuttingDown = true;
28
+ console.log(`[ccsm] shutting down · ${reason}`);
29
+ // Mark all running sessions as exited (best-effort) so the next launch
30
+ // doesn't show stale "running" rows.
31
+ try {
32
+ const all = await persistedSessions.loadAll();
33
+ for (const s of all) {
34
+ if (s.status === 'running') {
35
+ await persistedSessions.markExited(s.id, null).catch(() => {});
36
+ }
37
+ }
38
+ } catch {}
39
+ try { webTerminal.killAll(); } catch {}
40
+ try { tunnel.stop(); } catch {}
41
+ process.exit(0);
42
+ }
43
+
44
+ const app = express();
45
+ app.use(express.json({ limit: '1mb' }));
46
+
47
+ // CORS · allow the hosted-frontend (GH Pages) origin to call /api/* and
48
+ // open WebSockets. Listed explicitly — never reflect Origin or use '*' so
49
+ // random web pages can't reach the local backend. Localhost dev calls
50
+ // stay same-origin (browser doesn't add Origin header → middleware is a
51
+ // no-op for them).
52
+ const ALLOWED_ORIGINS = new Set([
53
+ 'https://bakapiano.github.io',
54
+ ]);
55
+ app.use((req, res, next) => {
56
+ const origin = req.headers.origin;
57
+ if (origin && ALLOWED_ORIGINS.has(origin)) {
58
+ res.setHeader('Access-Control-Allow-Origin', origin);
59
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
60
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Device-Id, X-Device-Code');
61
+ res.setHeader('Vary', 'Origin');
62
+ }
63
+ if (req.method === 'OPTIONS') return res.sendStatus(204);
64
+ next();
65
+ });
66
+
67
+ // Remote-access token guard. Once a token is set (via the Remote page
68
+ // POST /api/tunnel/start), any /api/* request that wasn't direct
69
+ // loopback must present the token either as `Authorization: Bearer
70
+ // <token>` or `?token=<token>`.
71
+ // "Direct loopback" = the Host header is loopback-shaped AND no
72
+ // proxy injected an X-Forwarded-* header. devtunnel rewrites Host
73
+ // to `localhost:7788` (it's reverse-proxying via a local socket) but
74
+ // adds `x-forwarded-host` / `x-forwarded-for`; cloudflared does the
75
+ // same with `cf-connecting-ip` etc. Either header's mere presence
76
+ // flips us into "treat as remote" mode regardless of Host. Real
77
+ // browsers on the host machine set neither.
78
+ // /api/health is exempt so tunnel URL probes keep working without
79
+ // leaking the token.
80
+ function isDirectLoopback(req) {
81
+ if (req.headers['x-forwarded-host']) return false;
82
+ if (req.headers['x-forwarded-for']) return false;
83
+ if (req.headers['cf-connecting-ip']) return false;
84
+ const host = String(req.headers.host || '').toLowerCase();
85
+ return /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(host);
86
+ }
87
+ // Device-approval gate.
88
+ //
89
+ // Two-stage trust model:
90
+ // 1. NEW device wants in → must hit /api/devices/me with a valid
91
+ // token. That's the only place new pending records get created
92
+ // (see the handler below). Without the token, a stranger can't
93
+ // flood the host's pending queue with random device ids.
94
+ // 2. ALREADY-known device → its UUID is the credential. The host
95
+ // Approved it once; subsequent calls go through with just the
96
+ // X-Device-Id header (no token needed). Rotating the host token
97
+ // doesn't break existing approved devices — they keep working
98
+ // until the host explicitly Revokes them.
99
+ //
100
+ // This middleware reads-only: it never inserts. Unknown ids → 401 to
101
+ // nudge the caller to re-arrive via the share URL (which lands them
102
+ // on /api/devices/me with the embedded token and registers them).
103
+ const DEVICE_EXEMPT_PATHS = new Set(['/api/health', '/api/devices/me']);
104
+ async function deviceGate(req, res, next) {
105
+ if (DEVICE_EXEMPT_PATHS.has(req.path)) return next();
106
+ if (!req.path.startsWith('/api/')) return next();
107
+ if (isDirectLoopback(req)) return next();
108
+ const id = String(req.headers['x-device-id'] || (req.query && req.query.device) || '');
109
+ if (!id) return res.status(400).json({ error: 'device id required' });
110
+ const d = await devices.get(id);
111
+ if (!d) return res.status(401).json({ error: 'unknown device · open the share URL to register' });
112
+ // Bump lastSeen via record() — it short-circuits the write when the
113
+ // last flush was recent (see MIN_FLUSH_MS in lib/devices.js).
114
+ try { await devices.record(id, {
115
+ userAgent: req.headers['user-agent'] || '',
116
+ ip: String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim(),
117
+ code: req.headers['x-device-code'] || '',
118
+ }); } catch { /* lastSeen bump is best-effort */ }
119
+ if (d.status === 'approved') return next();
120
+ return res.status(403).json({
121
+ error: d.status === 'rejected' ? 'device rejected by host' : 'pending host approval',
122
+ pending: d.status === 'pending',
123
+ rejected: d.status === 'rejected',
124
+ deviceId: d.id,
125
+ });
126
+ }
127
+ app.use(deviceGate);
128
+
129
+ // Final admin lock — all device management + tunnel-mutating routes are
130
+ // loopback-only. The remote browser already only sees /api/health and
131
+ // /api/devices/me through the gates above; this stops a remote from
132
+ // trying to enumerate or manipulate the approval list even if they
133
+ // somehow got past everything.
134
+ const HOST_ONLY_PREFIXES = ['/api/devices', '/api/tunnel'];
135
+ app.use((req, res, next) => {
136
+ if (!HOST_ONLY_PREFIXES.some((p) => req.path === p || req.path.startsWith(p + '/'))) return next();
137
+ if (req.path === '/api/devices/me') return next();
138
+ if (isDirectLoopback(req)) return next();
139
+ res.status(403).json({ error: 'host-only endpoint' });
140
+ });
141
+
142
+ // Dev mode = running from a checkout (not from an npm-install location).
143
+ // Used to gate two things: (a) serving static frontend from local public/
144
+ // so a contributor can iterate without pushing to GH Pages; (b) hot-reload
145
+ // SSE endpoint that watches public/ for changes. CCSM_NO_DEV=1 disables
146
+ // both explicitly. In production (npm-installed), backend is API-only —
147
+ // frontend lives at https://bakapiano.github.io/ccsm/ (router per-version).
148
+ const IS_DEV = !__dirname.includes(`${path.sep}node_modules${path.sep}`) && process.env.CCSM_NO_DEV !== '1';
149
+
150
+ // Always serve public/ when it exists alongside server.js. In a
151
+ // checkout this is the live frontend used during dev. In an npm
152
+ // install this lets a tunneled session (Remote page) reach the
153
+ // frontend at the tunnel URL the GH Pages hosted frontend is
154
+ // unreachable to a phone on cellular, but the locally-bundled
155
+ // public/ shipped in the package IS, via the tunnel. Same files
156
+ // either way; just no version router in front.
157
+ {
158
+ const publicDir = path.join(__dirname, 'public');
159
+ try {
160
+ if (require('node:fs').statSync(publicDir).isDirectory()) {
161
+ app.use(express.static(publicDir));
162
+ }
163
+ } catch { /* not bundled · API-only mode */ }
164
+ }
165
+
166
+ const reloadClients = new Set();
167
+ if (IS_DEV) {
168
+ app.get('/api/dev/ping', (_req, res) => res.json({ dev: true }));
169
+ app.get('/api/dev/reload', (req, res) => {
170
+ res.setHeader('Content-Type', 'text/event-stream');
171
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
172
+ res.setHeader('Connection', 'keep-alive');
173
+ res.flushHeaders();
174
+ res.write(': connected\n\n');
175
+ reloadClients.add(res);
176
+ const hb = setInterval(() => { try { res.write(': ping\n\n'); } catch {} }, 25000);
177
+ req.on('close', () => { clearInterval(hb); reloadClients.delete(res); });
178
+ });
179
+ const publicDir = path.join(__dirname, 'public');
180
+ const fs = require('node:fs');
181
+ let debounce = null;
182
+ fs.watch(publicDir, { recursive: true }, (_event, filename) => {
183
+ clearTimeout(debounce);
184
+ debounce = setTimeout(() => {
185
+ if (reloadClients.size === 0) return;
186
+ console.log(`[dev] reload · ${filename || '?'} → ${reloadClients.size} client(s)`);
187
+ for (const r of reloadClients) {
188
+ try { r.write(`event: reload\ndata: ${Date.now()}\n\n`); } catch {}
189
+ }
190
+ }, 80);
191
+ });
192
+ console.log('[dev] hot-reload watching public/');
193
+ }
194
+
195
+ function asyncH(fn) {
196
+ return (req, res) => {
197
+ Promise.resolve(fn(req, res)).catch((err) => {
198
+ console.error('[api error]', err);
199
+ res.status(500).json({ error: String(err && err.message || err) });
200
+ });
201
+ };
202
+ }
203
+
204
+ // ---- helpers ----
205
+
215
206
  function pickCli(cfg, requestedId) {
216
207
  const wanted = requestedId || cfg.defaultCliId;
217
208
  return cfg.clis.find((c) => c.id === wanted) || cfg.clis[0];
@@ -222,418 +213,471 @@ function findCliById(cfg, id) {
222
213
  }
223
214
 
224
215
  // Resolve how to spawn a CLI command. Windows quirks:
225
- // v1.1 — spawn strategy is now caller-controlled via cli.shell:
226
- // 'direct' — pty.spawn(command, args). Real .exe / absolute paths only.
227
- // Won't find pwsh aliases / functions.
228
- // 'pwsh' — wrap in `pwsh.exe -NoLogo -NoExit -Command "& { cmd args }"`.
229
- // Loads $PROFILE → pwsh aliases / functions work.
230
- // Falls back to powershell.exe (5.x) if pwsh.exe absent.
231
- // 'cmd' — wrap in `cmd.exe /d /s /c "cmd args"`. Resolves doskey aliases
232
- // and PATH-only names without pwsh dependency.
233
- function resolveCommand(commandRaw, userArgs = [], shell = 'direct') {
234
- if (!commandRaw) throw new Error('cli.command is empty');
235
- const cmd = commandRaw.replace(/^\.[\\\/]/, '');
236
-
237
- if (shell === 'pwsh') {
238
- // Build a single -Command string so pwsh tokenizes args itself. The
239
- // `& { ... }` wrapper makes pwsh execute the line as a script block —
240
- // critical for functions (which aren't visible without invocation).
241
- const joined = [cmd, ...userArgs.map(quoteForPwsh)].join(' ');
242
- return {
243
- exe: 'pwsh.exe',
244
- prefixArgs: ['-NoLogo', '-NoExit', '-Command', `& { ${joined} }`],
245
- fallbackExe: 'powershell.exe',
246
- consumesUserArgs: true,
247
- };
248
- }
249
-
250
- if (shell === 'cmd') {
251
- // /d skips AutoRun, /s preserves quoting, /c runs and exits.
252
- const joined = [cmd, ...userArgs.map(quoteForCmd)].join(' ');
253
- return {
254
- exe: process.env.ComSpec || 'cmd.exe',
255
- prefixArgs: ['/d', '/s', '/c', joined],
256
- consumesUserArgs: true,
257
- };
258
- }
259
-
260
- // shell === 'direct' — bare pty.spawn. Honour .cmd/.bat/.ps1 extensions
261
- // when an absolute path was provided so they still work without an
262
- // explicit shell choice.
263
- if (path.isAbsolute(cmd)) {
264
- const ext = path.extname(cmd).toLowerCase();
265
- if (ext === '.cmd' || ext === '.bat') {
266
- return { exe: process.env.ComSpec || 'cmd.exe', prefixArgs: ['/d', '/s', '/c', cmd], consumesUserArgs: false };
267
- }
268
- if (ext === '.ps1') {
269
- return { exe: 'powershell.exe', prefixArgs: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', cmd], consumesUserArgs: false };
270
- }
271
- return { exe: cmd, prefixArgs: [], consumesUserArgs: false };
272
- }
273
- // Bare name with shell=direct: defer to cmd.exe so Windows resolves
274
- // against PATH. Same behavior as before — preserves user expectations
275
- // for `claude` / `codex` configs that don't set shell.
276
- return { exe: process.env.ComSpec || 'cmd.exe', prefixArgs: ['/d', '/s', '/c', cmd], consumesUserArgs: false };
277
- }
278
-
279
- function quoteForPwsh(s) {
280
- if (s === '' || /[\s'"`$]/.test(s)) return `'${String(s).replace(/'/g, "''")}'`;
281
- return s;
282
- }
283
- function quoteForCmd(s) {
284
- if (s === '' || /[\s"&|<>^]/.test(s)) return `"${String(s).replace(/"/g, '""')}"`;
285
- return s;
286
- }
287
-
288
- function spawnCliSession({ cli, cwd, sessionId, meta, extraArgs = [], theme, cols, rows }) {
289
- if (!webTerminal.available) {
290
- const e = new Error('node-pty unavailable · cannot spawn web terminal');
291
- e.code = 'PTY_UNAVAILABLE';
292
- throw e;
293
- }
294
- // For shell wrappers (pwsh/cmd) we need to bake BOTH cli.args and
295
- // extraArgs into the single quoted command string — otherwise extraArgs
296
- // would become args to the shell itself, not the wrapped command.
297
- // Re-resolve here when extraArgs is present so the quoting is correct.
298
- // Force a session-scoped theme=auto for claude so its syntax/diff colours
299
- // follow the ccsm terminal background (which we report via OSC 10/11 in
300
- // TerminalView). claude's DEFAULT theme is *dark*, whose near-white tokens
301
- // (comments, f-string interpolations, call names) vanish on our light
302
- // terminal — the "字体颜色和背景重复" bug. --settings is session-scoped, so
303
- // the user's global ~/.claude/settings.json is left untouched, and ccsm
304
- // sessions Just Work on a fresh machine without anyone running /theme auto.
305
- // (Injected here as an integration arg, like --session-id — not via the
306
- // user-editable cli.args, so it reaches existing configs too.)
307
- // Skip the injection entirely if the user already put their own --settings
308
- // in cli.args — claude deep-merges multiple --settings (verified: later ones
309
- // win per-key), so ours would silently override a theme they set on purpose.
310
- // Respect the user's explicit choice instead.
311
- const userHasSettings = (cli.args || []).some(
312
- (a) => a === '--settings' || String(a).startsWith('--settings='));
313
- const baseArgs = [...(cli.args || [])];
314
- if (cli.type === 'claude' && !userHasSettings) baseArgs.push('--settings', '{"theme":"auto"}');
315
- const resolved = resolveCommand(
316
- cli.command,
317
- [...baseArgs, ...extraArgs],
318
- cli.shell || 'direct',
319
- );
320
- const { exe, prefixArgs, fallbackExe, consumesUserArgs } = resolved;
321
- const args = consumesUserArgs
322
- ? prefixArgs
323
- : [...prefixArgs, ...baseArgs, ...extraArgs];
324
- // Merge user-scope PATH from registry into the env we hand the PTY.
325
- // spawnEnv() also strips duplicate path-case keys so our override
326
- // doesn't get shadowed by the inherited `Path` from process.env.
327
- const env = spawnEnv(cli.env);
328
- // Tell background-aware CLIs which way the ccsm terminal is painted, so
329
- // their light/dark auto-detection matches it. COLORFGBG (fg;bg ANSI indices)
330
- // is the de-facto signal that codex (its DiffTheme probes it), copilot, and
331
- // claude all read — bg 15 = light, 0 = dark. claude additionally gets OSC
332
- // 10/11 answers + --settings auto; this covers codex/copilot, which detect
333
- // via COLORFGBG, not OSC. The frontend passes its resolved theme on spawn;
334
- // a theme switch is picked up on the next resume.
335
- if (theme === 'light' || theme === 'dark') {
336
- env.COLORFGBG = theme === 'light' ? '0;15' : '15;0';
337
- }
338
- // Spawn the PTY at the size the frontend measured for its terminal pane
339
- // (clamped against junk), so alt-screen CLIs lay out at the right height
340
- // from the first frame instead of node-pty's 120×30 default. Omitted ⇒
341
- // webTerminal.spawn keeps its default; xterm's first resize corrects any
342
- // small estimate error on attach regardless.
343
- const sized = (Number(cols) > 0 && Number(rows) > 0)
344
- ? { cols: Math.min(400, Math.max(20, Math.floor(Number(cols)))),
345
- rows: Math.min(200, Math.max(8, Math.floor(Number(rows)))) }
346
- : {};
347
- const trySpawn = (executable) => webTerminal.spawn({
348
- id: sessionId,
349
- command: executable,
350
- args,
351
- cwd,
352
- env,
353
- ...sized,
354
- meta: { ...meta, cliId: cli.id, cliName: cli.name },
355
- onData: () => {
356
- persistedSessions.touch(sessionId).catch(() => {});
357
- try { require('./lib/cliActivity').noteOutput(sessionId); } catch {}
358
- },
359
- onExit: ({ exitCode }) => {
360
- persistedSessions.markExited(sessionId, exitCode).catch(() => {});
361
- },
362
- });
363
- try {
364
- const entry = trySpawn(exe);
365
- return entry;
366
- } catch (e) {
367
- if (fallbackExe && /ENOENT|cannot find|not recognized/i.test(String(e && e.message || e))) {
368
- const entry = trySpawn(fallbackExe);
369
- return entry;
370
- }
371
- throw e;
372
- }
373
- }
374
-
375
- // Read user PATH from registry once at boot, prepend to process PATH.
376
- // On platforms other than Windows or if the read fails, fall back to
377
- // process.env.PATH unchanged.
378
- let mergedUserPath = null;
379
- function buildMergedUserPath() {
380
- if (process.platform !== 'win32') return process.env.PATH;
381
- try {
382
- const { spawnSync } = require('node:child_process');
383
- const r = spawnSync('reg.exe', ['query', 'HKCU\\Environment', '/v', 'PATH'], { encoding: 'utf8', windowsHide: true });
384
- if (r.status !== 0 || !r.stdout) return process.env.PATH;
385
- const line = r.stdout.split(/\r?\n/).find((l) => /\bPATH\b/i.test(l) && /REG_(EXPAND_)?SZ/i.test(l));
386
- if (!line) return process.env.PATH;
387
- const m = line.match(/REG_(?:EXPAND_)?SZ\s+(.+)$/);
388
- if (!m) return process.env.PATH;
389
- // Expand %VAR% references manually (REG_EXPAND_SZ keeps them literal).
390
- const userPath = m[1].replace(/%([^%]+)%/g, (_, name) => process.env[name] || '');
391
- const existing = (process.env.PATH || '').split(';').map((s) => s.trim()).filter(Boolean);
392
- const adds = userPath.split(';').map((s) => s.trim()).filter(Boolean);
393
- const merged = [];
394
- const seen = new Set();
395
- for (const p of [...adds, ...existing]) {
396
- const k = p.toLowerCase();
397
- if (seen.has(k)) continue;
398
- seen.add(k);
399
- merged.push(p);
400
- }
401
- return merged.join(';');
402
- } catch {
403
- return process.env.PATH;
404
- }
405
- }
406
- mergedUserPath = buildMergedUserPath();
407
-
408
- // Hand back a fresh env for spawning a child, with PATH overridden by
409
- // our merged user PATH and any duplicate case variants of "path"
410
- // stripped first. Windows env lookup is case-insensitive but the env
411
- // block we hand CreateProcess is an ordered byte buffer — if both
412
- // `Path` (inherited from process.env, OS canonical case) and `PATH`
413
- // (our override) are present, Windows resolves to whichever comes
414
- // first in the block. Node's Object.keys preserves insertion order,
415
- // so the inherited `Path` would win and our merged override silently
416
- // disappear. Strip all path-shaped keys first, then add the merge.
417
- function spawnEnv(extraEnv = {}) {
418
- const env = { ...process.env, ...extraEnv };
419
- if (process.platform === 'win32') {
420
- for (const k of Object.keys(env)) {
421
- if (k.toLowerCase() === 'path') delete env[k];
422
- }
423
- }
424
- if (mergedUserPath) env.PATH = mergedUserPath;
425
- return env;
426
- }
427
-
428
- // ---- config ----
429
-
430
- // Per-CLI install probe. Looks up the command on PATH using `where` (win)
431
- // or `which` (posix). Result is cached forever — restart ccsm after
432
- // installing/uninstalling a CLI to refresh. Cheap (10ms cold, 0ms cached).
433
- const cliProbeCache = new Map();
434
- function probeCli(command) {
435
- if (!command) return null;
436
- if (cliProbeCache.has(command)) return cliProbeCache.get(command);
437
- const { spawnSync } = require('node:child_process');
438
- let resolvedPath = null;
439
- try {
440
- const isWin = process.platform === 'win32';
441
- const cmd = isWin ? 'where.exe' : 'which';
442
- const env = { ...process.env };
443
- if (mergedUserPath) env.PATH = mergedUserPath;
444
- const r = spawnSync(cmd, [command], { encoding: 'utf8', windowsHide: true, env });
445
- if (r.status === 0 && r.stdout) {
446
- resolvedPath = r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0] || null;
447
- }
448
- } catch {}
449
- cliProbeCache.set(command, resolvedPath);
450
- return resolvedPath;
451
- }
452
-
453
- function decorateConfigWithProbes(cfg) {
454
- return {
455
- ...cfg,
456
- clis: (cfg.clis || []).map((c) => {
457
- const path = probeCli(c.command);
458
- return { ...c, installed: !!path, installPath: path };
459
- }),
460
- };
461
- }
462
-
463
- // The tunnel + devtunnel config blocks are managed exclusively through
464
- // /api/tunnel/* (host-only) — they hold the persisted remote-access
465
- // token and the named tunnelId. Strip them from /api/config so (a) the
466
- // plaintext token never reaches an approved remote device reading config
467
- // and (b) the frontend's whole-object config round-trip on save can't
468
- // clobber tunnelId/token with a stale snapshot.
469
- function stripTunnelKeys(cfg) {
470
- const rest = { ...cfg };
471
- delete rest.tunnel;
472
- delete rest.devtunnel;
473
- return rest;
474
- }
475
- app.get('/api/config', asyncH(async (_req, res) => {
476
- res.json(decorateConfigWithProbes(stripTunnelKeys(await loadConfig())));
477
- }));
478
-
479
- app.put('/api/config', asyncH(async (req, res) => {
480
- const body = { ...(req.body || {}) };
481
- delete body.tunnel;
482
- delete body.devtunnel;
483
- res.json(decorateConfigWithProbes(stripTunnelKeys(await saveConfig(body))));
484
- }));
485
-
486
- // ---- CLI probe / test ----
487
- //
488
- // Run the user's configured command with `--version` and report back
489
- // stdout/stderr + whether the output looks like the claimed CLI type.
490
- // Used by the Configure page "Test" button so the user can verify the
491
- // command resolves + actually launches the right tool BEFORE saving.
492
- // Body: { command, args?, shell?, type? }. args is ignored for the
493
- // version probe — we always append `--version` directly so the user's
494
- // runtime args (e.g. --dangerously-skip-permissions) don't perturb the
495
- // quick probe.
496
- app.post('/api/clis/test', asyncH(async (req, res) => {
497
- const { spawn } = require('node:child_process');
498
- const body = req.body || {};
499
- const command = String(body.command || '').trim();
500
- const shell = ['direct', 'pwsh', 'cmd'].includes(body.shell) ? body.shell : 'direct';
501
- const type = ['claude', 'codex', 'copilot', 'other'].includes(body.type) ? body.type : 'other';
502
- if (!command) return res.status(400).json({ error: 'command required' });
503
-
504
- // Build the test exec. Same shell-wrapping rules as resolveCommand,
505
- // but we force `--version` as the only arg and we DROP `-NoExit`
506
- // from the pwsh wrapper so pwsh terminates after printing.
507
- let exe, args;
508
- const cmd = command.replace(/^\.[\\\/]/, '');
509
- const versionArg = '--version';
510
- if (shell === 'pwsh') {
511
- const joined = `& ${/[\s'"\`$]/.test(cmd) ? `'${cmd.replace(/'/g, "''")}'` : cmd} ${versionArg}`;
512
- exe = 'pwsh.exe';
513
- args = ['-NoLogo', '-Command', joined];
514
- } else if (shell === 'cmd') {
515
- exe = process.env.ComSpec || 'cmd.exe';
516
- args = ['/d', '/s', '/c', `${cmd} ${versionArg}`];
517
- } else if (path.isAbsolute(cmd)) {
518
- const ext = path.extname(cmd).toLowerCase();
519
- if (ext === '.cmd' || ext === '.bat') {
520
- exe = process.env.ComSpec || 'cmd.exe';
521
- args = ['/d', '/s', '/c', cmd, versionArg];
522
- } else if (ext === '.ps1') {
523
- exe = 'powershell.exe';
524
- args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', cmd, versionArg];
525
- } else {
526
- exe = cmd;
527
- args = [versionArg];
528
- }
529
- } else {
530
- exe = process.env.ComSpec || 'cmd.exe';
531
- args = ['/d', '/s', '/c', cmd, versionArg];
532
- }
533
-
534
- const t0 = Date.now();
535
- let stdout = '';
536
- let stderr = '';
537
- let exitCode = null;
538
- let timedOut = false;
539
- let spawnError = null;
540
- try {
541
- const child = spawn(exe, args, { env: spawnEnv(), windowsHide: true });
542
- const killer = setTimeout(() => { timedOut = true; try { child.kill(); } catch {} }, 5000);
543
- child.stdout.on('data', (d) => { stdout += d.toString(); if (stdout.length > 8192) stdout = stdout.slice(0, 8192); });
544
- child.stderr.on('data', (d) => { stderr += d.toString(); if (stderr.length > 8192) stderr = stderr.slice(0, 8192); });
545
- exitCode = await new Promise((resolve, reject) => {
546
- child.on('exit', (code) => { clearTimeout(killer); resolve(code); });
547
- child.on('error', (err) => { clearTimeout(killer); reject(err); });
548
- });
549
- } catch (e) {
550
- spawnError = String(e && e.message || e);
551
- }
552
- const durationMs = Date.now() - t0;
553
-
554
- const out = (stdout + '\n' + stderr).toLowerCase();
555
- const PATTERNS = {
556
- claude: /claude/,
557
- codex: /codex|openai/,
558
- copilot: /copilot/,
559
- };
560
- const matchedType = type === 'other' ? null : (PATTERNS[type] ? PATTERNS[type].test(out) : null);
561
- const ok = !spawnError && !timedOut && exitCode === 0;
562
- res.json({
563
- ok, exitCode, durationMs, timedOut, spawnError,
564
- stdout: stdout.trim(),
565
- stderr: stderr.trim(),
566
- matchedType,
567
- expectedType: type,
568
- spawned: { exe, args },
569
- });
570
- }));
571
-
572
- // ---- folders ----
573
-
574
- app.get('/api/folders', asyncH(async (_req, res) => {
575
- const list = await folders.loadAll();
576
- list.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
577
- res.json({ folders: list });
578
- }));
579
-
580
- app.post('/api/folders', asyncH(async (req, res) => {
581
- const name = req.body && req.body.name;
582
- if (!name) return res.status(400).json({ error: 'name required' });
583
- res.json({ folder: await folders.create({ name }) });
584
- }));
585
-
586
- app.put('/api/folders/:id', asyncH(async (req, res) => {
587
- const updated = await folders.update(req.params.id, req.body || {});
588
- if (!updated) return res.status(404).json({ error: 'not found' });
589
- res.json({ folder: updated });
590
- }));
591
-
592
- app.delete('/api/folders/:id', asyncH(async (req, res) => {
593
- // Move all sessions in this folder to Unsorted before delete.
594
- const all = await persistedSessions.loadAll();
595
- for (const s of all) {
596
- if (s.folderId === req.params.id) {
597
- await persistedSessions.setFolder(s.id, null);
598
- }
599
- }
600
- const removed = await folders.remove(req.params.id);
601
- res.json({ removed });
602
- }));
603
-
604
- app.post('/api/folders/reorder', asyncH(async (req, res) => {
605
- const ids = req.body && req.body.ids;
606
- if (!Array.isArray(ids)) return res.status(400).json({ error: 'ids array required' });
607
- const next = await folders.reorder(ids);
608
- res.json({ folders: next });
609
- }));
610
-
611
- // ---- sessions (persisted, ccsm-owned) ----
612
-
613
- app.get('/api/sessions', asyncH(async (_req, res) => {
614
- const list = await persistedSessions.loadAll();
615
- // Cross-check status against live PTY pool so a stale "running" record
616
- // doesn't survive a server restart.
617
- const live = new Set(webTerminal.list().filter((t) => !t.exitedAt).map((t) => t.id));
618
- for (const s of list) {
619
- if (s.status === 'running' && !live.has(s.id)) {
620
- s.status = 'exited';
621
- }
622
- }
623
- // Per-session activity probe (transcript mtime → working/idle). Cheap
624
- // when cached — most calls are a single fs.stat(). Only runs for
625
- // running sessions; exited ones get 'unknown'.
626
- const cfg = await loadConfig();
627
- const cliById = new Map((cfg.clis || []).map((c) => [c.id, c]));
628
- const { probeActivity } = require('./lib/cliActivity');
629
- await Promise.all(list.map(async (s) => {
630
- if (s.status !== 'running') { s.activity = 'unknown'; return; }
631
- try { s.activity = await probeActivity(s, cliById.get(s.cliId)); }
632
- catch { s.activity = 'unknown'; }
633
- }));
634
- res.json({ sessions: list, takenAt: Date.now() });
635
- }));
636
-
216
+ // v1.1 — spawn strategy is now caller-controlled via cli.shell:
217
+ // 'direct' — pty.spawn(command, args). Real .exe / absolute paths only.
218
+ // Won't find pwsh aliases / functions.
219
+ // 'pwsh' — wrap in `pwsh.exe -NoLogo -NoExit -Command "& { cmd args }"`.
220
+ // Loads $PROFILE → pwsh aliases / functions work.
221
+ // Falls back to powershell.exe (5.x) if pwsh.exe absent.
222
+ // 'cmd' — wrap in `cmd.exe /d /s /c "cmd args"`. Resolves doskey aliases
223
+ // and PATH-only names without pwsh dependency.
224
+ function resolveCommand(commandRaw, userArgs = [], shell = 'direct') {
225
+ if (!commandRaw) throw new Error('cli.command is empty');
226
+ const cmd = commandRaw.replace(/^\.[\\\/]/, '');
227
+
228
+ if (shell === 'pwsh') {
229
+ // Build a single -Command string so pwsh tokenizes args itself. The
230
+ // `& { ... }` wrapper makes pwsh execute the line as a script block —
231
+ // critical for functions (which aren't visible without invocation).
232
+ const joined = [cmd, ...userArgs.map(quoteForPwsh)].join(' ');
233
+ return {
234
+ exe: 'pwsh.exe',
235
+ prefixArgs: ['-NoLogo', '-NoExit', '-Command', `& { ${joined} }`],
236
+ fallbackExe: 'powershell.exe',
237
+ consumesUserArgs: true,
238
+ };
239
+ }
240
+
241
+ if (shell === 'cmd') {
242
+ // /d skips AutoRun, /s preserves quoting, /c runs and exits.
243
+ const joined = [cmd, ...userArgs.map(quoteForCmd)].join(' ');
244
+ return {
245
+ exe: process.env.ComSpec || 'cmd.exe',
246
+ prefixArgs: ['/d', '/s', '/c', joined],
247
+ consumesUserArgs: true,
248
+ };
249
+ }
250
+
251
+ // shell === 'direct' — bare pty.spawn. Honour .cmd/.bat/.ps1 extensions
252
+ // when an absolute path was provided so they still work without an
253
+ // explicit shell choice.
254
+ if (path.isAbsolute(cmd)) {
255
+ const ext = path.extname(cmd).toLowerCase();
256
+ if (ext === '.cmd' || ext === '.bat') {
257
+ return { exe: process.env.ComSpec || 'cmd.exe', prefixArgs: ['/d', '/s', '/c', cmd], consumesUserArgs: false };
258
+ }
259
+ if (ext === '.ps1') {
260
+ return { exe: 'powershell.exe', prefixArgs: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', cmd], consumesUserArgs: false };
261
+ }
262
+ return { exe: cmd, prefixArgs: [], consumesUserArgs: false };
263
+ }
264
+ // Bare name with shell=direct: defer to cmd.exe so Windows resolves
265
+ // against PATH. Same behavior as before — preserves user expectations
266
+ // for `claude` / `codex` configs that don't set shell.
267
+ return { exe: process.env.ComSpec || 'cmd.exe', prefixArgs: ['/d', '/s', '/c', cmd], consumesUserArgs: false };
268
+ }
269
+
270
+ function quoteForPwsh(s) {
271
+ if (s === '' || /[\s'"`$]/.test(s)) return `'${String(s).replace(/'/g, "''")}'`;
272
+ return s;
273
+ }
274
+ function quoteForCmd(s) {
275
+ if (s === '' || /[\s"&|<>^]/.test(s)) return `"${String(s).replace(/"/g, '""')}"`;
276
+ return s;
277
+ }
278
+
279
+ function spawnCliSession({ cli, cwd, sessionId, meta, extraArgs = [], theme, cols, rows }) {
280
+ if (!webTerminal.available) {
281
+ const e = new Error('node-pty unavailable · cannot spawn web terminal');
282
+ e.code = 'PTY_UNAVAILABLE';
283
+ throw e;
284
+ }
285
+ // For shell wrappers (pwsh/cmd) we need to bake BOTH cli.args and
286
+ // extraArgs into the single quoted command string — otherwise extraArgs
287
+ // would become args to the shell itself, not the wrapped command.
288
+ // Re-resolve here when extraArgs is present so the quoting is correct.
289
+ // Force a session-scoped theme=auto for claude so its syntax/diff colours
290
+ // follow the ccsm terminal background (which we report via OSC 10/11 in
291
+ // TerminalView). claude's DEFAULT theme is *dark*, whose near-white tokens
292
+ // (comments, f-string interpolations, call names) vanish on our light
293
+ // terminal — the "字体颜色和背景重复" bug. --settings is session-scoped, so
294
+ // the user's global ~/.claude/settings.json is left untouched, and ccsm
295
+ // sessions Just Work on a fresh machine without anyone running /theme auto.
296
+ // (Injected here as an integration arg, not via the user-editable
297
+ // cli.args, so it reaches existing configs too.)
298
+ // Skip the injection entirely if the user already put their own --settings
299
+ // in cli.args — claude deep-merges multiple --settings (verified: later ones
300
+ // win per-key), so ours would silently override a theme they set on purpose.
301
+ // Respect the user's explicit choice instead.
302
+ const userHasSettings = (cli.args || []).some(
303
+ (a) => a === '--settings' || String(a).startsWith('--settings='));
304
+ const baseArgs = [...(cli.args || [])];
305
+ if (cli.type === 'claude' && !userHasSettings) baseArgs.push('--settings', '{"theme":"auto"}');
306
+ const resolved = resolveCommand(
307
+ cli.command,
308
+ [...baseArgs, ...extraArgs],
309
+ cli.shell || 'direct',
310
+ );
311
+ const { exe, prefixArgs, fallbackExe, consumesUserArgs } = resolved;
312
+ const args = consumesUserArgs
313
+ ? prefixArgs
314
+ : [...prefixArgs, ...baseArgs, ...extraArgs];
315
+ // Merge user-scope PATH from registry into the env we hand the PTY.
316
+ // spawnEnv() also strips duplicate path-case keys so our override
317
+ // doesn't get shadowed by the inherited `Path` from process.env.
318
+ const env = spawnEnv(cli.env);
319
+ // Tell background-aware CLIs which way the ccsm terminal is painted, so
320
+ // their light/dark auto-detection matches it. COLORFGBG (fg;bg ANSI indices)
321
+ // is the de-facto signal that codex (its DiffTheme probes it), copilot, and
322
+ // claude all read — bg 15 = light, 0 = dark. claude additionally gets OSC
323
+ // 10/11 answers + --settings auto; this covers codex/copilot, which detect
324
+ // via COLORFGBG, not OSC. The frontend passes its resolved theme on spawn;
325
+ // a theme switch is picked up on the next resume.
326
+ if (theme === 'light' || theme === 'dark') {
327
+ env.COLORFGBG = theme === 'light' ? '0;15' : '15;0';
328
+ }
329
+ // Spawn the PTY at the size the frontend measured for its terminal pane
330
+ // (clamped against junk), so alt-screen CLIs lay out at the right height
331
+ // from the first frame instead of node-pty's 120×30 default. Omitted ⇒
332
+ // webTerminal.spawn keeps its default; xterm's first resize corrects any
333
+ // small estimate error on attach regardless.
334
+ const sized = (Number(cols) > 0 && Number(rows) > 0)
335
+ ? { cols: Math.min(400, Math.max(20, Math.floor(Number(cols)))),
336
+ rows: Math.min(200, Math.max(8, Math.floor(Number(rows)))) }
337
+ : {};
338
+ const trySpawn = (executable) => webTerminal.spawn({
339
+ id: sessionId,
340
+ command: executable,
341
+ args,
342
+ cwd,
343
+ env,
344
+ ...sized,
345
+ meta: { ...meta, cliId: cli.id, cliName: cli.name },
346
+ onData: () => {
347
+ persistedSessions.touch(sessionId).catch(() => {});
348
+ try { require('./lib/cliActivity').noteOutput(sessionId); } catch {}
349
+ },
350
+ onExit: ({ exitCode }) => {
351
+ persistedSessions.markExited(sessionId, exitCode).catch(() => {});
352
+ },
353
+ });
354
+ try {
355
+ const entry = trySpawn(exe);
356
+ return entry;
357
+ } catch (e) {
358
+ if (fallbackExe && /ENOENT|cannot find|not recognized/i.test(String(e && e.message || e))) {
359
+ const entry = trySpawn(fallbackExe);
360
+ return entry;
361
+ }
362
+ throw e;
363
+ }
364
+ }
365
+
366
+ // Read user PATH from registry once at boot, prepend to process PATH.
367
+ // On platforms other than Windows or if the read fails, fall back to
368
+ // process.env.PATH unchanged.
369
+ let mergedUserPath = null;
370
+ function buildMergedUserPath() {
371
+ if (process.platform !== 'win32') return process.env.PATH;
372
+ try {
373
+ const { spawnSync } = require('node:child_process');
374
+ const r = spawnSync('reg.exe', ['query', 'HKCU\\Environment', '/v', 'PATH'], { encoding: 'utf8', windowsHide: true });
375
+ if (r.status !== 0 || !r.stdout) return process.env.PATH;
376
+ const line = r.stdout.split(/\r?\n/).find((l) => /\bPATH\b/i.test(l) && /REG_(EXPAND_)?SZ/i.test(l));
377
+ if (!line) return process.env.PATH;
378
+ const m = line.match(/REG_(?:EXPAND_)?SZ\s+(.+)$/);
379
+ if (!m) return process.env.PATH;
380
+ // Expand %VAR% references manually (REG_EXPAND_SZ keeps them literal).
381
+ const userPath = m[1].replace(/%([^%]+)%/g, (_, name) => process.env[name] || '');
382
+ const existing = (process.env.PATH || '').split(';').map((s) => s.trim()).filter(Boolean);
383
+ const adds = userPath.split(';').map((s) => s.trim()).filter(Boolean);
384
+ const merged = [];
385
+ const seen = new Set();
386
+ for (const p of [...adds, ...existing]) {
387
+ const k = p.toLowerCase();
388
+ if (seen.has(k)) continue;
389
+ seen.add(k);
390
+ merged.push(p);
391
+ }
392
+ return merged.join(';');
393
+ } catch {
394
+ return process.env.PATH;
395
+ }
396
+ }
397
+ mergedUserPath = buildMergedUserPath();
398
+
399
+ // Hand back a fresh env for spawning a child, with PATH overridden by
400
+ // our merged user PATH and any duplicate case variants of "path"
401
+ // stripped first. Windows env lookup is case-insensitive but the env
402
+ // block we hand CreateProcess is an ordered byte buffer — if both
403
+ // `Path` (inherited from process.env, OS canonical case) and `PATH`
404
+ // (our override) are present, Windows resolves to whichever comes
405
+ // first in the block. Node's Object.keys preserves insertion order,
406
+ // so the inherited `Path` would win and our merged override silently
407
+ // disappear. Strip all path-shaped keys first, then add the merge.
408
+ function spawnEnv(extraEnv = {}) {
409
+ const env = { ...process.env, ...extraEnv };
410
+ if (process.platform === 'win32') {
411
+ for (const k of Object.keys(env)) {
412
+ if (k.toLowerCase() === 'path') delete env[k];
413
+ }
414
+ }
415
+ if (mergedUserPath) env.PATH = mergedUserPath;
416
+ return env;
417
+ }
418
+
419
+ // ---- config ----
420
+
421
+ // Per-CLI install probe. Looks up the command on PATH using `where` (win)
422
+ // or `which` (posix). Result is cached forever — restart ccsm after
423
+ // installing/uninstalling a CLI to refresh. Cheap (10ms cold, 0ms cached).
424
+ const cliProbeCache = new Map();
425
+ function probeCli(command) {
426
+ if (!command) return null;
427
+ if (cliProbeCache.has(command)) return cliProbeCache.get(command);
428
+ const { spawnSync } = require('node:child_process');
429
+ let resolvedPath = null;
430
+ try {
431
+ const isWin = process.platform === 'win32';
432
+ const cmd = isWin ? 'where.exe' : 'which';
433
+ const env = { ...process.env };
434
+ if (mergedUserPath) env.PATH = mergedUserPath;
435
+ const r = spawnSync(cmd, [command], { encoding: 'utf8', windowsHide: true, env });
436
+ if (r.status === 0 && r.stdout) {
437
+ resolvedPath = r.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)[0] || null;
438
+ }
439
+ } catch {}
440
+ cliProbeCache.set(command, resolvedPath);
441
+ return resolvedPath;
442
+ }
443
+
444
+ function decorateConfigWithProbes(cfg) {
445
+ return {
446
+ ...cfg,
447
+ clis: (cfg.clis || []).map((c) => {
448
+ const path = probeCli(c.command);
449
+ return { ...c, installed: !!path, installPath: path };
450
+ }),
451
+ };
452
+ }
453
+
454
+ // The tunnel + devtunnel config blocks are managed exclusively through
455
+ // /api/tunnel/* (host-only) — they hold the persisted remote-access
456
+ // token and the named tunnelId. Strip them from /api/config so (a) the
457
+ // plaintext token never reaches an approved remote device reading config
458
+ // and (b) the frontend's whole-object config round-trip on save can't
459
+ // clobber tunnelId/token with a stale snapshot.
460
+ function stripTunnelKeys(cfg) {
461
+ const rest = { ...cfg };
462
+ delete rest.tunnel;
463
+ delete rest.devtunnel;
464
+ return rest;
465
+ }
466
+
467
+ function workspaceOccupancySessions(sessions, cfg) {
468
+ return (sessions || []).filter((s) => s && s.cwd);
469
+ }
470
+
471
+ function workspaceOccupancyLabel(cfg) {
472
+ return 'session';
473
+ }
474
+
475
+ function launchCwdFor(workspace, wantedRepos, explicitCwd) {
476
+ return explicitCwd
477
+ ? workspace.path
478
+ : (wantedRepos.length === 1 ? path.join(workspace.path, wantedRepos[0].name) : workspace.path);
479
+ }
480
+
481
+ function resumeMode(cfg) {
482
+ return cfg?.resumeMode === 'picker' ? 'picker' : 'latest';
483
+ }
484
+
485
+ function buildFolderResumeArgs(cli, cfg) {
486
+ const mode = resumeMode(cfg);
487
+ const field = mode === 'picker' ? 'resumePickerArgs' : 'resumeLatestArgs';
488
+ const args = Array.isArray(cli?.[field]) ? cli[field] : [];
489
+ if (args.length === 0) {
490
+ throw new Error(`CLI ${cli?.id || '(unknown)'} has no ${field} configured`);
491
+ }
492
+ return args;
493
+ }
494
+
495
+ async function spawnSessionRecord({ record, cli, cfg, body, resume = false }) {
496
+ const live = webTerminal.get(record.id);
497
+ if (live && !live.exitedAt) {
498
+ if (record.status !== 'running' || record.pid !== live.meta.pid) {
499
+ try { await persistedSessions.markRunning(record.id, live.meta.pid); } catch {}
500
+ }
501
+ return { id: record.id, pid: live.meta.pid, cliId: record.cliId };
502
+ }
503
+ const themeArgs = await codexThemeArgs(cli, body && body.theme);
504
+ const folderResumeArgs = resume ? buildFolderResumeArgs(cli, cfg) : [];
505
+ const entry = spawnCliSession({
506
+ cli,
507
+ cwd: record.cwd,
508
+ sessionId: record.id,
509
+ meta: { title: record.title || record.workspace, workspace: record.workspace, cwd: record.cwd },
510
+ extraArgs: [...themeArgs, ...folderResumeArgs],
511
+ theme: body && body.theme,
512
+ cols: body && body.cols,
513
+ rows: body && body.rows,
514
+ });
515
+ await persistedSessions.markRunning(record.id, entry.meta.pid);
516
+ return { id: record.id, pid: entry.meta.pid, cliId: cli.id };
517
+ }
518
+
519
+ app.get('/api/config', asyncH(async (_req, res) => {
520
+ res.json(decorateConfigWithProbes(stripTunnelKeys(await loadConfig())));
521
+ }));
522
+
523
+ app.put('/api/config', asyncH(async (req, res) => {
524
+ const body = { ...(req.body || {}) };
525
+ delete body.tunnel;
526
+ delete body.devtunnel;
527
+ res.json(decorateConfigWithProbes(stripTunnelKeys(await saveConfig(body))));
528
+ }));
529
+
530
+ // ---- CLI probe / test ----
531
+ //
532
+ // Run the user's configured command with `--version` and report back
533
+ // stdout/stderr + whether the output looks like the claimed CLI type.
534
+ // Used by the Configure page "Test" button so the user can verify the
535
+ // command resolves + actually launches the right tool BEFORE saving.
536
+ // Body: { command, args?, shell?, type? }. args is ignored for the
537
+ // version probe we always append `--version` directly so the user's
538
+ // runtime args (e.g. --dangerously-skip-permissions) don't perturb the
539
+ // quick probe.
540
+ app.post('/api/clis/test', asyncH(async (req, res) => {
541
+ const { spawn } = require('node:child_process');
542
+ const body = req.body || {};
543
+ const command = String(body.command || '').trim();
544
+ const shell = ['direct', 'pwsh', 'cmd'].includes(body.shell) ? body.shell : 'direct';
545
+ const type = ['claude', 'codex', 'copilot', 'other'].includes(body.type) ? body.type : 'other';
546
+ if (!command) return res.status(400).json({ error: 'command required' });
547
+
548
+ // Build the test exec. Same shell-wrapping rules as resolveCommand,
549
+ // but we force `--version` as the only arg and we DROP `-NoExit`
550
+ // from the pwsh wrapper so pwsh terminates after printing.
551
+ let exe, args;
552
+ const cmd = command.replace(/^\.[\\\/]/, '');
553
+ const versionArg = '--version';
554
+ if (shell === 'pwsh') {
555
+ const joined = `& ${/[\s'"\`$]/.test(cmd) ? `'${cmd.replace(/'/g, "''")}'` : cmd} ${versionArg}`;
556
+ exe = 'pwsh.exe';
557
+ args = ['-NoLogo', '-Command', joined];
558
+ } else if (shell === 'cmd') {
559
+ exe = process.env.ComSpec || 'cmd.exe';
560
+ args = ['/d', '/s', '/c', `${cmd} ${versionArg}`];
561
+ } else if (path.isAbsolute(cmd)) {
562
+ const ext = path.extname(cmd).toLowerCase();
563
+ if (ext === '.cmd' || ext === '.bat') {
564
+ exe = process.env.ComSpec || 'cmd.exe';
565
+ args = ['/d', '/s', '/c', cmd, versionArg];
566
+ } else if (ext === '.ps1') {
567
+ exe = 'powershell.exe';
568
+ args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', cmd, versionArg];
569
+ } else {
570
+ exe = cmd;
571
+ args = [versionArg];
572
+ }
573
+ } else {
574
+ exe = process.env.ComSpec || 'cmd.exe';
575
+ args = ['/d', '/s', '/c', cmd, versionArg];
576
+ }
577
+
578
+ const t0 = Date.now();
579
+ let stdout = '';
580
+ let stderr = '';
581
+ let exitCode = null;
582
+ let timedOut = false;
583
+ let spawnError = null;
584
+ try {
585
+ const child = spawn(exe, args, { env: spawnEnv(), windowsHide: true });
586
+ const killer = setTimeout(() => { timedOut = true; try { child.kill(); } catch {} }, 5000);
587
+ child.stdout.on('data', (d) => { stdout += d.toString(); if (stdout.length > 8192) stdout = stdout.slice(0, 8192); });
588
+ child.stderr.on('data', (d) => { stderr += d.toString(); if (stderr.length > 8192) stderr = stderr.slice(0, 8192); });
589
+ exitCode = await new Promise((resolve, reject) => {
590
+ child.on('exit', (code) => { clearTimeout(killer); resolve(code); });
591
+ child.on('error', (err) => { clearTimeout(killer); reject(err); });
592
+ });
593
+ } catch (e) {
594
+ spawnError = String(e && e.message || e);
595
+ }
596
+ const durationMs = Date.now() - t0;
597
+
598
+ const out = (stdout + '\n' + stderr).toLowerCase();
599
+ const PATTERNS = {
600
+ claude: /claude/,
601
+ codex: /codex|openai/,
602
+ copilot: /copilot/,
603
+ };
604
+ const matchedType = type === 'other' ? null : (PATTERNS[type] ? PATTERNS[type].test(out) : null);
605
+ const ok = !spawnError && !timedOut && exitCode === 0;
606
+ res.json({
607
+ ok, exitCode, durationMs, timedOut, spawnError,
608
+ stdout: stdout.trim(),
609
+ stderr: stderr.trim(),
610
+ matchedType,
611
+ expectedType: type,
612
+ spawned: { exe, args },
613
+ });
614
+ }));
615
+
616
+ // ---- folders ----
617
+
618
+ app.get('/api/folders', asyncH(async (_req, res) => {
619
+ const list = await folders.loadAll();
620
+ list.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
621
+ res.json({ folders: list });
622
+ }));
623
+
624
+ app.post('/api/folders', asyncH(async (req, res) => {
625
+ const name = req.body && req.body.name;
626
+ if (!name) return res.status(400).json({ error: 'name required' });
627
+ res.json({ folder: await folders.create({ name }) });
628
+ }));
629
+
630
+ app.put('/api/folders/:id', asyncH(async (req, res) => {
631
+ const updated = await folders.update(req.params.id, req.body || {});
632
+ if (!updated) return res.status(404).json({ error: 'not found' });
633
+ res.json({ folder: updated });
634
+ }));
635
+
636
+ app.delete('/api/folders/:id', asyncH(async (req, res) => {
637
+ // Move all sessions in this folder to Unsorted before delete.
638
+ const all = await persistedSessions.loadAll();
639
+ for (const s of all) {
640
+ if (s.folderId === req.params.id) {
641
+ await persistedSessions.setFolder(s.id, null);
642
+ }
643
+ }
644
+ const removed = await folders.remove(req.params.id);
645
+ res.json({ removed });
646
+ }));
647
+
648
+ app.post('/api/folders/reorder', asyncH(async (req, res) => {
649
+ const ids = req.body && req.body.ids;
650
+ if (!Array.isArray(ids)) return res.status(400).json({ error: 'ids array required' });
651
+ const next = await folders.reorder(ids);
652
+ res.json({ folders: next });
653
+ }));
654
+
655
+ // ---- sessions (persisted, ccsm-owned) ----
656
+
657
+ app.get('/api/sessions', asyncH(async (_req, res) => {
658
+ const list = await persistedSessions.loadAll();
659
+ // Cross-check status against live PTY pool so a stale "running" record
660
+ // doesn't survive a server restart.
661
+ const live = new Set(webTerminal.list().filter((t) => !t.exitedAt).map((t) => t.id));
662
+ for (const s of list) {
663
+ if (s.status === 'running' && !live.has(s.id)) {
664
+ s.status = 'exited';
665
+ }
666
+ }
667
+ // Per-session activity probe (transcript mtime → working/idle). Cheap
668
+ // when cached — most calls are a single fs.stat(). Only runs for
669
+ // running sessions; exited ones get 'unknown'.
670
+ const cfg = await loadConfig();
671
+ const cliById = new Map((cfg.clis || []).map((c) => [c.id, c]));
672
+ const { probeActivity } = require('./lib/cliActivity');
673
+ await Promise.all(list.map(async (s) => {
674
+ if (s.status !== 'running') { s.activity = 'unknown'; return; }
675
+ try { s.activity = await probeActivity(s, cliById.get(s.cliId)); }
676
+ catch { s.activity = 'unknown'; }
677
+ }));
678
+ res.json({ sessions: list, takenAt: Date.now() });
679
+ }));
680
+
637
681
  app.put('/api/sessions/:id', asyncH(async (req, res) => {
638
682
  const patch = {};
639
683
  if (typeof req.body.title === 'string') patch.title = req.body.title;
@@ -643,10 +687,9 @@ app.put('/api/sessions/:id', asyncH(async (req, res) => {
643
687
  res.json({ session: updated });
644
688
  }));
645
689
 
646
- // Switch the CLI config used to resume an existing session. This is
647
- // intentionally narrower than the generic PUT route: a session can only
648
- // move between configured CLIs of the same type (e.g. one claude wrapper
649
- // to another) so its captured upstream cliSessionId stays meaningful.
690
+ // Switch the CLI config used to resume an existing session. Folder-level
691
+ // resume only depends on the record cwd, so this is just a persisted
692
+ // preference for the next launch.
650
693
  app.post('/api/sessions/:id/switch-cli', asyncH(async (req, res) => {
651
694
  const targetCliId = typeof req.body?.cliId === 'string' ? req.body.cliId.trim() : '';
652
695
  if (!targetCliId) return res.status(400).json({ error: 'cliId required' });
@@ -659,11 +702,6 @@ app.post('/api/sessions/:id/switch-cli', asyncH(async (req, res) => {
659
702
  const targetCli = findCliById(cfg, targetCliId);
660
703
  if (!currentCli) return res.status(400).json({ error: `current CLI ${record.cliId} no longer configured` });
661
704
  if (!targetCli) return res.status(400).json({ error: `target CLI ${targetCliId} not configured` });
662
- if (currentCli.type !== targetCli.type) {
663
- return res.status(400).json({
664
- error: `cannot switch ${currentCli.type} session to ${targetCli.type} CLI`,
665
- });
666
- }
667
705
 
668
706
  if (record.cliId === targetCli.id) {
669
707
  const live = webTerminal.get(record.id);
@@ -678,7 +716,6 @@ app.post('/api/sessions/:id/switch-cli', asyncH(async (req, res) => {
678
716
  running: !!(live && !live.exitedAt),
679
717
  fromCliId: currentCli.id,
680
718
  toCliId: targetCli.id,
681
- cliType: targetCli.type,
682
719
  });
683
720
  }));
684
721
 
@@ -704,1184 +741,1078 @@ app.post('/api/sessions/:id/stop', asyncH(async (req, res) => {
704
741
  app.delete('/api/sessions/:id', asyncH(async (req, res) => {
705
742
  // Kill PTY first if it's still alive, then drop the record.
706
743
  try { webTerminal.kill(req.params.id); } catch {}
707
- const removed = await persistedSessions.remove(req.params.id);
708
- try { require('./lib/cliActivity').releaseSession(req.params.id); } catch {}
709
- res.json({ removed });
710
- }));
711
-
712
- // Open a session's working directory in the user's configured editor
713
- // (config.editor, default `code` = VS Code, whose Source Control panel is
714
- // also the review-changes view once the folder's open). Spawned detached
715
- // so it outlives ccsm; shell:true so Windows resolves `code.cmd` via
716
- // PATHEXT and a command like `code --reuse-window` parses, with the cwd
717
- // quoted so paths with spaces survive the shell. spawnEnv() merges the
718
- // user-scope PATH so `code`/`cursor` are found even when the inherited
719
- // env lacks them.
720
- app.post('/api/sessions/:id/open-editor', asyncH(async (req, res) => {
721
- const record = await persistedSessions.get(req.params.id);
722
- if (!record) return res.status(404).json({ error: 'session not found' });
723
- const cfg = await loadConfig();
724
- const editor = (cfg.editor || '').trim() || 'code';
725
- const { spawn } = require('node:child_process');
726
- try {
727
- const child = spawn(editor, [`"${record.cwd}"`], {
728
- detached: true, stdio: 'ignore', shell: true,
729
- env: spawnEnv(), windowsHide: true,
730
- });
731
- // A bad editor command fails the shell async (after we've responded);
732
- // log it so it's diagnosable, but the happy path needs no await.
733
- child.on('error', (e) => console.warn(`[ccsm] open-editor "${editor}" failed:`, e.message));
734
- child.unref();
735
- res.json({ ok: true, editor, cwd: record.cwd });
736
- } catch (e) {
737
- res.status(500).json({ error: `failed to launch ${editor}: ${e.message}` });
738
- }
739
- }));
740
-
741
- // Reorder sessions within a folder. Body: { folderId, ids } where ids
742
- // is the new sequence of session ids in their final display order
743
- // inside that folder. Each session gets `folderId` + `order: 0..N-1`
744
- // assigned. Setting folderId here (rather than requiring a separate
745
- // PUT) lets the drag-and-drop UI move a session across folders AND
746
- // drop it at a specific position in one shot — without the call, the
747
- // move would either land at the end of the destination folder (just
748
- // PUT folderId) or leave it in place (just reorder).
749
- app.post('/api/sessions/reorder', asyncH(async (req, res) => {
750
- const ids = Array.isArray(req.body?.ids) ? req.body.ids : null;
751
- if (!ids) return res.status(400).json({ error: 'ids array required' });
752
- const folderId = req.body?.folderId ?? null;
753
- for (let i = 0; i < ids.length; i++) {
754
- try { await persistedSessions.update(ids[i], { folderId, order: i }); } catch {}
755
- }
756
- res.json({ ok: true, count: ids.length });
757
- }));
758
-
759
- // ---- workspaces ----
760
-
761
- // ---- directory browser ----
762
- // Lets the launch picker walk the filesystem so users can pick any
763
- // existing directory as the session cwd. Returns the immediate child
764
- // dirs of `path` (defaults to home), plus a few hardcoded "starts"
765
- // (home, workDir, drive roots on Windows).
766
- app.get('/api/browse', asyncH(async (req, res) => {
767
- const fs = require('node:fs/promises');
768
- const os = require('node:os');
769
- const target = req.query.path ? path.resolve(String(req.query.path)) : os.homedir();
770
- let entries = [];
771
- let exists = true;
772
- try {
773
- const list = await fs.readdir(target, { withFileTypes: true });
774
- entries = list
775
- .filter((d) => d.isDirectory() && !d.name.startsWith('.'))
776
- .map((d) => ({ name: d.name, path: path.join(target, d.name) }))
777
- .sort((a, b) => a.name.localeCompare(b.name));
778
- } catch (e) {
779
- exists = false;
780
- }
781
- const parent = path.dirname(target);
782
- const cfg = await loadConfig();
783
- const starts = [
784
- { label: 'Home', path: os.homedir() },
785
- { label: 'Work dir', path: cfg.workDir },
786
- ];
787
- if (process.platform === 'win32') {
788
- // Best-effort drive enumeration so users on D:\ etc can hop roots.
789
- for (const letter of ['C', 'D', 'E', 'F', 'G', 'H']) {
790
- const root = `${letter}:\\`;
791
- try { await fs.access(root); starts.push({ label: `${letter}:\\`, path: root }); }
792
- catch {}
793
- }
794
- }
795
- res.json({
796
- path: target,
797
- parent: parent === target ? null : parent,
798
- exists,
799
- entries,
800
- starts,
801
- });
802
- }));
803
-
804
- app.get('/api/workspaces', asyncH(async (req, res) => {
805
- const cfg = await loadConfig();
806
- const workspaces = await listWorkspaces({
807
- workDir: cfg.workDir,
808
- repos: cfg.repos,
809
- });
810
- // Recompute inUse based on persistedSessions: a workspace is in use
811
- // iff any RUNNING ccsm session lives at-or-inside it.
812
- const allSess = await persistedSessions.loadAll();
813
- const busy = new Set(
814
- allSess.filter((s) => s.status === 'running').map((s) => path.resolve(s.cwd).toLowerCase())
815
- );
816
- for (const w of workspaces) {
817
- w.inUse = busy.has(path.resolve(w.path).toLowerCase());
818
- w.sessionsHere = allSess
819
- .filter((s) => s.status === 'running' && path.resolve(s.cwd).toLowerCase() === path.resolve(w.path).toLowerCase())
820
- .map((s) => s.id);
821
- }
822
- res.json({ workDir: cfg.workDir, repos: cfg.repos, workspaces });
823
- }));
824
-
825
- // Delete a workspace directory. Refuses if any RUNNING session lives
826
- // inside it, or if the resolved path escapes workDir. The name comes
827
- // from the URL we resolve it against workDir and verify containment.
828
- app.delete('/api/workspaces/:name', asyncH(async (req, res) => {
829
- const fsp = require('node:fs/promises');
830
- const cfg = await loadConfig();
831
- const name = String(req.params.name || '');
832
- // Reject anything that tries to escape via separators / traversal.
833
- if (!name || /[\\/]|^\.\.$|^\.$/.test(name)) {
834
- return res.status(400).json({ error: 'invalid workspace name' });
835
- }
836
- const target = path.resolve(cfg.workDir, name);
837
- if (!isInside(target, cfg.workDir) || path.resolve(target) === path.resolve(cfg.workDir)) {
838
- return res.status(400).json({ error: 'workspace must live under workDir' });
839
- }
840
- try {
841
- const st = await fsp.stat(target);
842
- if (!st.isDirectory()) return res.status(400).json({ error: 'not a directory' });
843
- } catch {
844
- return res.status(404).json({ error: 'workspace not found' });
845
- }
846
- const allSess = await persistedSessions.loadAll();
847
- const inUse = allSess.some((s) =>
848
- s.status === 'running' && isInside(s.cwd, target)
849
- );
850
- if (inUse) return res.status(409).json({ error: 'workspace is in use by a running session' });
851
- await fsp.rm(target, { recursive: true, force: true });
852
- res.json({ ok: true });
853
- }));
854
-
855
- // ---- new session ----
856
- // body: { cliId?, repos?, workspace?, folderId?, launch?: true }
857
- // Streams NDJSON: workspace / clone-* / launched / done.
858
- app.post('/api/sessions/new', async (req, res) => {
859
- res.setHeader('Content-Type', 'application/x-ndjson');
860
- res.setHeader('Cache-Control', 'no-cache, no-transform');
861
- res.setHeader('X-Accel-Buffering', 'no');
862
- if (typeof res.flushHeaders === 'function') res.flushHeaders();
863
-
864
- const emit = (obj) => { res.write(JSON.stringify(obj) + '\n'); };
865
- const fail = (msg, extra) => {
866
- emit({ type: 'done', success: false, error: msg, ...extra });
867
- res.end();
868
- };
869
-
870
- try {
871
- const cfg = await loadConfig();
872
- const cli = pickCli(cfg, req.body && req.body.cliId);
873
- if (!cli) return fail('No CLI configured. Add one in Configure → CLIs.');
874
-
875
- const explicitRepos = Array.isArray(req.body && req.body.repos);
876
- const wantedNames = explicitRepos
877
- ? req.body.repos
878
- : cfg.repos.filter((r) => r.defaultSelected).map((r) => r.name);
879
- const wantedRepos = cfg.repos.filter((r) => wantedNames.includes(r.name));
880
- if (wantedRepos.length === 0 && !explicitRepos && wantedNames.length > 0) {
881
- return fail('No matching repos found');
882
- }
883
-
884
- let workspace;
885
- let created = false;
886
- // Three cwd modes:
887
- // 1. body.cwd user picked an existing directory; skip clone.
888
- // 2. body.workspace reuse a named workspace under workDir.
889
- // 3. (neither) — auto-allocate a fresh ws-N.
890
- if (req.body && req.body.cwd) {
891
- const fsmod = require('node:fs/promises');
892
- const cwd = path.resolve(String(req.body.cwd));
893
- try {
894
- const st = await fsmod.stat(cwd);
895
- if (!st.isDirectory()) return fail(`${cwd} is not a directory`);
896
- } catch {
897
- return fail(`directory not found: ${cwd}`);
898
- }
899
- workspace = { name: path.basename(cwd) || cwd, path: cwd };
900
- } else if (req.body && req.body.workspace) {
901
- const all = await listWorkspaces({ workDir: cfg.workDir, repos: cfg.repos });
902
- workspace = all.find((w) => w.name === req.body.workspace);
903
- if (!workspace) return fail(`workspace ${req.body.workspace} not found`);
904
- } else {
905
- // Collect cwds of currently-running persisted sessions so
906
- // findOrCreateWorkspace can flag those workspaces as in-use and
907
- // skip past ws-1 when it's already occupied.
908
- const running = await persistedSessions.loadAll();
909
- const busyPaths = running
910
- .filter((s) => s.status === 'running')
911
- .map((s) => s.cwd);
912
- const r = await findOrCreateWorkspace({
913
- workDir: cfg.workDir,
914
- repos: cfg.repos,
915
- busyPaths,
916
- requireUnused: true,
917
- });
918
- workspace = r.workspace;
919
- created = r.created;
920
- }
921
- emit({ type: 'workspace', workspace, created });
922
-
923
- // Skip clone entirely when user picked an existing directory — we
924
- // don't want to dump random repos into someone's project.
925
- const cloneResults = (req.body && req.body.cwd) ? [] : await ensureReposInWorkspace({
926
- workspacePath: workspace.path,
927
- repos: wantedRepos,
928
- onRepoStart: (repo) =>
929
- emit({ type: 'clone-start', repo: repo.name, url: repo.url }),
930
- onProgress: (repo, p) =>
931
- emit({ type: 'clone-progress', repo: repo.name, ...p }),
932
- onLine: (repo, line) =>
933
- emit({ type: 'clone-line', repo: repo.name, line }),
934
- onRepoEnd: (repo, result) =>
935
- emit({ type: 'clone-end', repo: repo.name, ...result }),
936
- });
937
- const failed = cloneResults.filter((r) => !r.ok);
938
- if (failed.length > 0) return fail('Some repos failed to clone', { cloneResults });
939
-
940
- const shouldLaunch = req.body && req.body.launch !== false;
941
- let launched = null;
942
- if (shouldLaunch) {
943
- // Pre-assign the upstream CLI session UUID so we never have to
944
- // poll/scan the transcript dir to find out what id the CLI picked.
945
- // - claude / copilot expose `--session-id <uuid>` natively.
946
- // - codex has no flag, but accepts `resume <uuid>` against a
947
- // pre-existing rollout file. We seed a fake file (see
948
- // lib/codexSeed.js) so the first launch is a resume against
949
- // our seed; codex then appends to the same file.
950
- const newIdTpl = Array.isArray(cli.newSessionIdArgs) ? cli.newSessionIdArgs : [];
951
- const preAssignedId = newIdTpl.length > 0 ? crypto.randomUUID() : null;
952
- const newSessionArgs = preAssignedId
953
- ? newIdTpl.map((a) => (typeof a === 'string' ? a.replace(/<id>/g, preAssignedId) : a))
954
- : [];
955
-
956
- if (preAssignedId && cli.type === 'codex') {
957
- try {
958
- const { seedCodexSession } = require('./lib/codexSeed');
959
- await seedCodexSession({ id: preAssignedId, cwd: workspace.path, cli });
960
- } catch (e) {
961
- return fail(`codex seed failed: ${e.message}`);
962
- }
963
- }
964
-
965
- // Create the persistedSessions record FIRST so spawnCliSession can
966
- // use its id as the PTY id (matching ids simplify resume/attach).
967
- const record = await persistedSessions.create({
968
- cliId: cli.id,
969
- cwd: workspace.path,
970
- workspace: workspace.name,
971
- repos: wantedRepos.map((r) => r.name),
972
- folderId: (req.body && req.body.folderId) || null,
973
- title: '',
974
- cliSessionId: preAssignedId || undefined,
975
- });
976
- try {
977
- const themeArgs = await codexThemeArgs(cli, req.body && req.body.theme);
978
- const entry = spawnCliSession({
979
- cli,
980
- cwd: workspace.path,
981
- sessionId: record.id,
982
- meta: { title: workspace.name, workspace: workspace.name, cwd: workspace.path },
983
- extraArgs: [...themeArgs, ...newSessionArgs],
984
- theme: req.body && req.body.theme,
985
- cols: req.body && req.body.cols,
986
- rows: req.body && req.body.rows,
987
- });
988
- await persistedSessions.markRunning(record.id, entry.meta.pid);
989
- launched = { id: record.id, pid: entry.meta.pid, cliId: cli.id };
990
- emit({ type: 'launched', launched });
991
- } catch (e) {
992
- await persistedSessions.markExited(record.id, null);
993
- return fail(`spawn failed: ${e.message}`);
994
- }
995
- }
996
-
997
- emit({ type: 'done', success: true, workspace, created, cloneResults, launched });
998
- res.end();
999
- } catch (e) {
1000
- console.error('[/api/sessions/new]', e);
1001
- fail(String(e && e.message || e));
1002
- }
1003
- });
1004
-
1005
- // ---- list local CLI sessions discovered on disk (for "adopt") ----
1006
- // Returns sessions found in ~/.claude / ~/.codex / ~/.copilot that
1007
- // aren't yet adopted by ccsm. Frontend uses this in the Import modal.
1008
- app.get('/api/cli-sessions/:cliType', asyncH(async (req, res) => {
1009
- const type = String(req.params.cliType || '').toLowerCase();
1010
- if (!['claude', 'codex', 'copilot'].includes(type)) {
1011
- return res.status(400).json({ error: `unsupported cli type: ${type}` });
1012
- }
1013
- const offset = Math.max(0, Number(req.query.offset) || 0);
1014
- const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 30));
1015
-
1016
- const [page, adopted] = await Promise.all([
1017
- localCliSessions.listPaginated(type, { offset, limit }),
1018
- persistedSessions.loadAll(),
1019
- ]);
1020
-
1021
- const adoptedIds = new Set(adopted.map((s) => s.cliSessionId).filter(Boolean));
1022
- const sessions = page.sessions.map((s) => ({
1023
- ...s,
1024
- adopted: adoptedIds.has(s.cliSessionId),
1025
- }));
1026
- res.json({
1027
- sessions,
1028
- totalActive: page.totalActive,
1029
- totalNonActive: page.totalNonActive,
1030
- total: page.totalActive + page.totalNonActive,
1031
- offset: page.offset,
1032
- limit: page.limit,
1033
- hasMore: page.hasMore,
1034
- });
1035
- }));
1036
-
1037
- // ---- adopt: create a ccsm record pointing at an existing CLI session ----
1038
- // Body: { cliId, cliSessionId, cwd, title?, folderId? }
1039
- // Doesn't spawn — the new entry shows up as "exited" in the sidebar;
1040
- // clicking it kicks off the regular resume flow which uses
1041
- // `cli.resumeIdArgs` ('--resume <id>') so the upstream session reattaches.
1042
- app.post('/api/sessions/adopt', asyncH(async (req, res) => {
1043
- const { cliId, cliSessionId, cwd, title, folderId } = req.body || {};
1044
- if (!cliId || !cliSessionId || !cwd) {
1045
- return res.status(400).json({ error: 'cliId, cliSessionId and cwd required' });
1046
- }
1047
- const cfg = await loadConfig();
1048
- const cli = pickCli(cfg, cliId);
1049
- if (!cli) return res.status(400).json({ error: `CLI ${cliId} not configured` });
1050
-
1051
- // Normalize the cwd up front. /api/sessions/new also resolves cwd, and
1052
- // the workspaces "in use" check (GET /api/workspaces) does
1053
- // path.resolve(s.cwd).toLowerCase() adopted records must match the
1054
- // same shape, otherwise an adopted+running session leaves its
1055
- // workspace falsely marked as free and a fresh launch could collide.
1056
- const resolvedCwd = path.resolve(cwd);
1057
- try {
1058
- const fsmod = require('node:fs/promises');
1059
- const st = await fsmod.stat(resolvedCwd);
1060
- if (!st.isDirectory()) {
1061
- return res.status(400).json({ error: `cwd is not a directory: ${resolvedCwd}` });
1062
- }
1063
- } catch (e) {
1064
- return res.status(400).json({ error: `cwd not found: ${resolvedCwd}` });
1065
- }
1066
-
1067
- // Refuse duplicates: if any ccsm record already owns this upstream
1068
- // session id, return it so the caller can jump to it.
1069
- const all = await persistedSessions.loadAll();
1070
- const dup = all.find((s) => s.cliSessionId === cliSessionId);
1071
- if (dup) return res.json({ session: dup, alreadyAdopted: true });
1072
-
1073
- const workspace = path.basename(resolvedCwd) || resolvedCwd;
1074
- // Create directly with status='exited' + cliSessionId set, so a
1075
- // concurrent GET /api/sessions can never observe a "running but no
1076
- // PTY" intermediate state.
1077
- const record = await persistedSessions.create({
1078
- cliId,
1079
- cwd: resolvedCwd,
1080
- workspace,
1081
- folderId: folderId || null,
1082
- title: title || '',
1083
- repos: [],
1084
- status: 'exited',
1085
- cliSessionId,
1086
- });
1087
- res.json({ session: record, alreadyAdopted: false });
1088
- }));
1089
-
1090
- // ---- resume a previous session in the same cwd / cli ----
1091
- app.post('/api/sessions/:id/resume', asyncH(async (req, res) => {
1092
- const record = await persistedSessions.get(req.params.id);
1093
- if (!record) return res.status(404).json({ error: 'session not found' });
1094
- // Already running and attached → no-op, just return its id.
1095
- const live = webTerminal.get(record.id);
1096
- if (live && !live.exitedAt) {
1097
- // Pool says we're alive but the record may be stale (e.g. a prior
1098
- // markRunning got clobbered by an OLD entry's onExit before the
1099
- // respawn-guard landed, or boot mark-exited ran after a pool entry
1100
- // was already wired). Reconcile the file to match the pool so the
1101
- // frontend doesn't get stuck on "Resuming session…" forever.
1102
- if (record.status !== 'running' || record.pid !== live.meta.pid) {
1103
- try { await persistedSessions.markRunning(record.id, live.meta.pid); } catch {}
1104
- }
1105
- return res.json({ launched: { id: record.id, pid: live.meta.pid, cliId: record.cliId } });
1106
- }
1107
- const cfg = await loadConfig();
1108
- const cli = pickCli(cfg, record.cliId);
1109
- if (!cli) return res.status(400).json({ error: `CLI ${record.cliId} no longer configured` });
1110
- try {
1111
- // Resume always uses the captured upstream session UUID. With the
1112
- // pre-assignment refactor every ccsm-launched session has one (via
1113
- // newSessionIdArgs flag or the codex seed trick), and adopted
1114
- // sessions inherit theirs from the disk scan.
1115
- const themeArgs = await codexThemeArgs(cli, req.body && req.body.theme);
1116
- const extraArgs = buildResumeArgs(cli, record);
1117
- const entry = spawnCliSession({
1118
- cli,
1119
- cwd: record.cwd,
1120
- sessionId: record.id,
1121
- meta: { title: record.title || record.workspace, workspace: record.workspace, cwd: record.cwd },
1122
- extraArgs: [...themeArgs, ...extraArgs],
1123
- theme: req.body && req.body.theme,
1124
- cols: req.body && req.body.cols,
1125
- rows: req.body && req.body.rows,
1126
- });
1127
- await persistedSessions.markRunning(record.id, entry.meta.pid);
1128
- res.json({ launched: { id: record.id, pid: entry.meta.pid, cliId: cli.id } });
1129
- } catch (e) {
1130
- res.status(500).json({ error: e.message });
1131
- }
1132
- }));
1133
-
1134
- // codex-only: when the ccsm terminal is in LIGHT mode, inject a session-scoped
1135
- // `-c tui.theme=ccsm-light`. codex's diff theme detection (default_bg()) is
1136
- // compiled out on Windows and always falls back to a DARK diff palette, which
1137
- // reads poorly on a white terminal — and it ignores COLORFGBG/OSC. The only
1138
- // lever is a syntax theme whose markup.inserted/deleted scopes carry light
1139
- // backgrounds (they override the diff palette at true-color level). We ship
1140
- // that theme (ccsm-light.tmTheme), copy it into the codex home, and point
1141
- // tui.theme at it. Returns the args to prepend (before `resume <id>` so the
1142
- // global -c lands before the subcommand), or [] when not applicable. Skipped
1143
- // in dark mode (codex's dark default is already correct on a dark terminal)
1144
- // and when the user configured their own tui.theme in cli.args.
1145
- async function codexThemeArgs(cli, theme) {
1146
- if (!cli || cli.type !== 'codex' || theme !== 'light') return [];
1147
- const args = cli.args || [];
1148
- const userSet = args.some((a, i) =>
1149
- String(a).includes('tui.theme') || (a === '-c' && String(args[i + 1] || '').includes('tui.theme')));
1150
- if (userSet) return [];
1151
- try {
1152
- const { probeCodexHome, ensureCodexLightTheme } = require('./lib/codexSeed');
1153
- const home = await probeCodexHome({ command: cli.command, shell: cli.shell });
1154
- if (!(await ensureCodexLightTheme(home))) return [];
1155
- return ['-c', 'tui.theme="ccsm-light"'];
1156
- } catch { return []; }
1157
- }
1158
-
1159
- // Build the args appended on resume: substitute the captured upstream
1160
- // session UUID into cli.resumeIdArgs (e.g. ['--resume', '<id>']
1161
- // ['--resume', '7c28...']). Throws if either piece is missing — by
1162
- // design every ccsm session has a pre-assigned id, so missing one means
1163
- // something upstream is misconfigured (adopt without id, user-added CLI
1164
- // without resumeIdArgs, etc.) and we surface that instead of silently
1165
- // re-launching without the id.
1166
- function buildResumeArgs(cli, record) {
1167
- const id = record.cliSessionId;
1168
- const tpl = Array.isArray(cli.resumeIdArgs) ? cli.resumeIdArgs : [];
1169
- if (!id) throw new Error(`session ${record.id} has no cliSessionId — cannot resume`);
1170
- if (tpl.length === 0) throw new Error(`CLI ${cli.id} has no resumeIdArgs configured`);
1171
- return tpl.map((a) => (typeof a === 'string' ? a.replace(/<id>/g, id) : a));
1172
- }
1173
-
1174
- // ---- capabilities probe ----
1175
- app.get('/api/capabilities', (_req, res) => res.json({
1176
- webTerminal: webTerminal.available,
1177
- webTerminalError: webTerminal.available ? null : String(webTerminal.loadError?.message || 'unavailable'),
1178
- }));
1179
-
1180
- // ---- health ----
1181
- const pkg = require('./package.json');
1182
- app.get('/api/health', (_req, res) => res.json({ ok: true, pid: process.pid, version: pkg.version, name: pkg.name }));
1183
-
1184
- // ---- lifecycle ----
1185
- let currentPort = 0;
1186
- let frontendUrl = '';
1187
- let lastHeartbeat = Date.now();
1188
- let heartbeatSeen = false;
1189
- const HEARTBEAT_TIMEOUT_MS = 90_000;
1190
-
1191
- app.post('/api/heartbeat', (_req, res) => {
1192
- lastHeartbeat = Date.now();
1193
- heartbeatSeen = true;
1194
- res.json({ ok: true });
1195
- });
1196
-
1197
- app.post('/api/spawn-browser', asyncH(async (_req, res) => {
1198
- const opened = openInBrowser(frontendUrl || `http://localhost:${currentPort}`);
1199
- res.json({ ok: true, mode: opened.kind, url: frontendUrl });
1200
- }));
1201
-
1202
- app.post('/api/shutdown', (_req, res) => {
1203
- res.json({ ok: true, bye: 'shutting down' });
1204
- setImmediate(() => gracefulShutdown('/api/shutdown'));
1205
- });
1206
-
1207
- // ---- remote / tunnel ----
1208
- //
1209
- // Lifecycle: the Remote page POSTs /start with { provider, token }
1210
- // we save the token (used by the middleware above for auth) and spawn
1211
- // the chosen tunnel CLI. URL appears asynchronously in the CLI's
1212
- // stdout; lib/tunnel parses it. /status returns the latest snapshot
1213
- // for the page to poll.
1214
- app.get('/api/tunnel/status', asyncH(async (_req, res) => {
1215
- res.json(await tunnel.status());
1216
- }));
1217
- app.post('/api/tunnel/start', asyncH(async (req, res) => {
1218
- const { provider, token } = req.body || {};
1219
- if (!token || String(token).length < 8) {
1220
- return res.status(400).json({ error: 'token required (≥ 8 chars)' });
1221
- }
1222
- tunnel.setToken(token);
1223
- try {
1224
- const result = await tunnel.start({ provider, port: currentPort });
1225
- res.json(result);
1226
- } catch (e) {
1227
- res.status(400).json({ error: e.message, providers: await tunnel.probe().catch(() => ({})) });
1228
- }
1229
- }));
1230
- app.post('/api/tunnel/stop', asyncH(async (_req, res) => {
1231
- const stopped = tunnel.stop();
1232
- res.json({ stopped, ...(await tunnel.status()) });
1233
- }));
1234
- app.post('/api/tunnel/token', asyncH(async (req, res) => {
1235
- // Bare token update without touching the running tunnel.
1236
- // POST { token: '' } to clear and disable remote auth.
1237
- const t = (req.body && req.body.token) || '';
1238
- tunnel.setToken(t);
1239
- res.json(await tunnel.status());
1240
- }));
1241
- // Persist auto-start prefs. When ON, the backend brings this tunnel up
1242
- // during its own startup (the boot hook in the listen IIFE below) using
1243
- // the persisted token, so share URLs survive a backend restart. The
1244
- // token is written to config ONLY while auto-start is on; turning it off
1245
- // wipes the persisted token from disk. setToken keeps the in-memory copy
1246
- // in lockstep so the share URL the page renders stays valid immediately.
1247
- app.post('/api/tunnel/autostart', asyncH(async (req, res) => {
1248
- const { autoStart, provider, token } = req.body || {};
1249
- if (autoStart) {
1250
- if (!token || String(token).length < 8) {
1251
- return res.status(400).json({ error: 'token required (≥ 8 chars)' });
1252
- }
1253
- if (!['devtunnel', 'cloudflared'].includes(provider)) {
1254
- return res.status(400).json({ error: 'valid provider required' });
1255
- }
1256
- tunnel.setToken(token);
1257
- await saveConfig({ tunnel: { autoStart: true, provider, token } });
1258
- } else {
1259
- await saveConfig({ tunnel: { autoStart: false, provider: null, token: null } });
1260
- }
1261
- res.json(await tunnel.status());
1262
- }));
1263
- app.post('/api/tunnel/install', asyncH(async (req, res) => {
1264
- const { provider } = req.body || {};
1265
- try {
1266
- const r = tunnel.installViaWinget(provider);
1267
- res.json({ ok: true, ...r });
1268
- } catch (e) {
1269
- res.status(400).json({ error: e.message });
1270
- }
1271
- }));
1272
- // Interactive `devtunnel user login -d` driver. The Remote page POSTs
1273
- // here to start a device-code flow, then polls /api/tunnel/status to
1274
- // learn the URL+code it should display and the eventual outcome —
1275
- // avoids the older "copy this command into a shell" UX.
1276
- app.post('/api/tunnel/devtunnel/login', asyncH(async (req, res) => {
1277
- const { mode } = req.body || {};
1278
- try {
1279
- const snap = await tunnel.startDevtunnelLogin({ mode });
1280
- res.json({ ok: true, login: snap });
1281
- } catch (e) {
1282
- res.status(400).json({ error: e.message });
1283
- }
1284
- }));
1285
- app.post('/api/tunnel/devtunnel/login/cancel', asyncH(async (_req, res) => {
1286
- res.json({ ok: true, login: tunnel.cancelDevtunnelLogin() });
1287
- }));
1288
- app.post('/api/tunnel/devtunnel/login/dismiss', asyncH(async (_req, res) => {
1289
- tunnel.clearDevtunnelLogin();
1290
- res.json({ ok: true });
1291
- }));
1292
- // Wipe the persisted devtunnel tunnel id (and the remote tunnel
1293
- // resource itself, best-effort) so the next /api/tunnel/start mints
1294
- // a fresh one. Used by the Reset button in the Remote page when the
1295
- // user wants to rotate the public URL. Tunnel must be stopped first
1296
- // refuse otherwise so we don't yank state out from under a live
1297
- // `devtunnel host` child.
1298
- app.post('/api/tunnel/devtunnel/reset', asyncH(async (_req, res) => {
1299
- const s = await tunnel.status();
1300
- if (s.running && s.provider === 'devtunnel') {
1301
- return res.status(409).json({ error: 'stop the tunnel before resetting its id' });
1302
- }
1303
- const r = await tunnel.resetDevtunnelTunnelId();
1304
- res.json({ ok: true, ...r, ...(await tunnel.status()) });
1305
- }));
1306
-
1307
- // ---- devices ----
1308
- //
1309
- // /api/devices/me is callable from the remote browser BEFORE approval —
1310
- // it's how the PendingApprovalOverlay polls for the host's decision.
1311
- // Everything else is locked to loopback by the gate above.
1312
- app.get('/api/devices/me', asyncH(async (req, res) => {
1313
- const id = String(req.headers['x-device-id'] || (req.query && req.query.device) || '');
1314
- if (!id) return res.status(400).json({ error: 'device id required' });
1315
- // Token check applies HERE — this is the only endpoint where new
1316
- // device records are created (record() inserts pending on first
1317
- // sight). Demanding the token at registration time stops random
1318
- // tunnel-URL scanners from filling the host's pending queue with
1319
- // garbage entries. Already-known devices can re-poll without the
1320
- // token (the existing record is returned as-is).
1321
- const existing = await devices.get(id);
1322
- if (!existing) {
1323
- const tok = tunnel.getToken();
1324
- if (tok && !isDirectLoopback(req)) {
1325
- const auth = req.headers.authorization || '';
1326
- const qTok = req.query && req.query.token;
1327
- if (auth !== `Bearer ${tok}` && qTok !== tok) {
1328
- return res.status(401).json({ error: 'token required to register a new device' });
1329
- }
1330
- }
1331
- }
1332
- const ua = req.headers['user-agent'] || '';
1333
- const ip = String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
1334
- const code = String(req.headers['x-device-code'] || (req.query && req.query.code) || '').slice(0, 8);
1335
- const d = await devices.record(id, { userAgent: ua, ip, code });
1336
- res.json(d);
1337
- }));
1338
- app.get('/api/devices', asyncH(async (_req, res) => {
1339
- res.json({ devices: await devices.list() });
1340
- }));
1341
- app.post('/api/devices/:id/approve', asyncH(async (req, res) => {
1342
- const d = await devices.approve(req.params.id, req.body && req.body.label);
1343
- if (!d) return res.status(404).json({ error: 'device not found' });
1344
- res.json(d);
1345
- }));
1346
- app.post('/api/devices/:id/reject', asyncH(async (req, res) => {
1347
- const d = await devices.reject(req.params.id);
1348
- if (!d) return res.status(404).json({ error: 'device not found' });
1349
- res.json(d);
1350
- }));
1351
- app.post('/api/devices/:id/revoke', asyncH(async (req, res) => {
1352
- const d = await devices.revoke(req.params.id);
1353
- if (!d) return res.status(404).json({ error: 'device not found' });
1354
- res.json(d);
1355
- }));
1356
- app.put('/api/devices/:id', asyncH(async (req, res) => {
1357
- const d = await devices.rename(req.params.id, (req.body && req.body.label) || '');
1358
- if (!d) return res.status(404).json({ error: 'device not found' });
1359
- res.json(d);
1360
- }));
1361
- app.delete('/api/devices/:id', asyncH(async (req, res) => {
1362
- const removed = await devices.remove(req.params.id);
1363
- res.json({ removed });
1364
- }));
1365
-
1366
- // Restart: in production, spawn the restart-helper detached then
1367
- // gracefulShutdown the helper waits for the port to free and respawns
1368
- // `ccsm.cmd` (with CCSM_NO_BROWSER so we don't pop a new window — the
1369
- // frontend bounces through OfflineBanner / version router back into the
1370
- // new backend). In dev (CCSM_DEV=1, set by scripts/dev.js), we skip the
1371
- // helper entirely: just gracefulShutdown. scripts/dev.js sees its child
1372
- // exit and respawns `node --watch server.js` from the checkout, picking
1373
- // up any code changes.
1374
- let restartInFlight = false;
1375
- app.post('/api/restart', asyncH(async (_req, res) => {
1376
- if (restartInFlight) {
1377
- return res.status(409).json({ error: 'restart already in progress' });
1378
- }
1379
- restartInFlight = true;
1380
-
1381
- if (process.env.CCSM_DEV === '1') {
1382
- res.json({ ok: true, started: true, mode: 'dev', closeFrontend: false });
1383
- setImmediate(() => gracefulShutdown('restart (dev)'));
1384
- return;
1385
- }
1386
-
1387
- const fsp = require('node:fs/promises');
1388
- const helperSrc = path.join(__dirname, 'scripts', 'restart-helper.js');
1389
- const helperTmp = path.join(os.tmpdir(), `ccsm-restart-${process.pid}-${Date.now()}.js`);
1390
- try {
1391
- await fsp.copyFile(helperSrc, helperTmp);
1392
- } catch (e) {
1393
- restartInFlight = false;
1394
- return res.status(500).json({ error: `helper copy failed: ${e.message}` });
1395
- }
1396
- const args = [helperTmp, String(currentPort), String(process.pid)];
1397
- // closeFrontend asks the calling tab to window.close() itself — the
1398
- // helper will respawn ccsm WITHOUT CCSM_NO_BROWSER, so a fresh window
1399
- // pops up once the new backend is listening. Net effect: the user
1400
- // never sees the OfflineBanner during a restart.
1401
- res.json({ ok: true, started: true, helper: helperTmp, closeFrontend: true });
1402
-
1403
- setImmediate(() => {
1404
- const { spawn } = require('node:child_process');
1405
- try {
1406
- const child = spawn(process.execPath, args, {
1407
- detached: true,
1408
- stdio: 'ignore',
1409
- windowsHide: true,
1410
- shell: false,
1411
- });
1412
- child.unref();
1413
- console.log(`[restart] helper pid=${child.pid}, shutting down`);
1414
- } catch (e) {
1415
- console.error('[restart] helper spawn failed:', e.message);
1416
- restartInFlight = false;
1417
- return;
1418
- }
1419
- setTimeout(() => gracefulShutdown('restart'), 500);
1420
- });
1421
- }));
1422
-
1423
- // ---- version / upgrade ----
1424
- // `/api/version` reports the installed version (= pkg.version) and, if
1425
- // reachable, the latest published on the npm registry. The result is
1426
- // cached for 30 minutes in memory so the AboutPage poll doesn't hit the
1427
- // registry on every render.
1428
- //
1429
- // `/api/upgrade` kicks off `npm i -g @bakapiano/ccsm@latest` as a
1430
- // detached child. When the install completes, the child re-spawns `ccsm`
1431
- // (also detached) so the launcher comes back up on the new version, and
1432
- // the current server gracefulShutdowns. The frontend's OfflineBanner
1433
- // covers the gap; the version router picks up the new version on the
1434
- // next probe.
1435
- const VERSION_CACHE_MS = 30 * 60_000;
1436
- let versionCache = null; // { latest, fetchedAt }
1437
- let upgradeInFlight = false;
1438
-
1439
- async function fetchLatestFromNpm() {
1440
- // Node 18+ has a global fetch. Time out the registry call to avoid
1441
- // hanging the response when the user is offline / behind a captive
1442
- // portal.
1443
- const ctrl = new AbortController();
1444
- const t = setTimeout(() => ctrl.abort(), 4000);
1445
- try {
1446
- const r = await fetch('https://registry.npmjs.org/@bakapiano%2Fccsm/latest', {
1447
- headers: { 'Accept': 'application/json' },
1448
- signal: ctrl.signal,
1449
- });
1450
- if (!r.ok) throw new Error(`registry HTTP ${r.status}`);
1451
- const j = await r.json();
1452
- return String(j.version || '');
1453
- } finally {
1454
- clearTimeout(t);
1455
- }
1456
- }
1457
-
1458
- function cmpSemver(a, b) {
1459
- const pa = String(a || '').split('.').map(Number);
1460
- const pb = String(b || '').split('.').map(Number);
1461
- for (let i = 0; i < 3; i++) {
1462
- const x = pa[i] || 0, y = pb[i] || 0;
1463
- if (x > y) return 1;
1464
- if (x < y) return -1;
1465
- }
1466
- return 0;
1467
- }
1468
-
1469
- app.get('/api/version', asyncH(async (req, res) => {
1470
- const force = String(req.query.refresh || '') === '1';
1471
- const now = Date.now();
1472
- // devMode: set when the server was launched from scripts/dev.js
1473
- // (CCSM_DEV=1). Lets the About page render a "test upgrade flow"
1474
- // button that re-installs to a sandbox prefix without affecting the
1475
- // user's global ccsm install.
1476
- const devMode = process.env.CCSM_DEV === '1';
1477
- if (!force && versionCache && (now - versionCache.fetchedAt) < VERSION_CACHE_MS) {
1478
- return res.json({
1479
- current: pkg.version,
1480
- latest: versionCache.latest,
1481
- updateAvailable: cmpSemver(versionCache.latest, pkg.version) > 0,
1482
- fetchedAt: versionCache.fetchedAt,
1483
- cached: true,
1484
- devMode,
1485
- });
1486
- }
1487
- try {
1488
- const latest = await fetchLatestFromNpm();
1489
- versionCache = { latest, fetchedAt: now };
1490
- res.json({
1491
- current: pkg.version,
1492
- latest,
1493
- updateAvailable: cmpSemver(latest, pkg.version) > 0,
1494
- fetchedAt: now,
1495
- cached: false,
1496
- devMode,
1497
- });
1498
- } catch (e) {
1499
- res.json({
1500
- current: pkg.version,
1501
- latest: null,
1502
- updateAvailable: false,
1503
- fetchedAt: now,
1504
- error: String(e.message || e),
1505
- devMode,
1506
- });
1507
- }
1508
- }));
1509
-
1510
- app.post('/api/upgrade', asyncH(async (req, res) => {
1511
- if (upgradeInFlight) {
1512
- return res.status(409).json({ error: 'upgrade already in progress' });
1513
- }
1514
- const body = req.body || {};
1515
- const target = String(body.target || 'latest');
1516
- // Refuse anything that doesn't look like a semver dist-tag or version
1517
- // defends against `;` etc. winding up in the spawn argv even though
1518
- // we don't shell out.
1519
- if (!/^[a-z0-9.+\-^~]+$/i.test(target)) {
1520
- return res.status(400).json({ error: `invalid target: ${target}` });
1521
- }
1522
- // Optional sandbox install prefix (for testing without disturbing the
1523
- // user's real global ccsm). Validated as a plain absolute path so it
1524
- // can't be a flag injection.
1525
- const installPrefix = body.installPrefix ? String(body.installPrefix) : '';
1526
- if (installPrefix && (installPrefix.startsWith('-') || !path.isAbsolute(installPrefix))) {
1527
- return res.status(400).json({ error: 'installPrefix must be an absolute path' });
1528
- }
1529
- const respawn = body.respawn === false ? '0' : '1';
1530
- upgradeInFlight = true;
1531
- console.log(`[upgrade] target=${target}${installPrefix ? ` prefix=${installPrefix}` : ''}${respawn === '0' ? ' (no respawn)' : ''}`);
1532
-
1533
- // The helper runs OUTSIDE the package dir so npm can rename it
1534
- // without fighting open file handles. Copy the script to os.tmpdir()
1535
- // and spawn from there.
1536
- const fsp = require('node:fs/promises');
1537
- const helperSrc = path.join(__dirname, 'scripts', 'upgrade-helper.js');
1538
- const helperTmp = path.join(os.tmpdir(), `ccsm-upgrade-${process.pid}-${Date.now()}.js`);
1539
- try {
1540
- await fsp.copyFile(helperSrc, helperTmp);
1541
- } catch (e) {
1542
- upgradeInFlight = false;
1543
- return res.status(500).json({ error: `helper copy failed: ${e.message}` });
1544
- }
1545
- // Where to send the user back when the upgrade succeeds. In prod
1546
- // that's the GH Pages router (it'll re-probe localhost:7777 and
1547
- // redirect to the matching per-version frontend); in dev (CCSM_DEV=1)
1548
- // that's our local server on whatever port we're listening on, so
1549
- // the test sandbox flow returns to the dev instance instead of
1550
- // hitting GH Pages (which doesn't know about port 7788).
1551
- const redirectTo = frontendUrl || `http://localhost:${currentPort}/`;
1552
-
1553
- const args = [helperTmp, target, String(currentPort), String(process.pid), installPrefix, respawn, redirectTo];
1554
-
1555
- res.json({
1556
- ok: true,
1557
- started: true,
1558
- target,
1559
- helper: helperTmp,
1560
- helperUrl: 'http://localhost:7779/',
1561
- closeFrontend: false,
1562
- });
1563
-
1564
- // Flush response, then spawn helper detached and gracefulShutdown so
1565
- // the helper's npm install isn't fighting our open file handles.
1566
- setImmediate(() => {
1567
- const { spawn } = require('node:child_process');
1568
- try {
1569
- const child = spawn(process.execPath, args, {
1570
- detached: true,
1571
- stdio: 'ignore',
1572
- windowsHide: true,
1573
- shell: false,
1574
- });
1575
- child.unref();
1576
- console.log(`[upgrade] helper pid=${child.pid}, shutting down`);
1577
- } catch (e) {
1578
- console.error('[upgrade] helper spawn failed:', e.message);
1579
- upgradeInFlight = false;
1580
- return;
1581
- }
1582
- setTimeout(() => gracefulShutdown('upgrade'), 500);
1583
- });
1584
- }));
1585
-
1586
-
1587
- function listenWithFallback(preferred) {
1588
- return new Promise((resolve, reject) => {
1589
- const attempt = (port, tries) => {
1590
- const server = app.listen(port);
1591
- server.once('listening', () => resolve({ server, port: server.address().port }));
1592
- server.once('error', (err) => {
1593
- if (err.code !== 'EADDRINUSE') return reject(err);
1594
- if (tries < 9) attempt(port + 1, tries + 1);
1595
- else if (tries === 9) attempt(0, tries + 1);
1596
- else reject(err);
1597
- });
1598
- };
1599
- attempt(preferred, 0);
1600
- });
1601
- }
1602
-
1603
- function findAppModeBrowser() {
1604
- const candidates = [
1605
- 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
1606
- 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
1607
- 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
1608
- process.env.LOCALAPPDATA &&
1609
- path.join(process.env.LOCALAPPDATA, 'Google\\Chrome\\Application\\chrome.exe'),
1610
- 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
1611
- ].filter(Boolean);
1612
- const fs = require('node:fs');
1613
- for (const p of candidates) {
1614
- if (fs.existsSync(p)) return p;
1615
- }
1616
- return null;
1617
- }
1618
-
1619
- // Look for a Chrome/Edge PWA that the user already installed locally
1620
- // pointing at the ccsm frontend. When found, we launch it via
1621
- // `chrome.exe --profile-directory=... --app-id=<id>` — same as the
1622
- // shortcut Start Menu creates at install time. That path opens the
1623
- // PWA fully chromeless (respects manifest display:standalone + WCO).
1624
- // Without this we'd fall back to `--app=<URL> --user-data-dir=<ours>`
1625
- // which uses an isolated profile that doesn't see the install, so
1626
- // Chrome shows a minimal-ui address bar.
1627
- function findInstalledCcsmPwa() {
1628
- if (process.platform !== 'win32') return null;
1629
- const appData = process.env.APPDATA;
1630
- if (!appData) return null;
1631
- const fs = require('node:fs');
1632
- const startMenu = path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs');
1633
- const dirs = [
1634
- path.join(startMenu, 'Chrome Apps'),
1635
- path.join(startMenu, 'Edge Apps'),
1636
- ];
1637
- const candidates = [];
1638
- for (const dir of dirs) {
1639
- let names;
1640
- try { names = fs.readdirSync(dir); } catch { continue; }
1641
- for (const name of names) {
1642
- if (!name.toLowerCase().endsWith('.lnk')) continue;
1643
- // Filter by filename — Chrome names PWA shortcuts after the
1644
- // manifest's short_name/name. CCSM matches our manifest.
1645
- if (!/ccsm/i.test(name)) continue;
1646
- const full = path.join(dir, name);
1647
- try {
1648
- candidates.push({ name, path: full, mtime: fs.statSync(full).mtimeMs });
1649
- } catch {}
1650
- }
1651
- }
1652
- if (candidates.length === 0) return null;
1653
- // Newest install wins (covers the case where the user re-installed
1654
- // and accumulated CCSM, CCSM (1), etc.).
1655
- candidates.sort((a, b) => b.mtime - a.mtime);
1656
- // Resolve via WScript.Shell COM. Single PowerShell call enumerates
1657
- // every candidate; we stop at the first one whose target looks like
1658
- // a Chrome/Edge binary and whose args carry an --app-id.
1659
- const { spawnSync } = require('node:child_process');
1660
- const psPaths = candidates
1661
- .map((c) => `'${c.path.replace(/'/g, "''")}'`).join(',');
1662
- const script = `
1663
- $ErrorActionPreference = 'SilentlyContinue'
1664
- $wsh = New-Object -ComObject WScript.Shell
1665
- foreach ($p in @(${psPaths})) {
1666
- $sc = $wsh.CreateShortcut($p)
1667
- Write-Output ($sc.TargetPath + '|' + $sc.Arguments)
1668
- }`;
1669
- const r = spawnSync('powershell.exe',
1670
- ['-NoProfile', '-NonInteractive', '-Command', script],
1671
- { encoding: 'utf8', windowsHide: true });
1672
- if (r.status !== 0 || !r.stdout) return null;
1673
- for (const line of r.stdout.split(/\r?\n/)) {
1674
- if (!line.trim()) continue;
1675
- const sep = line.indexOf('|');
1676
- if (sep < 0) continue;
1677
- const target = line.slice(0, sep).trim();
1678
- const args = line.slice(sep + 1).trim();
1679
- if (!/chrome(_proxy)?\.exe$|msedge(_proxy)?\.exe$/i.test(target)) continue;
1680
- const appId = (args.match(/--app-id=(\S+)/) || [])[1];
1681
- if (!appId) continue;
1682
- const profile = (args.match(/--profile-directory=(\S+)/) || [])[1] || 'Default';
1683
- return { browserPath: target, appId, profile };
1684
- }
1685
- return null;
1686
- }
1687
-
1688
- // Auto-open the frontend in a browser when ccsm boots. Strategy:
1689
- // 1. If the user already installed the CCSM PWA, launch THAT (fully
1690
- // chromeless via --app-id, uses user's main browser profile).
1691
- // 2. Otherwise try a generic --app= window in an isolated profile —
1692
- // this shows a thin minimal-ui address bar but at least it's
1693
- // a dedicated window.
1694
- // 3. Fall back to the OS default browser as a regular tab.
1695
- // On non-Windows we skip — the bundled launcher isn't ported yet.
1696
- function openInBrowser(url) {
1697
- if (process.platform !== 'win32') return { kind: 'none', child: null };
1698
- const { spawn } = require('node:child_process');
1699
- const fs = require('node:fs');
1700
-
1701
- const installed = findInstalledCcsmPwa();
1702
- if (installed) {
1703
- console.log(`[ccsm] launching installed PWA · app-id=${installed.appId} profile=${installed.profile}`);
1704
- const child = spawn(
1705
- installed.browserPath,
1706
- [
1707
- `--profile-directory=${installed.profile}`,
1708
- `--app-id=${installed.appId}`,
1709
- ],
1710
- { detached: true, stdio: 'ignore' }
1711
- );
1712
- child.unref();
1713
- return { kind: 'pwa', child };
1714
- }
1715
-
1716
- const exe = findAppModeBrowser();
1717
- if (exe) {
1718
- const profileDir = path.join(DATA_DIR, 'browser-profile');
1719
- fs.mkdirSync(profileDir, { recursive: true });
1720
- console.log(`[ccsm] no installed PWA found · falling back to --app= window`);
1721
- const child = spawn(
1722
- exe,
1723
- [
1724
- `--app=${url}`,
1725
- `--user-data-dir=${profileDir}`,
1726
- '--window-size=1500,1100',
1727
- '--no-first-run',
1728
- '--no-default-browser-check',
1729
- ],
1730
- { detached: true, stdio: 'ignore' }
1731
- );
1732
- child.unref();
1733
- return { kind: 'app', child };
1734
- }
1735
- console.log('[ccsm] no Edge/Chrome found, opening default browser');
1736
- const child = spawn('cmd.exe', ['/c', 'start', '', url], {
1737
- detached: true,
1738
- stdio: 'ignore',
1739
- windowsHide: true,
1740
- });
1741
- child.unref();
1742
- return { kind: 'tab', child: null };
1743
- }
1744
-
1745
- (async () => {
1746
- const cfg = await loadConfig();
1747
- const preferredPort = process.env.CCSM_PORT ? Number(process.env.CCSM_PORT) : cfg.port;
1748
- const { server, port } = await listenWithFallback(preferredPort);
1749
- currentPort = port;
1750
-
1751
- // On boot, mark any persisted "running" sessions as exited — they
1752
- // belong to a previous server process whose PTYs are gone.
1753
- try {
1754
- const all = await persistedSessions.loadAll();
1755
- for (const s of all) {
1756
- if (s.status === 'running') {
1757
- await persistedSessions.markExited(s.id, null);
1758
- }
1759
- }
1760
- } catch (e) {
1761
- console.error('[ccsm] could not reconcile persisted sessions:', e.message);
1762
- }
1763
-
1764
- // Prewarm `tasklist` cache used by the import modal's "live" markers —
1765
- // it takes ~500ms on Windows and is the single biggest contributor to
1766
- // a slow Import dialog cold-open. Fire in the background; the lib also
1767
- // starts its own 15s refresh loop.
1768
- try { localCliSessions.prewarmLivePids(['claude.exe']); } catch {}
1769
- // Prewarm tunnel provider probe. First /api/tunnel/status round-trip
1770
- // shells out to where.exe / --version / devtunnel user show — ~700ms
1771
- // of synchronous work that the user otherwise waits on the moment
1772
- // they open the Remote tab. Fire in the background here so the cache
1773
- // is warm by the time anyone clicks.
1774
- try { tunnel.probe(true).catch(() => {}); } catch {}
1775
-
1776
- // Auto-start the tunnel if the user enabled it on the Remote page.
1777
- // This is the BACKEND PROCESS bringing its own tunnel up on startup —
1778
- // not an OS-level autostart (no registry / scheduled task). Reuses the
1779
- // persisted token so share URLs stay valid across restarts. Strictly
1780
- // fire-and-forget: a failure here (devtunnel not signed in, provider
1781
- // uninstalled, etc.) must never crash boot — it just logs and the user
1782
- // can start manually from the Remote page.
1783
- if (cfg.tunnel?.autoStart && cfg.tunnel?.token && cfg.tunnel?.provider) {
1784
- tunnel.setToken(cfg.tunnel.token);
1785
- tunnel.start({ provider: cfg.tunnel.provider, port: currentPort })
1786
- .then((s) => console.log(`[ccsm] tunnel auto-started · ${cfg.tunnel.provider} · ${s.url || 'URL pending'}`))
1787
- .catch((e) => console.warn(`[ccsm] tunnel auto-start failed · ${e.message}`));
1788
- }
1789
-
1790
- if (webTerminal.available) {
1791
- let WebSocketServer;
1792
- try { ({ WebSocketServer } = require('ws')); } catch {}
1793
- if (WebSocketServer) {
1794
- const wss = new WebSocketServer({ noServer: true });
1795
- server.on('upgrade', async (req, socket, head) => {
1796
- const direct = isDirectLoopback(req);
1797
- // Non-loopback WS: device id alone gates entry. The host
1798
- // explicitly Approved this device id earlier — that approval
1799
- // IS the credential. No token check here (matches the device
1800
- // gate above: token is only for /api/devices/me registration).
1801
- if (!direct) {
1802
- try {
1803
- const u = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
1804
- const devId = u.searchParams.get('device');
1805
- if (!devId) { socket.destroy(); return; }
1806
- const d = await devices.get(devId);
1807
- if (!d || d.status !== 'approved') { socket.destroy(); return; }
1808
- } catch { socket.destroy(); return; }
1809
- } else {
1810
- const origin = req.headers.origin;
1811
- if (origin && !ALLOWED_ORIGINS.has(origin) && !/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin)) {
1812
- socket.destroy();
1813
- return;
1814
- }
1815
- }
1816
- const m = req.url && req.url.match(/^\/ws\/terminal\/([^\/?#]+)/);
1817
- if (!m) { socket.destroy(); return; }
1818
- const id = decodeURIComponent(m[1]);
1819
- wss.handleUpgrade(req, socket, head, (ws) => webTerminal.attach(id, ws));
1820
- });
1821
- console.log('[ccsm] web terminal bridge active (WebSocket /ws/terminal/:id)');
1822
- }
1823
- }
1824
-
1825
- for (const sig of ['SIGINT', 'SIGTERM']) {
1826
- process.on(sig, () => gracefulShutdown(sig));
1827
- }
1828
- process.on('exit', () => { try { webTerminal.killAll(); } catch {} });
1829
-
1830
- const apiUrl = `http://localhost:${port}`;
1831
- const FRONTEND_URL = IS_DEV
1832
- ? apiUrl
1833
- : 'https://bakapiano.github.io/ccsm/';
1834
- frontendUrl = FRONTEND_URL;
1835
- console.log(`ccsm listening on ${apiUrl}${port !== preferredPort ? ` (requested ${preferredPort}, was taken)` : ''}`);
1836
- console.log(`frontend at ${FRONTEND_URL}`);
1837
- console.log(`data dir: ${DATA_DIR}`);
1838
- console.log(`work dir: ${cfg.workDir}`);
1839
- console.log(`clis: ${cfg.clis.map((c) => c.id).join(', ')} (default: ${cfg.defaultCliId})`);
1840
-
1841
- // CCSM_NO_BROWSER=1 (set by the ccsm:// protocol launcher) suppresses
1842
- // the auto-open entirely. CCSM_FROM_UPGRADE=1 (set by upgrade-helper
1843
- // when it respawns ccsm post-install) does the same: the user is
1844
- // already in the helper UI which redirects to this fresh backend, so
1845
- // a second app-mode window would just shadow the first. Otherwise try
1846
- // app-mode (chromeless Edge/Chrome window); if no such browser is
1847
- // installed, openInBrowser falls back to the OS default browser on
1848
- // its own.
1849
- const suppressBrowser = process.env.CCSM_NO_BROWSER === '1'
1850
- || process.env.CCSM_FROM_UPGRADE === '1';
1851
- const opened = suppressBrowser
1852
- ? { kind: 'none', child: null }
1853
- : openInBrowser(FRONTEND_URL);
1854
-
1855
- if (opened.kind === 'app' && opened.child && process.env.CCSM_KEEP_ALIVE !== '1') {
1856
- const launchedAt = Date.now();
1857
- opened.child.on('exit', () => {
1858
- const alive = Date.now() - launchedAt;
1859
- if (alive < 5000) {
1860
- console.log(`[ccsm] spawned browser child exited in ${alive}ms · handed off to an existing Edge instance, staying alive`);
1861
- return;
1862
- }
1863
- const closedAt = Date.now();
1864
- setTimeout(() => {
1865
- if (lastHeartbeat > closedAt + 100) {
1866
- console.log('[ccsm] browser closed but another client is heartbeating · staying alive');
1867
- return;
1868
- }
1869
- gracefulShutdown('browser window closed');
1870
- }, 12_000);
1871
- });
1872
- console.log('[ccsm] tied to browser window — close it to stop ccsm');
1873
- }
1874
-
1875
- if (process.env.CCSM_LAUNCHER === '1' && process.env.CCSM_KEEP_ALIVE !== '1') {
1876
- setInterval(() => {
1877
- if (!heartbeatSeen) return;
1878
- if (Date.now() - lastHeartbeat > HEARTBEAT_TIMEOUT_MS) {
1879
- gracefulShutdown(`no heartbeat for ${HEARTBEAT_TIMEOUT_MS / 1000}s`);
1880
- }
1881
- }, 30_000);
1882
- console.log('[ccsm] heartbeat watchdog active');
1883
- }
1884
- })().catch((err) => {
1885
- console.error('startup failed:', err);
1886
- process.exit(1);
1887
- });
744
+ const removed = await persistedSessions.remove(req.params.id);
745
+ try { require('./lib/cliActivity').releaseSession(req.params.id); } catch {}
746
+ res.json({ removed });
747
+ }));
748
+
749
+ // Open a session's working directory in the user's configured editor
750
+ // (config.editor, default `code` = VS Code, whose Source Control panel is
751
+ // also the review-changes view once the folder's open). Spawned detached
752
+ // so it outlives ccsm; shell:true so Windows resolves `code.cmd` via
753
+ // PATHEXT and a command like `code --reuse-window` parses, with the cwd
754
+ // quoted so paths with spaces survive the shell. spawnEnv() merges the
755
+ // user-scope PATH so `code`/`cursor` are found even when the inherited
756
+ // env lacks them.
757
+ app.post('/api/sessions/:id/open-editor', asyncH(async (req, res) => {
758
+ const record = await persistedSessions.get(req.params.id);
759
+ if (!record) return res.status(404).json({ error: 'session not found' });
760
+ const cfg = await loadConfig();
761
+ const editor = (cfg.editor || '').trim() || 'code';
762
+ const { spawn } = require('node:child_process');
763
+ try {
764
+ const child = spawn(editor, [`"${record.cwd}"`], {
765
+ detached: true, stdio: 'ignore', shell: true,
766
+ env: spawnEnv(), windowsHide: true,
767
+ });
768
+ // A bad editor command fails the shell async (after we've responded);
769
+ // log it so it's diagnosable, but the happy path needs no await.
770
+ child.on('error', (e) => console.warn(`[ccsm] open-editor "${editor}" failed:`, e.message));
771
+ child.unref();
772
+ res.json({ ok: true, editor, cwd: record.cwd });
773
+ } catch (e) {
774
+ res.status(500).json({ error: `failed to launch ${editor}: ${e.message}` });
775
+ }
776
+ }));
777
+
778
+ // Reorder sessions within a folder. Body: { folderId, ids } where ids
779
+ // is the new sequence of session ids in their final display order
780
+ // inside that folder. Each session gets `folderId` + `order: 0..N-1`
781
+ // assigned. Setting folderId here (rather than requiring a separate
782
+ // PUT) lets the drag-and-drop UI move a session across folders AND
783
+ // drop it at a specific position in one shot — without the call, the
784
+ // move would either land at the end of the destination folder (just
785
+ // PUT folderId) or leave it in place (just reorder).
786
+ app.post('/api/sessions/reorder', asyncH(async (req, res) => {
787
+ const ids = Array.isArray(req.body?.ids) ? req.body.ids : null;
788
+ if (!ids) return res.status(400).json({ error: 'ids array required' });
789
+ const folderId = req.body?.folderId ?? null;
790
+ for (let i = 0; i < ids.length; i++) {
791
+ try { await persistedSessions.update(ids[i], { folderId, order: i }); } catch {}
792
+ }
793
+ res.json({ ok: true, count: ids.length });
794
+ }));
795
+
796
+ // ---- workspaces ----
797
+
798
+ // ---- directory browser ----
799
+ // Lets the launch picker walk the filesystem so users can pick any
800
+ // existing directory as the session cwd. Returns the immediate child
801
+ // dirs of `path` (defaults to home), plus a few hardcoded "starts"
802
+ // (home, workDir, drive roots on Windows).
803
+ app.get('/api/browse', asyncH(async (req, res) => {
804
+ const fs = require('node:fs/promises');
805
+ const os = require('node:os');
806
+ const target = req.query.path ? path.resolve(String(req.query.path)) : os.homedir();
807
+ let entries = [];
808
+ let exists = true;
809
+ try {
810
+ const list = await fs.readdir(target, { withFileTypes: true });
811
+ entries = list
812
+ .filter((d) => d.isDirectory() && !d.name.startsWith('.'))
813
+ .map((d) => ({ name: d.name, path: path.join(target, d.name) }))
814
+ .sort((a, b) => a.name.localeCompare(b.name));
815
+ } catch (e) {
816
+ exists = false;
817
+ }
818
+ const parent = path.dirname(target);
819
+ const cfg = await loadConfig();
820
+ const starts = [
821
+ { label: 'Home', path: os.homedir() },
822
+ { label: 'Work dir', path: cfg.workDir },
823
+ ];
824
+ if (process.platform === 'win32') {
825
+ // Best-effort drive enumeration so users on D:\ etc can hop roots.
826
+ for (const letter of ['C', 'D', 'E', 'F', 'G', 'H']) {
827
+ const root = `${letter}:\\`;
828
+ try { await fs.access(root); starts.push({ label: `${letter}:\\`, path: root }); }
829
+ catch {}
830
+ }
831
+ }
832
+ res.json({
833
+ path: target,
834
+ parent: parent === target ? null : parent,
835
+ exists,
836
+ entries,
837
+ starts,
838
+ });
839
+ }));
840
+
841
+ app.get('/api/workspaces', asyncH(async (req, res) => {
842
+ const cfg = await loadConfig();
843
+ const allSess = await persistedSessions.loadAll();
844
+ const occupying = workspaceOccupancySessions(allSess, cfg);
845
+ const busyPaths = occupying.map((s) => s.cwd);
846
+ const workspaces = await listWorkspaces({
847
+ workDir: cfg.workDir,
848
+ repos: cfg.repos,
849
+ busyPaths,
850
+ });
851
+ for (const w of workspaces) {
852
+ w.sessionsHere = occupying.filter((s) => isInside(s.cwd, w.path)).map((s) => s.id);
853
+ w.inUse = w.sessionsHere.length > 0;
854
+ }
855
+ res.json({ workDir: cfg.workDir, repos: cfg.repos, workspaces });
856
+ }));
857
+
858
+ // Delete a workspace directory. Refuses if a session currently reserves
859
+ // it, or if the resolved path escapes workDir. The name comes from the
860
+ // URL — we resolve it against workDir and verify containment.
861
+ app.delete('/api/workspaces/:name', asyncH(async (req, res) => {
862
+ const fsp = require('node:fs/promises');
863
+ const cfg = await loadConfig();
864
+ const name = String(req.params.name || '');
865
+ // Reject anything that tries to escape via separators / traversal.
866
+ if (!name || /[\\/]|^\.\.$|^\.$/.test(name)) {
867
+ return res.status(400).json({ error: 'invalid workspace name' });
868
+ }
869
+ const target = path.resolve(cfg.workDir, name);
870
+ if (!isInside(target, cfg.workDir) || path.resolve(target) === path.resolve(cfg.workDir)) {
871
+ return res.status(400).json({ error: 'workspace must live under workDir' });
872
+ }
873
+ try {
874
+ const st = await fsp.stat(target);
875
+ if (!st.isDirectory()) return res.status(400).json({ error: 'not a directory' });
876
+ } catch {
877
+ return res.status(404).json({ error: 'workspace not found' });
878
+ }
879
+ const allSess = await persistedSessions.loadAll();
880
+ const occupying = workspaceOccupancySessions(allSess, cfg);
881
+ const inUse = occupying.some((s) => isInside(s.cwd, target));
882
+ if (inUse) {
883
+ return res.status(409).json({ error: `workspace is in use by a ${workspaceOccupancyLabel(cfg)}` });
884
+ }
885
+ await fsp.rm(target, { recursive: true, force: true });
886
+ res.json({ ok: true });
887
+ }));
888
+
889
+ // ---- new session ----
890
+ // body: { cliId?, repos?, workspace?, folderId?, launch?: true }
891
+ // Streams NDJSON: workspace / clone-* / launched / done.
892
+ app.post('/api/sessions/new', async (req, res) => {
893
+ res.setHeader('Content-Type', 'application/x-ndjson');
894
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
895
+ res.setHeader('X-Accel-Buffering', 'no');
896
+ if (typeof res.flushHeaders === 'function') res.flushHeaders();
897
+
898
+ const emit = (obj) => { res.write(JSON.stringify(obj) + '\n'); };
899
+ const fail = (msg, extra) => {
900
+ emit({ type: 'done', success: false, error: msg, ...extra });
901
+ res.end();
902
+ };
903
+
904
+ try {
905
+ const cfg = await loadConfig();
906
+ const cli = pickCli(cfg, req.body && req.body.cliId);
907
+ if (!cli) return fail('No CLI configured. Add one in Configure → CLIs.');
908
+
909
+ const explicitRepos = Array.isArray(req.body && req.body.repos);
910
+ const wantedNames = explicitRepos
911
+ ? req.body.repos
912
+ : cfg.repos.filter((r) => r.defaultSelected).map((r) => r.name);
913
+ const wantedRepos = cfg.repos.filter((r) => wantedNames.includes(r.name));
914
+ if (wantedRepos.length === 0 && !explicitRepos && wantedNames.length > 0) {
915
+ return fail('No matching repos found');
916
+ }
917
+
918
+ let workspace;
919
+ let created = false;
920
+ // Three cwd modes:
921
+ // 1. body.cwd — user picked an existing directory; skip clone.
922
+ // 2. body.workspace reuse a named workspace under workDir.
923
+ // 3. (neither) — auto-allocate a fresh ws-N.
924
+ if (req.body && req.body.cwd) {
925
+ const fsmod = require('node:fs/promises');
926
+ const cwd = path.resolve(String(req.body.cwd));
927
+ try {
928
+ const st = await fsmod.stat(cwd);
929
+ if (!st.isDirectory()) return fail(`${cwd} is not a directory`);
930
+ } catch {
931
+ return fail(`directory not found: ${cwd}`);
932
+ }
933
+ workspace = { name: path.basename(cwd) || cwd, path: cwd };
934
+ } else if (req.body && req.body.workspace) {
935
+ const allSess = await persistedSessions.loadAll();
936
+ const busyPaths = workspaceOccupancySessions(allSess, cfg).map((s) => s.cwd);
937
+ const all = await listWorkspaces({ workDir: cfg.workDir, repos: cfg.repos, busyPaths });
938
+ workspace = all.find((w) => w.name === req.body.workspace);
939
+ if (!workspace) return fail(`workspace ${req.body.workspace} not found`);
940
+ } else {
941
+ // Collect cwds of sessions that currently reserve workspaces so
942
+ // findOrCreateWorkspace can flag them as in-use and skip past them.
943
+ const allSess = await persistedSessions.loadAll();
944
+ const busyPaths = workspaceOccupancySessions(allSess, cfg).map((s) => s.cwd);
945
+ const r = await findOrCreateWorkspace({
946
+ workDir: cfg.workDir,
947
+ repos: cfg.repos,
948
+ busyPaths,
949
+ requireUnused: true,
950
+ });
951
+ workspace = r.workspace;
952
+ created = r.created;
953
+ }
954
+ emit({ type: 'workspace', workspace, created });
955
+
956
+ const launchCwd = launchCwdFor(workspace, wantedRepos, req.body && req.body.cwd);
957
+ const existing = await persistedSessions.findByCliAndCwd(cli.id, launchCwd);
958
+ if (workspace.inUse && !existing) {
959
+ return fail(`workspace ${workspace.name} is already used by a ${workspaceOccupancyLabel(cfg)}`);
960
+ }
961
+
962
+ const shouldLaunch = req.body && req.body.launch !== false;
963
+ if (existing) {
964
+ let launched = null;
965
+ if (shouldLaunch) {
966
+ try {
967
+ launched = await spawnSessionRecord({
968
+ record: existing,
969
+ cli,
970
+ cfg,
971
+ body: req.body,
972
+ resume: true,
973
+ });
974
+ emit({ type: 'launched', launched });
975
+ } catch (e) {
976
+ return fail(`spawn failed: ${e.message}`);
977
+ }
978
+ }
979
+ emit({
980
+ type: 'done',
981
+ success: true,
982
+ workspace,
983
+ created: false,
984
+ reused: true,
985
+ session: existing,
986
+ cloneResults: [],
987
+ launched,
988
+ });
989
+ res.end();
990
+ return;
991
+ }
992
+
993
+ // Skip clone entirely when user picked an existing directory — we
994
+ // don't want to dump random repos into someone's project.
995
+ const cloneResults = (req.body && req.body.cwd) ? [] : await ensureReposInWorkspace({
996
+ workspacePath: workspace.path,
997
+ repos: wantedRepos,
998
+ onRepoStart: (repo) =>
999
+ emit({ type: 'clone-start', repo: repo.name, url: repo.url }),
1000
+ onProgress: (repo, p) =>
1001
+ emit({ type: 'clone-progress', repo: repo.name, ...p }),
1002
+ onLine: (repo, line) =>
1003
+ emit({ type: 'clone-line', repo: repo.name, line }),
1004
+ onRepoEnd: (repo, result) =>
1005
+ emit({ type: 'clone-end', repo: repo.name, ...result }),
1006
+ });
1007
+ const failed = cloneResults.filter((r) => !r.ok);
1008
+ if (failed.length > 0) return fail('Some repos failed to clone', { cloneResults });
1009
+
1010
+ let launched = null;
1011
+ let record = null;
1012
+ if (shouldLaunch) {
1013
+ // Create the persistedSessions record FIRST so spawnCliSession can
1014
+ // use its id as the PTY id (matching ids simplify resume/attach).
1015
+ const createdRecord = await persistedSessions.createOrGetByCliAndCwd({
1016
+ cliId: cli.id,
1017
+ cwd: launchCwd,
1018
+ workspace: workspace.name,
1019
+ repos: wantedRepos.map((r) => r.name),
1020
+ folderId: (req.body && req.body.folderId) || null,
1021
+ title: '',
1022
+ });
1023
+ record = createdRecord.entry;
1024
+ try {
1025
+ launched = await spawnSessionRecord({
1026
+ record,
1027
+ cli,
1028
+ cfg,
1029
+ body: req.body,
1030
+ resume: !createdRecord.created,
1031
+ });
1032
+ emit({ type: 'launched', launched });
1033
+ } catch (e) {
1034
+ await persistedSessions.markExited(record.id, null);
1035
+ return fail(`spawn failed: ${e.message}`);
1036
+ }
1037
+ }
1038
+
1039
+ emit({ type: 'done', success: true, workspace, created, cloneResults, launched, session: record });
1040
+ res.end();
1041
+ } catch (e) {
1042
+ console.error('[/api/sessions/new]', e);
1043
+ fail(String(e && e.message || e));
1044
+ }
1045
+ });
1046
+
1047
+ // ---- resume a previous session in the same cwd / cli ----
1048
+ app.post('/api/sessions/:id/resume', asyncH(async (req, res) => {
1049
+ const record = await persistedSessions.get(req.params.id);
1050
+ if (!record) return res.status(404).json({ error: 'session not found' });
1051
+ // Already running and attached → no-op, just return its id.
1052
+ const live = webTerminal.get(record.id);
1053
+ if (live && !live.exitedAt) {
1054
+ // Pool says we're alive but the record may be stale (e.g. a prior
1055
+ // markRunning got clobbered by an OLD entry's onExit before the
1056
+ // respawn-guard landed, or boot mark-exited ran after a pool entry
1057
+ // was already wired). Reconcile the file to match the pool so the
1058
+ // frontend doesn't get stuck on "Resuming session…" forever.
1059
+ if (record.status !== 'running' || record.pid !== live.meta.pid) {
1060
+ try { await persistedSessions.markRunning(record.id, live.meta.pid); } catch {}
1061
+ }
1062
+ return res.json({ launched: { id: record.id, pid: live.meta.pid, cliId: record.cliId } });
1063
+ }
1064
+ const cfg = await loadConfig();
1065
+ const cli = findCliById(cfg, record.cliId);
1066
+ if (!cli) return res.status(400).json({ error: `CLI ${record.cliId} no longer configured` });
1067
+ try {
1068
+ const launched = await spawnSessionRecord({
1069
+ record,
1070
+ cli,
1071
+ cfg,
1072
+ body: req.body,
1073
+ resume: true,
1074
+ });
1075
+ res.json({ launched });
1076
+ } catch (e) {
1077
+ res.status(500).json({ error: e.message });
1078
+ }
1079
+ }));
1080
+
1081
+ // codex-only: when the ccsm terminal is in LIGHT mode, inject a session-scoped
1082
+ // `-c tui.theme=ccsm-light`. codex's diff theme detection (default_bg()) is
1083
+ // compiled out on Windows and always falls back to a DARK diff palette, which
1084
+ // reads poorly on a white terminal — and it ignores COLORFGBG/OSC. The only
1085
+ // lever is a syntax theme whose markup.inserted/deleted scopes carry light
1086
+ // backgrounds (they override the diff palette at true-color level). We ship
1087
+ // that theme (ccsm-light.tmTheme), copy it into the codex home, and point
1088
+ // tui.theme at it. Returns the args to prepend (before any subcommand so the
1089
+ // global -c lands before the subcommand), or [] when not applicable. Skipped
1090
+ // in dark mode (codex's dark default is already correct on a dark terminal)
1091
+ // and when the user configured their own tui.theme in cli.args.
1092
+ async function codexThemeArgs(cli, theme) {
1093
+ if (!cli || cli.type !== 'codex' || theme !== 'light') return [];
1094
+ const args = cli.args || [];
1095
+ const userSet = args.some((a, i) =>
1096
+ String(a).includes('tui.theme') || (a === '-c' && String(args[i + 1] || '').includes('tui.theme')));
1097
+ if (userSet) return [];
1098
+ try {
1099
+ const { probeCodexHome, ensureCodexLightTheme } = require('./lib/codexSeed');
1100
+ let home = null;
1101
+ try { home = await probeCodexHome({ command: cli.command, shell: cli.shell }); } catch {}
1102
+ home = home || process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
1103
+ if (!(await ensureCodexLightTheme(home))) return [];
1104
+ return ['-c', 'tui.theme="ccsm-light"'];
1105
+ } catch { return []; }
1106
+ }
1107
+
1108
+ // ---- capabilities probe ----
1109
+ app.get('/api/capabilities', (_req, res) => res.json({
1110
+ webTerminal: webTerminal.available,
1111
+ webTerminalError: webTerminal.available ? null : String(webTerminal.loadError?.message || 'unavailable'),
1112
+ }));
1113
+
1114
+ // ---- health ----
1115
+ const pkg = require('./package.json');
1116
+ app.get('/api/health', (_req, res) => res.json({ ok: true, pid: process.pid, version: pkg.version, name: pkg.name }));
1117
+
1118
+ // ---- lifecycle ----
1119
+ let currentPort = 0;
1120
+ let frontendUrl = '';
1121
+ let lastHeartbeat = Date.now();
1122
+ let heartbeatSeen = false;
1123
+ const HEARTBEAT_TIMEOUT_MS = 90_000;
1124
+
1125
+ app.post('/api/heartbeat', (_req, res) => {
1126
+ lastHeartbeat = Date.now();
1127
+ heartbeatSeen = true;
1128
+ res.json({ ok: true });
1129
+ });
1130
+
1131
+ app.post('/api/spawn-browser', asyncH(async (_req, res) => {
1132
+ const opened = openInBrowser(frontendUrl || `http://localhost:${currentPort}`);
1133
+ res.json({ ok: true, mode: opened.kind, url: frontendUrl });
1134
+ }));
1135
+
1136
+ app.post('/api/shutdown', (_req, res) => {
1137
+ res.json({ ok: true, bye: 'shutting down' });
1138
+ setImmediate(() => gracefulShutdown('/api/shutdown'));
1139
+ });
1140
+
1141
+ // ---- remote / tunnel ----
1142
+ //
1143
+ // Lifecycle: the Remote page POSTs /start with { provider, token }
1144
+ // we save the token (used by the middleware above for auth) and spawn
1145
+ // the chosen tunnel CLI. URL appears asynchronously in the CLI's
1146
+ // stdout; lib/tunnel parses it. /status returns the latest snapshot
1147
+ // for the page to poll.
1148
+ app.get('/api/tunnel/status', asyncH(async (_req, res) => {
1149
+ res.json(await tunnel.status());
1150
+ }));
1151
+ app.post('/api/tunnel/start', asyncH(async (req, res) => {
1152
+ const { provider, token } = req.body || {};
1153
+ if (!token || String(token).length < 8) {
1154
+ return res.status(400).json({ error: 'token required (≥ 8 chars)' });
1155
+ }
1156
+ tunnel.setToken(token);
1157
+ try {
1158
+ const result = await tunnel.start({ provider, port: currentPort });
1159
+ res.json(result);
1160
+ } catch (e) {
1161
+ res.status(400).json({ error: e.message, providers: await tunnel.probe().catch(() => ({})) });
1162
+ }
1163
+ }));
1164
+ app.post('/api/tunnel/stop', asyncH(async (_req, res) => {
1165
+ const stopped = tunnel.stop();
1166
+ res.json({ stopped, ...(await tunnel.status()) });
1167
+ }));
1168
+ app.post('/api/tunnel/token', asyncH(async (req, res) => {
1169
+ // Bare token update without touching the running tunnel.
1170
+ // POST { token: '' } to clear and disable remote auth.
1171
+ const t = (req.body && req.body.token) || '';
1172
+ tunnel.setToken(t);
1173
+ res.json(await tunnel.status());
1174
+ }));
1175
+ // Persist auto-start prefs. When ON, the backend brings this tunnel up
1176
+ // during its own startup (the boot hook in the listen IIFE below) using
1177
+ // the persisted token, so share URLs survive a backend restart. The
1178
+ // token is written to config ONLY while auto-start is on; turning it off
1179
+ // wipes the persisted token from disk. setToken keeps the in-memory copy
1180
+ // in lockstep so the share URL the page renders stays valid immediately.
1181
+ app.post('/api/tunnel/autostart', asyncH(async (req, res) => {
1182
+ const { autoStart, provider, token } = req.body || {};
1183
+ if (autoStart) {
1184
+ if (!token || String(token).length < 8) {
1185
+ return res.status(400).json({ error: 'token required (≥ 8 chars)' });
1186
+ }
1187
+ if (!['devtunnel', 'cloudflared'].includes(provider)) {
1188
+ return res.status(400).json({ error: 'valid provider required' });
1189
+ }
1190
+ tunnel.setToken(token);
1191
+ await saveConfig({ tunnel: { autoStart: true, provider, token } });
1192
+ } else {
1193
+ await saveConfig({ tunnel: { autoStart: false, provider: null, token: null } });
1194
+ }
1195
+ res.json(await tunnel.status());
1196
+ }));
1197
+ app.post('/api/tunnel/install', asyncH(async (req, res) => {
1198
+ const { provider } = req.body || {};
1199
+ try {
1200
+ const r = tunnel.installViaWinget(provider);
1201
+ res.json({ ok: true, ...r });
1202
+ } catch (e) {
1203
+ res.status(400).json({ error: e.message });
1204
+ }
1205
+ }));
1206
+ // Interactive `devtunnel user login -d` driver. The Remote page POSTs
1207
+ // here to start a device-code flow, then polls /api/tunnel/status to
1208
+ // learn the URL+code it should display and the eventual outcome
1209
+ // avoids the older "copy this command into a shell" UX.
1210
+ app.post('/api/tunnel/devtunnel/login', asyncH(async (req, res) => {
1211
+ const { mode } = req.body || {};
1212
+ try {
1213
+ const snap = await tunnel.startDevtunnelLogin({ mode });
1214
+ res.json({ ok: true, login: snap });
1215
+ } catch (e) {
1216
+ res.status(400).json({ error: e.message });
1217
+ }
1218
+ }));
1219
+ app.post('/api/tunnel/devtunnel/login/cancel', asyncH(async (_req, res) => {
1220
+ res.json({ ok: true, login: tunnel.cancelDevtunnelLogin() });
1221
+ }));
1222
+ app.post('/api/tunnel/devtunnel/login/dismiss', asyncH(async (_req, res) => {
1223
+ tunnel.clearDevtunnelLogin();
1224
+ res.json({ ok: true });
1225
+ }));
1226
+ // Wipe the persisted devtunnel tunnel id (and the remote tunnel
1227
+ // resource itself, best-effort) so the next /api/tunnel/start mints
1228
+ // a fresh one. Used by the Reset button in the Remote page when the
1229
+ // user wants to rotate the public URL. Tunnel must be stopped first
1230
+ // refuse otherwise so we don't yank state out from under a live
1231
+ // `devtunnel host` child.
1232
+ app.post('/api/tunnel/devtunnel/reset', asyncH(async (_req, res) => {
1233
+ const s = await tunnel.status();
1234
+ if (s.running && s.provider === 'devtunnel') {
1235
+ return res.status(409).json({ error: 'stop the tunnel before resetting its id' });
1236
+ }
1237
+ const r = await tunnel.resetDevtunnelTunnelId();
1238
+ res.json({ ok: true, ...r, ...(await tunnel.status()) });
1239
+ }));
1240
+
1241
+ // ---- devices ----
1242
+ //
1243
+ // /api/devices/me is callable from the remote browser BEFORE approval —
1244
+ // it's how the PendingApprovalOverlay polls for the host's decision.
1245
+ // Everything else is locked to loopback by the gate above.
1246
+ app.get('/api/devices/me', asyncH(async (req, res) => {
1247
+ const id = String(req.headers['x-device-id'] || (req.query && req.query.device) || '');
1248
+ if (!id) return res.status(400).json({ error: 'device id required' });
1249
+ // Token check applies HERE this is the only endpoint where new
1250
+ // device records are created (record() inserts pending on first
1251
+ // sight). Demanding the token at registration time stops random
1252
+ // tunnel-URL scanners from filling the host's pending queue with
1253
+ // garbage entries. Already-known devices can re-poll without the
1254
+ // token (the existing record is returned as-is).
1255
+ const existing = await devices.get(id);
1256
+ if (!existing) {
1257
+ const tok = tunnel.getToken();
1258
+ if (tok && !isDirectLoopback(req)) {
1259
+ const auth = req.headers.authorization || '';
1260
+ const qTok = req.query && req.query.token;
1261
+ if (auth !== `Bearer ${tok}` && qTok !== tok) {
1262
+ return res.status(401).json({ error: 'token required to register a new device' });
1263
+ }
1264
+ }
1265
+ }
1266
+ const ua = req.headers['user-agent'] || '';
1267
+ const ip = String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
1268
+ const code = String(req.headers['x-device-code'] || (req.query && req.query.code) || '').slice(0, 8);
1269
+ const d = await devices.record(id, { userAgent: ua, ip, code });
1270
+ res.json(d);
1271
+ }));
1272
+ app.get('/api/devices', asyncH(async (_req, res) => {
1273
+ res.json({ devices: await devices.list() });
1274
+ }));
1275
+ app.post('/api/devices/:id/approve', asyncH(async (req, res) => {
1276
+ const d = await devices.approve(req.params.id, req.body && req.body.label);
1277
+ if (!d) return res.status(404).json({ error: 'device not found' });
1278
+ res.json(d);
1279
+ }));
1280
+ app.post('/api/devices/:id/reject', asyncH(async (req, res) => {
1281
+ const d = await devices.reject(req.params.id);
1282
+ if (!d) return res.status(404).json({ error: 'device not found' });
1283
+ res.json(d);
1284
+ }));
1285
+ app.post('/api/devices/:id/revoke', asyncH(async (req, res) => {
1286
+ const d = await devices.revoke(req.params.id);
1287
+ if (!d) return res.status(404).json({ error: 'device not found' });
1288
+ res.json(d);
1289
+ }));
1290
+ app.put('/api/devices/:id', asyncH(async (req, res) => {
1291
+ const d = await devices.rename(req.params.id, (req.body && req.body.label) || '');
1292
+ if (!d) return res.status(404).json({ error: 'device not found' });
1293
+ res.json(d);
1294
+ }));
1295
+ app.delete('/api/devices/:id', asyncH(async (req, res) => {
1296
+ const removed = await devices.remove(req.params.id);
1297
+ res.json({ removed });
1298
+ }));
1299
+
1300
+ // Restart: in production, spawn the restart-helper detached then
1301
+ // gracefulShutdown the helper waits for the port to free and respawns
1302
+ // `ccsm.cmd` (with CCSM_NO_BROWSER so we don't pop a new window — the
1303
+ // frontend bounces through OfflineBanner / version router back into the
1304
+ // new backend). In dev (CCSM_DEV=1, set by scripts/dev.js), we skip the
1305
+ // helper entirely: just gracefulShutdown. scripts/dev.js sees its child
1306
+ // exit and respawns `node --watch server.js` from the checkout, picking
1307
+ // up any code changes.
1308
+ let restartInFlight = false;
1309
+ app.post('/api/restart', asyncH(async (_req, res) => {
1310
+ if (restartInFlight) {
1311
+ return res.status(409).json({ error: 'restart already in progress' });
1312
+ }
1313
+ restartInFlight = true;
1314
+
1315
+ if (process.env.CCSM_DEV === '1') {
1316
+ res.json({ ok: true, started: true, mode: 'dev', closeFrontend: false });
1317
+ setImmediate(() => gracefulShutdown('restart (dev)'));
1318
+ return;
1319
+ }
1320
+
1321
+ const fsp = require('node:fs/promises');
1322
+ const helperSrc = path.join(__dirname, 'scripts', 'restart-helper.js');
1323
+ const helperTmp = path.join(os.tmpdir(), `ccsm-restart-${process.pid}-${Date.now()}.js`);
1324
+ try {
1325
+ await fsp.copyFile(helperSrc, helperTmp);
1326
+ } catch (e) {
1327
+ restartInFlight = false;
1328
+ return res.status(500).json({ error: `helper copy failed: ${e.message}` });
1329
+ }
1330
+ const args = [helperTmp, String(currentPort), String(process.pid)];
1331
+ // closeFrontend asks the calling tab to window.close() itself the
1332
+ // helper will respawn ccsm WITHOUT CCSM_NO_BROWSER, so a fresh window
1333
+ // pops up once the new backend is listening. Net effect: the user
1334
+ // never sees the OfflineBanner during a restart.
1335
+ res.json({ ok: true, started: true, helper: helperTmp, closeFrontend: true });
1336
+
1337
+ setImmediate(() => {
1338
+ const { spawn } = require('node:child_process');
1339
+ try {
1340
+ const child = spawn(process.execPath, args, {
1341
+ detached: true,
1342
+ stdio: 'ignore',
1343
+ windowsHide: true,
1344
+ shell: false,
1345
+ });
1346
+ child.unref();
1347
+ console.log(`[restart] helper pid=${child.pid}, shutting down`);
1348
+ } catch (e) {
1349
+ console.error('[restart] helper spawn failed:', e.message);
1350
+ restartInFlight = false;
1351
+ return;
1352
+ }
1353
+ setTimeout(() => gracefulShutdown('restart'), 500);
1354
+ });
1355
+ }));
1356
+
1357
+ // ---- version / upgrade ----
1358
+ // `/api/version` reports the installed version (= pkg.version) and, if
1359
+ // reachable, the latest published on the npm registry. The result is
1360
+ // cached for 30 minutes in memory so the AboutPage poll doesn't hit the
1361
+ // registry on every render.
1362
+ //
1363
+ // `/api/upgrade` kicks off `npm i -g @bakapiano/ccsm@latest` as a
1364
+ // detached child. When the install completes, the child re-spawns `ccsm`
1365
+ // (also detached) so the launcher comes back up on the new version, and
1366
+ // the current server gracefulShutdowns. The frontend's OfflineBanner
1367
+ // covers the gap; the version router picks up the new version on the
1368
+ // next probe.
1369
+ const VERSION_CACHE_MS = 30 * 60_000;
1370
+ let versionCache = null; // { latest, fetchedAt }
1371
+ let upgradeInFlight = false;
1372
+
1373
+ async function fetchLatestFromNpm() {
1374
+ // Node 18+ has a global fetch. Time out the registry call to avoid
1375
+ // hanging the response when the user is offline / behind a captive
1376
+ // portal.
1377
+ const ctrl = new AbortController();
1378
+ const t = setTimeout(() => ctrl.abort(), 4000);
1379
+ try {
1380
+ const r = await fetch('https://registry.npmjs.org/@bakapiano%2Fccsm/latest', {
1381
+ headers: { 'Accept': 'application/json' },
1382
+ signal: ctrl.signal,
1383
+ });
1384
+ if (!r.ok) throw new Error(`registry HTTP ${r.status}`);
1385
+ const j = await r.json();
1386
+ return String(j.version || '');
1387
+ } finally {
1388
+ clearTimeout(t);
1389
+ }
1390
+ }
1391
+
1392
+ function cmpSemver(a, b) {
1393
+ const pa = String(a || '').split('.').map(Number);
1394
+ const pb = String(b || '').split('.').map(Number);
1395
+ for (let i = 0; i < 3; i++) {
1396
+ const x = pa[i] || 0, y = pb[i] || 0;
1397
+ if (x > y) return 1;
1398
+ if (x < y) return -1;
1399
+ }
1400
+ return 0;
1401
+ }
1402
+
1403
+ app.get('/api/version', asyncH(async (req, res) => {
1404
+ const force = String(req.query.refresh || '') === '1';
1405
+ const now = Date.now();
1406
+ // devMode: set when the server was launched from scripts/dev.js
1407
+ // (CCSM_DEV=1). Lets the About page render a "test upgrade flow"
1408
+ // button that re-installs to a sandbox prefix without affecting the
1409
+ // user's global ccsm install.
1410
+ const devMode = process.env.CCSM_DEV === '1';
1411
+ if (!force && versionCache && (now - versionCache.fetchedAt) < VERSION_CACHE_MS) {
1412
+ return res.json({
1413
+ current: pkg.version,
1414
+ latest: versionCache.latest,
1415
+ updateAvailable: cmpSemver(versionCache.latest, pkg.version) > 0,
1416
+ fetchedAt: versionCache.fetchedAt,
1417
+ cached: true,
1418
+ devMode,
1419
+ });
1420
+ }
1421
+ try {
1422
+ const latest = await fetchLatestFromNpm();
1423
+ versionCache = { latest, fetchedAt: now };
1424
+ res.json({
1425
+ current: pkg.version,
1426
+ latest,
1427
+ updateAvailable: cmpSemver(latest, pkg.version) > 0,
1428
+ fetchedAt: now,
1429
+ cached: false,
1430
+ devMode,
1431
+ });
1432
+ } catch (e) {
1433
+ res.json({
1434
+ current: pkg.version,
1435
+ latest: null,
1436
+ updateAvailable: false,
1437
+ fetchedAt: now,
1438
+ error: String(e.message || e),
1439
+ devMode,
1440
+ });
1441
+ }
1442
+ }));
1443
+
1444
+ app.post('/api/upgrade', asyncH(async (req, res) => {
1445
+ if (upgradeInFlight) {
1446
+ return res.status(409).json({ error: 'upgrade already in progress' });
1447
+ }
1448
+ const body = req.body || {};
1449
+ const target = String(body.target || 'latest');
1450
+ // Refuse anything that doesn't look like a semver dist-tag or version
1451
+ // defends against `;` etc. winding up in the spawn argv even though
1452
+ // we don't shell out.
1453
+ if (!/^[a-z0-9.+\-^~]+$/i.test(target)) {
1454
+ return res.status(400).json({ error: `invalid target: ${target}` });
1455
+ }
1456
+ // Optional sandbox install prefix (for testing without disturbing the
1457
+ // user's real global ccsm). Validated as a plain absolute path so it
1458
+ // can't be a flag injection.
1459
+ const installPrefix = body.installPrefix ? String(body.installPrefix) : '';
1460
+ if (installPrefix && (installPrefix.startsWith('-') || !path.isAbsolute(installPrefix))) {
1461
+ return res.status(400).json({ error: 'installPrefix must be an absolute path' });
1462
+ }
1463
+ const respawn = body.respawn === false ? '0' : '1';
1464
+ upgradeInFlight = true;
1465
+ console.log(`[upgrade] target=${target}${installPrefix ? ` prefix=${installPrefix}` : ''}${respawn === '0' ? ' (no respawn)' : ''}`);
1466
+
1467
+ // The helper runs OUTSIDE the package dir so npm can rename it
1468
+ // without fighting open file handles. Copy the script to os.tmpdir()
1469
+ // and spawn from there.
1470
+ const fsp = require('node:fs/promises');
1471
+ const helperSrc = path.join(__dirname, 'scripts', 'upgrade-helper.js');
1472
+ const helperTmp = path.join(os.tmpdir(), `ccsm-upgrade-${process.pid}-${Date.now()}.js`);
1473
+ try {
1474
+ await fsp.copyFile(helperSrc, helperTmp);
1475
+ } catch (e) {
1476
+ upgradeInFlight = false;
1477
+ return res.status(500).json({ error: `helper copy failed: ${e.message}` });
1478
+ }
1479
+ // Where to send the user back when the upgrade succeeds. In prod
1480
+ // that's the GH Pages router (it'll re-probe localhost:7777 and
1481
+ // redirect to the matching per-version frontend); in dev (CCSM_DEV=1)
1482
+ // that's our local server on whatever port we're listening on, so
1483
+ // the test sandbox flow returns to the dev instance instead of
1484
+ // hitting GH Pages (which doesn't know about port 7788).
1485
+ const redirectTo = frontendUrl || `http://localhost:${currentPort}/`;
1486
+
1487
+ const args = [helperTmp, target, String(currentPort), String(process.pid), installPrefix, respawn, redirectTo];
1488
+
1489
+ res.json({
1490
+ ok: true,
1491
+ started: true,
1492
+ target,
1493
+ helper: helperTmp,
1494
+ helperUrl: 'http://localhost:7779/',
1495
+ closeFrontend: false,
1496
+ });
1497
+
1498
+ // Flush response, then spawn helper detached and gracefulShutdown so
1499
+ // the helper's npm install isn't fighting our open file handles.
1500
+ setImmediate(() => {
1501
+ const { spawn } = require('node:child_process');
1502
+ try {
1503
+ const child = spawn(process.execPath, args, {
1504
+ detached: true,
1505
+ stdio: 'ignore',
1506
+ windowsHide: true,
1507
+ shell: false,
1508
+ });
1509
+ child.unref();
1510
+ console.log(`[upgrade] helper pid=${child.pid}, shutting down`);
1511
+ } catch (e) {
1512
+ console.error('[upgrade] helper spawn failed:', e.message);
1513
+ upgradeInFlight = false;
1514
+ return;
1515
+ }
1516
+ setTimeout(() => gracefulShutdown('upgrade'), 500);
1517
+ });
1518
+ }));
1519
+
1520
+
1521
+ function listenWithFallback(preferred) {
1522
+ return new Promise((resolve, reject) => {
1523
+ const attempt = (port, tries) => {
1524
+ const server = app.listen(port);
1525
+ server.once('listening', () => resolve({ server, port: server.address().port }));
1526
+ server.once('error', (err) => {
1527
+ if (err.code !== 'EADDRINUSE') return reject(err);
1528
+ if (tries < 9) attempt(port + 1, tries + 1);
1529
+ else if (tries === 9) attempt(0, tries + 1);
1530
+ else reject(err);
1531
+ });
1532
+ };
1533
+ attempt(preferred, 0);
1534
+ });
1535
+ }
1536
+
1537
+ function findAppModeBrowser() {
1538
+ const candidates = [
1539
+ 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
1540
+ 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
1541
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
1542
+ process.env.LOCALAPPDATA &&
1543
+ path.join(process.env.LOCALAPPDATA, 'Google\\Chrome\\Application\\chrome.exe'),
1544
+ 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
1545
+ ].filter(Boolean);
1546
+ const fs = require('node:fs');
1547
+ for (const p of candidates) {
1548
+ if (fs.existsSync(p)) return p;
1549
+ }
1550
+ return null;
1551
+ }
1552
+
1553
+ // Look for a Chrome/Edge PWA that the user already installed locally
1554
+ // pointing at the ccsm frontend. When found, we launch it via
1555
+ // `chrome.exe --profile-directory=... --app-id=<id>` — same as the
1556
+ // shortcut Start Menu creates at install time. That path opens the
1557
+ // PWA fully chromeless (respects manifest display:standalone + WCO).
1558
+ // Without this we'd fall back to `--app=<URL> --user-data-dir=<ours>`
1559
+ // which uses an isolated profile that doesn't see the install, so
1560
+ // Chrome shows a minimal-ui address bar.
1561
+ function findInstalledCcsmPwa() {
1562
+ if (process.platform !== 'win32') return null;
1563
+ const appData = process.env.APPDATA;
1564
+ if (!appData) return null;
1565
+ const fs = require('node:fs');
1566
+ const startMenu = path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs');
1567
+ const dirs = [
1568
+ path.join(startMenu, 'Chrome Apps'),
1569
+ path.join(startMenu, 'Edge Apps'),
1570
+ ];
1571
+ const candidates = [];
1572
+ for (const dir of dirs) {
1573
+ let names;
1574
+ try { names = fs.readdirSync(dir); } catch { continue; }
1575
+ for (const name of names) {
1576
+ if (!name.toLowerCase().endsWith('.lnk')) continue;
1577
+ // Filter by filename — Chrome names PWA shortcuts after the
1578
+ // manifest's short_name/name. CCSM matches our manifest.
1579
+ if (!/ccsm/i.test(name)) continue;
1580
+ const full = path.join(dir, name);
1581
+ try {
1582
+ candidates.push({ name, path: full, mtime: fs.statSync(full).mtimeMs });
1583
+ } catch {}
1584
+ }
1585
+ }
1586
+ if (candidates.length === 0) return null;
1587
+ // Newest install wins (covers the case where the user re-installed
1588
+ // and accumulated CCSM, CCSM (1), etc.).
1589
+ candidates.sort((a, b) => b.mtime - a.mtime);
1590
+ // Resolve via WScript.Shell COM. Single PowerShell call enumerates
1591
+ // every candidate; we stop at the first one whose target looks like
1592
+ // a Chrome/Edge binary and whose args carry an --app-id.
1593
+ const { spawnSync } = require('node:child_process');
1594
+ const psPaths = candidates
1595
+ .map((c) => `'${c.path.replace(/'/g, "''")}'`).join(',');
1596
+ const script = `
1597
+ $ErrorActionPreference = 'SilentlyContinue'
1598
+ $wsh = New-Object -ComObject WScript.Shell
1599
+ foreach ($p in @(${psPaths})) {
1600
+ $sc = $wsh.CreateShortcut($p)
1601
+ Write-Output ($sc.TargetPath + '|' + $sc.Arguments)
1602
+ }`;
1603
+ const r = spawnSync('powershell.exe',
1604
+ ['-NoProfile', '-NonInteractive', '-Command', script],
1605
+ { encoding: 'utf8', windowsHide: true });
1606
+ if (r.status !== 0 || !r.stdout) return null;
1607
+ for (const line of r.stdout.split(/\r?\n/)) {
1608
+ if (!line.trim()) continue;
1609
+ const sep = line.indexOf('|');
1610
+ if (sep < 0) continue;
1611
+ const target = line.slice(0, sep).trim();
1612
+ const args = line.slice(sep + 1).trim();
1613
+ if (!/chrome(_proxy)?\.exe$|msedge(_proxy)?\.exe$/i.test(target)) continue;
1614
+ const appId = (args.match(/--app-id=(\S+)/) || [])[1];
1615
+ if (!appId) continue;
1616
+ const profile = (args.match(/--profile-directory=(\S+)/) || [])[1] || 'Default';
1617
+ return { browserPath: target, appId, profile };
1618
+ }
1619
+ return null;
1620
+ }
1621
+
1622
+ // Auto-open the frontend in a browser when ccsm boots. Strategy:
1623
+ // 1. If the user already installed the CCSM PWA, launch THAT (fully
1624
+ // chromeless via --app-id, uses user's main browser profile).
1625
+ // 2. Otherwise try a generic --app= window in an isolated profile —
1626
+ // this shows a thin minimal-ui address bar but at least it's
1627
+ // a dedicated window.
1628
+ // 3. Fall back to the OS default browser as a regular tab.
1629
+ // On non-Windows we skip — the bundled launcher isn't ported yet.
1630
+ function openInBrowser(url) {
1631
+ if (process.platform !== 'win32') return { kind: 'none', child: null };
1632
+ const { spawn } = require('node:child_process');
1633
+ const fs = require('node:fs');
1634
+
1635
+ const installed = findInstalledCcsmPwa();
1636
+ if (installed) {
1637
+ console.log(`[ccsm] launching installed PWA · app-id=${installed.appId} profile=${installed.profile}`);
1638
+ const child = spawn(
1639
+ installed.browserPath,
1640
+ [
1641
+ `--profile-directory=${installed.profile}`,
1642
+ `--app-id=${installed.appId}`,
1643
+ ],
1644
+ { detached: true, stdio: 'ignore' }
1645
+ );
1646
+ child.unref();
1647
+ return { kind: 'pwa', child };
1648
+ }
1649
+
1650
+ const exe = findAppModeBrowser();
1651
+ if (exe) {
1652
+ const profileDir = path.join(DATA_DIR, 'browser-profile');
1653
+ fs.mkdirSync(profileDir, { recursive: true });
1654
+ console.log(`[ccsm] no installed PWA found · falling back to --app= window`);
1655
+ const child = spawn(
1656
+ exe,
1657
+ [
1658
+ `--app=${url}`,
1659
+ `--user-data-dir=${profileDir}`,
1660
+ '--window-size=1500,1100',
1661
+ '--no-first-run',
1662
+ '--no-default-browser-check',
1663
+ ],
1664
+ { detached: true, stdio: 'ignore' }
1665
+ );
1666
+ child.unref();
1667
+ return { kind: 'app', child };
1668
+ }
1669
+ console.log('[ccsm] no Edge/Chrome found, opening default browser');
1670
+ const child = spawn('cmd.exe', ['/c', 'start', '', url], {
1671
+ detached: true,
1672
+ stdio: 'ignore',
1673
+ windowsHide: true,
1674
+ });
1675
+ child.unref();
1676
+ return { kind: 'tab', child: null };
1677
+ }
1678
+
1679
+ (async () => {
1680
+ const cfg = await loadConfig();
1681
+ const preferredPort = process.env.CCSM_PORT ? Number(process.env.CCSM_PORT) : cfg.port;
1682
+ const { server, port } = await listenWithFallback(preferredPort);
1683
+ currentPort = port;
1684
+
1685
+ // On boot, normalize legacy records and mark any persisted "running"
1686
+ // sessions as exited — they belong to a previous server process whose
1687
+ // PTYs are gone.
1688
+ try {
1689
+ await persistedSessions.normalizeStore();
1690
+ const all = await persistedSessions.loadAll();
1691
+ for (const s of all) {
1692
+ if (s.status === 'running') {
1693
+ await persistedSessions.markExited(s.id, null);
1694
+ }
1695
+ }
1696
+ } catch (e) {
1697
+ console.error('[ccsm] could not reconcile persisted sessions:', e.message);
1698
+ }
1699
+
1700
+ // Prewarm tunnel provider probe. First /api/tunnel/status round-trip
1701
+ // shells out to where.exe / --version / devtunnel user show — ~700ms
1702
+ // of synchronous work that the user otherwise waits on the moment
1703
+ // they open the Remote tab. Fire in the background here so the cache
1704
+ // is warm by the time anyone clicks.
1705
+ try { tunnel.probe(true).catch(() => {}); } catch {}
1706
+
1707
+ // Auto-start the tunnel if the user enabled it on the Remote page.
1708
+ // This is the BACKEND PROCESS bringing its own tunnel up on startup —
1709
+ // not an OS-level autostart (no registry / scheduled task). Reuses the
1710
+ // persisted token so share URLs stay valid across restarts. Strictly
1711
+ // fire-and-forget: a failure here (devtunnel not signed in, provider
1712
+ // uninstalled, etc.) must never crash boot — it just logs and the user
1713
+ // can start manually from the Remote page.
1714
+ if (cfg.tunnel?.autoStart && cfg.tunnel?.token && cfg.tunnel?.provider) {
1715
+ tunnel.setToken(cfg.tunnel.token);
1716
+ tunnel.start({ provider: cfg.tunnel.provider, port: currentPort })
1717
+ .then((s) => console.log(`[ccsm] tunnel auto-started · ${cfg.tunnel.provider} · ${s.url || 'URL pending'}`))
1718
+ .catch((e) => console.warn(`[ccsm] tunnel auto-start failed · ${e.message}`));
1719
+ }
1720
+
1721
+ if (webTerminal.available) {
1722
+ let WebSocketServer;
1723
+ try { ({ WebSocketServer } = require('ws')); } catch {}
1724
+ if (WebSocketServer) {
1725
+ const wss = new WebSocketServer({ noServer: true });
1726
+ server.on('upgrade', async (req, socket, head) => {
1727
+ const direct = isDirectLoopback(req);
1728
+ // Non-loopback WS: device id alone gates entry. The host
1729
+ // explicitly Approved this device id earlier that approval
1730
+ // IS the credential. No token check here (matches the device
1731
+ // gate above: token is only for /api/devices/me registration).
1732
+ if (!direct) {
1733
+ try {
1734
+ const u = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
1735
+ const devId = u.searchParams.get('device');
1736
+ if (!devId) { socket.destroy(); return; }
1737
+ const d = await devices.get(devId);
1738
+ if (!d || d.status !== 'approved') { socket.destroy(); return; }
1739
+ } catch { socket.destroy(); return; }
1740
+ } else {
1741
+ const origin = req.headers.origin;
1742
+ if (origin && !ALLOWED_ORIGINS.has(origin) && !/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin)) {
1743
+ socket.destroy();
1744
+ return;
1745
+ }
1746
+ }
1747
+ const m = req.url && req.url.match(/^\/ws\/terminal\/([^\/?#]+)/);
1748
+ if (!m) { socket.destroy(); return; }
1749
+ const id = decodeURIComponent(m[1]);
1750
+ wss.handleUpgrade(req, socket, head, (ws) => webTerminal.attach(id, ws));
1751
+ });
1752
+ console.log('[ccsm] web terminal bridge active (WebSocket /ws/terminal/:id)');
1753
+ }
1754
+ }
1755
+
1756
+ for (const sig of ['SIGINT', 'SIGTERM']) {
1757
+ process.on(sig, () => gracefulShutdown(sig));
1758
+ }
1759
+ process.on('exit', () => { try { webTerminal.killAll(); } catch {} });
1760
+
1761
+ const apiUrl = `http://localhost:${port}`;
1762
+ const FRONTEND_URL = IS_DEV
1763
+ ? apiUrl
1764
+ : 'https://bakapiano.github.io/ccsm/';
1765
+ frontendUrl = FRONTEND_URL;
1766
+ console.log(`ccsm listening on ${apiUrl}${port !== preferredPort ? ` (requested ${preferredPort}, was taken)` : ''}`);
1767
+ console.log(`frontend at ${FRONTEND_URL}`);
1768
+ console.log(`data dir: ${DATA_DIR}`);
1769
+ console.log(`work dir: ${cfg.workDir}`);
1770
+ console.log(`clis: ${cfg.clis.map((c) => c.id).join(', ')} (default: ${cfg.defaultCliId})`);
1771
+
1772
+ // CCSM_NO_BROWSER=1 (set by the ccsm:// protocol launcher) suppresses
1773
+ // the auto-open entirely. CCSM_FROM_UPGRADE=1 (set by upgrade-helper
1774
+ // when it respawns ccsm post-install) does the same: the user is
1775
+ // already in the helper UI which redirects to this fresh backend, so
1776
+ // a second app-mode window would just shadow the first. Otherwise try
1777
+ // app-mode (chromeless Edge/Chrome window); if no such browser is
1778
+ // installed, openInBrowser falls back to the OS default browser on
1779
+ // its own.
1780
+ const suppressBrowser = process.env.CCSM_NO_BROWSER === '1'
1781
+ || process.env.CCSM_FROM_UPGRADE === '1';
1782
+ const opened = suppressBrowser
1783
+ ? { kind: 'none', child: null }
1784
+ : openInBrowser(FRONTEND_URL);
1785
+
1786
+ if (opened.kind === 'app' && opened.child && process.env.CCSM_KEEP_ALIVE !== '1') {
1787
+ const launchedAt = Date.now();
1788
+ opened.child.on('exit', () => {
1789
+ const alive = Date.now() - launchedAt;
1790
+ if (alive < 5000) {
1791
+ console.log(`[ccsm] spawned browser child exited in ${alive}ms · handed off to an existing Edge instance, staying alive`);
1792
+ return;
1793
+ }
1794
+ const closedAt = Date.now();
1795
+ setTimeout(() => {
1796
+ if (lastHeartbeat > closedAt + 100) {
1797
+ console.log('[ccsm] browser closed but another client is heartbeating · staying alive');
1798
+ return;
1799
+ }
1800
+ gracefulShutdown('browser window closed');
1801
+ }, 12_000);
1802
+ });
1803
+ console.log('[ccsm] tied to browser window close it to stop ccsm');
1804
+ }
1805
+
1806
+ if (process.env.CCSM_LAUNCHER === '1' && process.env.CCSM_KEEP_ALIVE !== '1') {
1807
+ setInterval(() => {
1808
+ if (!heartbeatSeen) return;
1809
+ if (Date.now() - lastHeartbeat > HEARTBEAT_TIMEOUT_MS) {
1810
+ gracefulShutdown(`no heartbeat for ${HEARTBEAT_TIMEOUT_MS / 1000}s`);
1811
+ }
1812
+ }, 30_000);
1813
+ console.log('[ccsm] heartbeat watchdog active');
1814
+ }
1815
+ })().catch((err) => {
1816
+ console.error('startup failed:', err);
1817
+ process.exit(1);
1818
+ });