@bakapiano/ccsm 0.16.0 → 0.17.2

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.
@@ -3,33 +3,39 @@
3
3
 
4
4
  // In-app upgrade helper · spawned detached by /api/upgrade.
5
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:
6
+ // Why this exists: running `npm i -g` from inside the live server hits
7
+ // EBUSY on Windows (npm tries to rename the package directory while the
8
+ // server has files open inside it). We can't do the install in-process.
11
9
  //
10
+ // What this script does:
12
11
  // 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.
12
+ // with the target version + caller's port/pid, sends 200 OK back to
13
+ // the frontend, then gracefulShutdowns.
14
+ // 2. Helper writes ~/.ccsm/.upgrade.lock (so a stray ccsm:// wake on
15
+ // ccsm.cmd doesn't try to start a new server while we're installing).
16
+ // 3. Helper starts a tiny HTTP server on port 7779 that serves a
17
+ // progress UI: inline HTML at /, JSON status at /api/upgrade/status,
18
+ // SSE stream of npm output at /api/upgrade/stream. The original
19
+ // frontend navigates to http://localhost:7779/ when it gets the
20
+ // upgrade response, so the user watches install progress live.
21
+ // 4. Helper waits for the old port + pid to be gone (up to 30s).
22
+ // 5. Helper runs `npm i -g @bakapiano/ccsm@<target>`, captures stdout +
23
+ // stderr line by line, pushes each line into the SSE stream.
24
+ // 6. On success: spawn ccsm.cmd (which boots the new server on 7777),
25
+ // push a `done` SSE event with redirectTo=7777 so the UI navigates
26
+ // back. Keep the helper server alive for ~30s for late clients,
27
+ // then exit + release the lock.
28
+ // 7. On failure: keep the helper server alive indefinitely so the user
29
+ // can read the error + copy the log. Exits when the user clicks
30
+ // Close in the UI (POST /api/upgrade/dismiss) OR after 10 min.
21
31
  //
22
32
  // 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
33
 
29
34
  const fs = require('node:fs');
30
35
  const path = require('node:path');
31
36
  const os = require('node:os');
32
37
  const net = require('node:net');
38
+ const http = require('node:http');
33
39
  const { spawn, spawnSync } = require('node:child_process');
34
40
 
35
41
  const target = process.argv[2] || 'latest';
@@ -37,19 +43,25 @@ const oldPort = Number(process.argv[3] || 7777);
37
43
  const oldPid = Number(process.argv[4] || 0);
38
44
  const installPrefix = process.argv[5] || '';
39
45
  const doRespawn = process.argv[6] !== '0';
46
+ // redirectTo: where the updater UI sends the browser after success.
47
+ // Server passes the FRONTEND_URL it computed (GH Pages router in
48
+ // prod, local apiUrl in dev). Fallback to localhost:oldPort/ so old
49
+ // callers that don't pass anything still work.
50
+ const redirectTo = process.argv[7] || `http://localhost:${oldPort}/`;
40
51
 
52
+ const HELPER_PORT = 7779;
41
53
  const HOME = process.env.CCSM_HOME || path.join(os.homedir(), '.ccsm');
42
54
  const LOG = path.join(HOME, 'upgrade.log');
55
+ const LOCK = path.join(HOME, '.upgrade.lock');
43
56
  try { fs.mkdirSync(HOME, { recursive: true }); } catch {}
44
57
 
