@bakapiano/ccsm 0.22.3 → 0.22.5

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 (61) hide show
  1. package/CLAUDE.md +538 -538
  2. package/README.md +189 -189
  3. package/bin/ccsm.js +235 -235
  4. package/lib/cliActivity.js +139 -139
  5. package/lib/codexSeed.js +183 -183
  6. package/lib/config.js +274 -274
  7. package/lib/devices.js +229 -229
  8. package/lib/folders.js +124 -124
  9. package/lib/localCliSessions.js +519 -519
  10. package/lib/persistedSessions.js +129 -129
  11. package/lib/tunnel.js +621 -621
  12. package/lib/webTerminal.js +225 -225
  13. package/lib/workspace.js +233 -233
  14. package/package.json +57 -57
  15. package/public/css/base.css +99 -99
  16. package/public/css/cards.css +183 -183
  17. package/public/css/feedback.css +504 -504
  18. package/public/css/forms.css +453 -453
  19. package/public/css/layout.css +176 -176
  20. package/public/css/modal.css +190 -190
  21. package/public/css/responsive.css +176 -176
  22. package/public/css/sidebar.css +707 -707
  23. package/public/css/terminals.css +645 -543
  24. package/public/css/tokens.css +81 -81
  25. package/public/css/wco.css +196 -196
  26. package/public/css/widgets.css +2725 -2725
  27. package/public/index.html +152 -152
  28. package/public/js/api.js +371 -371
  29. package/public/js/backend.js +149 -149
  30. package/public/js/components/App.js +73 -73
  31. package/public/js/components/DirectoryPicker.js +203 -203
  32. package/public/js/components/EntityFormModal.js +153 -153
  33. package/public/js/components/Modal.js +57 -57
  34. package/public/js/components/OfflineBanner.js +67 -67
  35. package/public/js/components/PageTitleBar.js +13 -13
  36. package/public/js/components/PendingApprovalOverlay.js +128 -128
  37. package/public/js/components/Picker.js +179 -179
  38. package/public/js/components/Popover.js +55 -55
  39. package/public/js/components/RestartOverlay.js +36 -36
  40. package/public/js/components/Sidebar.js +380 -380
  41. package/public/js/components/TerminalInstance.js +159 -22
  42. package/public/js/components/TerminalResizeDebouncer.js +126 -0
  43. package/public/js/components/TerminalView.js +15 -2
  44. package/public/js/components/XtermTerminal.js +74 -15
  45. package/public/js/components/useDragSort.js +67 -67
  46. package/public/js/dialog.js +67 -67
  47. package/public/js/icons.js +212 -212
  48. package/public/js/main.js +296 -296
  49. package/public/js/pages/AboutPage.js +90 -90
  50. package/public/js/pages/ConfigurePage.js +713 -713
  51. package/public/js/pages/LaunchPage.js +421 -421
  52. package/public/js/pages/RemotePage.js +743 -743
  53. package/public/js/pages/SessionsPage.js +199 -80
  54. package/public/js/state.js +335 -335
  55. package/public/manifest.webmanifest +25 -0
  56. package/public/setup/index.html +567 -0
  57. package/scripts/dev.js +149 -149
  58. package/scripts/install.js +153 -153
  59. package/scripts/restart-helper.js +96 -96
  60. package/scripts/upgrade-helper.js +687 -687
  61. package/server.js +1807 -1807
