@bakapiano/ccsm 0.13.0 → 0.15.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.
Files changed (53) hide show
  1. package/CLAUDE.md +474 -475
  2. package/README.md +189 -190
  3. package/bin/ccsm.js +194 -194
  4. package/lib/cliActivity.js +118 -0
  5. package/lib/codexSeed.js +147 -0
  6. package/lib/config.js +211 -188
  7. package/lib/folders.js +105 -105
  8. package/lib/localCliSessions.js +489 -489
  9. package/lib/persistedSessions.js +144 -142
  10. package/lib/webTerminal.js +224 -224
  11. package/lib/workspace.js +230 -230
  12. package/package.json +57 -57
  13. package/public/css/base.css +99 -99
  14. package/public/css/cards.css +183 -183
  15. package/public/css/feedback.css +303 -303
  16. package/public/css/forms.css +405 -405
  17. package/public/css/layout.css +160 -160
  18. package/public/css/modal.css +190 -190
  19. package/public/css/responsive.css +10 -10
  20. package/public/css/sidebar.css +613 -608
  21. package/public/css/terminals.css +294 -294
  22. package/public/css/tokens.css +81 -81
  23. package/public/css/wco.css +98 -98
  24. package/public/css/widgets.css +1628 -1628
  25. package/public/index.html +111 -105
  26. package/public/js/api.js +296 -280
  27. package/public/js/components/AdoptModal.js +343 -343
  28. package/public/js/components/App.js +35 -35
  29. package/public/js/components/DirectoryPicker.js +203 -203
  30. package/public/js/components/EntityFormModal.js +141 -141
  31. package/public/js/components/Modal.js +51 -51
  32. package/public/js/components/OfflineBanner.js +93 -93
  33. package/public/js/components/PageTitleBar.js +13 -13
  34. package/public/js/components/Picker.js +179 -179
  35. package/public/js/components/Popover.js +55 -55
  36. package/public/js/components/Sidebar.js +299 -299
  37. package/public/js/components/TerminalView.js +314 -314
  38. package/public/js/components/useDragSort.js +67 -67
  39. package/public/js/dialog.js +67 -67
  40. package/public/js/icons.js +177 -177
  41. package/public/js/main.js +132 -132
  42. package/public/js/pages/AboutPage.js +165 -165
  43. package/public/js/pages/ConfigurePage.js +505 -475
  44. package/public/js/pages/LaunchPage.js +369 -369
  45. package/public/js/pages/SessionsPage.js +101 -97
  46. package/public/js/state.js +231 -231
  47. package/scripts/dev.js +44 -11
  48. package/scripts/install.js +158 -137
  49. package/scripts/restart-helper.js +91 -0
  50. package/scripts/upgrade-helper.js +155 -0
  51. package/server.js +1278 -1232
  52. package/lib/cliSessionWatcher.js +0 -249
  53. package/public/manifest.webmanifest +0 -15
