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.
@@ -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.source === 'both' ? 'npm + GitHub' : data.source === 'github' ? 'GitHub' : 'npm';
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
- // Channel-aware install command. Beta users staying on beta keep the
153
- // @beta tag; both targets exist on stable so we surface a single line.
154
- const channel = _versionData.installedChannel || 'stable';
155
- const target = _versionData.latest || '';
156
- const targetLooksBeta = parseSemverPre(target);
157
- const installTag = (channel === 'prerelease' && targetLooksBeta) ? 'beta' : 'latest';
158
- const installCmd = `npm install -g synabun@${installTag}`;
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({ tag, autoRestart }),
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
- // 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
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
- // 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.
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
- 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}` });
1744
+ if (!_synabunUpdateCache.checkedAt) {
1745
+ try { await checkSynabunUpdate(); } catch {}
1672
1746
  }
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 {}
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 argsBase = ['--tag', tag, '--pid', String(serverPid)];
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(cmdLine, {
1704
- shell: true,
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: false,
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 shellEscape = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
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', 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]],
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: npm install -g synabun@${tag}`
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 (tag=${tag}, autoRestart=${autoRestart}, shim=${shimPath}). Shutting down...`);
1792
- res.json({ ok: true, tag, autoRestart, shimPath, platform: plat });
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
- try { _ocpProc.kill(); } catch {}
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
- signalPids([managedPid], 'SIGTERM');
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
- npmLatestBeta = tags.beta || tags.next || tags.rc || null;
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
- gitLatest = best?.tag.replace(/^v\.?/, '') || null;
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
- npmLatest = compareSemver(npmLatestBeta, npmLatestStable) > 0 ? npmLatestBeta : npmLatestStable;
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
- process.on('SIGINT', () => { stopOpencodeServer(); saveSessionSnapshot(); process.exit(0); });
23591
- process.on('SIGTERM', () => { stopOpencodeServer(); saveSessionSnapshot(); process.exit(0); });
23592
- process.on('exit', () => { stopOpencodeServer(); saveSessionSnapshot(); });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synabun",
3
- "version": "2026.4.26-beta.6",
3
+ "version": "2026.4.26-beta.9",
4
4
  "description": "Persistent vector memory for AI assistants",
5
5
  "license": "Apache-2.0",
6
6
  "author": "The SynaBun Authors",
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" --tag X --pid Y [--restart]
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
- // 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.
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
- // 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
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 tag = sanitizeTag(args.tag || 'latest');
29
- const serverPid = args.pid ? Number(args.pid) : null;
30
- const autoRestart = !!args.restart;
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
- console.log(`\n${HR}`);
42
- console.log(` SynaBun Updater — installing synabun@${tag}`);
43
- console.log(`${HR}\n`);
33
+ try {
34
+ if (!installSpec) throw new Error('No valid SynaBun install target was provided.');
44
35
 
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);
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
- 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
- }
41
+ console.log(`${HR}\n`);
64
42
 
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)`);
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.error(` • EACCES: re-run with sudo, or fix global npm prefix permissions.`);
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
- console.log(`\n✓ SynaBun updated to ${tag}!\n`);
57
+ await sleep(1500);
58
+ if (isWin && serverPid) forceKillTree(serverPid);
100
59
 
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',
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
- child.unref();
112
- console.log(` ✓ synabun launched (detached).\n`);
113
- } else {
114
- console.log(`Run "synabun" to start the new version.\n`);
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
- await holdOpen();
118
- process.exit(0);
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
- // ── Helpers ──────────────────────────────────────────────────────
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 { process.kill(pid, 0); return true; }
147
- catch (e) { return e.code === 'EPERM'; } // EPERM = exists but we lack rights
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, windowsHide: true, stdio: 'ignore',
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 sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
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 holdOpen() {
199
+ function maybeHoldOpen(skip) {
200
+ if (skip) return Promise.resolve();
166
201
  return new Promise(resolve => {
167
- console.log(`\n[Press Enter to close this window]`);
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