local-kanban 1.7.0 → 1.8.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/CHANGELOG.md CHANGED
@@ -3,6 +3,29 @@
3
3
  Notable changes, newest first. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
4
4
  and the project uses [semantic versioning](https://semver.org/).
5
5
 
6
+ ## [1.8.0]
7
+
8
+ ### Added
9
+
10
+ - **The board updates itself from a button.** Until now the update line told you a command to type
11
+ in a terminal; now there is a button next to it, and it appears only when an update is actually
12
+ waiting. It installs the update the way the board was installed — `git pull` for a clone,
13
+ `npm install -g` for a package — and installs it into the copy that is running, not into whatever
14
+ prefix the machine happens to prefer. Under pm2 the board then restarts itself and the tab
15
+ reloads on its own; started by hand, it says plainly that the update is in place and the board
16
+ has to be started again, because promising a restart nobody can perform would leave you looking
17
+ at a dead tab.
18
+
19
+ ## [1.7.1]
20
+
21
+ ### Fixed
22
+
23
+ - **The update check asked the registry in a format it answers with an error.** The request carried
24
+ npm's abbreviated media type, which is meant for a package document and not for the `/latest`
25
+ address: the registry replies 406 with an empty body, so an npm install was told "cannot check"
26
+ again. A cached response let it through often enough to look like a flaky network — it was not.
27
+ Caught on a clean install of 1.7.0 minutes after it was published.
28
+
6
29
  ## [1.7.0]
7
30
 
8
31
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "local-kanban",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "license": "MIT",
5
5
  "author": "Dannylamao",
6
6
  "repository": {
package/public/js/core.js CHANGED
@@ -284,6 +284,7 @@ const I18N_RU = {
284
284
  'in review — waiting for you': 'на проверке — ждёт тебя',
285
285
  'in the backlog': 'в бэклоге',
286
286
  'Install the kb command for Claude like this:': 'Команда kb для Claude ставится так:',
287
+ 'Install the update': 'Установить обновление',
287
288
  'Interface language': 'Язык интерфейса',
288
289
  'is gone from disk. Archive the project? Tasks are kept.': 'удалена с диска. Архивировать проект? Задачи сохранятся.',
289
290
  'it has to go through work first': 'сначала через работу',
@@ -397,10 +398,12 @@ const I18N_RU = {
397
398
  'The project notes and the deploy skill — Claude opens them before touching the code. Paths are absolute: copy one and paste it into the chat.': 'Заметки проекта и деплой-скилл — Claude открывает их до того, как трогать код. Пути полные: скопируй и вставь в чат.',
398
399
  'the skill Claude follows to ship this project; “deploy” is the shared one. A new one is created in Settings → Skills.': 'каким скиллом Claude выкатывает этот проект; «deploy» — общий. Свой заводится в «Настройки → Скиллы».',
399
400
  'the skill is not installed — the file will be created on save': 'скилл не установлен — файл появится при сохранении',
401
+ 'the update did not go through': 'обновиться не удалось',
400
402
  'this is a link — only the link is removed, the file it points to stays': 'это ссылка — снимется только она, файл по ту сторону останется',
401
403
  'this is a symlink — saving rewrites the file it points to:': 'это ссылка — сохранение перезапишет файл, на который она ведёт:',
402
404
  'This project is gone': 'Этого проекта больше нет',
403
405
  'this skill was already on disk — added it to the list': 'такой скилл уже лежал на диске — добавил его в список',
406
+ 'Update the board now? It will restart.': 'Обновить доску сейчас? Она перезапустится.',
404
407
  'update with: npm install -g local-kanban@latest': 'обновить: npm install -g local-kanban@latest',
405
408
  Archive: 'Архив',
406
409
  'Archived boards are hidden from the sidebar and left out of every dashboard number. Their tasks are kept.': 'архивные доски спрятаны из сайдбара и не считаются ни в одной цифре дашборда. Задачи при этом сохраняются.',
@@ -592,10 +595,14 @@ const I18N_RU = {
592
595
  'up to date ✓': 'последняя версия ✓',
593
596
  'update available': 'есть обновление',
594
597
  'update with: npm run update': 'обнови командой: npm run update',
598
+ 'updated — start the board again to pick it up': 'обновлено — запусти доску заново, чтобы она подхватила новую версию',
599
+ 'updated, but the board did not come back — start it yourself': 'обновлено, но доска не поднялась — запусти её сам',
600
+ 'updating…': 'обновляю…',
595
601
  'Upload': 'Загрузить',
596
602
  'used by': 'используют',
597
603
  'Using the board at work': 'Пользуешься доской в работе',
598
604
  'version': 'версия',
605
+ 'waiting for the board to come back…': 'жду, пока доска поднимется…',
599
606
  'Waiting for you': 'Ждёт тебя',
600
607
  'Wed': 'Ср',
601
608
  'Week': 'Неделя',
@@ -409,7 +409,9 @@ async function renderSection(sec) {
409
409
  } else {
410
410
  body.innerHTML = `<div class="sm-h">${tr('About')}</div>`
411
411
  + `<div class="set-row"><span class="set-lab">${tr('Code version')}<small id="about-ver">…</small></span><span class="about-fresh" id="about-fresh"></span></div>`
412
- + `<div class="set-row"><span class="set-lab"><span id="about-branch-lab">${tr('Board updates')}</span><small id="about-upd-sub">…</small></span><span class="about-fresh" id="about-upd"></span></div>`
412
+ + `<div class="set-row"><span class="set-lab"><span id="about-branch-lab">${tr('Board updates')}</span><small id="about-upd-sub">…</small></span>`
413
+ + `<span class="about-upd-side"><span class="about-fresh" id="about-upd"></span>`
414
+ + `<button class="btn-ghost hidden" id="about-do-upd">${tr('Install the update')}</button></span></div>`
413
415
  + `<div class="set-row hidden" id="about-dev-row"><span class="set-lab">${tr('dev branch')}<small id="about-dev-sub">…</small></span><span class="about-fresh" id="about-dev"></span></div>`
414
416
  + `<div class="set-row"><span class="set-lab">${tr('Check for updates')}<small id="about-recheck-sub">${tr('re-read branch state from GitHub')}</small></span><button class="btn-ghost" id="about-recheck">${tr('Check')}</button></div>`
415
417
  + `<div class="set-row"><span class="set-lab">${tr('Reload the page')}<small>${tr('re-read the board code right now')}</small></span><button class="btn-ghost" id="about-reload">${tr('Reload now')}</button></div>`
@@ -444,7 +446,12 @@ async function renderSection(sec) {
444
446
  ? tr('cannot check — the npm registry did not answer')
445
447
  : tr('cannot check (no network, or the repo is private/not a git checkout)');
446
448
  }
447
- else if (u.update_available) { el.textContent = `⟳ ${tr('update available')} ${u.local} → ${u.remote}`; el.classList.add('stale-ver'); sub.textContent = `${what} ${u.local}${tag} · ${updateHint()}`; }
449
+ else if (u.update_available) {
450
+ el.textContent = `⟳ ${tr('update available')} ${u.local} → ${u.remote}`;
451
+ el.classList.add('stale-ver');
452
+ sub.textContent = `${what} ${u.local}${tag} · ${updateHint()}`;
453
+ $('about-do-upd')?.classList.remove('hidden');
454
+ }
448
455
  else if (u.dev?.ahead) { el.textContent = `${tr('in dev')} ${u.dev.ahead} ${commitsWord(u.dev.ahead)} ${tr('awaiting release')}`; el.classList.add('stale-ver'); sub.textContent = `${tr('commit')} ${u.local}${tag} · ${tr('matches GitHub')}`; }
449
456
  else { el.textContent = tr('up to date ✓'); sub.textContent = `${what} ${u.local}${tag} · ${same}`; }
450
457
  const row = $('about-dev-row'); const dEl = $('about-dev'); const dSub = $('about-dev-sub');
@@ -460,6 +467,23 @@ async function renderSection(sec) {
460
467
  api('GET', '/api/about').catch(() => null).then((a) => { about = a; })
461
468
  .then(() => api('GET', '/api/update-check')).then(renderUpd)
462
469
  .catch(() => { const s = $('about-upd-sub'); if (s) s.textContent = '—'; });
470
+ $('about-do-upd').onclick = async () => {
471
+ const btn = $('about-do-upd'); const sub = $('about-upd-sub');
472
+ if (!(await styledConfirm(tr('Update the board now? It will restart.')))) return;
473
+ btn.disabled = true; sub.textContent = tr('updating…');
474
+ let r;
475
+ try { r = await api('POST', '/api/update', null, { quiet: true }); }
476
+ catch (err) { sub.textContent = `${tr('the update did not go through')}: ${err.message || err}`; btn.disabled = false; return; }
477
+ if (!r?.ok) { sub.textContent = `${tr('the update did not go through')}: ${(r?.output || '').split('\n').pop() || '—'}`; btn.disabled = false; return; }
478
+ if (r.restart !== 'pm2') { sub.textContent = tr('updated — start the board again to pick it up'); return; }
479
+ sub.textContent = tr('waiting for the board to come back…');
480
+ for (let i = 0; i < 60; i++) {
481
+ await new Promise((ok) => setTimeout(ok, 1000));
482
+ try { await api('GET', '/api/about', null, { quiet: true }); location.reload(); return; } catch { }
483
+ }
484
+ sub.textContent = tr('updated, but the board did not come back — start it yourself');
485
+ };
486
+
463
487
  $('about-recheck').onclick = async () => {
464
488
  const btn = $('about-recheck');
465
489
  btn.disabled = true; $('about-upd-sub').textContent = tr('checking…');
package/public/style.css CHANGED
@@ -141,6 +141,7 @@ body {
141
141
  }
142
142
  .btn-ghost:hover { color: var(--text); }
143
143
  a.btn-ghost { text-decoration: none; }
144
+ .about-upd-side { display: flex; align-items: center; gap: 10px; justify-content: flex-end; }
144
145
  .btn-danger {
145
146
  background: none; border: 1px solid var(--border); border-radius: 7px;
146
147
  padding: 7px 10px; cursor: pointer; color: var(--danger);
@@ -34,12 +34,42 @@ export function isNewer(candidate, current) {
34
34
  return false;
35
35
  }
36
36
 
37
+ export function updatePlan({ packaged, name, root }) {
38
+ if (!packaged) return { how: 'git', cmd: 'git', args: ['pull', '--ff-only'] };
39
+ const args = ['install', '-g', `${name}@latest`];
40
+ const prefix = globalPrefixOf(root, name);
41
+ if (prefix) args.push('--prefix', prefix);
42
+ return { how: 'npm', cmd: 'npm', args };
43
+ }
44
+
45
+ export function globalPrefixOf(root, name) {
46
+ const parts = String(root || '').split(sep).filter(Boolean);
47
+ const tail = String(name || '').split('/').filter(Boolean);
48
+ if (!tail.length || parts.length < tail.length + 3) return null;
49
+ const own = parts.slice(-tail.length).join('/') === tail.join('/');
50
+ const cut = parts.slice(0, -tail.length);
51
+ if (!own || cut.at(-1) !== 'node_modules' || cut.at(-2) !== 'lib') return null;
52
+ const prefix = cut.slice(0, -2).join(sep);
53
+ return prefix ? (root.startsWith(sep) ? sep + prefix : prefix) : null;
54
+ }
55
+
56
+ export function failureReason(output, fallback = '') {
57
+ const lines = String(output || '').split('\n').map((l) => l.trim()).filter(Boolean);
58
+ const marked = lines.filter((l) => /^(error\b|fatal:|npm error)/i.test(l));
59
+ const pick = marked.length ? marked.reduce((a, b) => (b.length > a.length ? b : a)) : lines.at(-1);
60
+ return (pick || fallback || '').slice(0, 300);
61
+ }
62
+
63
+ export function restartMode(env = process.env) {
64
+ return env.pm_id !== undefined && env.pm_id !== '' ? 'pm2' : 'manual';
65
+ }
66
+
37
67
  export async function registryCheck() {
38
68
  const pkg = selfPkg();
39
69
  if (!pkg) return null;
40
70
  try {
41
71
  const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(pkg.name)}/latest`, {
42
- headers: { 'User-Agent': 'local-kanban', Accept: 'application/vnd.npm.install-v1+json' },
72
+ headers: { 'User-Agent': 'local-kanban', Accept: 'application/json' },
43
73
  signal: AbortSignal.timeout(8000),
44
74
  });
45
75
  if (!res.ok) return null;
@@ -267,6 +297,32 @@ export default async function systemRoutes(app) {
267
297
  return data;
268
298
  });
269
299
 
300
+ let updateRunning = null;
301
+ app.post('/api/update', async (req, reply) => {
302
+ if (updateRunning) return reply.code(409).send({ error: 'an update is already running' });
303
+ const pkg = selfPkg();
304
+ const plan = updatePlan({ packaged: PACKAGED, name: pkg?.name, root: ROOT_DIR });
305
+ if (plan.how === 'npm' && !pkg?.name) return reply.code(500).send({ error: 'cannot tell which package to update' });
306
+
307
+ const run = new Promise((resolve) => {
308
+ execFile(plan.cmd, plan.args, { cwd: ROOT_DIR, timeout: 300000, maxBuffer: 4 * 1024 * 1024 },
309
+ (err, out, errOut) => resolve({ err, text: `${out || ''}${errOut || ''}`.trim() }));
310
+ });
311
+ updateRunning = run;
312
+ let res;
313
+ try { res = await run; } finally { if (updateRunning === run) updateRunning = null; }
314
+
315
+ const output = res.text.slice(-2000);
316
+ if (res.err) {
317
+ logError('server', 'update', `update via ${plan.how} failed: ${res.err.message}`, output);
318
+ return reply.code(500).send({ ok: false, how: plan.how, output, error: failureReason(output, res.err.message) });
319
+ }
320
+ const restart = restartMode();
321
+ if (restart === 'pm2') setTimeout(() => process.exit(0), 400).unref();
322
+ updCache = { at: 0, ttl: 0, data: null };
323
+ return { ok: true, how: plan.how, restart, output };
324
+ });
325
+
270
326
  app.get('/api/lang', () => ({ lang: uiLang(), source: langOverride() ? 'env' : 'kv' }));
271
327
  app.post('/api/lang', (req) => {
272
328
  if (!langOverride()) kvSet('ui.lang', req.body?.lang === 'en' ? 'en' : 'ru');