@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/bin/ccsm.js
CHANGED
|
@@ -1,235 +1,235 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
// ccsm launcher · entry point for `ccsm` / `npx @bakapiano/ccsm`.
|
|
5
|
-
//
|
|
6
|
-
// Two modes by how it's invoked:
|
|
7
|
-
//
|
|
8
|
-
// plain `ccsm` → start backend if not running, open a browser
|
|
9
|
-
// window pointing at it. Terminal returns to a
|
|
10
|
-
// prompt immediately (detached).
|
|
11
|
-
//
|
|
12
|
-
// `ccsm ccsm://<action>` → fired by Windows when the user clicks a
|
|
13
|
-
// ccsm:// link (PWA offline banner). Same
|
|
14
|
-
// backend startup as above, but DO NOT spawn
|
|
15
|
-
// an extra browser — the PWA window that
|
|
16
|
-
// triggered the click is already open and
|
|
17
|
-
// will reconnect as soon as the backend
|
|
18
|
-
// becomes reachable.
|
|
19
|
-
//
|
|
20
|
-
// In both modes, if a server is already running we just ping it. New
|
|
21
|
-
// browser window opens only in the plain-`ccsm` case.
|
|
22
|
-
|
|
23
|
-
const path = require('node:path');
|
|
24
|
-
const fs = require('node:fs');
|
|
25
|
-
const os = require('node:os');
|
|
26
|
-
const http = require('node:http');
|
|
27
|
-
const { spawn } = require('node:child_process');
|
|
28
|
-
|
|
29
|
-
const SERVER = path.join(__dirname, '..', 'server.js');
|
|
30
|
-
const HOME = process.env.CCSM_HOME || path.join(os.homedir(), '.ccsm');
|
|
31
|
-
const LOG = path.join(HOME, 'server.log');
|
|
32
|
-
|
|
33
|
-
function loadPreferredPort() {
|
|
34
|
-
try {
|
|
35
|
-
const cfg = JSON.parse(fs.readFileSync(path.join(HOME, 'config.json'), 'utf8'));
|
|
36
|
-
return Number(cfg.port) || 7777;
|
|
37
|
-
} catch {
|
|
38
|
-
return 7777;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// Cheap "is this pid still alive" check using kill(pid, 0). Returns
|
|
43
|
-
// true for live pids we own, also true for pids in other security
|
|
44
|
-
// contexts (EPERM means it exists, we just can't signal it).
|
|
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
|
-
function probe(port, timeoutMs = 800) {
|
|
52
|
-
return new Promise((resolve) => {
|
|
53
|
-
const req = http.get(`http://localhost:${port}/api/health`, { timeout: timeoutMs }, (res) => {
|
|
54
|
-
let body = '';
|
|
55
|
-
res.on('data', (c) => body += c);
|
|
56
|
-
res.on('end', () => {
|
|
57
|
-
try {
|
|
58
|
-
const j = JSON.parse(body);
|
|
59
|
-
resolve(j && j.name === '@bakapiano/ccsm' ? j : null);
|
|
60
|
-
} catch { resolve(null); }
|
|
61
|
-
});
|
|
62
|
-
});
|
|
63
|
-
req.on('error', () => resolve(null));
|
|
64
|
-
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function post(port, pathname, timeoutMs = 2000) {
|
|
69
|
-
return new Promise((resolve) => {
|
|
70
|
-
const req = http.request({
|
|
71
|
-
hostname: 'localhost', port, path: pathname, method: 'POST',
|
|
72
|
-
headers: { 'Content-Type': 'application/json', 'Content-Length': 2 },
|
|
73
|
-
timeout: timeoutMs,
|
|
74
|
-
}, (res) => {
|
|
75
|
-
res.resume();
|
|
76
|
-
res.on('end', () => resolve(res.statusCode < 300));
|
|
77
|
-
});
|
|
78
|
-
req.on('error', () => resolve(false));
|
|
79
|
-
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
80
|
-
req.write('{}');
|
|
81
|
-
req.end();
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Detect ccsm:// protocol invocation. Windows runs us as
|
|
86
|
-
// `ccsm.cmd ccsm://start` when the user clicks a protocol link.
|
|
87
|
-
// argv layout: [node, ccsm.js, "ccsm://..."]
|
|
88
|
-
function parseProtocolArg() {
|
|
89
|
-
const a = process.argv[2];
|
|
90
|
-
if (!a || !/^ccsm:\/\//i.test(a)) return null;
|
|
91
|
-
try {
|
|
92
|
-
// Normalise: ccsm://start or ccsm://start?foo=bar
|
|
93
|
-
const u = new URL(a);
|
|
94
|
-
// host is the action (`start`, `restart`, ...); empty host means
|
|
95
|
-
// the URL was `ccsm:start` or `ccsm:///action`
|
|
96
|
-
const action = (u.hostname || u.pathname.replace(/^\/+/, '').split('/')[0] || '').toLowerCase();
|
|
97
|
-
return { action, raw: a };
|
|
98
|
-
} catch {
|
|
99
|
-
return { action: '', raw: a };
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Compare what's running with what's installed. Returns true if they
|
|
104
|
-
// match (or running is unknown). False means we should restart so the
|
|
105
|
-
// new code takes over after an `npm i -g @bakapiano/ccsm@latest`.
|
|
106
|
-
function isSameVersion(running) {
|
|
107
|
-
try {
|
|
108
|
-
const installed = require('../package.json').version;
|
|
109
|
-
return running.version === installed;
|
|
110
|
-
} catch { return true; }
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
(async () => {
|
|
114
|
-
const protocol = parseProtocolArg();
|
|
115
|
-
const SILENT = !!protocol; // ccsm:// invocations should not open a new browser
|
|
116
|
-
const port = loadPreferredPort();
|
|
117
|
-
|
|
118
|
-
// Upgrade-in-progress guard. The updater helper writes
|
|
119
|
-
// ~/.ccsm/.upgrade.lock at start. If a ccsm:// click (or any other
|
|
120
|
-
// launcher trigger) races during an in-flight install, spawning a
|
|
121
|
-
// new server would: (a) fight npm for the package dir, EBUSY; or
|
|
122
|
-
// (b) bind port 7777 before the helper's own respawn does. Either
|
|
123
|
-
// way the upgrade derails. Bail out instead — the helper's UI on
|
|
124
|
-
// 7779 is already showing the user what's happening.
|
|
125
|
-
//
|
|
126
|
-
// Exception: the helper itself spawns ccsm.cmd at the END of the
|
|
127
|
-
// upgrade (after npm install completes) to bring the new backend up.
|
|
128
|
-
// It sets CCSM_FROM_UPGRADE=1 in that child's env. We MUST skip the
|
|
129
|
-
// lock check in that case, otherwise we'd refuse our own respawn and
|
|
130
|
-
// the user would be stuck staring at "Backend not running".
|
|
131
|
-
if (process.env.CCSM_FROM_UPGRADE !== '1') {
|
|
132
|
-
const lockPath = path.join(HOME, '.upgrade.lock');
|
|
133
|
-
try {
|
|
134
|
-
const raw = fs.readFileSync(lockPath, 'utf8');
|
|
135
|
-
const lock = JSON.parse(raw);
|
|
136
|
-
const ageMs = Date.now() - (lock.startedAt || 0);
|
|
137
|
-
const ownerAlive = lock.pid ? pidAlive(lock.pid) : false;
|
|
138
|
-
if (ownerAlive && ageMs < 10 * 60_000) {
|
|
139
|
-
console.log(`ccsm: upgrade in progress (helper pid=${lock.pid}, ${Math.round(ageMs/1000)}s ago, target=${lock.target || '?'})`);
|
|
140
|
-
console.log(` see http://localhost:${lock.helperPort || 7779}/ for live progress`);
|
|
141
|
-
process.exit(0);
|
|
142
|
-
}
|
|
143
|
-
// Stale lock (pid dead OR > 10min) — clean up and continue.
|
|
144
|
-
try { fs.unlinkSync(lockPath); } catch {}
|
|
145
|
-
} catch {
|
|
146
|
-
// ENOENT or parse error → no lock, proceed.
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Case 1: existing instance on the preferred port
|
|
151
|
-
let existing = await probe(port);
|
|
152
|
-
|
|
153
|
-
// If an old version is running, ask it to shut down so the freshly
|
|
154
|
-
// installed code can take over. The launcher then falls through to
|
|
155
|
-
// Case 2 and spawns the new server itself.
|
|
156
|
-
if (existing && !isSameVersion(existing)) {
|
|
157
|
-
const installed = require('../package.json').version;
|
|
158
|
-
console.log(`ccsm upgrading · running v${existing.version} → installed v${installed}`);
|
|
159
|
-
await post(port, '/api/shutdown');
|
|
160
|
-
// Wait for the old process to actually exit so its port frees up.
|
|
161
|
-
for (let i = 0; i < 30; i++) {
|
|
162
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
163
|
-
if (!(await probe(port, 200))) { existing = null; break; }
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
if (existing) {
|
|
168
|
-
if (!SILENT) {
|
|
169
|
-
const opened = await post(port, '/api/spawn-browser');
|
|
170
|
-
console.log(`ccsm already running · v${existing.version} · http://localhost:${port}`);
|
|
171
|
-
if (!opened) console.log('(could not open a new window — server might be busy)');
|
|
172
|
-
} else {
|
|
173
|
-
console.log(`ccsm already running · ${protocol.raw}`);
|
|
174
|
-
}
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Case 2: spawn detached server
|
|
179
|
-
fs.mkdirSync(HOME, { recursive: true });
|
|
180
|
-
const out = fs.openSync(LOG, 'a');
|
|
181
|
-
fs.writeSync(out, `\n[${new Date().toISOString()}] ccsm starting (protocol=${protocol?.raw || '-'})...\n`);
|
|
182
|
-
|
|
183
|
-
const child = spawn(process.execPath, [SERVER], {
|
|
184
|
-
detached: true,
|
|
185
|
-
stdio: ['ignore', out, out],
|
|
186
|
-
windowsHide: true,
|
|
187
|
-
env: {
|
|
188
|
-
...process.env,
|
|
189
|
-
CCSM_LAUNCHER: '1',
|
|
190
|
-
// Suppress the server's own auto-spawn of a browser when this launch
|
|
191
|
-
// came from a ccsm:// click — the PWA window that fired it is the
|
|
192
|
-
// browser, and a second window would just be noise.
|
|
193
|
-
...(SILENT ? { CCSM_NO_BROWSER: '1' } : {}),
|
|
194
|
-
},
|
|
195
|
-
});
|
|
196
|
-
child.unref();
|
|
197
|
-
|
|
198
|
-
// Poll /api/health for up to ~10s. Once it answers we know the server
|
|
199
|
-
// is fully booted (port is bound, config loaded, snapshot loop running).
|
|
200
|
-
// The actual port may differ from the preferred one if it was taken,
|
|
201
|
-
// so on each iteration we re-probe the preferred port first, then fall
|
|
202
|
-
// back to scanning preferred+1..preferred+9.
|
|
203
|
-
const portsToTry = [port, ...Array.from({ length: 9 }, (_, i) => port + i + 1)];
|
|
204
|
-
let actualPort = null;
|
|
205
|
-
let ready = null;
|
|
206
|
-
outer:
|
|
207
|
-
for (let i = 0; i < 50; i++) {
|
|
208
|
-
await new Promise((r) => setTimeout(r, 200));
|
|
209
|
-
for (const p of portsToTry) {
|
|
210
|
-
const r = await probe(p, 300);
|
|
211
|
-
if (r) { ready = r; actualPort = p; break outer; }
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
if (!ready) {
|
|
215
|
-
console.error(`ccsm server did not come up in 10s. Check ${LOG}`);
|
|
216
|
-
process.exit(1);
|
|
217
|
-
}
|
|
218
|
-
console.log(`ccsm started · v${ready.version}`);
|
|
219
|
-
console.log(`backend: http://localhost:${actualPort}${actualPort !== port ? ` (preferred ${port} was taken)` : ''}`);
|
|
220
|
-
console.log(`frontend: https://bakapiano.github.io/ccsm/v1/`);
|
|
221
|
-
console.log(`logs: ${LOG}`);
|
|
222
|
-
|
|
223
|
-
// First-run hint — printed once, then a marker file makes us quiet.
|
|
224
|
-
const firstRunMark = path.join(HOME, '.first-run-shown');
|
|
225
|
-
if (!fs.existsSync(firstRunMark)) {
|
|
226
|
-
try { fs.writeFileSync(firstRunMark, new Date().toISOString()); } catch {}
|
|
227
|
-
console.log('');
|
|
228
|
-
console.log('First run · ccsm is now running in the background.');
|
|
229
|
-
console.log('Open the frontend URL above, click "Install ccsm" in your browser');
|
|
230
|
-
console.log('to install it as a PWA so the icon launches directly into the app.');
|
|
231
|
-
}
|
|
232
|
-
})().catch((err) => {
|
|
233
|
-
console.error('ccsm launcher failed:', err);
|
|
234
|
-
process.exit(1);
|
|
235
|
-
});
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// ccsm launcher · entry point for `ccsm` / `npx @bakapiano/ccsm`.
|
|
5
|
+
//
|
|
6
|
+
// Two modes by how it's invoked:
|
|
7
|
+
//
|
|
8
|
+
// plain `ccsm` → start backend if not running, open a browser
|
|
9
|
+
// window pointing at it. Terminal returns to a
|
|
10
|
+
// prompt immediately (detached).
|
|
11
|
+
//
|
|
12
|
+
// `ccsm ccsm://<action>` → fired by Windows when the user clicks a
|
|
13
|
+
// ccsm:// link (PWA offline banner). Same
|
|
14
|
+
// backend startup as above, but DO NOT spawn
|
|
15
|
+
// an extra browser — the PWA window that
|
|
16
|
+
// triggered the click is already open and
|
|
17
|
+
// will reconnect as soon as the backend
|
|
18
|
+
// becomes reachable.
|
|
19
|
+
//
|
|
20
|
+
// In both modes, if a server is already running we just ping it. New
|
|
21
|
+
// browser window opens only in the plain-`ccsm` case.
|
|
22
|
+
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
const fs = require('node:fs');
|
|
25
|
+
const os = require('node:os');
|
|
26
|
+
const http = require('node:http');
|
|
27
|
+
const { spawn } = require('node:child_process');
|
|
28
|
+
|
|
29
|
+
const SERVER = path.join(__dirname, '..', 'server.js');
|
|
30
|
+
const HOME = process.env.CCSM_HOME || path.join(os.homedir(), '.ccsm');
|
|
31
|
+
const LOG = path.join(HOME, 'server.log');
|
|
32
|
+
|
|
33
|
+
function loadPreferredPort() {
|
|
34
|
+
try {
|
|
35
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(HOME, 'config.json'), 'utf8'));
|
|
36
|
+
return Number(cfg.port) || 7777;
|
|
37
|
+
} catch {
|
|
38
|
+
return 7777;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Cheap "is this pid still alive" check using kill(pid, 0). Returns
|
|
43
|
+
// true for live pids we own, also true for pids in other security
|
|
44
|
+
// contexts (EPERM means it exists, we just can't signal it).
|
|
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
|
+
function probe(port, timeoutMs = 800) {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
const req = http.get(`http://localhost:${port}/api/health`, { timeout: timeoutMs }, (res) => {
|
|
54
|
+
let body = '';
|
|
55
|
+
res.on('data', (c) => body += c);
|
|
56
|
+
res.on('end', () => {
|
|
57
|
+
try {
|
|
58
|
+
const j = JSON.parse(body);
|
|
59
|
+
resolve(j && j.name === '@bakapiano/ccsm' ? j : null);
|
|
60
|
+
} catch { resolve(null); }
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
req.on('error', () => resolve(null));
|
|
64
|
+
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function post(port, pathname, timeoutMs = 2000) {
|
|
69
|
+
return new Promise((resolve) => {
|
|
70
|
+
const req = http.request({
|
|
71
|
+
hostname: 'localhost', port, path: pathname, method: 'POST',
|
|
72
|
+
headers: { 'Content-Type': 'application/json', 'Content-Length': 2 },
|
|
73
|
+
timeout: timeoutMs,
|
|
74
|
+
}, (res) => {
|
|
75
|
+
res.resume();
|
|
76
|
+
res.on('end', () => resolve(res.statusCode < 300));
|
|
77
|
+
});
|
|
78
|
+
req.on('error', () => resolve(false));
|
|
79
|
+
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
80
|
+
req.write('{}');
|
|
81
|
+
req.end();
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Detect ccsm:// protocol invocation. Windows runs us as
|
|
86
|
+
// `ccsm.cmd ccsm://start` when the user clicks a protocol link.
|
|
87
|
+
// argv layout: [node, ccsm.js, "ccsm://..."]
|
|
88
|
+
function parseProtocolArg() {
|
|
89
|
+
const a = process.argv[2];
|
|
90
|
+
if (!a || !/^ccsm:\/\//i.test(a)) return null;
|
|
91
|
+
try {
|
|
92
|
+
// Normalise: ccsm://start or ccsm://start?foo=bar
|
|
93
|
+
const u = new URL(a);
|
|
94
|
+
// host is the action (`start`, `restart`, ...); empty host means
|
|
95
|
+
// the URL was `ccsm:start` or `ccsm:///action`
|
|
96
|
+
const action = (u.hostname || u.pathname.replace(/^\/+/, '').split('/')[0] || '').toLowerCase();
|
|
97
|
+
return { action, raw: a };
|
|
98
|
+
} catch {
|
|
99
|
+
return { action: '', raw: a };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Compare what's running with what's installed. Returns true if they
|
|
104
|
+
// match (or running is unknown). False means we should restart so the
|
|
105
|
+
// new code takes over after an `npm i -g @bakapiano/ccsm@latest`.
|
|
106
|
+
function isSameVersion(running) {
|
|
107
|
+
try {
|
|
108
|
+
const installed = require('../package.json').version;
|
|
109
|
+
return running.version === installed;
|
|
110
|
+
} catch { return true; }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
(async () => {
|
|
114
|
+
const protocol = parseProtocolArg();
|
|
115
|
+
const SILENT = !!protocol; // ccsm:// invocations should not open a new browser
|
|
116
|
+
const port = loadPreferredPort();
|
|
117
|
+
|
|
118
|
+
// Upgrade-in-progress guard. The updater helper writes
|
|
119
|
+
// ~/.ccsm/.upgrade.lock at start. If a ccsm:// click (or any other
|
|
120
|
+
// launcher trigger) races during an in-flight install, spawning a
|
|
121
|
+
// new server would: (a) fight npm for the package dir, EBUSY; or
|
|
122
|
+
// (b) bind port 7777 before the helper's own respawn does. Either
|
|
123
|
+
// way the upgrade derails. Bail out instead — the helper's UI on
|
|
124
|
+
// 7779 is already showing the user what's happening.
|
|
125
|
+
//
|
|
126
|
+
// Exception: the helper itself spawns ccsm.cmd at the END of the
|
|
127
|
+
// upgrade (after npm install completes) to bring the new backend up.
|
|
128
|
+
// It sets CCSM_FROM_UPGRADE=1 in that child's env. We MUST skip the
|
|
129
|
+
// lock check in that case, otherwise we'd refuse our own respawn and
|
|
130
|
+
// the user would be stuck staring at "Backend not running".
|
|
131
|
+
if (process.env.CCSM_FROM_UPGRADE !== '1') {
|
|
132
|
+
const lockPath = path.join(HOME, '.upgrade.lock');
|
|
133
|
+
try {
|
|
134
|
+
const raw = fs.readFileSync(lockPath, 'utf8');
|
|
135
|
+
const lock = JSON.parse(raw);
|
|
136
|
+
const ageMs = Date.now() - (lock.startedAt || 0);
|
|
137
|
+
const ownerAlive = lock.pid ? pidAlive(lock.pid) : false;
|
|
138
|
+
if (ownerAlive && ageMs < 10 * 60_000) {
|
|
139
|
+
console.log(`ccsm: upgrade in progress (helper pid=${lock.pid}, ${Math.round(ageMs/1000)}s ago, target=${lock.target || '?'})`);
|
|
140
|
+
console.log(` see http://localhost:${lock.helperPort || 7779}/ for live progress`);
|
|
141
|
+
process.exit(0);
|
|
142
|
+
}
|
|
143
|
+
// Stale lock (pid dead OR > 10min) — clean up and continue.
|
|
144
|
+
try { fs.unlinkSync(lockPath); } catch {}
|
|
145
|
+
} catch {
|
|
146
|
+
// ENOENT or parse error → no lock, proceed.
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Case 1: existing instance on the preferred port
|
|
151
|
+
let existing = await probe(port);
|
|
152
|
+
|
|
153
|
+
// If an old version is running, ask it to shut down so the freshly
|
|
154
|
+
// installed code can take over. The launcher then falls through to
|
|
155
|
+
// Case 2 and spawns the new server itself.
|
|
156
|
+
if (existing && !isSameVersion(existing)) {
|
|
157
|
+
const installed = require('../package.json').version;
|
|
158
|
+
console.log(`ccsm upgrading · running v${existing.version} → installed v${installed}`);
|
|
159
|
+
await post(port, '/api/shutdown');
|
|
160
|
+
// Wait for the old process to actually exit so its port frees up.
|
|
161
|
+
for (let i = 0; i < 30; i++) {
|
|
162
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
163
|
+
if (!(await probe(port, 200))) { existing = null; break; }
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (existing) {
|
|
168
|
+
if (!SILENT) {
|
|
169
|
+
const opened = await post(port, '/api/spawn-browser');
|
|
170
|
+
console.log(`ccsm already running · v${existing.version} · http://localhost:${port}`);
|
|
171
|
+
if (!opened) console.log('(could not open a new window — server might be busy)');
|
|
172
|
+
} else {
|
|
173
|
+
console.log(`ccsm already running · ${protocol.raw}`);
|
|
174
|
+
}
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Case 2: spawn detached server
|
|
179
|
+
fs.mkdirSync(HOME, { recursive: true });
|
|
180
|
+
const out = fs.openSync(LOG, 'a');
|
|
181
|
+
fs.writeSync(out, `\n[${new Date().toISOString()}] ccsm starting (protocol=${protocol?.raw || '-'})...\n`);
|
|
182
|
+
|
|
183
|
+
const child = spawn(process.execPath, [SERVER], {
|
|
184
|
+
detached: true,
|
|
185
|
+
stdio: ['ignore', out, out],
|
|
186
|
+
windowsHide: true,
|
|
187
|
+
env: {
|
|
188
|
+
...process.env,
|
|
189
|
+
CCSM_LAUNCHER: '1',
|
|
190
|
+
// Suppress the server's own auto-spawn of a browser when this launch
|
|
191
|
+
// came from a ccsm:// click — the PWA window that fired it is the
|
|
192
|
+
// browser, and a second window would just be noise.
|
|
193
|
+
...(SILENT ? { CCSM_NO_BROWSER: '1' } : {}),
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
child.unref();
|
|
197
|
+
|
|
198
|
+
// Poll /api/health for up to ~10s. Once it answers we know the server
|
|
199
|
+
// is fully booted (port is bound, config loaded, snapshot loop running).
|
|
200
|
+
// The actual port may differ from the preferred one if it was taken,
|
|
201
|
+
// so on each iteration we re-probe the preferred port first, then fall
|
|
202
|
+
// back to scanning preferred+1..preferred+9.
|
|
203
|
+
const portsToTry = [port, ...Array.from({ length: 9 }, (_, i) => port + i + 1)];
|
|
204
|
+
let actualPort = null;
|
|
205
|
+
let ready = null;
|
|
206
|
+
outer:
|
|
207
|
+
for (let i = 0; i < 50; i++) {
|
|
208
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
209
|
+
for (const p of portsToTry) {
|
|
210
|
+
const r = await probe(p, 300);
|
|
211
|
+
if (r) { ready = r; actualPort = p; break outer; }
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (!ready) {
|
|
215
|
+
console.error(`ccsm server did not come up in 10s. Check ${LOG}`);
|
|
216
|
+
process.exit(1);
|
|
217
|
+
}
|
|
218
|
+
console.log(`ccsm started · v${ready.version}`);
|
|
219
|
+
console.log(`backend: http://localhost:${actualPort}${actualPort !== port ? ` (preferred ${port} was taken)` : ''}`);
|
|
220
|
+
console.log(`frontend: https://bakapiano.github.io/ccsm/v1/`);
|
|
221
|
+
console.log(`logs: ${LOG}`);
|
|
222
|
+
|
|
223
|
+
// First-run hint — printed once, then a marker file makes us quiet.
|
|
224
|
+
const firstRunMark = path.join(HOME, '.first-run-shown');
|
|
225
|
+
if (!fs.existsSync(firstRunMark)) {
|
|
226
|
+
try { fs.writeFileSync(firstRunMark, new Date().toISOString()); } catch {}
|
|
227
|
+
console.log('');
|
|
228
|
+
console.log('First run · ccsm is now running in the background.');
|
|
229
|
+
console.log('Open the frontend URL above, click "Install ccsm" in your browser');
|
|
230
|
+
console.log('to install it as a PWA so the icon launches directly into the app.');
|
|
231
|
+
}
|
|
232
|
+
})().catch((err) => {
|
|
233
|
+
console.error('ccsm launcher failed:', err);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
});
|