local-kanban 1.12.0 → 1.14.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 +67 -0
- package/README.md +1 -0
- package/README.ru.md +1 -0
- package/bin/kb +129 -25
- package/package.json +1 -1
- package/public/js/core.js +7 -0
- package/public/js/settings.js +45 -2
- package/public/js/sidebar.js +4 -1
- package/public/style.css +13 -0
- package/skills/kanban/SKILL.md +35 -13
- package/src/config.js +1 -0
- package/src/routes/system.js +52 -13
- package/src/sync/github.js +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,73 @@
|
|
|
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
|
+
Anything not shipped yet is collected under **Unreleased**; releasing renames that section to the
|
|
7
|
+
version it goes out as and opens an empty one again. Released sections are history and stay as they are.
|
|
8
|
+
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
## [1.14.0]
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`kb` shows how tasks are linked, and can make a sub-task.** The board keeps parents, sub-tasks and
|
|
16
|
+
related tasks, but `kb show` and `kb take` printed none of them, so Claude could not know that two
|
|
17
|
+
tasks were meant to go together. Both commands now print one line per link: `↑ parent`,
|
|
18
|
+
`↓ subtask`, `↔ related`, with the key, title and status. `kb link A B child` makes B a sub-task
|
|
19
|
+
of A, `kb link A B parent` makes it A's parent; without a third word the link is related, as before.
|
|
20
|
+
- **`kb info` prints the project's description and its documents.** The description is where owners
|
|
21
|
+
keep the context for work, and it never reached Claude. A `docs:` line now lists the documents to
|
|
22
|
+
read before a task (`CLAUDE.md`, `README.md`, `ARCHITECTURE.md` …) and says so plainly when there is
|
|
23
|
+
no `CLAUDE.md` or the project folder is not on this machine.
|
|
24
|
+
- **`kb check <key> <n>` / `kb uncheck` tick checklist items from the CLI.** Claude could read the
|
|
25
|
+
checklist but not tick it, so at acceptance every box was empty and the human could not tell what
|
|
26
|
+
of the list was done. The item is addressed by its number in the order `kb show` prints it.
|
|
27
|
+
- **`KB_GH_BIN` points the board at `gh` when it lives somewhere unusual.** The board looks for `gh`
|
|
28
|
+
in the usual install locations (Homebrew, `/usr/bin`, `~/.local/bin`) and then on `PATH`; a copy
|
|
29
|
+
anywhere else can now be named explicitly.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- **`kb` no longer loses what the human wrote in a card.** `kb take` printed only the latest note
|
|
34
|
+
from the human, so a send-back followed by a second note lost the first; `kb show` printed the last
|
|
35
|
+
five comments of any author, and Claude's own notes plus the commit stats pushed the human's note
|
|
36
|
+
out without a word. Both now print every note the human left since Claude's last report, each
|
|
37
|
+
comment carries its date and time, and anything left out is counted ("… 3 more comments not
|
|
38
|
+
printed") instead of dropped silently.
|
|
39
|
+
- **A commit stat is no longer printed as the human speaking.** `kb show` marked everything that was
|
|
40
|
+
not Claude's with 👤, including the commit stat the board attaches on its own. It is marked ⎇ now.
|
|
41
|
+
- **`kb take` tells a blocked To do from an empty one.** When every card in To do was blocked it
|
|
42
|
+
answered a bare `empty`, and Claude reported an empty column while the human could see cards in
|
|
43
|
+
it. It now says `empty (2 blocked)`; `kb next` does the same.
|
|
44
|
+
- **`kb take` and `kb show` print the issue the same way** — `issue: #42 <link>`. One printed the
|
|
45
|
+
number and the other the link.
|
|
46
|
+
- **The checklist now reaches Claude.** A card's checklist is shown on the board right under the
|
|
47
|
+
description, but `kb show` and `kb take` printed everything about the task except it — the CLI is
|
|
48
|
+
the only channel Claude has, so the items simply did not exist for it: a card with three checklist
|
|
49
|
+
items and an empty description arrived as a bare title. Both commands now print the items as
|
|
50
|
+
`- [ ]` / `- [x]` lines, in the board's order, right after the description.
|
|
51
|
+
|
|
52
|
+
## [1.13.0]
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
|
|
56
|
+
- **The board tells you what's new.** The changelog was written for every release and shown nowhere:
|
|
57
|
+
the only place to read it was the package page on npm. The About screen now has a "What's new"
|
|
58
|
+
block. For the version you are running it reads the `CHANGELOG.md` that ships next to the code, so
|
|
59
|
+
it needs no network at all. When an update is waiting, it shows the notes of the version you do
|
|
60
|
+
*not* have yet, read from that release's page on GitHub — the decision to update is made on what
|
|
61
|
+
actually changed, not on a version number. Releases now get such a page: publishing puts the
|
|
62
|
+
changelog section for that version into it, which also gives the npm page real releases to link to.
|
|
63
|
+
|
|
64
|
+
### Fixed
|
|
65
|
+
|
|
66
|
+
- **A folder the board is not allowed to read is no longer reported as missing.** The Check button
|
|
67
|
+
next to the project folder answered every refusal the same way — "no such folder on this computer" —
|
|
68
|
+
whether the folder was absent, was a file, or simply could not be read. For a folder without
|
|
69
|
+
permissions that is plainly untrue: it is there, the board was not let in, and the answer sent you
|
|
70
|
+
off to fix a path that was already correct. Each case now says what actually happened, and a check
|
|
71
|
+
that could not reach the server says so instead of blaming the path.
|
|
72
|
+
|
|
6
73
|
## [1.12.0]
|
|
7
74
|
|
|
8
75
|
### Added
|
package/README.md
CHANGED
|
@@ -142,6 +142,7 @@ Releases live in `main` (stable, tagged); day-to-day development happens in `dev
|
|
|
142
142
|
| `KB_PANEL_INFO` | — | optional path to an external panel's info.json for category sync |
|
|
143
143
|
| `KB_SSE_MAX` | `20` | cap on concurrent SSE connections |
|
|
144
144
|
| `KB_SKILLS_EXTRA` | — | extra skill root directories, `:`-separated |
|
|
145
|
+
| `KB_GH_BIN` | — | path to the `gh` binary when it is not in a usual place (Homebrew, `/usr/bin`, `~/.local/bin`) |
|
|
145
146
|
|
|
146
147
|
The legacy names `PANEL_URL` and `PANEL_INFO` (without the prefix) still work as a fallback.
|
|
147
148
|
|
package/README.ru.md
CHANGED
|
@@ -141,6 +141,7 @@ npm run update # git pull + npm install + pm2 restart (если примени
|
|
|
141
141
|
| `KB_PANEL_INFO` | — | опциональный info.json внешней панели для синка категорий |
|
|
142
142
|
| `KB_SSE_MAX` | `20` | потолок одновременных SSE-соединений |
|
|
143
143
|
| `KB_SKILLS_EXTRA` | — | дополнительные корни скиллов через `:` |
|
|
144
|
+
| `KB_GH_BIN` | — | путь к `gh`, если он лежит не в обычном месте (Homebrew, `/usr/bin`, `~/.local/bin`) |
|
|
144
145
|
|
|
145
146
|
Старые имена `PANEL_URL` и `PANEL_INFO` (без префикса) продолжают работать как запасные.
|
|
146
147
|
|
package/bin/kb
CHANGED
|
@@ -63,8 +63,96 @@ function taskLine(t) {
|
|
|
63
63
|
return `${t.key} ${t.status}${flags ? ' ' + flags : ''} ${t.title}${labels}`;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// The checklist is part of the task's spec: the board shows it right under the description, and
|
|
67
|
+
// this CLI is the only channel Claude has — an item that is not printed here does not exist for
|
|
68
|
+
// Claude. Markdown task-list lines, `- [ ]` open and `- [x]` done, in the board's order.
|
|
69
|
+
function printChecklist(t) {
|
|
70
|
+
for (const c of (t.checklist || [])) console.log(`- [${c.done ? 'x' : ' '}] ${c.text}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// here, and the skill's rule "related tasks solved together are taken together" cannot be followed
|
|
74
|
+
// without knowing they are related. `rel` is relative to this task: 'parent' — the other task is
|
|
75
|
+
// this one's parent, 'child' — the other task is a sub-task of this one, 'related' — symmetric.
|
|
76
|
+
const REL = { parent: ['↑', 'parent'], child: ['↓', 'subtask'], related: ['↔', 'related'] };
|
|
77
|
+
function printLinks(t) {
|
|
78
|
+
for (const l of (t.links || [])) {
|
|
79
|
+
const [arrow, word] = REL[l.rel] || REL.related;
|
|
80
|
+
console.log(`${arrow} ${word} ${l.key} ${l.title} (${l.status})`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const linksLine = (id, links) => `${id} ${links.map((l) => (REL[l.rel] || REL.related)[0] + l.key).join(' ') || '—'}`;
|
|
84
|
+
|
|
85
|
+
// the skill had to explain two formats. The number goes into commit messages, the link is for eyes.
|
|
86
|
+
function printIssue(t) {
|
|
87
|
+
if (t.gh_issue_number) console.log(`issue: #${t.gh_issue_number}${t.gh_issue_url ? ' ' + t.gh_issue_url : ''}`);
|
|
88
|
+
else if (t.gh_issue_url) console.log(`issue: ${t.gh_issue_url}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// sees cards in the column, and a bare "empty" made Claude report that there was nothing there.
|
|
92
|
+
function emptyLine(todo) {
|
|
93
|
+
const blocked = todo.filter((t) => t.blocked).length;
|
|
94
|
+
return blocked ? `empty (${blocked} blocked)` : 'empty';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function printAttachments(t) {
|
|
98
|
+
for (const a of (t.attachments || [])) console.log(`📎 screenshot (open it with Read): ${a.path}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Comments. Three authors: the human ('me'), Claude, and 'git' — the commit stat the board attaches
|
|
102
|
+
// by itself on the move to review. Each gets its own mark: a commit printed as 👤 read as the human
|
|
103
|
+
const WHO = { claude: '🤖', git: '⎇' };
|
|
104
|
+
|
|
105
|
+
// The database keeps UTC ('YYYY-MM-DD HH:MM:SS'); print local time, to the minute — enough to tell
|
|
106
|
+
function when(c) {
|
|
107
|
+
const d = new Date(String(c.created_at || '').replace(' ', 'T') + 'Z');
|
|
108
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
109
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
110
|
+
return `[${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}] `;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function printComment(c) {
|
|
114
|
+
const at = when(c);
|
|
115
|
+
if (c.body) console.log(`> ${WHO[c.author] || '👤'} ${at}${c.author === 'git' ? 'commit ' : ''}${c.body}`);
|
|
116
|
+
if (c.image_path) console.log(`📎 image from a comment ${at}(open it with Read): ${c.image_path}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Claude's last report — not just the latest one (a send-back plus a follow-up note lost the first)
|
|
120
|
+
// and not whatever happens to fit a window of the last few (Claude's own notes and commit stats
|
|
121
|
+
// pushed the human out of it). Those are always printed; `window` adds the last N comments of any
|
|
122
|
+
// author on top, and the latest images stay too — they are usually the correction itself. When the
|
|
123
|
+
// human has said nothing since the last report, the last thing they did say is printed, dated, so
|
|
124
|
+
// it can be told apart from a fresh one. Whatever is left out is counted, never dropped silently.
|
|
125
|
+
function printComments(cs, window) {
|
|
126
|
+
const keep = new Set();
|
|
127
|
+
let lastReport = -1;
|
|
128
|
+
cs.forEach((c, i) => { if (c.author === 'claude') lastReport = i; });
|
|
129
|
+
const human = (c) => c.author !== 'claude' && c.author !== 'git';
|
|
130
|
+
cs.forEach((c, i) => { if (i > lastReport && human(c)) keep.add(i); });
|
|
131
|
+
if (!keep.size) {
|
|
132
|
+
const i = cs.findLastIndex((c) => human(c) && c.body);
|
|
133
|
+
if (i >= 0) keep.add(i);
|
|
134
|
+
}
|
|
135
|
+
for (let i = Math.max(0, cs.length - window); i < cs.length; i++) keep.add(i);
|
|
136
|
+
cs.map((c, i) => (c.image_path ? i : -1)).filter((i) => i >= 0).slice(-3).forEach((i) => keep.add(i));
|
|
137
|
+
const hidden = cs.length - keep.size;
|
|
138
|
+
if (hidden > 0) console.log(`… ${hidden} more comment${hidden === 1 ? '' : 's'} not printed — the board has them all`);
|
|
139
|
+
cs.forEach((c, i) => { if (keep.has(i)) printComment(c); });
|
|
140
|
+
}
|
|
141
|
+
|
|
66
142
|
const [cmd, ...args] = process.argv.slice(2);
|
|
67
143
|
|
|
144
|
+
async function tick(done) {
|
|
145
|
+
const [id, n] = args;
|
|
146
|
+
const no = Number(n);
|
|
147
|
+
if (!id || !Number.isInteger(no) || no < 1) die(`kb ${done ? 'check' : 'uncheck'} <key> <n> (n = the item number, from 1, in the order kb show prints them)`);
|
|
148
|
+
const t = await api('GET', `/api/tasks/${id}`);
|
|
149
|
+
const items = t.checklist || [];
|
|
150
|
+
if (no > items.length) die(`kb: ${t.key} has ${items.length ? `only ${items.length} checklist item${items.length === 1 ? '' : 's'}` : 'no checklist'}`);
|
|
151
|
+
const item = await api('PATCH', `/api/tasks/${id}/checklist/${items[no - 1].id}`, { done });
|
|
152
|
+
const open = items.filter((c, i) => (i === no - 1 ? !item.done : !c.done)).length;
|
|
153
|
+
console.log(`${t.key} - [${item.done ? 'x' : ' '}] ${item.text} (${open} open)`);
|
|
154
|
+
}
|
|
155
|
+
|
|
68
156
|
const commands = {
|
|
69
157
|
async p() {
|
|
70
158
|
const ps = await api('GET', '/api/projects');
|
|
@@ -91,28 +179,27 @@ const commands = {
|
|
|
91
179
|
if (!proj) die('kb next <project>');
|
|
92
180
|
const ts = await api('GET', `/api/tasks?${new URLSearchParams({ project: proj, status: 'todo' })}`);
|
|
93
181
|
const free = ts.filter((t) => !t.blocked);
|
|
94
|
-
console.log(free.length ? taskLine(free[0]) :
|
|
182
|
+
console.log(free.length ? taskLine(free[0]) : emptyLine(ts));
|
|
95
183
|
},
|
|
96
184
|
|
|
97
|
-
// take the next task and put it straight into prep (study before code); prints the
|
|
98
|
-
//
|
|
185
|
+
// take the next task and put it straight into prep (study before code); prints what the human
|
|
186
|
+
// wrote since Claude's last report (notes from a send-back). Writing code → kb mv <key> doing.
|
|
99
187
|
async take() {
|
|
100
188
|
const [proj] = args;
|
|
101
189
|
if (!proj) die('kb take <project>');
|
|
102
190
|
const ts = await api('GET', `/api/tasks?${new URLSearchParams({ project: proj, status: 'todo' })}`);
|
|
103
191
|
const free = ts.filter((t) => !t.blocked);
|
|
104
|
-
if (!free.length) return console.log(
|
|
192
|
+
if (!free.length) return console.log(emptyLine(ts));
|
|
105
193
|
const t = await api('PATCH', `/api/tasks/${free[0].id}`, { status: 'prep' });
|
|
106
194
|
console.log(taskLine({ ...t, labels: t.labels }));
|
|
107
195
|
if (t.description) console.log(t.description);
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
for (const c of cs.filter((c) => c.image_path).slice(-3)) console.log(`📎 image from a comment (open it with Read): ${c.image_path}`);
|
|
196
|
+
// PATCH answers with the bare row; the checklist and the attachments live on the full card
|
|
197
|
+
const full = await api('GET', `/api/tasks/${t.id}`);
|
|
198
|
+
printChecklist(full);
|
|
199
|
+
printLinks(full);
|
|
200
|
+
printIssue(full);
|
|
201
|
+
printAttachments(full);
|
|
202
|
+
printComments(await api('GET', `/api/tasks/${t.id}/comments`), 0);
|
|
116
203
|
},
|
|
117
204
|
|
|
118
205
|
// leave a note and move to review in one command
|
|
@@ -131,13 +218,11 @@ const commands = {
|
|
|
131
218
|
console.log(`${t.key} ${t.project} ${t.status}${t.blocked ? ` blocked: ${t.blocked_reason || ''}` : ''}${PRI[t.priority] ? ' ' + PRI[t.priority] : ''}${t.labels.length ? ' [' + t.labels.join(',') + ']' : ''}`);
|
|
132
219
|
console.log(t.title);
|
|
133
220
|
if (t.description) console.log(t.description);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (c.image_path) console.log(`📎 image from a comment (open it with Read): ${c.image_path}`);
|
|
140
|
-
}
|
|
221
|
+
printChecklist(t);
|
|
222
|
+
printLinks(t);
|
|
223
|
+
printIssue(t);
|
|
224
|
+
printAttachments(t);
|
|
225
|
+
printComments(await api('GET', `/api/tasks/${id}/comments`), 5);
|
|
141
226
|
},
|
|
142
227
|
|
|
143
228
|
async add() {
|
|
@@ -222,20 +307,28 @@ const commands = {
|
|
|
222
307
|
console.log(`${t.key} 📝`);
|
|
223
308
|
},
|
|
224
309
|
|
|
310
|
+
// kb link A B [child|parent|related] — `child`: B becomes a sub-task of A, `parent`: B becomes
|
|
311
|
+
// A's parent. Without a third word the link is plain `related`, as it always was. A pair holds
|
|
312
|
+
// one link, so linking again with another word re-types it.
|
|
225
313
|
async link() {
|
|
226
|
-
const [id, other] = args;
|
|
227
|
-
if (!id || !other) die('kb link <key> <key> (
|
|
228
|
-
const links = await api('POST', `/api/tasks/${id}/links`, { key: other });
|
|
229
|
-
console.log(
|
|
314
|
+
const [id, other, rel = 'related'] = args;
|
|
315
|
+
if (!id || !other || !REL[rel]) die('kb link <key> <key> [child|parent|related] (child: the second task becomes a sub-task of the first)');
|
|
316
|
+
const links = await api('POST', `/api/tasks/${id}/links`, { key: other, rel });
|
|
317
|
+
console.log(linksLine(id, links));
|
|
230
318
|
},
|
|
231
319
|
|
|
232
320
|
async unlink() {
|
|
233
321
|
const [id, other] = args;
|
|
234
322
|
if (!id || !other) die('kb unlink <key> <key>');
|
|
235
323
|
const links = await api('DELETE', `/api/tasks/${id}/links/${other}`);
|
|
236
|
-
console.log(
|
|
324
|
+
console.log(linksLine(id, links));
|
|
237
325
|
},
|
|
238
326
|
|
|
327
|
+
// empty and could not tell what of the list was done. The item is addressed by its number — the
|
|
328
|
+
// position in which show/take print it (the board's order), counting from 1.
|
|
329
|
+
async check() { await tick(true); },
|
|
330
|
+
async uncheck() { await tick(false); },
|
|
331
|
+
|
|
239
332
|
async block() {
|
|
240
333
|
const [id, reason] = args;
|
|
241
334
|
if (!id) die('kb block <key> ["reason"]');
|
|
@@ -268,6 +361,17 @@ const commands = {
|
|
|
268
361
|
const services = parsePm2Services(p.pm2_services);
|
|
269
362
|
if (services.length) out.push(`pm2: ${services.join(', ')}`);
|
|
270
363
|
if (p.domain) out.push(`domain: ${p.domain}`);
|
|
364
|
+
// CLAUDE.md", "deployed by skill X", DNS facts) — info printed everything but it
|
|
365
|
+
if (p.description) out.push(`description: ${p.description}`);
|
|
366
|
+
// page; without this line Claude went looking by itself and sometimes missed. A missing
|
|
367
|
+
// CLAUDE.md is said out loud — it is a signal (the task starts without architecture notes).
|
|
368
|
+
if (p.path) {
|
|
369
|
+
const d = await api('GET', `/api/projects/${encodeURIComponent(p.slug)}/docs`);
|
|
370
|
+
const found = (d.docs || []).filter((x) => x.exists).map((x) => x.name);
|
|
371
|
+
if (!d.path_exists) out.push('docs: — the project folder does not exist on this machine');
|
|
372
|
+
else if (!found.includes('CLAUDE.md')) out.push(`docs: no CLAUDE.md${found.length ? ` — read ${found.join(', ')} instead` : ', and no README.md / ARCHITECTURE.md either'}`);
|
|
373
|
+
else out.push(`docs: ${found.join(', ')}`);
|
|
374
|
+
}
|
|
271
375
|
console.log(out.join('\n'));
|
|
272
376
|
},
|
|
273
377
|
|
|
@@ -297,7 +401,7 @@ const commands = {
|
|
|
297
401
|
};
|
|
298
402
|
|
|
299
403
|
if (!cmd || !commands[cmd]) {
|
|
300
|
-
die('kb p|ls|next|take|show|add|mv|review|done|cancel|pri|lbl|desc|note|link|unlink|block|unblock|rm|info|svc|sync|retry');
|
|
404
|
+
die('kb p|ls|next|take|show|add|mv|review|done|cancel|pri|lbl|desc|note|link|unlink|check|uncheck|block|unblock|rm|info|svc|sync|retry');
|
|
301
405
|
}
|
|
302
406
|
commands[cmd]().catch((e) => {
|
|
303
407
|
// die() inside a command already exited the process — only real exceptions reach here.
|
package/package.json
CHANGED
package/public/js/core.js
CHANGED
|
@@ -375,9 +375,11 @@ const I18N_RU = {
|
|
|
375
375
|
'Claude moves these columns itself': 'эти колонки двигает сам Claude',
|
|
376
376
|
'no file': 'файла нет',
|
|
377
377
|
'no folders inside': 'внутри нет папок',
|
|
378
|
+
'no notes for this version': 'для этой версии заметок нет',
|
|
378
379
|
'no such folder on this computer': 'такой папки на этом компьютере нет',
|
|
379
380
|
'not deleted': 'не удалено',
|
|
380
381
|
'not installed': 'не установлен',
|
|
382
|
+
'not installed yet': 'ещё не установлена',
|
|
381
383
|
'not set': 'не задан',
|
|
382
384
|
'not set — no deploy': 'не задан — деплоя нет',
|
|
383
385
|
'not there': 'нет файла',
|
|
@@ -425,11 +427,13 @@ const I18N_RU = {
|
|
|
425
427
|
'subfolders': 'подпапок',
|
|
426
428
|
'key is free': 'ключ свободен',
|
|
427
429
|
'task prefix': 'префикс задач',
|
|
430
|
+
'the folder is there, but the board cannot read it — check its permissions': 'папка есть, но доска не может её прочитать — посмотри права доступа',
|
|
428
431
|
'this key is already taken by': 'этот ключ уже занят проектом',
|
|
429
432
|
'no such host — check the name, or add it to your ~/.ssh/config': 'такого хоста нет — проверь имя или добавь его в свой ~/.ssh/config',
|
|
430
433
|
'the server refused the key — a password will not help here, the key has to be authorized': 'сервер не принял ключ — пароль тут не поможет, ключ надо разрешить на сервере',
|
|
431
434
|
'the host key is unknown — connect once from your terminal and confirm the fingerprint': 'ключ хоста неизвестен — подключись разок из терминала и подтверди отпечаток',
|
|
432
435
|
'the server did not answer — wrong address, wrong port, or it is down': 'сервер не ответил — не тот адрес, не тот порт или он лежит',
|
|
436
|
+
'could not check the folder': 'не удалось проверить папку',
|
|
433
437
|
'could not connect': 'подключиться не вышло',
|
|
434
438
|
'on create, git clone puts it on THIS computer at': 'при создании git clone положит его на ЭТОТ компьютер в',
|
|
435
439
|
'An existing folder is refused, not overwritten. Git credentials are not asked here — access comes from your own git and ssh setup.': 'Если папка уже есть — откажет, а не перезапишет. Логин git тут не спрашивается: доступ берётся из твоих git-настроек и ssh-ключей.',
|
|
@@ -535,6 +539,7 @@ const I18N_RU = {
|
|
|
535
539
|
'recovered on retry': 'починилось повторной попыткой',
|
|
536
540
|
'related': 'связана',
|
|
537
541
|
'Read': 'Открыть',
|
|
542
|
+
'release page': 'страница релиза',
|
|
538
543
|
'Reload now': 'Обновить сейчас',
|
|
539
544
|
'Reload the page': 'Обновить страницу',
|
|
540
545
|
'remove': 'убрать',
|
|
@@ -636,6 +641,7 @@ const I18N_RU = {
|
|
|
636
641
|
'The board could not reach the server — the change was not saved': 'доска не достучалась до сервера — правка не сохранена',
|
|
637
642
|
'capped: the task sat in a working column too long': 'урезано: задача слишком долго лежала в рабочей колонке',
|
|
638
643
|
'To do': 'Сделать',
|
|
644
|
+
'this path is not a folder': 'по этому пути не папка',
|
|
639
645
|
'Today': 'Сегодня',
|
|
640
646
|
'Top projects': 'Топ проектов',
|
|
641
647
|
'Tue': 'Вт',
|
|
@@ -657,6 +663,7 @@ const I18N_RU = {
|
|
|
657
663
|
'waiting for the board to come back…': 'жду, пока доска поднимется…',
|
|
658
664
|
'Waiting for you': 'Ждёт тебя',
|
|
659
665
|
'Wed': 'Ср',
|
|
666
|
+
"What's new": 'Что нового',
|
|
660
667
|
'Week': 'Неделя',
|
|
661
668
|
'Weeks': 'Недели',
|
|
662
669
|
'Welcome!': 'Добро пожаловать!',
|
package/public/js/settings.js
CHANGED
|
@@ -6,6 +6,35 @@ import { checkVersion, pendingReload } from './init.js';
|
|
|
6
6
|
import { loadProjects, popLayer, pushLayer, setupPromptBlockHTML, styledAlert, styledConfirm, styledPrompt, wireSetupPrompt } from './sidebar.js';
|
|
7
7
|
import { SHORT_MONTHS, SOUND_LIB, STATUS_LABELS, audioCtx, lastMissedAt, lastSseAt, missedSounds, pageLoadedAt, playNamedSound, refresh, relTime, titleOr, updateSoundBadge } from './sse.js';
|
|
8
8
|
|
|
9
|
+
function mdInline(text) {
|
|
10
|
+
return esc(text)
|
|
11
|
+
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
12
|
+
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
13
|
+
.replace(/\*([^*\n]+)\*/g, '<em>$1</em>')
|
|
14
|
+
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
|
|
15
|
+
}
|
|
16
|
+
export function mdLite(md) {
|
|
17
|
+
const out = [];
|
|
18
|
+
let list = false;
|
|
19
|
+
const closeList = () => { if (list) { out.push('</ul>'); list = false; } };
|
|
20
|
+
for (const block of String(md || '').split(/\n\s*\n/).map((b) => b.trim()).filter(Boolean)) {
|
|
21
|
+
const flat = block.split('\n').map((l) => l.trim()).join(' ');
|
|
22
|
+
const head = flat.match(/^#{1,6}\s+(.*)$/);
|
|
23
|
+
if (head) { closeList(); out.push(`<h4>${mdInline(head[1])}</h4>`); continue; }
|
|
24
|
+
if (/^[-*]\s+/.test(block)) {
|
|
25
|
+
if (!list) { out.push('<ul>'); list = true; }
|
|
26
|
+
for (const item of block.split(/\n(?=[-*]\s)/)) {
|
|
27
|
+
out.push(`<li>${mdInline(item.replace(/^[-*]\s+/, '').split('\n').map((l) => l.trim()).join(' '))}</li>`);
|
|
28
|
+
}
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
closeList();
|
|
32
|
+
out.push(`<p>${mdInline(flat)}</p>`);
|
|
33
|
+
}
|
|
34
|
+
closeList();
|
|
35
|
+
return out.join('');
|
|
36
|
+
}
|
|
37
|
+
|
|
9
38
|
const SETTINGS_DEFAULTS = { sound: true, soundReview: 'ding', soundDone: 'click', startScreen: 'dashboard', dashRange: 'week', theme: 'system', keepDrawer: false, notifyReview: false, compactCards: false, linkLines: true, keymap: {} };
|
|
10
39
|
export function getSetting(k) {
|
|
11
40
|
const v = localStorage.getItem('kb.set.' + k);
|
|
@@ -424,6 +453,7 @@ async function renderSection(sec) {
|
|
|
424
453
|
+ `<div class="set-col hidden" id="about-upd-fail"><span class="set-lab">${tr('Run it yourself')}<small>${tr('the board is not allowed to do this — the command is the same one it just tried')}</small></span>`
|
|
425
454
|
+ '<pre class="onb-code" id="about-upd-cmd"></pre></div>'
|
|
426
455
|
+ `<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>`
|
|
456
|
+
+ `<div class="set-col"><span class="set-lab">${tr("What's new")}<small id="about-news-sub">…</small></span><div class="about-news" id="about-news">…</div></div>`
|
|
427
457
|
+ `<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>`
|
|
428
458
|
+ `<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>`
|
|
429
459
|
+ `<div class="set-row"><span class="set-lab">${tr('Using the board at work')}<small>${tr('free for everyone, nothing switched off — if it earns you money, $12 a year per person keeps it maintained')}</small></span>`
|
|
@@ -475,8 +505,21 @@ async function renderSection(sec) {
|
|
|
475
505
|
? `${u.dev.sha}${u.dev.date ? ` ${tr('at')} ${fmtDbTime(u.dev.date.slice(0, 19).replace('T', ' '))}` : ''}${u.dev.message ? ` · ${u.dev.message}` : ''}`
|
|
476
506
|
: tr('dev has no commits beyond main');
|
|
477
507
|
};
|
|
508
|
+
const loadNews = async (upd, forced) => {
|
|
509
|
+
const box = $('about-news'); const sub = $('about-news-sub');
|
|
510
|
+
if (!box || !sub) return;
|
|
511
|
+
try {
|
|
512
|
+
const remote = upd?.update_available === true ? `?remote=1${forced ? '&refresh=1' : ''}` : '';
|
|
513
|
+
const n = await api('GET', `/api/whats-new${remote}`, null, { quiet: true });
|
|
514
|
+
box.innerHTML = n?.notes ? mdLite(n.notes) : `<p class="muted">${esc(tr('no notes for this version'))}</p>`;
|
|
515
|
+
const ver = n?.version ? `${tr('version')} ${n.version}${n.source === 'github' ? ` · ${tr('not installed yet')}` : ''}` : '';
|
|
516
|
+
const link = /^https:\/\//.test(n?.url || '')
|
|
517
|
+
? ` · <a href="${esc(n.url)}" target="_blank" rel="noopener noreferrer">${esc(tr('release page'))}</a>` : '';
|
|
518
|
+
sub.innerHTML = esc(ver) + link;
|
|
519
|
+
} catch { box.textContent = '—'; sub.textContent = ''; }
|
|
520
|
+
};
|
|
478
521
|
api('GET', '/api/about').catch(() => null).then((a) => { about = a; })
|
|
479
|
-
.then(() => api('GET', '/api/update-check')).then(renderUpd)
|
|
522
|
+
.then(() => api('GET', '/api/update-check')).then((u) => { renderUpd(u); return loadNews(u); })
|
|
480
523
|
.catch(() => { const s = $('about-upd-sub'); if (s) s.textContent = '—'; });
|
|
481
524
|
const showFailCmd = (cmd) => {
|
|
482
525
|
const wrap = $('about-upd-fail');
|
|
@@ -531,7 +574,7 @@ async function renderSection(sec) {
|
|
|
531
574
|
$('about-recheck').onclick = async () => {
|
|
532
575
|
const btn = $('about-recheck');
|
|
533
576
|
btn.disabled = true; $('about-upd-sub').textContent = tr('checking…');
|
|
534
|
-
try {
|
|
577
|
+
try { const u = await api('GET', '/api/update-check?refresh=1'); renderUpd(u); await loadNews(u, true); }
|
|
535
578
|
catch { $('about-upd-sub').textContent = '—'; }
|
|
536
579
|
btn.disabled = false;
|
|
537
580
|
};
|
package/public/js/sidebar.js
CHANGED
|
@@ -374,7 +374,10 @@ export async function checkPath(path) {
|
|
|
374
374
|
setState('wiz-path-state', `${tr('the folder is there')} · ${r.dirs.length} ${tr('subfolders')}`, 'ok');
|
|
375
375
|
return true;
|
|
376
376
|
} catch (e) {
|
|
377
|
-
|
|
377
|
+
const said = { 404: tr('no such folder on this computer'),
|
|
378
|
+
400: tr('this path is not a folder'),
|
|
379
|
+
403: tr('the folder is there, but the board cannot read it — check its permissions') }[e.status];
|
|
380
|
+
setState('wiz-path-state', said || `${tr('could not check the folder')}: ${e.message || path}`, 'bad');
|
|
378
381
|
return false;
|
|
379
382
|
}
|
|
380
383
|
}
|
package/public/style.css
CHANGED
|
@@ -995,6 +995,19 @@ body.dragging-board .col-add-ghost { display: none; }
|
|
|
995
995
|
.theme-opt { border: none; background: var(--panel); color: var(--muted); font: inherit; font-size: 12.5px; padding: 6px 12px; cursor: pointer; }
|
|
996
996
|
.theme-opt + .theme-opt { border-left: 1px solid var(--border); }
|
|
997
997
|
.theme-opt.active { background: var(--accent); color: #fff; }
|
|
998
|
+
.about-news {
|
|
999
|
+
max-height: 260px; overflow-y: auto; font-size: 12.5px; line-height: 1.5;
|
|
1000
|
+
border: 1px solid var(--border-soft); border-radius: 8px; padding: 10px 12px; background: var(--panel);
|
|
1001
|
+
}
|
|
1002
|
+
.about-news h4 { font-size: 12.5px; font-weight: 600; margin: 12px 0 6px; color: var(--muted); }
|
|
1003
|
+
.about-news h4:first-child { margin-top: 0; }
|
|
1004
|
+
.about-news p { margin: 0 0 8px; }
|
|
1005
|
+
.about-news ul { margin: 0 0 8px; padding-left: 18px; }
|
|
1006
|
+
.about-news li { margin-bottom: 7px; }
|
|
1007
|
+
.about-news li:last-child, .about-news p:last-child { margin-bottom: 0; }
|
|
1008
|
+
.about-news code { font-size: 11.5px; background: var(--border-soft); border-radius: 4px; padding: 1px 4px; }
|
|
1009
|
+
.about-news a { color: var(--accent); }
|
|
1010
|
+
|
|
998
1011
|
.bk-list { display: flex; flex-direction: column; gap: 0; margin-top: 6px; }
|
|
999
1012
|
.bk-row { display: flex; justify-content: space-between; gap: 12px; font-size: 12.5px; padding: 7px 0; border-top: 1px solid var(--border-soft); }
|
|
1000
1013
|
.bk-row:first-child { border-top: none; }
|
package/skills/kanban/SKILL.md
CHANGED
|
@@ -15,10 +15,14 @@ Statuses: `backlog` → `todo` → `prep` → `doing` → `deploy` → `review`
|
|
|
15
15
|
|
|
16
16
|
```
|
|
17
17
|
kb take <proj> # take the next task from todo → prep; prints the description,
|
|
18
|
-
# the
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
18
|
+
# the checklist as "- [ ] item" / "- [x] item" lines (that IS the
|
|
19
|
+
# spec — do every open item, and verify them one by one before
|
|
20
|
+
# review), the issue as "issue: #N <link>" (#N goes into commit
|
|
21
|
+
# messages; kb show prints the same line), EVERY note the
|
|
22
|
+
# human left since your last report, dated — "> 👤 [2026-09-02 11:30]
|
|
23
|
+
# …" (if the task was sent back, act on them!) and the paths of
|
|
24
|
+
# attached screenshots as "📎 <path>" — OPEN those with Read, they
|
|
25
|
+
# are context for the task.
|
|
22
26
|
# started writing code → kb mv <key> doing.
|
|
23
27
|
kb review <key> "note" # leave a note and move to review. NOT atomic: the note is stored
|
|
24
28
|
# EVEN IF the move is rejected (backlog and todo cannot go straight
|
|
@@ -27,7 +31,11 @@ kb review <key> "note" # leave a note and move to review. NOT atomic: the
|
|
|
27
31
|
# never went through doing.
|
|
28
32
|
kb p # projects: slug (PREFIX) b0 t2 pp0 d1 dp0 r1 ✓5 !1 (pp = prep)
|
|
29
33
|
kb ls <proj> [-s st] [-a] # tasks (without -a, backlog/done/cancelled are hidden)
|
|
30
|
-
kb show <key> # details + issue +
|
|
34
|
+
kb show <key> # details + checklist + issue + comments: the last five plus every
|
|
35
|
+
# human note since your last report. Marks: 🤖 yours, 👤 the
|
|
36
|
+
# human's, ⎇ a commit stat the board attached by itself (not the
|
|
37
|
+
# human speaking). "… N more comments not printed" = the rest are
|
|
38
|
+
# on the board — nothing is dropped silently.
|
|
31
39
|
kb add <proj> "title" [-d "desc"] [-p high|med|low] [-s todo] # goes to backlog by default!
|
|
32
40
|
kb mv <key> todo|prep|doing|deploy|review|done|cancelled
|
|
33
41
|
kb done <key> · kb cancel <key> · kb pri <key> high|med|low|none
|
|
@@ -40,9 +48,16 @@ kb lbl <key> +bug -ui # labels: bug docs duplicate enhancement ui featur
|
|
|
40
48
|
# label goes into the LABELS palette first, and into this file.
|
|
41
49
|
kb desc <key> "description" # set or overwrite a task description (used when grooming the backlog)
|
|
42
50
|
kb note <key> "text" # a standalone comment (rarely needed — kb review covers it)
|
|
43
|
-
kb
|
|
51
|
+
kb check <key> <n> # tick checklist item n (from 1, in the order show/take print them)
|
|
52
|
+
# · kb uncheck <key> <n> clears it. Answers "- [x] item (2 open)"
|
|
53
|
+
kb link <key> <key> [child|parent] # link two tasks (the board draws a line); `child` makes the
|
|
54
|
+
# second task a sub-task of the first, `parent` its parent, no word
|
|
55
|
+
# = plain related · kb unlink to unlink
|
|
44
56
|
kb block <key> "why" · kb unblock <key>
|
|
45
|
-
kb info <proj> # path, deploy_skill, server, pm2, domain
|
|
57
|
+
kb info <proj> # path, deploy_skill, server, pm2, domain, the project's description
|
|
58
|
+
# (the owner's context for work — read it) and "docs: CLAUDE.md,
|
|
59
|
+
# README.md" — the documents to read before the task; "docs: no
|
|
60
|
+
# CLAUDE.md — read README.md instead" is a signal, not an empty line
|
|
46
61
|
kb svc <proj> # live status of the project's services (local pm2 or your own PANEL_URL)
|
|
47
62
|
kb sync # state of the sync queue (or "sync is off" in local-only mode)
|
|
48
63
|
```
|
|
@@ -92,15 +107,18 @@ Claude takes a task as far as *review* and stops. Only the user moves anything t
|
|
|
92
107
|
**When the prompt lists specific keys** (the 📋 button on the "To do" column produces them) those
|
|
93
108
|
are SPECIFIC tasks, not a queue. Take each one with `kb show <key>` (NOT `kb take`, which pulls
|
|
94
109
|
ONE next task from the project's `todo`) and **move it to prep right away: `kb mv <key> prep`** —
|
|
95
|
-
`kb show` does not change the status. If `kb show` printed `> 👤 Send back: …`, the task was
|
|
96
|
-
returned: open the `📎` screenshots with Read and act on that note FIRST. Then follow steps 2–6
|
|
110
|
+
`kb show` does not change the status. If `kb show` printed `> 👤 [date] Send back: …`, the task was
|
|
111
|
+
returned — the date tells a fresh send-back from an old one you already answered: open the `📎` screenshots with Read and act on that note FIRST. Then follow steps 2–6
|
|
97
112
|
for each. Use `kb take X` only when asked to "work through project X" without specific keys.
|
|
98
113
|
|
|
99
114
|
1. `kb take X` — take a task (lands in prep; description and issue are already in the output).
|
|
100
|
-
"empty" → report it and stop.
|
|
115
|
+
"empty" → report it and stop. "empty (N blocked)" is NOT an empty column: every card in To do
|
|
116
|
+
is blocked — list them (`kb ls X -s todo`) and report that, not "nothing to do". `> 👤 …` lines are what the human wrote since your last report
|
|
117
|
+
(a send-back and its follow-ups) — act on all of them first.
|
|
101
118
|
`📎 <path>` — open the screenshot with Read.
|
|
102
119
|
2. **HARD RULE — starting a task (never skip, even for a one-line change).** `kb info X` → go to
|
|
103
|
-
`path
|
|
120
|
+
`path`; its `description:` and `docs:` lines say what the owner wants you to know and which
|
|
121
|
+
documents the project has. Before any code you MUST read: (a) the project's `CLAUDE.md` if it has one — **and if
|
|
104
122
|
it does not, find where the project keeps its architecture or research document (`README.md`,
|
|
105
123
|
`EXPERIMENTS.md`, `docs/`) and read that; apply the "doc in the same commit" rule to it**;
|
|
106
124
|
(b) the deploy skill at `deploy_skill_path` from `kb info`, if one is set. The matching commit
|
|
@@ -126,7 +144,9 @@ for each. Use `kb take X` only when asked to "work through project X" without sp
|
|
|
126
144
|
The generic `deploy` skill ships with the board (`skills/deploy/`) and works purely off
|
|
127
145
|
`kb info` data. Production commands (ssh/rsync) will ask for confirmation — that is expected.
|
|
128
146
|
5. `kb review <key> "<two plain sentences: 1) what the problem was 2) how you solved it>"` → take
|
|
129
|
-
the next one.
|
|
147
|
+
the next one. **If the task has a checklist, tick each item as you finish it (`kb check <key>
|
|
148
|
+
<n>`).** Before review every item is either ticked or named in the note as not done — the human
|
|
149
|
+
accepts the task by those boxes, and an unticked done item reads as undone.
|
|
130
150
|
6. Stuck, or the decision is the human's → `kb block <key> "reason"` and move on.
|
|
131
151
|
7. At the end, a one-line summary: how many in review, how many blocked.
|
|
132
152
|
|
|
@@ -161,7 +181,9 @@ One line of summary at the end.
|
|
|
161
181
|
- **You found an unrelated problem along the way** → do not fix it silently. Create a task
|
|
162
182
|
(`kb add <proj> "..." -d "..."`) and ask in chat: do it now, defer it, or is it for the human.
|
|
163
183
|
- **Related tasks solved together** → take both at once (both in doing), fix them in one pass and
|
|
164
|
-
link them with `kb link`.
|
|
184
|
+
link them with `kb link`. `kb show`/`kb take` print a task's links as `↑ parent KEY title
|
|
185
|
+
(status)`, `↓ subtask …`, `↔ related …` — look at them before you start: a linked task in To do
|
|
186
|
+
is usually meant to go together with this one, and a parent explains why the task exists.
|
|
165
187
|
- **A discussion or question task** (no code): study it in `prep`, put the full answer in
|
|
166
188
|
`kb note <key> "..."`, then `kb mv <key> review`. You write no code and do not move it to done.
|
|
167
189
|
- A "check whether the service is alive" task → `kb svc <proj>` (one line per service, no ssh).
|
package/src/config.js
CHANGED
|
@@ -38,3 +38,4 @@ export const allowedHosts = () => env('KB_ALLOWED_HOSTS').split(',').map((s) =>
|
|
|
38
38
|
export const langOverride = () => env('KB_LANG');
|
|
39
39
|
export const ghOwnerEnv = () => env('KB_GH_OWNER');
|
|
40
40
|
export const ghRepoEnv = () => env('KB_GH_REPO');
|
|
41
|
+
export const ghBinEnv = () => expandPath(env('KB_GH_BIN'));
|
package/src/routes/system.js
CHANGED
|
@@ -20,8 +20,8 @@ let selfPkgCache;
|
|
|
20
20
|
function selfPkg() {
|
|
21
21
|
if (selfPkgCache === undefined) {
|
|
22
22
|
try {
|
|
23
|
-
const { name, version } = JSON.parse(readFileSync(join(ROOT_DIR, 'package.json'), 'utf8'));
|
|
24
|
-
selfPkgCache = name && version ? { name, version } : null;
|
|
23
|
+
const { name, version, repository } = JSON.parse(readFileSync(join(ROOT_DIR, 'package.json'), 'utf8'));
|
|
24
|
+
selfPkgCache = name && version ? { name, version, repository: repository?.url || repository || null } : null;
|
|
25
25
|
} catch { selfPkgCache = null; }
|
|
26
26
|
}
|
|
27
27
|
return selfPkgCache;
|
|
@@ -82,6 +82,32 @@ export async function registryCheck() {
|
|
|
82
82
|
} catch { return null; }
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
export async function ghApi(path) {
|
|
86
|
+
try {
|
|
87
|
+
const res = await fetch(`https://api.github.com/${path}`, {
|
|
88
|
+
headers: { 'User-Agent': 'local-kanban', Accept: 'application/vnd.github+json' },
|
|
89
|
+
signal: AbortSignal.timeout(8000),
|
|
90
|
+
});
|
|
91
|
+
if (res.ok) return await res.json();
|
|
92
|
+
} catch { }
|
|
93
|
+
try { return JSON.parse(await ghCli(['api', path], { timeout: 10000 })); }
|
|
94
|
+
catch { return null; }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function changelogSection(text, version) {
|
|
98
|
+
const head = new RegExp(`^##\\s*\\[?${String(version).replace(/\./g, '\\.')}\\]?`, 'm');
|
|
99
|
+
const m = head.exec(String(text || ''));
|
|
100
|
+
if (!m) return null;
|
|
101
|
+
const rest = text.slice(m.index + m[0].length);
|
|
102
|
+
const end = rest.search(/^## /m);
|
|
103
|
+
return (end === -1 ? rest : rest.slice(0, end)).trim() || null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function repoSlug(url) {
|
|
107
|
+
const m = String(url || '').match(/github\.com[:/]([\w.-]+)\/([\w.-]+?)(?:\.git)?(?:#.*)?$/);
|
|
108
|
+
return m ? `${m[1]}/${m[2]}` : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
85
111
|
const UPLOAD_FLOOR = 64 * 1024 * 1024;
|
|
86
112
|
function uploadLimit() {
|
|
87
113
|
try { return Math.max(UPLOAD_FLOOR, statSync(join(DATA_DIR, 'kanban.db')).size * 4); } catch { return UPLOAD_FLOOR; }
|
|
@@ -255,17 +281,6 @@ export default async function systemRoutes(app) {
|
|
|
255
281
|
const git = (args) => new Promise((resolve) => {
|
|
256
282
|
execFile('git', args, { cwd: ROOT, timeout: 8000 }, (err, out) => resolve(err ? null : out.trim()));
|
|
257
283
|
});
|
|
258
|
-
const ghApi = async (path) => {
|
|
259
|
-
try {
|
|
260
|
-
const res = await fetch(`https://api.github.com/${path}`, {
|
|
261
|
-
headers: { 'User-Agent': 'local-kanban', Accept: 'application/vnd.github+json' },
|
|
262
|
-
signal: AbortSignal.timeout(8000),
|
|
263
|
-
});
|
|
264
|
-
if (res.ok) return await res.json();
|
|
265
|
-
} catch { }
|
|
266
|
-
try { return JSON.parse(await ghCli(['api', path], { timeout: 10000 })); }
|
|
267
|
-
catch { return null; }
|
|
268
|
-
};
|
|
269
284
|
let data = { update_available: null, local: null, remote: null, branch: null, tag: null, dev: null };
|
|
270
285
|
try {
|
|
271
286
|
const [local, branch, origin, tag] = await Promise.all([
|
|
@@ -300,6 +315,30 @@ export default async function systemRoutes(app) {
|
|
|
300
315
|
return data;
|
|
301
316
|
});
|
|
302
317
|
|
|
318
|
+
const NEWS_OK_MS = 3600000;
|
|
319
|
+
const NEWS_FAIL_MS = 60000;
|
|
320
|
+
let newsCache = { at: 0, ttl: 0, data: null };
|
|
321
|
+
app.get('/api/whats-new', async (req) => {
|
|
322
|
+
const version = selfPkg()?.version || null;
|
|
323
|
+
const own = () => {
|
|
324
|
+
let text = null;
|
|
325
|
+
try { text = readFileSync(join(ROOT_DIR, 'CHANGELOG.md'), 'utf8'); } catch { }
|
|
326
|
+
return { version, notes: (text && version) ? changelogSection(text, version) : null, source: 'changelog', url: null };
|
|
327
|
+
};
|
|
328
|
+
if (!req.query?.remote) return own();
|
|
329
|
+
if (!req.query?.refresh && newsCache.data && Date.now() - newsCache.at < newsCache.ttl) return newsCache.data;
|
|
330
|
+
const repo = repoSlug(selfPkg()?.repository);
|
|
331
|
+
const rel = repo ? await ghApi(`repos/${repo}/releases/latest`) : null;
|
|
332
|
+
const tag = String(rel?.tag_name || '').replace(/^v/, '');
|
|
333
|
+
const body = String(rel?.body || '').trim();
|
|
334
|
+
const got = Boolean(tag && body);
|
|
335
|
+
const data = got
|
|
336
|
+
? { version: tag, notes: body, source: 'github', url: rel.html_url || null }
|
|
337
|
+
: own();
|
|
338
|
+
newsCache = { at: Date.now(), ttl: got ? NEWS_OK_MS : NEWS_FAIL_MS, data };
|
|
339
|
+
return data;
|
|
340
|
+
});
|
|
341
|
+
|
|
303
342
|
let updateRunning = null;
|
|
304
343
|
app.post('/api/update', async (req, reply) => {
|
|
305
344
|
if (updateRunning) return reply.code(409).send({ error: 'an update is already running' });
|
package/src/sync/github.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { db, ghOwner, ghRepo, uiLang } from '../db.js';
|
|
4
|
+
import { ghBinEnv } from '../config.js';
|
|
4
5
|
const GH_CANDIDATES = [
|
|
5
6
|
'/opt/homebrew/bin/gh', '/usr/local/bin/gh', '/usr/bin/gh',
|
|
6
7
|
'/home/linuxbrew/.linuxbrew/bin/gh', `${process.env.HOME}/.local/bin/gh`,
|
|
7
8
|
];
|
|
8
|
-
const
|
|
9
|
+
const GH_FOUND = GH_CANDIDATES.find((p) => existsSync(p)) || 'gh';
|
|
10
|
+
const ghBin = () => ghBinEnv() || GH_FOUND;
|
|
9
11
|
|
|
10
12
|
const PERMANENT = [
|
|
11
13
|
/validation failed/i,
|
|
@@ -19,7 +21,7 @@ export function isPermanent(stderr) {
|
|
|
19
21
|
|
|
20
22
|
export function gh(args, { timeout = 60000 } = {}) {
|
|
21
23
|
return new Promise((resolve, reject) => {
|
|
22
|
-
execFile(
|
|
24
|
+
execFile(ghBin(), args, { timeout, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
23
25
|
if (err) {
|
|
24
26
|
const e = new Error((stderr || err.message || '').trim().slice(0, 500));
|
|
25
27
|
e.permanent = isPermanent(String(stderr));
|