synabun 2026.4.26-beta.5 → 2026.4.26-beta.7

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.
@@ -1637,9 +1637,12 @@ app.post('/api/server/shutdown', async (req, res) => {
1637
1637
 
1638
1638
  // POST /api/system/run-update — Click-to-update flow for npm-installed users.
1639
1639
  //
1640
- // Spawns updater.mjs in a NEW visible terminal window (cmd on Windows,
1641
- // Terminal.app on macOS, x-terminal-emulator/gnome-terminal/konsole/xterm
1642
- // fallback on Linux). The updater:
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:
1643
1646
  // 1. Waits for the server PID to exit
1644
1647
  // 2. Runs `npm install -g synabun@<tag>`
1645
1648
  // 3. Optionally relaunches synabun
@@ -1647,8 +1650,8 @@ app.post('/api/server/shutdown', async (req, res) => {
1647
1650
  //
1648
1651
  // Right after spawning the detached updater, we run gracefulShutdown +
1649
1652
  // process.exit so file handles in neural-interface/ release and npm's
1650
- // rename succeeds. The updater handles waiting on us — it only starts npm
1651
- // after our PID is gone.
1653
+ // rename succeeds. The updater polls our PID and only starts npm after
1654
+ // we're gone.
1652
1655
  app.post('/api/system/run-update', async (req, res) => {
1653
1656
  try {
1654
1657
  const tag = String((req.body?.tag || 'latest')).replace(/[^a-zA-Z0-9_.-]/g, '');
@@ -1658,73 +1661,148 @@ app.post('/api/system/run-update', async (req, res) => {
1658
1661
  return res.status(400).json({ error: `tag must be one of: ${[...allowedTags].join(', ')}` });
1659
1662
  }
1660
1663
 
1661
- const updaterPath = resolve(PACKAGE_ROOT, 'updater.mjs');
1662
- if (!existsSync(updaterPath)) {
1663
- return res.status(500).json({ error: `updater.mjs not found at ${updaterPath}. SynaBun install may be corrupt.` });
1664
+ const plat = process.platform;
1665
+ const shimPath = plat === 'win32'
1666
+ ? resolve(PACKAGE_ROOT, 'updater.cmd')
1667
+ : resolve(PACKAGE_ROOT, 'updater.sh');
1668
+ const updaterMjsPath = resolve(PACKAGE_ROOT, 'updater.mjs');
1669
+
1670
+ if (!existsSync(updaterMjsPath)) {
1671
+ return res.status(500).json({ error: `updater.mjs not found at ${updaterMjsPath}. SynaBun install may be corrupt — reinstall: npm install -g synabun@${tag}` });
1672
+ }
1673
+ if (!existsSync(shimPath)) {
1674
+ return res.status(500).json({ error: `${plat === 'win32' ? 'updater.cmd' : 'updater.sh'} not found at ${shimPath}. SynaBun install may be corrupt — reinstall: npm install -g synabun@${tag}` });
1675
+ }
1676
+
1677
+ // On Unix, ensure the shim is executable. npm publish preserves the
1678
+ // executable bit only if it was set when packed; defensive chmod here
1679
+ // covers users whose tarball lost it (some Windows-staged pipelines do).
1680
+ if (plat !== 'win32') {
1681
+ try { chmodSync(shimPath, 0o755); } catch {}
1664
1682
  }
1665
1683
 
1666
1684
  const serverPid = process.pid;
1667
1685
  const argsBase = ['--tag', tag, '--pid', String(serverPid)];
1668
1686
  if (autoRestart) argsBase.push('--restart');
1669
1687
 
1670
- const plat = process.platform;
1671
1688
  let spawned = null;
1689
+ let launchError = null;
1672
1690
 
1673
1691
  if (plat === 'win32') {
1674
- // `cmd /c start "<title>" cmd /k "node <updater> ..."` opens a new
1675
- // visible cmd window that stays open after the script finishes (/k).
1676
- const nodeExe = process.execPath;
1677
- const cmdLine = `"${nodeExe}" "${updaterPath}" ${argsBase.map(a => `"${a}"`).join(' ')}`;
1678
- spawned = spawn('cmd', ['/c', 'start', '"SynaBun Update"', 'cmd', '/k', cmdLine], {
1679
- detached: true, stdio: 'ignore', windowsHide: false, shell: false,
1680
- });
1692
+ // Use shell:true so cmd parses our string directly — bypasses node's
1693
+ // arg-escape algorithm that was mangling the title arg ("SynaBun
1694
+ // Update" was being eaten by start's title slot, leaving Windows to
1695
+ // try to launch "Update" as a program).
1696
+ //
1697
+ // `start "" "<shim>" args` — empty title (cmd's start ALWAYS treats
1698
+ // the first quoted arg as a title), then runs the .cmd shim with
1699
+ // args. The shim handles its own pause-before-close.
1700
+ const argsLine = argsBase.map(a => `"${String(a).replace(/"/g, '""')}"`).join(' ');
1701
+ const cmdLine = `start "" "${shimPath}" ${argsLine}`;
1702
+ try {
1703
+ spawned = spawn(cmdLine, {
1704
+ shell: true,
1705
+ detached: true,
1706
+ stdio: 'ignore',
1707
+ windowsHide: false,
1708
+ });
1709
+ } catch (err) { launchError = err; }
1681
1710
  } else if (plat === 'darwin') {
1682
- // Use osascript to drive Terminal.app — opens a visible window the
1683
- // user can watch. Escape internal quotes for AppleScript string.
1684
- const nodeExe = process.execPath;
1685
- const cmd = `${JSON.stringify(nodeExe)} ${JSON.stringify(updaterPath)} ${argsBase.map(a => JSON.stringify(a)).join(' ')}`;
1686
- const escaped = cmd.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1687
- spawned = spawn('osascript', [
1688
- '-e', `tell application "Terminal" to activate`,
1689
- '-e', `tell application "Terminal" to do script "${escaped}"`,
1690
- ], { detached: true, stdio: 'ignore' });
1711
+ // Drive Terminal.app via osascript. The shim path may contain spaces;
1712
+ // wrap it in single quotes inside the AppleScript string. AppleScript
1713
+ // string is wrapped in JS double-quote, so we escape JS backslashes
1714
+ // and double-quotes after building the inner shell command.
1715
+ const shellEscape = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
1716
+ const appleCmd = `${shellEscape(shimPath)} ${argsBase.map(shellEscape).join(' ')}`;
1717
+ try {
1718
+ spawned = spawn('osascript', [
1719
+ '-e', 'tell application "Terminal" to activate',
1720
+ '-e', `tell application "Terminal" to do script "${appleCmd.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`,
1721
+ ], { detached: true, stdio: 'ignore' });
1722
+ } catch (err) { launchError = err; }
1691
1723
  } else {
1692
- // Linux — try common terminal emulators in order, fall back to xterm.
1693
- const nodeExe = process.execPath;
1694
- const inner = `${nodeExe} ${updaterPath} ${argsBase.join(' ')}; echo; echo '[Press Enter to close]'; read`;
1724
+ // Linux/BSD/etc — try common terminal emulators in order. Each gets
1725
+ // the shim path as the program to run; the shim itself is a bash
1726
+ // script so we let the terminal exec it directly without bash -c.
1695
1727
  const candidates = [
1696
- ['x-terminal-emulator', ['-e', `bash -c "${inner.replace(/"/g, '\\"')}"`]],
1697
- ['gnome-terminal', ['--', 'bash', '-c', inner]],
1698
- ['konsole', ['-e', 'bash', '-c', inner]],
1699
- ['xfce4-terminal', ['-e', `bash -c "${inner.replace(/"/g, '\\"')}"`]],
1700
- ['xterm', ['-e', 'bash', '-c', inner]],
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]],
1701
1740
  ];
1702
1741
  for (const [bin, args] of candidates) {
1742
+ let child = null;
1703
1743
  try {
1704
- spawned = spawn(bin, args, { detached: true, stdio: 'ignore' });
1705
- break;
1706
- } catch {}
1744
+ child = spawn(bin, args, { detached: true, stdio: 'ignore' });
1745
+ } catch {
1746
+ continue;
1747
+ }
1748
+ // spawn() doesn't throw synchronously when bin is missing — it emits
1749
+ // 'error' async (ENOENT). Probe briefly to detect that case before
1750
+ // declaring success and unrefing.
1751
+ const survived = await new Promise((resolveProbe) => {
1752
+ let settled = false;
1753
+ const onErr = () => {
1754
+ if (settled) return;
1755
+ settled = true;
1756
+ child.removeAllListeners('error');
1757
+ resolveProbe(false);
1758
+ };
1759
+ child.once('error', onErr);
1760
+ setTimeout(() => {
1761
+ if (settled) return;
1762
+ settled = true;
1763
+ child.removeAllListeners('error');
1764
+ resolveProbe(true);
1765
+ }, 150);
1766
+ });
1767
+ if (survived) { spawned = child; break; }
1707
1768
  }
1708
1769
  if (!spawned) {
1709
- return res.status(500).json({ error: 'No supported terminal emulator found. Install xterm or run `npm install -g synabun@' + tag + '` manually.' });
1770
+ launchError = new Error(
1771
+ 'No supported terminal emulator found (tried: x-terminal-emulator, gnome-terminal, konsole, xfce4-terminal, mate-terminal, lxterminal, terminator, alacritty, kitty, wezterm, urxvt, xterm). ' +
1772
+ `Install one (e.g. \`apt install xterm\`) or run manually: npm install -g synabun@${tag}`
1773
+ );
1710
1774
  }
1711
1775
  }
1712
1776
 
1777
+ if (!spawned) {
1778
+ const msg = launchError?.message || `Failed to launch updater terminal on ${plat}`;
1779
+ console.error('[server] run-update launch failed:', msg);
1780
+ return res.status(500).json({ error: msg });
1781
+ }
1782
+
1783
+ // Don't tie our shutdown to the spawned process — it's a long-living
1784
+ // terminal we don't want to wait on.
1713
1785
  spawned.unref();
1714
- console.log(`[server] Updater terminal launched (tag=${tag}, autoRestart=${autoRestart}). Shutting down...`);
1715
- res.json({ ok: true, tag, autoRestart, updaterPath });
1786
+ spawned.on('error', (err) => {
1787
+ // Async ENOENT etc. — log but the response already went out.
1788
+ console.error('[server] updater spawn async error:', err.message);
1789
+ });
1790
+
1791
+ console.log(`[server] Updater terminal launched (tag=${tag}, autoRestart=${autoRestart}, shim=${shimPath}). Shutting down...`);
1792
+ res.json({ ok: true, tag, autoRestart, shimPath, platform: plat });
1716
1793
 
1717
1794
  // Give the spawned terminal time to fully launch before we tear down
1718
- // (on Windows, `start` returns instantly but the new window takes a
1719
- // beat to actually init node). 1.2s is generous; updater polls our PID
1720
- // anyway so an extra second here is harmless.
1795
+ // (on Windows `start` returns instantly but the new window takes a
1796
+ // beat to init node; on macOS osascript dispatches AppleScript async).
1797
+ // 1.2s is generous; updater polls our PID anyway so an extra second
1798
+ // here is harmless.
1721
1799
  setTimeout(async () => {
1722
1800
  await gracefulShutdown('run-update');
1723
1801
  process.exit(0);
1724
1802
  }, 1200);
1725
1803
  } catch (err) {
1726
1804
  console.error('[server] run-update error:', err?.message);
1727
- res.status(500).json({ error: err.message });
1805
+ if (!res.headersSent) res.status(500).json({ error: err.message });
1728
1806
  }
1729
1807
  });
1730
1808
 
@@ -4431,12 +4509,36 @@ function stopOpencodeServer() {
4431
4509
  stopOpencodeSSERelay();
4432
4510
  const managedPid = readOpencodeManagedPid();
4433
4511
  const procPid = _ocpProc?.pid || null;
4512
+ const isWin = process.platform === 'win32';
4513
+
4514
+ // On Windows `process.kill(pid, 'SIGTERM')` is unreliable for non-Node
4515
+ // children (cmd.exe shells, opencode native binaries). Use taskkill /T /F
4516
+ // to walk the process tree and force-terminate every descendant.
4517
+ // This is the recurring source of EBUSY errors during `npm install -g
4518
+ // synabun@latest` — opencode-ai's `serve` process survives parent SynaBun
4519
+ // exit and keeps file handles inside neural-interface/ open.
4520
+ const winKill = (pid) => {
4521
+ try {
4522
+ spawn('taskkill', ['/pid', String(pid), '/T', '/F'], {
4523
+ shell: true, windowsHide: true, stdio: 'ignore',
4524
+ });
4525
+ } catch {}
4526
+ };
4527
+
4434
4528
  if (_ocpProc && !_ocpProc.killed) {
4435
- try { _ocpProc.kill(); } catch {}
4529
+ if (isWin && procPid) {
4530
+ winKill(procPid);
4531
+ } else {
4532
+ try { _ocpProc.kill(); } catch {}
4533
+ }
4436
4534
  _ocpProc = null;
4437
4535
  }
4438
4536
  if (managedPid && managedPid !== procPid) {
4439
- signalPids([managedPid], 'SIGTERM');
4537
+ if (isWin) {
4538
+ winKill(managedPid);
4539
+ } else {
4540
+ signalPids([managedPid], 'SIGTERM');
4541
+ }
4440
4542
  }
4441
4543
  clearOpencodeManagedPid();
4442
4544
  _ocpReady = false;
@@ -23509,6 +23611,21 @@ function saveSessionSnapshot() {
23509
23611
  setInterval(saveSessionSnapshot, SESSION_SNAPSHOT_INTERVAL_MS);
23510
23612
 
23511
23613
  // Also try to save on clean shutdown (works with Ctrl+C, not window close)
23512
- process.on('SIGINT', () => { stopOpencodeServer(); saveSessionSnapshot(); process.exit(0); });
23513
- process.on('SIGTERM', () => { stopOpencodeServer(); saveSessionSnapshot(); process.exit(0); });
23514
- process.on('exit', () => { stopOpencodeServer(); saveSessionSnapshot(); });
23614
+ // Manual termination paths (Ctrl+C, kill, parent shell exit) all flow through
23615
+ // gracefulShutdown — same teardown the in-app restart/shutdown/update paths
23616
+ // use. Without this, hand-killed servers still leave orphaned opencode +
23617
+ // agent children holding file handles, which blocks `npm install -g synabun`.
23618
+ process.on('SIGINT', async () => {
23619
+ console.log('\n[server] SIGINT received.');
23620
+ await gracefulShutdown('sigint');
23621
+ process.exit(0);
23622
+ });
23623
+ process.on('SIGTERM', async () => {
23624
+ console.log('\n[server] SIGTERM received.');
23625
+ await gracefulShutdown('sigterm');
23626
+ process.exit(0);
23627
+ });
23628
+ // `exit` fires after process.exit() — only safe for synchronous final tasks.
23629
+ // gracefulShutdown is async so we cannot await here; just snapshot + ocp stop
23630
+ // best-effort. The async paths above are the primary teardown.
23631
+ 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.5",
3
+ "version": "2026.4.26-beta.7",
4
4
  "description": "Persistent vector memory for AI assistants",
5
5
  "license": "Apache-2.0",
6
6
  "author": "The SynaBun Authors",
@@ -18,6 +18,8 @@
18
18
  "postinstall.js",
19
19
  "preuninstall.js",
20
20
  "updater.mjs",
21
+ "updater.cmd",
22
+ "updater.sh",
21
23
  "mcp-server/package.json",
22
24
  "mcp-server/dist/",
23
25
  "mcp-server/tsconfig.json",
package/postinstall.js CHANGED
@@ -197,6 +197,25 @@ function registerProtocolHandler() {
197
197
  }
198
198
  }
199
199
 
200
+ // ── Updater shim executable bit ──
201
+ //
202
+ // npm publish drops the executable bit on macOS/Linux when the tarball is
203
+ // built on Windows (a common pipeline issue). The in-app updater needs
204
+ // updater.sh to be executable so the per-OS terminal-launch path can
205
+ // `exec()` it directly. Restore the bit defensively on every postinstall.
206
+
207
+ function ensureUpdaterShExecutable() {
208
+ if (platform() === 'win32') return;
209
+ const shPath = resolve(__dirname, 'updater.sh');
210
+ if (!existsSync(shPath)) return;
211
+ try {
212
+ chmodSync(shPath, 0o755);
213
+ ok('updater.sh marked executable');
214
+ } catch (err) {
215
+ warn(`updater.sh chmod failed (${err.message}) — manual fix: chmod +x "${shPath}"`);
216
+ }
217
+ }
218
+
200
219
  // ── Main ──
201
220
 
202
221
  function main() {
@@ -207,6 +226,7 @@ function main() {
207
226
  rebuildPty();
208
227
  buildMcpServer();
209
228
  registerProtocolHandler();
229
+ ensureUpdaterShExecutable();
210
230
 
211
231
  console.log(`\n ${c.green}\u2713${c.reset} Setup complete. Run ${c.cyan}synabun${c.reset} to start.\n`);
212
232
  }
package/preuninstall.js CHANGED
@@ -69,6 +69,80 @@ 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
+ // Filter for our install path (escape backslashes for the -like wildcard).
95
+ // Stop-Process -Force kills synchronously; -ErrorAction SilentlyContinue
96
+ // suppresses "process already exited" noise.
97
+ const escaped = installPath.replace(/\\/g, '\\\\').replace(/'/g, `''`);
98
+ const ps = `
99
+ $ErrorActionPreference = 'SilentlyContinue';
100
+ Get-CimInstance Win32_Process |
101
+ Where-Object {
102
+ $_.ProcessId -ne ${selfPid} -and (
103
+ ($_.CommandLine -and $_.CommandLine -like '*${escaped}*') -or
104
+ ($_.ExecutablePath -and $_.ExecutablePath -like '${escaped}*')
105
+ )
106
+ } |
107
+ ForEach-Object {
108
+ Write-Host (' Killing orphan PID ' + $_.ProcessId + ' (' + $_.Name + ')');
109
+ Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
110
+ }
111
+ `.trim();
112
+ try {
113
+ execSync(`powershell -NoProfile -ExecutionPolicy Bypass -Command "${ps.replace(/"/g, '\\"').replace(/\n/g, ' ')}"`, {
114
+ stdio: 'inherit',
115
+ timeout: 8000,
116
+ });
117
+ } catch {
118
+ // PowerShell unavailable, blocked, or timed out. Fall through.
119
+ }
120
+ } else {
121
+ // Unix: pgrep -f matches against the full command line. -P 1 skips
122
+ // self/children of init we shouldn't touch. We just enumerate then
123
+ // SIGKILL each one that isn't us.
124
+ try {
125
+ const out = execSync(`pgrep -f "${installPath.replace(/[/.*?+^$|(){}\\\[\]]/g, '\\$&')}"`, {
126
+ encoding: 'utf8',
127
+ timeout: 5000,
128
+ }).trim();
129
+ const pids = out.split('\n')
130
+ .map(s => Number(s.trim()))
131
+ .filter(n => Number.isFinite(n) && n > 0 && n !== selfPid);
132
+ for (const pid of pids) {
133
+ try { process.kill(pid, 'SIGKILL'); } catch {}
134
+ }
135
+ } catch {
136
+ // No matches or pgrep missing — fine.
137
+ }
138
+ }
139
+
140
+ // Brief grace for OS to release file handles after killing.
141
+ // Synchronous busy-wait so we run before the rmSync calls below.
142
+ const until = Date.now() + 1500;
143
+ while (Date.now() < until) { /* spin */ }
144
+ }
145
+
72
146
  // ── Step 2: remove nested node_modules ──
73
147
 
74
148
  const dirs = [
package/updater.cmd ADDED
@@ -0,0 +1,31 @@
1
+ @echo off
2
+ setlocal enableextensions
3
+ title SynaBun Updater
4
+
5
+ rem ───────────────────────────────────────────────────────
6
+ rem Windows shim for the cross-platform updater (updater.mjs).
7
+ rem Invoked by the running SynaBun server via:
8
+ rem start "" "<install>\updater.cmd" --tag X --pid Y [--restart]
9
+ rem
10
+ rem We use a shim instead of spawning node directly so cmd's
11
+ rem quoting rules don't mangle the command line, and so the
12
+ rem window stays open for the user to read npm output even
13
+ rem if node exits unexpectedly.
14
+ rem ───────────────────────────────────────────────────────
15
+
16
+ set "SCRIPT_DIR=%~dp0"
17
+ set "EXIT_CODE=0"
18
+
19
+ rem %~dp0 ends with backslash. updater.mjs lives next to this file.
20
+ node "%SCRIPT_DIR%updater.mjs" %*
21
+ set EXIT_CODE=%ERRORLEVEL%
22
+
23
+ echo.
24
+ if %EXIT_CODE%==0 (
25
+ echo [Update finished. Press any key to close this window.]
26
+ ) else (
27
+ echo [Update exited with code %EXIT_CODE%. Press any key to close this window.]
28
+ )
29
+ pause >nul
30
+
31
+ endlocal ^& exit /b %EXIT_CODE%
package/updater.sh ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env bash
2
+ # ───────────────────────────────────────────────────────
3
+ # Unix shim for the cross-platform updater (updater.mjs).
4
+ # Invoked by the running SynaBun server inside a fresh
5
+ # terminal window — keeps the terminal open after node
6
+ # exits so the user can read npm output and any errors.
7
+ # ───────────────────────────────────────────────────────
8
+
9
+ set +e
10
+
11
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
12
+ NODE_BIN="${SYNABUN_NODE_BIN:-node}"
13
+
14
+ # Pretty terminal title (no-op in some terminals).
15
+ printf '\033]0;SynaBun Updater\007' 2>/dev/null || true
16
+
17
+ "$NODE_BIN" "$SCRIPT_DIR/updater.mjs" "$@"
18
+ EXIT_CODE=$?
19
+
20
+ echo
21
+ if [ "$EXIT_CODE" = "0" ]; then
22
+ echo "[Update finished. Press Enter to close this window.]"
23
+ else
24
+ echo "[Update exited with code $EXIT_CODE. Press Enter to close this window.]"
25
+ fi
26
+ # Read one line. If stdin is closed (e.g. piped run), fall through immediately.
27
+ read -r _ || true
28
+
29
+ exit $EXIT_CODE