synabun 2026.4.26-beta.4 → 2026.4.26-beta.6
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/ui-update.js +93 -12
- package/neural-interface/server.js +256 -6
- package/package.json +4 -1
- package/postinstall.js +20 -0
- package/preuninstall.js +58 -6
- package/updater.cmd +31 -0
- package/updater.mjs +177 -0
- package/updater.sh +29 -0
|
@@ -160,7 +160,7 @@ function openUpdateModal() {
|
|
|
160
160
|
return `
|
|
161
161
|
<h3 class="update-modal-title">Run the Update</h3>
|
|
162
162
|
<p style="font-size:12px;color:var(--t-muted);margin:4px 0 12px">
|
|
163
|
-
|
|
163
|
+
SynaBun will close and run this command in a new terminal window:
|
|
164
164
|
</p>
|
|
165
165
|
<div class="update-code-block">
|
|
166
166
|
<code>${esc(installCmd)}</code>
|
|
@@ -168,19 +168,29 @@ function openUpdateModal() {
|
|
|
168
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>
|
|
169
169
|
</button>
|
|
170
170
|
</div>
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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>
|
|
179
180
|
</div>
|
|
180
|
-
|
|
181
|
+
|
|
182
|
+
<div class="tag-delete-modal-actions" style="margin-top:14px;flex-wrap:wrap;gap:8px">
|
|
181
183
|
<button class="action-btn action-btn--ghost" id="update-back">Back</button>
|
|
182
|
-
<button class="action-btn action-btn--
|
|
183
|
-
|
|
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>`;
|
|
184
194
|
}
|
|
185
195
|
|
|
186
196
|
function bindEvents() {
|
|
@@ -221,6 +231,16 @@ function openUpdateModal() {
|
|
|
221
231
|
// Backup download
|
|
222
232
|
overlay.querySelector('#update-backup-download')?.addEventListener('click', handleBackupDownload);
|
|
223
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
|
+
|
|
224
244
|
// Skip checkbox
|
|
225
245
|
const skipCb = overlay.querySelector('#update-skip-backup');
|
|
226
246
|
if (skipCb) {
|
|
@@ -232,6 +252,67 @@ function openUpdateModal() {
|
|
|
232
252
|
}
|
|
233
253
|
}
|
|
234
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
|
+
|
|
235
316
|
async function handleBackupDownload() {
|
|
236
317
|
const btn = overlay.querySelector('#update-backup-download');
|
|
237
318
|
const status = overlay.querySelector('#update-backup-status');
|
|
@@ -1546,14 +1546,264 @@ 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 the per-OS updater shim (updater.cmd on Windows, updater.sh on
|
|
1641
|
+
// macOS/Linux) in a NEW visible terminal window. Each shim invokes
|
|
1642
|
+
// updater.mjs and handles its own "press a key to close" lifecycle so we
|
|
1643
|
+
// don't have to thread cmd /k or bash -c quoting through node's spawn.
|
|
1644
|
+
//
|
|
1645
|
+
// The updater shim:
|
|
1646
|
+
// 1. Waits for the server PID to exit
|
|
1647
|
+
// 2. Runs `npm install -g synabun@<tag>`
|
|
1648
|
+
// 3. Optionally relaunches synabun
|
|
1649
|
+
// 4. Holds the terminal open so the user sees output / errors
|
|
1650
|
+
//
|
|
1651
|
+
// Right after spawning the detached updater, we run gracefulShutdown +
|
|
1652
|
+
// process.exit so file handles in neural-interface/ release and npm's
|
|
1653
|
+
// rename succeeds. The updater polls our PID and only starts npm after
|
|
1654
|
+
// we're gone.
|
|
1655
|
+
app.post('/api/system/run-update', async (req, res) => {
|
|
1656
|
+
try {
|
|
1657
|
+
const tag = String((req.body?.tag || 'latest')).replace(/[^a-zA-Z0-9_.-]/g, '');
|
|
1658
|
+
const autoRestart = !!req.body?.autoRestart;
|
|
1659
|
+
const allowedTags = new Set(['latest', 'beta', 'next', 'rc', 'alpha']);
|
|
1660
|
+
if (!allowedTags.has(tag)) {
|
|
1661
|
+
return res.status(400).json({ error: `tag must be one of: ${[...allowedTags].join(', ')}` });
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
const plat = process.platform;
|
|
1665
|
+
const shimPath = plat === 'win32'
|
|
1666
|
+
? resolve(PACKAGE_ROOT, 'updater.cmd')
|
|
1667
|
+
: resolve(PACKAGE_ROOT, 'updater.sh');
|
|
1668
|
+
const updaterMjsPath = resolve(PACKAGE_ROOT, 'updater.mjs');
|
|
1669
|
+
|
|
1670
|
+
if (!existsSync(updaterMjsPath)) {
|
|
1671
|
+
return res.status(500).json({ error: `updater.mjs not found at ${updaterMjsPath}. SynaBun install may be corrupt — reinstall: npm install -g synabun@${tag}` });
|
|
1672
|
+
}
|
|
1673
|
+
if (!existsSync(shimPath)) {
|
|
1674
|
+
return res.status(500).json({ error: `${plat === 'win32' ? 'updater.cmd' : 'updater.sh'} not found at ${shimPath}. SynaBun install may be corrupt — reinstall: npm install -g synabun@${tag}` });
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
// On Unix, ensure the shim is executable. npm publish preserves the
|
|
1678
|
+
// executable bit only if it was set when packed; defensive chmod here
|
|
1679
|
+
// covers users whose tarball lost it (some Windows-staged pipelines do).
|
|
1680
|
+
if (plat !== 'win32') {
|
|
1681
|
+
try { chmodSync(shimPath, 0o755); } catch {}
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
const serverPid = process.pid;
|
|
1685
|
+
const argsBase = ['--tag', tag, '--pid', String(serverPid)];
|
|
1686
|
+
if (autoRestart) argsBase.push('--restart');
|
|
1687
|
+
|
|
1688
|
+
let spawned = null;
|
|
1689
|
+
let launchError = null;
|
|
1690
|
+
|
|
1691
|
+
if (plat === 'win32') {
|
|
1692
|
+
// Use shell:true so cmd parses our string directly — bypasses node's
|
|
1693
|
+
// arg-escape algorithm that was mangling the title arg ("SynaBun
|
|
1694
|
+
// Update" was being eaten by start's title slot, leaving Windows to
|
|
1695
|
+
// try to launch "Update" as a program).
|
|
1696
|
+
//
|
|
1697
|
+
// `start "" "<shim>" args` — empty title (cmd's start ALWAYS treats
|
|
1698
|
+
// the first quoted arg as a title), then runs the .cmd shim with
|
|
1699
|
+
// args. The shim handles its own pause-before-close.
|
|
1700
|
+
const argsLine = argsBase.map(a => `"${String(a).replace(/"/g, '""')}"`).join(' ');
|
|
1701
|
+
const cmdLine = `start "" "${shimPath}" ${argsLine}`;
|
|
1702
|
+
try {
|
|
1703
|
+
spawned = spawn(cmdLine, {
|
|
1704
|
+
shell: true,
|
|
1705
|
+
detached: true,
|
|
1706
|
+
stdio: 'ignore',
|
|
1707
|
+
windowsHide: false,
|
|
1708
|
+
});
|
|
1709
|
+
} catch (err) { launchError = err; }
|
|
1710
|
+
} else if (plat === 'darwin') {
|
|
1711
|
+
// Drive Terminal.app via osascript. The shim path may contain spaces;
|
|
1712
|
+
// wrap it in single quotes inside the AppleScript string. AppleScript
|
|
1713
|
+
// string is wrapped in JS double-quote, so we escape JS backslashes
|
|
1714
|
+
// and double-quotes after building the inner shell command.
|
|
1715
|
+
const shellEscape = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
1716
|
+
const appleCmd = `${shellEscape(shimPath)} ${argsBase.map(shellEscape).join(' ')}`;
|
|
1717
|
+
try {
|
|
1718
|
+
spawned = spawn('osascript', [
|
|
1719
|
+
'-e', 'tell application "Terminal" to activate',
|
|
1720
|
+
'-e', `tell application "Terminal" to do script "${appleCmd.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`,
|
|
1721
|
+
], { detached: true, stdio: 'ignore' });
|
|
1722
|
+
} catch (err) { launchError = err; }
|
|
1723
|
+
} else {
|
|
1724
|
+
// Linux/BSD/etc — try common terminal emulators in order. Each gets
|
|
1725
|
+
// the shim path as the program to run; the shim itself is a bash
|
|
1726
|
+
// script so we let the terminal exec it directly without bash -c.
|
|
1727
|
+
const candidates = [
|
|
1728
|
+
['x-terminal-emulator', ['-e', shimPath, ...argsBase]],
|
|
1729
|
+
['gnome-terminal', ['--', shimPath, ...argsBase]],
|
|
1730
|
+
['konsole', ['-e', shimPath, ...argsBase]],
|
|
1731
|
+
['xfce4-terminal', ['-e', `${shimPath} ${argsBase.map(a => `"${a}"`).join(' ')}`]],
|
|
1732
|
+
['mate-terminal', ['-e', `${shimPath} ${argsBase.map(a => `"${a}"`).join(' ')}`]],
|
|
1733
|
+
['lxterminal', ['-e', shimPath, ...argsBase]],
|
|
1734
|
+
['terminator', ['-e', `${shimPath} ${argsBase.map(a => `"${a}"`).join(' ')}`]],
|
|
1735
|
+
['alacritty', ['-e', shimPath, ...argsBase]],
|
|
1736
|
+
['kitty', [shimPath, ...argsBase]],
|
|
1737
|
+
['wezterm', ['start', '--', shimPath, ...argsBase]],
|
|
1738
|
+
['urxvt', ['-e', shimPath, ...argsBase]],
|
|
1739
|
+
['xterm', ['-e', shimPath, ...argsBase]],
|
|
1740
|
+
];
|
|
1741
|
+
for (const [bin, args] of candidates) {
|
|
1742
|
+
let child = null;
|
|
1743
|
+
try {
|
|
1744
|
+
child = spawn(bin, args, { detached: true, stdio: 'ignore' });
|
|
1745
|
+
} catch {
|
|
1746
|
+
continue;
|
|
1747
|
+
}
|
|
1748
|
+
// spawn() doesn't throw synchronously when bin is missing — it emits
|
|
1749
|
+
// 'error' async (ENOENT). Probe briefly to detect that case before
|
|
1750
|
+
// declaring success and unrefing.
|
|
1751
|
+
const survived = await new Promise((resolveProbe) => {
|
|
1752
|
+
let settled = false;
|
|
1753
|
+
const onErr = () => {
|
|
1754
|
+
if (settled) return;
|
|
1755
|
+
settled = true;
|
|
1756
|
+
child.removeAllListeners('error');
|
|
1757
|
+
resolveProbe(false);
|
|
1758
|
+
};
|
|
1759
|
+
child.once('error', onErr);
|
|
1760
|
+
setTimeout(() => {
|
|
1761
|
+
if (settled) return;
|
|
1762
|
+
settled = true;
|
|
1763
|
+
child.removeAllListeners('error');
|
|
1764
|
+
resolveProbe(true);
|
|
1765
|
+
}, 150);
|
|
1766
|
+
});
|
|
1767
|
+
if (survived) { spawned = child; break; }
|
|
1768
|
+
}
|
|
1769
|
+
if (!spawned) {
|
|
1770
|
+
launchError = new Error(
|
|
1771
|
+
'No supported terminal emulator found (tried: x-terminal-emulator, gnome-terminal, konsole, xfce4-terminal, mate-terminal, lxterminal, terminator, alacritty, kitty, wezterm, urxvt, xterm). ' +
|
|
1772
|
+
`Install one (e.g. \`apt install xterm\`) or run manually: npm install -g synabun@${tag}`
|
|
1773
|
+
);
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
if (!spawned) {
|
|
1778
|
+
const msg = launchError?.message || `Failed to launch updater terminal on ${plat}`;
|
|
1779
|
+
console.error('[server] run-update launch failed:', msg);
|
|
1780
|
+
return res.status(500).json({ error: msg });
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
// Don't tie our shutdown to the spawned process — it's a long-living
|
|
1784
|
+
// terminal we don't want to wait on.
|
|
1785
|
+
spawned.unref();
|
|
1786
|
+
spawned.on('error', (err) => {
|
|
1787
|
+
// Async ENOENT etc. — log but the response already went out.
|
|
1788
|
+
console.error('[server] updater spawn async error:', err.message);
|
|
1789
|
+
});
|
|
1790
|
+
|
|
1791
|
+
console.log(`[server] Updater terminal launched (tag=${tag}, autoRestart=${autoRestart}, shim=${shimPath}). Shutting down...`);
|
|
1792
|
+
res.json({ ok: true, tag, autoRestart, shimPath, platform: plat });
|
|
1793
|
+
|
|
1794
|
+
// Give the spawned terminal time to fully launch before we tear down
|
|
1795
|
+
// (on Windows `start` returns instantly but the new window takes a
|
|
1796
|
+
// beat to init node; on macOS osascript dispatches AppleScript async).
|
|
1797
|
+
// 1.2s is generous; updater polls our PID anyway so an extra second
|
|
1798
|
+
// here is harmless.
|
|
1799
|
+
setTimeout(async () => {
|
|
1800
|
+
await gracefulShutdown('run-update');
|
|
1801
|
+
process.exit(0);
|
|
1802
|
+
}, 1200);
|
|
1803
|
+
} catch (err) {
|
|
1804
|
+
console.error('[server] run-update error:', err?.message);
|
|
1805
|
+
if (!res.headersSent) res.status(500).json({ error: err.message });
|
|
1806
|
+
}
|
|
1557
1807
|
});
|
|
1558
1808
|
|
|
1559
1809
|
// POST /api/settings/move-db — Move SQLite database to a new directory
|
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.6",
|
|
4
4
|
"description": "Persistent vector memory for AI assistants",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "The SynaBun Authors",
|
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
"setup.js",
|
|
18
18
|
"postinstall.js",
|
|
19
19
|
"preuninstall.js",
|
|
20
|
+
"updater.mjs",
|
|
21
|
+
"updater.cmd",
|
|
22
|
+
"updater.sh",
|
|
20
23
|
"mcp-server/package.json",
|
|
21
24
|
"mcp-server/dist/",
|
|
22
25
|
"mcp-server/tsconfig.json",
|
package/postinstall.js
CHANGED
|
@@ -197,6 +197,25 @@ function registerProtocolHandler() {
|
|
|
197
197
|
}
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
// ── Updater shim executable bit ──
|
|
201
|
+
//
|
|
202
|
+
// npm publish drops the executable bit on macOS/Linux when the tarball is
|
|
203
|
+
// built on Windows (a common pipeline issue). The in-app updater needs
|
|
204
|
+
// updater.sh to be executable so the per-OS terminal-launch path can
|
|
205
|
+
// `exec()` it directly. Restore the bit defensively on every postinstall.
|
|
206
|
+
|
|
207
|
+
function ensureUpdaterShExecutable() {
|
|
208
|
+
if (platform() === 'win32') return;
|
|
209
|
+
const shPath = resolve(__dirname, 'updater.sh');
|
|
210
|
+
if (!existsSync(shPath)) return;
|
|
211
|
+
try {
|
|
212
|
+
chmodSync(shPath, 0o755);
|
|
213
|
+
ok('updater.sh marked executable');
|
|
214
|
+
} catch (err) {
|
|
215
|
+
warn(`updater.sh chmod failed (${err.message}) — manual fix: chmod +x "${shPath}"`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
200
219
|
// ── Main ──
|
|
201
220
|
|
|
202
221
|
function main() {
|
|
@@ -207,6 +226,7 @@ function main() {
|
|
|
207
226
|
rebuildPty();
|
|
208
227
|
buildMcpServer();
|
|
209
228
|
registerProtocolHandler();
|
|
229
|
+
ensureUpdaterShExecutable();
|
|
210
230
|
|
|
211
231
|
console.log(`\n ${c.green}\u2713${c.reset} Setup complete. Run ${c.cyan}synabun${c.reset} to start.\n`);
|
|
212
232
|
}
|
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.cmd
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
@echo off
|
|
2
|
+
setlocal enableextensions
|
|
3
|
+
title SynaBun Updater
|
|
4
|
+
|
|
5
|
+
rem ───────────────────────────────────────────────────────
|
|
6
|
+
rem Windows shim for the cross-platform updater (updater.mjs).
|
|
7
|
+
rem Invoked by the running SynaBun server via:
|
|
8
|
+
rem start "" "<install>\updater.cmd" --tag X --pid Y [--restart]
|
|
9
|
+
rem
|
|
10
|
+
rem We use a shim instead of spawning node directly so cmd's
|
|
11
|
+
rem quoting rules don't mangle the command line, and so the
|
|
12
|
+
rem window stays open for the user to read npm output even
|
|
13
|
+
rem if node exits unexpectedly.
|
|
14
|
+
rem ───────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
set "SCRIPT_DIR=%~dp0"
|
|
17
|
+
set "EXIT_CODE=0"
|
|
18
|
+
|
|
19
|
+
rem %~dp0 ends with backslash. updater.mjs lives next to this file.
|
|
20
|
+
node "%SCRIPT_DIR%updater.mjs" %*
|
|
21
|
+
set EXIT_CODE=%ERRORLEVEL%
|
|
22
|
+
|
|
23
|
+
echo.
|
|
24
|
+
if %EXIT_CODE%==0 (
|
|
25
|
+
echo [Update finished. Press any key to close this window.]
|
|
26
|
+
) else (
|
|
27
|
+
echo [Update exited with code %EXIT_CODE%. Press any key to close this window.]
|
|
28
|
+
)
|
|
29
|
+
pause >nul
|
|
30
|
+
|
|
31
|
+
endlocal ^& exit /b %EXIT_CODE%
|
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
|
+
}
|
package/updater.sh
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ───────────────────────────────────────────────────────
|
|
3
|
+
# Unix shim for the cross-platform updater (updater.mjs).
|
|
4
|
+
# Invoked by the running SynaBun server inside a fresh
|
|
5
|
+
# terminal window — keeps the terminal open after node
|
|
6
|
+
# exits so the user can read npm output and any errors.
|
|
7
|
+
# ───────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
set +e
|
|
10
|
+
|
|
11
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
12
|
+
NODE_BIN="${SYNABUN_NODE_BIN:-node}"
|
|
13
|
+
|
|
14
|
+
# Pretty terminal title (no-op in some terminals).
|
|
15
|
+
printf '\033]0;SynaBun Updater\007' 2>/dev/null || true
|
|
16
|
+
|
|
17
|
+
"$NODE_BIN" "$SCRIPT_DIR/updater.mjs" "$@"
|
|
18
|
+
EXIT_CODE=$?
|
|
19
|
+
|
|
20
|
+
echo
|
|
21
|
+
if [ "$EXIT_CODE" = "0" ]; then
|
|
22
|
+
echo "[Update finished. Press Enter to close this window.]"
|
|
23
|
+
else
|
|
24
|
+
echo "[Update exited with code $EXIT_CODE. Press Enter to close this window.]"
|
|
25
|
+
fi
|
|
26
|
+
# Read one line. If stdin is closed (e.g. piped run), fall through immediately.
|
|
27
|
+
read -r _ || true
|
|
28
|
+
|
|
29
|
+
exit $EXIT_CODE
|