@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
|
@@ -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
|
-
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
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
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
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
|
+
});
|