synabun 2026.4.26-beta.7 → 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 +233 -77
- package/package.json +1 -1
- package/preuninstall.js +14 -6
- 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}` });
|
|
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}` });
|
|
1744
|
+
if (!_synabunUpdateCache.checkedAt) {
|
|
1745
|
+
try { await checkSynabunUpdate(); } catch {}
|
|
1675
1746
|
}
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
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
|
|
@@ -10359,18 +10429,74 @@ let _synabunUpdateCache = {
|
|
|
10359
10429
|
installedChannel: 'stable', // 'stable' | 'prerelease'
|
|
10360
10430
|
npmLatestStable: null, // dist-tags.latest
|
|
10361
10431
|
npmLatestBeta: null, // dist-tags.beta (or null if absent)
|
|
10432
|
+
npmLatestTag: null, // dist-tag selected for install
|
|
10362
10433
|
npmLatest: null, // chosen target for installed channel
|
|
10363
10434
|
gitLatest: null, // tag_name from GitHub release matching channel
|
|
10435
|
+
gitLatestRaw: null, // original GitHub tag, preserving leading v
|
|
10364
10436
|
latest: null, // newer of (npmLatest, gitLatest) — legacy field
|
|
10365
10437
|
npmUpdateAvailable: false,
|
|
10366
10438
|
gitUpdateAvailable: false,
|
|
10367
10439
|
updateAvailable: false, // npmUpdateAvailable || gitUpdateAvailable
|
|
10368
10440
|
source: null, // 'npm' | 'github' | 'both' | null
|
|
10441
|
+
installPlan: null, // server-owned npm install target for click-to-update
|
|
10369
10442
|
checkedAt: null,
|
|
10370
10443
|
npmError: null,
|
|
10371
10444
|
gitError: null,
|
|
10372
10445
|
};
|
|
10373
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
|
+
|
|
10374
10500
|
async function checkSynabunUpdate() {
|
|
10375
10501
|
let current = _synabunUpdateCache.current;
|
|
10376
10502
|
try {
|
|
@@ -10411,14 +10537,20 @@ async function checkSynabunUpdate() {
|
|
|
10411
10537
|
fetchJsonWithTimeout(githubUrl),
|
|
10412
10538
|
]);
|
|
10413
10539
|
|
|
10414
|
-
let npmLatestStable = null, npmLatestBeta = null;
|
|
10415
|
-
let gitLatest = null;
|
|
10540
|
+
let npmLatestStable = null, npmLatestBeta = null, npmLatestBetaTag = null, npmLatestTag = null;
|
|
10541
|
+
let gitLatest = null, gitLatestRaw = null;
|
|
10416
10542
|
let npmError = null, gitError = null;
|
|
10417
10543
|
|
|
10418
10544
|
if (npmRes.status === 'fulfilled') {
|
|
10419
10545
|
const tags = npmRes.value?.['dist-tags'] || {};
|
|
10420
10546
|
npmLatestStable = tags.latest || null;
|
|
10421
|
-
|
|
10547
|
+
for (const tagName of ['beta', 'next', 'rc', 'alpha']) {
|
|
10548
|
+
if (tags[tagName]) {
|
|
10549
|
+
npmLatestBeta = tags[tagName];
|
|
10550
|
+
npmLatestBetaTag = tagName;
|
|
10551
|
+
break;
|
|
10552
|
+
}
|
|
10553
|
+
}
|
|
10422
10554
|
} else {
|
|
10423
10555
|
npmError = npmRes.reason?.message || 'npm fetch failed';
|
|
10424
10556
|
}
|
|
@@ -10438,10 +10570,12 @@ async function checkSynabunUpdate() {
|
|
|
10438
10570
|
best = { tag, parsed };
|
|
10439
10571
|
}
|
|
10440
10572
|
}
|
|
10441
|
-
|
|
10573
|
+
gitLatestRaw = best?.tag || null;
|
|
10574
|
+
gitLatest = gitLatestRaw ? gitLatestRaw.replace(/^v\.?/, '') : null;
|
|
10442
10575
|
} else {
|
|
10443
10576
|
// /releases/latest — single object
|
|
10444
10577
|
const tag = gitRes.value?.tag_name || gitRes.value?.name || '';
|
|
10578
|
+
gitLatestRaw = tag || null;
|
|
10445
10579
|
gitLatest = tag ? tag.replace(/^v\.?/, '') : null;
|
|
10446
10580
|
}
|
|
10447
10581
|
} else {
|
|
@@ -10457,12 +10591,20 @@ async function checkSynabunUpdate() {
|
|
|
10457
10591
|
let npmLatest = null;
|
|
10458
10592
|
if (installedChannel === 'prerelease') {
|
|
10459
10593
|
if (npmLatestStable && npmLatestBeta) {
|
|
10460
|
-
|
|
10594
|
+
if (compareSemver(npmLatestBeta, npmLatestStable) > 0) {
|
|
10595
|
+
npmLatest = npmLatestBeta;
|
|
10596
|
+
npmLatestTag = npmLatestBetaTag;
|
|
10597
|
+
} else {
|
|
10598
|
+
npmLatest = npmLatestStable;
|
|
10599
|
+
npmLatestTag = 'latest';
|
|
10600
|
+
}
|
|
10461
10601
|
} else {
|
|
10462
10602
|
npmLatest = npmLatestStable || npmLatestBeta;
|
|
10603
|
+
npmLatestTag = npmLatestStable ? 'latest' : npmLatestBetaTag;
|
|
10463
10604
|
}
|
|
10464
10605
|
} else {
|
|
10465
10606
|
npmLatest = npmLatestStable;
|
|
10607
|
+
npmLatestTag = npmLatest ? 'latest' : null;
|
|
10466
10608
|
}
|
|
10467
10609
|
|
|
10468
10610
|
const npmUpdateAvailable = !!(npmLatest && semverNewer(current, npmLatest));
|
|
@@ -10482,18 +10624,32 @@ async function checkSynabunUpdate() {
|
|
|
10482
10624
|
else if (npmUpdateAvailable) source = 'npm';
|
|
10483
10625
|
else if (gitUpdateAvailable) source = 'github';
|
|
10484
10626
|
|
|
10627
|
+
const installPlan = buildSynabunInstallPlan({
|
|
10628
|
+
current,
|
|
10629
|
+
installedChannel,
|
|
10630
|
+
npmLatest,
|
|
10631
|
+
npmLatestTag,
|
|
10632
|
+
npmUpdateAvailable,
|
|
10633
|
+
gitLatest,
|
|
10634
|
+
gitLatestRaw,
|
|
10635
|
+
gitUpdateAvailable,
|
|
10636
|
+
});
|
|
10637
|
+
|
|
10485
10638
|
_synabunUpdateCache = {
|
|
10486
10639
|
current,
|
|
10487
10640
|
installedChannel,
|
|
10488
10641
|
npmLatestStable,
|
|
10489
10642
|
npmLatestBeta,
|
|
10643
|
+
npmLatestTag,
|
|
10490
10644
|
npmLatest,
|
|
10491
10645
|
gitLatest,
|
|
10646
|
+
gitLatestRaw,
|
|
10492
10647
|
latest,
|
|
10493
10648
|
npmUpdateAvailable,
|
|
10494
10649
|
gitUpdateAvailable,
|
|
10495
10650
|
updateAvailable,
|
|
10496
10651
|
source,
|
|
10652
|
+
installPlan,
|
|
10497
10653
|
checkedAt: new Date().toISOString(),
|
|
10498
10654
|
npmError,
|
|
10499
10655
|
gitError,
|
package/package.json
CHANGED
package/preuninstall.js
CHANGED
|
@@ -91,28 +91,36 @@ function killOrphansInsideInstall() {
|
|
|
91
91
|
|
|
92
92
|
if (plat === 'win32') {
|
|
93
93
|
// PowerShell: Get-CimInstance returns every process with CommandLine.
|
|
94
|
-
//
|
|
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.
|
|
95
96
|
// Stop-Process -Force kills synchronously; -ErrorAction SilentlyContinue
|
|
96
97
|
// suppresses "process already exited" noise.
|
|
97
|
-
const escaped = installPath.replace(/\\/g, '\\\\').replace(/'/g, `''`);
|
|
98
98
|
const ps = `
|
|
99
99
|
$ErrorActionPreference = 'SilentlyContinue';
|
|
100
|
+
$installPath = $env:SYNABUN_INSTALL_PATH;
|
|
101
|
+
$selfPid = [int]$env:SYNABUN_PREUNINSTALL_PID;
|
|
100
102
|
Get-CimInstance Win32_Process |
|
|
101
103
|
Where-Object {
|
|
102
|
-
$_.ProcessId -ne $
|
|
103
|
-
|
|
104
|
-
($_.
|
|
104
|
+
$_.ProcessId -ne $selfPid -and
|
|
105
|
+
$_.ProcessId -ne $PID -and (
|
|
106
|
+
($_.CommandLine -and $_.CommandLine -like "*$installPath*") -or
|
|
107
|
+
($_.ExecutablePath -and $_.ExecutablePath -like "$installPath*")
|
|
105
108
|
)
|
|
106
109
|
} |
|
|
107
110
|
ForEach-Object {
|
|
108
111
|
Write-Host (' Killing orphan PID ' + $_.ProcessId + ' (' + $_.Name + ')');
|
|
109
112
|
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
|
|
110
113
|
}
|
|
111
|
-
`.trim();
|
|
114
|
+
`.trim();
|
|
112
115
|
try {
|
|
113
116
|
execSync(`powershell -NoProfile -ExecutionPolicy Bypass -Command "${ps.replace(/"/g, '\\"').replace(/\n/g, ' ')}"`, {
|
|
114
117
|
stdio: 'inherit',
|
|
115
118
|
timeout: 8000,
|
|
119
|
+
env: {
|
|
120
|
+
...process.env,
|
|
121
|
+
SYNABUN_INSTALL_PATH: installPath,
|
|
122
|
+
SYNABUN_PREUNINSTALL_PID: String(selfPid),
|
|
123
|
+
},
|
|
116
124
|
});
|
|
117
125
|
} catch {
|
|
118
126
|
// PowerShell unavailable, blocked, or timed out. Fall through.
|
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
|