synabun 2026.4.26-beta.6 → 2026.4.26-beta.9
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 +46 -25
- package/neural-interface/server.js +277 -82
- package/package.json +1 -1
- package/preuninstall.js +82 -0
- package/updater.cmd +2 -2
- package/updater.mjs +138 -104
- package/updater.sh +1 -1
|
@@ -4,10 +4,21 @@
|
|
|
4
4
|
// and provides a 3-step modal wizard with blocking backup gate.
|
|
5
5
|
// ═══════════════════════════════════════════
|
|
6
6
|
|
|
7
|
+
import { showUpdateToast } from './ui-tool-updates.js';
|
|
8
|
+
|
|
7
9
|
const $ = (id) => document.getElementById(id);
|
|
8
10
|
|
|
9
11
|
let _versionData = null;
|
|
10
12
|
let _btnWired = false;
|
|
13
|
+
let _pollWired = false;
|
|
14
|
+
|
|
15
|
+
function sourceLabel(data) {
|
|
16
|
+
const src = data?.installPlan?.source || data?.source;
|
|
17
|
+
if (data?.source === 'both' && src === 'npm') return 'npm + GitHub';
|
|
18
|
+
if (src === 'github') return 'GitHub';
|
|
19
|
+
if (src === 'npm') return 'npm';
|
|
20
|
+
return data?.source === 'both' ? 'npm + GitHub' : data?.source || 'update';
|
|
21
|
+
}
|
|
11
22
|
|
|
12
23
|
function applyTopRightButton(data) {
|
|
13
24
|
const btn = $('topright-update-btn');
|
|
@@ -17,7 +28,7 @@ function applyTopRightButton(data) {
|
|
|
17
28
|
btn.style.display = has ? '' : 'none';
|
|
18
29
|
if (badge) badge.textContent = has ? '!' : '';
|
|
19
30
|
if (has) {
|
|
20
|
-
const srcLabel = data
|
|
31
|
+
const srcLabel = sourceLabel(data);
|
|
21
32
|
btn.dataset.tooltip = `Update: v${data.current} → v${data.latest} (${srcLabel})`;
|
|
22
33
|
}
|
|
23
34
|
if (!_btnWired) {
|
|
@@ -29,24 +40,50 @@ function applyTopRightButton(data) {
|
|
|
29
40
|
}
|
|
30
41
|
}
|
|
31
42
|
|
|
43
|
+
function maybeShowUpdateAlert(data, { once = true } = {}) {
|
|
44
|
+
if (!data?.updateAvailable) return;
|
|
45
|
+
const key = `synabun-update-alert:${data.current}->${data.latest}:${sourceLabel(data)}`;
|
|
46
|
+
try {
|
|
47
|
+
if (once && sessionStorage.getItem(key) === '1') return;
|
|
48
|
+
sessionStorage.setItem(key, '1');
|
|
49
|
+
} catch {}
|
|
50
|
+
|
|
51
|
+
setTimeout(() => {
|
|
52
|
+
showUpdateToast({
|
|
53
|
+
updates: [{ label: `SynaBun v${data.current} → v${data.latest}`, source: sourceLabel(data) }],
|
|
54
|
+
errors: [],
|
|
55
|
+
onClick: () => openSynabunUpdateModal(),
|
|
56
|
+
});
|
|
57
|
+
}, 500);
|
|
58
|
+
}
|
|
59
|
+
|
|
32
60
|
export async function initUpdate() {
|
|
33
61
|
try {
|
|
34
62
|
const res = await fetch('/api/system/version');
|
|
35
63
|
if (!res.ok) return;
|
|
36
64
|
_versionData = await res.json();
|
|
37
65
|
applyTopRightButton(_versionData);
|
|
66
|
+
maybeShowUpdateAlert(_versionData);
|
|
67
|
+
|
|
68
|
+
if (!_pollWired) {
|
|
69
|
+
_pollWired = true;
|
|
70
|
+
setInterval(() => {
|
|
71
|
+
forceCheckSynabunUpdate({ alert: true }).catch(() => {});
|
|
72
|
+
}, 30 * 60 * 1000);
|
|
73
|
+
}
|
|
38
74
|
} catch { /* silent — no update UI if check fails */ }
|
|
39
75
|
}
|
|
40
76
|
|
|
41
77
|
// Force a fresh server-side check (bypasses 1h cache via ?force=1) and
|
|
42
78
|
// re-applies the top-right button + badge state. Returns the latest
|
|
43
79
|
// _synabunUpdateCache shape so callers can compose toasts.
|
|
44
|
-
export async function forceCheckSynabunUpdate() {
|
|
80
|
+
export async function forceCheckSynabunUpdate({ alert = false } = {}) {
|
|
45
81
|
try {
|
|
46
82
|
const res = await fetch('/api/system/version?force=1');
|
|
47
83
|
if (!res.ok) return null;
|
|
48
84
|
_versionData = await res.json();
|
|
49
85
|
applyTopRightButton(_versionData);
|
|
86
|
+
if (alert) maybeShowUpdateAlert(_versionData);
|
|
50
87
|
return _versionData;
|
|
51
88
|
} catch {
|
|
52
89
|
return null;
|
|
@@ -149,13 +186,11 @@ function openUpdateModal() {
|
|
|
149
186
|
}
|
|
150
187
|
|
|
151
188
|
function renderStep3() {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const installTag = (channel === 'prerelease' && targetLooksBeta) ? 'beta' : 'latest';
|
|
158
|
-
const installCmd = `npm install -g synabun@${installTag}`;
|
|
189
|
+
const installPlan = _versionData.installPlan || {};
|
|
190
|
+
const installCmd = installPlan.displayCommand || 'npm install -g synabun@latest';
|
|
191
|
+
const installSource = installPlan.source === 'github'
|
|
192
|
+
? 'Installing from GitHub because that release is newer than npm.'
|
|
193
|
+
: 'Installing from npm.';
|
|
159
194
|
|
|
160
195
|
return `
|
|
161
196
|
<h3 class="update-modal-title">Run the Update</h3>
|
|
@@ -168,6 +203,7 @@ function openUpdateModal() {
|
|
|
168
203
|
<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
204
|
</button>
|
|
170
205
|
</div>
|
|
206
|
+
<p style="font-size:11px;color:var(--t-muted);opacity:.75;margin:8px 0 0">${installSource}</p>
|
|
171
207
|
|
|
172
208
|
<label class="update-skip-label" style="margin:14px 0 8px;display:flex;align-items:center;gap:8px;cursor:pointer">
|
|
173
209
|
<input type="checkbox" id="update-auto-restart" checked>
|
|
@@ -253,12 +289,6 @@ function openUpdateModal() {
|
|
|
253
289
|
}
|
|
254
290
|
|
|
255
291
|
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
292
|
const autoRestartCb = overlay.querySelector('#update-auto-restart');
|
|
263
293
|
const autoRestart = !!autoRestartCb?.checked;
|
|
264
294
|
|
|
@@ -282,7 +312,7 @@ function openUpdateModal() {
|
|
|
282
312
|
const res = await fetch('/api/system/run-update', {
|
|
283
313
|
method: 'POST',
|
|
284
314
|
headers: { 'Content-Type': 'application/json' },
|
|
285
|
-
body: JSON.stringify({
|
|
315
|
+
body: JSON.stringify({ autoRestart }),
|
|
286
316
|
});
|
|
287
317
|
if (!res.ok) {
|
|
288
318
|
let msg = `Updater failed (${res.status})`;
|
|
@@ -380,12 +410,3 @@ function esc(str) {
|
|
|
380
410
|
d.textContent = str || '';
|
|
381
411
|
return d.innerHTML;
|
|
382
412
|
}
|
|
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
|
-
}
|
|
@@ -1635,76 +1635,146 @@ app.post('/api/server/shutdown', async (req, res) => {
|
|
|
1635
1635
|
}, 200);
|
|
1636
1636
|
});
|
|
1637
1637
|
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1638
|
+
function shellSingleQuote(s) {
|
|
1639
|
+
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
function cleanupOldUpdaterStages(baseDir) {
|
|
1643
|
+
try {
|
|
1644
|
+
if (!existsSync(baseDir)) return;
|
|
1645
|
+
const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
1646
|
+
for (const name of readdirSync(baseDir)) {
|
|
1647
|
+
const full = resolve(baseDir, name);
|
|
1648
|
+
try {
|
|
1649
|
+
const st = statSync(full);
|
|
1650
|
+
if (st.isDirectory() && st.mtimeMs < cutoff) rmSync(full, { recursive: true, force: true });
|
|
1651
|
+
} catch {}
|
|
1652
|
+
}
|
|
1653
|
+
} catch {}
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
function stageSynabunUpdater({ installPlan, serverPid, autoRestart }) {
|
|
1657
|
+
const sourceUpdater = resolve(PACKAGE_ROOT, 'updater.mjs');
|
|
1658
|
+
if (!existsSync(sourceUpdater)) {
|
|
1659
|
+
throw new Error(`updater.mjs not found at ${sourceUpdater}. SynaBun install may be corrupt - reinstall manually: ${installPlan.displayCommand}`);
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
const stagesRoot = resolve(DATA_HOME, 'data', 'updater');
|
|
1663
|
+
mkdirSync(stagesRoot, { recursive: true });
|
|
1664
|
+
cleanupOldUpdaterStages(stagesRoot);
|
|
1665
|
+
|
|
1666
|
+
const stageDir = resolve(stagesRoot, `${Date.now()}-${randomBytes(4).toString('hex')}`);
|
|
1667
|
+
mkdirSync(stageDir, { recursive: true });
|
|
1668
|
+
|
|
1669
|
+
const stagedUpdater = resolve(stageDir, 'updater.mjs');
|
|
1670
|
+
const payloadPath = resolve(stageDir, 'payload.json');
|
|
1671
|
+
const runnerPath = resolve(stageDir, process.platform === 'win32' ? 'run-update.cmd' : 'run-update.sh');
|
|
1672
|
+
copyFileSync(sourceUpdater, stagedUpdater);
|
|
1673
|
+
|
|
1674
|
+
const payload = {
|
|
1675
|
+
serverPid,
|
|
1676
|
+
autoRestart,
|
|
1677
|
+
installSpec: installPlan.installSpec,
|
|
1678
|
+
displayCommand: installPlan.displayCommand,
|
|
1679
|
+
source: installPlan.source,
|
|
1680
|
+
current: installPlan.current,
|
|
1681
|
+
target: installPlan.target,
|
|
1682
|
+
channel: installPlan.channel,
|
|
1683
|
+
};
|
|
1684
|
+
writeFileSync(payloadPath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
|
1685
|
+
|
|
1686
|
+
if (process.platform === 'win32') {
|
|
1687
|
+
writeFileSync(runnerPath, [
|
|
1688
|
+
'@echo off',
|
|
1689
|
+
'setlocal enableextensions',
|
|
1690
|
+
'title SynaBun Updater',
|
|
1691
|
+
'cd /d "%~dp0"',
|
|
1692
|
+
'set "EXIT_CODE=0"',
|
|
1693
|
+
'set "NODE_BIN=node"',
|
|
1694
|
+
'if defined SYNABUN_NODE_BIN set "NODE_BIN=%SYNABUN_NODE_BIN%"',
|
|
1695
|
+
'"%NODE_BIN%" "%~dp0updater.mjs" --payload "%~dp0payload.json" --no-hold',
|
|
1696
|
+
'set "EXIT_CODE=%ERRORLEVEL%"',
|
|
1697
|
+
'echo.',
|
|
1698
|
+
'if "%EXIT_CODE%"=="0" (',
|
|
1699
|
+
' echo [Update finished. Press any key to close this window.]',
|
|
1700
|
+
') else (',
|
|
1701
|
+
' echo [Update exited with code %EXIT_CODE%. Press any key to close this window.]',
|
|
1702
|
+
')',
|
|
1703
|
+
'pause >nul',
|
|
1704
|
+
'endlocal & exit /b %EXIT_CODE%',
|
|
1705
|
+
'',
|
|
1706
|
+
].join('\r\n'), 'utf8');
|
|
1707
|
+
} else {
|
|
1708
|
+
const nodeBin = shellSingleQuote(process.execPath);
|
|
1709
|
+
writeFileSync(runnerPath, [
|
|
1710
|
+
'#!/usr/bin/env bash',
|
|
1711
|
+
'set +e',
|
|
1712
|
+
'SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"',
|
|
1713
|
+
'cd "$SCRIPT_DIR" || exit 1',
|
|
1714
|
+
'printf \'\\033]0;SynaBun Updater\\007\' 2>/dev/null || true',
|
|
1715
|
+
'NODE_BIN="${SYNABUN_NODE_BIN:-}"',
|
|
1716
|
+
`if [ -z "$NODE_BIN" ]; then NODE_BIN=${nodeBin}; fi`,
|
|
1717
|
+
'"$NODE_BIN" "$SCRIPT_DIR/updater.mjs" --payload "$SCRIPT_DIR/payload.json" --no-hold',
|
|
1718
|
+
'EXIT_CODE=$?',
|
|
1719
|
+
'echo',
|
|
1720
|
+
'if [ "$EXIT_CODE" = "0" ]; then',
|
|
1721
|
+
' echo "[Update finished. Press Enter to close this window.]"',
|
|
1722
|
+
'else',
|
|
1723
|
+
' echo "[Update exited with code $EXIT_CODE. Press Enter to close this window.]"',
|
|
1724
|
+
'fi',
|
|
1725
|
+
'read -r _ || true',
|
|
1726
|
+
'exit "$EXIT_CODE"',
|
|
1727
|
+
'',
|
|
1728
|
+
].join('\n'), 'utf8');
|
|
1729
|
+
try { chmodSync(runnerPath, 0o755); } catch {}
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
return { stageDir, runnerPath, payloadPath };
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
// POST /api/system/run-update - click-to-update flow.
|
|
1650
1736
|
//
|
|
1651
|
-
//
|
|
1652
|
-
//
|
|
1653
|
-
//
|
|
1654
|
-
// we're gone.
|
|
1737
|
+
// The updater is staged under DATA_HOME, not PACKAGE_ROOT, before launch. This
|
|
1738
|
+
// matters on Windows: a terminal cwd or command line inside node_modules/synabun
|
|
1739
|
+
// can lock the directory npm is trying to replace.
|
|
1655
1740
|
app.post('/api/system/run-update', async (req, res) => {
|
|
1656
1741
|
try {
|
|
1657
|
-
const tag = String((req.body?.tag || 'latest')).replace(/[^a-zA-Z0-9_.-]/g, '');
|
|
1658
1742
|
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
1743
|
|
|
1664
|
-
|
|
1665
|
-
|
|
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}` });
|
|
1744
|
+
if (!_synabunUpdateCache.checkedAt) {
|
|
1745
|
+
try { await checkSynabunUpdate(); } catch {}
|
|
1672
1746
|
}
|
|
1673
|
-
|
|
1674
|
-
|
|
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 {}
|
|
1747
|
+
const installPlan = _synabunUpdateCache.installPlan;
|
|
1748
|
+
if (!_synabunUpdateCache.updateAvailable || !installPlan?.installSpec) {
|
|
1749
|
+
return res.status(409).json({ error: 'No SynaBun update is currently available.' });
|
|
1682
1750
|
}
|
|
1683
1751
|
|
|
1752
|
+
const plat = process.platform;
|
|
1684
1753
|
const serverPid = process.pid;
|
|
1685
|
-
const
|
|
1686
|
-
if (autoRestart) argsBase.push('--restart');
|
|
1754
|
+
const staged = stageSynabunUpdater({ installPlan, serverPid, autoRestart });
|
|
1687
1755
|
|
|
1688
1756
|
let spawned = null;
|
|
1689
1757
|
let launchError = null;
|
|
1690
1758
|
|
|
1691
1759
|
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
1760
|
try {
|
|
1703
|
-
spawned = spawn(
|
|
1704
|
-
|
|
1761
|
+
spawned = spawn('powershell.exe', [
|
|
1762
|
+
'-NoProfile',
|
|
1763
|
+
'-ExecutionPolicy', 'Bypass',
|
|
1764
|
+
'-Command',
|
|
1765
|
+
'$p=$env:SYNABUN_UPDATER_RUNNER; $w=$env:SYNABUN_UPDATER_CWD; Start-Process -FilePath $p -WorkingDirectory $w -WindowStyle Normal',
|
|
1766
|
+
], {
|
|
1767
|
+
cwd: staged.stageDir,
|
|
1768
|
+
shell: false,
|
|
1705
1769
|
detached: true,
|
|
1706
1770
|
stdio: 'ignore',
|
|
1707
|
-
windowsHide:
|
|
1771
|
+
windowsHide: true,
|
|
1772
|
+
env: {
|
|
1773
|
+
...process.env,
|
|
1774
|
+
SYNABUN_NODE_BIN: process.execPath,
|
|
1775
|
+
SYNABUN_UPDATER_RUNNER: staged.runnerPath,
|
|
1776
|
+
SYNABUN_UPDATER_CWD: staged.stageDir,
|
|
1777
|
+
},
|
|
1708
1778
|
});
|
|
1709
1779
|
} catch (err) { launchError = err; }
|
|
1710
1780
|
} else if (plat === 'darwin') {
|
|
@@ -1712,36 +1782,36 @@ app.post('/api/system/run-update', async (req, res) => {
|
|
|
1712
1782
|
// wrap it in single quotes inside the AppleScript string. AppleScript
|
|
1713
1783
|
// string is wrapped in JS double-quote, so we escape JS backslashes
|
|
1714
1784
|
// and double-quotes after building the inner shell command.
|
|
1715
|
-
const
|
|
1716
|
-
const appleCmd = `${shellEscape(shimPath)} ${argsBase.map(shellEscape).join(' ')}`;
|
|
1785
|
+
const appleCmd = shellSingleQuote(staged.runnerPath);
|
|
1717
1786
|
try {
|
|
1718
1787
|
spawned = spawn('osascript', [
|
|
1719
1788
|
'-e', 'tell application "Terminal" to activate',
|
|
1720
1789
|
'-e', `tell application "Terminal" to do script "${appleCmd.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`,
|
|
1721
|
-
], { detached: true, stdio: 'ignore' });
|
|
1790
|
+
], { cwd: staged.stageDir, detached: true, stdio: 'ignore' });
|
|
1722
1791
|
} catch (err) { launchError = err; }
|
|
1723
1792
|
} else {
|
|
1724
1793
|
// Linux/BSD/etc — try common terminal emulators in order. Each gets
|
|
1725
1794
|
// the shim path as the program to run; the shim itself is a bash
|
|
1726
1795
|
// script so we let the terminal exec it directly without bash -c.
|
|
1796
|
+
const quotedRunner = shellSingleQuote(staged.runnerPath);
|
|
1727
1797
|
const candidates = [
|
|
1728
|
-
['x-terminal-emulator', ['-e',
|
|
1729
|
-
['gnome-terminal', ['--',
|
|
1730
|
-
['konsole', ['-e',
|
|
1731
|
-
['xfce4-terminal', ['-e',
|
|
1732
|
-
['mate-terminal', ['-e',
|
|
1733
|
-
['lxterminal', ['-e',
|
|
1734
|
-
['terminator', ['-e',
|
|
1735
|
-
['alacritty', ['-e',
|
|
1736
|
-
['kitty', [
|
|
1737
|
-
['wezterm', ['start', '--',
|
|
1738
|
-
['urxvt', ['-e',
|
|
1739
|
-
['xterm', ['-e',
|
|
1798
|
+
['x-terminal-emulator', ['-e', staged.runnerPath]],
|
|
1799
|
+
['gnome-terminal', ['--', staged.runnerPath]],
|
|
1800
|
+
['konsole', ['-e', staged.runnerPath]],
|
|
1801
|
+
['xfce4-terminal', ['-e', quotedRunner]],
|
|
1802
|
+
['mate-terminal', ['-e', quotedRunner]],
|
|
1803
|
+
['lxterminal', ['-e', staged.runnerPath]],
|
|
1804
|
+
['terminator', ['-e', quotedRunner]],
|
|
1805
|
+
['alacritty', ['-e', staged.runnerPath]],
|
|
1806
|
+
['kitty', [staged.runnerPath]],
|
|
1807
|
+
['wezterm', ['start', '--', staged.runnerPath]],
|
|
1808
|
+
['urxvt', ['-e', staged.runnerPath]],
|
|
1809
|
+
['xterm', ['-e', staged.runnerPath]],
|
|
1740
1810
|
];
|
|
1741
1811
|
for (const [bin, args] of candidates) {
|
|
1742
1812
|
let child = null;
|
|
1743
1813
|
try {
|
|
1744
|
-
child = spawn(bin, args, { detached: true, stdio: 'ignore' });
|
|
1814
|
+
child = spawn(bin, args, { cwd: staged.stageDir, detached: true, stdio: 'ignore' });
|
|
1745
1815
|
} catch {
|
|
1746
1816
|
continue;
|
|
1747
1817
|
}
|
|
@@ -1769,7 +1839,7 @@ app.post('/api/system/run-update', async (req, res) => {
|
|
|
1769
1839
|
if (!spawned) {
|
|
1770
1840
|
launchError = new Error(
|
|
1771
1841
|
'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:
|
|
1842
|
+
`Install one (e.g. \`apt install xterm\`) or run manually: ${installPlan.displayCommand}`
|
|
1773
1843
|
);
|
|
1774
1844
|
}
|
|
1775
1845
|
}
|
|
@@ -1788,8 +1858,8 @@ app.post('/api/system/run-update', async (req, res) => {
|
|
|
1788
1858
|
console.error('[server] updater spawn async error:', err.message);
|
|
1789
1859
|
});
|
|
1790
1860
|
|
|
1791
|
-
console.log(`[server] Updater terminal launched (
|
|
1792
|
-
res.json({ ok: true,
|
|
1861
|
+
console.log(`[server] Updater terminal launched (${installPlan.displayCommand}, autoRestart=${autoRestart}, stage=${staged.stageDir}). Shutting down...`);
|
|
1862
|
+
res.json({ ok: true, autoRestart, installPlan, stageDir: staged.stageDir, platform: plat });
|
|
1793
1863
|
|
|
1794
1864
|
// Give the spawned terminal time to fully launch before we tear down
|
|
1795
1865
|
// (on Windows `start` returns instantly but the new window takes a
|
|
@@ -4509,12 +4579,36 @@ function stopOpencodeServer() {
|
|
|
4509
4579
|
stopOpencodeSSERelay();
|
|
4510
4580
|
const managedPid = readOpencodeManagedPid();
|
|
4511
4581
|
const procPid = _ocpProc?.pid || null;
|
|
4582
|
+
const isWin = process.platform === 'win32';
|
|
4583
|
+
|
|
4584
|
+
// On Windows `process.kill(pid, 'SIGTERM')` is unreliable for non-Node
|
|
4585
|
+
// children (cmd.exe shells, opencode native binaries). Use taskkill /T /F
|
|
4586
|
+
// to walk the process tree and force-terminate every descendant.
|
|
4587
|
+
// This is the recurring source of EBUSY errors during `npm install -g
|
|
4588
|
+
// synabun@latest` — opencode-ai's `serve` process survives parent SynaBun
|
|
4589
|
+
// exit and keeps file handles inside neural-interface/ open.
|
|
4590
|
+
const winKill = (pid) => {
|
|
4591
|
+
try {
|
|
4592
|
+
spawn('taskkill', ['/pid', String(pid), '/T', '/F'], {
|
|
4593
|
+
shell: true, windowsHide: true, stdio: 'ignore',
|
|
4594
|
+
});
|
|
4595
|
+
} catch {}
|
|
4596
|
+
};
|
|
4597
|
+
|
|
4512
4598
|
if (_ocpProc && !_ocpProc.killed) {
|
|
4513
|
-
|
|
4599
|
+
if (isWin && procPid) {
|
|
4600
|
+
winKill(procPid);
|
|
4601
|
+
} else {
|
|
4602
|
+
try { _ocpProc.kill(); } catch {}
|
|
4603
|
+
}
|
|
4514
4604
|
_ocpProc = null;
|
|
4515
4605
|
}
|
|
4516
4606
|
if (managedPid && managedPid !== procPid) {
|
|
4517
|
-
|
|
4607
|
+
if (isWin) {
|
|
4608
|
+
winKill(managedPid);
|
|
4609
|
+
} else {
|
|
4610
|
+
signalPids([managedPid], 'SIGTERM');
|
|
4611
|
+
}
|
|
4518
4612
|
}
|
|
4519
4613
|
clearOpencodeManagedPid();
|
|
4520
4614
|
_ocpReady = false;
|
|
@@ -10335,18 +10429,74 @@ let _synabunUpdateCache = {
|
|
|
10335
10429
|
installedChannel: 'stable', // 'stable' | 'prerelease'
|
|
10336
10430
|
npmLatestStable: null, // dist-tags.latest
|
|
10337
10431
|
npmLatestBeta: null, // dist-tags.beta (or null if absent)
|
|
10432
|
+
npmLatestTag: null, // dist-tag selected for install
|
|
10338
10433
|
npmLatest: null, // chosen target for installed channel
|
|
10339
10434
|
gitLatest: null, // tag_name from GitHub release matching channel
|
|
10435
|
+
gitLatestRaw: null, // original GitHub tag, preserving leading v
|
|
10340
10436
|
latest: null, // newer of (npmLatest, gitLatest) — legacy field
|
|
10341
10437
|
npmUpdateAvailable: false,
|
|
10342
10438
|
gitUpdateAvailable: false,
|
|
10343
10439
|
updateAvailable: false, // npmUpdateAvailable || gitUpdateAvailable
|
|
10344
10440
|
source: null, // 'npm' | 'github' | 'both' | null
|
|
10441
|
+
installPlan: null, // server-owned npm install target for click-to-update
|
|
10345
10442
|
checkedAt: null,
|
|
10346
10443
|
npmError: null,
|
|
10347
10444
|
gitError: null,
|
|
10348
10445
|
};
|
|
10349
10446
|
|
|
10447
|
+
function normalizeGitHubTagForNpm(rawTag, normalizedVersion) {
|
|
10448
|
+
const tag = String(rawTag || '').trim();
|
|
10449
|
+
if (tag && parseSemver(tag)) return tag;
|
|
10450
|
+
return normalizedVersion ? `v${normalizedVersion}` : null;
|
|
10451
|
+
}
|
|
10452
|
+
|
|
10453
|
+
function buildSynabunInstallPlan({
|
|
10454
|
+
current,
|
|
10455
|
+
installedChannel,
|
|
10456
|
+
npmLatest,
|
|
10457
|
+
npmLatestTag,
|
|
10458
|
+
npmUpdateAvailable,
|
|
10459
|
+
gitLatest,
|
|
10460
|
+
gitLatestRaw,
|
|
10461
|
+
gitUpdateAvailable,
|
|
10462
|
+
}) {
|
|
10463
|
+
if (!npmUpdateAvailable && !gitUpdateAvailable) return null;
|
|
10464
|
+
|
|
10465
|
+
let targetSource = null;
|
|
10466
|
+
if (npmUpdateAvailable && gitUpdateAvailable) {
|
|
10467
|
+
targetSource = compareSemver(gitLatest, npmLatest) > 0 ? 'github' : 'npm';
|
|
10468
|
+
} else {
|
|
10469
|
+
targetSource = gitUpdateAvailable ? 'github' : 'npm';
|
|
10470
|
+
}
|
|
10471
|
+
|
|
10472
|
+
if (targetSource === 'github') {
|
|
10473
|
+
const tag = normalizeGitHubTagForNpm(gitLatestRaw, gitLatest);
|
|
10474
|
+
if (!tag) return null;
|
|
10475
|
+
const installSpec = `github:danilokhury/Synabun#${tag}`;
|
|
10476
|
+
return {
|
|
10477
|
+
source: 'github',
|
|
10478
|
+
current,
|
|
10479
|
+
target: gitLatest,
|
|
10480
|
+
channel: installedChannel,
|
|
10481
|
+
installSpec,
|
|
10482
|
+
displayCommand: `npm install -g ${installSpec}`,
|
|
10483
|
+
githubTag: tag,
|
|
10484
|
+
};
|
|
10485
|
+
}
|
|
10486
|
+
|
|
10487
|
+
const tag = npmLatestTag || 'latest';
|
|
10488
|
+
const installSpec = `synabun@${tag}`;
|
|
10489
|
+
return {
|
|
10490
|
+
source: 'npm',
|
|
10491
|
+
current,
|
|
10492
|
+
target: npmLatest,
|
|
10493
|
+
channel: installedChannel,
|
|
10494
|
+
installSpec,
|
|
10495
|
+
displayCommand: `npm install -g ${installSpec}`,
|
|
10496
|
+
npmTag: tag,
|
|
10497
|
+
};
|
|
10498
|
+
}
|
|
10499
|
+
|
|
10350
10500
|
async function checkSynabunUpdate() {
|
|
10351
10501
|
let current = _synabunUpdateCache.current;
|
|
10352
10502
|
try {
|
|
@@ -10387,14 +10537,20 @@ async function checkSynabunUpdate() {
|
|
|
10387
10537
|
fetchJsonWithTimeout(githubUrl),
|
|
10388
10538
|
]);
|
|
10389
10539
|
|
|
10390
|
-
let npmLatestStable = null, npmLatestBeta = null;
|
|
10391
|
-
let gitLatest = null;
|
|
10540
|
+
let npmLatestStable = null, npmLatestBeta = null, npmLatestBetaTag = null, npmLatestTag = null;
|
|
10541
|
+
let gitLatest = null, gitLatestRaw = null;
|
|
10392
10542
|
let npmError = null, gitError = null;
|
|
10393
10543
|
|
|
10394
10544
|
if (npmRes.status === 'fulfilled') {
|
|
10395
10545
|
const tags = npmRes.value?.['dist-tags'] || {};
|
|
10396
10546
|
npmLatestStable = tags.latest || null;
|
|
10397
|
-
|
|
10547
|
+
for (const tagName of ['beta', 'next', 'rc', 'alpha']) {
|
|
10548
|
+
if (tags[tagName]) {
|
|
10549
|
+
npmLatestBeta = tags[tagName];
|
|
10550
|
+
npmLatestBetaTag = tagName;
|
|
10551
|
+
break;
|
|
10552
|
+
}
|
|
10553
|
+
}
|
|
10398
10554
|
} else {
|
|
10399
10555
|
npmError = npmRes.reason?.message || 'npm fetch failed';
|
|
10400
10556
|
}
|
|
@@ -10414,10 +10570,12 @@ async function checkSynabunUpdate() {
|
|
|
10414
10570
|
best = { tag, parsed };
|
|
10415
10571
|
}
|
|
10416
10572
|
}
|
|
10417
|
-
|
|
10573
|
+
gitLatestRaw = best?.tag || null;
|
|
10574
|
+
gitLatest = gitLatestRaw ? gitLatestRaw.replace(/^v\.?/, '') : null;
|
|
10418
10575
|
} else {
|
|
10419
10576
|
// /releases/latest — single object
|
|
10420
10577
|
const tag = gitRes.value?.tag_name || gitRes.value?.name || '';
|
|
10578
|
+
gitLatestRaw = tag || null;
|
|
10421
10579
|
gitLatest = tag ? tag.replace(/^v\.?/, '') : null;
|
|
10422
10580
|
}
|
|
10423
10581
|
} else {
|
|
@@ -10433,12 +10591,20 @@ async function checkSynabunUpdate() {
|
|
|
10433
10591
|
let npmLatest = null;
|
|
10434
10592
|
if (installedChannel === 'prerelease') {
|
|
10435
10593
|
if (npmLatestStable && npmLatestBeta) {
|
|
10436
|
-
|
|
10594
|
+
if (compareSemver(npmLatestBeta, npmLatestStable) > 0) {
|
|
10595
|
+
npmLatest = npmLatestBeta;
|
|
10596
|
+
npmLatestTag = npmLatestBetaTag;
|
|
10597
|
+
} else {
|
|
10598
|
+
npmLatest = npmLatestStable;
|
|
10599
|
+
npmLatestTag = 'latest';
|
|
10600
|
+
}
|
|
10437
10601
|
} else {
|
|
10438
10602
|
npmLatest = npmLatestStable || npmLatestBeta;
|
|
10603
|
+
npmLatestTag = npmLatestStable ? 'latest' : npmLatestBetaTag;
|
|
10439
10604
|
}
|
|
10440
10605
|
} else {
|
|
10441
10606
|
npmLatest = npmLatestStable;
|
|
10607
|
+
npmLatestTag = npmLatest ? 'latest' : null;
|
|
10442
10608
|
}
|
|
10443
10609
|
|
|
10444
10610
|
const npmUpdateAvailable = !!(npmLatest && semverNewer(current, npmLatest));
|
|
@@ -10458,18 +10624,32 @@ async function checkSynabunUpdate() {
|
|
|
10458
10624
|
else if (npmUpdateAvailable) source = 'npm';
|
|
10459
10625
|
else if (gitUpdateAvailable) source = 'github';
|
|
10460
10626
|
|
|
10627
|
+
const installPlan = buildSynabunInstallPlan({
|
|
10628
|
+
current,
|
|
10629
|
+
installedChannel,
|
|
10630
|
+
npmLatest,
|
|
10631
|
+
npmLatestTag,
|
|
10632
|
+
npmUpdateAvailable,
|
|
10633
|
+
gitLatest,
|
|
10634
|
+
gitLatestRaw,
|
|
10635
|
+
gitUpdateAvailable,
|
|
10636
|
+
});
|
|
10637
|
+
|
|
10461
10638
|
_synabunUpdateCache = {
|
|
10462
10639
|
current,
|
|
10463
10640
|
installedChannel,
|
|
10464
10641
|
npmLatestStable,
|
|
10465
10642
|
npmLatestBeta,
|
|
10643
|
+
npmLatestTag,
|
|
10466
10644
|
npmLatest,
|
|
10467
10645
|
gitLatest,
|
|
10646
|
+
gitLatestRaw,
|
|
10468
10647
|
latest,
|
|
10469
10648
|
npmUpdateAvailable,
|
|
10470
10649
|
gitUpdateAvailable,
|
|
10471
10650
|
updateAvailable,
|
|
10472
10651
|
source,
|
|
10652
|
+
installPlan,
|
|
10473
10653
|
checkedAt: new Date().toISOString(),
|
|
10474
10654
|
npmError,
|
|
10475
10655
|
gitError,
|
|
@@ -23587,6 +23767,21 @@ function saveSessionSnapshot() {
|
|
|
23587
23767
|
setInterval(saveSessionSnapshot, SESSION_SNAPSHOT_INTERVAL_MS);
|
|
23588
23768
|
|
|
23589
23769
|
// Also try to save on clean shutdown (works with Ctrl+C, not window close)
|
|
23590
|
-
|
|
23591
|
-
|
|
23592
|
-
|
|
23770
|
+
// Manual termination paths (Ctrl+C, kill, parent shell exit) all flow through
|
|
23771
|
+
// gracefulShutdown — same teardown the in-app restart/shutdown/update paths
|
|
23772
|
+
// use. Without this, hand-killed servers still leave orphaned opencode +
|
|
23773
|
+
// agent children holding file handles, which blocks `npm install -g synabun`.
|
|
23774
|
+
process.on('SIGINT', async () => {
|
|
23775
|
+
console.log('\n[server] SIGINT received.');
|
|
23776
|
+
await gracefulShutdown('sigint');
|
|
23777
|
+
process.exit(0);
|
|
23778
|
+
});
|
|
23779
|
+
process.on('SIGTERM', async () => {
|
|
23780
|
+
console.log('\n[server] SIGTERM received.');
|
|
23781
|
+
await gracefulShutdown('sigterm');
|
|
23782
|
+
process.exit(0);
|
|
23783
|
+
});
|
|
23784
|
+
// `exit` fires after process.exit() — only safe for synchronous final tasks.
|
|
23785
|
+
// gracefulShutdown is async so we cannot await here; just snapshot + ocp stop
|
|
23786
|
+
// best-effort. The async paths above are the primary teardown.
|
|
23787
|
+
process.on('exit', () => { try { stopOpencodeServer(); } catch {} try { saveSessionSnapshot(); } catch {} });
|
package/package.json
CHANGED
package/preuninstall.js
CHANGED
|
@@ -69,6 +69,88 @@ async function tryGracefulServerShutdown() {
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
// ── Step 1b: kill any orphaned processes holding file handles ──
|
|
73
|
+
//
|
|
74
|
+
// Even after a graceful server shutdown (or when no server is running),
|
|
75
|
+
// orphaned children can survive: opencode-ai sidecars spawned via cmd.exe,
|
|
76
|
+
// MCP servers (claude-memory-mcp) spawned by Claude Code instances rather
|
|
77
|
+
// than by SynaBun itself, agent processes, etc. On Windows these orphans
|
|
78
|
+
// keep file handles inside the install dir open, causing EBUSY when npm
|
|
79
|
+
// tries to rename it.
|
|
80
|
+
//
|
|
81
|
+
// We nuke any process whose command line references the SynaBun install
|
|
82
|
+
// path. This catches every orphan regardless of how they got that way.
|
|
83
|
+
// Skips the current PID (we are the preuninstall process itself).
|
|
84
|
+
|
|
85
|
+
killOrphansInsideInstall();
|
|
86
|
+
|
|
87
|
+
function killOrphansInsideInstall() {
|
|
88
|
+
const installPath = __dirname;
|
|
89
|
+
const selfPid = process.pid;
|
|
90
|
+
const plat = platform();
|
|
91
|
+
|
|
92
|
+
if (plat === 'win32') {
|
|
93
|
+
// PowerShell: Get-CimInstance returns every process with CommandLine.
|
|
94
|
+
// Pass the install path via env so this PowerShell command line does not
|
|
95
|
+
// itself contain the path we are trying to match and kill.
|
|
96
|
+
// Stop-Process -Force kills synchronously; -ErrorAction SilentlyContinue
|
|
97
|
+
// suppresses "process already exited" noise.
|
|
98
|
+
const ps = `
|
|
99
|
+
$ErrorActionPreference = 'SilentlyContinue';
|
|
100
|
+
$installPath = $env:SYNABUN_INSTALL_PATH;
|
|
101
|
+
$selfPid = [int]$env:SYNABUN_PREUNINSTALL_PID;
|
|
102
|
+
Get-CimInstance Win32_Process |
|
|
103
|
+
Where-Object {
|
|
104
|
+
$_.ProcessId -ne $selfPid -and
|
|
105
|
+
$_.ProcessId -ne $PID -and (
|
|
106
|
+
($_.CommandLine -and $_.CommandLine -like "*$installPath*") -or
|
|
107
|
+
($_.ExecutablePath -and $_.ExecutablePath -like "$installPath*")
|
|
108
|
+
)
|
|
109
|
+
} |
|
|
110
|
+
ForEach-Object {
|
|
111
|
+
Write-Host (' Killing orphan PID ' + $_.ProcessId + ' (' + $_.Name + ')');
|
|
112
|
+
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
|
|
113
|
+
}
|
|
114
|
+
`.trim();
|
|
115
|
+
try {
|
|
116
|
+
execSync(`powershell -NoProfile -ExecutionPolicy Bypass -Command "${ps.replace(/"/g, '\\"').replace(/\n/g, ' ')}"`, {
|
|
117
|
+
stdio: 'inherit',
|
|
118
|
+
timeout: 8000,
|
|
119
|
+
env: {
|
|
120
|
+
...process.env,
|
|
121
|
+
SYNABUN_INSTALL_PATH: installPath,
|
|
122
|
+
SYNABUN_PREUNINSTALL_PID: String(selfPid),
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
} catch {
|
|
126
|
+
// PowerShell unavailable, blocked, or timed out. Fall through.
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
// Unix: pgrep -f matches against the full command line. -P 1 skips
|
|
130
|
+
// self/children of init we shouldn't touch. We just enumerate then
|
|
131
|
+
// SIGKILL each one that isn't us.
|
|
132
|
+
try {
|
|
133
|
+
const out = execSync(`pgrep -f "${installPath.replace(/[/.*?+^$|(){}\\\[\]]/g, '\\$&')}"`, {
|
|
134
|
+
encoding: 'utf8',
|
|
135
|
+
timeout: 5000,
|
|
136
|
+
}).trim();
|
|
137
|
+
const pids = out.split('\n')
|
|
138
|
+
.map(s => Number(s.trim()))
|
|
139
|
+
.filter(n => Number.isFinite(n) && n > 0 && n !== selfPid);
|
|
140
|
+
for (const pid of pids) {
|
|
141
|
+
try { process.kill(pid, 'SIGKILL'); } catch {}
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
// No matches or pgrep missing — fine.
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Brief grace for OS to release file handles after killing.
|
|
149
|
+
// Synchronous busy-wait so we run before the rmSync calls below.
|
|
150
|
+
const until = Date.now() + 1500;
|
|
151
|
+
while (Date.now() < until) { /* spin */ }
|
|
152
|
+
}
|
|
153
|
+
|
|
72
154
|
// ── Step 2: remove nested node_modules ──
|
|
73
155
|
|
|
74
156
|
const dirs = [
|
package/updater.cmd
CHANGED
|
@@ -5,7 +5,7 @@ title SynaBun Updater
|
|
|
5
5
|
rem ───────────────────────────────────────────────────────
|
|
6
6
|
rem Windows shim for the cross-platform updater (updater.mjs).
|
|
7
7
|
rem Invoked by the running SynaBun server via:
|
|
8
|
-
rem start "" "<install>\updater.cmd" --
|
|
8
|
+
rem start "" "<install>\updater.cmd" --payload payload.json
|
|
9
9
|
rem
|
|
10
10
|
rem We use a shim instead of spawning node directly so cmd's
|
|
11
11
|
rem quoting rules don't mangle the command line, and so the
|
|
@@ -17,7 +17,7 @@ set "SCRIPT_DIR=%~dp0"
|
|
|
17
17
|
set "EXIT_CODE=0"
|
|
18
18
|
|
|
19
19
|
rem %~dp0 ends with backslash. updater.mjs lives next to this file.
|
|
20
|
-
node "%SCRIPT_DIR%updater.mjs" %*
|
|
20
|
+
node "%SCRIPT_DIR%updater.mjs" %* --no-hold
|
|
21
21
|
set EXIT_CODE=%ERRORLEVEL%
|
|
22
22
|
|
|
23
23
|
echo.
|
package/updater.mjs
CHANGED
|
@@ -1,123 +1,103 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
//
|
|
3
|
-
// SynaBun NPM Updater
|
|
4
|
-
// ═══════════════════════════════════════════
|
|
2
|
+
// SynaBun updater.
|
|
5
3
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// Normal flow:
|
|
5
|
+
// 1. The running server copies this file into a user-data staging directory.
|
|
6
|
+
// 2. The staged runner calls it with --payload payload.json.
|
|
7
|
+
// 3. This process waits for the old server to exit, then runs npm install -g.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
|
9
|
+
// Keeping the updater outside the installed package is intentional. On Windows,
|
|
10
|
+
// running from node_modules/synabun can hold locks on the directory npm needs to
|
|
11
|
+
// rename during a global update.
|
|
24
12
|
|
|
25
13
|
import { spawn, spawnSync } from 'node:child_process';
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
26
15
|
|
|
27
16
|
const args = parseArgs(process.argv.slice(2));
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
const
|
|
17
|
+
const payload = loadPayload(args.payload);
|
|
18
|
+
|
|
19
|
+
const legacyTag = sanitizeTag(args.tag || 'latest');
|
|
20
|
+
const installSpec = sanitizeInstallSpec(payload.installSpec || args['install-spec'] || `synabun@${legacyTag}`);
|
|
21
|
+
const displayCommand = payload.displayCommand || `npm install -g ${installSpec}`;
|
|
22
|
+
const serverPid = toPid(payload.serverPid ?? args.pid);
|
|
23
|
+
const autoRestart = payload.autoRestart != null ? !!payload.autoRestart : !!args.restart;
|
|
24
|
+
const safeCwd = payload.cwd || process.cwd();
|
|
25
|
+
const noHold = !!args['no-hold'];
|
|
31
26
|
|
|
32
27
|
const isWin = process.platform === 'win32';
|
|
33
28
|
const NPM_BIN = isWin ? 'npm.cmd' : 'npm';
|
|
29
|
+
const HR = '='.repeat(56);
|
|
34
30
|
|
|
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
31
|
process.on('SIGINT', () => {});
|
|
40
32
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
console.log(`${HR}\n`);
|
|
33
|
+
try {
|
|
34
|
+
if (!installSpec) throw new Error('No valid SynaBun install target was provided.');
|
|
44
35
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
let exited = false;
|
|
50
|
-
while (Date.now() < deadline) {
|
|
51
|
-
if (!isPidAlive(serverPid)) { exited = true; break; }
|
|
52
|
-
await sleep(250);
|
|
36
|
+
console.log(`\n${HR}`);
|
|
37
|
+
console.log(` SynaBun Updater`);
|
|
38
|
+
if (payload.current || payload.target) {
|
|
39
|
+
console.log(` ${payload.current || '?'} -> ${payload.target || installSpec}`);
|
|
53
40
|
}
|
|
54
|
-
|
|
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
|
-
}
|
|
41
|
+
console.log(`${HR}\n`);
|
|
64
42
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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)`);
|
|
43
|
+
if (serverPid) {
|
|
44
|
+
console.log(`Waiting for SynaBun server (PID ${serverPid}) to exit...`);
|
|
45
|
+
const exited = await waitForExit(serverPid, 30000);
|
|
46
|
+
if (exited) {
|
|
47
|
+
console.log(' Server exited.\n');
|
|
48
|
+
} else {
|
|
49
|
+
console.warn(' Server still alive after 30s - forcing kill.\n');
|
|
50
|
+
forceKillTree(serverPid);
|
|
51
|
+
await sleep(1500);
|
|
52
|
+
}
|
|
89
53
|
} else {
|
|
90
|
-
console.
|
|
91
|
-
console.error(` • Stale lock: rm -rf ~/.npm/_locks/*`);
|
|
54
|
+
console.log('No server PID provided - assuming already stopped.\n');
|
|
92
55
|
}
|
|
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
56
|
|
|
99
|
-
|
|
57
|
+
await sleep(1500);
|
|
58
|
+
if (isWin && serverPid) forceKillTree(serverPid);
|
|
100
59
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const child = spawn(isWin ? 'synabun.cmd' : 'synabun', [], {
|
|
107
|
-
detached: true,
|
|
108
|
-
stdio: 'ignore',
|
|
60
|
+
console.log(`Running: ${displayCommand}\n`);
|
|
61
|
+
console.log(HR);
|
|
62
|
+
const installRes = spawnSync(NPM_BIN, ['install', '-g', installSpec], {
|
|
63
|
+
cwd: safeCwd,
|
|
64
|
+
stdio: 'inherit',
|
|
109
65
|
shell: isWin,
|
|
110
66
|
});
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
67
|
+
console.log(HR);
|
|
68
|
+
|
|
69
|
+
if (installRes.error) throw installRes.error;
|
|
70
|
+
if (installRes.status !== 0) {
|
|
71
|
+
console.error(`\nnpm install failed with exit code ${installRes.status}.\n`);
|
|
72
|
+
printFixTips(displayCommand);
|
|
73
|
+
await maybeHoldOpen(noHold);
|
|
74
|
+
process.exit(installRes.status || 1);
|
|
75
|
+
}
|
|
116
76
|
|
|
117
|
-
|
|
118
|
-
|
|
77
|
+
console.log(`\nSynaBun update installed.\n`);
|
|
78
|
+
|
|
79
|
+
if (autoRestart) {
|
|
80
|
+
console.log('Relaunching SynaBun...\n');
|
|
81
|
+
const child = spawn(isWin ? 'synabun.cmd' : 'synabun', [], {
|
|
82
|
+
cwd: safeCwd,
|
|
83
|
+
detached: true,
|
|
84
|
+
stdio: 'ignore',
|
|
85
|
+
shell: isWin,
|
|
86
|
+
});
|
|
87
|
+
child.unref();
|
|
88
|
+
console.log(' synabun launched (detached).\n');
|
|
89
|
+
} else {
|
|
90
|
+
console.log('Run "synabun" to start the new version.\n');
|
|
91
|
+
}
|
|
119
92
|
|
|
120
|
-
|
|
93
|
+
await maybeHoldOpen(noHold);
|
|
94
|
+
process.exit(0);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
console.error(`\nUpdate failed: ${err.message}\n`);
|
|
97
|
+
printFixTips(displayCommand);
|
|
98
|
+
await maybeHoldOpen(noHold);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
121
101
|
|
|
122
102
|
function parseArgs(argv) {
|
|
123
103
|
const out = {};
|
|
@@ -136,22 +116,60 @@ function parseArgs(argv) {
|
|
|
136
116
|
return out;
|
|
137
117
|
}
|
|
138
118
|
|
|
119
|
+
function loadPayload(path) {
|
|
120
|
+
if (!path) return {};
|
|
121
|
+
try {
|
|
122
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
123
|
+
} catch (err) {
|
|
124
|
+
throw new Error(`Could not read update payload: ${err.message}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
139
128
|
function sanitizeTag(raw) {
|
|
140
129
|
const allowed = new Set(['latest', 'beta', 'next', 'rc', 'alpha']);
|
|
141
|
-
const t = String(raw).replace(/[^a-zA-Z0-9_.-]/g, '');
|
|
130
|
+
const t = String(raw || '').replace(/[^a-zA-Z0-9_.-]/g, '');
|
|
142
131
|
return allowed.has(t) ? t : 'latest';
|
|
143
132
|
}
|
|
144
133
|
|
|
134
|
+
function sanitizeInstallSpec(raw) {
|
|
135
|
+
const spec = String(raw || '').trim();
|
|
136
|
+
if (/^synabun@(latest|beta|next|rc|alpha)$/.test(spec)) return spec;
|
|
137
|
+
if (/^synabun@\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(spec)) return spec;
|
|
138
|
+
if (/^github:danilokhury\/Synabun#[vV]\.?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(spec)) return spec;
|
|
139
|
+
if (/^github:danilokhury\/Synabun#\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(spec)) return spec;
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function toPid(raw) {
|
|
144
|
+
const pid = Number(raw);
|
|
145
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function waitForExit(pid, timeoutMs) {
|
|
149
|
+
const deadline = Date.now() + timeoutMs;
|
|
150
|
+
while (Date.now() < deadline) {
|
|
151
|
+
if (!isPidAlive(pid)) return true;
|
|
152
|
+
await sleep(250);
|
|
153
|
+
}
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
145
157
|
function isPidAlive(pid) {
|
|
146
|
-
try {
|
|
147
|
-
|
|
158
|
+
try {
|
|
159
|
+
process.kill(pid, 0);
|
|
160
|
+
return true;
|
|
161
|
+
} catch (e) {
|
|
162
|
+
return e.code === 'EPERM';
|
|
163
|
+
}
|
|
148
164
|
}
|
|
149
165
|
|
|
150
166
|
function forceKillTree(pid) {
|
|
151
167
|
try {
|
|
152
168
|
if (isWin) {
|
|
153
169
|
spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], {
|
|
154
|
-
shell: true,
|
|
170
|
+
shell: true,
|
|
171
|
+
windowsHide: true,
|
|
172
|
+
stdio: 'ignore',
|
|
155
173
|
});
|
|
156
174
|
} else {
|
|
157
175
|
try { spawnSync('pkill', ['-P', String(pid)], { stdio: 'ignore' }); } catch {}
|
|
@@ -160,17 +178,33 @@ function forceKillTree(pid) {
|
|
|
160
178
|
} catch {}
|
|
161
179
|
}
|
|
162
180
|
|
|
163
|
-
function
|
|
181
|
+
function printFixTips(command) {
|
|
182
|
+
console.error('Common fixes:');
|
|
183
|
+
if (isWin) {
|
|
184
|
+
console.error(' EBUSY/EPERM: close other SynaBun terminals/windows, then retry.');
|
|
185
|
+
console.error(' Run as admin: right-click cmd -> "Run as administrator", then retry.');
|
|
186
|
+
console.error(' Force kill: taskkill /F /IM node.exe (warning: kills all node processes)');
|
|
187
|
+
} else {
|
|
188
|
+
console.error(' EACCES: re-run with sudo, or fix global npm prefix permissions.');
|
|
189
|
+
console.error(' Stale lock: rm -rf ~/.npm/_locks/*');
|
|
190
|
+
}
|
|
191
|
+
console.error(' Cache: npm cache clean --force');
|
|
192
|
+
console.error(`\nManual retry: ${command}`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function sleep(ms) {
|
|
196
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
197
|
+
}
|
|
164
198
|
|
|
165
|
-
function
|
|
199
|
+
function maybeHoldOpen(skip) {
|
|
200
|
+
if (skip) return Promise.resolve();
|
|
166
201
|
return new Promise(resolve => {
|
|
167
|
-
console.log(
|
|
202
|
+
console.log('\n[Press Enter to close this window]');
|
|
168
203
|
try {
|
|
169
204
|
process.stdin.setEncoding('utf8');
|
|
170
205
|
process.stdin.resume();
|
|
171
206
|
process.stdin.once('data', () => resolve());
|
|
172
207
|
} catch {
|
|
173
|
-
// stdin not available — auto-close after 30s
|
|
174
208
|
setTimeout(resolve, 30000);
|
|
175
209
|
}
|
|
176
210
|
});
|
package/updater.sh
CHANGED
|
@@ -14,7 +14,7 @@ NODE_BIN="${SYNABUN_NODE_BIN:-node}"
|
|
|
14
14
|
# Pretty terminal title (no-op in some terminals).
|
|
15
15
|
printf '\033]0;SynaBun Updater\007' 2>/dev/null || true
|
|
16
16
|
|
|
17
|
-
"$NODE_BIN" "$SCRIPT_DIR/updater.mjs" "$@"
|
|
17
|
+
"$NODE_BIN" "$SCRIPT_DIR/updater.mjs" "$@" --no-hold
|
|
18
18
|
EXIT_CODE=$?
|
|
19
19
|
|
|
20
20
|
echo
|