claude-code-hub 1.0.2 → 1.1.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/lib/update-check.js +56 -0
- package/package.json +5 -5
- package/public/app.js +81 -4
- package/public/index.html +78 -0
- package/server.js +13 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const REGISTRY_URL = 'https://registry.npmjs.org/claude-code-hub/latest';
|
|
4
|
+
const RELEASE_URL = 'https://github.com/NikiforovAll/claude-code-hub/releases/tag/v';
|
|
5
|
+
const TIMEOUT_MS = 3000;
|
|
6
|
+
const TTL_MS = 12 * 60 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
function parse(v) {
|
|
9
|
+
const m = /^(\d+)\.(\d+)\.(\d+)(-.+)?$/.exec(String(v));
|
|
10
|
+
return m && { nums: m.slice(1, 4).map(Number), pre: Boolean(m[4]) };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// A stable release is newer than its own prereleases. A prerelease is never offered.
|
|
14
|
+
function isNewer(latest, current) {
|
|
15
|
+
const a = parse(latest);
|
|
16
|
+
const b = parse(current);
|
|
17
|
+
if (!a || !b || a.pre) return false;
|
|
18
|
+
for (let i = 0; i < 3; i++) if (a.nums[i] !== b.nums[i]) return a.nums[i] > b.nums[i];
|
|
19
|
+
return b.pre;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function fetchLatest() {
|
|
23
|
+
const res = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
24
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
25
|
+
return (await res.json()).version;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function createUpdateCheck({ current, enabled = true, fetch = fetchLatest, now = Date.now, ttlMs = TTL_MS }) {
|
|
29
|
+
let latest = null;
|
|
30
|
+
let checkedAt = -Infinity;
|
|
31
|
+
let inflight = null;
|
|
32
|
+
|
|
33
|
+
function refresh() {
|
|
34
|
+
if (!enabled) return Promise.resolve();
|
|
35
|
+
if (inflight) return inflight;
|
|
36
|
+
checkedAt = now();
|
|
37
|
+
inflight = (async () => {
|
|
38
|
+
latest = await fetch();
|
|
39
|
+
})()
|
|
40
|
+
.catch(() => {})
|
|
41
|
+
.finally(() => {
|
|
42
|
+
inflight = null;
|
|
43
|
+
});
|
|
44
|
+
return inflight;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function update() {
|
|
48
|
+
if (now() - checkedAt >= ttlMs) refresh();
|
|
49
|
+
await inflight;
|
|
50
|
+
return isNewer(latest, current) ? { latest, url: RELEASE_URL + latest } : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { refresh, update };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
module.exports = { createUpdateCheck, isNewer };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-hub",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Unified hub for Claude Code tools — Marketplace + Kanban in one PWA",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -45,10 +45,10 @@
|
|
|
45
45
|
},
|
|
46
46
|
"homepage": "https://github.com/NikiforovAll/claude-code-hub#readme",
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"claude-code-cost": "1.
|
|
49
|
-
"claude-code-kanban": "5.
|
|
50
|
-
"claude-code-marketplace": "1.
|
|
51
|
-
"claude-code-memory-explorer": "1.
|
|
48
|
+
"claude-code-cost": "1.1.0",
|
|
49
|
+
"claude-code-kanban": "5.2.0",
|
|
50
|
+
"claude-code-marketplace": "1.1.0",
|
|
51
|
+
"claude-code-memory-explorer": "1.1.0",
|
|
52
52
|
"express": "^4.21.0",
|
|
53
53
|
"open": "^10.1.0"
|
|
54
54
|
},
|
package/public/app.js
CHANGED
|
@@ -229,6 +229,80 @@ async function init() {
|
|
|
229
229
|
listenKeys();
|
|
230
230
|
bindPalette();
|
|
231
231
|
registerSW();
|
|
232
|
+
bindErrorToast();
|
|
233
|
+
showUpdateToast();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// One key, overwritten by each dismissal: a dismissed version stays hidden until a newer one ships.
|
|
237
|
+
const UPDATE_DISMISSED_KEY = 'hub-update-dismissed';
|
|
238
|
+
const UPDATE_TOAST_MS = 10_000;
|
|
239
|
+
|
|
240
|
+
async function showUpdateToast() {
|
|
241
|
+
let info;
|
|
242
|
+
try {
|
|
243
|
+
info = await sendJson('GET', '/api/update');
|
|
244
|
+
} catch {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const latest = info.update?.latest;
|
|
248
|
+
let dismissed = null;
|
|
249
|
+
try {
|
|
250
|
+
dismissed = localStorage.getItem(UPDATE_DISMISSED_KEY);
|
|
251
|
+
} catch {}
|
|
252
|
+
if (!latest || latest === dismissed) return;
|
|
253
|
+
const toast = document.getElementById('update-toast');
|
|
254
|
+
document.getElementById('update-latest').textContent = latest;
|
|
255
|
+
document.getElementById('update-current').textContent = info.version;
|
|
256
|
+
document.getElementById('update-notes').href = info.update.url;
|
|
257
|
+
let timer = 0;
|
|
258
|
+
const hide = () => {
|
|
259
|
+
clearTimeout(timer);
|
|
260
|
+
toast.classList.add('leaving');
|
|
261
|
+
setTimeout(() => (toast.hidden = true), 200);
|
|
262
|
+
};
|
|
263
|
+
const arm = () => (timer = setTimeout(hide, UPDATE_TOAST_MS));
|
|
264
|
+
toast.addEventListener('mouseenter', () => clearTimeout(timer));
|
|
265
|
+
toast.addEventListener('mouseleave', arm);
|
|
266
|
+
document.getElementById('update-close').addEventListener('click', hide);
|
|
267
|
+
document.getElementById('update-skip').addEventListener('click', () => {
|
|
268
|
+
try {
|
|
269
|
+
localStorage.setItem(UPDATE_DISMISSED_KEY, latest);
|
|
270
|
+
} catch {}
|
|
271
|
+
hide();
|
|
272
|
+
});
|
|
273
|
+
toast.hidden = false;
|
|
274
|
+
arm();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const ERROR_TOAST_MS = 8_000;
|
|
278
|
+
let errorToastTimer = 0;
|
|
279
|
+
|
|
280
|
+
function hideErrorToast() {
|
|
281
|
+
clearTimeout(errorToastTimer);
|
|
282
|
+
const toast = document.getElementById('error-toast');
|
|
283
|
+
toast.classList.add('leaving');
|
|
284
|
+
setTimeout(() => (toast.hidden = true), 200);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// A 401 means the hub_token cookie no longer matches the token file. Opening /?token=… sets a new
|
|
288
|
+
// cookie; reloading lands on the locked page, where the token can be pasted instead.
|
|
289
|
+
function showErrorToast(action, err) {
|
|
290
|
+
const unauthorized = err.status === 401;
|
|
291
|
+
document.getElementById('error-title').textContent = unauthorized ? `${action}: hub token rejected` : action;
|
|
292
|
+
document.getElementById('error-detail').textContent = unauthorized
|
|
293
|
+
? `This browser has an old hub token. Open the link the hub printed in the terminal (${location.origin}/?token=…) to renew it.`
|
|
294
|
+
: err.message;
|
|
295
|
+
document.getElementById('error-actions').hidden = !unauthorized;
|
|
296
|
+
const toast = document.getElementById('error-toast');
|
|
297
|
+
toast.classList.remove('leaving');
|
|
298
|
+
toast.hidden = false;
|
|
299
|
+
clearTimeout(errorToastTimer);
|
|
300
|
+
if (!unauthorized) errorToastTimer = setTimeout(hideErrorToast, ERROR_TOAST_MS);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function bindErrorToast() {
|
|
304
|
+
document.getElementById('error-close').addEventListener('click', hideErrorToast);
|
|
305
|
+
document.getElementById('error-reload').addEventListener('click', () => location.reload());
|
|
232
306
|
}
|
|
233
307
|
|
|
234
308
|
// The hub has no visible chrome, so the window/tab title is the only place the active config dir
|
|
@@ -680,7 +754,7 @@ async function removeConfigDir(dirPath) {
|
|
|
680
754
|
try {
|
|
681
755
|
palette.configDirs = await sendJson('DELETE', '/api/config-dirs', { path: dirPath });
|
|
682
756
|
} catch (err) {
|
|
683
|
-
|
|
757
|
+
showErrorToast('Could not remove config dir', err);
|
|
684
758
|
return;
|
|
685
759
|
}
|
|
686
760
|
if (dirPath in themeState.colorThemes) {
|
|
@@ -951,7 +1025,10 @@ function openPalette(mode) {
|
|
|
951
1025
|
.then(() => {
|
|
952
1026
|
if (palette.open && palette.mode === mode) renderPalette();
|
|
953
1027
|
})
|
|
954
|
-
.catch((err) =>
|
|
1028
|
+
.catch((err) => {
|
|
1029
|
+
if (err.status === 401) showErrorToast('Could not load the list', err);
|
|
1030
|
+
else console.warn(`${mode} list unavailable:`, err.message);
|
|
1031
|
+
});
|
|
955
1032
|
}
|
|
956
1033
|
|
|
957
1034
|
function closePalette() {
|
|
@@ -1015,7 +1092,7 @@ async function commitConfigDir(row) {
|
|
|
1015
1092
|
try {
|
|
1016
1093
|
target = (await withPaletteBusy(() => sendJson('POST', '/api/config-dirs', { path: row.path }))).path;
|
|
1017
1094
|
} catch (err) {
|
|
1018
|
-
|
|
1095
|
+
showErrorToast('Could not add config dir', err);
|
|
1019
1096
|
return;
|
|
1020
1097
|
}
|
|
1021
1098
|
}
|
|
@@ -1033,7 +1110,7 @@ async function commitConfigDir(row) {
|
|
|
1033
1110
|
applyHubTheme();
|
|
1034
1111
|
reloadIframes();
|
|
1035
1112
|
} catch (err) {
|
|
1036
|
-
|
|
1113
|
+
showErrorToast('Could not switch config dir', err);
|
|
1037
1114
|
hideLoading();
|
|
1038
1115
|
}
|
|
1039
1116
|
}
|
package/public/index.html
CHANGED
|
@@ -322,6 +322,61 @@
|
|
|
322
322
|
}
|
|
323
323
|
|
|
324
324
|
.palette-hint:empty { display: none; }
|
|
325
|
+
|
|
326
|
+
.update-toast {
|
|
327
|
+
position: fixed;
|
|
328
|
+
right: 16px;
|
|
329
|
+
bottom: 16px;
|
|
330
|
+
z-index: 15;
|
|
331
|
+
display: flex;
|
|
332
|
+
align-items: flex-start;
|
|
333
|
+
gap: 12px;
|
|
334
|
+
width: min(440px, calc(100vw - 32px));
|
|
335
|
+
padding: 16px 18px;
|
|
336
|
+
background: var(--surface);
|
|
337
|
+
border: 1px solid var(--border);
|
|
338
|
+
border-left: 3px solid var(--accent);
|
|
339
|
+
border-radius: 8px;
|
|
340
|
+
box-shadow: 0 10px 32px var(--shadow);
|
|
341
|
+
color: var(--text);
|
|
342
|
+
font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
343
|
+
transition: opacity 0.2s, transform 0.2s;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
.update-toast[hidden] { display: none; }
|
|
347
|
+
.update-toast.leaving { opacity: 0; transform: translateY(8px); }
|
|
348
|
+
.update-toast-body { flex: 1; min-width: 0; }
|
|
349
|
+
.update-toast-title { font-weight: 600; font-size: 15px; }
|
|
350
|
+
.update-toast-meta { color: var(--text-dim); }
|
|
351
|
+
.update-toast code { display: block; margin-top: 8px; color: var(--accent); user-select: all; }
|
|
352
|
+
.update-toast a { color: var(--accent); }
|
|
353
|
+
.update-toast-actions { margin-top: 12px; }
|
|
354
|
+
|
|
355
|
+
.update-toast-skip {
|
|
356
|
+
padding: 5px 12px;
|
|
357
|
+
border: 1px solid var(--border);
|
|
358
|
+
border-radius: 6px;
|
|
359
|
+
background: transparent;
|
|
360
|
+
color: var(--text-dim);
|
|
361
|
+
font: inherit;
|
|
362
|
+
font-size: 13px;
|
|
363
|
+
cursor: pointer;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
.update-toast-skip:hover { color: var(--text); background: var(--surface-hover); }
|
|
367
|
+
|
|
368
|
+
.update-toast-close {
|
|
369
|
+
border: none;
|
|
370
|
+
background: none;
|
|
371
|
+
color: var(--text-dim);
|
|
372
|
+
font: inherit;
|
|
373
|
+
font-size: 20px;
|
|
374
|
+
line-height: 1;
|
|
375
|
+
cursor: pointer;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
.update-toast-close:hover { color: var(--text); }
|
|
379
|
+
.error-toast { z-index: 25; border-left-color: #e5484d; }
|
|
325
380
|
</style>
|
|
326
381
|
</head>
|
|
327
382
|
<body>
|
|
@@ -382,6 +437,29 @@
|
|
|
382
437
|
</div>
|
|
383
438
|
</div>
|
|
384
439
|
|
|
440
|
+
<div class="update-toast" id="update-toast" role="status" hidden>
|
|
441
|
+
<div class="update-toast-body">
|
|
442
|
+
<div class="update-toast-title">Claude Code Hub <span id="update-latest"></span> is available</div>
|
|
443
|
+
<div class="update-toast-meta">You have <span id="update-current"></span> · <a id="update-notes" target="_blank" rel="noopener">Release notes</a></div>
|
|
444
|
+
<code>npm i -g claude-code-hub@latest</code>
|
|
445
|
+
<div class="update-toast-actions">
|
|
446
|
+
<button class="update-toast-skip" id="update-skip" type="button">Skip this version</button>
|
|
447
|
+
</div>
|
|
448
|
+
</div>
|
|
449
|
+
<button class="update-toast-close" id="update-close" type="button" aria-label="Close">×</button>
|
|
450
|
+
</div>
|
|
451
|
+
|
|
452
|
+
<div class="update-toast error-toast" id="error-toast" role="alert" hidden>
|
|
453
|
+
<div class="update-toast-body">
|
|
454
|
+
<div class="update-toast-title" id="error-title"></div>
|
|
455
|
+
<div class="update-toast-meta" id="error-detail"></div>
|
|
456
|
+
<div class="update-toast-actions" id="error-actions" hidden>
|
|
457
|
+
<button class="update-toast-skip" id="error-reload" type="button">Paste token instead</button>
|
|
458
|
+
</div>
|
|
459
|
+
</div>
|
|
460
|
+
<button class="update-toast-close" id="error-close" type="button" aria-label="Close">×</button>
|
|
461
|
+
</div>
|
|
462
|
+
|
|
385
463
|
<script src="/app.js"></script>
|
|
386
464
|
</body>
|
|
387
465
|
</html>
|
package/server.js
CHANGED
|
@@ -15,6 +15,8 @@ const { createNetGuard, KEEP_ALIVE_MS } = require('./lib/net-guard');
|
|
|
15
15
|
const { ping } = require('./lib/ping');
|
|
16
16
|
const { killChild } = require('./lib/kill-child');
|
|
17
17
|
const { readRegistry, themeConfig } = require('./lib/themes');
|
|
18
|
+
const { createUpdateCheck } = require('./lib/update-check');
|
|
19
|
+
const { version: HUB_VERSION } = require('./package.json');
|
|
18
20
|
|
|
19
21
|
function getArg(name) {
|
|
20
22
|
const idx = process.argv.findIndex((a) => a.startsWith(`--${name}`));
|
|
@@ -842,6 +844,16 @@ function appsConfig() {
|
|
|
842
844
|
);
|
|
843
845
|
}
|
|
844
846
|
|
|
847
|
+
// A git checkout updates through git, not npm.
|
|
848
|
+
const updateCheck = createUpdateCheck({
|
|
849
|
+
current: HUB_VERSION,
|
|
850
|
+
enabled: !process.env.CLAUDE_HUB_NO_UPDATE_CHECK && !fs.existsSync(path.join(__dirname, '.git')),
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
app.get('/api/update', async (_req, res) => {
|
|
854
|
+
res.json({ version: HUB_VERSION, update: await updateCheck.update() });
|
|
855
|
+
});
|
|
856
|
+
|
|
845
857
|
app.get('/api/config', (_req, res) => {
|
|
846
858
|
res.json({
|
|
847
859
|
...themeConfig(THEME_REGISTRY, { userFile: USER_THEMES_FILE }),
|
|
@@ -993,6 +1005,7 @@ app.use(express.static(path.join(__dirname, 'public')));
|
|
|
993
1005
|
const onReady = (actual) => {
|
|
994
1006
|
hubPort = actual;
|
|
995
1007
|
ensurePool(hubConfig.activeConfigDir);
|
|
1008
|
+
updateCheck.refresh();
|
|
996
1009
|
printBanner(actual);
|
|
997
1010
|
if (process.argv.includes('--open')) {
|
|
998
1011
|
import('open').then((m) => m.default(accessUrl(actual)));
|