Skip to content

Commit 60e94e3

Browse files
authored
feat: add update notification system
Adds automatic update checking that notifies users when a new version is available on npm, with install-method-aware update commands. - Zero-dependency npm registry check with 24h disk cache - CLI startup banner with non-blocking async check - API endpoint GET /api/update-check with auth + rate limiting - Web UI dismissable update banner on session manager page - Check for updates button with Copy in About modal - 39 tests with deterministic mocking - Security hardening (sanitizeVersion, response size limit, bounded regex) - Install method detection (npm/npx/yarn/pnpm)
1 parent 2f6ced0 commit 60e94e3

8 files changed

Lines changed: 868 additions & 6 deletions

File tree

.github/copilot-instructions.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
## Build, Test, and Lint
44

55
```bash
6-
npm test # run all tests
6+
npm test # run all tests (output buffered until done)
7+
node --test test/*.test.js # run all tests with streaming output (preferred for dev/CI agents)
78
node --test test/auth.test.js # run a single test file
89
npm run test:coverage # tests + coverage (c8, 92% threshold)
910
npm run lint # syntax-check with node --check
@@ -12,11 +13,13 @@ npm run dev # start with auto-generated password
1213
npm start # start with defaults
1314
```
1415

16+
> **Agent note:** Prefer `node --test test/*.test.js` over `npm test` when you need streaming output. The `npm test` script wraps `node --test` in `execFileSync` which buffers all output until completion — this makes it look like tests are hanging when they're actually running fine. The direct command gives real-time feedback.
17+
1518
Pre-commit hooks (Husky + lint-staged) auto-format and syntax-check staged files.
1619

1720
### Testing Best Practices
1821

19-
**Suite overview:** 464 tests, ~17s total. Tests run in parallel child processes via Node's built-in test runner. Most files run in <1s; `integration.test.js` (~17s) and `service.test.js` (~9s) are the slow outliers.
22+
**Suite overview:** 530+ tests, ~17s total. Tests run in parallel child processes via Node's built-in test runner. Most files run in <1s; `integration.test.js` (~17s) and `service.test.js` (~9s) are the slow outliers.
2023

2124
**Slow tests and why:**
2225

