synabun 2026.4.26-beta.3 → 2026.4.26-beta.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.
- package/neural-interface/public/shared/styles.css +16 -0
- package/neural-interface/public/shared/ui-update.js +128 -18
- package/neural-interface/server.js +329 -35
- package/package.json +2 -1
- package/preuninstall.js +58 -6
- package/updater.mjs +177 -0
|
@@ -24369,3 +24369,19 @@
|
|
|
24369
24369
|
.update-check-toast .utc-sub { color: var(--t-muted); }
|
|
24370
24370
|
.update-check-toast .utc-hint { color: var(--t-muted); opacity: 0.7; margin-top: 6px; font-size: 11px; }
|
|
24371
24371
|
|
|
24372
|
+
.update-channel-pill {
|
|
24373
|
+
display: inline-block;
|
|
24374
|
+
padding: 2px 8px;
|
|
24375
|
+
margin-left: 8px;
|
|
24376
|
+
font-size: 10px;
|
|
24377
|
+
font-weight: 600;
|
|
24378
|
+
letter-spacing: 0.04em;
|
|
24379
|
+
text-transform: uppercase;
|
|
24380
|
+
border-radius: 6px;
|
|
24381
|
+
vertical-align: middle;
|
|
24382
|
+
background: rgba(255, 159, 10, 0.18);
|
|
24383
|
+
color: #ff9f0a;
|
|
24384
|
+
}
|
|
24385
|
+
.update-channel-pill--beta { background: rgba(255, 159, 10, 0.18); color: #ff9f0a; }
|
|
24386
|
+
|
|
24387
|
+
|
|
@@ -88,24 +88,36 @@ function openUpdateModal() {
|
|
|
88
88
|
|
|
89
89
|
function renderStep1() {
|
|
90
90
|
const src = _versionData.source;
|
|
91
|
+
const channel = _versionData.installedChannel || 'stable';
|
|
92
|
+
const channelTag = channel === 'prerelease'
|
|
93
|
+
? '<span class="update-channel-pill update-channel-pill--beta">beta</span>'
|
|
94
|
+
: '';
|
|
95
|
+
|
|
91
96
|
const srcCopy = src === 'both'
|
|
92
97
|
? 'A new version of SynaBun is available on npm and GitHub.'
|
|
93
98
|
: src === 'github'
|
|
94
99
|
? 'A new SynaBun release is tagged on GitHub. The npm publish may still be in progress.'
|
|
95
100
|
: 'A new version of SynaBun has been published to npm.';
|
|
96
101
|
|
|
97
|
-
const
|
|
98
|
-
|
|
102
|
+
const npmStable = _versionData.npmLatestStable
|
|
103
|
+
? `npm latest v${esc(_versionData.npmLatestStable)}`
|
|
104
|
+
: 'npm latest —';
|
|
105
|
+
const npmBeta = _versionData.npmLatestBeta
|
|
106
|
+
? ` · npm beta v${esc(_versionData.npmLatestBeta)}`
|
|
107
|
+
: '';
|
|
108
|
+
const gitBit = _versionData.gitLatest
|
|
109
|
+
? ` · GitHub v${esc(_versionData.gitLatest)}`
|
|
110
|
+
: ' · GitHub —';
|
|
99
111
|
|
|
100
112
|
return `
|
|
101
|
-
<h3 class="update-modal-title">Update Available</h3>
|
|
113
|
+
<h3 class="update-modal-title">Update Available ${channelTag}</h3>
|
|
102
114
|
<div class="update-version-diff">
|
|
103
115
|
<span class="update-ver update-ver--old">v${esc(_versionData.current)}</span>
|
|
104
116
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
|
|
105
117
|
<span class="update-ver update-ver--new">v${esc(_versionData.latest)}</span>
|
|
106
118
|
</div>
|
|
107
119
|
<p style="font-size:12px;color:var(--t-muted);margin:12px 0 6px">${srcCopy}</p>
|
|
108
|
-
<p style="font-size:11px;color:var(--t-muted);opacity:.75;margin:0 0 18px">${
|
|
120
|
+
<p style="font-size:11px;color:var(--t-muted);opacity:.75;margin:0 0 18px">${npmStable}${npmBeta}${gitBit}</p>
|
|
109
121
|
<div class="tag-delete-modal-actions">
|
|
110
122
|
<button class="action-btn action-btn--ghost" id="update-cancel">Cancel</button>
|
|
111
123
|
<button class="action-btn action-btn--primary" id="update-next">Next</button>
|
|
@@ -137,30 +149,48 @@ function openUpdateModal() {
|
|
|
137
149
|
}
|
|
138
150
|
|
|
139
151
|
function renderStep3() {
|
|
152
|
+
// Channel-aware install command. Beta users staying on beta keep the
|
|
153
|
+
// @beta tag; both targets exist on stable so we surface a single line.
|
|
154
|
+
const channel = _versionData.installedChannel || 'stable';
|
|
155
|
+
const target = _versionData.latest || '';
|
|
156
|
+
const targetLooksBeta = parseSemverPre(target);
|
|
157
|
+
const installTag = (channel === 'prerelease' && targetLooksBeta) ? 'beta' : 'latest';
|
|
158
|
+
const installCmd = `npm install -g synabun@${installTag}`;
|
|
159
|
+
|
|
140
160
|
return `
|
|
141
161
|
<h3 class="update-modal-title">Run the Update</h3>
|
|
142
162
|
<p style="font-size:12px;color:var(--t-muted);margin:4px 0 12px">
|
|
143
|
-
|
|
163
|
+
SynaBun will close and run this command in a new terminal window:
|
|
144
164
|
</p>
|
|
145
165
|
<div class="update-code-block">
|
|
146
|
-
<code
|
|
147
|
-
<button class="update-copy-btn" data-copy="
|
|
166
|
+
<code>${esc(installCmd)}</code>
|
|
167
|
+
<button class="update-copy-btn" data-copy="${esc(installCmd)}" title="Copy">
|
|
148
168
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
|
149
169
|
</button>
|
|
150
170
|
</div>
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
171
|
+
|
|
172
|
+
<label class="update-skip-label" style="margin:14px 0 8px;display:flex;align-items:center;gap:8px;cursor:pointer">
|
|
173
|
+
<input type="checkbox" id="update-auto-restart" checked>
|
|
174
|
+
<span>Relaunch SynaBun after update</span>
|
|
175
|
+
</label>
|
|
176
|
+
|
|
177
|
+
<div class="update-run-status" id="update-run-status" style="display:none;margin:10px 0 0">
|
|
178
|
+
<span class="update-backup-icon" id="update-run-icon"></span>
|
|
179
|
+
<span id="update-run-text"></span>
|
|
159
180
|
</div>
|
|
160
|
-
|
|
181
|
+
|
|
182
|
+
<div class="tag-delete-modal-actions" style="margin-top:14px;flex-wrap:wrap;gap:8px">
|
|
161
183
|
<button class="action-btn action-btn--ghost" id="update-back">Back</button>
|
|
162
|
-
<button class="action-btn action-btn--
|
|
163
|
-
|
|
184
|
+
<button class="action-btn action-btn--ghost" id="update-manual" title="Run the command yourself in your own terminal">Manual</button>
|
|
185
|
+
<button class="action-btn action-btn--primary" id="update-run-now">
|
|
186
|
+
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><polyline points="8 17 12 21 16 17"/><line x1="12" y1="12" x2="12" y2="21"/><path d="M20.88 18.09A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.29"/></svg>
|
|
187
|
+
Update Now
|
|
188
|
+
</button>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
<p id="update-manual-note" style="display:none;font-size:11px;color:var(--t-muted);opacity:.75;margin:10px 0 0">
|
|
192
|
+
After running the command, restart SynaBun: <code>synabun</code>
|
|
193
|
+
</p>`;
|
|
164
194
|
}
|
|
165
195
|
|
|
166
196
|
function bindEvents() {
|
|
@@ -201,6 +231,16 @@ function openUpdateModal() {
|
|
|
201
231
|
// Backup download
|
|
202
232
|
overlay.querySelector('#update-backup-download')?.addEventListener('click', handleBackupDownload);
|
|
203
233
|
|
|
234
|
+
// Update Now — kick off click-to-update flow
|
|
235
|
+
overlay.querySelector('#update-run-now')?.addEventListener('click', handleRunUpdate);
|
|
236
|
+
|
|
237
|
+
// Manual — surface the command + relaunch hint, hide auto-update controls
|
|
238
|
+
overlay.querySelector('#update-manual')?.addEventListener('click', () => {
|
|
239
|
+
overlay.querySelector('#update-manual-note').style.display = '';
|
|
240
|
+
overlay.querySelector('#update-run-now').style.display = 'none';
|
|
241
|
+
overlay.querySelector('#update-auto-restart').closest('label').style.display = 'none';
|
|
242
|
+
});
|
|
243
|
+
|
|
204
244
|
// Skip checkbox
|
|
205
245
|
const skipCb = overlay.querySelector('#update-skip-backup');
|
|
206
246
|
if (skipCb) {
|
|
@@ -212,6 +252,67 @@ function openUpdateModal() {
|
|
|
212
252
|
}
|
|
213
253
|
}
|
|
214
254
|
|
|
255
|
+
async function handleRunUpdate() {
|
|
256
|
+
// Resolve the same install tag the modal copy showed (channel-aware).
|
|
257
|
+
const target = _versionData?.latest || '';
|
|
258
|
+
const channel = _versionData?.installedChannel || 'stable';
|
|
259
|
+
const targetIsBeta = !!parseSemverPre(target);
|
|
260
|
+
const tag = (channel === 'prerelease' && targetIsBeta) ? 'beta' : 'latest';
|
|
261
|
+
|
|
262
|
+
const autoRestartCb = overlay.querySelector('#update-auto-restart');
|
|
263
|
+
const autoRestart = !!autoRestartCb?.checked;
|
|
264
|
+
|
|
265
|
+
const runBtn = overlay.querySelector('#update-run-now');
|
|
266
|
+
const backBtn = overlay.querySelector('#update-back');
|
|
267
|
+
const manualBtn = overlay.querySelector('#update-manual');
|
|
268
|
+
const status = overlay.querySelector('#update-run-status');
|
|
269
|
+
const icon = overlay.querySelector('#update-run-icon');
|
|
270
|
+
const text = overlay.querySelector('#update-run-text');
|
|
271
|
+
|
|
272
|
+
runBtn.disabled = true;
|
|
273
|
+
backBtn.disabled = true;
|
|
274
|
+
manualBtn.disabled = true;
|
|
275
|
+
runBtn.innerHTML = '<span class="update-spinner"></span> Launching updater...';
|
|
276
|
+
status.style.display = 'flex';
|
|
277
|
+
icon.className = 'update-backup-icon spin';
|
|
278
|
+
icon.textContent = '⟳';
|
|
279
|
+
text.textContent = 'Opening updater terminal and shutting down server...';
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
const res = await fetch('/api/system/run-update', {
|
|
283
|
+
method: 'POST',
|
|
284
|
+
headers: { 'Content-Type': 'application/json' },
|
|
285
|
+
body: JSON.stringify({ tag, autoRestart }),
|
|
286
|
+
});
|
|
287
|
+
if (!res.ok) {
|
|
288
|
+
let msg = `Updater failed (${res.status})`;
|
|
289
|
+
try { const b = await res.json(); msg = b.error || msg; } catch {}
|
|
290
|
+
throw new Error(msg);
|
|
291
|
+
}
|
|
292
|
+
const body = await res.json();
|
|
293
|
+
|
|
294
|
+
icon.className = 'update-backup-icon';
|
|
295
|
+
icon.textContent = '✓';
|
|
296
|
+
icon.style.color = '#34c759';
|
|
297
|
+
text.innerHTML = `Updater terminal launched. <strong>This window will go offline.</strong> Watch the new terminal for install progress${autoRestart ? ' — SynaBun relaunches when done.' : '. Run <code>synabun</code> after install completes.'}`;
|
|
298
|
+
runBtn.innerHTML = 'Updater running…';
|
|
299
|
+
|
|
300
|
+
// Server is shutting down. Optionally redirect to a friendly offline
|
|
301
|
+
// notice — for now leave the page; user already understands from copy.
|
|
302
|
+
// Re-enable Back so they can close the modal.
|
|
303
|
+
setTimeout(() => { backBtn.disabled = false; }, 2500);
|
|
304
|
+
} catch (err) {
|
|
305
|
+
icon.className = 'update-backup-icon';
|
|
306
|
+
icon.textContent = '✗';
|
|
307
|
+
icon.style.color = 'var(--accent-red)';
|
|
308
|
+
text.textContent = 'Failed to launch updater: ' + err.message;
|
|
309
|
+
runBtn.disabled = false;
|
|
310
|
+
backBtn.disabled = false;
|
|
311
|
+
manualBtn.disabled = false;
|
|
312
|
+
runBtn.innerHTML = `<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><polyline points="8 17 12 21 16 17"/><line x1="12" y1="12" x2="12" y2="21"/><path d="M20.88 18.09A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.29"/></svg> Retry`;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
215
316
|
async function handleBackupDownload() {
|
|
216
317
|
const btn = overlay.querySelector('#update-backup-download');
|
|
217
318
|
const status = overlay.querySelector('#update-backup-status');
|
|
@@ -279,3 +380,12 @@ function esc(str) {
|
|
|
279
380
|
d.textContent = str || '';
|
|
280
381
|
return d.innerHTML;
|
|
281
382
|
}
|
|
383
|
+
|
|
384
|
+
// Returns the prerelease tag from a semver-ish string ("2026.4.26-beta.3"
|
|
385
|
+
// -> "beta.3"), or null. Used to pick install command target.
|
|
386
|
+
function parseSemverPre(raw) {
|
|
387
|
+
if (raw == null) return null;
|
|
388
|
+
const s = String(raw).trim().replace(/^v\.?/, '');
|
|
389
|
+
const m = s.match(/^\d+\.\d+\.\d+(?:-([0-9A-Za-z.-]+))?/);
|
|
390
|
+
return m && m[1] ? m[1] : null;
|
|
391
|
+
}
|
|
@@ -1546,14 +1546,186 @@ app.put('/api/settings', (req, res) => {
|
|
|
1546
1546
|
}
|
|
1547
1547
|
});
|
|
1548
1548
|
|
|
1549
|
-
//
|
|
1550
|
-
|
|
1551
|
-
|
|
1549
|
+
// ── Graceful shutdown — kills all spawned children before exiting ──
|
|
1550
|
+
//
|
|
1551
|
+
// Background: the previous /api/server/restart only ran closeDb() + exit(0),
|
|
1552
|
+
// leaving spawned children orphaned (opencode sidecar, agents, _sidecarProcess
|
|
1553
|
+
// CLI links, pty terminals, cloudflare tunnel). On Windows those orphans
|
|
1554
|
+
// kept file handles inside neural-interface/ open, causing EBUSY errors when
|
|
1555
|
+
// `npm install -g synabun@latest` tried to rename the package directory.
|
|
1556
|
+
//
|
|
1557
|
+
// gracefulShutdown() centralizes shutdown so /api/server/restart,
|
|
1558
|
+
// /api/server/shutdown, and the new /api/system/run-update all release
|
|
1559
|
+
// children before exiting.
|
|
1560
|
+
|
|
1561
|
+
let _shutdownInProgress = false;
|
|
1562
|
+
|
|
1563
|
+
async function gracefulShutdown(reason = 'shutdown') {
|
|
1564
|
+
if (_shutdownInProgress) return;
|
|
1565
|
+
_shutdownInProgress = true;
|
|
1566
|
+
console.log(`[server] gracefulShutdown(${reason}) — releasing children...`);
|
|
1567
|
+
|
|
1568
|
+
// 1. opencode sidecar — has its own teardown that handles managed PID + ollama.
|
|
1569
|
+
try { stopOpencodeServer(); } catch (err) { console.warn(' opencode stop err:', err?.message); }
|
|
1570
|
+
|
|
1571
|
+
// 2. Agents in agentRegistry — use platform-native tree kill so descendants die too.
|
|
1572
|
+
try {
|
|
1573
|
+
const isWin = process.platform === 'win32';
|
|
1574
|
+
for (const agent of agentRegistry.values()) {
|
|
1575
|
+
const pid = agent?.process?.pid;
|
|
1576
|
+
if (!pid || agent.status !== 'running') continue;
|
|
1577
|
+
agent._stopped = true;
|
|
1578
|
+
agent.status = 'stopped';
|
|
1579
|
+
try {
|
|
1580
|
+
if (isWin) {
|
|
1581
|
+
spawn('taskkill', ['/pid', String(pid), '/T', '/F'], { shell: true, windowsHide: true });
|
|
1582
|
+
} else {
|
|
1583
|
+
try { process.kill(-pid, 'SIGTERM'); } catch { try { agent.process.kill('SIGTERM'); } catch {} }
|
|
1584
|
+
}
|
|
1585
|
+
} catch {}
|
|
1586
|
+
}
|
|
1587
|
+
} catch (err) { console.warn(' agent kill err:', err?.message); }
|
|
1588
|
+
|
|
1589
|
+
// 3. Save snapshot + DB last so children release file handles before SQLite closes.
|
|
1590
|
+
try { saveSessionSnapshot(); } catch {}
|
|
1591
|
+
|
|
1592
|
+
// 4. Brief grace period so SIGTERM'd children can flush.
|
|
1593
|
+
await new Promise(r => setTimeout(r, 500));
|
|
1594
|
+
|
|
1595
|
+
try { closeDb(); } catch {}
|
|
1596
|
+
|
|
1597
|
+
// 5. Belt-and-suspenders: tree-kill THIS process's descendants on Windows.
|
|
1598
|
+
// Synabun spawns CLI links, pty terminals, cloudflare tunnel etc. that may
|
|
1599
|
+
// not be reachable through the registries above. The taskkill /T flag walks
|
|
1600
|
+
// the process tree and terminates every descendant.
|
|
1601
|
+
if (process.platform === 'win32') {
|
|
1602
|
+
try {
|
|
1603
|
+
// Use spawn(detached) so taskkill survives our own exit and finishes
|
|
1604
|
+
// killing children even if we exit before it completes.
|
|
1605
|
+
const tk = spawn('taskkill', ['/pid', String(process.pid), '/T'], {
|
|
1606
|
+
shell: true, windowsHide: true, detached: true, stdio: 'ignore',
|
|
1607
|
+
});
|
|
1608
|
+
tk.unref();
|
|
1609
|
+
} catch {}
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
console.log(`[server] gracefulShutdown(${reason}) complete — exiting.`);
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// POST /api/server/restart — Gracefully restart the server process.
|
|
1616
|
+
// (Caller is expected to launch a new instance externally; we just exit.)
|
|
1617
|
+
app.post('/api/server/restart', async (req, res) => {
|
|
1618
|
+
console.log('[server] Restart requested.');
|
|
1552
1619
|
res.json({ ok: true, message: 'Server restarting...' });
|
|
1553
|
-
|
|
1554
|
-
|
|
1620
|
+
// Defer so the response flushes before we start tearing down children.
|
|
1621
|
+
setTimeout(async () => {
|
|
1622
|
+
await gracefulShutdown('restart');
|
|
1623
|
+
process.exit(0);
|
|
1624
|
+
}, 200);
|
|
1625
|
+
});
|
|
1626
|
+
|
|
1627
|
+
// POST /api/server/shutdown — Graceful shutdown without restart. Used by
|
|
1628
|
+
// `npm uninstall synabun` (preuninstall.js) and the in-app updater.
|
|
1629
|
+
app.post('/api/server/shutdown', async (req, res) => {
|
|
1630
|
+
console.log('[server] Shutdown requested.');
|
|
1631
|
+
res.json({ ok: true, message: 'Server shutting down...' });
|
|
1632
|
+
setTimeout(async () => {
|
|
1633
|
+
await gracefulShutdown('shutdown');
|
|
1555
1634
|
process.exit(0);
|
|
1556
|
-
},
|
|
1635
|
+
}, 200);
|
|
1636
|
+
});
|
|
1637
|
+
|
|
1638
|
+
// POST /api/system/run-update — Click-to-update flow for npm-installed users.
|
|
1639
|
+
//
|
|
1640
|
+
// Spawns updater.mjs in a NEW visible terminal window (cmd on Windows,
|
|
1641
|
+
// Terminal.app on macOS, x-terminal-emulator/gnome-terminal/konsole/xterm
|
|
1642
|
+
// fallback on Linux). The updater:
|
|
1643
|
+
// 1. Waits for the server PID to exit
|
|
1644
|
+
// 2. Runs `npm install -g synabun@<tag>`
|
|
1645
|
+
// 3. Optionally relaunches synabun
|
|
1646
|
+
// 4. Holds the terminal open so the user sees output / errors
|
|
1647
|
+
//
|
|
1648
|
+
// Right after spawning the detached updater, we run gracefulShutdown +
|
|
1649
|
+
// process.exit so file handles in neural-interface/ release and npm's
|
|
1650
|
+
// rename succeeds. The updater handles waiting on us — it only starts npm
|
|
1651
|
+
// after our PID is gone.
|
|
1652
|
+
app.post('/api/system/run-update', async (req, res) => {
|
|
1653
|
+
try {
|
|
1654
|
+
const tag = String((req.body?.tag || 'latest')).replace(/[^a-zA-Z0-9_.-]/g, '');
|
|
1655
|
+
const autoRestart = !!req.body?.autoRestart;
|
|
1656
|
+
const allowedTags = new Set(['latest', 'beta', 'next', 'rc', 'alpha']);
|
|
1657
|
+
if (!allowedTags.has(tag)) {
|
|
1658
|
+
return res.status(400).json({ error: `tag must be one of: ${[...allowedTags].join(', ')}` });
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
const updaterPath = resolve(PACKAGE_ROOT, 'updater.mjs');
|
|
1662
|
+
if (!existsSync(updaterPath)) {
|
|
1663
|
+
return res.status(500).json({ error: `updater.mjs not found at ${updaterPath}. SynaBun install may be corrupt.` });
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
const serverPid = process.pid;
|
|
1667
|
+
const argsBase = ['--tag', tag, '--pid', String(serverPid)];
|
|
1668
|
+
if (autoRestart) argsBase.push('--restart');
|
|
1669
|
+
|
|
1670
|
+
const plat = process.platform;
|
|
1671
|
+
let spawned = null;
|
|
1672
|
+
|
|
1673
|
+
if (plat === 'win32') {
|
|
1674
|
+
// `cmd /c start "<title>" cmd /k "node <updater> ..."` opens a new
|
|
1675
|
+
// visible cmd window that stays open after the script finishes (/k).
|
|
1676
|
+
const nodeExe = process.execPath;
|
|
1677
|
+
const cmdLine = `"${nodeExe}" "${updaterPath}" ${argsBase.map(a => `"${a}"`).join(' ')}`;
|
|
1678
|
+
spawned = spawn('cmd', ['/c', 'start', '"SynaBun Update"', 'cmd', '/k', cmdLine], {
|
|
1679
|
+
detached: true, stdio: 'ignore', windowsHide: false, shell: false,
|
|
1680
|
+
});
|
|
1681
|
+
} else if (plat === 'darwin') {
|
|
1682
|
+
// Use osascript to drive Terminal.app — opens a visible window the
|
|
1683
|
+
// user can watch. Escape internal quotes for AppleScript string.
|
|
1684
|
+
const nodeExe = process.execPath;
|
|
1685
|
+
const cmd = `${JSON.stringify(nodeExe)} ${JSON.stringify(updaterPath)} ${argsBase.map(a => JSON.stringify(a)).join(' ')}`;
|
|
1686
|
+
const escaped = cmd.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
1687
|
+
spawned = spawn('osascript', [
|
|
1688
|
+
'-e', `tell application "Terminal" to activate`,
|
|
1689
|
+
'-e', `tell application "Terminal" to do script "${escaped}"`,
|
|
1690
|
+
], { detached: true, stdio: 'ignore' });
|
|
1691
|
+
} else {
|
|
1692
|
+
// Linux — try common terminal emulators in order, fall back to xterm.
|
|
1693
|
+
const nodeExe = process.execPath;
|
|
1694
|
+
const inner = `${nodeExe} ${updaterPath} ${argsBase.join(' ')}; echo; echo '[Press Enter to close]'; read`;
|
|
1695
|
+
const candidates = [
|
|
1696
|
+
['x-terminal-emulator', ['-e', `bash -c "${inner.replace(/"/g, '\\"')}"`]],
|
|
1697
|
+
['gnome-terminal', ['--', 'bash', '-c', inner]],
|
|
1698
|
+
['konsole', ['-e', 'bash', '-c', inner]],
|
|
1699
|
+
['xfce4-terminal', ['-e', `bash -c "${inner.replace(/"/g, '\\"')}"`]],
|
|
1700
|
+
['xterm', ['-e', 'bash', '-c', inner]],
|
|
1701
|
+
];
|
|
1702
|
+
for (const [bin, args] of candidates) {
|
|
1703
|
+
try {
|
|
1704
|
+
spawned = spawn(bin, args, { detached: true, stdio: 'ignore' });
|
|
1705
|
+
break;
|
|
1706
|
+
} catch {}
|
|
1707
|
+
}
|
|
1708
|
+
if (!spawned) {
|
|
1709
|
+
return res.status(500).json({ error: 'No supported terminal emulator found. Install xterm or run `npm install -g synabun@' + tag + '` manually.' });
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
spawned.unref();
|
|
1714
|
+
console.log(`[server] Updater terminal launched (tag=${tag}, autoRestart=${autoRestart}). Shutting down...`);
|
|
1715
|
+
res.json({ ok: true, tag, autoRestart, updaterPath });
|
|
1716
|
+
|
|
1717
|
+
// Give the spawned terminal time to fully launch before we tear down
|
|
1718
|
+
// (on Windows, `start` returns instantly but the new window takes a
|
|
1719
|
+
// beat to actually init node). 1.2s is generous; updater polls our PID
|
|
1720
|
+
// anyway so an extra second here is harmless.
|
|
1721
|
+
setTimeout(async () => {
|
|
1722
|
+
await gracefulShutdown('run-update');
|
|
1723
|
+
process.exit(0);
|
|
1724
|
+
}, 1200);
|
|
1725
|
+
} catch (err) {
|
|
1726
|
+
console.error('[server] run-update error:', err?.message);
|
|
1727
|
+
res.status(500).json({ error: err.message });
|
|
1728
|
+
}
|
|
1557
1729
|
});
|
|
1558
1730
|
|
|
1559
1731
|
// POST /api/settings/move-db — Move SQLite database to a new directory
|
|
@@ -10082,8 +10254,11 @@ app.post('/api/search/memories', async (req, res) => {
|
|
|
10082
10254
|
|
|
10083
10255
|
let _synabunUpdateCache = {
|
|
10084
10256
|
current: null,
|
|
10085
|
-
|
|
10086
|
-
|
|
10257
|
+
installedChannel: 'stable', // 'stable' | 'prerelease'
|
|
10258
|
+
npmLatestStable: null, // dist-tags.latest
|
|
10259
|
+
npmLatestBeta: null, // dist-tags.beta (or null if absent)
|
|
10260
|
+
npmLatest: null, // chosen target for installed channel
|
|
10261
|
+
gitLatest: null, // tag_name from GitHub release matching channel
|
|
10087
10262
|
latest: null, // newer of (npmLatest, gitLatest) — legacy field
|
|
10088
10263
|
npmUpdateAvailable: false,
|
|
10089
10264
|
gitUpdateAvailable: false,
|
|
@@ -10101,6 +10276,9 @@ async function checkSynabunUpdate() {
|
|
|
10101
10276
|
current = pkg.version;
|
|
10102
10277
|
} catch { /* keep prior current if package.json read fails */ }
|
|
10103
10278
|
|
|
10279
|
+
const curParsed = parseSemver(current);
|
|
10280
|
+
const installedChannel = curParsed?.pre ? 'prerelease' : 'stable';
|
|
10281
|
+
|
|
10104
10282
|
const fetchJsonWithTimeout = async (url, ms = 5000) => {
|
|
10105
10283
|
const controller = new AbortController();
|
|
10106
10284
|
const timeout = setTimeout(() => controller.abort(), ms);
|
|
@@ -10116,40 +10294,83 @@ async function checkSynabunUpdate() {
|
|
|
10116
10294
|
}
|
|
10117
10295
|
};
|
|
10118
10296
|
|
|
10119
|
-
//
|
|
10297
|
+
// npm: fetch the FULL registry document so we can read every dist-tag
|
|
10298
|
+
// (latest + beta + any future channel). The /latest shorthand only
|
|
10299
|
+
// surfaces dist-tags.latest, which made beta installs invisible.
|
|
10300
|
+
// github: when installed is a prerelease, /releases/latest is wrong
|
|
10301
|
+
// because it skips prereleases — fetch the full list and pick newest.
|
|
10302
|
+
const npmUrl = 'https://registry.npmjs.org/synabun';
|
|
10303
|
+
const githubUrl = installedChannel === 'prerelease'
|
|
10304
|
+
? 'https://api.github.com/repos/danilokhury/Synabun/releases?per_page=20'
|
|
10305
|
+
: 'https://api.github.com/repos/danilokhury/Synabun/releases/latest';
|
|
10306
|
+
|
|
10120
10307
|
const [npmRes, gitRes] = await Promise.allSettled([
|
|
10121
|
-
fetchJsonWithTimeout(
|
|
10122
|
-
fetchJsonWithTimeout(
|
|
10308
|
+
fetchJsonWithTimeout(npmUrl),
|
|
10309
|
+
fetchJsonWithTimeout(githubUrl),
|
|
10123
10310
|
]);
|
|
10124
10311
|
|
|
10125
|
-
let
|
|
10312
|
+
let npmLatestStable = null, npmLatestBeta = null;
|
|
10313
|
+
let gitLatest = null;
|
|
10126
10314
|
let npmError = null, gitError = null;
|
|
10127
10315
|
|
|
10128
10316
|
if (npmRes.status === 'fulfilled') {
|
|
10129
|
-
|
|
10317
|
+
const tags = npmRes.value?.['dist-tags'] || {};
|
|
10318
|
+
npmLatestStable = tags.latest || null;
|
|
10319
|
+
npmLatestBeta = tags.beta || tags.next || tags.rc || null;
|
|
10130
10320
|
} else {
|
|
10131
10321
|
npmError = npmRes.reason?.message || 'npm fetch failed';
|
|
10132
10322
|
}
|
|
10133
10323
|
|
|
10134
10324
|
if (gitRes.status === 'fulfilled') {
|
|
10135
|
-
|
|
10136
|
-
|
|
10325
|
+
if (Array.isArray(gitRes.value)) {
|
|
10326
|
+
// /releases — pick newest non-draft entry whose tag parses as semver.
|
|
10327
|
+
// For stable channel users we'd skip prereleases, but this branch only
|
|
10328
|
+
// runs for prerelease users (per githubUrl above), so include both.
|
|
10329
|
+
let best = null;
|
|
10330
|
+
for (const r of gitRes.value) {
|
|
10331
|
+
if (r?.draft) continue;
|
|
10332
|
+
const tag = r?.tag_name || r?.name || '';
|
|
10333
|
+
const parsed = parseSemver(tag);
|
|
10334
|
+
if (!parsed) continue;
|
|
10335
|
+
if (!best || compareSemver(parsed, best.parsed) > 0) {
|
|
10336
|
+
best = { tag, parsed };
|
|
10337
|
+
}
|
|
10338
|
+
}
|
|
10339
|
+
gitLatest = best?.tag.replace(/^v\.?/, '') || null;
|
|
10340
|
+
} else {
|
|
10341
|
+
// /releases/latest — single object
|
|
10342
|
+
const tag = gitRes.value?.tag_name || gitRes.value?.name || '';
|
|
10343
|
+
gitLatest = tag ? tag.replace(/^v\.?/, '') : null;
|
|
10344
|
+
}
|
|
10137
10345
|
} else {
|
|
10138
10346
|
gitError = gitRes.reason?.message || 'github fetch failed';
|
|
10139
10347
|
}
|
|
10140
10348
|
|
|
10141
|
-
|
|
10142
|
-
|
|
10143
|
-
|
|
10349
|
+
// Channel-aware npm target:
|
|
10350
|
+
// - prerelease user: pick whichever of (stable, beta) is newest. Either
|
|
10351
|
+
// is genuinely "newer than current"; surfacing stable when on beta is
|
|
10352
|
+
// intentional (some users manually flip back to stable).
|
|
10353
|
+
// - stable user: only stable. Beta on a stable install would look like
|
|
10354
|
+
// a downgrade ("2026.4.26 → 2026.4.26-beta.3" via semver = older).
|
|
10355
|
+
let npmLatest = null;
|
|
10356
|
+
if (installedChannel === 'prerelease') {
|
|
10357
|
+
if (npmLatestStable && npmLatestBeta) {
|
|
10358
|
+
npmLatest = compareSemver(npmLatestBeta, npmLatestStable) > 0 ? npmLatestBeta : npmLatestStable;
|
|
10359
|
+
} else {
|
|
10360
|
+
npmLatest = npmLatestStable || npmLatestBeta;
|
|
10361
|
+
}
|
|
10362
|
+
} else {
|
|
10363
|
+
npmLatest = npmLatestStable;
|
|
10364
|
+
}
|
|
10144
10365
|
|
|
10145
|
-
const npmUpdateAvailable = !!(
|
|
10146
|
-
const gitUpdateAvailable = !!(
|
|
10366
|
+
const npmUpdateAvailable = !!(npmLatest && semverNewer(current, npmLatest));
|
|
10367
|
+
const gitUpdateAvailable = !!(gitLatest && semverNewer(current, gitLatest));
|
|
10147
10368
|
const updateAvailable = npmUpdateAvailable || gitUpdateAvailable;
|
|
10148
10369
|
|
|
10149
|
-
//
|
|
10370
|
+
// Newer of the two (npm vs github) for legacy "latest" display.
|
|
10150
10371
|
let latest = null;
|
|
10151
|
-
if (
|
|
10152
|
-
latest =
|
|
10372
|
+
if (npmLatest && gitLatest) {
|
|
10373
|
+
latest = compareSemver(npmLatest, gitLatest) >= 0 ? npmLatest : gitLatest;
|
|
10153
10374
|
} else {
|
|
10154
10375
|
latest = npmLatest || gitLatest;
|
|
10155
10376
|
}
|
|
@@ -10160,14 +10381,24 @@ async function checkSynabunUpdate() {
|
|
|
10160
10381
|
else if (gitUpdateAvailable) source = 'github';
|
|
10161
10382
|
|
|
10162
10383
|
_synabunUpdateCache = {
|
|
10163
|
-
current,
|
|
10164
|
-
|
|
10384
|
+
current,
|
|
10385
|
+
installedChannel,
|
|
10386
|
+
npmLatestStable,
|
|
10387
|
+
npmLatestBeta,
|
|
10388
|
+
npmLatest,
|
|
10389
|
+
gitLatest,
|
|
10390
|
+
latest,
|
|
10391
|
+
npmUpdateAvailable,
|
|
10392
|
+
gitUpdateAvailable,
|
|
10393
|
+
updateAvailable,
|
|
10394
|
+
source,
|
|
10165
10395
|
checkedAt: new Date().toISOString(),
|
|
10166
|
-
npmError,
|
|
10396
|
+
npmError,
|
|
10397
|
+
gitError,
|
|
10167
10398
|
};
|
|
10168
10399
|
|
|
10169
10400
|
if (updateAvailable) {
|
|
10170
|
-
console.log(` Updates: v${current} → v${latest} (${source})`);
|
|
10401
|
+
console.log(` Updates: v${current} → v${latest} (${source}; channel: ${installedChannel})`);
|
|
10171
10402
|
} else if (npmError && gitError) {
|
|
10172
10403
|
console.log(` Updates: check failed (npm: ${npmError}; github: ${gitError})`);
|
|
10173
10404
|
} else if (npmError) {
|
|
@@ -10175,7 +10406,7 @@ async function checkSynabunUpdate() {
|
|
|
10175
10406
|
} else if (gitError) {
|
|
10176
10407
|
console.log(` Updates: up to date (v${current}; github check failed: ${gitError})`);
|
|
10177
10408
|
} else {
|
|
10178
|
-
console.log(` Updates: up to date (v${current})`);
|
|
10409
|
+
console.log(` Updates: up to date (v${current}; channel: ${installedChannel})`);
|
|
10179
10410
|
}
|
|
10180
10411
|
}
|
|
10181
10412
|
|
|
@@ -10205,17 +10436,80 @@ const TOOL_DEFS = [
|
|
|
10205
10436
|
|
|
10206
10437
|
let _toolVersionCache = { checkedAt: null, tools: {} };
|
|
10207
10438
|
|
|
10439
|
+
// Backwards-compat helper — returns a "MAJOR.MINOR.PATCH" string (no pre).
|
|
10440
|
+
// Kept because legacy callers expect a string for display purposes only.
|
|
10441
|
+
// Comparison logic must use parseSemver/compareSemver below to handle
|
|
10442
|
+
// prerelease/iteration suffixes correctly.
|
|
10208
10443
|
function parseVersion(raw) {
|
|
10209
|
-
const m = raw.match(/(\d
|
|
10210
|
-
return m ? m[1] : null;
|
|
10444
|
+
const m = String(raw ?? '').match(/(\d+)\.(\d+)\.(\d+)/);
|
|
10445
|
+
return m ? `${m[1]}.${m[2]}.${m[3]}` : null;
|
|
10446
|
+
}
|
|
10447
|
+
|
|
10448
|
+
// Structured semver parse — handles calver bumps and prerelease tags:
|
|
10449
|
+
// "2026.4.26" -> { major:2026, minor:4, patch:26, pre:null }
|
|
10450
|
+
// "2026.4.20-2" -> { major:2026, minor:4, patch:20, pre:"2" }
|
|
10451
|
+
// "2026.4.26-beta.3" -> { major:2026, minor:4, patch:26, pre:"beta.3" }
|
|
10452
|
+
// "v1.2.3-rc.1" -> { major:1, minor:2, patch:3, pre:"rc.1" }
|
|
10453
|
+
function parseSemver(raw) {
|
|
10454
|
+
if (raw == null) return null;
|
|
10455
|
+
const s = String(raw).trim().replace(/^v\.?/, '');
|
|
10456
|
+
const m = s.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);
|
|
10457
|
+
if (!m) return null;
|
|
10458
|
+
return {
|
|
10459
|
+
major: +m[1],
|
|
10460
|
+
minor: +m[2],
|
|
10461
|
+
patch: +m[3],
|
|
10462
|
+
pre: m[4] || null,
|
|
10463
|
+
};
|
|
10464
|
+
}
|
|
10465
|
+
|
|
10466
|
+
// Semver-spec precedence including prerelease handling:
|
|
10467
|
+
// 1.0.0-alpha < 1.0.0-alpha.1 < 1.0.0-beta < 1.0.0-beta.2 < 1.0.0
|
|
10468
|
+
// Returns negative if a<b, positive if a>b, 0 if equal. Accepts strings or
|
|
10469
|
+
// parsed objects.
|
|
10470
|
+
function compareSemver(a, b) {
|
|
10471
|
+
const pa = (a && typeof a === 'object' && 'major' in a) ? a : parseSemver(a);
|
|
10472
|
+
const pb = (b && typeof b === 'object' && 'major' in b) ? b : parseSemver(b);
|
|
10473
|
+
if (!pa && !pb) return 0;
|
|
10474
|
+
if (!pa) return -1;
|
|
10475
|
+
if (!pb) return 1;
|
|
10476
|
+
if (pa.major !== pb.major) return pa.major - pb.major;
|
|
10477
|
+
if (pa.minor !== pb.minor) return pa.minor - pb.minor;
|
|
10478
|
+
if (pa.patch !== pb.patch) return pa.patch - pb.patch;
|
|
10479
|
+
// Equal x.y.z. Prerelease vs none: a release without prerelease is GREATER.
|
|
10480
|
+
if (!pa.pre && !pb.pre) return 0;
|
|
10481
|
+
if (!pa.pre && pb.pre) return 1;
|
|
10482
|
+
if ( pa.pre && !pb.pre) return -1;
|
|
10483
|
+
// Both have prerelease — compare identifiers per semver §11.4.
|
|
10484
|
+
const ia = pa.pre.split('.');
|
|
10485
|
+
const ib = pb.pre.split('.');
|
|
10486
|
+
const len = Math.max(ia.length, ib.length);
|
|
10487
|
+
for (let i = 0; i < len; i++) {
|
|
10488
|
+
if (i >= ia.length) return -1; // shorter set has lower precedence
|
|
10489
|
+
if (i >= ib.length) return 1;
|
|
10490
|
+
const xa = ia[i], xb = ib[i];
|
|
10491
|
+
const na = /^\d+$/.test(xa), nb = /^\d+$/.test(xb);
|
|
10492
|
+
if (na && nb) {
|
|
10493
|
+
const da = +xa, db = +xb;
|
|
10494
|
+
if (da !== db) return da - db;
|
|
10495
|
+
} else if (na && !nb) {
|
|
10496
|
+
return -1; // numeric < alphanumeric
|
|
10497
|
+
} else if (!na && nb) {
|
|
10498
|
+
return 1;
|
|
10499
|
+
} else {
|
|
10500
|
+
if (xa !== xb) return xa < xb ? -1 : 1;
|
|
10501
|
+
}
|
|
10502
|
+
}
|
|
10503
|
+
return 0;
|
|
10211
10504
|
}
|
|
10212
10505
|
|
|
10213
10506
|
function semverNewer(a, b) {
|
|
10214
|
-
|
|
10215
|
-
|
|
10216
|
-
|
|
10217
|
-
|
|
10218
|
-
|
|
10507
|
+
// True iff b is strictly newer than a. Falls back to false if either side
|
|
10508
|
+
// can't be parsed — same defensive contract as the prior implementation.
|
|
10509
|
+
if (a == null || b == null) return false;
|
|
10510
|
+
const pa = parseSemver(a), pb = parseSemver(b);
|
|
10511
|
+
if (!pa || !pb) return false;
|
|
10512
|
+
return compareSemver(pa, pb) < 0;
|
|
10219
10513
|
}
|
|
10220
10514
|
|
|
10221
10515
|
function resolveBinPath(cmd, env) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "synabun",
|
|
3
|
-
"version": "2026.4.26-beta.
|
|
3
|
+
"version": "2026.4.26-beta.5",
|
|
4
4
|
"description": "Persistent vector memory for AI assistants",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "The SynaBun Authors",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"setup.js",
|
|
18
18
|
"postinstall.js",
|
|
19
19
|
"preuninstall.js",
|
|
20
|
+
"updater.mjs",
|
|
20
21
|
"mcp-server/package.json",
|
|
21
22
|
"mcp-server/dist/",
|
|
22
23
|
"mcp-server/tsconfig.json",
|
package/preuninstall.js
CHANGED
|
@@ -3,10 +3,18 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* SynaBun — NPM preuninstall script
|
|
5
5
|
*
|
|
6
|
-
* Runs before `npm uninstall synabun
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Runs before `npm uninstall synabun` AND before `npm install -g synabun@x`
|
|
7
|
+
* overwrites the existing install.
|
|
8
|
+
*
|
|
9
|
+
* 1. Politely ask any running SynaBun server to shut down so its child
|
|
10
|
+
* processes (opencode sidecar, agents, pty terminals) release file
|
|
11
|
+
* handles inside neural-interface/. Without this step, npm's rename
|
|
12
|
+
* step fails with EBUSY on Windows.
|
|
13
|
+
* 2. Remove nested node_modules that cause EPERM/deep-path errors on Windows.
|
|
14
|
+
* 3. Remove the synabun:// protocol handler registration.
|
|
15
|
+
*
|
|
16
|
+
* Graceful — if any step fails, don't block the uninstall. npm will handle
|
|
17
|
+
* the remainder; the user can always retry.
|
|
10
18
|
*/
|
|
11
19
|
|
|
12
20
|
import { rmSync, existsSync } from 'node:fs';
|
|
@@ -17,7 +25,51 @@ import { homedir, platform } from 'node:os';
|
|
|
17
25
|
|
|
18
26
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
27
|
|
|
20
|
-
// ──
|
|
28
|
+
// ── Step 1: ask running SynaBun server to shut down cleanly ──
|
|
29
|
+
// Default port 3344 (matches NEURAL_PORT default in server.js). If the user
|
|
30
|
+
// changed NEURAL_PORT we miss them; that's acceptable since the next steps
|
|
31
|
+
// degrade gracefully. We don't want to scan all ports — too invasive for a
|
|
32
|
+
// preuninstall script.
|
|
33
|
+
|
|
34
|
+
await tryGracefulServerShutdown();
|
|
35
|
+
|
|
36
|
+
async function tryGracefulServerShutdown() {
|
|
37
|
+
const port = Number(process.env.NEURAL_PORT) || 3344;
|
|
38
|
+
const url = `http://127.0.0.1:${port}/api/server/shutdown`;
|
|
39
|
+
try {
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
const timeout = setTimeout(() => controller.abort(), 2000);
|
|
42
|
+
const res = await fetch(url, {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: { 'Content-Type': 'application/json' },
|
|
45
|
+
body: '{}',
|
|
46
|
+
signal: controller.signal,
|
|
47
|
+
});
|
|
48
|
+
clearTimeout(timeout);
|
|
49
|
+
if (!res.ok) return; // server returned but not OK — proceed anyway
|
|
50
|
+
// Wait for server to actually exit (poll with a HEAD request to /api/health
|
|
51
|
+
// or just sleep — sleep is simpler and uses no network).
|
|
52
|
+
for (let i = 0; i < 20; i++) {
|
|
53
|
+
await new Promise(r => setTimeout(r, 250));
|
|
54
|
+
try {
|
|
55
|
+
const ping = new AbortController();
|
|
56
|
+
const tt = setTimeout(() => ping.abort(), 500);
|
|
57
|
+
await fetch(`http://127.0.0.1:${port}/api/health`, { signal: ping.signal });
|
|
58
|
+
clearTimeout(tt);
|
|
59
|
+
// Still up — keep waiting.
|
|
60
|
+
} catch {
|
|
61
|
+
// Connection refused / aborted — server is gone.
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Extra grace for child processes to release file handles on Windows.
|
|
66
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
67
|
+
} catch {
|
|
68
|
+
// No server listening, fetch unavailable, etc. — just continue.
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Step 2: remove nested node_modules ──
|
|
21
73
|
|
|
22
74
|
const dirs = [
|
|
23
75
|
resolve(__dirname, 'neural-interface', 'node_modules'),
|
|
@@ -32,7 +84,7 @@ for (const dir of dirs) {
|
|
|
32
84
|
}
|
|
33
85
|
}
|
|
34
86
|
|
|
35
|
-
// ──
|
|
87
|
+
// ── Step 3: remove protocol handler ──
|
|
36
88
|
|
|
37
89
|
const plat = platform();
|
|
38
90
|
|
package/updater.mjs
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ═══════════════════════════════════════════
|
|
3
|
+
// SynaBun NPM Updater
|
|
4
|
+
// ═══════════════════════════════════════════
|
|
5
|
+
//
|
|
6
|
+
// Runs in a detached terminal window spawned by the running SynaBun server
|
|
7
|
+
// when the user clicks "Update Now" in the in-app update modal.
|
|
8
|
+
//
|
|
9
|
+
// Sequence:
|
|
10
|
+
// 1. Wait for the SynaBun server PID to fully exit (so file handles inside
|
|
11
|
+
// neural-interface/ are released — required to avoid EBUSY on Windows
|
|
12
|
+
// during `npm install -g`).
|
|
13
|
+
// 2. As a last-ditch belt-and-suspenders kill any lingering descendants of
|
|
14
|
+
// the server PID (taskkill /T /F on Windows, pkill -P on Unix).
|
|
15
|
+
// 3. Run `npm install -g synabun@<tag>`. Output streams to the terminal
|
|
16
|
+
// so the user sees install progress and any failures.
|
|
17
|
+
// 4. (Optional) Relaunch SynaBun via `synabun` if --restart was passed.
|
|
18
|
+
// 5. Hold the terminal open until the user presses Enter.
|
|
19
|
+
//
|
|
20
|
+
// CLI args:
|
|
21
|
+
// --tag <latest|beta|next|rc|alpha> npm dist-tag to install (default: latest)
|
|
22
|
+
// --pid <serverPid> Server PID to wait on (optional but recommended)
|
|
23
|
+
// --restart After install, exec `synabun` to relaunch
|
|
24
|
+
|
|
25
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
26
|
+
|
|
27
|
+
const args = parseArgs(process.argv.slice(2));
|
|
28
|
+
const tag = sanitizeTag(args.tag || 'latest');
|
|
29
|
+
const serverPid = args.pid ? Number(args.pid) : null;
|
|
30
|
+
const autoRestart = !!args.restart;
|
|
31
|
+
|
|
32
|
+
const isWin = process.platform === 'win32';
|
|
33
|
+
const NPM_BIN = isWin ? 'npm.cmd' : 'npm';
|
|
34
|
+
|
|
35
|
+
const HR = '═'.repeat(56);
|
|
36
|
+
|
|
37
|
+
// Ignore Ctrl+C during the npm install phase so the user doesn't accidentally
|
|
38
|
+
// abort halfway. On Windows this avoids an immediate window close on misclick.
|
|
39
|
+
process.on('SIGINT', () => {});
|
|
40
|
+
|
|
41
|
+
console.log(`\n${HR}`);
|
|
42
|
+
console.log(` SynaBun Updater — installing synabun@${tag}`);
|
|
43
|
+
console.log(`${HR}\n`);
|
|
44
|
+
|
|
45
|
+
// ── Step 1: wait for server to exit ───────────────────────────────
|
|
46
|
+
if (serverPid && Number.isFinite(serverPid)) {
|
|
47
|
+
console.log(`→ Waiting for SynaBun server (PID ${serverPid}) to exit...`);
|
|
48
|
+
const deadline = Date.now() + 30000;
|
|
49
|
+
let exited = false;
|
|
50
|
+
while (Date.now() < deadline) {
|
|
51
|
+
if (!isPidAlive(serverPid)) { exited = true; break; }
|
|
52
|
+
await sleep(250);
|
|
53
|
+
}
|
|
54
|
+
if (exited) {
|
|
55
|
+
console.log(' ✓ Server exited.\n');
|
|
56
|
+
} else {
|
|
57
|
+
console.warn(` ⚠ Server still alive after 30s — forcing kill.\n`);
|
|
58
|
+
forceKillTree(serverPid);
|
|
59
|
+
await sleep(1500);
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
console.log('→ No server PID provided — assuming already stopped.\n');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Step 2: extra grace window for AV / Windows file-locks ────────
|
|
66
|
+
// Windows Defender + indexing services hold file handles briefly after
|
|
67
|
+
// process exit. 1.5s is empirically enough on most Win10/11 boxes.
|
|
68
|
+
await sleep(1500);
|
|
69
|
+
|
|
70
|
+
// Belt-and-suspenders: tree-kill any leftover descendants by PID.
|
|
71
|
+
if (isWin && serverPid) forceKillTree(serverPid);
|
|
72
|
+
|
|
73
|
+
// ── Step 3: run npm install -g synabun@<tag> ──────────────────────
|
|
74
|
+
console.log(`→ Running: ${NPM_BIN} install -g synabun@${tag}\n`);
|
|
75
|
+
console.log(HR);
|
|
76
|
+
const installRes = spawnSync(NPM_BIN, ['install', '-g', `synabun@${tag}`], {
|
|
77
|
+
stdio: 'inherit',
|
|
78
|
+
shell: isWin, // npm.cmd on Windows requires shell to resolve via PATHEXT
|
|
79
|
+
});
|
|
80
|
+
console.log(HR);
|
|
81
|
+
|
|
82
|
+
if (installRes.status !== 0) {
|
|
83
|
+
console.error(`\n✗ npm install failed with exit code ${installRes.status}.\n`);
|
|
84
|
+
console.error(`Common fixes:`);
|
|
85
|
+
if (isWin) {
|
|
86
|
+
console.error(` • EBUSY/EPERM: close other SynaBun terminals/windows, then retry.`);
|
|
87
|
+
console.error(` • Run as admin: right-click cmd → "Run as administrator", then retry.`);
|
|
88
|
+
console.error(` • Force kill: taskkill /F /IM node.exe (warning: kills ALL node)`);
|
|
89
|
+
} else {
|
|
90
|
+
console.error(` • EACCES: re-run with sudo, or fix global npm prefix permissions.`);
|
|
91
|
+
console.error(` • Stale lock: rm -rf ~/.npm/_locks/*`);
|
|
92
|
+
}
|
|
93
|
+
console.error(` • Cache: npm cache clean --force`);
|
|
94
|
+
console.error(`\nManual retry: npm install -g synabun@${tag}`);
|
|
95
|
+
await holdOpen();
|
|
96
|
+
process.exit(installRes.status || 1);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
console.log(`\n✓ SynaBun updated to ${tag}!\n`);
|
|
100
|
+
|
|
101
|
+
// ── Step 4: optional auto-restart ────────────────────────────────
|
|
102
|
+
if (autoRestart) {
|
|
103
|
+
console.log(`→ Relaunching SynaBun...\n`);
|
|
104
|
+
// Detached so this updater terminal can close without killing the new
|
|
105
|
+
// server. stdio:'ignore' so synabun doesn't tie its lifetime to ours.
|
|
106
|
+
const child = spawn(isWin ? 'synabun.cmd' : 'synabun', [], {
|
|
107
|
+
detached: true,
|
|
108
|
+
stdio: 'ignore',
|
|
109
|
+
shell: isWin,
|
|
110
|
+
});
|
|
111
|
+
child.unref();
|
|
112
|
+
console.log(` ✓ synabun launched (detached).\n`);
|
|
113
|
+
} else {
|
|
114
|
+
console.log(`Run "synabun" to start the new version.\n`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
await holdOpen();
|
|
118
|
+
process.exit(0);
|
|
119
|
+
|
|
120
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
function parseArgs(argv) {
|
|
123
|
+
const out = {};
|
|
124
|
+
for (let i = 0; i < argv.length; i++) {
|
|
125
|
+
const a = argv[i];
|
|
126
|
+
if (!a.startsWith('--')) continue;
|
|
127
|
+
const key = a.slice(2);
|
|
128
|
+
const next = argv[i + 1];
|
|
129
|
+
if (next == null || next.startsWith('--')) {
|
|
130
|
+
out[key] = true;
|
|
131
|
+
} else {
|
|
132
|
+
out[key] = next;
|
|
133
|
+
i++;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function sanitizeTag(raw) {
|
|
140
|
+
const allowed = new Set(['latest', 'beta', 'next', 'rc', 'alpha']);
|
|
141
|
+
const t = String(raw).replace(/[^a-zA-Z0-9_.-]/g, '');
|
|
142
|
+
return allowed.has(t) ? t : 'latest';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isPidAlive(pid) {
|
|
146
|
+
try { process.kill(pid, 0); return true; }
|
|
147
|
+
catch (e) { return e.code === 'EPERM'; } // EPERM = exists but we lack rights
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function forceKillTree(pid) {
|
|
151
|
+
try {
|
|
152
|
+
if (isWin) {
|
|
153
|
+
spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], {
|
|
154
|
+
shell: true, windowsHide: true, stdio: 'ignore',
|
|
155
|
+
});
|
|
156
|
+
} else {
|
|
157
|
+
try { spawnSync('pkill', ['-P', String(pid)], { stdio: 'ignore' }); } catch {}
|
|
158
|
+
try { process.kill(pid, 'SIGKILL'); } catch {}
|
|
159
|
+
}
|
|
160
|
+
} catch {}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
164
|
+
|
|
165
|
+
function holdOpen() {
|
|
166
|
+
return new Promise(resolve => {
|
|
167
|
+
console.log(`\n[Press Enter to close this window]`);
|
|
168
|
+
try {
|
|
169
|
+
process.stdin.setEncoding('utf8');
|
|
170
|
+
process.stdin.resume();
|
|
171
|
+
process.stdin.once('data', () => resolve());
|
|
172
|
+
} catch {
|
|
173
|
+
// stdin not available — auto-close after 30s
|
|
174
|
+
setTimeout(resolve, 30000);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|