synabun 2026.4.26 → 2026.4.27

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.
@@ -174,7 +174,81 @@ export async function initToolUpdates() {
174
174
  export async function forceCheckToolUpdates() {
175
175
  try {
176
176
  const res = await fetch('/api/system/tool-versions?force=1');
177
- if (!res.ok) return;
178
- applyBadges(await res.json());
179
- } catch { /* silent */ }
177
+ if (!res.ok) return null;
178
+ const data = await res.json();
179
+ applyBadges(data);
180
+ return data;
181
+ } catch { return null; }
182
+ }
183
+
184
+ // Re-export human-readable tool labels so toast/menu code can build summaries
185
+ // without redefining the label map.
186
+ export { TOOL_LABELS };
187
+
188
+ // ── Update-check toast ──
189
+ // Bottom-right ephemeral pill. One of three modes: updates available (with
190
+ // list + optional click handler), all-clear, or check-failed. Auto-dismisses.
191
+
192
+ function _toastEsc(s) {
193
+ const d = document.createElement('div');
194
+ d.textContent = String(s ?? '');
195
+ return d.innerHTML;
196
+ }
197
+
198
+ export function showUpdateToast({ updates, errors, onClick } = {}) {
199
+ document.querySelectorAll('.update-check-toast').forEach(el => el.remove());
200
+
201
+ const toast = document.createElement('div');
202
+ toast.className = 'update-check-toast';
203
+
204
+ const hasUpdates = Array.isArray(updates) && updates.length > 0;
205
+ const hasErrors = Array.isArray(errors) && errors.length > 0;
206
+ const clickable = hasUpdates && typeof onClick === 'function';
207
+ if (clickable) toast.classList.add('update-check-toast--clickable');
208
+
209
+ if (hasUpdates) {
210
+ const items = updates.map(u => {
211
+ const label = _toastEsc(u?.label || '');
212
+ const src = u?.source ? `<span class="utc-src">${_toastEsc(u.source)}</span>` : '';
213
+ return `<li>${label}${src}</li>`;
214
+ }).join('');
215
+ toast.innerHTML = `
216
+ <div class="utc-icon utc-icon--up">↑</div>
217
+ <div class="utc-body">
218
+ <div class="utc-title">Updates available</div>
219
+ <ul class="utc-list">${items}</ul>
220
+ ${clickable ? '<div class="utc-hint">Click to open update guide</div>' : ''}
221
+ </div>`;
222
+ } else if (hasErrors) {
223
+ toast.innerHTML = `
224
+ <div class="utc-icon utc-icon--err">!</div>
225
+ <div class="utc-body">
226
+ <div class="utc-title">Update check failed</div>
227
+ <div class="utc-sub">${_toastEsc(errors.join(' · '))}</div>
228
+ </div>`;
229
+ } else {
230
+ toast.innerHTML = `
231
+ <div class="utc-icon utc-icon--ok">✓</div>
232
+ <div class="utc-body">
233
+ <div class="utc-title">All up to date</div>
234
+ </div>`;
235
+ }
236
+
237
+ if (clickable) {
238
+ toast.addEventListener('click', () => {
239
+ try { onClick(); } catch {}
240
+ toast.classList.remove('show');
241
+ setTimeout(() => toast.remove(), 250);
242
+ });
243
+ }
244
+
245
+ document.body.appendChild(toast);
246
+ requestAnimationFrame(() => toast.classList.add('show'));
247
+
248
+ const ttl = hasUpdates ? 7000 : hasErrors ? 5000 : 3500;
249
+ setTimeout(() => {
250
+ if (!toast.isConnected) return;
251
+ toast.classList.remove('show');
252
+ setTimeout(() => toast.remove(), 250);
253
+ }, ttl);
180
254
  }
@@ -4,30 +4,96 @@
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);
10
+ const SYNABUN_GITHUB_URL = 'https://github.com/danilokhury/Synabun';
8
11
 
9
12
  let _versionData = null;