@@ -1,687 +1,687 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- // In-app upgrade helper · spawned detached by /api/upgrade.
5
- //
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.
9
- //
10
- // What this script does:
11
- // 1. Server validates the upgrade request, spawns this helper detached
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.
31
- //
32
- // Argv: node upgrade-helper.js <target> <port> <pid> [installPrefix] [respawn=1|0]
33
-
34
- const fs = require('node:fs');
35
- const path = require('node:path');
36
- const os = require('node:os');
37
- const net = require('node:net');
38
- const http = require('node:http');
39
- const { spawn, spawnSync } = require('node:child_process');
40
-
41
- const target = process.argv[2] || 'latest';
42
- const oldPort = Number(process.argv[3] || 7777);
43
- const oldPid = Number(process.argv[4] || 0);
44
- const installPrefix = process.argv[5] || '';
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}/`;
51
-
52
- const HELPER_PORT = 7779;
53
- const HOME = process.env.CCSM_HOME || path.join(os.homedir(), '.ccsm');
54
- const LOG = path.join(HOME, 'upgrade.log');
55
- const LOCK = path.join(HOME, '.upgrade.lock');
56
- try { fs.mkdirSync(HOME, { recursive: true }); } catch {}
57
-
58
- function fileLog(msg) {
59
- const line = `[${new Date().toISOString()}] ${msg}\n`;
60
- try { fs.appendFileSync(LOG, line); } catch {}
61
- }
62
-
63
- function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
64
-
65
- function portFree(port, timeoutMs = 800) {
66
- return new Promise((resolve) => {
67
- const s = new net.Socket();
68
- let settled = false;
69
- const finish = (free) => { if (settled) return; settled = true; try { s.destroy(); } catch {} resolve(free); };
70
- s.setTimeout(timeoutMs);
71
- s.once('connect', () => finish(false));
72
- s.once('timeout', () => finish(true));
73
- s.once('error', () => finish(true));
74
- s.connect(port, '127.0.0.1');
75
- });
76
- }
77
-
78
- function pidAlive(pid) {
79
- if (!pid) return false;
80
- try { process.kill(pid, 0); return true; }
81
- catch (e) { return e.code === 'EPERM'; }
82
- }
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 ────────────────────────────────────────────────
523
- (async () => {
524
- fileLog(`start · target=${target} oldPort=${oldPort} oldPid=${oldPid}${installPrefix ? ` prefix=${installPrefix}` : ''}${!doRespawn ? ' (no respawn)' : ''}`);
525
- pushLine('info', `Upgrading ccsm to ${target}`);
526
-
527
- setPhase('waiting-port');
528
- const deadline = Date.now() + 30_000;
529
- while (Date.now() < deadline) {
530
- const free = await portFree(oldPort);
531
- const dead = !pidAlive(oldPid);
532
- if (free && dead) break;
533
- await sleep(250);
534
- }
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}` : ''}`);
539
-
540
- // Extra settle: gracefulShutdown only waits for the server pid, but
541
- // node-pty grandchildren (winpty-agent / conpty) need a beat longer
542
- // to release file locks on node_modules/node-pty/build/Release/*.node.
543
- // Without this beat, npm hits EBUSY/EPERM renaming the package dir.
544
- await sleep(2000);
545
-
546
- const isWin = process.platform === 'win32';
547
- const arg = `@bakapiano/ccsm@${target}`;
548
- const npmArgs = ['i', '-g'];
549
- if (installPrefix) {
550
- try { fs.mkdirSync(installPrefix, { recursive: true }); } catch {}
551
- npmArgs.push(`--prefix=${installPrefix}`);
552
- }
553
- npmArgs.push(arg);
554
-
555
- let exe, exeArgs;
556
- if (isWin) {
557
- exe = process.env.ComSpec || 'cmd.exe';
558
- exeArgs = ['/d', '/s', '/c', 'npm', ...npmArgs];
559
- } else {
560
- exe = 'npm';
561
- exeArgs = npmArgs;
562
- }
563
-
564
- // Postinstall opens the hosted setup guide by default — fine on a
565
- // first npm i, but during an in-app upgrade the user is already in
566
- // the updater UI and a fresh tab to /setup/ is just noise.
567
- const npmEnv = { ...process.env, CCSM_NO_AUTOLAUNCH: '1' };
568
-
569
- const LOCK_PATTERN = /\b(EBUSY|EPERM|ENOTEMPTY|EEXIST|ELOCKED|locked|in use|cannot rename|operation not permitted)\b/i;
570
-
571
- async function runNpmOnce() {
572
- let sawLockError = false;
573
- const exit = await new Promise((resolve) => {
574
- const child = spawn(exe, exeArgs, { windowsHide: true, env: npmEnv });
575
- const pipe = (stream, label) => {
576
- let leftover = '';
577
- stream.on('data', (chunk) => {
578
- const text = leftover + chunk.toString();
579
- const lines = text.split(/\r?\n/);
580
- leftover = lines.pop() || '';
581
- for (const line of lines) {
582
- if (!line) continue;
583
- if (LOCK_PATTERN.test(line)) sawLockError = true;
584
- pushLine(label, line);
585
- }
586
- });
587
- stream.on('end', () => { if (leftover) pushLine(label, leftover); });
588
- };
589
- pipe(child.stdout, 'stdout');
590
- pipe(child.stderr, 'stderr');
591
- child.on('error', (e) => {
592
- pushLine('stderr', `spawn error: ${e.message}`);
593
- resolve(-1);
594
- });
595
- child.on('exit', (code) => resolve(code));
596
- });
597
- return { exit, sawLockError };
598
- }
599
-
600
- let npmExit = -1;
601
- // Up to 3 attempts: original + 2 retries with growing backoff. Only
602
- // retry when the failure looks like a file-lock issue from straggling
603
- // child handles, never on a clean nonzero exit (auth, 404, etc).
604
- const backoffs = [3000, 6000];
605
- let attempt = 0;
606
- while (true) {
607
- attempt++;
608
- const { exit, sawLockError } = await runNpmOnce();
609
- npmExit = exit;
610
- if (exit === 0) break;
611
- if (!sawLockError || attempt > backoffs.length) break;
612
- const wait = backoffs[attempt - 1];
613
- pushLine('info', `npm failed with what looks like a file lock; retrying in ${Math.round(wait/1000)}s (attempt ${attempt + 1})…`);
614
- await sleep(wait);
615
- }
616
-
617
- if (npmExit !== 0) {
618
- errorMsg = `npm exited with code ${npmExit}`;
619
- pushLine('stderr', errorMsg);
620
- notifyFailed();
621
- // Stay alive 10min so user can copy log + read error.
622
- setTimeout(() => { removeLock(); process.exit(1); }, 10 * 60_000);
623
- return;
624
- }
625
- pushLine('info', `npm install completed (exit ${npmExit}).`);
626
-
627
- if (!doRespawn) {
628
- pushLine('info', 'respawn skipped (respawn=0).');
629
- setPhase('done');
630
- notifyDone(redirectTo);
631
- setTimeout(() => { removeLock(); process.exit(0); }, 30_000);
632
- return;
633
- }
634
-
635
- setPhase('spawning');
636
- pushLine('info', 'Starting new backend…');
637
-
638
- const ccsmCmd = installPrefix
639
- ? (isWin ? path.join(installPrefix, 'ccsm.cmd') : path.join(installPrefix, 'bin', 'ccsm'))
640
- : (isWin ? 'ccsm.cmd' : 'ccsm');
641
- const childEnv = { ...process.env };
642
- delete childEnv.CCSM_NO_BROWSER;
643
- // Hint the new ccsm that it was spawned by the updater so it can
644
- // skip auto-opening a browser window — the user is already looking
645
- // at the updater UI which will redirect to the new server.
646
- childEnv.CCSM_FROM_UPGRADE = '1';
647
- let respawnExe, respawnArgs;
648
- if (isWin) {
649
- respawnExe = process.env.ComSpec || 'cmd.exe';
650
- respawnArgs = ['/d', '/s', '/c', ccsmCmd];
651
- } else {
652
- respawnExe = ccsmCmd;
653
- respawnArgs = [];
654
- }
655
- try {
656
- const child = spawn(respawnExe, respawnArgs, {
657
- detached: true,
658
- stdio: 'ignore',
659
- windowsHide: true,
660
- shell: false,
661
- env: childEnv,
662
- });
663
- child.unref();
664
- pushLine('info', `Spawned ${ccsmCmd} (via ${path.basename(respawnExe)}).`);
665
- } catch (e) {
666
- errorMsg = `respawn failed: ${e.message}`;
667
- pushLine('stderr', errorMsg);
668
- notifyFailed();
669
- setTimeout(() => { removeLock(); process.exit(1); }, 10 * 60_000);
670
- return;
671
- }
672
-
673
- setPhase('done');
674
- notifyDone(redirectTo);
675
- pushLine('info', 'Done. Redirecting frontend to the new backend.');
676
-
677
- // Stay alive briefly so late-arriving SSE clients still see the
678
- // success state. After that the helper exits and releases the lock;
679
- // the new ccsm at port 7777 takes over.
680
- setTimeout(() => { removeLock(); process.exit(0); }, 30_000);
681
- })().catch((e) => {
682
- errorMsg = e?.message || String(e);
683
- fileLog(`fatal: ${errorMsg}`);
684
- pushLine('stderr', `fatal: ${errorMsg}`);
685
- notifyFailed();
686
- setTimeout(() => { removeLock(); process.exit(1); }, 10 * 60_000);
687
- });
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // In-app upgrade helper · spawned detached by /api/upgrade.
5
+ //
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.
9
+ //
10
+ // What this script does:
11
+ // 1. Server validates the upgrade request, spawns this helper detached
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.
31
+ //
32
+ // Argv: node upgrade-helper.js <target> <port> <pid> [installPrefix] [respawn=1|0]
33
+
34
+ const fs = require('node:fs');
35
+ const path = require('node:path');
36
+ const os = require('node:os');
37
+ const net = require('node:net');
38
+ const http = require('node:http');
39
+ const { spawn, spawnSync } = require('node:child_process');
40
+
41
+ const target = process.argv[2] || 'latest';
42
+ const oldPort = Number(process.argv[3] || 7777);
43
+ const oldPid = Number(process.argv[4] || 0);
44
+ const installPrefix = process.argv[5] || '';
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}/`;
51
+
52
+ const HELPER_PORT = 7779;
53
+ const HOME = process.env.CCSM_HOME || path.join(os.homedir(), '.ccsm');
54
+ const LOG = path.join(HOME, 'upgrade.log');
55
+ const LOCK = path.join(HOME, '.upgrade.lock');
56
+ try { fs.mkdirSync(HOME, { recursive: true }); } catch {}
57
+
58
+ function fileLog(msg) {
59
+ const line = `[${new Date().toISOString()}] ${msg}\n`;
60
+ try { fs.appendFileSync(LOG, line); } catch {}
61
+ }
62
+
63
+ function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
64
+
65
+ function portFree(port, timeoutMs = 800) {
66
+ return new Promise((resolve) => {
67
+ const s = new net.Socket();
68
+ let settled = false;
69
+ const finish = (free) => { if (settled) return; settled = true; try { s.destroy(); } catch {} resolve(free); };
70
+ s.setTimeout(timeoutMs);
71
+ s.once('connect', () => finish(false));
72
+ s.once('timeout', () => finish(true));
73
+ s.once('error', () => finish(true));
74
+ s.connect(port, '127.0.0.1');
75
+ });
76
+ }
77
+
78
+ function pidAlive(pid) {
79
+ if (!pid) return false;
80
+ try { process.kill(pid, 0); return true; }
81
+ catch (e) { return e.code === 'EPERM'; }
82
+ }
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 ────────────────────────────────────────────────
523
+ (async () => {
524
+ fileLog(`start · target=${target} oldPort=${oldPort} oldPid=${oldPid}${installPrefix ? ` prefix=${installPrefix}` : ''}${!doRespawn ? ' (no respawn)' : ''}`);
525
+ pushLine('info', `Upgrading ccsm to ${target}`);
526
+
527
+ setPhase('waiting-port');
528
+ const deadline = Date.now() + 30_000;
529
+ while (Date.now() < deadline) {
530
+ const free = await portFree(oldPort);
531
+ const dead = !pidAlive(oldPid);
532
+ if (free && dead) break;
533
+ await sleep(250);
534
+ }
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}` : ''}`);
539
+
540
+ // Extra settle: gracefulShutdown only waits for the server pid, but
541
+ // node-pty grandchildren (winpty-agent / conpty) need a beat longer
542
+ // to release file locks on node_modules/node-pty/build/Release/*.node.
543
+ // Without this beat, npm hits EBUSY/EPERM renaming the package dir.
544
+ await sleep(2000);
545
+
546
+ const isWin = process.platform === 'win32';
547
+ const arg = `@bakapiano/ccsm@${target}`;
548
+ const npmArgs = ['i', '-g'];
549
+ if (installPrefix) {
550
+ try { fs.mkdirSync(installPrefix, { recursive: true }); } catch {}
551
+ npmArgs.push(`--prefix=${installPrefix}`);
552
+ }
553
+ npmArgs.push(arg);
554
+
555
+ let exe, exeArgs;
556
+ if (isWin) {
557
+ exe = process.env.ComSpec || 'cmd.exe';
558
+ exeArgs = ['/d', '/s', '/c', 'npm', ...npmArgs];
559
+ } else {
560
+ exe = 'npm';
561
+ exeArgs = npmArgs;
562
+ }
563
+
564
+ // Postinstall opens the hosted setup guide by default — fine on a
565
+ // first npm i, but during an in-app upgrade the user is already in
566
+ // the updater UI and a fresh tab to /setup/ is just noise.
567
+ const npmEnv = { ...process.env, CCSM_NO_AUTOLAUNCH: '1' };
568
+
569
+ const LOCK_PATTERN = /\b(EBUSY|EPERM|ENOTEMPTY|EEXIST|ELOCKED|locked|in use|cannot rename|operation not permitted)\b/i;
570
+
571
+ async function runNpmOnce() {
572
+ let sawLockError = false;
573
+ const exit = await new Promise((resolve) => {
574
+ const child = spawn(exe, exeArgs, { windowsHide: true, env: npmEnv });
575
+ const pipe = (stream, label) => {
576
+ let leftover = '';
577
+ stream.on('data', (chunk) => {
578
+ const text = leftover + chunk.toString();
579
+ const lines = text.split(/\r?\n/);
580
+ leftover = lines.pop() || '';
581
+ for (const line of lines) {
582
+ if (!line) continue;
583
+ if (LOCK_PATTERN.test(line)) sawLockError = true;
584
+ pushLine(label, line);
585
+ }
586
+ });
587
+ stream.on('end', () => { if (leftover) pushLine(label, leftover); });
588
+ };
589
+ pipe(child.stdout, 'stdout');
590
+ pipe(child.stderr, 'stderr');
591
+ child.on('error', (e) => {
592
+ pushLine('stderr', `spawn error: ${e.message}`);
593
+ resolve(-1);
594
+ });
595
+ child.on('exit', (code) => resolve(code));
596
+ });
597
+ return { exit, sawLockError };
598
+ }
599
+
600
+ let npmExit = -1;
601
+ // Up to 3 attempts: original + 2 retries with growing backoff. Only
602
+ // retry when the failure looks like a file-lock issue from straggling
603
+ // child handles, never on a clean nonzero exit (auth, 404, etc).
604
+ const backoffs = [3000, 6000];
605
+ let attempt = 0;
606
+ while (true) {
607
+ attempt++;
608
+ const { exit, sawLockError } = await runNpmOnce();
609
+ npmExit = exit;
610
+ if (exit === 0) break;
611
+ if (!sawLockError || attempt > backoffs.length) break;
612
+ const wait = backoffs[attempt - 1];
613
+ pushLine('info', `npm failed with what looks like a file lock; retrying in ${Math.round(wait/1000)}s (attempt ${attempt + 1})…`);
614
+ await sleep(wait);
615
+ }
616
+
617
+ if (npmExit !== 0) {
618
+ errorMsg = `npm exited with code ${npmExit}`;
619
+ pushLine('stderr', errorMsg);
620
+ notifyFailed();
621
+ // Stay alive 10min so user can copy log + read error.
622
+ setTimeout(() => { removeLock(); process.exit(1); }, 10 * 60_000);
623
+ return;
624
+ }
625
+ pushLine('info', `npm install completed (exit ${npmExit}).`);
626
+
627
+ if (!doRespawn) {
628
+ pushLine('info', 'respawn skipped (respawn=0).');
629
+ setPhase('done');
630
+ notifyDone(redirectTo);
631
+ setTimeout(() => { removeLock(); process.exit(0); }, 30_000);
632
+ return;
633
+ }
634
+
635
+ setPhase('spawning');
636
+ pushLine('info', 'Starting new backend…');
637
+
638
+ const ccsmCmd = installPrefix
639
+ ? (isWin ? path.join(installPrefix, 'ccsm.cmd') : path.join(installPrefix, 'bin', 'ccsm'))
640
+ : (isWin ? 'ccsm.cmd' : 'ccsm');
641
+ const childEnv = { ...process.env };
642
+ delete childEnv.CCSM_NO_BROWSER;
643
+ // Hint the new ccsm that it was spawned by the updater so it can
644
+ // skip auto-opening a browser window — the user is already looking
645
+ // at the updater UI which will redirect to the new server.
646
+ childEnv.CCSM_FROM_UPGRADE = '1';
647
+ let respawnExe, respawnArgs;
648
+ if (isWin) {
649
+ respawnExe = process.env.ComSpec || 'cmd.exe';
650
+ respawnArgs = ['/d', '/s', '/c', ccsmCmd];
651
+ } else {
652
+ respawnExe = ccsmCmd;
653
+ respawnArgs = [];
654
+ }
655
+ try {
656
+ const child = spawn(respawnExe, respawnArgs, {
657
+ detached: true,
658
+ stdio: 'ignore',
659
+ windowsHide: true,
660
+ shell: false,
661
+ env: childEnv,
662
+ });
663
+ child.unref();
664
+ pushLine('info', `Spawned ${ccsmCmd} (via ${path.basename(respawnExe)}).`);
665
+ } catch (e) {
666
+ errorMsg = `respawn failed: ${e.message}`;
667
+ pushLine('stderr', errorMsg);
668
+ notifyFailed();
669
+ setTimeout(() => { removeLock(); process.exit(1); }, 10 * 60_000);
670
+ return;
671
+ }
672
+
673
+ setPhase('done');
674
+ notifyDone(redirectTo);
675
+ pushLine('info', 'Done. Redirecting frontend to the new backend.');
676
+
677
+ // Stay alive briefly so late-arriving SSE clients still see the
678
+ // success state. After that the helper exits and releases the lock;
679
+ // the new ccsm at port 7777 takes over.
680
+ setTimeout(() => { removeLock(); process.exit(0); }, 30_000);
681
+ })().catch((e) => {
682
+ errorMsg = e?.message || String(e);
683
+ fileLog(`fatal: ${errorMsg}`);
684
+ pushLine('stderr', `fatal: ${errorMsg}`);
685
+ notifyFailed();
686
+ setTimeout(() => { removeLock(); process.exit(1); }, 10 * 60_000);
687
+ });