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.
@@ -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.2",
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.0.0",
49
- "claude-code-kanban": "5.1.1",
50
- "claude-code-marketplace": "1.0.0",
51
- "claude-code-memory-explorer": "1.0.0",
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
- console.warn('remove config dir failed:', err.message);
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) => console.warn(`${mode} list unavailable:`, err.message));
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
- console.warn('add config dir failed:', err.message);
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
- console.warn('config dir switch failed:', err.message);
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)));