13
+ let _btnWired = false;
14
+ let _pollWired = false;
15
+
16
+ function sourceLabel(data) {
17
+ const src = data?.source;
18
+ if (src === 'both') return 'npm + GitHub';
19
+ if (src === 'github') return 'GitHub';
20
+ if (src === 'npm') return 'npm';
21
+ return src || 'update';
22
+ }
23
+
24
+ function applyTopRightButton(data) {
25
+ const btn = $('topright-update-btn');
26
+ const badge = $('update-badge');
27
+ if (!btn) return;
28
+ const has = !!data?.updateAvailable;
29
+ btn.style.display = has ? '' : 'none';
30
+ if (badge) badge.textContent = has ? '!' : '';
31
+ if (has) {
32
+ const srcLabel = sourceLabel(data);
33
+ btn.dataset.tooltip = `Update: v${data.current} → v${data.latest} (${srcLabel})`;
34
+ }
35
+ if (!_btnWired) {
36
+ _btnWired = true;
37
+ btn.addEventListener('click', (e) => {
38
+ e.stopPropagation();
39
+ openUpdateModal();
40
+ });
41
+ }
42
+ }
43
+
44
+ function maybeShowUpdateAlert(data, { once = true } = {}) {
45
+ if (!data?.updateAvailable) return;
46
+ const key = `synabun-update-alert:${data.current}->${data.latest}:${sourceLabel(data)}`;
47
+ try {
48
+ if (once && sessionStorage.getItem(key) === '1') return;
49
+ sessionStorage.setItem(key, '1');
50
+ } catch {}
51
+
52
+ setTimeout(() => {
53
+ showUpdateToast({
54
+ updates: [{ label: `SynaBun v${data.current} → v${data.latest}`, source: sourceLabel(data) }],
55
+ errors: [],
56
+ onClick: () => openSynabunUpdateModal(),
57
+ });
58
+ }, 500);
59
+ }
10
60
 
11
61
  export async function initUpdate() {
12
62
  try {
13
63
  const res = await fetch('/api/system/version');
14
64
  if (!res.ok) return;
15
65
  _versionData = await res.json();
66
+ applyTopRightButton(_versionData);
67
+ maybeShowUpdateAlert(_versionData);
68
+
69
+ if (!_pollWired) {
70
+ _pollWired = true;
71
+ setInterval(() => {
72
+ forceCheckSynabunUpdate({ alert: true }).catch(() => {});
73
+ }, 30 * 60 * 1000);
74
+ }
75
+ } catch { /* silent — no update UI if check fails */ }
76
+ }
16
77
 
