@bakapiano/ccsm 0.13.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/package.json +1 -1
- package/scripts/install.js +32 -11
- package/scripts/upgrade-helper.js +155 -0
- package/server.js +62 -40
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/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/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) => {
|
|
@@ -852,6 +861,14 @@ app.post('/api/sessions/:id/resume', asyncH(async (req, res) => {
|
|
|
852
861
|
// Already running and attached → no-op, just return its id.
|
|
853
862
|
const live = webTerminal.get(record.id);
|
|
854
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
|
+
}
|
|
855
872
|
return res.json({ launched: { id: record.id, pid: live.meta.pid, cliId: record.cliId } });
|
|
856
873
|
}
|
|
857
874
|
const cfg = await loadConfig();
|
|
@@ -1007,55 +1024,60 @@ app.post('/api/upgrade', asyncH(async (req, res) => {
|
|
|
1007
1024
|
if (upgradeInFlight) {
|
|
1008
1025
|
return res.status(409).json({ error: 'upgrade already in progress' });
|
|
1009
1026
|
}
|
|
1010
|
-
|
|
1011
|
-
const target = String(
|
|
1027
|
+
const body = req.body || {};
|
|
1028
|
+
const target = String(body.target || 'latest');
|
|
1012
1029
|
// Refuse anything that doesn't look like a semver dist-tag or version
|
|
1013
1030
|
// — defends against `;` etc. winding up in the spawn argv even though
|
|
1014
1031
|
// we don't shell out.
|
|
1015
1032
|
if (!/^[a-z0-9.+\-^~]+$/i.test(target)) {
|
|
1016
|
-
upgradeInFlight = false;
|
|
1017
1033
|
return res.status(400).json({ error: `invalid target: ${target}` });
|
|
1018
1034
|
}
|
|
1019
|
-
|
|
1020
|
-
|
|
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)' : ''}`);
|
|
1045
|
+
|
|
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];
|
|
1021
1059
|
|
|
1022
|
-
|
|
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.
|
|
1023
1064
|
setImmediate(() => {
|
|
1024
1065
|
const { spawn } = require('node:child_process');
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
});
|
|
1037
|
-
child.on('exit', (code) => {
|
|
1038
|
-
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);
|
|
1039
1077
|
upgradeInFlight = false;
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
try {
|
|
1044
|
-
const ccsmCmd = process.platform === 'win32' ? 'ccsm.cmd' : 'ccsm';
|
|
1045
|
-
const respawn = spawn(ccsmCmd, [], {
|
|
1046
|
-
detached: true,
|
|
1047
|
-
stdio: 'ignore',
|
|
1048
|
-
windowsHide: true,
|
|
1049
|
-
shell: false,
|
|
1050
|
-
env: { ...process.env, CCSM_NO_BROWSER: '1' },
|
|
1051
|
-
});
|
|
1052
|
-
respawn.unref();
|
|
1053
|
-
} catch (e) {
|
|
1054
|
-
console.error('[upgrade] respawn failed:', e.message);
|
|
1055
|
-
}
|
|
1056
|
-
setTimeout(() => gracefulShutdown('upgrade'), 1500);
|
|
1057
|
-
});
|
|
1058
|
-
child.unref();
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
setTimeout(() => gracefulShutdown('upgrade'), 500);
|
|
1059
1081
|
});
|
|
1060
1082
|
}));
|
|
1061
1083
|
|