@bakapiano/ccsm 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/cliSessionWatcher.js +28 -2
- package/lib/webTerminal.js +17 -1
- package/package.json +1 -1
- package/public/css/widgets.css +32 -0
- package/public/js/api.js +12 -4
- package/public/js/components/EntityFormModal.js +37 -1
- package/public/js/components/Sidebar.js +0 -42
- package/public/js/components/TerminalView.js +16 -0
- package/public/js/pages/ConfigurePage.js +4 -4
- package/scripts/install.js +32 -11
- package/scripts/upgrade-helper.js +155 -0
- package/server.js +148 -41
package/lib/cliSessionWatcher.js
CHANGED
|
@@ -34,6 +34,19 @@ const readline = require('node:readline');
|
|
|
34
34
|
const POLL_MS = 1_500;
|
|
35
35
|
const WINDOW_MS = 5 * 60_000;
|
|
36
36
|
|
|
37
|
+
// Module-level set of upstream session UUIDs known to belong to some
|
|
38
|
+
// ccsm session — either persisted on disk (seeded at watcher start via
|
|
39
|
+
// the excludeIds arg), or captured by another in-flight watcher in
|
|
40
|
+
// THIS server's lifetime. Grows monotonically; we never unclaim because
|
|
41
|
+
// the file-level cliSessionId is sticky too.
|
|
42
|
+
//
|
|
43
|
+
// Why module-level rather than per-watcher: when two ccsm sessions
|
|
44
|
+
// spawn back-to-back in the same cwd, the FIRST watcher might capture
|
|
45
|
+
// its UUID AFTER the second watcher started. The second watcher needs
|
|
46
|
+
// to see "this UUID just got claimed" without us threading a notify
|
|
47
|
+
// callback. Sharing the set in module scope is the simplest fix.
|
|
48
|
+
const claimedIds = new Set();
|
|
49
|
+
|
|
37
50
|
const profiles = {
|
|
38
51
|
claude: {
|
|
39
52
|
dirFor: (cwd) => path.join(os.homedir(), '.claude', 'projects', claudeSlug(cwd)),
|
|
@@ -76,12 +89,17 @@ const profiles = {
|
|
|
76
89
|
},
|
|
77
90
|
};
|
|
78
91
|
|
|
79
|
-
function captureSessionId({ cliType, cwd, onCapture, onTimeout, windowMs = WINDOW_MS }) {
|
|
92
|
+
function captureSessionId({ cliType, cwd, onCapture, onTimeout, windowMs = WINDOW_MS, excludeIds = [] }) {
|
|
80
93
|
const profile = profiles[cliType];
|
|
81
94
|
if (!profile) return () => {};
|
|
82
95
|
const dir = profile.dirFor(cwd);
|
|
83
96
|
const spawnAt = Date.now();
|
|
84
|
-
|
|
97
|
+
// Seed module-level claimedIds with the caller's view of "already
|
|
98
|
+
// assigned" UUIDs (typically persistedSessions cliSessionIds). The
|
|
99
|
+
// poll loop reads claimedIds fresh every tick so concurrent watchers
|
|
100
|
+
// see each other's captures.
|
|
101
|
+
for (const id of (excludeIds || [])) if (id) claimedIds.add(id);
|
|
102
|
+
console.log(`[cliSessionWatcher] start ${cliType} dir=${dir} cwd=${cwd}${claimedIds.size ? ` claimed=${claimedIds.size}` : ''}`);
|
|
85
103
|
|
|
86
104
|
let stopped = false;
|
|
87
105
|
let captured = false;
|
|
@@ -104,6 +122,10 @@ function captureSessionId({ cliType, cwd, onCapture, onTimeout, windowMs = WINDO
|
|
|
104
122
|
const finish = (sessionId) => {
|
|
105
123
|
if (stopped) return;
|
|
106
124
|
captured = true;
|
|
125
|
+
// Add to module-level claimedIds BEFORE firing onCapture so any
|
|
126
|
+
// concurrent watcher polling at the same instant won't also grab
|
|
127
|
+
// this UUID. Sticky for the lifetime of the server.
|
|
128
|
+
claimedIds.add(sessionId);
|
|
107
129
|
cleanup();
|
|
108
130
|
console.log(`[cliSessionWatcher] captured ${cliType} ${sessionId}`);
|
|
109
131
|
try { onCapture?.(sessionId); } catch (e) { console.error('[cliSessionWatcher] onCapture:', e); }
|
|
@@ -128,6 +150,10 @@ function captureSessionId({ cliType, cwd, onCapture, onTimeout, windowMs = WINDO
|
|
|
128
150
|
if (profile.filePattern && !profile.filePattern.test(base)) continue;
|
|
129
151
|
const id = profile.parseId(base);
|
|
130
152
|
if (!id) continue;
|
|
153
|
+
// Already-claimed-by-another-ccsm-session UUIDs are never ours.
|
|
154
|
+
// claimedIds is module-level so concurrent watchers see each
|
|
155
|
+
// other's captures (not just the snapshot at our spawn time).
|
|
156
|
+
if (claimedIds.has(id)) { rejected.add(entryPath); continue; }
|
|
131
157
|
let st;
|
|
132
158
|
try { st = await fsp.stat(entryPath); } catch { continue; }
|
|
133
159
|
// Mtime gate is re-evaluated every poll: don't memoise it. If the
|
package/lib/webTerminal.js
CHANGED
|
@@ -84,6 +84,12 @@ function spawn({ command, args = [], cwd, env, cols = 120, rows = 30, meta = {},
|
|
|
84
84
|
if (entry.onDataExtra) { try { entry.onDataExtra(data); } catch {} }
|
|
85
85
|
});
|
|
86
86
|
proc.onExit(({ exitCode, signal }) => {
|
|
87
|
+
// If a respawn replaced us in the pool (same entryId, new entry
|
|
88
|
+
// object), do not touch persistedSessions or schedule a delete —
|
|
89
|
+
// those belong to the new entry now. Without this guard, a slow-
|
|
90
|
+
// dying old PTY would fire markExited on the same sessionId and
|
|
91
|
+
// clobber the new spawn's markRunning state.
|
|
92
|
+
if (sessions.get(entryId) !== entry) return;
|
|
87
93
|
entry.exitCode = exitCode;
|
|
88
94
|
entry.exitedAt = Date.now();
|
|
89
95
|
const frame = JSON.stringify({ type: 'exit', code: exitCode, signal });
|
|
@@ -91,8 +97,18 @@ function spawn({ command, args = [], cwd, env, cols = 120, rows = 30, meta = {},
|
|
|
91
97
|
try { ws.send(frame); } catch {}
|
|
92
98
|
}
|
|
93
99
|
if (entry.onExitExtra) { try { entry.onExitExtra({ exitCode, signal }); } catch {} }
|
|
94
|
-
setTimeout(() =>
|
|
100
|
+
setTimeout(() => {
|
|
101
|
+
if (sessions.get(entryId) === entry) sessions.delete(entryId);
|
|
102
|
+
}, 30_000);
|
|
95
103
|
});
|
|
104
|
+
// If a previous entry exists under the same id (respawn), kill its
|
|
105
|
+
// pty so we don't have zombie claude.exe processes hanging on. The
|
|
106
|
+
// onExit guard above ensures its callback no-ops once we've taken
|
|
107
|
+
// over the slot.
|
|
108
|
+
const prev = sessions.get(entryId);
|
|
109
|
+
if (prev && !prev.exitedAt) {
|
|
110
|
+
try { prev.pty.kill(); } catch {}
|
|
111
|
+
}
|
|
96
112
|
sessions.set(entryId, entry);
|
|
97
113
|
return entry;
|
|
98
114
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bakapiano/ccsm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Claude Code Session Manager — Windows web UI to manage many concurrent claude sessions: live list, snapshot/restore, focus existing window, new session in an isolated workspace with repo clones",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "server.js",
|
package/public/css/widgets.css
CHANGED
|
@@ -980,6 +980,38 @@
|
|
|
980
980
|
gap: 6px;
|
|
981
981
|
margin-top: 4px;
|
|
982
982
|
}
|
|
983
|
+
.entity-test-button { margin-right: auto; }
|
|
984
|
+
|
|
985
|
+
.entity-test-result {
|
|
986
|
+
margin-top: 4px;
|
|
987
|
+
padding: 8px 10px;
|
|
988
|
+
border-radius: 6px;
|
|
989
|
+
border: 1px solid var(--border);
|
|
990
|
+
background: var(--bg);
|
|
991
|
+
display: flex;
|
|
992
|
+
flex-direction: column;
|
|
993
|
+
gap: 6px;
|
|
994
|
+
font-size: 12.5px;
|
|
995
|
+
}
|
|
996
|
+
.entity-test-result.is-ok { border-color: rgba(74, 138, 74, 0.4); background: rgba(74, 138, 74, 0.06); }
|
|
997
|
+
.entity-test-result.is-fail { border-color: rgba(183, 63, 63, 0.4); background: rgba(183, 63, 63, 0.06); }
|
|
998
|
+
.entity-test-summary {
|
|
999
|
+
font-family: var(--body);
|
|
1000
|
+
font-weight: 500;
|
|
1001
|
+
color: var(--ink);
|
|
1002
|
+
}
|
|
1003
|
+
.entity-test-result.is-fail .entity-test-summary { color: var(--red); }
|
|
1004
|
+
.entity-test-out {
|
|
1005
|
+
margin: 0;
|
|
1006
|
+
font-family: var(--mono);
|
|
1007
|
+
font-size: 11.5px;
|
|
1008
|
+
color: var(--ink-mid);
|
|
1009
|
+
white-space: pre-wrap;
|
|
1010
|
+
word-break: break-word;
|
|
1011
|
+
max-height: 120px;
|
|
1012
|
+
overflow-y: auto;
|
|
1013
|
+
}
|
|
1014
|
+
.entity-test-out.is-stderr { color: var(--ink-muted); border-top: 1px dashed var(--border); padding-top: 4px; }
|
|
983
1015
|
|
|
984
1016
|
.icon-radio {
|
|
985
1017
|
display: grid;
|
package/public/js/api.js
CHANGED
|
@@ -28,11 +28,11 @@ export async function loadConfig() {
|
|
|
28
28
|
export async function updateCli(id, patch) {
|
|
29
29
|
const cfg = S.config.value || (await api('GET', '/api/config'));
|
|
30
30
|
const target = (cfg.clis || []).find((c) => c.id === id);
|
|
31
|
-
// Built-in CLIs lock down
|
|
32
|
-
//
|
|
33
|
-
//
|
|
31
|
+
// Built-in CLIs lock down structural fields (id + builtin flag) but
|
|
32
|
+
// allow command edits — users routinely need to point at an absolute
|
|
33
|
+
// path (e.g. C:\Users\you\.local\bin\claude.exe) or a wrapper script
|
|
34
|
+
// when the bare name isn't on the spawn-time PATH.
|
|
34
35
|
if (target?.builtin) {
|
|
35
|
-
delete patch.command;
|
|
36
36
|
delete patch.id;
|
|
37
37
|
delete patch.builtin;
|
|
38
38
|
}
|
|
@@ -52,6 +52,14 @@ export async function updateCli(id, patch) {
|
|
|
52
52
|
return id;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// Probe a (possibly-unsaved) CLI config: spawn its command with
|
|
56
|
+
// `--version`, capture output, see if it looks like the claimed type.
|
|
57
|
+
// `args` is intentionally ignored server-side — runtime flags can
|
|
58
|
+
// disturb a quick probe.
|
|
59
|
+
export async function testCli({ command, shell, type }) {
|
|
60
|
+
return api('POST', '/api/clis/test', { command, shell, type });
|
|
61
|
+
}
|
|
62
|
+
|
|
55
63
|
export async function deleteCli(id) {
|
|
56
64
|
const cfg = S.config.value || (await api('GET', '/api/config'));
|
|
57
65
|
const target = (cfg.clis || []).find((c) => c.id === id);
|
|
@@ -18,10 +18,13 @@ import { Modal } from './Modal.js';
|
|
|
18
18
|
export function EntityFormModal({
|
|
19
19
|
title, fields, initial = {}, submitLabel = 'Save',
|
|
20
20
|
readOnlyKeys = [],
|
|
21
|
-
onSubmit, onClose,
|
|
21
|
+
onSubmit, onClose, onTest, testLabel = 'Test',
|
|
22
|
+
danger,
|
|
22
23
|
}) {
|
|
23
24
|
const [draft, setDraft] = useState(() => ({ ...initialFrom(fields), ...initial }));
|
|
24
25
|
const [saving, setSaving] = useState(false);
|
|
26
|
+
const [testing, setTesting] = useState(false);
|
|
27
|
+
const [testResult, setTestResult] = useState(null);
|
|
25
28
|
|
|
26
29
|
const isReadOnly = (key) => readOnlyKeys.includes(key);
|
|
27
30
|
|
|
@@ -36,6 +39,20 @@ export function EntityFormModal({
|
|
|
36
39
|
finally { setSaving(false); }
|
|
37
40
|
};
|
|
38
41
|
|
|
42
|
+
const runTest = async () => {
|
|
43
|
+
if (!onTest) return;
|
|
44
|
+
setTesting(true);
|
|
45
|
+
setTestResult(null);
|
|
46
|
+
try {
|
|
47
|
+
const r = await onTest(draft);
|
|
48
|
+
setTestResult(r);
|
|
49
|
+
} catch (e) {
|
|
50
|
+
setTestResult({ ok: false, spawnError: String(e?.message || e) });
|
|
51
|
+
} finally {
|
|
52
|
+
setTesting(false);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
39
56
|
return html`
|
|
40
57
|
<${Modal} title=${title} onClose=${onClose} width=${440}>
|
|
41
58
|
<form class="entity-form" onSubmit=${submit}>
|
|
@@ -87,7 +104,26 @@ export function EntityFormModal({
|
|
|
87
104
|
${f.hint && f.type !== 'checkbox' ? html`
|
|
88
105
|
<span class="entity-field-hint">${f.hint}</span>` : null}
|
|
89
106
|
</label>`)}
|
|
107
|
+
${testResult ? html`
|
|
108
|
+
<div class=${`entity-test-result ${testResult.ok ? 'is-ok' : 'is-fail'}`}>
|
|
109
|
+
<div class="entity-test-summary">
|
|
110
|
+
${testResult.ok ? '✓' : '✗'} ${testResult.ok ? 'works' : 'failed'}
|
|
111
|
+
${typeof testResult.exitCode === 'number' ? html` · exit ${testResult.exitCode}` : null}
|
|
112
|
+
${typeof testResult.durationMs === 'number' ? html` · ${testResult.durationMs}ms` : null}
|
|
113
|
+
${testResult.timedOut ? html` · timed out` : null}
|
|
114
|
+
${testResult.matchedType === true ? html` · type matches ${testResult.expectedType}` : null}
|
|
115
|
+
${testResult.matchedType === false ? html` · type mismatch (expected ${testResult.expectedType})` : null}
|
|
116
|
+
</div>
|
|
117
|
+
${testResult.spawnError ? html`<pre class="entity-test-out">${testResult.spawnError}</pre>` : null}
|
|
118
|
+
${testResult.stdout ? html`<pre class="entity-test-out">${testResult.stdout}</pre>` : null}
|
|
119
|
+
${testResult.stderr ? html`<pre class="entity-test-out is-stderr">${testResult.stderr}</pre>` : null}
|
|
120
|
+
</div>` : null}
|
|
90
121
|
<div class="entity-form-actions">
|
|
122
|
+
${onTest ? html`
|
|
123
|
+
<button type="button" class="action small subtle entity-test-button"
|
|
124
|
+
disabled=${testing} onClick=${runTest}>
|
|
125
|
+
${testing ? 'Testing…' : testLabel}
|
|
126
|
+
</button>` : null}
|
|
91
127
|
<button type="button" class="action small subtle" onClick=${onClose}>Cancel</button>
|
|
92
128
|
<button type="submit" class=${`action small ${danger ? 'danger' : 'primary'}`}
|
|
93
129
|
disabled=${saving}>
|
|
@@ -56,47 +56,6 @@ function SessionRow({ s }) {
|
|
|
56
56
|
}
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
-
const onContext = async (ev) => {
|
|
60
|
-
ev.preventDefault();
|
|
61
|
-
ev.stopPropagation();
|
|
62
|
-
// Quick menu: Rename / Move / Delete. We use sequential prompts
|
|
63
|
-
// to avoid building a real context-menu component for now.
|
|
64
|
-
const action = await ccsmPrompt(
|
|
65
|
-
`${title} · ${running ? 'running' : 'stopped'}\nType: rename / move / delete / resume / cancel`,
|
|
66
|
-
'cancel', { title: s.id, okLabel: 'OK' });
|
|
67
|
-
if (!action) return;
|
|
68
|
-
const verb = action.trim().toLowerCase();
|
|
69
|
-
if (verb === 'rename') {
|
|
70
|
-
const next = await ccsmPrompt('New title', title, { title: 'Rename session', okLabel: 'Save' });
|
|
71
|
-
if (next === null) return;
|
|
72
|
-
try { await setSessionTitle(s.id, next.trim()); setToast('renamed'); }
|
|
73
|
-
catch (e) { setToast(e.message, 'error'); }
|
|
74
|
-
} else if (verb === 'move') {
|
|
75
|
-
// Move to a folder by name
|
|
76
|
-
const folderNames = folders.value.map((f) => f.name).join(', ');
|
|
77
|
-
const target = await ccsmPrompt(
|
|
78
|
-
`Move to which folder? (empty = Unsorted)\nExisting: ${folderNames || '(none)'}`,
|
|
79
|
-
'', { title: 'Move', okLabel: 'Move' });
|
|
80
|
-
if (target === null) return;
|
|
81
|
-
const t = target.trim();
|
|
82
|
-
const folder = t ? folders.value.find((f) => f.name.toLowerCase() === t.toLowerCase()) : null;
|
|
83
|
-
if (t && !folder) { setToast(`no folder named "${t}"`, 'error'); return; }
|
|
84
|
-
try { await setSessionFolder(s.id, folder ? folder.id : null); setToast('moved'); }
|
|
85
|
-
catch (e) { setToast(e.message, 'error'); }
|
|
86
|
-
} else if (verb === 'delete') {
|
|
87
|
-
const ok = await ccsmConfirm(`Delete session ${title}? PTY will be killed if alive.`, {
|
|
88
|
-
title: 'Delete session', okLabel: 'Delete', danger: true });
|
|
89
|
-
if (!ok) return;
|
|
90
|
-
try {
|
|
91
|
-
await deleteSession(s.id);
|
|
92
|
-
if (activeSessionId.value === s.id) activeSessionId.value = null;
|
|
93
|
-
} catch (e) { setToast(e.message, 'error'); }
|
|
94
|
-
} else if (verb === 'resume' && !running) {
|
|
95
|
-
try { await resumeSession(s.id); selectSession(s.id); }
|
|
96
|
-
catch (e) { setToast(e.message, 'error'); }
|
|
97
|
-
}
|
|
98
|
-
};
|
|
99
|
-
|
|
100
59
|
const onRenameClick = async (ev) => {
|
|
101
60
|
ev.preventDefault();
|
|
102
61
|
ev.stopPropagation();
|
|
@@ -135,7 +94,6 @@ function SessionRow({ s }) {
|
|
|
135
94
|
onDragStart=${onDragStart}
|
|
136
95
|
onDragEnd=${onDragEnd}
|
|
137
96
|
onClick=${onClick}
|
|
138
|
-
onContextMenu=${onContext}
|
|
139
97
|
title=${`${title}\n${s.cwd}\n${running ? 'running' : 'stopped'} · ${s.cliId}`}>
|
|
140
98
|
<span class=${`tree-dot ${running ? 'is-running' : 'is-stopped'}`}></span>
|
|
141
99
|
<span class="tree-label">${title}</span>
|
|
@@ -86,6 +86,22 @@ export function TerminalView({ terminalId }) {
|
|
|
86
86
|
} catch (e) {
|
|
87
87
|
console.warn('[ccsm] WebGL addon failed, using DOM renderer:', e);
|
|
88
88
|
}
|
|
89
|
+
// Ctrl+C with a selection: by default xterm.js sends \x03 AND the
|
|
90
|
+
// browser's own copy event fires — so the user gets "selection
|
|
91
|
+
// copied to clipboard" AND the running CLI gets SIGINT. Mirror
|
|
92
|
+
// VSCode/Windows Terminal behaviour: when there's a selection,
|
|
93
|
+
// suppress \x03 and let the copy event do its thing. With no
|
|
94
|
+
// selection, Ctrl+C still sends \x03 normally.
|
|
95
|
+
term.attachCustomKeyEventHandler((ev) => {
|
|
96
|
+
if (ev.type === 'keydown'
|
|
97
|
+
&& ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey
|
|
98
|
+
&& ev.key.toLowerCase() === 'c'
|
|
99
|
+
&& term.hasSelection()) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
return true;
|
|
103
|
+
});
|
|
104
|
+
|
|
89
105
|
const host = hostRef.current;
|
|
90
106
|
term.open(host);
|
|
91
107
|
// Defer fit one tick so the container has measured layout
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from '../state.js';
|
|
11
11
|
import {
|
|
12
12
|
api, loadConfig, loadWorkspaces, loadFolders,
|
|
13
|
-
createCli, updateCli, deleteCli, setDefaultCli,
|
|
13
|
+
createCli, updateCli, deleteCli, setDefaultCli, testCli,
|
|
14
14
|
createRepo, updateRepo, deleteRepo,
|
|
15
15
|
createFolder, renameFolder, deleteFolder, reorderFolders,
|
|
16
16
|
deleteWorkspace,
|
|
@@ -252,6 +252,7 @@ export function ConfigurePage() {
|
|
|
252
252
|
${edit?.kind === 'cli-new' ? html`
|
|
253
253
|
<${EntityFormModal} title="New CLI" fields=${cliFieldsFor({ creating: true })}
|
|
254
254
|
onClose=${close} submitLabel="Create"
|
|
255
|
+
onTest=${(v) => testCli({ command: v.command, shell: v.shell, type: v.type })}
|
|
255
256
|
onSubmit=${async (v) => {
|
|
256
257
|
try { await createCli(v); setToast(`created CLI · ${v.name}`); }
|
|
257
258
|
catch (e) { setToast(e.message, 'error'); throw e; }
|
|
@@ -259,7 +260,7 @@ export function ConfigurePage() {
|
|
|
259
260
|
|
|
260
261
|
${edit?.kind === 'cli-edit' ? html`
|
|
261
262
|
<${EntityFormModal} title=${`Edit ${edit.payload.name}`} fields=${cliFieldsFor()}
|
|
262
|
-
readOnlyKeys=${edit.payload.builtin ? ['type'
|
|
263
|
+
readOnlyKeys=${edit.payload.builtin ? ['type'] : []}
|
|
263
264
|
initial=${{
|
|
264
265
|
...edit.payload,
|
|
265
266
|
args: (edit.payload.args || []).join(' '),
|
|
@@ -267,6 +268,7 @@ export function ConfigurePage() {
|
|
|
267
268
|
resumeIdArgs: (edit.payload.resumeIdArgs || []).join(' '),
|
|
268
269
|
}}
|
|
269
270
|
onClose=${close}
|
|
271
|
+
onTest=${(v) => testCli({ command: v.command, shell: v.shell, type: v.type })}
|
|
270
272
|
onSubmit=${async (v) => {
|
|
271
273
|
try {
|
|
272
274
|
const patch = {
|
|
@@ -275,8 +277,6 @@ export function ConfigurePage() {
|
|
|
275
277
|
resumeArgs: typeof v.resumeArgs === 'string' ? v.resumeArgs.split(/\s+/).filter(Boolean) : v.resumeArgs,
|
|
276
278
|
resumeIdArgs: typeof v.resumeIdArgs === 'string' ? v.resumeIdArgs.split(/\s+/).filter(Boolean) : v.resumeIdArgs,
|
|
277
279
|
};
|
|
278
|
-
// command is locked on builtins — drop any tampered value.
|
|
279
|
-
if (edit.payload.builtin) delete patch.command;
|
|
280
280
|
await updateCli(edit.payload.id, patch);
|
|
281
281
|
setToast('saved');
|
|
282
282
|
} catch (e) { setToast(e.message, 'error'); throw e; }
|
package/scripts/install.js
CHANGED
|
@@ -29,17 +29,34 @@ if (process.platform !== 'win32') {
|
|
|
29
29
|
// always means a first-time `npx @bakapiano/ccsm` gets the full "click
|
|
30
30
|
// to wake" UX without needing a separate `npm i -g`.
|
|
31
31
|
|
|
32
|
+
// Returns { ccsmCmd, isSandbox } where isSandbox=true means this install
|
|
33
|
+
// went into a non-default prefix (e.g. `npm i -g --prefix=<tmp>` from
|
|
34
|
+
// the in-app upgrade's test mode). For sandboxed installs we DO NOT
|
|
35
|
+
// touch the global launcher.vbs / ccsm:// protocol registration —
|
|
36
|
+
// otherwise we'd repoint them at a directory that gets deleted later.
|
|
32
37
|
function findCcsmCmd() {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
const givenPrefix = process.env.npm_config_prefix || null;
|
|
39
|
+
let defaultPrefix = null;
|
|
40
|
+
try {
|
|
41
|
+
// npm config get prefix when run WITHOUT --prefix returns the
|
|
42
|
+
// user's default global prefix; with --prefix it echoes the flag.
|
|
43
|
+
// We want the env-independent default so we can compare. INIT_CWD
|
|
44
|
+
// and a clean spawn give us the user-default value.
|
|
45
|
+
const r = spawnSync('npm', ['config', 'get', 'prefix'], {
|
|
46
|
+
encoding: 'utf8', shell: true,
|
|
47
|
+
env: { ...process.env, npm_config_prefix: '' },
|
|
48
|
+
});
|
|
49
|
+
defaultPrefix = r.stdout?.trim() || null;
|
|
50
|
+
} catch {}
|
|
51
|
+
const prefix = givenPrefix || defaultPrefix;
|
|
52
|
+
if (!prefix) return { ccsmCmd: null, isSandbox: false };
|
|
41
53
|
const candidate = path.join(prefix, 'ccsm.cmd');
|
|
42
|
-
|
|
54
|
+
const isSandbox = !!(givenPrefix && defaultPrefix
|
|
55
|
+
&& path.resolve(givenPrefix).toLowerCase() !== path.resolve(defaultPrefix).toLowerCase());
|
|
56
|
+
return {
|
|
57
|
+
ccsmCmd: fs.existsSync(candidate) ? candidate : null,
|
|
58
|
+
isSandbox,
|
|
59
|
+
};
|
|
43
60
|
}
|
|
44
61
|
|
|
45
62
|
// Write a tiny VBScript wrapper that ccsm:// dispatches into. Why VBS:
|
|
@@ -91,13 +108,17 @@ function registerProtocol(vbsPath) {
|
|
|
91
108
|
}
|
|
92
109
|
}
|
|
93
110
|
|
|
94
|
-
const ccsmCmd = (() => {
|
|
95
|
-
try { return findCcsmCmd(); } catch { return null; }
|
|
111
|
+
const { ccsmCmd, isSandbox } = (() => {
|
|
112
|
+
try { return findCcsmCmd(); } catch { return { ccsmCmd: null, isSandbox: false }; }
|
|
96
113
|
})();
|
|
97
114
|
if (!ccsmCmd) {
|
|
98
115
|
warn('could not locate ccsm.cmd · skipping protocol registration');
|
|
99
116
|
process.exit(0);
|
|
100
117
|
}
|
|
118
|
+
if (isSandbox) {
|
|
119
|
+
log(`sandbox install detected (prefix=${process.env.npm_config_prefix}) · skipping global launcher.vbs + protocol registration + auto-launch`);
|
|
120
|
+
process.exit(0);
|
|
121
|
+
}
|
|
101
122
|
|
|
102
123
|
try {
|
|
103
124
|
const vbsPath = writeLauncherVbs(ccsmCmd);
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// In-app upgrade helper · spawned detached by /api/upgrade.
|
|
5
|
+
//
|
|
6
|
+
// The previous implementation kicked off `npm i -g` directly from the
|
|
7
|
+
// running server. On Windows that fails with EBUSY: npm tries to rename
|
|
8
|
+
// the package directory but the server has files open inside it.
|
|
9
|
+
//
|
|
10
|
+
// This script breaks the cycle:
|
|
11
|
+
//
|
|
12
|
+
// 1. Server validates the upgrade request, spawns this helper detached
|
|
13
|
+
// with [target, port, pid] argv, sends 200 OK, then gracefulShutdowns.
|
|
14
|
+
// 2. Helper waits for the old port to free up + the old pid to die.
|
|
15
|
+
// 3. Helper runs `npm i -g @bakapiano/ccsm@<target>` synchronously.
|
|
16
|
+
// 4. On success it spawns `ccsm` detached (which spins up the new
|
|
17
|
+
// backend on the same port) and exits.
|
|
18
|
+
//
|
|
19
|
+
// Logs everything to ~/.ccsm/upgrade.log so a failed upgrade is
|
|
20
|
+
// debuggable without the user needing to re-run the command manually.
|
|
21
|
+
//
|
|
22
|
+
// Argv: node upgrade-helper.js <target> <port> <pid> [installPrefix] [respawn=1|0]
|
|
23
|
+
// - installPrefix: when set, runs `npm i -g --prefix=<this>` so the
|
|
24
|
+
// global install can be redirected to a sandbox dir for testing
|
|
25
|
+
// against a live prod install without disturbing it. Respawn then
|
|
26
|
+
// uses <prefix>/ccsm.cmd (Windows) or <prefix>/bin/ccsm (posix).
|
|
27
|
+
// - respawn: '0' skips the final ccsm respawn (also useful for tests).
|
|
28
|
+
|
|
29
|
+
const fs = require('node:fs');
|
|
30
|
+
const path = require('node:path');
|
|
31
|
+
const os = require('node:os');
|
|
32
|
+
const net = require('node:net');
|
|
33
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
34
|
+
|
|
35
|
+
const target = process.argv[2] || 'latest';
|
|
36
|
+
const oldPort = Number(process.argv[3] || 7777);
|
|
37
|
+
const oldPid = Number(process.argv[4] || 0);
|
|
38
|
+
const installPrefix = process.argv[5] || '';
|
|
39
|
+
const doRespawn = process.argv[6] !== '0';
|
|
40
|
+
|
|
41
|
+
const HOME = process.env.CCSM_HOME || path.join(os.homedir(), '.ccsm');
|
|
42
|
+
const LOG = path.join(HOME, 'upgrade.log');
|
|
43
|
+
try { fs.mkdirSync(HOME, { recursive: true }); } catch {}
|
|
44
|
+
|
|
45
|
+
function log(msg) {
|
|
46
|
+
const line = `[${new Date().toISOString()}] ${msg}\n`;
|
|
47
|
+
try { fs.appendFileSync(LOG, line); } catch {}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
|
51
|
+
|
|
52
|
+
// Returns true once nothing answers on host:port within timeoutMs.
|
|
53
|
+
function portFree(port, timeoutMs = 800) {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const s = new net.Socket();
|
|
56
|
+
let settled = false;
|
|
57
|
+
const finish = (free) => { if (settled) return; settled = true; try { s.destroy(); } catch {} resolve(free); };
|
|
58
|
+
s.setTimeout(timeoutMs);
|
|
59
|
+
s.once('connect', () => finish(false));
|
|
60
|
+
s.once('timeout', () => finish(true));
|
|
61
|
+
s.once('error', () => finish(true));
|
|
62
|
+
s.connect(port, '127.0.0.1');
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function pidAlive(pid) {
|
|
67
|
+
if (!pid) return false;
|
|
68
|
+
try { process.kill(pid, 0); return true; }
|
|
69
|
+
catch (e) { return e.code === 'EPERM'; }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
(async () => {
|
|
73
|
+
log(`start · target=${target} oldPort=${oldPort} oldPid=${oldPid}${installPrefix ? ` prefix=${installPrefix}` : ''}${!doRespawn ? ' (no respawn)' : ''}`);
|
|
74
|
+
|
|
75
|
+
// Wait up to 30s for the old server to be gone. Both port-free AND
|
|
76
|
+
// pid-dead so we don't fight npm's rename for a stale file handle.
|
|
77
|
+
const deadline = Date.now() + 30_000;
|
|
78
|
+
while (Date.now() < deadline) {
|
|
79
|
+
const free = await portFree(oldPort);
|
|
80
|
+
const dead = !pidAlive(oldPid);
|
|
81
|
+
if (free && dead) break;
|
|
82
|
+
await sleep(250);
|
|
83
|
+
}
|
|
84
|
+
log(`old server gone (or 30s elapsed) · running npm install`);
|
|
85
|
+
|
|
86
|
+
// npm.cmd is a batch wrapper on Windows; spawn it via cmd.exe /c so
|
|
87
|
+
// we don't need shell:true (which would mean argv quoting). target
|
|
88
|
+
// has already been regex-validated server-side so this is safe.
|
|
89
|
+
const isWin = process.platform === 'win32';
|
|
90
|
+
const arg = `@bakapiano/ccsm@${target}`;
|
|
91
|
+
const npmArgs = ['i', '-g'];
|
|
92
|
+
if (installPrefix) {
|
|
93
|
+
try { fs.mkdirSync(installPrefix, { recursive: true }); } catch {}
|
|
94
|
+
npmArgs.push(`--prefix=${installPrefix}`);
|
|
95
|
+
}
|
|
96
|
+
npmArgs.push(arg);
|
|
97
|
+
let r;
|
|
98
|
+
if (isWin) {
|
|
99
|
+
r = spawnSync(process.env.ComSpec || 'cmd.exe',
|
|
100
|
+
['/d', '/s', '/c', 'npm', ...npmArgs],
|
|
101
|
+
{ stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
102
|
+
} else {
|
|
103
|
+
r = spawnSync('npm', npmArgs,
|
|
104
|
+
{ stdio: ['ignore', 'pipe', 'pipe'] });
|
|
105
|
+
}
|
|
106
|
+
const stdout = r.stdout?.toString().trim();
|
|
107
|
+
const stderr = r.stderr?.toString().trim();
|
|
108
|
+
log(`npm exit=${r.status}${stdout ? `\nSTDOUT:\n${stdout}` : ''}${stderr ? `\nSTDERR:\n${stderr}` : ''}`);
|
|
109
|
+
if (r.status !== 0) {
|
|
110
|
+
log(`upgrade failed · not respawning`);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!doRespawn) {
|
|
115
|
+
log(`respawn skipped (respawn=0)`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Respawn ccsm. With installPrefix the binary lives there; otherwise
|
|
120
|
+
// it's on PATH from the global npm install. The launcher handles
|
|
121
|
+
// detect-or-spawn-server and detaches.
|
|
122
|
+
//
|
|
123
|
+
// On Windows, CreateProcess refuses to spawn .cmd / .bat directly —
|
|
124
|
+
// they're cmd.exe scripts, not native exes. Route through cmd.exe /c
|
|
125
|
+
// so it loads the wrapper.
|
|
126
|
+
const ccsmCmd = installPrefix
|
|
127
|
+
? (isWin ? path.join(installPrefix, 'ccsm.cmd') : path.join(installPrefix, 'bin', 'ccsm'))
|
|
128
|
+
: (isWin ? 'ccsm.cmd' : 'ccsm');
|
|
129
|
+
const childEnv = { ...process.env, CCSM_NO_BROWSER: '1' };
|
|
130
|
+
let exe, exeArgs;
|
|
131
|
+
if (isWin) {
|
|
132
|
+
exe = process.env.ComSpec || 'cmd.exe';
|
|
133
|
+
exeArgs = ['/d', '/s', '/c', ccsmCmd];
|
|
134
|
+
} else {
|
|
135
|
+
exe = ccsmCmd;
|
|
136
|
+
exeArgs = [];
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
const child = spawn(exe, exeArgs, {
|
|
140
|
+
detached: true,
|
|
141
|
+
stdio: 'ignore',
|
|
142
|
+
windowsHide: true,
|
|
143
|
+
shell: false,
|
|
144
|
+
env: childEnv,
|
|
145
|
+
});
|
|
146
|
+
child.unref();
|
|
147
|
+
log(`respawned ${ccsmCmd} (via ${path.basename(exe)})`);
|
|
148
|
+
} catch (e) {
|
|
149
|
+
log(`respawn failed: ${e.message}`);
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
})().catch((e) => {
|
|
153
|
+
log(`fatal: ${e.message}`);
|
|
154
|
+
process.exit(1);
|
|
155
|
+
});
|
package/server.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
const path = require('node:path');
|
|
5
|
+
const os = require('node:os');
|
|
5
6
|
const express = require('express');
|
|
6
7
|
|
|
7
8
|
const { loadConfig, saveConfig, DATA_DIR } = require('./lib/config');
|
|
@@ -267,16 +268,24 @@ async function maybeWatchCliSessionId({ cli, cwd, ccsmSessionId }) {
|
|
|
267
268
|
// If a previous watcher was still alive on this id (e.g. fast restart),
|
|
268
269
|
// tear it down first.
|
|
269
270
|
stopWatcher(ccsmSessionId);
|
|
271
|
+
let excludeIds = [];
|
|
270
272
|
try {
|
|
271
|
-
const
|
|
273
|
+
const all = await persistedSessions.loadAll();
|
|
274
|
+
const existing = all.find((s) => s.id === ccsmSessionId);
|
|
272
275
|
if (existing?.cliSessionId) {
|
|
273
276
|
console.log(`[cliSessionId] skip watcher · ${cli.type} session already known (${existing.cliSessionId})`);
|
|
274
277
|
return;
|
|
275
278
|
}
|
|
279
|
+
// Other ccsm sessions' upstream UUIDs — exclude so the watcher
|
|
280
|
+
// doesn't misclaim a sibling's actively-touched transcript.
|
|
281
|
+
excludeIds = all
|
|
282
|
+
.filter((s) => s.id !== ccsmSessionId && s.cliSessionId)
|
|
283
|
+
.map((s) => s.cliSessionId);
|
|
276
284
|
} catch {}
|
|
277
285
|
const cleanup = cliSessionWatcher.captureSessionId({
|
|
278
286
|
cliType: cli.type,
|
|
279
287
|
cwd,
|
|
288
|
+
excludeIds,
|
|
280
289
|
onCapture: (cliSessionId) => {
|
|
281
290
|
activeWatchers.delete(ccsmSessionId);
|
|
282
291
|
persistedSessions.update(ccsmSessionId, { cliSessionId }).catch((e) => {
|
|
@@ -393,7 +402,92 @@ app.put('/api/config', asyncH(async (req, res) => {
|
|
|
393
402
|
res.json(decorateConfigWithProbes(cfg));
|
|
394
403
|
}));
|
|
395
404
|
|
|
396
|
-
// ----
|
|
405
|
+
// ---- CLI probe / test ----
|
|
406
|
+
//
|
|
407
|
+
// Run the user's configured command with `--version` and report back
|
|
408
|
+
// stdout/stderr + whether the output looks like the claimed CLI type.
|
|
409
|
+
// Used by the Configure page "Test" button so the user can verify the
|
|
410
|
+
// command resolves + actually launches the right tool BEFORE saving.
|
|
411
|
+
// Body: { command, args?, shell?, type? }. args is ignored for the
|
|
412
|
+
// version probe — we always append `--version` directly so the user's
|
|
413
|
+
// runtime args (e.g. --dangerously-skip-permissions) don't perturb the
|
|
414
|
+
// quick probe.
|
|
415
|
+
app.post('/api/clis/test', asyncH(async (req, res) => {
|
|
416
|
+
const { spawn } = require('node:child_process');
|
|
417
|
+
const body = req.body || {};
|
|
418
|
+
const command = String(body.command || '').trim();
|
|
419
|
+
const shell = ['direct', 'pwsh', 'cmd'].includes(body.shell) ? body.shell : 'direct';
|
|
420
|
+
const type = ['claude', 'codex', 'copilot', 'other'].includes(body.type) ? body.type : 'other';
|
|
421
|
+
if (!command) return res.status(400).json({ error: 'command required' });
|
|
422
|
+
|
|
423
|
+
// Build the test exec. Same shell-wrapping rules as resolveCommand,
|
|
424
|
+
// but we force `--version` as the only arg and we DROP `-NoExit`
|
|
425
|
+
// from the pwsh wrapper so pwsh terminates after printing.
|
|
426
|
+
let exe, args;
|
|
427
|
+
const cmd = command.replace(/^\.[\\\/]/, '');
|
|
428
|
+
const versionArg = '--version';
|
|
429
|
+
if (shell === 'pwsh') {
|
|
430
|
+
const joined = `& ${/[\s'"\`$]/.test(cmd) ? `'${cmd.replace(/'/g, "''")}'` : cmd} ${versionArg}`;
|
|
431
|
+
exe = 'pwsh.exe';
|
|
432
|
+
args = ['-NoLogo', '-Command', joined];
|
|
433
|
+
} else if (shell === 'cmd') {
|
|
434
|
+
exe = process.env.ComSpec || 'cmd.exe';
|
|
435
|
+
args = ['/d', '/s', '/c', `${cmd} ${versionArg}`];
|
|
436
|
+
} else if (path.isAbsolute(cmd)) {
|
|
437
|
+
const ext = path.extname(cmd).toLowerCase();
|
|
438
|
+
if (ext === '.cmd' || ext === '.bat') {
|
|
439
|
+
exe = process.env.ComSpec || 'cmd.exe';
|
|
440
|
+
args = ['/d', '/s', '/c', cmd, versionArg];
|
|
441
|
+
} else if (ext === '.ps1') {
|
|
442
|
+
exe = 'powershell.exe';
|
|
443
|
+
args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', cmd, versionArg];
|
|
444
|
+
} else {
|
|
445
|
+
exe = cmd;
|
|
446
|
+
args = [versionArg];
|
|
447
|
+
}
|
|
448
|
+
} else {
|
|
449
|
+
exe = process.env.ComSpec || 'cmd.exe';
|
|
450
|
+
args = ['/d', '/s', '/c', cmd, versionArg];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const t0 = Date.now();
|
|
454
|
+
let stdout = '';
|
|
455
|
+
let stderr = '';
|
|
456
|
+
let exitCode = null;
|
|
457
|
+
let timedOut = false;
|
|
458
|
+
let spawnError = null;
|
|
459
|
+
try {
|
|
460
|
+
const child = spawn(exe, args, { env: spawnEnv(), windowsHide: true });
|
|
461
|
+
const killer = setTimeout(() => { timedOut = true; try { child.kill(); } catch {} }, 5000);
|
|
462
|
+
child.stdout.on('data', (d) => { stdout += d.toString(); if (stdout.length > 8192) stdout = stdout.slice(0, 8192); });
|
|
463
|
+
child.stderr.on('data', (d) => { stderr += d.toString(); if (stderr.length > 8192) stderr = stderr.slice(0, 8192); });
|
|
464
|
+
exitCode = await new Promise((resolve, reject) => {
|
|
465
|
+
child.on('exit', (code) => { clearTimeout(killer); resolve(code); });
|
|
466
|
+
child.on('error', (err) => { clearTimeout(killer); reject(err); });
|
|
467
|
+
});
|
|
468
|
+
} catch (e) {
|
|
469
|
+
spawnError = String(e && e.message || e);
|
|
470
|
+
}
|
|
471
|
+
const durationMs = Date.now() - t0;
|
|
472
|
+
|
|
473
|
+
const out = (stdout + '\n' + stderr).toLowerCase();
|
|
474
|
+
const PATTERNS = {
|
|
475
|
+
claude: /claude/,
|
|
476
|
+
codex: /codex|openai/,
|
|
477
|
+
copilot: /copilot/,
|
|
478
|
+
};
|
|
479
|
+
const matchedType = type === 'other' ? null : (PATTERNS[type] ? PATTERNS[type].test(out) : null);
|
|
480
|
+
const ok = !spawnError && !timedOut && exitCode === 0;
|
|
481
|
+
res.json({
|
|
482
|
+
ok, exitCode, durationMs, timedOut, spawnError,
|
|
483
|
+
stdout: stdout.trim(),
|
|
484
|
+
stderr: stderr.trim(),
|
|
485
|
+
matchedType,
|
|
486
|
+
expectedType: type,
|
|
487
|
+
spawned: { exe, args },
|
|
488
|
+
});
|
|
489
|
+
}));
|
|
490
|
+
|
|
397
491
|
// ---- folders ----
|
|
398
492
|
|
|
399
493
|
app.get('/api/folders', asyncH(async (_req, res) => {
|
|
@@ -767,6 +861,14 @@ app.post('/api/sessions/:id/resume', asyncH(async (req, res) => {
|
|
|
767
861
|
// Already running and attached → no-op, just return its id.
|
|
768
862
|
const live = webTerminal.get(record.id);
|
|
769
863
|
if (live && !live.exitedAt) {
|
|
864
|
+
// Pool says we're alive but the record may be stale (e.g. a prior
|
|
865
|
+
// markRunning got clobbered by an OLD entry's onExit before the
|
|
866
|
+
// respawn-guard landed, or boot mark-exited ran after a pool entry
|
|
867
|
+
// was already wired). Reconcile the file to match the pool so the
|
|
868
|
+
// frontend doesn't get stuck on "Resuming session…" forever.
|
|
869
|
+
if (record.status !== 'running' || record.pid !== live.meta.pid) {
|
|
870
|
+
try { await persistedSessions.markRunning(record.id, live.meta.pid); } catch {}
|
|
871
|
+
}
|
|
770
872
|
return res.json({ launched: { id: record.id, pid: live.meta.pid, cliId: record.cliId } });
|
|
771
873
|
}
|
|
772
874
|
const cfg = await loadConfig();
|
|
@@ -922,55 +1024,60 @@ app.post('/api/upgrade', asyncH(async (req, res) => {
|
|
|
922
1024
|
if (upgradeInFlight) {
|
|
923
1025
|
return res.status(409).json({ error: 'upgrade already in progress' });
|
|
924
1026
|
}
|
|
925
|
-
|
|
926
|
-
const target = String(
|
|
1027
|
+
const body = req.body || {};
|
|
1028
|
+
const target = String(body.target || 'latest');
|
|
927
1029
|
// Refuse anything that doesn't look like a semver dist-tag or version
|
|
928
1030
|
// — defends against `;` etc. winding up in the spawn argv even though
|
|
929
1031
|
// we don't shell out.
|
|
930
1032
|
if (!/^[a-z0-9.+\-^~]+$/i.test(target)) {
|
|
931
|
-
upgradeInFlight = false;
|
|
932
1033
|
return res.status(400).json({ error: `invalid target: ${target}` });
|
|
933
1034
|
}
|
|
934
|
-
|
|
935
|
-
|
|
1035
|
+
// Optional sandbox install prefix (for testing without disturbing the
|
|
1036
|
+
// user's real global ccsm). Validated as a plain absolute path so it
|
|
1037
|
+
// can't be a flag injection.
|
|
1038
|
+
const installPrefix = body.installPrefix ? String(body.installPrefix) : '';
|
|
1039
|
+
if (installPrefix && (installPrefix.startsWith('-') || !path.isAbsolute(installPrefix))) {
|
|
1040
|
+
return res.status(400).json({ error: 'installPrefix must be an absolute path' });
|
|
1041
|
+
}
|
|
1042
|
+
const respawn = body.respawn === false ? '0' : '1';
|
|
1043
|
+
upgradeInFlight = true;
|
|
1044
|
+
console.log(`[upgrade] target=${target}${installPrefix ? ` prefix=${installPrefix}` : ''}${respawn === '0' ? ' (no respawn)' : ''}`);
|
|
936
1045
|
|
|
937
|
-
//
|
|
1046
|
+
// The helper runs OUTSIDE the package dir so npm can rename it
|
|
1047
|
+
// without fighting open file handles. Copy the script to os.tmpdir()
|
|
1048
|
+
// and spawn from there.
|
|
1049
|
+
const fsp = require('node:fs/promises');
|
|
1050
|
+
const helperSrc = path.join(__dirname, 'scripts', 'upgrade-helper.js');
|
|
1051
|
+
const helperTmp = path.join(os.tmpdir(), `ccsm-upgrade-${process.pid}-${Date.now()}.js`);
|
|
1052
|
+
try {
|
|
1053
|
+
await fsp.copyFile(helperSrc, helperTmp);
|
|
1054
|
+
} catch (e) {
|
|
1055
|
+
upgradeInFlight = false;
|
|
1056
|
+
return res.status(500).json({ error: `helper copy failed: ${e.message}` });
|
|
1057
|
+
}
|
|
1058
|
+
const args = [helperTmp, target, String(currentPort), String(process.pid), installPrefix, respawn];
|
|
1059
|
+
|
|
1060
|
+
res.json({ ok: true, started: true, target, helper: helperTmp });
|
|
1061
|
+
|
|
1062
|
+
// Flush response, then spawn helper detached and gracefulShutdown so
|
|
1063
|
+
// the helper's npm install isn't fighting our open file handles.
|
|
938
1064
|
setImmediate(() => {
|
|
939
1065
|
const { spawn } = require('node:child_process');
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
});
|
|
952
|
-
child.on('exit', (code) => {
|
|
953
|
-
console.log(`[upgrade] npm exit ${code}`);
|
|
1066
|
+
try {
|
|
1067
|
+
const child = spawn(process.execPath, args, {
|
|
1068
|
+
detached: true,
|
|
1069
|
+
stdio: 'ignore',
|
|
1070
|
+
windowsHide: true,
|
|
1071
|
+
shell: false,
|
|
1072
|
+
});
|
|
1073
|
+
child.unref();
|
|
1074
|
+
console.log(`[upgrade] helper pid=${child.pid}, shutting down`);
|
|
1075
|
+
} catch (e) {
|
|
1076
|
+
console.error('[upgrade] helper spawn failed:', e.message);
|
|
954
1077
|
upgradeInFlight = false;
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
try {
|
|
959
|
-
const ccsmCmd = process.platform === 'win32' ? 'ccsm.cmd' : 'ccsm';
|
|
960
|
-
const respawn = spawn(ccsmCmd, [], {
|
|
961
|
-
detached: true,
|
|
962
|
-
stdio: 'ignore',
|
|
963
|
-
windowsHide: true,
|
|
964
|
-
shell: false,
|
|
965
|
-
env: { ...process.env, CCSM_NO_BROWSER: '1' },
|
|
966
|
-
});
|
|
967
|
-
respawn.unref();
|
|
968
|
-
} catch (e) {
|
|
969
|
-
console.error('[upgrade] respawn failed:', e.message);
|
|
970
|
-
}
|
|
971
|
-
setTimeout(() => gracefulShutdown('upgrade'), 1500);
|
|
972
|
-
});
|
|
973
|
-
child.unref();
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
setTimeout(() => gracefulShutdown('upgrade'), 500);
|
|
974
1081
|
});
|
|
975
1082
|
}));
|
|
976
1083
|
|