@bakapiano/ccsm 0.22.5 → 0.22.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +538 -538
- package/README.md +189 -189
- package/bin/ccsm.js +235 -235
- package/lib/cliActivity.js +139 -139
- package/lib/codexSeed.js +183 -183
- package/lib/config.js +279 -274
- package/lib/devices.js +229 -229
- package/lib/folders.js +124 -124
- package/lib/localCliSessions.js +519 -519
- package/lib/persistedSessions.js +129 -129
- package/lib/tunnel.js +621 -621
- package/lib/webTerminal.js +225 -225
- package/lib/workspace.js +233 -233
- package/package.json +57 -57
- package/public/css/base.css +99 -99
- package/public/css/cards.css +183 -183
- package/public/css/feedback.css +504 -504
- package/public/css/forms.css +453 -453
- package/public/css/layout.css +177 -176
- package/public/css/modal.css +190 -190
- package/public/css/responsive.css +176 -176
- package/public/css/sidebar.css +707 -707
- package/public/css/terminals.css +547 -553
- package/public/css/tokens.css +81 -81
- package/public/css/wco.css +196 -196
- package/public/css/widgets.css +2725 -2725
- package/public/index.html +152 -152
- package/public/js/api.js +371 -371
- package/public/js/backend.js +149 -149
- package/public/js/components/App.js +73 -73
- package/public/js/components/DirectoryPicker.js +203 -203
- package/public/js/components/EntityFormModal.js +153 -153
- package/public/js/components/Modal.js +57 -57
- package/public/js/components/OfflineBanner.js +67 -67
- package/public/js/components/PageTitleBar.js +13 -13
- package/public/js/components/PendingApprovalOverlay.js +128 -128
- package/public/js/components/Picker.js +179 -179
- package/public/js/components/Popover.js +55 -55
- package/public/js/components/RestartOverlay.js +36 -36
- package/public/js/components/Sidebar.js +380 -380
- package/public/js/components/TerminalInstance.js +28 -9
- package/public/js/components/XtermTerminal.js +62 -2
- package/public/js/components/useDragSort.js +67 -67
- package/public/js/dialog.js +67 -67
- package/public/js/icons.js +212 -212
- package/public/js/main.js +296 -296
- package/public/js/pages/AboutPage.js +90 -90
- package/public/js/pages/ConfigurePage.js +728 -713
- package/public/js/pages/LaunchPage.js +421 -421
- package/public/js/pages/RemotePage.js +743 -743
- package/public/js/pages/SessionsPage.js +73 -80
- package/public/js/state.js +335 -335
- package/scripts/dev.js +149 -149
- package/scripts/install.js +153 -153
- package/scripts/restart-helper.js +96 -96
- package/scripts/upgrade-helper.js +687 -687
- package/server.js +1820 -1807
- package/public/manifest.webmanifest +0 -25
- package/public/setup/index.html +0 -567
package/server.js
CHANGED
|
@@ -1,217 +1,217 @@
|
|
|
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 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
|
+
|
|
215
215
|
function pickCli(cfg, requestedId) {
|
|
216
216
|
const wanted = requestedId || cfg.defaultCliId;
|
|
217
217
|
return cfg.clis.find((c) => c.id === wanted) || cfg.clis[0];
|
|
@@ -222,418 +222,430 @@ function findCliById(cfg, id) {
|
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
// 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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
}
|
|
529
|
-
} else {
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
app.
|
|
587
|
-
const
|
|
588
|
-
|
|
589
|
-
res.json({
|
|
590
|
-
}));
|
|
591
|
-
|
|
592
|
-
app.
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
res.json({
|
|
602
|
-
}));
|
|
603
|
-
|
|
604
|
-
app.
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
const
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
const
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
const
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
+
|
|
476
|
+
function workspaceOccupancySessions(sessions, cfg) {
|
|
477
|
+
const includeStopped = cfg?.reserveWorkspacesForStoppedSessions === true;
|
|
478
|
+
return (sessions || []).filter((s) =>
|
|
479
|
+
s && s.cwd && (includeStopped || s.status === 'running')
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function workspaceOccupancyLabel(cfg) {
|
|
484
|
+
return cfg?.reserveWorkspacesForStoppedSessions === true ? 'session' : 'running session';
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
app.get('/api/config', asyncH(async (_req, res) => {
|
|
488
|
+
res.json(decorateConfigWithProbes(stripTunnelKeys(await loadConfig())));
|
|
489
|
+
}));
|
|
490
|
+
|
|
491
|
+
app.put('/api/config', asyncH(async (req, res) => {
|
|
492
|
+
const body = { ...(req.body || {}) };
|
|
493
|
+
delete body.tunnel;
|
|
494
|
+
delete body.devtunnel;
|
|
495
|
+
res.json(decorateConfigWithProbes(stripTunnelKeys(await saveConfig(body))));
|
|
496
|
+
}));
|
|
497
|
+
|
|
498
|
+
// ---- CLI probe / test ----
|
|
499
|
+
//
|
|
500
|
+
// Run the user's configured command with `--version` and report back
|
|
501
|
+
// stdout/stderr + whether the output looks like the claimed CLI type.
|
|
502
|
+
// Used by the Configure page "Test" button so the user can verify the
|
|
503
|
+
// command resolves + actually launches the right tool BEFORE saving.
|
|
504
|
+
// Body: { command, args?, shell?, type? }. args is ignored for the
|
|
505
|
+
// version probe — we always append `--version` directly so the user's
|
|
506
|
+
// runtime args (e.g. --dangerously-skip-permissions) don't perturb the
|
|
507
|
+
// quick probe.
|
|
508
|
+
app.post('/api/clis/test', asyncH(async (req, res) => {
|
|
509
|
+
const { spawn } = require('node:child_process');
|
|
510
|
+
const body = req.body || {};
|
|
511
|
+
const command = String(body.command || '').trim();
|
|
512
|
+
const shell = ['direct', 'pwsh', 'cmd'].includes(body.shell) ? body.shell : 'direct';
|
|
513
|
+
const type = ['claude', 'codex', 'copilot', 'other'].includes(body.type) ? body.type : 'other';
|
|
514
|
+
if (!command) return res.status(400).json({ error: 'command required' });
|
|
515
|
+
|
|
516
|
+
// Build the test exec. Same shell-wrapping rules as resolveCommand,
|
|
517
|
+
// but we force `--version` as the only arg and we DROP `-NoExit`
|
|
518
|
+
// from the pwsh wrapper so pwsh terminates after printing.
|
|
519
|
+
let exe, args;
|
|
520
|
+
const cmd = command.replace(/^\.[\\\/]/, '');
|
|
521
|
+
const versionArg = '--version';
|
|
522
|
+
if (shell === 'pwsh') {
|
|
523
|
+
const joined = `& ${/[\s'"\`$]/.test(cmd) ? `'${cmd.replace(/'/g, "''")}'` : cmd} ${versionArg}`;
|
|
524
|
+
exe = 'pwsh.exe';
|
|
525
|
+
args = ['-NoLogo', '-Command', joined];
|
|
526
|
+
} else if (shell === 'cmd') {
|
|
527
|
+
exe = process.env.ComSpec || 'cmd.exe';
|
|
528
|
+
args = ['/d', '/s', '/c', `${cmd} ${versionArg}`];
|
|
529
|
+
} else if (path.isAbsolute(cmd)) {
|
|
530
|
+
const ext = path.extname(cmd).toLowerCase();
|
|
531
|
+
if (ext === '.cmd' || ext === '.bat') {
|
|
532
|
+
exe = process.env.ComSpec || 'cmd.exe';
|
|
533
|
+
args = ['/d', '/s', '/c', cmd, versionArg];
|
|
534
|
+
} else if (ext === '.ps1') {
|
|
535
|
+
exe = 'powershell.exe';
|
|
536
|
+
args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', cmd, versionArg];
|
|
537
|
+
} else {
|
|
538
|
+
exe = cmd;
|
|
539
|
+
args = [versionArg];
|
|
540
|
+
}
|
|
541
|
+
} else {
|
|
542
|
+
exe = process.env.ComSpec || 'cmd.exe';
|
|
543
|
+
args = ['/d', '/s', '/c', cmd, versionArg];
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const t0 = Date.now();
|
|
547
|
+
let stdout = '';
|
|
548
|
+
let stderr = '';
|
|
549
|
+
let exitCode = null;
|
|
550
|
+
let timedOut = false;
|
|
551
|
+
let spawnError = null;
|
|
552
|
+
try {
|
|
553
|
+
const child = spawn(exe, args, { env: spawnEnv(), windowsHide: true });
|
|
554
|
+
const killer = setTimeout(() => { timedOut = true; try { child.kill(); } catch {} }, 5000);
|
|
555
|
+
child.stdout.on('data', (d) => { stdout += d.toString(); if (stdout.length > 8192) stdout = stdout.slice(0, 8192); });
|
|
556
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); if (stderr.length > 8192) stderr = stderr.slice(0, 8192); });
|
|
557
|
+
exitCode = await new Promise((resolve, reject) => {
|
|
558
|
+
child.on('exit', (code) => { clearTimeout(killer); resolve(code); });
|
|
559
|
+
child.on('error', (err) => { clearTimeout(killer); reject(err); });
|
|
560
|
+
});
|
|
561
|
+
} catch (e) {
|
|
562
|
+
spawnError = String(e && e.message || e);
|
|
563
|
+
}
|
|
564
|
+
const durationMs = Date.now() - t0;
|
|
565
|
+
|
|
566
|
+
const out = (stdout + '\n' + stderr).toLowerCase();
|
|
567
|
+
const PATTERNS = {
|
|
568
|
+
claude: /claude/,
|
|
569
|
+
codex: /codex|openai/,
|
|
570
|
+
copilot: /copilot/,
|
|
571
|
+
};
|
|
572
|
+
const matchedType = type === 'other' ? null : (PATTERNS[type] ? PATTERNS[type].test(out) : null);
|
|
573
|
+
const ok = !spawnError && !timedOut && exitCode === 0;
|
|
574
|
+
res.json({
|
|
575
|
+
ok, exitCode, durationMs, timedOut, spawnError,
|
|
576
|
+
stdout: stdout.trim(),
|
|
577
|
+
stderr: stderr.trim(),
|
|
578
|
+
matchedType,
|
|
579
|
+
expectedType: type,
|
|
580
|
+
spawned: { exe, args },
|
|
581
|
+
});
|
|
582
|
+
}));
|
|
583
|
+
|
|
584
|
+
// ---- folders ----
|
|
585
|
+
|
|
586
|
+
app.get('/api/folders', asyncH(async (_req, res) => {
|
|
587
|
+
const list = await folders.loadAll();
|
|
588
|
+
list.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
589
|
+
res.json({ folders: list });
|
|
590
|
+
}));
|
|
591
|
+
|
|
592
|
+
app.post('/api/folders', asyncH(async (req, res) => {
|
|
593
|
+
const name = req.body && req.body.name;
|
|
594
|
+
if (!name) return res.status(400).json({ error: 'name required' });
|
|
595
|
+
res.json({ folder: await folders.create({ name }) });
|
|
596
|
+
}));
|
|
597
|
+
|
|
598
|
+
app.put('/api/folders/:id', asyncH(async (req, res) => {
|
|
599
|
+
const updated = await folders.update(req.params.id, req.body || {});
|
|
600
|
+
if (!updated) return res.status(404).json({ error: 'not found' });
|
|
601
|
+
res.json({ folder: updated });
|
|
602
|
+
}));
|
|
603
|
+
|
|
604
|
+
app.delete('/api/folders/:id', asyncH(async (req, res) => {
|
|
605
|
+
// Move all sessions in this folder to Unsorted before delete.
|
|
606
|
+
const all = await persistedSessions.loadAll();
|
|
607
|
+
for (const s of all) {
|
|
608
|
+
if (s.folderId === req.params.id) {
|
|
609
|
+
await persistedSessions.setFolder(s.id, null);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
const removed = await folders.remove(req.params.id);
|
|
613
|
+
res.json({ removed });
|
|
614
|
+
}));
|
|
615
|
+
|
|
616
|
+
app.post('/api/folders/reorder', asyncH(async (req, res) => {
|
|
617
|
+
const ids = req.body && req.body.ids;
|
|
618
|
+
if (!Array.isArray(ids)) return res.status(400).json({ error: 'ids array required' });
|
|
619
|
+
const next = await folders.reorder(ids);
|
|
620
|
+
res.json({ folders: next });
|
|
621
|
+
}));
|
|
622
|
+
|
|
623
|
+
// ---- sessions (persisted, ccsm-owned) ----
|
|
624
|
+
|
|
625
|
+
app.get('/api/sessions', asyncH(async (_req, res) => {
|
|
626
|
+
const list = await persistedSessions.loadAll();
|
|
627
|
+
// Cross-check status against live PTY pool so a stale "running" record
|
|
628
|
+
// doesn't survive a server restart.
|
|
629
|
+
const live = new Set(webTerminal.list().filter((t) => !t.exitedAt).map((t) => t.id));
|
|
630
|
+
for (const s of list) {
|
|
631
|
+
if (s.status === 'running' && !live.has(s.id)) {
|
|
632
|
+
s.status = 'exited';
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
// Per-session activity probe (transcript mtime → working/idle). Cheap
|
|
636
|
+
// when cached — most calls are a single fs.stat(). Only runs for
|
|
637
|
+
// running sessions; exited ones get 'unknown'.
|
|
638
|
+
const cfg = await loadConfig();
|
|
639
|
+
const cliById = new Map((cfg.clis || []).map((c) => [c.id, c]));
|
|
640
|
+
const { probeActivity } = require('./lib/cliActivity');
|
|
641
|
+
await Promise.all(list.map(async (s) => {
|
|
642
|
+
if (s.status !== 'running') { s.activity = 'unknown'; return; }
|
|
643
|
+
try { s.activity = await probeActivity(s, cliById.get(s.cliId)); }
|
|
644
|
+
catch { s.activity = 'unknown'; }
|
|
645
|
+
}));
|
|
646
|
+
res.json({ sessions: list, takenAt: Date.now() });
|
|
647
|
+
}));
|
|
648
|
+
|
|
637
649
|
app.put('/api/sessions/:id', asyncH(async (req, res) => {
|
|
638
650
|
const patch = {};
|
|
639
651
|
if (typeof req.body.title === 'string') patch.title = req.body.title;
|
|
@@ -704,1184 +716,1185 @@ app.post('/api/sessions/:id/stop', asyncH(async (req, res) => {
|
|
|
704
716
|
app.delete('/api/sessions/:id', asyncH(async (req, res) => {
|
|
705
717
|
// Kill PTY first if it's still alive, then drop the record.
|
|
706
718
|
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
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
if (!
|
|
834
|
-
return res.status(400).json({ error: '
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
return res.status(400).json({ error: '
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
);
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
res.
|
|
860
|
-
|
|
861
|
-
res.
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
const
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
const
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
//
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
if (
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
//
|
|
908
|
-
|
|
909
|
-
const
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
//
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
//
|
|
944
|
-
//
|
|
945
|
-
// -
|
|
946
|
-
//
|
|
947
|
-
//
|
|
948
|
-
//
|
|
949
|
-
|
|
950
|
-
const
|
|
951
|
-
const
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
//
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
const
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
//
|
|
1006
|
-
//
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
const
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
const
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
//
|
|
1038
|
-
//
|
|
1039
|
-
//
|
|
1040
|
-
//
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
const
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
//
|
|
1052
|
-
//
|
|
1053
|
-
//
|
|
1054
|
-
//
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
const
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
//
|
|
1068
|
-
|
|
1069
|
-
const
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
//
|
|
1075
|
-
//
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
});
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
//
|
|
1098
|
-
//
|
|
1099
|
-
//
|
|
1100
|
-
//
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
const
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
//
|
|
1112
|
-
//
|
|
1113
|
-
//
|
|
1114
|
-
|
|
1115
|
-
const
|
|
1116
|
-
const
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
//
|
|
1135
|
-
//
|
|
1136
|
-
//
|
|
1137
|
-
//
|
|
1138
|
-
//
|
|
1139
|
-
//
|
|
1140
|
-
//
|
|
1141
|
-
//
|
|
1142
|
-
//
|
|
1143
|
-
//
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
const
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
return [
|
|
1156
|
-
|
|
1157
|
-
}
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
//
|
|
1161
|
-
// ['--resume', '
|
|
1162
|
-
//
|
|
1163
|
-
//
|
|
1164
|
-
//
|
|
1165
|
-
//
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
const
|
|
1169
|
-
|
|
1170
|
-
if (
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
let
|
|
1187
|
-
let
|
|
1188
|
-
let
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
});
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
})
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
//
|
|
1209
|
-
//
|
|
1210
|
-
//
|
|
1211
|
-
// the
|
|
1212
|
-
//
|
|
1213
|
-
//
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
}
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
})
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
//
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
//
|
|
1243
|
-
//
|
|
1244
|
-
//
|
|
1245
|
-
//
|
|
1246
|
-
//
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
}
|
|
1272
|
-
|
|
1273
|
-
//
|
|
1274
|
-
//
|
|
1275
|
-
//
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
}
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
})
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
})
|
|
1292
|
-
|
|
1293
|
-
//
|
|
1294
|
-
//
|
|
1295
|
-
//
|
|
1296
|
-
//
|
|
1297
|
-
//
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
})
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
//
|
|
1309
|
-
//
|
|
1310
|
-
//
|
|
1311
|
-
//
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
//
|
|
1317
|
-
//
|
|
1318
|
-
//
|
|
1319
|
-
//
|
|
1320
|
-
//
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
const
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
const
|
|
1334
|
-
const
|
|
1335
|
-
const
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
})
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
res.json(
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
res.json(
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
res.json(
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
res.json(
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
})
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
//
|
|
1368
|
-
//
|
|
1369
|
-
//
|
|
1370
|
-
//
|
|
1371
|
-
//
|
|
1372
|
-
//
|
|
1373
|
-
//
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
const
|
|
1389
|
-
const
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
//
|
|
1399
|
-
//
|
|
1400
|
-
//
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
})
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
//
|
|
1425
|
-
//
|
|
1426
|
-
//
|
|
1427
|
-
//
|
|
1428
|
-
//
|
|
1429
|
-
//
|
|
1430
|
-
//
|
|
1431
|
-
//
|
|
1432
|
-
// the
|
|
1433
|
-
//
|
|
1434
|
-
//
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
let
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
//
|
|
1442
|
-
//
|
|
1443
|
-
|
|
1444
|
-
const
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
}
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
const
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
if (x
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
const
|
|
1472
|
-
|
|
1473
|
-
//
|
|
1474
|
-
//
|
|
1475
|
-
//
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
}
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
const
|
|
1516
|
-
|
|
1517
|
-
//
|
|
1518
|
-
//
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
//
|
|
1524
|
-
//
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
//
|
|
1535
|
-
//
|
|
1536
|
-
|
|
1537
|
-
const
|
|
1538
|
-
const
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
//
|
|
1547
|
-
//
|
|
1548
|
-
//
|
|
1549
|
-
//
|
|
1550
|
-
//
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
//
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
})
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
server.once('
|
|
1593
|
-
|
|
1594
|
-
if (
|
|
1595
|
-
|
|
1596
|
-
else
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
}
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
1607
|
-
'C:\\Program Files\\
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
//
|
|
1621
|
-
//
|
|
1622
|
-
//
|
|
1623
|
-
//
|
|
1624
|
-
//
|
|
1625
|
-
//
|
|
1626
|
-
//
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
const
|
|
1633
|
-
const
|
|
1634
|
-
|
|
1635
|
-
path.join(startMenu, '
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
//
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
//
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
//
|
|
1658
|
-
//
|
|
1659
|
-
|
|
1660
|
-
const
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
$
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
const
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
//
|
|
1690
|
-
//
|
|
1691
|
-
//
|
|
1692
|
-
//
|
|
1693
|
-
// a
|
|
1694
|
-
//
|
|
1695
|
-
//
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
const
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
`--
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
`--
|
|
1726
|
-
|
|
1727
|
-
'--
|
|
1728
|
-
'--no-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
}
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
const
|
|
1748
|
-
const
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
//
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
//
|
|
1766
|
-
//
|
|
1767
|
-
//
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
//
|
|
1771
|
-
//
|
|
1772
|
-
//
|
|
1773
|
-
//
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
//
|
|
1778
|
-
//
|
|
1779
|
-
//
|
|
1780
|
-
//
|
|
1781
|
-
//
|
|
1782
|
-
//
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
tunnel.
|
|
1786
|
-
|
|
1787
|
-
.
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
//
|
|
1799
|
-
//
|
|
1800
|
-
//
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
const
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
const
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
console.log(`
|
|
1837
|
-
console.log(`
|
|
1838
|
-
console.log(`
|
|
1839
|
-
console.log(`
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
//
|
|
1843
|
-
//
|
|
1844
|
-
//
|
|
1845
|
-
//
|
|
1846
|
-
// app-mode
|
|
1847
|
-
//
|
|
1848
|
-
//
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
:
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
if (
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
}
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
719
|
+
const removed = await persistedSessions.remove(req.params.id);
|
|
720
|
+
try { require('./lib/cliActivity').releaseSession(req.params.id); } catch {}
|
|
721
|
+
res.json({ removed });
|
|
722
|
+
}));
|
|
723
|
+
|
|
724
|
+
// Open a session's working directory in the user's configured editor
|
|
725
|
+
// (config.editor, default `code` = VS Code, whose Source Control panel is
|
|
726
|
+
// also the review-changes view once the folder's open). Spawned detached
|
|
727
|
+
// so it outlives ccsm; shell:true so Windows resolves `code.cmd` via
|
|
728
|
+
// PATHEXT and a command like `code --reuse-window` parses, with the cwd
|
|
729
|
+
// quoted so paths with spaces survive the shell. spawnEnv() merges the
|
|
730
|
+
// user-scope PATH so `code`/`cursor` are found even when the inherited
|
|
731
|
+
// env lacks them.
|
|
732
|
+
app.post('/api/sessions/:id/open-editor', asyncH(async (req, res) => {
|
|
733
|
+
const record = await persistedSessions.get(req.params.id);
|
|
734
|
+
if (!record) return res.status(404).json({ error: 'session not found' });
|
|
735
|
+
const cfg = await loadConfig();
|
|
736
|
+
const editor = (cfg.editor || '').trim() || 'code';
|
|
737
|
+
const { spawn } = require('node:child_process');
|
|
738
|
+
try {
|
|
739
|
+
const child = spawn(editor, [`"${record.cwd}"`], {
|
|
740
|
+
detached: true, stdio: 'ignore', shell: true,
|
|
741
|
+
env: spawnEnv(), windowsHide: true,
|
|
742
|
+
});
|
|
743
|
+
// A bad editor command fails the shell async (after we've responded);
|
|
744
|
+
// log it so it's diagnosable, but the happy path needs no await.
|
|
745
|
+
child.on('error', (e) => console.warn(`[ccsm] open-editor "${editor}" failed:`, e.message));
|
|
746
|
+
child.unref();
|
|
747
|
+
res.json({ ok: true, editor, cwd: record.cwd });
|
|
748
|
+
} catch (e) {
|
|
749
|
+
res.status(500).json({ error: `failed to launch ${editor}: ${e.message}` });
|
|
750
|
+
}
|
|
751
|
+
}));
|
|
752
|
+
|
|
753
|
+
// Reorder sessions within a folder. Body: { folderId, ids } where ids
|
|
754
|
+
// is the new sequence of session ids in their final display order
|
|
755
|
+
// inside that folder. Each session gets `folderId` + `order: 0..N-1`
|
|
756
|
+
// assigned. Setting folderId here (rather than requiring a separate
|
|
757
|
+
// PUT) lets the drag-and-drop UI move a session across folders AND
|
|
758
|
+
// drop it at a specific position in one shot — without the call, the
|
|
759
|
+
// move would either land at the end of the destination folder (just
|
|
760
|
+
// PUT folderId) or leave it in place (just reorder).
|
|
761
|
+
app.post('/api/sessions/reorder', asyncH(async (req, res) => {
|
|
762
|
+
const ids = Array.isArray(req.body?.ids) ? req.body.ids : null;
|
|
763
|
+
if (!ids) return res.status(400).json({ error: 'ids array required' });
|
|
764
|
+
const folderId = req.body?.folderId ?? null;
|
|
765
|
+
for (let i = 0; i < ids.length; i++) {
|
|
766
|
+
try { await persistedSessions.update(ids[i], { folderId, order: i }); } catch {}
|
|
767
|
+
}
|
|
768
|
+
res.json({ ok: true, count: ids.length });
|
|
769
|
+
}));
|
|
770
|
+
|
|
771
|
+
// ---- workspaces ----
|
|
772
|
+
|
|
773
|
+
// ---- directory browser ----
|
|
774
|
+
// Lets the launch picker walk the filesystem so users can pick any
|
|
775
|
+
// existing directory as the session cwd. Returns the immediate child
|
|
776
|
+
// dirs of `path` (defaults to home), plus a few hardcoded "starts"
|
|
777
|
+
// (home, workDir, drive roots on Windows).
|
|
778
|
+
app.get('/api/browse', asyncH(async (req, res) => {
|
|
779
|
+
const fs = require('node:fs/promises');
|
|
780
|
+
const os = require('node:os');
|
|
781
|
+
const target = req.query.path ? path.resolve(String(req.query.path)) : os.homedir();
|
|
782
|
+
let entries = [];
|
|
783
|
+
let exists = true;
|
|
784
|
+
try {
|
|
785
|
+
const list = await fs.readdir(target, { withFileTypes: true });
|
|
786
|
+
entries = list
|
|
787
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith('.'))
|
|
788
|
+
.map((d) => ({ name: d.name, path: path.join(target, d.name) }))
|
|
789
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
790
|
+
} catch (e) {
|
|
791
|
+
exists = false;
|
|
792
|
+
}
|
|
793
|
+
const parent = path.dirname(target);
|
|
794
|
+
const cfg = await loadConfig();
|
|
795
|
+
const starts = [
|
|
796
|
+
{ label: 'Home', path: os.homedir() },
|
|
797
|
+
{ label: 'Work dir', path: cfg.workDir },
|
|
798
|
+
];
|
|
799
|
+
if (process.platform === 'win32') {
|
|
800
|
+
// Best-effort drive enumeration so users on D:\ etc can hop roots.
|
|
801
|
+
for (const letter of ['C', 'D', 'E', 'F', 'G', 'H']) {
|
|
802
|
+
const root = `${letter}:\\`;
|
|
803
|
+
try { await fs.access(root); starts.push({ label: `${letter}:\\`, path: root }); }
|
|
804
|
+
catch {}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
res.json({
|
|
808
|
+
path: target,
|
|
809
|
+
parent: parent === target ? null : parent,
|
|
810
|
+
exists,
|
|
811
|
+
entries,
|
|
812
|
+
starts,
|
|
813
|
+
});
|
|
814
|
+
}));
|
|
815
|
+
|
|
816
|
+
app.get('/api/workspaces', asyncH(async (req, res) => {
|
|
817
|
+
const cfg = await loadConfig();
|
|
818
|
+
const allSess = await persistedSessions.loadAll();
|
|
819
|
+
const occupying = workspaceOccupancySessions(allSess, cfg);
|
|
820
|
+
const busyPaths = occupying.map((s) => s.cwd);
|
|
821
|
+
const workspaces = await listWorkspaces({
|
|
822
|
+
workDir: cfg.workDir,
|
|
823
|
+
repos: cfg.repos,
|
|
824
|
+
busyPaths,
|
|
825
|
+
});
|
|
826
|
+
for (const w of workspaces) {
|
|
827
|
+
w.sessionsHere = occupying.filter((s) => isInside(s.cwd, w.path)).map((s) => s.id);
|
|
828
|
+
w.inUse = w.sessionsHere.length > 0;
|
|
829
|
+
}
|
|
830
|
+
res.json({ workDir: cfg.workDir, repos: cfg.repos, workspaces });
|
|
831
|
+
}));
|
|
832
|
+
|
|
833
|
+
// Delete a workspace directory. Refuses if a session currently reserves
|
|
834
|
+
// it, or if the resolved path escapes workDir. The name comes from the
|
|
835
|
+
// URL — we resolve it against workDir and verify containment.
|
|
836
|
+
app.delete('/api/workspaces/:name', asyncH(async (req, res) => {
|
|
837
|
+
const fsp = require('node:fs/promises');
|
|
838
|
+
const cfg = await loadConfig();
|
|
839
|
+
const name = String(req.params.name || '');
|
|
840
|
+
// Reject anything that tries to escape via separators / traversal.
|
|
841
|
+
if (!name || /[\\/]|^\.\.$|^\.$/.test(name)) {
|
|
842
|
+
return res.status(400).json({ error: 'invalid workspace name' });
|
|
843
|
+
}
|
|
844
|
+
const target = path.resolve(cfg.workDir, name);
|
|
845
|
+
if (!isInside(target, cfg.workDir) || path.resolve(target) === path.resolve(cfg.workDir)) {
|
|
846
|
+
return res.status(400).json({ error: 'workspace must live under workDir' });
|
|
847
|
+
}
|
|
848
|
+
try {
|
|
849
|
+
const st = await fsp.stat(target);
|
|
850
|
+
if (!st.isDirectory()) return res.status(400).json({ error: 'not a directory' });
|
|
851
|
+
} catch {
|
|
852
|
+
return res.status(404).json({ error: 'workspace not found' });
|
|
853
|
+
}
|
|
854
|
+
const allSess = await persistedSessions.loadAll();
|
|
855
|
+
const occupying = workspaceOccupancySessions(allSess, cfg);
|
|
856
|
+
const inUse = occupying.some((s) => isInside(s.cwd, target));
|
|
857
|
+
if (inUse) {
|
|
858
|
+
return res.status(409).json({ error: `workspace is in use by a ${workspaceOccupancyLabel(cfg)}` });
|
|
859
|
+
}
|
|
860
|
+
await fsp.rm(target, { recursive: true, force: true });
|
|
861
|
+
res.json({ ok: true });
|
|
862
|
+
}));
|
|
863
|
+
|
|
864
|
+
// ---- new session ----
|
|
865
|
+
// body: { cliId?, repos?, workspace?, folderId?, launch?: true }
|
|
866
|
+
// Streams NDJSON: workspace / clone-* / launched / done.
|
|
867
|
+
app.post('/api/sessions/new', async (req, res) => {
|
|
868
|
+
res.setHeader('Content-Type', 'application/x-ndjson');
|
|
869
|
+
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
|
870
|
+
res.setHeader('X-Accel-Buffering', 'no');
|
|
871
|
+
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
|
872
|
+
|
|
873
|
+
const emit = (obj) => { res.write(JSON.stringify(obj) + '\n'); };
|
|
874
|
+
const fail = (msg, extra) => {
|
|
875
|
+
emit({ type: 'done', success: false, error: msg, ...extra });
|
|
876
|
+
res.end();
|
|
877
|
+
};
|
|
878
|
+
|
|
879
|
+
try {
|
|
880
|
+
const cfg = await loadConfig();
|
|
881
|
+
const cli = pickCli(cfg, req.body && req.body.cliId);
|
|
882
|
+
if (!cli) return fail('No CLI configured. Add one in Configure → CLIs.');
|
|
883
|
+
|
|
884
|
+
const explicitRepos = Array.isArray(req.body && req.body.repos);
|
|
885
|
+
const wantedNames = explicitRepos
|
|
886
|
+
? req.body.repos
|
|
887
|
+
: cfg.repos.filter((r) => r.defaultSelected).map((r) => r.name);
|
|
888
|
+
const wantedRepos = cfg.repos.filter((r) => wantedNames.includes(r.name));
|
|
889
|
+
if (wantedRepos.length === 0 && !explicitRepos && wantedNames.length > 0) {
|
|
890
|
+
return fail('No matching repos found');
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
let workspace;
|
|
894
|
+
let created = false;
|
|
895
|
+
// Three cwd modes:
|
|
896
|
+
// 1. body.cwd — user picked an existing directory; skip clone.
|
|
897
|
+
// 2. body.workspace — reuse a named workspace under workDir.
|
|
898
|
+
// 3. (neither) — auto-allocate a fresh ws-N.
|
|
899
|
+
if (req.body && req.body.cwd) {
|
|
900
|
+
const fsmod = require('node:fs/promises');
|
|
901
|
+
const cwd = path.resolve(String(req.body.cwd));
|
|
902
|
+
try {
|
|
903
|
+
const st = await fsmod.stat(cwd);
|
|
904
|
+
if (!st.isDirectory()) return fail(`${cwd} is not a directory`);
|
|
905
|
+
} catch {
|
|
906
|
+
return fail(`directory not found: ${cwd}`);
|
|
907
|
+
}
|
|
908
|
+
workspace = { name: path.basename(cwd) || cwd, path: cwd };
|
|
909
|
+
} else if (req.body && req.body.workspace) {
|
|
910
|
+
const allSess = await persistedSessions.loadAll();
|
|
911
|
+
const busyPaths = workspaceOccupancySessions(allSess, cfg).map((s) => s.cwd);
|
|
912
|
+
const all = await listWorkspaces({ workDir: cfg.workDir, repos: cfg.repos, busyPaths });
|
|
913
|
+
workspace = all.find((w) => w.name === req.body.workspace);
|
|
914
|
+
if (!workspace) return fail(`workspace ${req.body.workspace} not found`);
|
|
915
|
+
if (workspace.inUse) {
|
|
916
|
+
return fail(`workspace ${req.body.workspace} is already used by a ${workspaceOccupancyLabel(cfg)}`);
|
|
917
|
+
}
|
|
918
|
+
} else {
|
|
919
|
+
// Collect cwds of sessions that currently reserve workspaces so
|
|
920
|
+
// findOrCreateWorkspace can flag them as in-use and skip past them.
|
|
921
|
+
const allSess = await persistedSessions.loadAll();
|
|
922
|
+
const busyPaths = workspaceOccupancySessions(allSess, cfg).map((s) => s.cwd);
|
|
923
|
+
const r = await findOrCreateWorkspace({
|
|
924
|
+
workDir: cfg.workDir,
|
|
925
|
+
repos: cfg.repos,
|
|
926
|
+
busyPaths,
|
|
927
|
+
requireUnused: true,
|
|
928
|
+
});
|
|
929
|
+
workspace = r.workspace;
|
|
930
|
+
created = r.created;
|
|
931
|
+
}
|
|
932
|
+
emit({ type: 'workspace', workspace, created });
|
|
933
|
+
|
|
934
|
+
// Skip clone entirely when user picked an existing directory — we
|
|
935
|
+
// don't want to dump random repos into someone's project.
|
|
936
|
+
const cloneResults = (req.body && req.body.cwd) ? [] : await ensureReposInWorkspace({
|
|
937
|
+
workspacePath: workspace.path,
|
|
938
|
+
repos: wantedRepos,
|
|
939
|
+
onRepoStart: (repo) =>
|
|
940
|
+
emit({ type: 'clone-start', repo: repo.name, url: repo.url }),
|
|
941
|
+
onProgress: (repo, p) =>
|
|
942
|
+
emit({ type: 'clone-progress', repo: repo.name, ...p }),
|
|
943
|
+
onLine: (repo, line) =>
|
|
944
|
+
emit({ type: 'clone-line', repo: repo.name, line }),
|
|
945
|
+
onRepoEnd: (repo, result) =>
|
|
946
|
+
emit({ type: 'clone-end', repo: repo.name, ...result }),
|
|
947
|
+
});
|
|
948
|
+
const failed = cloneResults.filter((r) => !r.ok);
|
|
949
|
+
if (failed.length > 0) return fail('Some repos failed to clone', { cloneResults });
|
|
950
|
+
|
|
951
|
+
const shouldLaunch = req.body && req.body.launch !== false;
|
|
952
|
+
let launched = null;
|
|
953
|
+
if (shouldLaunch) {
|
|
954
|
+
// Pre-assign the upstream CLI session UUID so we never have to
|
|
955
|
+
// poll/scan the transcript dir to find out what id the CLI picked.
|
|
956
|
+
// - claude / copilot expose `--session-id <uuid>` natively.
|
|
957
|
+
// - codex has no flag, but accepts `resume <uuid>` against a
|
|
958
|
+
// pre-existing rollout file. We seed a fake file (see
|
|
959
|
+
// lib/codexSeed.js) so the first launch is a resume against
|
|
960
|
+
// our seed; codex then appends to the same file.
|
|
961
|
+
const newIdTpl = Array.isArray(cli.newSessionIdArgs) ? cli.newSessionIdArgs : [];
|
|
962
|
+
const preAssignedId = newIdTpl.length > 0 ? crypto.randomUUID() : null;
|
|
963
|
+
const newSessionArgs = preAssignedId
|
|
964
|
+
? newIdTpl.map((a) => (typeof a === 'string' ? a.replace(/<id>/g, preAssignedId) : a))
|
|
965
|
+
: [];
|
|
966
|
+
|
|
967
|
+
if (preAssignedId && cli.type === 'codex') {
|
|
968
|
+
try {
|
|
969
|
+
const { seedCodexSession } = require('./lib/codexSeed');
|
|
970
|
+
await seedCodexSession({ id: preAssignedId, cwd: workspace.path, cli });
|
|
971
|
+
} catch (e) {
|
|
972
|
+
return fail(`codex seed failed: ${e.message}`);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// Create the persistedSessions record FIRST so spawnCliSession can
|
|
977
|
+
// use its id as the PTY id (matching ids simplify resume/attach).
|
|
978
|
+
const record = await persistedSessions.create({
|
|
979
|
+
cliId: cli.id,
|
|
980
|
+
cwd: workspace.path,
|
|
981
|
+
workspace: workspace.name,
|
|
982
|
+
repos: wantedRepos.map((r) => r.name),
|
|
983
|
+
folderId: (req.body && req.body.folderId) || null,
|
|
984
|
+
title: '',
|
|
985
|
+
cliSessionId: preAssignedId || undefined,
|
|
986
|
+
});
|
|
987
|
+
try {
|
|
988
|
+
const themeArgs = await codexThemeArgs(cli, req.body && req.body.theme);
|
|
989
|
+
const entry = spawnCliSession({
|
|
990
|
+
cli,
|
|
991
|
+
cwd: workspace.path,
|
|
992
|
+
sessionId: record.id,
|
|
993
|
+
meta: { title: workspace.name, workspace: workspace.name, cwd: workspace.path },
|
|
994
|
+
extraArgs: [...themeArgs, ...newSessionArgs],
|
|
995
|
+
theme: req.body && req.body.theme,
|
|
996
|
+
cols: req.body && req.body.cols,
|
|
997
|
+
rows: req.body && req.body.rows,
|
|
998
|
+
});
|
|
999
|
+
await persistedSessions.markRunning(record.id, entry.meta.pid);
|
|
1000
|
+
launched = { id: record.id, pid: entry.meta.pid, cliId: cli.id };
|
|
1001
|
+
emit({ type: 'launched', launched });
|
|
1002
|
+
} catch (e) {
|
|
1003
|
+
await persistedSessions.markExited(record.id, null);
|
|
1004
|
+
return fail(`spawn failed: ${e.message}`);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
emit({ type: 'done', success: true, workspace, created, cloneResults, launched });
|
|
1009
|
+
res.end();
|
|
1010
|
+
} catch (e) {
|
|
1011
|
+
console.error('[/api/sessions/new]', e);
|
|
1012
|
+
fail(String(e && e.message || e));
|
|
1013
|
+
}
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
// ---- list local CLI sessions discovered on disk (for "adopt") ----
|
|
1017
|
+
// Returns sessions found in ~/.claude / ~/.codex / ~/.copilot that
|
|
1018
|
+
// aren't yet adopted by ccsm. Frontend uses this in the Import modal.
|
|
1019
|
+
app.get('/api/cli-sessions/:cliType', asyncH(async (req, res) => {
|
|
1020
|
+
const type = String(req.params.cliType || '').toLowerCase();
|
|
1021
|
+
if (!['claude', 'codex', 'copilot'].includes(type)) {
|
|
1022
|
+
return res.status(400).json({ error: `unsupported cli type: ${type}` });
|
|
1023
|
+
}
|
|
1024
|
+
const offset = Math.max(0, Number(req.query.offset) || 0);
|
|
1025
|
+
const limit = Math.min(200, Math.max(1, Number(req.query.limit) || 30));
|
|
1026
|
+
|
|
1027
|
+
const [page, adopted] = await Promise.all([
|
|
1028
|
+
localCliSessions.listPaginated(type, { offset, limit }),
|
|
1029
|
+
persistedSessions.loadAll(),
|
|
1030
|
+
]);
|
|
1031
|
+
|
|
1032
|
+
const adoptedIds = new Set(adopted.map((s) => s.cliSessionId).filter(Boolean));
|
|
1033
|
+
const sessions = page.sessions.map((s) => ({
|
|
1034
|
+
...s,
|
|
1035
|
+
adopted: adoptedIds.has(s.cliSessionId),
|
|
1036
|
+
}));
|
|
1037
|
+
res.json({
|
|
1038
|
+
sessions,
|
|
1039
|
+
totalActive: page.totalActive,
|
|
1040
|
+
totalNonActive: page.totalNonActive,
|
|
1041
|
+
total: page.totalActive + page.totalNonActive,
|
|
1042
|
+
offset: page.offset,
|
|
1043
|
+
limit: page.limit,
|
|
1044
|
+
hasMore: page.hasMore,
|
|
1045
|
+
});
|
|
1046
|
+
}));
|
|
1047
|
+
|
|
1048
|
+
// ---- adopt: create a ccsm record pointing at an existing CLI session ----
|
|
1049
|
+
// Body: { cliId, cliSessionId, cwd, title?, folderId? }
|
|
1050
|
+
// Doesn't spawn — the new entry shows up as "exited" in the sidebar;
|
|
1051
|
+
// clicking it kicks off the regular resume flow which uses
|
|
1052
|
+
// `cli.resumeIdArgs` ('--resume <id>') so the upstream session reattaches.
|
|
1053
|
+
app.post('/api/sessions/adopt', asyncH(async (req, res) => {
|
|
1054
|
+
const { cliId, cliSessionId, cwd, title, folderId } = req.body || {};
|
|
1055
|
+
if (!cliId || !cliSessionId || !cwd) {
|
|
1056
|
+
return res.status(400).json({ error: 'cliId, cliSessionId and cwd required' });
|
|
1057
|
+
}
|
|
1058
|
+
const cfg = await loadConfig();
|
|
1059
|
+
const cli = pickCli(cfg, cliId);
|
|
1060
|
+
if (!cli) return res.status(400).json({ error: `CLI ${cliId} not configured` });
|
|
1061
|
+
|
|
1062
|
+
// Normalize the cwd up front. /api/sessions/new also resolves cwd, and
|
|
1063
|
+
// the workspaces "in use" check (GET /api/workspaces) does
|
|
1064
|
+
// path.resolve(s.cwd).toLowerCase() — adopted records must match the
|
|
1065
|
+
// same shape, otherwise an adopted+running session leaves its
|
|
1066
|
+
// workspace falsely marked as free and a fresh launch could collide.
|
|
1067
|
+
const resolvedCwd = path.resolve(cwd);
|
|
1068
|
+
try {
|
|
1069
|
+
const fsmod = require('node:fs/promises');
|
|
1070
|
+
const st = await fsmod.stat(resolvedCwd);
|
|
1071
|
+
if (!st.isDirectory()) {
|
|
1072
|
+
return res.status(400).json({ error: `cwd is not a directory: ${resolvedCwd}` });
|
|
1073
|
+
}
|
|
1074
|
+
} catch (e) {
|
|
1075
|
+
return res.status(400).json({ error: `cwd not found: ${resolvedCwd}` });
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
// Refuse duplicates: if any ccsm record already owns this upstream
|
|
1079
|
+
// session id, return it so the caller can jump to it.
|
|
1080
|
+
const all = await persistedSessions.loadAll();
|
|
1081
|
+
const dup = all.find((s) => s.cliSessionId === cliSessionId);
|
|
1082
|
+
if (dup) return res.json({ session: dup, alreadyAdopted: true });
|
|
1083
|
+
|
|
1084
|
+
const workspace = path.basename(resolvedCwd) || resolvedCwd;
|
|
1085
|
+
// Create directly with status='exited' + cliSessionId set, so a
|
|
1086
|
+
// concurrent GET /api/sessions can never observe a "running but no
|
|
1087
|
+
// PTY" intermediate state.
|
|
1088
|
+
const record = await persistedSessions.create({
|
|
1089
|
+
cliId,
|
|
1090
|
+
cwd: resolvedCwd,
|
|
1091
|
+
workspace,
|
|
1092
|
+
folderId: folderId || null,
|
|
1093
|
+
title: title || '',
|
|
1094
|
+
repos: [],
|
|
1095
|
+
status: 'exited',
|
|
1096
|
+
cliSessionId,
|
|
1097
|
+
});
|
|
1098
|
+
res.json({ session: record, alreadyAdopted: false });
|
|
1099
|
+
}));
|
|
1100
|
+
|
|
1101
|
+
// ---- resume a previous session in the same cwd / cli ----
|
|
1102
|
+
app.post('/api/sessions/:id/resume', asyncH(async (req, res) => {
|
|
1103
|
+
const record = await persistedSessions.get(req.params.id);
|
|
1104
|
+
if (!record) return res.status(404).json({ error: 'session not found' });
|
|
1105
|
+
// Already running and attached → no-op, just return its id.
|
|
1106
|
+
const live = webTerminal.get(record.id);
|
|
1107
|
+
if (live && !live.exitedAt) {
|
|
1108
|
+
// Pool says we're alive but the record may be stale (e.g. a prior
|
|
1109
|
+
// markRunning got clobbered by an OLD entry's onExit before the
|
|
1110
|
+
// respawn-guard landed, or boot mark-exited ran after a pool entry
|
|
1111
|
+
// was already wired). Reconcile the file to match the pool so the
|
|
1112
|
+
// frontend doesn't get stuck on "Resuming session…" forever.
|
|
1113
|
+
if (record.status !== 'running' || record.pid !== live.meta.pid) {
|
|
1114
|
+
try { await persistedSessions.markRunning(record.id, live.meta.pid); } catch {}
|
|
1115
|
+
}
|
|
1116
|
+
return res.json({ launched: { id: record.id, pid: live.meta.pid, cliId: record.cliId } });
|
|
1117
|
+
}
|
|
1118
|
+
const cfg = await loadConfig();
|
|
1119
|
+
const cli = pickCli(cfg, record.cliId);
|
|
1120
|
+
if (!cli) return res.status(400).json({ error: `CLI ${record.cliId} no longer configured` });
|
|
1121
|
+
try {
|
|
1122
|
+
// Resume always uses the captured upstream session UUID. With the
|
|
1123
|
+
// pre-assignment refactor every ccsm-launched session has one (via
|
|
1124
|
+
// newSessionIdArgs flag or the codex seed trick), and adopted
|
|
1125
|
+
// sessions inherit theirs from the disk scan.
|
|
1126
|
+
const themeArgs = await codexThemeArgs(cli, req.body && req.body.theme);
|
|
1127
|
+
const extraArgs = buildResumeArgs(cli, record);
|
|
1128
|
+
const entry = spawnCliSession({
|
|
1129
|
+
cli,
|
|
1130
|
+
cwd: record.cwd,
|
|
1131
|
+
sessionId: record.id,
|
|
1132
|
+
meta: { title: record.title || record.workspace, workspace: record.workspace, cwd: record.cwd },
|
|
1133
|
+
extraArgs: [...themeArgs, ...extraArgs],
|
|
1134
|
+
theme: req.body && req.body.theme,
|
|
1135
|
+
cols: req.body && req.body.cols,
|
|
1136
|
+
rows: req.body && req.body.rows,
|
|
1137
|
+
});
|
|
1138
|
+
await persistedSessions.markRunning(record.id, entry.meta.pid);
|
|
1139
|
+
res.json({ launched: { id: record.id, pid: entry.meta.pid, cliId: cli.id } });
|
|
1140
|
+
} catch (e) {
|
|
1141
|
+
res.status(500).json({ error: e.message });
|
|
1142
|
+
}
|
|
1143
|
+
}));
|
|
1144
|
+
|
|
1145
|
+
// codex-only: when the ccsm terminal is in LIGHT mode, inject a session-scoped
|
|
1146
|
+
// `-c tui.theme=ccsm-light`. codex's diff theme detection (default_bg()) is
|
|
1147
|
+
// compiled out on Windows and always falls back to a DARK diff palette, which
|
|
1148
|
+
// reads poorly on a white terminal — and it ignores COLORFGBG/OSC. The only
|
|
1149
|
+
// lever is a syntax theme whose markup.inserted/deleted scopes carry light
|
|
1150
|
+
// backgrounds (they override the diff palette at true-color level). We ship
|
|
1151
|
+
// that theme (ccsm-light.tmTheme), copy it into the codex home, and point
|
|
1152
|
+
// tui.theme at it. Returns the args to prepend (before `resume <id>` so the
|
|
1153
|
+
// global -c lands before the subcommand), or [] when not applicable. Skipped
|
|
1154
|
+
// in dark mode (codex's dark default is already correct on a dark terminal)
|
|
1155
|
+
// and when the user configured their own tui.theme in cli.args.
|
|
1156
|
+
async function codexThemeArgs(cli, theme) {
|
|
1157
|
+
if (!cli || cli.type !== 'codex' || theme !== 'light') return [];
|
|
1158
|
+
const args = cli.args || [];
|
|
1159
|
+
const userSet = args.some((a, i) =>
|
|
1160
|
+
String(a).includes('tui.theme') || (a === '-c' && String(args[i + 1] || '').includes('tui.theme')));
|
|
1161
|
+
if (userSet) return [];
|
|
1162
|
+
try {
|
|
1163
|
+
const { probeCodexHome, ensureCodexLightTheme } = require('./lib/codexSeed');
|
|
1164
|
+
let home = null;
|
|
1165
|
+
try { home = await probeCodexHome({ command: cli.command, shell: cli.shell }); } catch {}
|
|
1166
|
+
home = home || process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
1167
|
+
if (!(await ensureCodexLightTheme(home))) return [];
|
|
1168
|
+
return ['-c', 'tui.theme="ccsm-light"'];
|
|
1169
|
+
} catch { return []; }
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// Build the args appended on resume: substitute the captured upstream
|
|
1173
|
+
// session UUID into cli.resumeIdArgs (e.g. ['--resume', '<id>'] →
|
|
1174
|
+
// ['--resume', '7c28...']). Throws if either piece is missing — by
|
|
1175
|
+
// design every ccsm session has a pre-assigned id, so missing one means
|
|
1176
|
+
// something upstream is misconfigured (adopt without id, user-added CLI
|
|
1177
|
+
// without resumeIdArgs, etc.) and we surface that instead of silently
|
|
1178
|
+
// re-launching without the id.
|
|
1179
|
+
function buildResumeArgs(cli, record) {
|
|
1180
|
+
const id = record.cliSessionId;
|
|
1181
|
+
const tpl = Array.isArray(cli.resumeIdArgs) ? cli.resumeIdArgs : [];
|
|
1182
|
+
if (!id) throw new Error(`session ${record.id} has no cliSessionId — cannot resume`);
|
|
1183
|
+
if (tpl.length === 0) throw new Error(`CLI ${cli.id} has no resumeIdArgs configured`);
|
|
1184
|
+
return tpl.map((a) => (typeof a === 'string' ? a.replace(/<id>/g, id) : a));
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// ---- capabilities probe ----
|
|
1188
|
+
app.get('/api/capabilities', (_req, res) => res.json({
|
|
1189
|
+
webTerminal: webTerminal.available,
|
|
1190
|
+
webTerminalError: webTerminal.available ? null : String(webTerminal.loadError?.message || 'unavailable'),
|
|
1191
|
+
}));
|
|
1192
|
+
|
|
1193
|
+
// ---- health ----
|
|
1194
|
+
const pkg = require('./package.json');
|
|
1195
|
+
app.get('/api/health', (_req, res) => res.json({ ok: true, pid: process.pid, version: pkg.version, name: pkg.name }));
|
|
1196
|
+
|
|
1197
|
+
// ---- lifecycle ----
|
|
1198
|
+
let currentPort = 0;
|
|
1199
|
+
let frontendUrl = '';
|
|
1200
|
+
let lastHeartbeat = Date.now();
|
|
1201
|
+
let heartbeatSeen = false;
|
|
1202
|
+
const HEARTBEAT_TIMEOUT_MS = 90_000;
|
|
1203
|
+
|
|
1204
|
+
app.post('/api/heartbeat', (_req, res) => {
|
|
1205
|
+
lastHeartbeat = Date.now();
|
|
1206
|
+
heartbeatSeen = true;
|
|
1207
|
+
res.json({ ok: true });
|
|
1208
|
+
});
|
|
1209
|
+
|
|
1210
|
+
app.post('/api/spawn-browser', asyncH(async (_req, res) => {
|
|
1211
|
+
const opened = openInBrowser(frontendUrl || `http://localhost:${currentPort}`);
|
|
1212
|
+
res.json({ ok: true, mode: opened.kind, url: frontendUrl });
|
|
1213
|
+
}));
|
|
1214
|
+
|
|
1215
|
+
app.post('/api/shutdown', (_req, res) => {
|
|
1216
|
+
res.json({ ok: true, bye: 'shutting down' });
|
|
1217
|
+
setImmediate(() => gracefulShutdown('/api/shutdown'));
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
// ---- remote / tunnel ----
|
|
1221
|
+
//
|
|
1222
|
+
// Lifecycle: the Remote page POSTs /start with { provider, token } —
|
|
1223
|
+
// we save the token (used by the middleware above for auth) and spawn
|
|
1224
|
+
// the chosen tunnel CLI. URL appears asynchronously in the CLI's
|
|
1225
|
+
// stdout; lib/tunnel parses it. /status returns the latest snapshot
|
|
1226
|
+
// for the page to poll.
|
|
1227
|
+
app.get('/api/tunnel/status', asyncH(async (_req, res) => {
|
|
1228
|
+
res.json(await tunnel.status());
|
|
1229
|
+
}));
|
|
1230
|
+
app.post('/api/tunnel/start', asyncH(async (req, res) => {
|
|
1231
|
+
const { provider, token } = req.body || {};
|
|
1232
|
+
if (!token || String(token).length < 8) {
|
|
1233
|
+
return res.status(400).json({ error: 'token required (≥ 8 chars)' });
|
|
1234
|
+
}
|
|
1235
|
+
tunnel.setToken(token);
|
|
1236
|
+
try {
|
|
1237
|
+
const result = await tunnel.start({ provider, port: currentPort });
|
|
1238
|
+
res.json(result);
|
|
1239
|
+
} catch (e) {
|
|
1240
|
+
res.status(400).json({ error: e.message, providers: await tunnel.probe().catch(() => ({})) });
|
|
1241
|
+
}
|
|
1242
|
+
}));
|
|
1243
|
+
app.post('/api/tunnel/stop', asyncH(async (_req, res) => {
|
|
1244
|
+
const stopped = tunnel.stop();
|
|
1245
|
+
res.json({ stopped, ...(await tunnel.status()) });
|
|
1246
|
+
}));
|
|
1247
|
+
app.post('/api/tunnel/token', asyncH(async (req, res) => {
|
|
1248
|
+
// Bare token update without touching the running tunnel.
|
|
1249
|
+
// POST { token: '' } to clear and disable remote auth.
|
|
1250
|
+
const t = (req.body && req.body.token) || '';
|
|
1251
|
+
tunnel.setToken(t);
|
|
1252
|
+
res.json(await tunnel.status());
|
|
1253
|
+
}));
|
|
1254
|
+
// Persist auto-start prefs. When ON, the backend brings this tunnel up
|
|
1255
|
+
// during its own startup (the boot hook in the listen IIFE below) using
|
|
1256
|
+
// the persisted token, so share URLs survive a backend restart. The
|
|
1257
|
+
// token is written to config ONLY while auto-start is on; turning it off
|
|
1258
|
+
// wipes the persisted token from disk. setToken keeps the in-memory copy
|
|
1259
|
+
// in lockstep so the share URL the page renders stays valid immediately.
|
|
1260
|
+
app.post('/api/tunnel/autostart', asyncH(async (req, res) => {
|
|
1261
|
+
const { autoStart, provider, token } = req.body || {};
|
|
1262
|
+
if (autoStart) {
|
|
1263
|
+
if (!token || String(token).length < 8) {
|
|
1264
|
+
return res.status(400).json({ error: 'token required (≥ 8 chars)' });
|
|
1265
|
+
}
|
|
1266
|
+
if (!['devtunnel', 'cloudflared'].includes(provider)) {
|
|
1267
|
+
return res.status(400).json({ error: 'valid provider required' });
|
|
1268
|
+
}
|
|
1269
|
+
tunnel.setToken(token);
|
|
1270
|
+
await saveConfig({ tunnel: { autoStart: true, provider, token } });
|
|
1271
|
+
} else {
|
|
1272
|
+
await saveConfig({ tunnel: { autoStart: false, provider: null, token: null } });
|
|
1273
|
+
}
|
|
1274
|
+
res.json(await tunnel.status());
|
|
1275
|
+
}));
|
|
1276
|
+
app.post('/api/tunnel/install', asyncH(async (req, res) => {
|
|
1277
|
+
const { provider } = req.body || {};
|
|
1278
|
+
try {
|
|
1279
|
+
const r = tunnel.installViaWinget(provider);
|
|
1280
|
+
res.json({ ok: true, ...r });
|
|
1281
|
+
} catch (e) {
|
|
1282
|
+
res.status(400).json({ error: e.message });
|
|
1283
|
+
}
|
|
1284
|
+
}));
|
|
1285
|
+
// Interactive `devtunnel user login -d` driver. The Remote page POSTs
|
|
1286
|
+
// here to start a device-code flow, then polls /api/tunnel/status to
|
|
1287
|
+
// learn the URL+code it should display and the eventual outcome —
|
|
1288
|
+
// avoids the older "copy this command into a shell" UX.
|
|
1289
|
+
app.post('/api/tunnel/devtunnel/login', asyncH(async (req, res) => {
|
|
1290
|
+
const { mode } = req.body || {};
|
|
1291
|
+
try {
|
|
1292
|
+
const snap = await tunnel.startDevtunnelLogin({ mode });
|
|
1293
|
+
res.json({ ok: true, login: snap });
|
|
1294
|
+
} catch (e) {
|
|
1295
|
+
res.status(400).json({ error: e.message });
|
|
1296
|
+
}
|
|
1297
|
+
}));
|
|
1298
|
+
app.post('/api/tunnel/devtunnel/login/cancel', asyncH(async (_req, res) => {
|
|
1299
|
+
res.json({ ok: true, login: tunnel.cancelDevtunnelLogin() });
|
|
1300
|
+
}));
|
|
1301
|
+
app.post('/api/tunnel/devtunnel/login/dismiss', asyncH(async (_req, res) => {
|
|
1302
|
+
tunnel.clearDevtunnelLogin();
|
|
1303
|
+
res.json({ ok: true });
|
|
1304
|
+
}));
|
|
1305
|
+
// Wipe the persisted devtunnel tunnel id (and the remote tunnel
|
|
1306
|
+
// resource itself, best-effort) so the next /api/tunnel/start mints
|
|
1307
|
+
// a fresh one. Used by the Reset button in the Remote page when the
|
|
1308
|
+
// user wants to rotate the public URL. Tunnel must be stopped first
|
|
1309
|
+
// — refuse otherwise so we don't yank state out from under a live
|
|
1310
|
+
// `devtunnel host` child.
|
|
1311
|
+
app.post('/api/tunnel/devtunnel/reset', asyncH(async (_req, res) => {
|
|
1312
|
+
const s = await tunnel.status();
|
|
1313
|
+
if (s.running && s.provider === 'devtunnel') {
|
|
1314
|
+
return res.status(409).json({ error: 'stop the tunnel before resetting its id' });
|
|
1315
|
+
}
|
|
1316
|
+
const r = await tunnel.resetDevtunnelTunnelId();
|
|
1317
|
+
res.json({ ok: true, ...r, ...(await tunnel.status()) });
|
|
1318
|
+
}));
|
|
1319
|
+
|
|
1320
|
+
// ---- devices ----
|
|
1321
|
+
//
|
|
1322
|
+
// /api/devices/me is callable from the remote browser BEFORE approval —
|
|
1323
|
+
// it's how the PendingApprovalOverlay polls for the host's decision.
|
|
1324
|
+
// Everything else is locked to loopback by the gate above.
|
|
1325
|
+
app.get('/api/devices/me', asyncH(async (req, res) => {
|
|
1326
|
+
const id = String(req.headers['x-device-id'] || (req.query && req.query.device) || '');
|
|
1327
|
+
if (!id) return res.status(400).json({ error: 'device id required' });
|
|
1328
|
+
// Token check applies HERE — this is the only endpoint where new
|
|
1329
|
+
// device records are created (record() inserts pending on first
|
|
1330
|
+
// sight). Demanding the token at registration time stops random
|
|
1331
|
+
// tunnel-URL scanners from filling the host's pending queue with
|
|
1332
|
+
// garbage entries. Already-known devices can re-poll without the
|
|
1333
|
+
// token (the existing record is returned as-is).
|
|
1334
|
+
const existing = await devices.get(id);
|
|
1335
|
+
if (!existing) {
|
|
1336
|
+
const tok = tunnel.getToken();
|
|
1337
|
+
if (tok && !isDirectLoopback(req)) {
|
|
1338
|
+
const auth = req.headers.authorization || '';
|
|
1339
|
+
const qTok = req.query && req.query.token;
|
|
1340
|
+
if (auth !== `Bearer ${tok}` && qTok !== tok) {
|
|
1341
|
+
return res.status(401).json({ error: 'token required to register a new device' });
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
const ua = req.headers['user-agent'] || '';
|
|
1346
|
+
const ip = String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
|
|
1347
|
+
const code = String(req.headers['x-device-code'] || (req.query && req.query.code) || '').slice(0, 8);
|
|
1348
|
+
const d = await devices.record(id, { userAgent: ua, ip, code });
|
|
1349
|
+
res.json(d);
|
|
1350
|
+
}));
|
|
1351
|
+
app.get('/api/devices', asyncH(async (_req, res) => {
|
|
1352
|
+
res.json({ devices: await devices.list() });
|
|
1353
|
+
}));
|
|
1354
|
+
app.post('/api/devices/:id/approve', asyncH(async (req, res) => {
|
|
1355
|
+
const d = await devices.approve(req.params.id, req.body && req.body.label);
|
|
1356
|
+
if (!d) return res.status(404).json({ error: 'device not found' });
|
|
1357
|
+
res.json(d);
|
|
1358
|
+
}));
|
|
1359
|
+
app.post('/api/devices/:id/reject', asyncH(async (req, res) => {
|
|
1360
|
+
const d = await devices.reject(req.params.id);
|
|
1361
|
+
if (!d) return res.status(404).json({ error: 'device not found' });
|
|
1362
|
+
res.json(d);
|
|
1363
|
+
}));
|
|
1364
|
+
app.post('/api/devices/:id/revoke', asyncH(async (req, res) => {
|
|
1365
|
+
const d = await devices.revoke(req.params.id);
|
|
1366
|
+
if (!d) return res.status(404).json({ error: 'device not found' });
|
|
1367
|
+
res.json(d);
|
|
1368
|
+
}));
|
|
1369
|
+
app.put('/api/devices/:id', asyncH(async (req, res) => {
|
|
1370
|
+
const d = await devices.rename(req.params.id, (req.body && req.body.label) || '');
|
|
1371
|
+
if (!d) return res.status(404).json({ error: 'device not found' });
|
|
1372
|
+
res.json(d);
|
|
1373
|
+
}));
|
|
1374
|
+
app.delete('/api/devices/:id', asyncH(async (req, res) => {
|
|
1375
|
+
const removed = await devices.remove(req.params.id);
|
|
1376
|
+
res.json({ removed });
|
|
1377
|
+
}));
|
|
1378
|
+
|
|
1379
|
+
// Restart: in production, spawn the restart-helper detached then
|
|
1380
|
+
// gracefulShutdown — the helper waits for the port to free and respawns
|
|
1381
|
+
// `ccsm.cmd` (with CCSM_NO_BROWSER so we don't pop a new window — the
|
|
1382
|
+
// frontend bounces through OfflineBanner / version router back into the
|
|
1383
|
+
// new backend). In dev (CCSM_DEV=1, set by scripts/dev.js), we skip the
|
|
1384
|
+
// helper entirely: just gracefulShutdown. scripts/dev.js sees its child
|
|
1385
|
+
// exit and respawns `node --watch server.js` from the checkout, picking
|
|
1386
|
+
// up any code changes.
|
|
1387
|
+
let restartInFlight = false;
|
|
1388
|
+
app.post('/api/restart', asyncH(async (_req, res) => {
|
|
1389
|
+
if (restartInFlight) {
|
|
1390
|
+
return res.status(409).json({ error: 'restart already in progress' });
|
|
1391
|
+
}
|
|
1392
|
+
restartInFlight = true;
|
|
1393
|
+
|
|
1394
|
+
if (process.env.CCSM_DEV === '1') {
|
|
1395
|
+
res.json({ ok: true, started: true, mode: 'dev', closeFrontend: false });
|
|
1396
|
+
setImmediate(() => gracefulShutdown('restart (dev)'));
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
const fsp = require('node:fs/promises');
|
|
1401
|
+
const helperSrc = path.join(__dirname, 'scripts', 'restart-helper.js');
|
|
1402
|
+
const helperTmp = path.join(os.tmpdir(), `ccsm-restart-${process.pid}-${Date.now()}.js`);
|
|
1403
|
+
try {
|
|
1404
|
+
await fsp.copyFile(helperSrc, helperTmp);
|
|
1405
|
+
} catch (e) {
|
|
1406
|
+
restartInFlight = false;
|
|
1407
|
+
return res.status(500).json({ error: `helper copy failed: ${e.message}` });
|
|
1408
|
+
}
|
|
1409
|
+
const args = [helperTmp, String(currentPort), String(process.pid)];
|
|
1410
|
+
// closeFrontend asks the calling tab to window.close() itself — the
|
|
1411
|
+
// helper will respawn ccsm WITHOUT CCSM_NO_BROWSER, so a fresh window
|
|
1412
|
+
// pops up once the new backend is listening. Net effect: the user
|
|
1413
|
+
// never sees the OfflineBanner during a restart.
|
|
1414
|
+
res.json({ ok: true, started: true, helper: helperTmp, closeFrontend: true });
|
|
1415
|
+
|
|
1416
|
+
setImmediate(() => {
|
|
1417
|
+
const { spawn } = require('node:child_process');
|
|
1418
|
+
try {
|
|
1419
|
+
const child = spawn(process.execPath, args, {
|
|
1420
|
+
detached: true,
|
|
1421
|
+
stdio: 'ignore',
|
|
1422
|
+
windowsHide: true,
|
|
1423
|
+
shell: false,
|
|
1424
|
+
});
|
|
1425
|
+
child.unref();
|
|
1426
|
+
console.log(`[restart] helper pid=${child.pid}, shutting down`);
|
|
1427
|
+
} catch (e) {
|
|
1428
|
+
console.error('[restart] helper spawn failed:', e.message);
|
|
1429
|
+
restartInFlight = false;
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
setTimeout(() => gracefulShutdown('restart'), 500);
|
|
1433
|
+
});
|
|
1434
|
+
}));
|
|
1435
|
+
|
|
1436
|
+
// ---- version / upgrade ----
|
|
1437
|
+
// `/api/version` reports the installed version (= pkg.version) and, if
|
|
1438
|
+
// reachable, the latest published on the npm registry. The result is
|
|
1439
|
+
// cached for 30 minutes in memory so the AboutPage poll doesn't hit the
|
|
1440
|
+
// registry on every render.
|
|
1441
|
+
//
|
|
1442
|
+
// `/api/upgrade` kicks off `npm i -g @bakapiano/ccsm@latest` as a
|
|
1443
|
+
// detached child. When the install completes, the child re-spawns `ccsm`
|
|
1444
|
+
// (also detached) so the launcher comes back up on the new version, and
|
|
1445
|
+
// the current server gracefulShutdowns. The frontend's OfflineBanner
|
|
1446
|
+
// covers the gap; the version router picks up the new version on the
|
|
1447
|
+
// next probe.
|
|
1448
|
+
const VERSION_CACHE_MS = 30 * 60_000;
|
|
1449
|
+
let versionCache = null; // { latest, fetchedAt }
|
|
1450
|
+
let upgradeInFlight = false;
|
|
1451
|
+
|
|
1452
|
+
async function fetchLatestFromNpm() {
|
|
1453
|
+
// Node 18+ has a global fetch. Time out the registry call to avoid
|
|
1454
|
+
// hanging the response when the user is offline / behind a captive
|
|
1455
|
+
// portal.
|
|
1456
|
+
const ctrl = new AbortController();
|
|
1457
|
+
const t = setTimeout(() => ctrl.abort(), 4000);
|
|
1458
|
+
try {
|
|
1459
|
+
const r = await fetch('https://registry.npmjs.org/@bakapiano%2Fccsm/latest', {
|
|
1460
|
+
headers: { 'Accept': 'application/json' },
|
|
1461
|
+
signal: ctrl.signal,
|
|
1462
|
+
});
|
|
1463
|
+
if (!r.ok) throw new Error(`registry HTTP ${r.status}`);
|
|
1464
|
+
const j = await r.json();
|
|
1465
|
+
return String(j.version || '');
|
|
1466
|
+
} finally {
|
|
1467
|
+
clearTimeout(t);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
function cmpSemver(a, b) {
|
|
1472
|
+
const pa = String(a || '').split('.').map(Number);
|
|
1473
|
+
const pb = String(b || '').split('.').map(Number);
|
|
1474
|
+
for (let i = 0; i < 3; i++) {
|
|
1475
|
+
const x = pa[i] || 0, y = pb[i] || 0;
|
|
1476
|
+
if (x > y) return 1;
|
|
1477
|
+
if (x < y) return -1;
|
|
1478
|
+
}
|
|
1479
|
+
return 0;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
app.get('/api/version', asyncH(async (req, res) => {
|
|
1483
|
+
const force = String(req.query.refresh || '') === '1';
|
|
1484
|
+
const now = Date.now();
|
|
1485
|
+
// devMode: set when the server was launched from scripts/dev.js
|
|
1486
|
+
// (CCSM_DEV=1). Lets the About page render a "test upgrade flow"
|
|
1487
|
+
// button that re-installs to a sandbox prefix without affecting the
|
|
1488
|
+
// user's global ccsm install.
|
|
1489
|
+
const devMode = process.env.CCSM_DEV === '1';
|
|
1490
|
+
if (!force && versionCache && (now - versionCache.fetchedAt) < VERSION_CACHE_MS) {
|
|
1491
|
+
return res.json({
|
|
1492
|
+
current: pkg.version,
|
|
1493
|
+
latest: versionCache.latest,
|
|
1494
|
+
updateAvailable: cmpSemver(versionCache.latest, pkg.version) > 0,
|
|
1495
|
+
fetchedAt: versionCache.fetchedAt,
|
|
1496
|
+
cached: true,
|
|
1497
|
+
devMode,
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
try {
|
|
1501
|
+
const latest = await fetchLatestFromNpm();
|
|
1502
|
+
versionCache = { latest, fetchedAt: now };
|
|
1503
|
+
res.json({
|
|
1504
|
+
current: pkg.version,
|
|
1505
|
+
latest,
|
|
1506
|
+
updateAvailable: cmpSemver(latest, pkg.version) > 0,
|
|
1507
|
+
fetchedAt: now,
|
|
1508
|
+
cached: false,
|
|
1509
|
+
devMode,
|
|
1510
|
+
});
|
|
1511
|
+
} catch (e) {
|
|
1512
|
+
res.json({
|
|
1513
|
+
current: pkg.version,
|
|
1514
|
+
latest: null,
|
|
1515
|
+
updateAvailable: false,
|
|
1516
|
+
fetchedAt: now,
|
|
1517
|
+
error: String(e.message || e),
|
|
1518
|
+
devMode,
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
}));
|
|
1522
|
+
|
|
1523
|
+
app.post('/api/upgrade', asyncH(async (req, res) => {
|
|
1524
|
+
if (upgradeInFlight) {
|
|
1525
|
+
return res.status(409).json({ error: 'upgrade already in progress' });
|
|
1526
|
+
}
|
|
1527
|
+
const body = req.body || {};
|
|
1528
|
+
const target = String(body.target || 'latest');
|
|
1529
|
+
// Refuse anything that doesn't look like a semver dist-tag or version
|
|
1530
|
+
// — defends against `;` etc. winding up in the spawn argv even though
|
|
1531
|
+
// we don't shell out.
|
|
1532
|
+
if (!/^[a-z0-9.+\-^~]+$/i.test(target)) {
|
|
1533
|
+
return res.status(400).json({ error: `invalid target: ${target}` });
|
|
1534
|
+
}
|
|
1535
|
+
// Optional sandbox install prefix (for testing without disturbing the
|
|
1536
|
+
// user's real global ccsm). Validated as a plain absolute path so it
|
|
1537
|
+
// can't be a flag injection.
|
|
1538
|
+
const installPrefix = body.installPrefix ? String(body.installPrefix) : '';
|
|
1539
|
+
if (installPrefix && (installPrefix.startsWith('-') || !path.isAbsolute(installPrefix))) {
|
|
1540
|
+
return res.status(400).json({ error: 'installPrefix must be an absolute path' });
|
|
1541
|
+
}
|
|
1542
|
+
const respawn = body.respawn === false ? '0' : '1';
|
|
1543
|
+
upgradeInFlight = true;
|
|
1544
|
+
console.log(`[upgrade] target=${target}${installPrefix ? ` prefix=${installPrefix}` : ''}${respawn === '0' ? ' (no respawn)' : ''}`);
|
|
1545
|
+
|
|
1546
|
+
// The helper runs OUTSIDE the package dir so npm can rename it
|
|
1547
|
+
// without fighting open file handles. Copy the script to os.tmpdir()
|
|
1548
|
+
// and spawn from there.
|
|
1549
|
+
const fsp = require('node:fs/promises');
|
|
1550
|
+
const helperSrc = path.join(__dirname, 'scripts', 'upgrade-helper.js');
|
|
1551
|
+
const helperTmp = path.join(os.tmpdir(), `ccsm-upgrade-${process.pid}-${Date.now()}.js`);
|
|
1552
|
+
try {
|
|
1553
|
+
await fsp.copyFile(helperSrc, helperTmp);
|
|
1554
|
+
} catch (e) {
|
|
1555
|
+
upgradeInFlight = false;
|
|
1556
|
+
return res.status(500).json({ error: `helper copy failed: ${e.message}` });
|
|
1557
|
+
}
|
|
1558
|
+
// Where to send the user back when the upgrade succeeds. In prod
|
|
1559
|
+
// that's the GH Pages router (it'll re-probe localhost:7777 and
|
|
1560
|
+
// redirect to the matching per-version frontend); in dev (CCSM_DEV=1)
|
|
1561
|
+
// that's our local server on whatever port we're listening on, so
|
|
1562
|
+
// the test sandbox flow returns to the dev instance instead of
|
|
1563
|
+
// hitting GH Pages (which doesn't know about port 7788).
|
|
1564
|
+
const redirectTo = frontendUrl || `http://localhost:${currentPort}/`;
|
|
1565
|
+
|
|
1566
|
+
const args = [helperTmp, target, String(currentPort), String(process.pid), installPrefix, respawn, redirectTo];
|
|
1567
|
+
|
|
1568
|
+
res.json({
|
|
1569
|
+
ok: true,
|
|
1570
|
+
started: true,
|
|
1571
|
+
target,
|
|
1572
|
+
helper: helperTmp,
|
|
1573
|
+
helperUrl: 'http://localhost:7779/',
|
|
1574
|
+
closeFrontend: false,
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
// Flush response, then spawn helper detached and gracefulShutdown so
|
|
1578
|
+
// the helper's npm install isn't fighting our open file handles.
|
|
1579
|
+
setImmediate(() => {
|
|
1580
|
+
const { spawn } = require('node:child_process');
|
|
1581
|
+
try {
|
|
1582
|
+
const child = spawn(process.execPath, args, {
|
|
1583
|
+
detached: true,
|
|
1584
|
+
stdio: 'ignore',
|
|
1585
|
+
windowsHide: true,
|
|
1586
|
+
shell: false,
|
|
1587
|
+
});
|
|
1588
|
+
child.unref();
|
|
1589
|
+
console.log(`[upgrade] helper pid=${child.pid}, shutting down`);
|
|
1590
|
+
} catch (e) {
|
|
1591
|
+
console.error('[upgrade] helper spawn failed:', e.message);
|
|
1592
|
+
upgradeInFlight = false;
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
setTimeout(() => gracefulShutdown('upgrade'), 500);
|
|
1596
|
+
});
|
|
1597
|
+
}));
|
|
1598
|
+
|
|
1599
|
+
|
|
1600
|
+
function listenWithFallback(preferred) {
|
|
1601
|
+
return new Promise((resolve, reject) => {
|
|
1602
|
+
const attempt = (port, tries) => {
|
|
1603
|
+
const server = app.listen(port);
|
|
1604
|
+
server.once('listening', () => resolve({ server, port: server.address().port }));
|
|
1605
|
+
server.once('error', (err) => {
|
|
1606
|
+
if (err.code !== 'EADDRINUSE') return reject(err);
|
|
1607
|
+
if (tries < 9) attempt(port + 1, tries + 1);
|
|
1608
|
+
else if (tries === 9) attempt(0, tries + 1);
|
|
1609
|
+
else reject(err);
|
|
1610
|
+
});
|
|
1611
|
+
};
|
|
1612
|
+
attempt(preferred, 0);
|
|
1613
|
+
});
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
function findAppModeBrowser() {
|
|
1617
|
+
const candidates = [
|
|
1618
|
+
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
1619
|
+
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
|
1620
|
+
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
1621
|
+
process.env.LOCALAPPDATA &&
|
|
1622
|
+
path.join(process.env.LOCALAPPDATA, 'Google\\Chrome\\Application\\chrome.exe'),
|
|
1623
|
+
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
|
1624
|
+
].filter(Boolean);
|
|
1625
|
+
const fs = require('node:fs');
|
|
1626
|
+
for (const p of candidates) {
|
|
1627
|
+
if (fs.existsSync(p)) return p;
|
|
1628
|
+
}
|
|
1629
|
+
return null;
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// Look for a Chrome/Edge PWA that the user already installed locally
|
|
1633
|
+
// pointing at the ccsm frontend. When found, we launch it via
|
|
1634
|
+
// `chrome.exe --profile-directory=... --app-id=<id>` — same as the
|
|
1635
|
+
// shortcut Start Menu creates at install time. That path opens the
|
|
1636
|
+
// PWA fully chromeless (respects manifest display:standalone + WCO).
|
|
1637
|
+
// Without this we'd fall back to `--app=<URL> --user-data-dir=<ours>`
|
|
1638
|
+
// which uses an isolated profile that doesn't see the install, so
|
|
1639
|
+
// Chrome shows a minimal-ui address bar.
|
|
1640
|
+
function findInstalledCcsmPwa() {
|
|
1641
|
+
if (process.platform !== 'win32') return null;
|
|
1642
|
+
const appData = process.env.APPDATA;
|
|
1643
|
+
if (!appData) return null;
|
|
1644
|
+
const fs = require('node:fs');
|
|
1645
|
+
const startMenu = path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs');
|
|
1646
|
+
const dirs = [
|
|
1647
|
+
path.join(startMenu, 'Chrome Apps'),
|
|
1648
|
+
path.join(startMenu, 'Edge Apps'),
|
|
1649
|
+
];
|
|
1650
|
+
const candidates = [];
|
|
1651
|
+
for (const dir of dirs) {
|
|
1652
|
+
let names;
|
|
1653
|
+
try { names = fs.readdirSync(dir); } catch { continue; }
|
|
1654
|
+
for (const name of names) {
|
|
1655
|
+
if (!name.toLowerCase().endsWith('.lnk')) continue;
|
|
1656
|
+
// Filter by filename — Chrome names PWA shortcuts after the
|
|
1657
|
+
// manifest's short_name/name. CCSM matches our manifest.
|
|
1658
|
+
if (!/ccsm/i.test(name)) continue;
|
|
1659
|
+
const full = path.join(dir, name);
|
|
1660
|
+
try {
|
|
1661
|
+
candidates.push({ name, path: full, mtime: fs.statSync(full).mtimeMs });
|
|
1662
|
+
} catch {}
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
if (candidates.length === 0) return null;
|
|
1666
|
+
// Newest install wins (covers the case where the user re-installed
|
|
1667
|
+
// and accumulated CCSM, CCSM (1), etc.).
|
|
1668
|
+
candidates.sort((a, b) => b.mtime - a.mtime);
|
|
1669
|
+
// Resolve via WScript.Shell COM. Single PowerShell call enumerates
|
|
1670
|
+
// every candidate; we stop at the first one whose target looks like
|
|
1671
|
+
// a Chrome/Edge binary and whose args carry an --app-id.
|
|
1672
|
+
const { spawnSync } = require('node:child_process');
|
|
1673
|
+
const psPaths = candidates
|
|
1674
|
+
.map((c) => `'${c.path.replace(/'/g, "''")}'`).join(',');
|
|
1675
|
+
const script = `
|
|
1676
|
+
$ErrorActionPreference = 'SilentlyContinue'
|
|
1677
|
+
$wsh = New-Object -ComObject WScript.Shell
|
|
1678
|
+
foreach ($p in @(${psPaths})) {
|
|
1679
|
+
$sc = $wsh.CreateShortcut($p)
|
|
1680
|
+
Write-Output ($sc.TargetPath + '|' + $sc.Arguments)
|
|
1681
|
+
}`;
|
|
1682
|
+
const r = spawnSync('powershell.exe',
|
|
1683
|
+
['-NoProfile', '-NonInteractive', '-Command', script],
|
|
1684
|
+
{ encoding: 'utf8', windowsHide: true });
|
|
1685
|
+
if (r.status !== 0 || !r.stdout) return null;
|
|
1686
|
+
for (const line of r.stdout.split(/\r?\n/)) {
|
|
1687
|
+
if (!line.trim()) continue;
|
|
1688
|
+
const sep = line.indexOf('|');
|
|
1689
|
+
if (sep < 0) continue;
|
|
1690
|
+
const target = line.slice(0, sep).trim();
|
|
1691
|
+
const args = line.slice(sep + 1).trim();
|
|
1692
|
+
if (!/chrome(_proxy)?\.exe$|msedge(_proxy)?\.exe$/i.test(target)) continue;
|
|
1693
|
+
const appId = (args.match(/--app-id=(\S+)/) || [])[1];
|
|
1694
|
+
if (!appId) continue;
|
|
1695
|
+
const profile = (args.match(/--profile-directory=(\S+)/) || [])[1] || 'Default';
|
|
1696
|
+
return { browserPath: target, appId, profile };
|
|
1697
|
+
}
|
|
1698
|
+
return null;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
// Auto-open the frontend in a browser when ccsm boots. Strategy:
|
|
1702
|
+
// 1. If the user already installed the CCSM PWA, launch THAT (fully
|
|
1703
|
+
// chromeless via --app-id, uses user's main browser profile).
|
|
1704
|
+
// 2. Otherwise try a generic --app= window in an isolated profile —
|
|
1705
|
+
// this shows a thin minimal-ui address bar but at least it's
|
|
1706
|
+
// a dedicated window.
|
|
1707
|
+
// 3. Fall back to the OS default browser as a regular tab.
|
|
1708
|
+
// On non-Windows we skip — the bundled launcher isn't ported yet.
|
|
1709
|
+
function openInBrowser(url) {
|
|
1710
|
+
if (process.platform !== 'win32') return { kind: 'none', child: null };
|
|
1711
|
+
const { spawn } = require('node:child_process');
|
|
1712
|
+
const fs = require('node:fs');
|
|
1713
|
+
|
|
1714
|
+
const installed = findInstalledCcsmPwa();
|
|
1715
|
+
if (installed) {
|
|
1716
|
+
console.log(`[ccsm] launching installed PWA · app-id=${installed.appId} profile=${installed.profile}`);
|
|
1717
|
+
const child = spawn(
|
|
1718
|
+
installed.browserPath,
|
|
1719
|
+
[
|
|
1720
|
+
`--profile-directory=${installed.profile}`,
|
|
1721
|
+
`--app-id=${installed.appId}`,
|
|
1722
|
+
],
|
|
1723
|
+
{ detached: true, stdio: 'ignore' }
|
|
1724
|
+
);
|
|
1725
|
+
child.unref();
|
|
1726
|
+
return { kind: 'pwa', child };
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
const exe = findAppModeBrowser();
|
|
1730
|
+
if (exe) {
|
|
1731
|
+
const profileDir = path.join(DATA_DIR, 'browser-profile');
|
|
1732
|
+
fs.mkdirSync(profileDir, { recursive: true });
|
|
1733
|
+
console.log(`[ccsm] no installed PWA found · falling back to --app= window`);
|
|
1734
|
+
const child = spawn(
|
|
1735
|
+
exe,
|
|
1736
|
+
[
|
|
1737
|
+
`--app=${url}`,
|
|
1738
|
+
`--user-data-dir=${profileDir}`,
|
|
1739
|
+
'--window-size=1500,1100',
|
|
1740
|
+
'--no-first-run',
|
|
1741
|
+
'--no-default-browser-check',
|
|
1742
|
+
],
|
|
1743
|
+
{ detached: true, stdio: 'ignore' }
|
|
1744
|
+
);
|
|
1745
|
+
child.unref();
|
|
1746
|
+
return { kind: 'app', child };
|
|
1747
|
+
}
|
|
1748
|
+
console.log('[ccsm] no Edge/Chrome found, opening default browser');
|
|
1749
|
+
const child = spawn('cmd.exe', ['/c', 'start', '', url], {
|
|
1750
|
+
detached: true,
|
|
1751
|
+
stdio: 'ignore',
|
|
1752
|
+
windowsHide: true,
|
|
1753
|
+
});
|
|
1754
|
+
child.unref();
|
|
1755
|
+
return { kind: 'tab', child: null };
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
(async () => {
|
|
1759
|
+
const cfg = await loadConfig();
|
|
1760
|
+
const preferredPort = process.env.CCSM_PORT ? Number(process.env.CCSM_PORT) : cfg.port;
|
|
1761
|
+
const { server, port } = await listenWithFallback(preferredPort);
|
|
1762
|
+
currentPort = port;
|
|
1763
|
+
|
|
1764
|
+
// On boot, mark any persisted "running" sessions as exited — they
|
|
1765
|
+
// belong to a previous server process whose PTYs are gone.
|
|
1766
|
+
try {
|
|
1767
|
+
const all = await persistedSessions.loadAll();
|
|
1768
|
+
for (const s of all) {
|
|
1769
|
+
if (s.status === 'running') {
|
|
1770
|
+
await persistedSessions.markExited(s.id, null);
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
} catch (e) {
|
|
1774
|
+
console.error('[ccsm] could not reconcile persisted sessions:', e.message);
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
// Prewarm `tasklist` cache used by the import modal's "live" markers —
|
|
1778
|
+
// it takes ~500ms on Windows and is the single biggest contributor to
|
|
1779
|
+
// a slow Import dialog cold-open. Fire in the background; the lib also
|
|
1780
|
+
// starts its own 15s refresh loop.
|
|
1781
|
+
try { localCliSessions.prewarmLivePids(['claude.exe']); } catch {}
|
|
1782
|
+
// Prewarm tunnel provider probe. First /api/tunnel/status round-trip
|
|
1783
|
+
// shells out to where.exe / --version / devtunnel user show — ~700ms
|
|
1784
|
+
// of synchronous work that the user otherwise waits on the moment
|
|
1785
|
+
// they open the Remote tab. Fire in the background here so the cache
|
|
1786
|
+
// is warm by the time anyone clicks.
|
|
1787
|
+
try { tunnel.probe(true).catch(() => {}); } catch {}
|
|
1788
|
+
|
|
1789
|
+
// Auto-start the tunnel if the user enabled it on the Remote page.
|
|
1790
|
+
// This is the BACKEND PROCESS bringing its own tunnel up on startup —
|
|
1791
|
+
// not an OS-level autostart (no registry / scheduled task). Reuses the
|
|
1792
|
+
// persisted token so share URLs stay valid across restarts. Strictly
|
|
1793
|
+
// fire-and-forget: a failure here (devtunnel not signed in, provider
|
|
1794
|
+
// uninstalled, etc.) must never crash boot — it just logs and the user
|
|
1795
|
+
// can start manually from the Remote page.
|
|
1796
|
+
if (cfg.tunnel?.autoStart && cfg.tunnel?.token && cfg.tunnel?.provider) {
|
|
1797
|
+
tunnel.setToken(cfg.tunnel.token);
|
|
1798
|
+
tunnel.start({ provider: cfg.tunnel.provider, port: currentPort })
|
|
1799
|
+
.then((s) => console.log(`[ccsm] tunnel auto-started · ${cfg.tunnel.provider} · ${s.url || 'URL pending'}`))
|
|
1800
|
+
.catch((e) => console.warn(`[ccsm] tunnel auto-start failed · ${e.message}`));
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
if (webTerminal.available) {
|
|
1804
|
+
let WebSocketServer;
|
|
1805
|
+
try { ({ WebSocketServer } = require('ws')); } catch {}
|
|
1806
|
+
if (WebSocketServer) {
|
|
1807
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
1808
|
+
server.on('upgrade', async (req, socket, head) => {
|
|
1809
|
+
const direct = isDirectLoopback(req);
|
|
1810
|
+
// Non-loopback WS: device id alone gates entry. The host
|
|
1811
|
+
// explicitly Approved this device id earlier — that approval
|
|
1812
|
+
// IS the credential. No token check here (matches the device
|
|
1813
|
+
// gate above: token is only for /api/devices/me registration).
|
|
1814
|
+
if (!direct) {
|
|
1815
|
+
try {
|
|
1816
|
+
const u = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
1817
|
+
const devId = u.searchParams.get('device');
|
|
1818
|
+
if (!devId) { socket.destroy(); return; }
|
|
1819
|
+
const d = await devices.get(devId);
|
|
1820
|
+
if (!d || d.status !== 'approved') { socket.destroy(); return; }
|
|
1821
|
+
} catch { socket.destroy(); return; }
|
|
1822
|
+
} else {
|
|
1823
|
+
const origin = req.headers.origin;
|
|
1824
|
+
if (origin && !ALLOWED_ORIGINS.has(origin) && !/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin)) {
|
|
1825
|
+
socket.destroy();
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
const m = req.url && req.url.match(/^\/ws\/terminal\/([^\/?#]+)/);
|
|
1830
|
+
if (!m) { socket.destroy(); return; }
|
|
1831
|
+
const id = decodeURIComponent(m[1]);
|
|
1832
|
+
wss.handleUpgrade(req, socket, head, (ws) => webTerminal.attach(id, ws));
|
|
1833
|
+
});
|
|
1834
|
+
console.log('[ccsm] web terminal bridge active (WebSocket /ws/terminal/:id)');
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
1839
|
+
process.on(sig, () => gracefulShutdown(sig));
|
|
1840
|
+
}
|
|
1841
|
+
process.on('exit', () => { try { webTerminal.killAll(); } catch {} });
|
|
1842
|
+
|
|
1843
|
+
const apiUrl = `http://localhost:${port}`;
|
|
1844
|
+
const FRONTEND_URL = IS_DEV
|
|
1845
|
+
? apiUrl
|
|
1846
|
+
: 'https://bakapiano.github.io/ccsm/';
|
|
1847
|
+
frontendUrl = FRONTEND_URL;
|
|
1848
|
+
console.log(`ccsm listening on ${apiUrl}${port !== preferredPort ? ` (requested ${preferredPort}, was taken)` : ''}`);
|
|
1849
|
+
console.log(`frontend at ${FRONTEND_URL}`);
|
|
1850
|
+
console.log(`data dir: ${DATA_DIR}`);
|
|
1851
|
+
console.log(`work dir: ${cfg.workDir}`);
|
|
1852
|
+
console.log(`clis: ${cfg.clis.map((c) => c.id).join(', ')} (default: ${cfg.defaultCliId})`);
|
|
1853
|
+
|
|
1854
|
+
// CCSM_NO_BROWSER=1 (set by the ccsm:// protocol launcher) suppresses
|
|
1855
|
+
// the auto-open entirely. CCSM_FROM_UPGRADE=1 (set by upgrade-helper
|
|
1856
|
+
// when it respawns ccsm post-install) does the same: the user is
|
|
1857
|
+
// already in the helper UI which redirects to this fresh backend, so
|
|
1858
|
+
// a second app-mode window would just shadow the first. Otherwise try
|
|
1859
|
+
// app-mode (chromeless Edge/Chrome window); if no such browser is
|
|
1860
|
+
// installed, openInBrowser falls back to the OS default browser on
|
|
1861
|
+
// its own.
|
|
1862
|
+
const suppressBrowser = process.env.CCSM_NO_BROWSER === '1'
|
|
1863
|
+
|| process.env.CCSM_FROM_UPGRADE === '1';
|
|
1864
|
+
const opened = suppressBrowser
|
|
1865
|
+
? { kind: 'none', child: null }
|
|
1866
|
+
: openInBrowser(FRONTEND_URL);
|
|
1867
|
+
|
|
1868
|
+
if (opened.kind === 'app' && opened.child && process.env.CCSM_KEEP_ALIVE !== '1') {
|
|
1869
|
+
const launchedAt = Date.now();
|
|
1870
|
+
opened.child.on('exit', () => {
|
|
1871
|
+
const alive = Date.now() - launchedAt;
|
|
1872
|
+
if (alive < 5000) {
|
|
1873
|
+
console.log(`[ccsm] spawned browser child exited in ${alive}ms · handed off to an existing Edge instance, staying alive`);
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
const closedAt = Date.now();
|
|
1877
|
+
setTimeout(() => {
|
|
1878
|
+
if (lastHeartbeat > closedAt + 100) {
|
|
1879
|
+
console.log('[ccsm] browser closed but another client is heartbeating · staying alive');
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1882
|
+
gracefulShutdown('browser window closed');
|
|
1883
|
+
}, 12_000);
|
|
1884
|
+
});
|
|
1885
|
+
console.log('[ccsm] tied to browser window — close it to stop ccsm');
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
if (process.env.CCSM_LAUNCHER === '1' && process.env.CCSM_KEEP_ALIVE !== '1') {
|
|
1889
|
+
setInterval(() => {
|
|
1890
|
+
if (!heartbeatSeen) return;
|
|
1891
|
+
if (Date.now() - lastHeartbeat > HEARTBEAT_TIMEOUT_MS) {
|
|
1892
|
+
gracefulShutdown(`no heartbeat for ${HEARTBEAT_TIMEOUT_MS / 1000}s`);
|
|
1893
|
+
}
|
|
1894
|
+
}, 30_000);
|
|
1895
|
+
console.log('[ccsm] heartbeat watchdog active');
|
|
1896
|
+
}
|
|
1897
|
+
})().catch((err) => {
|
|
1898
|
+
console.error('startup failed:', err);
|
|
1899
|
+
process.exit(1);
|
|
1900
|
+
});
|