docs/api.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,33 @@ Get the server version.
273273
{ "version": "1.0.0" }
274274
```
275275

276+
#### `GET /api/update-check`
277+
278+
Check if a newer version of TermBeam is available on npm. Results are cached for 24 hours.
279+
Requires authentication (session cookie or `Authorization: Bearer <password>`) when authentication is enabled. If the server is started with `--no-password`, this endpoint is accessible without authentication.
280+
281+
**Query parameters:**
282+
283+
| Parameter | Type | Description |
284+
| --------- | ------- | --------------------------------- |
285+
| `force` | boolean | Bypass cache and fetch fresh data |
286+
287+
**Response:**
288+
289+
```json
290+
{
291+
"current": "1.10.2",
292+
"latest": "1.11.0",
293+
"updateAvailable": true,
294+
"method": "npm",
295+
"command": "npm install -g termbeam@latest"
296+
}
297+
```
298+
299+
The `method` field indicates how TermBeam was installed (`npm`, `npx`, `yarn`, or `pnpm`) and `command` provides the appropriate update command.
300+
301+
When no update is available or the check fails, `updateAvailable` is `false` and `latest` may be `null`.
302+
276303
#### `GET /api/dirs?q=/path`
277304

278305
List subdirectories for the folder browser.

public/index.html

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,45 @@
197197
border: 1px solid rgba(128, 128, 128, 0.3);
198198
}
199199

200+
.update-banner {
201+
margin: 12px 16px 0;
202+
padding: 10px 14px;
203+
background: var(--surface);
204+
border: 1px solid var(--accent);
205+
border-radius: 10px;
206+
display: none;
207+
align-items: center;
208+
gap: 10px;
209+
font-size: 13px;
210+
color: var(--text);
211+
}
212+
.update-banner.visible {
213+
display: flex;
214+
}
215+
.update-banner-text {
216+
flex: 1;
217+
}
218+
.update-banner-text code {
219+
font-size: 12px;
220+
background: var(--border);
221+
padding: 2px 6px;
222+
border-radius: 4px;
223+
word-break: break-all;
224+
}
225+
.update-banner-dismiss {
226+
background: none;
227+
border: none;
228+
color: var(--text-dim);
229+
cursor: pointer;
230+
font-size: 18px;
231+
line-height: 1;
232+
padding: 0 2px;
233+
flex-shrink: 0;
234+
}
235+
.update-banner-dismiss:hover {
236+
color: var(--text);
237+
}
238+
200239
.sessions-list {
201240
padding: 16px;
202241
padding-bottom: calc(80px + env(safe-area-inset-bottom, 0px));
@@ -730,7 +769,8 @@
730769
<div class="header">
731770
<h1>📡 Term<span>Beam</span></h1>
732771
<p>
733-
Beam your terminal to any device · <span id="version" style="color: var(--accent)"></span>
772+
Beam your terminal to any device ·
773+
<span id="version" style="color: var(--accent)"></span>
734774
</p>
735775
<button class="header-btn" id="share-btn" style="right: 96px; top: 16px" title="Share link">
736776
<svg
@@ -833,6 +873,23 @@ <h1>📡 Term<span>Beam</span></h1>
833873
</div>
834874
</div>
835875

876+
<div class="update-banner" id="update-banner">
877+
<div class="update-banner-text">
878+
<strong>Update available:</strong> <span id="update-versions"></span><br />
879+
<span id="update-command-text"
880+
>Run: <code id="update-command">npm install -g termbeam@latest</code></span
881+
>
882+
</div>
883+
<button
884+
class="update-banner-dismiss"
885+
id="update-dismiss"
886+
title="Dismiss"
887+
aria-label="Dismiss update notification"
888+
>
889+
&times;
890+
</button>
891+
</div>
892+
836893
<div class="sessions-list" id="sessions-list"></div>
837894
<button class="new-session" id="new-session-btn">+ New Session</button>
838895

@@ -981,6 +1038,36 @@ <h3>
9811038
const listEl = document.getElementById('sessions-list');
9821039
const modal = document.getElementById('modal');
9831040

1041+
// Update notification
1042+
(async function checkUpdate() {
1043+
if (sessionStorage.getItem('update-dismissed')) return;
1044+
try {
1045+
const res = await fetch('/api/update-check');
1046+
if (!res.ok) return;
1047+
const info = await res.json();
1048+
if (info.updateAvailable && info.latest) {
1049+
const banner = document.getElementById('update-banner');
1050+
document.getElementById('update-versions').textContent =
1051+
'v' + info.current + ' \u2192 v' + info.latest;
1052+
if (info.command) {
1053+
const cmdEl = document.getElementById('update-command');
1054+
cmdEl.textContent = info.command;
1055+
if (info.method === 'npx') {
1056+
document.getElementById('update-command-text').textContent = 'Next time, run: ';
1057+
document.getElementById('update-command-text').appendChild(cmdEl);
1058+
}
1059+
}
1060+
banner.classList.add('visible');
1061+
}
1062+
} catch {
1063+
// Silent — update check is non-critical
1064+
}
1065+
})();
1066+
document.getElementById('update-dismiss').addEventListener('click', () => {
1067+
document.getElementById('update-banner').classList.remove('visible');
1068+
sessionStorage.setItem('update-dismissed', '1');
1069+
});
1070+
9841071
function getActivityLabel(ts) {
9851072
if (!ts) return '';
9861073
const diff = (Date.now() - ts) / 1000;

public/terminal.html

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4545,15 +4545,82 @@ <h3>
45454545
box.innerHTML =
45464546
'<div style="font-size:24px;margin-bottom:8px;">⚡</div>' +
45474547
'<div style="font-size:16px;font-weight:600;color:var(--text);margin-bottom:4px;">TermBeam</div>' +
4548-
'<div style="font-size:13px;color:var(--text-secondary);margin-bottom:16px;">' +
4548+
'<div style="font-size:13px;color:var(--text-secondary);margin-bottom:12px;">' +
45494549
esc(ver) +
45504550
'</div>' +
45514551
'<div style="font-size:12px;color:var(--text-secondary);margin-bottom:16px;">Terminal in your browser, optimized for mobile.</div>' +
45524552
'<div style="display:flex;gap:16px;justify-content:center;margin-bottom:16px;">' +
45534553
'<a href="https://github.com/dorlugasigal/TermBeam" target="_blank" rel="noopener" style="color:var(--accent);font-size:12px;text-decoration:none;">GitHub</a>' +
45544554
'<a href="https://dorlugasigal.github.io/TermBeam/" target="_blank" rel="noopener" style="color:var(--accent);font-size:12px;text-decoration:none;">Docs</a>' +
45554555
'<a href="https://termbeam.pages.dev" target="_blank" rel="noopener" style="color:var(--accent);font-size:12px;text-decoration:none;">Website</a>' +
4556-
'</div>';
4556+
'</div>' +
4557+
'<div id="about-update-area" style="margin-bottom:12px;"></div>';
4558+
const updateArea = box.querySelector('#about-update-area');
4559+
const updateBtn = document.createElement('button');
4560+
updateBtn.textContent = 'Check for updates';
4561+
updateBtn.style.cssText =
4562+
'padding:6px 16px;border-radius:6px;border:1px solid var(--border);background:transparent;color:var(--text-secondary);font-size:12px;cursor:pointer;';
4563+
updateBtn.onclick = async () => {
4564+
updateBtn.textContent = 'Checking...';
4565+
updateBtn.disabled = true;
4566+
updateBtn.style.cursor = 'default';
4567+
try {
4568+
const res = await fetch('/api/update-check?force=true');
4569+
if (!res.ok) throw new Error();
4570+
const info = await res.json();
4571+
if (info.updateAvailable && info.latest) {
4572+
const cmd = info.command || 'npm install -g termbeam@latest';
4573+
updateArea.innerHTML = '';
4574+
const status = document.createElement('div');
4575+
status.style.cssText = 'font-size:12px;color:var(--accent);margin-bottom:8px;';
4576+
status.textContent = 'v' + info.latest + ' available';
4577+
updateArea.appendChild(status);
4578+
const cmdRow = document.createElement('div');
4579+
cmdRow.style.cssText =
4580+
'display:flex;align-items:center;justify-content:center;gap:6px;';
4581+
const cmdText = document.createElement('code');
4582+
cmdText.textContent = cmd;
4583+
cmdText.style.cssText =
4584+
'font-size:11px;color:var(--accent);background:var(--bg);padding:4px 8px;border-radius:4px;border:1px solid var(--border);';
4585+
const copyBtn = document.createElement('button');
4586+
copyBtn.textContent = 'Copy';
4587+
copyBtn.style.cssText =
4588+
'padding:4px 10px;border-radius:4px;border:1px solid var(--accent);background:transparent;color:var(--accent);font-size:11px;cursor:pointer;';
4589+
copyBtn.onclick = () => {
4590+
const onSuccess = () => {
4591+
copyBtn.textContent = 'Copied!';
4592+
setTimeout(() => {
4593+
copyBtn.textContent = 'Copy';
4594+
}, 2000);
4595+
};
4596+
if (navigator.clipboard && navigator.clipboard.writeText) {
4597+
navigator.clipboard
4598+
.writeText(cmd)
4599+
.then(onSuccess)
4600+
.catch(() => {
4601+
copyFallback(cmd);
4602+
onSuccess();
4603+
});
4604+
} else {
4605+
copyFallback(cmd);
4606+
onSuccess();
4607+
}
4608+
};
4609+
cmdRow.appendChild(cmdText);
4610+
cmdRow.appendChild(copyBtn);
4611+
updateArea.appendChild(cmdRow);
4612+
} else {
4613+
updateBtn.textContent = 'Up to date';
4614+
updateBtn.style.color = '#4ec9b0';
4615+
updateBtn.style.borderColor = '#4ec9b0';
4616+
}
4617+
} catch {
4618+
updateBtn.textContent = 'Check failed — try again';
4619+
updateBtn.disabled = false;
4620+
updateBtn.style.cursor = 'pointer';
4621+
}
4622+
};
4623+
updateArea.appendChild(updateBtn);
45574624
const btn = document.createElement('button');
45584625
btn.textContent = 'Close';
45594626
btn.style.cssText =

src/routes.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,29 @@ function setupRoutes(app, { auth, sessions, config, state }) {
8787
res.json({ version: getVersion() });
8888
});
8989

90+
// Update check API
91+
app.get('/api/update-check', apiRateLimit, auth.middleware, async (req, res) => {
92+
const { checkForUpdate, detectInstallMethod } = require('./update-check');
93+
const force = req.query.force === 'true';
94+
95+
try {
96+
const info = await checkForUpdate({ currentVersion: config.version, force });
97+
const installInfo = detectInstallMethod();
98+
state.updateInfo = { ...info, ...installInfo };
99+
res.json(state.updateInfo);
100+
} catch {
101+
const installInfo = detectInstallMethod();
102+
const fallback = {
103+
current: config.version,
104+
latest: null,
105+
updateAvailable: false,
106+
...installInfo,
107+
};
108+
state.updateInfo = fallback;
109+
res.json(fallback);
110+
}
111+
});
112+
90113
// Share token auto-login middleware: validates ?ott= param, sets session cookie, redirects to clean URL
91114
function autoLogin(req, res, next) {
92115
const { ott } = req.query;

src/server.js

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const { setupWebSocket } = require('./websocket');
1616
const { startTunnel, cleanupTunnel, findDevtunnel } = require('./tunnel');
1717
const { createPreviewProxy } = require('./preview');
1818
const { writeConnectionConfig, removeConnectionConfig } = require('./resume');
19+
const { checkForUpdate, detectInstallMethod } = require('./update-check');
1920

2021
// --- Helpers ---
2122
function getLocalIP() {
@@ -73,7 +74,7 @@ function createTermBeamServer(overrides = {}) {
7374
const server = http.createServer(app);
7475
const wss = new WebSocketServer({ server, path: '/ws', maxPayload: 1 * 1024 * 1024 });
7576

76-
const state = { shareBaseUrl: null };
77+
const state = { shareBaseUrl: null, updateInfo: null };
7778
app.use('/preview', auth.middleware, createPreviewProxy());
7879
setupRoutes(app, { auth, sessions, config, state });
7980
setupWebSocket(wss, { auth, sessions });
@@ -273,6 +274,36 @@ function createTermBeamServer(overrides = {}) {
273274
);
274275
console.log('');
275276

277+
// Non-blocking update check — runs after banner, never delays startup.
278+
// Skip under the Node test runner to avoid network requests in tests.
279+
// Accept any version containing a semver-like pattern (including dev builds).
280+
const versionParts = config.version.match(/(\d{1,10})\.(\d{1,10})\.(\d{1,10})/);
281+
if (versionParts && !process.env.NODE_TEST_CONTEXT && !process.argv.includes('--test')) {
282+
const installInfo = detectInstallMethod();
283+
checkForUpdate({ currentVersion: config.version })
284+
.then((info) => {
285+
state.updateInfo = { ...info, ...installInfo };
286+
if (info.updateAvailable) {
287+
const yl = '\x1b[33m';
288+
const gn2 = '\x1b[38;5;114m';
289+
const dm = '\x1b[2m';
290+
console.log('');
291+
console.log(
292+
` ${yl}Update available:${rs} ${dm}${info.current}${rs}${gn2}${info.latest}${rs}`,
293+
);
294+
if (installInfo.method === 'npx') {
295+
console.log(` Next time, run: ${gn2}npx termbeam@latest${rs}`);
296+
} else {
297+
console.log(` Run: ${gn2}${installInfo.command}${rs}`);
298+
}
299+
console.log('');
300+
}
301+
})
302+
.catch(() => {
303+
// Silent failure — update check is non-critical
304+
});
305+
}
306+
276307
resolve({ url: `http://localhost:${actualPort}`, defaultId });
277308
});
278309
});

0 commit comments

Comments
 (0)