termbeam 1.10.2 โ 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/index.html +88 -1
- package/public/terminal.html +78 -19
- package/src/routes.js +23 -0
- package/src/server.js +32 -1
- package/src/update-check.js +240 -0
package/package.json
CHANGED
package/public/index.html
CHANGED
|
@@ -197,6 +197,45 @@
|
|
|
197
197
|
border: 1px solid rgba(128, 128, 128, 0.3);
|
|
198
198
|
}
|
|
199
199
|
|
|
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
|
+
|
|
200
239
|
.sessions-list {
|
|
201
240
|
padding: 16px;
|
|
202
241
|
padding-bottom: calc(80px + env(safe-area-inset-bottom, 0px));
|
|
@@ -730,7 +769,8 @@
|
|
|
730
769
|
<div class="header">
|
|
731
770
|
<h1>๐ก Term<span>Beam</span></h1>
|
|
732
771
|
<p>
|
|
733
|
-
Beam your terminal to any device ยท
|
|
772
|
+
Beam your terminal to any device ยท
|
|
773
|
+
<span id="version" style="color: var(--accent)"></span>
|
|
734
774
|
</p>
|
|
735
775
|
<button class="header-btn" id="share-btn" style="right: 96px; top: 16px" title="Share link">
|
|
736
776
|
<svg
|
|
@@ -833,6 +873,23 @@
|
|
|
833
873
|
</div>
|
|
834
874
|
</div>
|
|
835
875
|
|
|
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
|
+
×
|
|
890
|
+
</button>
|
|
891
|
+
</div>
|
|
892
|
+
|
|
836
893
|
<div class="sessions-list" id="sessions-list"></div>
|
|
837
894
|
<button class="new-session" id="new-session-btn">+ New Session</button>
|
|
838
895
|
|
|
@@ -981,6 +1038,36 @@
|
|
|
981
1038
|
const listEl = document.getElementById('sessions-list');
|
|
982
1039
|
const modal = document.getElementById('modal');
|
|
983
1040
|
|
|
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
|
+
|
|
984
1071
|
function getActivityLabel(ts) {
|
|
985
1072
|
if (!ts) return '';
|
|
986
1073
|
const diff = (Date.now() - ts) / 1000;
|
package/public/terminal.html
CHANGED
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
[data-theme='night-owl'] { --key-bg: #1d3b53; --key-border: #264863; --key-shadow: rgba(0, 0, 0, 0.4); --key-special-bg: #0d2a45; }
|
|
43
43
|
@font-face {
|
|
44
44
|
font-family: 'NerdFont';
|
|
45
|
-
src: url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@
|
|
45
|
+
src: url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@v3.4.0/patched-fonts/JetBrainsMono/Ligatures/Regular/JetBrainsMonoNerdFont-Regular.ttf')
|
|
46
46
|
format('truetype');
|
|
47
47
|
font-weight: normal;
|
|
48
48
|
font-style: normal;
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
}
|
|
51
51
|
@font-face {
|
|
52
52
|
font-family: 'NerdFont';
|
|
53
|
-
src: url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@
|
|
53
|
+
src: url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@v3.4.0/patched-fonts/JetBrainsMono/Ligatures/Bold/JetBrainsMonoNerdFont-Bold.ttf')
|
|
54
54
|
format('truetype');
|
|
55
55
|
font-weight: bold;
|
|
56
56
|
font-style: normal;
|
|
@@ -2378,7 +2378,7 @@
|
|
|
2378
2378
|
// ===== Font Loading (non-blocking) =====
|
|
2379
2379
|
const nerdFont = new FontFace(
|
|
2380
2380
|
'NerdFont',
|
|
2381
|
-
"url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@
|
|
2381
|
+
"url('https://cdn.jsdelivr.net/gh/ryanoasis/nerd-fonts@v3.4.0/patched-fonts/JetBrainsMono/Ligatures/Regular/JetBrainsMonoNerdFont-Regular.ttf')",
|
|
2382
2382
|
);
|
|
2383
2383
|
nerdFont
|
|
2384
2384
|
.load()
|
|
@@ -2497,9 +2497,8 @@
|
|
|
2497
2497
|
|
|
2498
2498
|
if (startId) activateSession(startId);
|
|
2499
2499
|
else {
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
document.getElementById('stop-btn').style.display = 'none';
|
|
2500
|
+
window.location.replace('/');
|
|
2501
|
+
return;
|
|
2503
2502
|
}
|
|
2504
2503
|
|
|
2505
2504
|
renderTabs();
|
|
@@ -3139,11 +3138,8 @@
|
|
|
3139
3138
|
const remaining = [...managed.keys()];
|
|
3140
3139
|
if (remaining.length > 0) activateSession(remaining[0]);
|
|
3141
3140
|
else {
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
statusText.textContent = '';
|
|
3145
|
-
statusDot.className = '';
|
|
3146
|
-
document.getElementById('stop-btn').style.display = 'none';
|
|
3141
|
+
window.location.replace('/');
|
|
3142
|
+
return;
|
|
3147
3143
|
}
|
|
3148
3144
|
}
|
|
3149
3145
|
renderTabs();
|
|
@@ -4232,12 +4228,8 @@
|
|
|
4232
4228
|
const remaining = [...managed.keys()];
|
|
4233
4229
|
if (remaining.length > 0) activateSession(remaining[0]);
|
|
4234
4230
|
else {
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
statusText.textContent = '';
|
|
4238
|
-
statusDot.className = '';
|
|
4239
|
-
document.getElementById('stop-btn').style.display = 'none';
|
|
4240
|
-
reconnectOverlay.classList.remove('visible');
|
|
4231
|
+
window.location.replace('/');
|
|
4232
|
+
return;
|
|
4241
4233
|
}
|
|
4242
4234
|
}
|
|
4243
4235
|
}
|
|
@@ -4553,7 +4545,7 @@
|
|
|
4553
4545
|
box.innerHTML =
|
|
4554
4546
|
'<div style="font-size:24px;margin-bottom:8px;">โก</div>' +
|
|
4555
4547
|
'<div style="font-size:16px;font-weight:600;color:var(--text);margin-bottom:4px;">TermBeam</div>' +
|
|
4556
|
-
'<div style="font-size:13px;color:var(--text-secondary);margin-bottom:
|
|
4548
|
+
'<div style="font-size:13px;color:var(--text-secondary);margin-bottom:12px;">' +
|
|
4557
4549
|
esc(ver) +
|
|
4558
4550
|
'</div>' +
|
|
4559
4551
|
'<div style="font-size:12px;color:var(--text-secondary);margin-bottom:16px;">Terminal in your browser, optimized for mobile.</div>' +
|
|
@@ -4561,7 +4553,74 @@
|
|
|
4561
4553
|
'<a href="https://github.com/dorlugasigal/TermBeam" target="_blank" rel="noopener" style="color:var(--accent);font-size:12px;text-decoration:none;">GitHub</a>' +
|
|
4562
4554
|
'<a href="https://dorlugasigal.github.io/TermBeam/" target="_blank" rel="noopener" style="color:var(--accent);font-size:12px;text-decoration:none;">Docs</a>' +
|
|
4563
4555
|
'<a href="https://termbeam.pages.dev" target="_blank" rel="noopener" style="color:var(--accent);font-size:12px;text-decoration:none;">Website</a>' +
|
|
4564
|
-
'</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);
|
|
4565
4624
|
const btn = document.createElement('button');
|
|
4566
4625
|
btn.textContent = 'Close';
|
|
4567
4626
|
btn.style.cssText =
|
package/src/routes.js
CHANGED
|
@@ -87,6 +87,29 @@ function setupRoutes(app, { auth, sessions, config, state }) {
|
|
|
87
87
|
res.json({ version: getVersion() });
|
|
88
88
|
});
|
|
89
89
|
|
|
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
|
+
|
|
90
113
|
// Share token auto-login middleware: validates ?ott= param, sets session cookie, redirects to clean URL
|
|
91
114
|
function autoLogin(req, res, next) {
|
|
92
115
|
const { ott } = req.query;
|
package/src/server.js
CHANGED
|
@@ -16,6 +16,7 @@ const { setupWebSocket } = require('./websocket');
|
|
|
16
16
|
const { startTunnel, cleanupTunnel, findDevtunnel } = require('./tunnel');
|
|
17
17
|
const { createPreviewProxy } = require('./preview');
|
|
18
18
|
const { writeConnectionConfig, removeConnectionConfig } = require('./resume');
|
|
19
|
+
const { checkForUpdate, detectInstallMethod } = require('./update-check');
|
|
19
20
|
|
|
20
21
|
// --- Helpers ---
|
|
21
22
|
function getLocalIP() {
|
|
@@ -73,7 +74,7 @@ function createTermBeamServer(overrides = {}) {
|
|
|
73
74
|
const server = http.createServer(app);
|
|
74
75
|
const wss = new WebSocketServer({ server, path: '/ws', maxPayload: 1 * 1024 * 1024 });
|
|
75
76
|
|
|
76
|
-
const state = { shareBaseUrl: null };
|
|
77
|
+
const state = { shareBaseUrl: null, updateInfo: null };
|
|
77
78
|
app.use('/preview', auth.middleware, createPreviewProxy());
|
|
78
79
|
setupRoutes(app, { auth, sessions, config, state });
|
|
79
80
|
setupWebSocket(wss, { auth, sessions });
|
|
@@ -273,6 +274,36 @@ function createTermBeamServer(overrides = {}) {
|
|
|
273
274
|
);
|
|
274
275
|
console.log('');
|
|
275
276
|
|
|
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
|
+
|
|
276
307
|
resolve({ url: `http://localhost:${actualPort}`, defaultId });
|
|
277
308
|
});
|
|
278
309
|
});
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
const https = require('https');
|
|
2
|
+
const http = require('http');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
|
|
7
|
+
const PACKAGE_NAME = 'termbeam';
|
|
8
|
+
const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
9
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
10
|
+
const REQUEST_TIMEOUT_MS = 5000;
|
|
11
|
+
const MAX_RESPONSE_SIZE = 100 * 1024; // 100 KB โ real npm responses are ~3-4 KB
|
|
12
|
+
|
|
13
|
+
function getCacheFilePath() {
|
|
14
|
+
const configDir = process.env.TERMBEAM_CONFIG_DIR || path.join(os.homedir(), '.termbeam');
|
|
15
|
+
return path.join(configDir, 'update-check.json');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function readCache() {
|
|
19
|
+
try {
|
|
20
|
+
const data = JSON.parse(fs.readFileSync(getCacheFilePath(), 'utf8'));
|
|
21
|
+
if (data && typeof data.latest === 'string' && typeof data.checkedAt === 'number') {
|
|
22
|
+
return data;
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
// Cache missing or corrupt โ will re-fetch
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function writeCache(latest) {
|
|
31
|
+
try {
|
|
32
|
+
const cacheFile = getCacheFilePath();
|
|
33
|
+
fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
|
|
34
|
+
fs.writeFileSync(cacheFile, JSON.stringify({ latest, checkedAt: Date.now() }) + '\n', {
|
|
35
|
+
mode: 0o600,
|
|
36
|
+
});
|
|
37
|
+
} catch {
|
|
38
|
+
// Non-critical โ next check will just re-fetch
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Normalize a version string into a [major, minor, patch] numeric tuple.
|
|
44
|
+
* Strips leading "v", drops prerelease/build metadata.
|
|
45
|
+
* Returns null if the version cannot be safely parsed.
|
|
46
|
+
*/
|
|
47
|
+
function normalizeVersion(version) {
|
|
48
|
+
if (typeof version !== 'string') return null;
|
|
49
|
+
let v = version.trim();
|
|
50
|
+
if (!v) return null;
|
|
51
|
+
if (v[0] === 'v' || v[0] === 'V') v = v.slice(1);
|
|
52
|
+
|
|
53
|
+
// Drop build metadata (+foo) and prerelease tags (-beta.1)
|
|
54
|
+
const plusIdx = v.indexOf('+');
|
|
55
|
+
if (plusIdx !== -1) v = v.slice(0, plusIdx);
|
|
56
|
+
const dashIdx = v.indexOf('-');
|
|
57
|
+
if (dashIdx !== -1) v = v.slice(0, dashIdx);
|
|
58
|
+
if (!v) return null;
|
|
59
|
+
|
|
60
|
+
const parts = v.split('.');
|
|
61
|
+
if (parts.length === 0 || parts.length > 3) return null;
|
|
62
|
+
|
|
63
|
+
const nums = [];
|
|
64
|
+
for (const part of parts) {
|
|
65
|
+
if (!/^\d+$/.test(part)) return null;
|
|
66
|
+
nums.push(Number(part));
|
|
67
|
+
}
|
|
68
|
+
while (nums.length < 3) nums.push(0);
|
|
69
|
+
return nums;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Compare two semver version strings (e.g. "1.10.2" vs "1.11.0").
|
|
74
|
+
* Returns true if `latest` is newer than `current`.
|
|
75
|
+
* Returns false if either version cannot be parsed.
|
|
76
|
+
*/
|
|
77
|
+
function isNewerVersion(current, latest) {
|
|
78
|
+
const cur = normalizeVersion(current);
|
|
79
|
+
const lat = normalizeVersion(latest);
|
|
80
|
+
if (!cur || !lat) return false;
|
|
81
|
+
for (let i = 0; i < 3; i++) {
|
|
82
|
+
if (lat[i] > cur[i]) return true;
|
|
83
|
+
if (lat[i] < cur[i]) return false;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Strip ANSI escape sequences and control characters from a string.
|
|
90
|
+
* Prevents terminal injection if the registry returns malicious data.
|
|
91
|
+
*/
|
|
92
|
+
function sanitizeVersion(v) {
|
|
93
|
+
if (typeof v !== 'string') return '';
|
|
94
|
+
return (
|
|
95
|
+
v
|
|
96
|
+
// CSI sequences: ESC [ ... command
|
|
97
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
|
|
98
|
+
// OSC sequences: ESC ] ... BEL or ESC ] ... ESC \
|
|
99
|
+
.replace(/\x1b\][^\x1b\x07]*(?:\x07|\x1b\\)/g, '')
|
|
100
|
+
// DCS, SOS, PM, APC: ESC P/X/^/_ ... ESC \
|
|
101
|
+
.replace(/\x1b[PX^_][\s\S]*?\x1b\\/g, '')
|
|
102
|
+
// Single-character ESC sequences
|
|
103
|
+
.replace(/\x1b[@-Z\\-_]/g, '')
|
|
104
|
+
// Remaining C0 and C1 control characters
|
|
105
|
+
.replace(/[\x00-\x1f\x7f-\x9f]/g, '')
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Fetch the latest version from the npm registry.
|
|
111
|
+
* Returns the version string or null on failure.
|
|
112
|
+
* @param {string} [registryUrl] - Override the registry URL (for testing).
|
|
113
|
+
*/
|
|
114
|
+
function fetchLatestVersion(registryUrl) {
|
|
115
|
+
const url = registryUrl || REGISTRY_URL;
|
|
116
|
+
const client = url.startsWith('https') ? https : http;
|
|
117
|
+
return new Promise((resolve) => {
|
|
118
|
+
const req = client.get(url, { timeout: REQUEST_TIMEOUT_MS }, (res) => {
|
|
119
|
+
if (res.statusCode !== 200) {
|
|
120
|
+
res.resume();
|
|
121
|
+
resolve(null);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
let body = '';
|
|
125
|
+
let aborted = false;
|
|
126
|
+
res.setEncoding('utf8');
|
|
127
|
+
res.on('data', (chunk) => {
|
|
128
|
+
if (aborted) return;
|
|
129
|
+
body += chunk;
|
|
130
|
+
if (body.length > MAX_RESPONSE_SIZE) {
|
|
131
|
+
aborted = true;
|
|
132
|
+
req.destroy();
|
|
133
|
+
resolve(null);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
res.on('end', () => {
|
|
137
|
+
if (aborted) return;
|
|
138
|
+
try {
|
|
139
|
+
const data = JSON.parse(body);
|
|
140
|
+
const version = data.version;
|
|
141
|
+
if (!version || typeof version !== 'string') {
|
|
142
|
+
resolve(null);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (!/^\d+\.\d+\.\d+$/.test(version)) {
|
|
146
|
+
resolve(null);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
resolve(sanitizeVersion(version));
|
|
150
|
+
} catch {
|
|
151
|
+
resolve(null);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
// Unref so a pending update check can't delay process exit
|
|
156
|
+
req.on('socket', (socket) => socket.unref());
|
|
157
|
+
req.on('error', () => resolve(null));
|
|
158
|
+
req.on('timeout', () => {
|
|
159
|
+
req.destroy();
|
|
160
|
+
resolve(null);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Check for available updates.
|
|
167
|
+
* @param {object} options
|
|
168
|
+
* @param {string} options.currentVersion - The current version (e.g. "1.10.2")
|
|
169
|
+
* @param {boolean} [options.force=false] - Bypass cache and fetch fresh data
|
|
170
|
+
* @returns {Promise<{current: string, latest: string|null, updateAvailable: boolean}>}
|
|
171
|
+
*/
|
|
172
|
+
async function checkForUpdate({ currentVersion, force = false } = {}) {
|
|
173
|
+
if (!currentVersion) {
|
|
174
|
+
return { current: 'unknown', latest: null, updateAvailable: false };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Check cache first (unless forced)
|
|
178
|
+
if (!force) {
|
|
179
|
+
const cache = readCache();
|
|
180
|
+
if (cache && Date.now() - cache.checkedAt < CACHE_TTL_MS) {
|
|
181
|
+
const cachedLatest = typeof cache.latest === 'string' ? sanitizeVersion(cache.latest) : null;
|
|
182
|
+
if (cachedLatest && /^\d+\.\d+\.\d+$/.test(cachedLatest)) {
|
|
183
|
+
return {
|
|
184
|
+
current: currentVersion,
|
|
185
|
+
latest: cachedLatest,
|
|
186
|
+
updateAvailable: isNewerVersion(currentVersion, cachedLatest),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Fetch from registry
|
|
193
|
+
const latest = await module.exports.fetchLatestVersion();
|
|
194
|
+
if (!latest) {
|
|
195
|
+
return { current: currentVersion, latest: null, updateAvailable: false };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Cache the result
|
|
199
|
+
writeCache(latest);
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
current: currentVersion,
|
|
203
|
+
latest,
|
|
204
|
+
updateAvailable: isNewerVersion(currentVersion, latest),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Detect how TermBeam was installed and return the appropriate update command.
|
|
210
|
+
* @returns {{ method: string, command: string }}
|
|
211
|
+
*/
|
|
212
|
+
function detectInstallMethod() {
|
|
213
|
+
// npx / npm exec โ npm sets npm_command=exec
|
|
214
|
+
if (process.env.npm_command === 'exec') {
|
|
215
|
+
return { method: 'npx', command: 'npx termbeam@latest' };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Detect package manager from npm_execpath (set during npm/yarn/pnpm lifecycle)
|
|
219
|
+
const execPath = process.env.npm_execpath || '';
|
|
220
|
+
if (execPath.includes('yarn')) {
|
|
221
|
+
return { method: 'yarn', command: 'yarn global add termbeam@latest' };
|
|
222
|
+
}
|
|
223
|
+
if (execPath.includes('pnpm')) {
|
|
224
|
+
return { method: 'pnpm', command: 'pnpm add -g termbeam@latest' };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Default: npm global install
|
|
228
|
+
return { method: 'npm', command: 'npm install -g termbeam@latest' };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = {
|
|
232
|
+
checkForUpdate,
|
|
233
|
+
isNewerVersion,
|
|
234
|
+
normalizeVersion,
|
|
235
|
+
fetchLatestVersion,
|
|
236
|
+
readCache,
|
|
237
|
+
writeCache,
|
|
238
|
+
sanitizeVersion,
|
|
239
|
+
detectInstallMethod,
|
|
240
|
+
};
|