45
- function log(msg) {
58
+ function fileLog(msg) {
46
59
  const line = `[${new Date().toISOString()}] ${msg}\n`;
47
60
  try { fs.appendFileSync(LOG, line); } catch {}
48
61
  }
49
62
 
50
63
  function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
51
64
 
52
- // Returns true once nothing answers on host:port within timeoutMs.
53
65
  function portFree(port, timeoutMs = 800) {
54
66
  return new Promise((resolve) => {
55
67
  const s = new net.Socket();
@@ -69,11 +81,450 @@ function pidAlive(pid) {
69
81
  catch (e) { return e.code === 'EPERM'; }
70
82
  }
71
83
 
84
+ // ── Lockfile so bin/ccsm.js refuses to spawn a new server mid-upgrade.
85
+ // bin reads .upgrade.lock at startup; if it exists with a live pid and
86
+ // startedAt < 10min ago it exits early. Stale locks are auto-cleared.
87
+ let phaseValue = 'starting';
88
+ function writeLock() {
89
+ try {
90
+ fs.writeFileSync(LOCK, JSON.stringify({
91
+ pid: process.pid,
92
+ startedAt: Date.now(),
93
+ target,
94
+ phase: phaseValue,
95
+ helperPort: HELPER_PORT,
96
+ }, null, 2));
97
+ } catch {}
98
+ }
99
+ function removeLock() {
100
+ try { fs.unlinkSync(LOCK); } catch {}
101
+ }
102
+ writeLock();
103
+ process.on('exit', removeLock);
104
+ process.on('SIGINT', () => { removeLock(); process.exit(0); });
105
+ process.on('SIGTERM', () => { removeLock(); process.exit(0); });
106
+ process.on('uncaughtException', (e) => { fileLog(`uncaught: ${e.stack || e.message}`); removeLock(); process.exit(1); });
107
+
108
+ // ── Progress state shared with the HTTP server. ─────────────────────
109
+ const startedAt = Date.now();
110
+ const linesBuffer = []; // ring of {ts, stream, text}
111
+ const LINES_CAP = 2000;
112
+ const sseClients = new Set(); // res objects we push events to
113
+ let errorMsg = null;
114
+ let finishedAt = null;
115
+
116
+ function pushLine(stream, text) {
117
+ if (!text) return;
118
+ const entry = { ts: Date.now(), stream, text };
119
+ linesBuffer.push(entry);
120
+ if (linesBuffer.length > LINES_CAP) linesBuffer.shift();
121
+ const payload = JSON.stringify(entry);
122
+ for (const res of sseClients) {
123
+ try { res.write(`data: ${payload}\n\n`); } catch {}
124
+ }
125
+ fileLog(`[${stream}] ${text}`);
126
+ }
127
+
128
+ function setPhase(p) {
129
+ phaseValue = p;
130
+ writeLock();
131
+ const payload = JSON.stringify({ phase: p, ts: Date.now() });
132
+ for (const res of sseClients) {
133
+ try { res.write(`event: phase\ndata: ${payload}\n\n`); } catch {}
134
+ }
135
+ fileLog(`[phase] ${p}`);
136
+ }
137
+
138
+ function notifyDone(redirectTo) {
139
+ finishedAt = Date.now();
140
+ const payload = JSON.stringify({ redirectTo, ts: Date.now() });
141
+ for (const res of sseClients) {
142
+ try { res.write(`event: done\ndata: ${payload}\n\n`); } catch {}
143
+ }
144
+ }
145
+
146
+ function notifyFailed() {
147
+ finishedAt = Date.now();
148
+ const payload = JSON.stringify({ error: errorMsg, ts: Date.now() });
149
+ for (const res of sseClients) {
150
+ try { res.write(`event: failed\ndata: ${payload}\n\n`); } catch {}
151
+ }
152
+ }
153
+
154
+ // ── Inline updater UI ────────────────────────────────────────────────
155
+ const UI_HTML = `<!doctype html>
156
+ <html lang="en">
157
+ <head>
158
+ <meta charset="utf-8" />
159
+ <title>ccsm · upgrade</title>
160
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
161
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
162
+ <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" />
163
+ <style>
164
+ :root {
165
+ --bg: #faf9f5;
166
+ --bg-elev: #ffffff;
167
+ --ink: #1a1815;
168
+ --ink-mid: #6b665d;
169
+ --ink-muted: #9a9489;
170
+ --border: #e8e3d5;
171
+ --accent: #4a73a5;
172
+ --green: #4a8a4a;
173
+ --red: #b73f3f;
174
+ --warn: #c79544;
175
+ }
176
+ * { box-sizing: border-box; }
177
+ body {
178
+ margin: 0;
179
+ min-height: 100vh;
180
+ background: var(--bg);
181
+ color: var(--ink);
182
+ font-family: 'Geist', -apple-system, BlinkMacSystemFont, sans-serif;
183
+ font-size: 14px;
184
+ display: flex;
185
+ align-items: flex-start;
186
+ justify-content: center;
187
+ padding: 40px 20px;
188
+ }
189
+ .card {
190
+ width: 100%;
191
+ max-width: 720px;
192
+ background: var(--bg-elev);
193
+ border: 1px solid var(--border);
194
+ border-radius: 10px;
195
+ padding: 24px 28px;
196
+ box-shadow: 0 1px 2px rgba(0,0,0,0.04);
197
+ }
198
+ h1 {
199
+ margin: 0 0 6px;
200
+ font-size: 18px;
201
+ font-weight: 600;
202
+ letter-spacing: -0.01em;
203
+ }
204
+ .subtitle {
205
+ color: var(--ink-mid);
206
+ margin: 0 0 20px;
207
+ font-size: 13px;
208
+ }
209
+ .subtitle .mono { font-family: 'JetBrains Mono', monospace; font-size: 12.5px; }
210
+ .phase-row {
211
+ display: flex;
212
+ align-items: center;
213
+ gap: 10px;
214
+ padding: 10px 0;
215
+ border-bottom: 1px dashed var(--border);
216
+ }
217
+ .phase-row:last-of-type { border-bottom: 0; }
218
+ .phase-dot {
219
+ width: 8px;
220
+ height: 8px;
221
+ border-radius: 50%;
222
+ background: var(--border);
223
+ flex-shrink: 0;
224
+ }
225
+ .phase-row.active .phase-dot {
226
+ background: var(--accent);
227
+ animation: pulse 1.4s ease-in-out infinite;
228
+ }
229
+ .phase-row.done .phase-dot { background: var(--green); }
230
+ .phase-row.failed .phase-dot { background: var(--red); }
231
+ .phase-row.pending .phase-dot { background: var(--border); }
232
+ .phase-label {
233
+ flex: 1;
234
+ font-size: 13px;
235
+ color: var(--ink);
236
+ }
237
+ .phase-row.pending .phase-label { color: var(--ink-muted); }
238
+ .phase-row.done .phase-label { color: var(--ink-mid); }
239
+ .phase-meta {
240
+ font-family: 'JetBrains Mono', monospace;
241
+ font-size: 11.5px;
242
+ color: var(--ink-muted);
243
+ }
244
+ @keyframes pulse {
245
+ 0%, 100% { opacity: 1; transform: scale(1); }
246
+ 50% { opacity: 0.4; transform: scale(1.3); }
247
+ }
248
+ .log {
249
+ margin-top: 16px;
250
+ background: #1a1815;
251
+ color: #e8e3d5;
252
+ border-radius: 6px;
253
+ padding: 12px 14px;
254
+ font-family: 'JetBrains Mono', monospace;
255
+ font-size: 11.5px;
256
+ line-height: 1.5;
257
+ max-height: 320px;
258
+ overflow-y: auto;
259
+ white-space: pre-wrap;
260
+ word-break: break-word;
261
+ }
262
+ .log .line.err { color: #e07b6e; }
263
+ .log .line.info { color: #9bb8d8; }
264
+ .log .ts { color: #534e44; margin-right: 8px; }
265
+ .banner {
266
+ margin-top: 16px;
267
+ padding: 10px 14px;
268
+ border-radius: 6px;
269
+ font-size: 13px;
270
+ }
271
+ .banner.success {
272
+ background: rgba(74, 138, 74, 0.08);
273
+ color: var(--green);
274
+ border: 1px solid rgba(74, 138, 74, 0.3);
275
+ }
276
+ .banner.error {
277
+ background: rgba(183, 63, 63, 0.08);
278
+ color: var(--red);
279
+ border: 1px solid rgba(183, 63, 63, 0.3);
280
+ }
281
+ .actions {
282
+ margin-top: 16px;
283
+ display: flex;
284
+ gap: 8px;
285
+ justify-content: flex-end;
286
+ }
287
+ .btn {
288
+ appearance: none;
289
+ border: 1px solid var(--border);
290
+ background: var(--bg-elev);
291
+ color: var(--ink);
292
+ padding: 7px 14px;
293
+ border-radius: 6px;
294
+ cursor: pointer;
295
+ font: inherit;
296
+ font-size: 13px;
297
+ }
298
+ .btn.primary {
299
+ background: var(--ink);
300
+ color: var(--bg-elev);
301
+ border-color: var(--ink);
302
+ }
303
+ .btn:hover { background: rgba(0,0,0,0.04); }
304
+ .btn.primary:hover { background: #000; }
305
+ </style>
306
+ </head>
307
+ <body>
308
+ <div class="card">
309
+ <h1>Upgrading ccsm</h1>
310
+ <p class="subtitle">target: <span class="mono">@bakapiano/ccsm@<span id="target"></span></span></p>
311
+
312
+ <div id="phases">
313
+ <div class="phase-row pending" data-phase="waiting-port">
314
+ <span class="phase-dot"></span>
315
+ <span class="phase-label">Wait for old backend to exit</span>
316
+ <span class="phase-meta" data-meta-for="waiting-port"></span>
317
+ </div>
318
+ <div class="phase-row pending" data-phase="installing">
319
+ <span class="phase-dot"></span>
320
+ <span class="phase-label">Run <span class="phase-meta">npm i -g</span></span>
321
+ <span class="phase-meta" data-meta-for="installing"></span>
322
+ </div>
323
+ <div class="phase-row pending" data-phase="spawning">
324
+ <span class="phase-dot"></span>
325
+ <span class="phase-label">Start new backend</span>
326
+ <span class="phase-meta" data-meta-for="spawning"></span>
327
+ </div>
328
+ </div>
329
+
330
+ <div id="banner"></div>
331
+ <div class="log" id="log"></div>
332
+
333
+ <div class="actions">
334
+ <button class="btn" id="copyLog">Copy log</button>
335
+ <button class="btn primary" id="close" style="display:none">Close</button>
336
+ </div>
337
+ </div>
338
+
339
+ <script>
340
+ (function () {
341
+ const targetEl = document.getElementById('target');
342
+ const logEl = document.getElementById('log');
343
+ const bannerEl = document.getElementById('banner');
344
+ const closeBtn = document.getElementById('close');
345
+ const copyBtn = document.getElementById('copyLog');
346
+
347
+ const PHASE_ORDER = ['waiting-port', 'installing', 'spawning', 'done'];
348
+ let lastPhaseTs = Date.now();
349
+
350
+ function setPhase(phase) {
351
+ const idx = PHASE_ORDER.indexOf(phase);
352
+ document.querySelectorAll('.phase-row').forEach((row) => {
353
+ const p = row.getAttribute('data-phase');
354
+ const pi = PHASE_ORDER.indexOf(p);
355
+ row.classList.remove('pending', 'active', 'done', 'failed');
356
+ if (phase === 'failed') {
357
+ if (pi < idx || (pi === idx - 1)) row.classList.add('done');
358
+ else if (pi === idx) row.classList.add('failed');
359
+ else row.classList.add('pending');
360
+ } else if (pi < idx) row.classList.add('done');
361
+ else if (pi === idx) row.classList.add('active');
362
+ else row.classList.add('pending');
363
+ });
364
+ }
365
+
366
+ function appendLine(entry) {
367
+ const div = document.createElement('div');
368
+ div.className = 'line ' + (entry.stream === 'stderr' ? 'err' : entry.stream === 'info' ? 'info' : '');
369
+ const t = new Date(entry.ts).toLocaleTimeString();
370
+ div.innerHTML = '<span class="ts">' + t + '</span>' + escapeHtml(entry.text);
371
+ logEl.appendChild(div);
372
+ logEl.scrollTop = logEl.scrollHeight;
373
+ }
374
+
375
+ function escapeHtml(s) {
376
+ return String(s).replace(/[&<>"']/g, (c) => ({
377
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
378
+ })[c]);
379
+ }
380
+
381
+ copyBtn.addEventListener('click', () => {
382
+ const text = Array.from(logEl.children).map((d) => d.innerText).join('\\n');
383
+ navigator.clipboard.writeText(text).then(() => {
384
+ copyBtn.innerText = 'Copied';
385
+ setTimeout(() => { copyBtn.innerText = 'Copy log'; }, 1500);
386
+ });
387
+ });
388
+
389
+ closeBtn.addEventListener('click', () => {
390
+ fetch('/api/upgrade/dismiss', { method: 'POST' }).catch(() => {});
391
+ window.close();
392
+ });
393
+
394
+ // SSE stream — server replays the buffer first then streams new
395
+ // events. EventSource auto-reconnects.
396
+ const es = new EventSource('/api/upgrade/stream');
397
+
398
+ // Status fetch up-front to fill target + initial phase quickly even
399
+ // if SSE is slow to push.
400
+ fetch('/api/upgrade/status').then((r) => r.json()).then((s) => {
401
+ targetEl.innerText = s.target || '';
402
+ if (s.phase) setPhase(s.phase);
403
+ if (s.errorMsg) showFailed(s.errorMsg);
404
+ if (s.lines) s.lines.forEach(appendLine);
405
+ }).catch(() => {});
406
+
407
+ es.addEventListener('message', (ev) => {
408
+ try {
409
+ const entry = JSON.parse(ev.data);
410
+ if (entry.stream && entry.text != null) appendLine(entry);
411
+ } catch {}
412
+ });
413
+ es.addEventListener('phase', (ev) => {
414
+ try {
415
+ const data = JSON.parse(ev.data);
416
+ if (data.phase) setPhase(data.phase);
417
+ } catch {}
418
+ });
419
+ es.addEventListener('done', (ev) => {
420
+ try {
421
+ const data = JSON.parse(ev.data);
422
+ setPhase('done');
423
+ document.querySelectorAll('.phase-row').forEach((r) => r.classList.add('done'));
424
+ bannerEl.className = 'banner success';
425
+ bannerEl.innerText = 'Upgrade complete. Redirecting to the new backend…';
426
+ closeBtn.style.display = '';
427
+ // Give the new backend a moment to bind 7777 before redirecting.
428
+ setTimeout(() => {
429
+ location.href = data.redirectTo || 'http://localhost:7777/';
430
+ }, 1500);
431
+ } catch {}
432
+ });
433
+ es.addEventListener('failed', (ev) => {
434
+ try {
435
+ const data = JSON.parse(ev.data);
436
+ showFailed(data.error);
437
+ } catch {}
438
+ });
439
+
440
+ function showFailed(msg) {
441
+ setPhase('failed');
442
+ bannerEl.className = 'banner error';
443
+ bannerEl.innerText = 'Upgrade failed: ' + (msg || 'unknown error');
444
+ closeBtn.style.display = '';
445
+ closeBtn.innerText = 'Close';
446
+ }
447
+ })();
448
+ </script>
449
+ </body>
450
+ </html>`;
451
+
452
+ // ── HTTP server ──────────────────────────────────────────────────────
453
+ function buildStatus() {
454
+ return {
455
+ target,
456
+ phase: phaseValue,
457
+ startedAt,
458
+ finishedAt,
459
+ errorMsg,
460
+ redirectTo,
461
+ helperPort: HELPER_PORT,
462
+ lines: linesBuffer.slice(-500),
463
+ };
464
+ }
465
+
466
+ const httpServer = http.createServer((req, res) => {
467
+ // Permissive CORS — only listens on localhost anyway.
468
+ res.setHeader('Access-Control-Allow-Origin', '*');
469
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
470
+ if (req.method === 'OPTIONS') return res.end();
471
+
472
+ if (req.url === '/' || req.url === '/index.html') {
473
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
474
+ return res.end(UI_HTML);
475
+ }
476
+ if (req.url === '/api/upgrade/status') {
477
+ res.setHeader('Content-Type', 'application/json');
478
+ return res.end(JSON.stringify(buildStatus()));
479
+ }
480
+ if (req.url === '/api/upgrade/stream') {
481
+ res.setHeader('Content-Type', 'text/event-stream');
482
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
483
+ res.setHeader('Connection', 'keep-alive');
484
+ res.flushHeaders();
485
+ res.write(': connected\n\n');
486
+ // Replay buffered lines so a late client catches up.
487
+ for (const entry of linesBuffer) {
488
+ res.write(`data: ${JSON.stringify(entry)}\n\n`);
489
+ }
490
+ res.write(`event: phase\ndata: ${JSON.stringify({ phase: phaseValue, ts: Date.now() })}\n\n`);
491
+ if (finishedAt && errorMsg) {
492
+ res.write(`event: failed\ndata: ${JSON.stringify({ error: errorMsg })}\n\n`);
493
+ } else if (finishedAt) {
494
+ res.write(`event: done\ndata: ${JSON.stringify({ redirectTo })}\n\n`);
495
+ }
496
+ sseClients.add(res);
497
+ const hb = setInterval(() => { try { res.write(': ping\n\n'); } catch {} }, 25_000);
498
+ req.on('close', () => { clearInterval(hb); sseClients.delete(res); });
499
+ return;
500
+ }
501
+ if (req.url === '/api/upgrade/dismiss' && req.method === 'POST') {
502
+ res.end('{"ok":true}');
503
+ // Schedule self-exit so the user closing the window also wraps up
504
+ // the helper. Give SSE a chance to flush.
505
+ setTimeout(() => {
506
+ removeLock();
507
+ process.exit(0);
508
+ }, 300);
509
+ return;
510
+ }
511
+ res.statusCode = 404;
512
+ res.end('not found');
513
+ });
514
+ httpServer.on('error', (e) => {
515
+ fileLog(`http server error: ${e.message}`);
516
+ });
517
+ httpServer.listen(HELPER_PORT, '127.0.0.1', () => {
518
+ fileLog(`updater UI at http://localhost:${HELPER_PORT}/`);
519
+ pushLine('info', `Helper UI listening on http://localhost:${HELPER_PORT}/`);
520
+ });
521
+
522
+ // ── Main upgrade flow ────────────────────────────────────────────────
72
523
  (async () => {
73
- log(`start · target=${target} oldPort=${oldPort} oldPid=${oldPid}${installPrefix ? ` prefix=${installPrefix}` : ''}${!doRespawn ? ' (no respawn)' : ''}`);
524
+ fileLog(`start · target=${target} oldPort=${oldPort} oldPid=${oldPid}${installPrefix ? ` prefix=${installPrefix}` : ''}${!doRespawn ? ' (no respawn)' : ''}`);
525
+ pushLine('info', `Upgrading ccsm to ${target}`);
74
526
 
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.
527
+ setPhase('waiting-port');
77
528
  const deadline = Date.now() + 30_000;
78
529
  while (Date.now() < deadline) {
79
530
  const free = await portFree(oldPort);
@@ -81,11 +532,11 @@ function pidAlive(pid) {
81
532
  if (free && dead) break;
82
533
  await sleep(250);
83
534
  }
84
- log(`old server gone (or 30s elapsed) · running npm install`);
535
+ pushLine('info', `Old backend gone (port ${oldPort} free, pid ${oldPid} dead).`);
536
+
537
+ setPhase('installing');
538
+ pushLine('info', `Running: npm i -g @bakapiano/ccsm@${target}${installPrefix ? ` --prefix=${installPrefix}` : ''}`);
85
539
 
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
540
  const isWin = process.platform === 'win32';
90
541
  const arg = `@bakapiano/ccsm@${target}`;
91
542
  const npmArgs = ['i', '-g'];
@@ -94,54 +545,77 @@ function pidAlive(pid) {
94
545
  npmArgs.push(`--prefix=${installPrefix}`);
95
546
  }
96
547
  npmArgs.push(arg);
97
- let r;
548
+
549
+ let exe, exeArgs;
98
550
  if (isWin) {
99
- r = spawnSync(process.env.ComSpec || 'cmd.exe',
100
- ['/d', '/s', '/c', 'npm', ...npmArgs],
101
- { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
551
+ exe = process.env.ComSpec || 'cmd.exe';
552
+ exeArgs = ['/d', '/s', '/c', 'npm', ...npmArgs];
102
553
  } else {
103
- r = spawnSync('npm', npmArgs,
104
- { stdio: ['ignore', 'pipe', 'pipe'] });
554
+ exe = 'npm';
555
+ exeArgs = npmArgs;
105
556
  }
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);
557
+
558
+ const npmExit = await new Promise((resolve) => {
559
+ const child = spawn(exe, exeArgs, { windowsHide: true });
560
+ const pipe = (stream, label) => {
561
+ let leftover = '';
562
+ stream.on('data', (chunk) => {
563
+ const text = leftover + chunk.toString();
564
+ const lines = text.split(/\r?\n/);
565
+ leftover = lines.pop() || '';
566
+ for (const line of lines) if (line) pushLine(label, line);
567
+ });
568
+ stream.on('end', () => { if (leftover) pushLine(label, leftover); });
569
+ };
570
+ pipe(child.stdout, 'stdout');
571
+ pipe(child.stderr, 'stderr');
572
+ child.on('error', (e) => {
573
+ pushLine('stderr', `spawn error: ${e.message}`);
574
+ resolve(-1);
575
+ });
576
+ child.on('exit', (code) => resolve(code));
577
+ });
578
+
579
+ if (npmExit !== 0) {
580
+ errorMsg = `npm exited with code ${npmExit}`;
581
+ pushLine('stderr', errorMsg);
582
+ notifyFailed();
583
+ // Stay alive 10min so user can copy log + read error.
584
+ setTimeout(() => { removeLock(); process.exit(1); }, 10 * 60_000);
585
+ return;
112
586
  }
587
+ pushLine('info', `npm install completed (exit ${npmExit}).`);
113
588
 
114
589
  if (!doRespawn) {
115
- log(`respawn skipped (respawn=0)`);
590
+ pushLine('info', 'respawn skipped (respawn=0).');
591
+ setPhase('done');
592
+ notifyDone(redirectTo);
593
+ setTimeout(() => { removeLock(); process.exit(0); }, 30_000);
116
594
  return;
117
595
  }
118
596
 
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.
597
+ setPhase('spawning');
598
+ pushLine('info', 'Starting new backend…');
599
+
126
600
  const ccsmCmd = installPrefix
127
601
  ? (isWin ? path.join(installPrefix, 'ccsm.cmd') : path.join(installPrefix, 'bin', 'ccsm'))
128
602
  : (isWin ? 'ccsm.cmd' : 'ccsm');
129
- // Drop CCSM_NO_BROWSER so the post-upgrade server pops a fresh window
130
- // — the frontend that triggered the upgrade window.close()'d in
131
- // parallel, and the new window takes its place. Skips the
132
- // OfflineBanner gap that used to bridge the upgrade.
133
603
  const childEnv = { ...process.env };
134
604
  delete childEnv.CCSM_NO_BROWSER;
135
- let exe, exeArgs;
605
+ // Hint the new ccsm that it was spawned by the updater so it can
606
+ // skip auto-opening a browser window — the user is already looking
607
+ // at the updater UI which will redirect to the new server.
608
+ childEnv.CCSM_FROM_UPGRADE = '1';
609
+ let respawnExe, respawnArgs;
136
610
  if (isWin) {
137
- exe = process.env.ComSpec || 'cmd.exe';
138
- exeArgs = ['/d', '/s', '/c', ccsmCmd];
611
+ respawnExe = process.env.ComSpec || 'cmd.exe';
612
+ respawnArgs = ['/d', '/s', '/c', ccsmCmd];
139
613
  } else {
140
- exe = ccsmCmd;
141
- exeArgs = [];
614
+ respawnExe = ccsmCmd;
615
+ respawnArgs = [];
142
616
  }
143
617
  try {
144
- const child = spawn(exe, exeArgs, {
618
+ const child = spawn(respawnExe, respawnArgs, {
145
619
  detached: true,
146
620
  stdio: 'ignore',
147
621
  windowsHide: true,
@@ -149,12 +623,27 @@ function pidAlive(pid) {
149
623
  env: childEnv,
150
624
  });
151
625
  child.unref();
152
- log(`respawned ${ccsmCmd} (via ${path.basename(exe)})`);
626
+ pushLine('info', `Spawned ${ccsmCmd} (via ${path.basename(respawnExe)}).`);
153
627
  } catch (e) {
154
- log(`respawn failed: ${e.message}`);
155
- process.exit(1);
628
+ errorMsg = `respawn failed: ${e.message}`;
629
+ pushLine('stderr', errorMsg);
630
+ notifyFailed();
631
+ setTimeout(() => { removeLock(); process.exit(1); }, 10 * 60_000);
632
+ return;
156
633
  }
634
+
635
+ setPhase('done');
636
+ notifyDone(redirectTo);
637
+ pushLine('info', 'Done. Redirecting frontend to the new backend.');
638
+
639
+ // Stay alive briefly so late-arriving SSE clients still see the
640
+ // success state. After that the helper exits and releases the lock;
641
+ // the new ccsm at port 7777 takes over.
642
+ setTimeout(() => { removeLock(); process.exit(0); }, 30_000);
157
643
  })().catch((e) => {
158
- log(`fatal: ${e.message}`);
159
- process.exit(1);
644
+ errorMsg = e?.message || String(e);
645
+ fileLog(`fatal: ${errorMsg}`);
646
+ pushLine('stderr', `fatal: ${errorMsg}`);
647
+ notifyFailed();
648
+ setTimeout(() => { removeLock(); process.exit(1); }, 10 * 60_000);
160
649
  });