17
- if (!_versionData.updateAvailable) return;
18
-
19
- const btn = $('topright-update-btn');
20
- if (!btn) return;
21
-
22
- btn.style.display = '';
23
- const badge = $('update-badge');
24
- if (badge) badge.textContent = '!';
78
+ // Force a fresh server-side check (bypasses 1h cache via ?force=1) and
79
+ // re-applies the top-right button + badge state. Returns the latest
80
+ // _synabunUpdateCache shape so callers can compose toasts.
81
+ export async function forceCheckSynabunUpdate({ alert = false } = {}) {
82
+ try {
83
+ const res = await fetch('/api/system/version?force=1');
84
+ if (!res.ok) return null;
85
+ _versionData = await res.json();
86
+ applyTopRightButton(_versionData);
87
+ if (alert) maybeShowUpdateAlert(_versionData);
88
+ return _versionData;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
25
93
 
26
- btn.addEventListener('click', (e) => {
27
- e.stopPropagation();
28
- openUpdateModal();
29
- });
30
- } catch { /* silent — no update UI if check fails */ }
94
+ export function openSynabunUpdateModal() {
95
+ if (!_versionData?.updateAvailable) return;
96
+ openUpdateModal();
31
97
  }
32
98
 
33
99
  // ── Modal Wizard ──
@@ -59,16 +125,37 @@ function openUpdateModal() {
59
125
  }
60
126
 
61
127
  function renderStep1() {
128
+ const src = _versionData.source;
129
+ const channel = _versionData.installedChannel || 'stable';
130
+ const channelTag = channel === 'prerelease'
131
+ ? '<span class="update-channel-pill update-channel-pill--beta">beta</span>'
132
+ : '';
133
+
134
+ const srcCopy = src === 'both'
135
+ ? 'A new version of SynaBun is available on npm and GitHub.'
136
+ : src === 'github'
137
+ ? 'A new SynaBun release is tagged on GitHub. The npm publish may still be in progress.'
138
+ : 'A new version of SynaBun has been published to npm.';
139
+
140
+ const npmStable = _versionData.npmLatestStable
141
+ ? `npm latest v${esc(_versionData.npmLatestStable)}`
142
+ : 'npm latest —';
143
+ const npmBeta = _versionData.npmLatestBeta
144
+ ? ` · npm beta v${esc(_versionData.npmLatestBeta)}`
145
+ : '';
146
+ const gitBit = _versionData.gitLatest
147
+ ? ` · GitHub v${esc(_versionData.gitLatest)}`
148
+ : ' · GitHub —';
149
+
62
150
  return `
63
- <h3 class="update-modal-title">Update Available</h3>
151
+ <h3 class="update-modal-title">Update Available ${channelTag}</h3>
64
152
  <div class="update-version-diff">
65
153
  <span class="update-ver update-ver--old">v${esc(_versionData.current)}</span>
66
154
  <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>
67
155
  <span class="update-ver update-ver--new">v${esc(_versionData.latest)}</span>
68
156
  </div>
69
- <p style="font-size:12px;color:var(--t-muted);margin:12px 0 18px">
70
- A new version of SynaBun is available on npm.
71
- </p>
157
+ <p style="font-size:12px;color:var(--t-muted);margin:12px 0 6px">${srcCopy}</p>
158
+ <p style="font-size:11px;color:var(--t-muted);opacity:.75;margin:0 0 18px">${npmStable}${npmBeta}${gitBit}</p>
72
159
  <div class="tag-delete-modal-actions">
73
160
  <button class="action-btn action-btn--ghost" id="update-cancel">Cancel</button>
74
161
  <button class="action-btn action-btn--primary" id="update-next">Next</button>
@@ -100,30 +187,63 @@ function openUpdateModal() {
100
187
  }
101
188
 
102
189
  function renderStep3() {
190
+ const installPlan = _versionData.installPlan || {};
191
+ const canAutoUpdate = installPlan.canAutoUpdate === true;
192
+ const repoUrl = installPlan.openUrl || SYNABUN_GITHUB_URL;
193
+ const installCmd = canAutoUpdate
194
+ ? (installPlan.displayCommand || 'npm i -g synabun@latest')
195
+ : repoUrl;
196
+ const installIntro = canAutoUpdate
197
+ ? 'SynaBun will close and run this command in a new terminal window:'
198
+ : 'This SynaBun install was not detected as an npm install. Open the GitHub repository to update from your checkout or installer:';
199
+ const title = canAutoUpdate ? 'Run the Update' : 'Update from GitHub';
200
+ const installSource = canAutoUpdate
201
+ ? 'Installing from npm.'
202
+ : (installPlan.manualHint || 'Open the SynaBun repository for the correct update path.');
203
+ const autoRestartControl = canAutoUpdate ? `
204
+ <p style="font-size:11px;color:var(--t-muted);opacity:.75;margin:10px 0 0">
205
+ SynaBun will relaunch automatically after the update finishes.
206
+ </p>` : '';
207
+ const manualButton = canAutoUpdate ? `
208
+ <button class="action-btn action-btn--ghost" id="update-manual" title="Run the command yourself in your own terminal">Manual</button>` : '';
209
+ const updateNowButton = canAutoUpdate ? `
210
+ <button class="action-btn action-btn--primary" id="update-run-now">
211
+ <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><polyline points="8 17 12 21 16 17"/><line x1="12" y1="12" x2="12" y2="21"/><path d="M20.88 18.09A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.29"/></svg>
212
+ Update Now
213
+ </button>` : `
214
+ <button class="action-btn action-btn--primary" id="update-run-now" data-url="${esc(repoUrl)}">
215
+ <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>
216
+ Open GitHub
217
+ </button>`;
218
+
103
219
  return `
104
- <h3 class="update-modal-title">Run the Update</h3>
220
+ <h3 class="update-modal-title">${title}</h3>
105
221
  <p style="font-size:12px;color:var(--t-muted);margin:4px 0 12px">
106
- Run this command in your terminal to update SynaBun:
222
+ ${esc(installIntro)}
107
223
  </p>
108
224
  <div class="update-code-block">
109
- <code>npm install -g synabun@latest</code>
110
- <button class="update-copy-btn" data-copy="npm install -g synabun@latest" title="Copy">
225
+ <code>${esc(installCmd)}</code>
226
+ <button class="update-copy-btn" data-copy="${esc(installCmd)}" title="Copy">
111
227
  <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>
112
228
  </button>
113
229
  </div>
114
- <p style="font-size:12px;color:var(--t-muted);margin:12px 0 8px">
115
- Then restart the Neural Interface:
116
- </p>
117
- <div class="update-code-block">
118
- <code>node neural-interface/server.js</code>
119
- <button class="update-copy-btn" data-copy="node neural-interface/server.js" title="Copy">
120
- <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>
121
- </button>
230
+ <p style="font-size:11px;color:var(--t-muted);opacity:.75;margin:8px 0 0">${esc(installSource)}</p>
231
+ ${autoRestartControl}
232
+
233
+ <div class="update-run-status" id="update-run-status" style="display:none;margin:10px 0 0">
234
+ <span class="update-backup-icon" id="update-run-icon"></span>
235
+ <span id="update-run-text"></span>
122
236
  </div>
123
- <div class="tag-delete-modal-actions" style="margin-top:18px">
237
+
238
+ <div class="tag-delete-modal-actions" style="margin-top:14px;flex-wrap:wrap;gap:8px">
124
239
  <button class="action-btn action-btn--ghost" id="update-back">Back</button>
125
- <button class="action-btn action-btn--primary" id="update-done">Done</button>
126
- </div>`;
240
+ ${manualButton}
241
+ ${updateNowButton}
242
+ </div>
243
+
244
+ <p id="update-manual-note" style="display:none;font-size:11px;color:var(--t-muted);opacity:.75;margin:10px 0 0">
245
+ After running the command, restart SynaBun with <code>synabun</code>.
246
+ </p>`;
127
247
  }
128
248
 
129
249
  function bindEvents() {
@@ -164,6 +284,16 @@ function openUpdateModal() {
164
284
  // Backup download
165
285
  overlay.querySelector('#update-backup-download')?.addEventListener('click', handleBackupDownload);
166
286
 
287
+ // Update Now — kick off click-to-update flow
288
+ overlay.querySelector('#update-run-now')?.addEventListener('click', handleRunUpdate);
289
+
290
+ // Manual — surface the command + relaunch hint, hide auto-update controls
291
+ overlay.querySelector('#update-manual')?.addEventListener('click', () => {
292
+ overlay.querySelector('#update-manual-note').style.display = '';
293
+ const runNow = overlay.querySelector('#update-run-now');
294
+ if (runNow) runNow.style.display = 'none';
295
+ });
296
+
167
297
  // Skip checkbox
168
298
  const skipCb = overlay.querySelector('#update-skip-backup');
169
299
  if (skipCb) {
@@ -175,6 +305,78 @@ function openUpdateModal() {
175
305
  }
176
306
  }
177
307
 
308
+ async function handleRunUpdate() {
309
+ const installPlan = _versionData?.installPlan || {};
310
+ const canAutoUpdate = installPlan.canAutoUpdate === true;
311
+ const repoUrl = installPlan.openUrl || SYNABUN_GITHUB_URL;
312
+
313
+ // Auto-restart is always on; server forces it true regardless of payload.
314
+ const autoRestart = true;
315
+
316
+ const runBtn = overlay.querySelector('#update-run-now');
317
+ const backBtn = overlay.querySelector('#update-back');
318
+ const manualBtn = overlay.querySelector('#update-manual');
319
+ const status = overlay.querySelector('#update-run-status');
320
+ const icon = overlay.querySelector('#update-run-icon');
321
+ const text = overlay.querySelector('#update-run-text');
322
+
323
+ if (!runBtn || !backBtn || !status || !icon || !text) return;
324
+
325
+ if (!canAutoUpdate) {
326
+ window.open(repoUrl, '_blank', 'noopener,noreferrer');
327
+ status.style.display = 'flex';
328
+ icon.className = 'update-backup-icon';
329
+ icon.textContent = '↗';
330
+ icon.style.color = '#4fc3f7';
331
+ text.innerHTML = `<strong>GitHub repository opened</strong><span class="update-run-detail">Use the repository instructions to update this non-npm installation.</span>`;
332
+ return;
333
+ }
334
+
335
+ runBtn.disabled = true;
336
+ backBtn.disabled = true;
337
+ if (manualBtn) manualBtn.disabled = true;
338
+ runBtn.innerHTML = '<span class="update-spinner"></span> Launching updater...';
339
+ status.style.display = 'flex';
340
+ icon.className = 'update-backup-icon spin';
341
+ icon.textContent = '⟳';
342
+ icon.style.color = '';
343
+ text.innerHTML = '<strong>Launching updater</strong><span class="update-run-detail">Opening a terminal window for the npm update.</span>';
344
+
345
+ try {
346
+ const res = await fetch('/api/system/run-update', {
347
+ method: 'POST',
348
+ headers: { 'Content-Type': 'application/json' },
349
+ body: JSON.stringify({ autoRestart }),
350
+ });
351
+ if (!res.ok) {
352
+ let msg = `Updater failed (${res.status})`;
353
+ try { const b = await res.json(); msg = b.error || msg; } catch {}
354
+ throw new Error(msg);
355
+ }
356
+ await res.json();
357
+
358
+ icon.className = 'update-backup-icon';
359
+ icon.textContent = '✓';
360
+ icon.style.color = '#34c759';
361
+ text.innerHTML = `<strong>Updater terminal launched</strong><span class="update-run-detail">SynaBun is shutting down so npm can replace the package. Watch the terminal for install progress; SynaBun will relaunch automatically when it finishes.</span><span class="update-run-hint">If no terminal window appears, run <code>${esc(installPlan.displayCommand || 'npm i -g synabun@latest')}</code> manually.</span>`;
362
+ runBtn.innerHTML = 'Updater running…';
363
+
364
+ // Server is shutting down. Optionally redirect to a friendly offline
365
+ // notice — for now leave the page; user already understands from copy.
366
+ // Re-enable Back so they can close the modal.
367
+ setTimeout(() => { backBtn.disabled = false; }, 2500);
368
+ } catch (err) {
369
+ icon.className = 'update-backup-icon';
370
+ icon.textContent = '✗';
371
+ icon.style.color = 'var(--accent-red)';
372
+ text.textContent = 'Failed to launch updater: ' + err.message;
373
+ runBtn.disabled = false;
374
+ backBtn.disabled = false;
375
+ if (manualBtn) manualBtn.disabled = false;
376
+ runBtn.innerHTML = `<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:6px"><polyline points="8 17 12 21 16 17"/><line x1="12" y1="12" x2="12" y2="21"/><path d="M20.88 18.09A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.29"/></svg> Retry`;
377
+ }
378
+ }
379
+
178
380
  async function handleBackupDownload() {
179
381
  const btn = overlay.querySelector('#update-backup-download');
180
382
  const status = overlay.querySelector('#update-backup-status');