@@ -1,137 +1,158 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- // ccsm postinstall · Windows-only · runs after `npm install -g @bakapiano/ccsm`.
5
- // Registers the `ccsm://` URL protocol in HKCU so the hosted frontend
6
- // (https://bakapiano.github.io/ccsm/v1/) can fire `<a href="ccsm://start">`
7
- // from its OfflineBanner and have Windows spawn the backend on demand.
8
- //
9
- // Best-effort: any failure MUST NOT break npm install. Each step is in
10
- // its own try/catch; we just log and move on.
11
- //
12
- // No .lnk file, no Start Menu shortcut — just the protocol handler.
13
-
14
- const path = require('node:path');
15
- const fs = require('node:fs');
16
- const { spawnSync } = require('node:child_process');
17
-
18
- function log(msg) { process.stdout.write(`[ccsm install] ${msg}\n`); }
19
- function warn(msg) { process.stderr.write(`[ccsm install] ${msg}\n`); }
20
-
21
- if (process.platform !== 'win32') {
22
- log('non-Windows · skipping ccsm:// registration');
23
- process.exit(0);
24
- }
25
- // Note: we DO register on npx-cache installs too (not just global). The
26
- // npx cache path is stable across re-runs of the same package, and even
27
- // if the user later cleans the cache, the only consequence is the
28
- // OfflineBanner button no-ops — nothing actively broken. Registering
29
- // always means a first-time `npx @bakapiano/ccsm` gets the full "click
30
- // to wake" UX without needing a separate `npm i -g`.
31
-
32
- function findCcsmCmd() {
33
- const prefix = process.env.npm_config_prefix
34
- || (() => {
35
- try {
36
- const r = spawnSync('npm', ['config', 'get', 'prefix'], { encoding: 'utf8', shell: true });
37
- return r.stdout?.trim() || null;
38
- } catch { return null; }
39
- })();
40
- if (!prefix) return null;
41
- const candidate = path.join(prefix, 'ccsm.cmd');
42
- return fs.existsSync(candidate) ? candidate : null;
43
- }
44
-
45
- // Write a tiny VBScript wrapper that ccsm:// dispatches into. Why VBS:
46
- // wscript.exe is a Windows-subsystem host (no console window), and
47
- // `Shell.Run(..., 0, False)` launches the target completely hidden — so
48
- // when the user clicks ccsm://start, NOTHING flashes on screen, the
49
- // backend just appears in the next health probe.
50
- function writeLauncherVbs(ccsmCmd) {
51
- const home = process.env.LOCALAPPDATA || process.env.APPDATA;
52
- if (!home) throw new Error('no LOCALAPPDATA/APPDATA env var');
53
- const dir = path.join(home, 'ccsm');
54
- fs.mkdirSync(dir, { recursive: true });
55
- const vbsPath = path.join(dir, 'launcher.vbs');
56
- // Escape any double-quotes in the cmd path (rare but possible).
57
- const cmdEsc = ccsmCmd.replace(/"/g, '""');
58
- const vbs = [
59
- "' ccsm protocol launcher · invoked by wscript.exe via the registered",
60
- "' ccsm:// URL handler. Spawns ccsm.cmd with WindowStyle 0 (hidden) +",
61
- "' bWaitOnReturn=False (async), so the click leaves zero visible trace.",
62
- 'If WScript.Arguments.Count >= 1 Then',
63
- ' arg = WScript.Arguments(0)',
64
- 'Else',
65
- ' arg = ""',
66
- 'End If',
67
- 'Set sh = CreateObject("WScript.Shell")',
68
- `sh.Run """${cmdEsc}"" """ & arg & """", 0, False`,
69
- '',
70
- ].join('\r\n');
71
- fs.writeFileSync(vbsPath, vbs, { encoding: 'utf8' });
72
- return vbsPath;
73
- }
74
-
75
- function registerProtocol(vbsPath) {
76
- // wscript.exe is a no-console host. The protocol-registered command
77
- // hands the entire ccsm:// URL to launcher.vbs as argv[0]; the VBS
78
- // forwards it to ccsm.cmd "%1" with a hidden window.
79
- const command = `wscript.exe "${vbsPath}" "%1"`;
80
- const root = 'HKCU\\Software\\Classes\\ccsm';
81
- const calls = [
82
- ['add', root, '/ve', '/d', 'URL:ccsm protocol', '/f'],
83
- ['add', root, '/v', 'URL Protocol', '/d', '', '/f'],
84
- ['add', `${root}\\shell\\open\\command`, '/ve', '/d', command, '/f'],
85
- ];
86
- for (const args of calls) {
87
- const r = spawnSync('reg.exe', args, { windowsHide: true });
88
- if (r.status !== 0) {
89
- throw new Error(`reg ${args.join(' ')} → exit ${r.status}: ${r.stderr?.toString() || ''}`);
90
- }
91
- }
92
- }
93
-
94
- const ccsmCmd = (() => {
95
- try { return findCcsmCmd(); } catch { return null; }
96
- })();
97
- if (!ccsmCmd) {
98
- warn('could not locate ccsm.cmd · skipping protocol registration');
99
- process.exit(0);
100
- }
101
-
102
- try {
103
- const vbsPath = writeLauncherVbs(ccsmCmd);
104
- registerProtocol(vbsPath);
105
- log(`launcher · ${vbsPath}`);
106
- log(`ccsm:// protocol registered (silent · via wscript.exe)`);
107
- } catch (e) {
108
- warn(`failed · ${e.message}`);
109
- warn('the hosted frontend\'s "Start ccsm" button will not be able to launch the backend. You can still run `ccsm` manually in a terminal.');
110
- }
111
-
112
- // Auto-launch ccsm after install so the user lands directly in the app
113
- // without needing a second command. Detached + windowsHide so the npm
114
- // install command returns immediately. Skip if CCSM_NO_AUTOLAUNCH=1 is
115
- // set (CI, headless setups).
116
- if (process.env.CCSM_NO_AUTOLAUNCH !== '1') {
117
- try {
118
- const { spawn } = require('node:child_process');
119
- // Spawn `node bin/ccsm.js` directly NOT ccsm.cmd. On Windows,
120
- // child_process.spawn() with shell:false refuses .cmd files (throws
121
- // EINVAL); using shell:true would flash a console window. Going
122
- // through node + the JS entrypoint sidesteps both problems and
123
- // matches exactly what the .cmd shim would have invoked.
124
- const launcherJs = path.join(__dirname, '..', 'bin', 'ccsm.js');
125
- const child = spawn(process.execPath, [launcherJs], {
126
- detached: true,
127
- stdio: 'ignore',
128
- windowsHide: true,
129
- });
130
- child.unref();
131
- log('launching ccsm now · check for the chromeless window');
132
- log('(set CCSM_NO_AUTOLAUNCH=1 to skip this on future installs)');
133
- } catch (e) {
134
- warn(`auto-launch failed · ${e.message}`);
135
- warn('run `ccsm` manually to start.');
136
- }
137
- }
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // ccsm postinstall · Windows-only · runs after `npm install -g @bakapiano/ccsm`.
5
+ // Registers the `ccsm://` URL protocol in HKCU so the hosted frontend
6
+ // (https://bakapiano.github.io/ccsm/v1/) can fire `<a href="ccsm://start">`
7
+ // from its OfflineBanner and have Windows spawn the backend on demand.
8
+ //
9
+ // Best-effort: any failure MUST NOT break npm install. Each step is in
10
+ // its own try/catch; we just log and move on.
11
+ //
12
+ // No .lnk file, no Start Menu shortcut — just the protocol handler.
13
+
14
+ const path = require('node:path');
15
+ const fs = require('node:fs');
16
+ const { spawnSync } = require('node:child_process');
17
+
18
+ function log(msg) { process.stdout.write(`[ccsm install] ${msg}\n`); }
19
+ function warn(msg) { process.stderr.write(`[ccsm install] ${msg}\n`); }
20
+
21
+ if (process.platform !== 'win32') {
22
+ log('non-Windows · skipping ccsm:// registration');
23
+ process.exit(0);
24
+ }
25
+ // Note: we DO register on npx-cache installs too (not just global). The
26
+ // npx cache path is stable across re-runs of the same package, and even
27
+ // if the user later cleans the cache, the only consequence is the
28
+ // OfflineBanner button no-ops — nothing actively broken. Registering
29
+ // always means a first-time `npx @bakapiano/ccsm` gets the full "click
30
+ // to wake" UX without needing a separate `npm i -g`.
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.
37
+ function findCcsmCmd() {
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 };
53
+ const candidate = path.join(prefix, 'ccsm.cmd');
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
+ };
60
+ }
61
+
62
+ // Write a tiny VBScript wrapper that ccsm:// dispatches into. Why VBS:
63
+ // wscript.exe is a Windows-subsystem host (no console window), and
64
+ // `Shell.Run(..., 0, False)` launches the target completely hidden — so
65
+ // when the user clicks ccsm://start, NOTHING flashes on screen, the
66
+ // backend just appears in the next health probe.
67
+ function writeLauncherVbs(ccsmCmd) {
68
+ const home = process.env.LOCALAPPDATA || process.env.APPDATA;
69
+ if (!home) throw new Error('no LOCALAPPDATA/APPDATA env var');
70
+ const dir = path.join(home, 'ccsm');
71
+ fs.mkdirSync(dir, { recursive: true });
72
+ const vbsPath = path.join(dir, 'launcher.vbs');
73
+ // Escape any double-quotes in the cmd path (rare but possible).
74
+ const cmdEsc = ccsmCmd.replace(/"/g, '""');
75
+ const vbs = [
76
+ "' ccsm protocol launcher · invoked by wscript.exe via the registered",
77
+ "' ccsm:// URL handler. Spawns ccsm.cmd with WindowStyle 0 (hidden) +",
78
+ "' bWaitOnReturn=False (async), so the click leaves zero visible trace.",
79
+ 'If WScript.Arguments.Count >= 1 Then',
80
+ ' arg = WScript.Arguments(0)',
81
+ 'Else',
82
+ ' arg = ""',
83
+ 'End If',
84
+ 'Set sh = CreateObject("WScript.Shell")',
85
+ `sh.Run """${cmdEsc}"" """ & arg & """", 0, False`,
86
+ '',
87
+ ].join('\r\n');
88
+ fs.writeFileSync(vbsPath, vbs, { encoding: 'utf8' });
89
+ return vbsPath;
90
+ }
91
+
92
+ function registerProtocol(vbsPath) {
93
+ // wscript.exe is a no-console host. The protocol-registered command
94
+ // hands the entire ccsm:// URL to launcher.vbs as argv[0]; the VBS
95
+ // forwards it to ccsm.cmd "%1" with a hidden window.
96
+ const command = `wscript.exe "${vbsPath}" "%1"`;
97
+ const root = 'HKCU\\Software\\Classes\\ccsm';
98
+ const calls = [
99
+ ['add', root, '/ve', '/d', 'URL:ccsm protocol', '/f'],
100
+ ['add', root, '/v', 'URL Protocol', '/d', '', '/f'],
101
+ ['add', `${root}\\shell\\open\\command`, '/ve', '/d', command, '/f'],
102
+ ];
103
+ for (const args of calls) {
104
+ const r = spawnSync('reg.exe', args, { windowsHide: true });
105
+ if (r.status !== 0) {
106
+ throw new Error(`reg ${args.join(' ')} exit ${r.status}: ${r.stderr?.toString() || ''}`);
107
+ }
108
+ }
109
+ }
110
+
111
+ const { ccsmCmd, isSandbox } = (() => {
112
+ try { return findCcsmCmd(); } catch { return { ccsmCmd: null, isSandbox: false }; }
113
+ })();
114
+ if (!ccsmCmd) {
115
+ warn('could not locate ccsm.cmd · skipping protocol registration');
116
+ process.exit(0);
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
+ }
122
+
123
+ try {
124
+ const vbsPath = writeLauncherVbs(ccsmCmd);
125
+ registerProtocol(vbsPath);
126
+ log(`launcher · ${vbsPath}`);
127
+ log(`ccsm:// protocol registered (silent · via wscript.exe)`);
128
+ } catch (e) {
129
+ warn(`failed · ${e.message}`);
130
+ warn('the hosted frontend\'s "Start ccsm" button will not be able to launch the backend. You can still run `ccsm` manually in a terminal.');
131
+ }
132
+
133
+ // Auto-launch ccsm after install so the user lands directly in the app
134
+ // without needing a second command. Detached + windowsHide so the npm
135
+ // install command returns immediately. Skip if CCSM_NO_AUTOLAUNCH=1 is
136
+ // set (CI, headless setups).
137
+ if (process.env.CCSM_NO_AUTOLAUNCH !== '1') {
138
+ try {
139
+ const { spawn } = require('node:child_process');
140
+ // Spawn `node bin/ccsm.js` directly — NOT ccsm.cmd. On Windows,
141
+ // child_process.spawn() with shell:false refuses .cmd files (throws
142
+ // EINVAL); using shell:true would flash a console window. Going
143
+ // through node + the JS entrypoint sidesteps both problems and
144
+ // matches exactly what the .cmd shim would have invoked.
145
+ const launcherJs = path.join(__dirname, '..', 'bin', 'ccsm.js');
146
+ const child = spawn(process.execPath, [launcherJs], {
147
+ detached: true,
148
+ stdio: 'ignore',
149
+ windowsHide: true,
150
+ });
151
+ child.unref();
152
+ log('launching ccsm now · check for the chromeless window');
153
+ log('(set CCSM_NO_AUTOLAUNCH=1 to skip this on future installs)');
154
+ } catch (e) {
155
+ warn(`auto-launch failed · ${e.message}`);
156
+ warn('run `ccsm` manually to start.');
157
+ }
158
+ }
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Restart helper · spawned detached by /api/restart.
5
+ //
6
+ // Just like upgrade-helper but skips the `npm i` step. Server kicks
7
+ // this off + gracefulShutdowns; helper waits for the port to free, then
8
+ // respawns ccsm (which finds no live backend and starts a fresh one).
9
+ //
10
+ // Argv: node restart-helper.js <port> <pid>
11
+
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+ const os = require('node:os');
15
+ const net = require('node:net');
16
+ const { spawn } = require('node:child_process');
17
+
18
+ const oldPort = Number(process.argv[2] || 7777);
19
+ const oldPid = Number(process.argv[3] || 0);
20
+
21
+ const HOME = process.env.CCSM_HOME || path.join(os.homedir(), '.ccsm');
22
+ const LOG = path.join(HOME, 'restart.log');
23
+ try { fs.mkdirSync(HOME, { recursive: true }); } catch {}
24
+
25
+ function log(msg) {
26
+ const line = `[${new Date().toISOString()}] ${msg}\n`;
27
+ try { fs.appendFileSync(LOG, line); } catch {}
28
+ }
29
+
30
+ function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
31
+
32
+ function portFree(port, timeoutMs = 800) {
33
+ return new Promise((resolve) => {
34
+ const s = new net.Socket();
35
+ let settled = false;
36
+ const finish = (free) => { if (settled) return; settled = true; try { s.destroy(); } catch {} resolve(free); };
37
+ s.setTimeout(timeoutMs);
38
+ s.once('connect', () => finish(false));
39
+ s.once('timeout', () => finish(true));
40
+ s.once('error', () => finish(true));
41
+ s.connect(port, '127.0.0.1');
42
+ });
43
+ }
44
+
45
+ function pidAlive(pid) {
46
+ if (!pid) return false;
47
+ try { process.kill(pid, 0); return true; }
48
+ catch (e) { return e.code === 'EPERM'; }
49
+ }
50
+
51
+ (async () => {
52
+ log(`start · oldPort=${oldPort} oldPid=${oldPid}`);
53
+
54
+ const deadline = Date.now() + 30_000;
55
+ while (Date.now() < deadline) {
56
+ const free = await portFree(oldPort);
57
+ const dead = !pidAlive(oldPid);
58
+ if (free && dead) break;
59
+ await sleep(250);
60
+ }
61
+ log(`old server gone (or 30s elapsed) · respawning`);
62
+
63
+ const isWin = process.platform === 'win32';
64
+ const ccsmCmd = isWin ? 'ccsm.cmd' : 'ccsm';
65
+ const childEnv = { ...process.env, CCSM_NO_BROWSER: '1' };
66
+ let exe, exeArgs;
67
+ if (isWin) {
68
+ exe = process.env.ComSpec || 'cmd.exe';
69
+ exeArgs = ['/d', '/s', '/c', ccsmCmd];
70
+ } else {
71
+ exe = ccsmCmd;
72
+ exeArgs = [];
73
+ }
74
+ try {
75
+ const child = spawn(exe, exeArgs, {
76
+ detached: true,
77
+ stdio: 'ignore',
78
+ windowsHide: true,
79
+ shell: false,
80
+ env: childEnv,
81
+ });
82
+ child.unref();
83
+ log(`respawned ${ccsmCmd} (via ${path.basename(exe)})`);
84
+ } catch (e) {
85
+ log(`respawn failed: ${e.message}`);
86
+ process.exit(1);
87
+ }
88
+ })().catch((e) => {
89
+ log(`fatal: ${e.message}`);
90
+ process.exit(1);
91
+ });
@@ -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
+ });