local-kanban 1.6.3

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.
Files changed (64) hide show
  1. package/CHANGELOG.md +381 -0
  2. package/LICENSE +21 -0
  3. package/README.md +144 -0
  4. package/README.ru.md +144 -0
  5. package/bin/kb +306 -0
  6. package/bin/local-kanban +217 -0
  7. package/ecosystem.example.cjs +25 -0
  8. package/package.json +52 -0
  9. package/public/app.js +18 -0
  10. package/public/boot-fallback.js +3 -0
  11. package/public/drawer-sync.js +14 -0
  12. package/public/favicon-180.png +0 -0
  13. package/public/favicon-32.png +0 -0
  14. package/public/favicon-64.png +0 -0
  15. package/public/favicon.ico +0 -0
  16. package/public/favicon.svg +6 -0
  17. package/public/icon-192.png +0 -0
  18. package/public/icon-512.png +0 -0
  19. package/public/icon-maskable-192.png +0 -0
  20. package/public/icon-maskable-512.png +0 -0
  21. package/public/index.html +138 -0
  22. package/public/js/board.js +698 -0
  23. package/public/js/chaos.js +247 -0
  24. package/public/js/core.js +821 -0
  25. package/public/js/dash.js +114 -0
  26. package/public/js/drawer.js +919 -0
  27. package/public/js/horizon.js +140 -0
  28. package/public/js/init.js +208 -0
  29. package/public/js/project.js +338 -0
  30. package/public/js/settings.js +905 -0
  31. package/public/js/sidebar.js +640 -0
  32. package/public/js/sse.js +386 -0
  33. package/public/manifest.webmanifest +32 -0
  34. package/public/offline.html +39 -0
  35. package/public/style.css +1247 -0
  36. package/public/sw.js +31 -0
  37. package/scripts/raycast/open-kanban.sh +18 -0
  38. package/scripts/update.js +43 -0
  39. package/skills/deploy/SKILL.md +70 -0
  40. package/skills/kanban/SKILL.md +217 -0
  41. package/src/backup.js +111 -0
  42. package/src/bus.js +10 -0
  43. package/src/config.js +40 -0
  44. package/src/db.js +321 -0
  45. package/src/error-handler.js +11 -0
  46. package/src/file-snapshot.js +22 -0
  47. package/src/origin-guard.js +32 -0
  48. package/src/pm2-services.js +31 -0
  49. package/src/query-params.js +17 -0
  50. package/src/repo-base.js +23 -0
  51. package/src/restore.js +153 -0
  52. package/src/routes/dashboard.js +132 -0
  53. package/src/routes/events.js +30 -0
  54. package/src/routes/horizons.js +66 -0
  55. package/src/routes/projects.js +532 -0
  56. package/src/routes/skills.js +116 -0
  57. package/src/routes/system.js +285 -0
  58. package/src/routes/tasks.js +477 -0
  59. package/src/schema.sql +135 -0
  60. package/src/server.js +100 -0
  61. package/src/skills.js +119 -0
  62. package/src/sync/github.js +265 -0
  63. package/src/sync/worker.js +136 -0
  64. package/src/task-shape.js +29 -0
@@ -0,0 +1,532 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync } from 'node:fs';
2
+ import { execFile } from 'node:child_process';
3
+ import { join, basename, resolve, sep } from 'node:path';
4
+ import { db, DATA_DIR, genPrefix, usedPrefixes, makePrefix, kvGet, kvSet, logError } from '../db.js';
5
+ import { localRoot, panelUrl, panelInfo } from '../config.js';
6
+ import { emit } from '../bus.js';
7
+ import { forgetRepoBase } from '../repo-base.js';
8
+ import { parsePm2Services, serializePm2Services } from '../pm2-services.js';
9
+ import { GENERIC_DEPLOY_SKILL, SKILL_NAME_RE, resolveSkillPath, skillInfo } from '../skills.js';
10
+ import { snapshotFile } from '../file-snapshot.js';
11
+
12
+ const DEFAULT_CATEGORY = 'Other';
13
+ const LOCKED_CATEGORIES = new Set([DEFAULT_CATEGORY, 'Local']);
14
+ function extraCategories() {
15
+ try {
16
+ const v = JSON.parse(kvGet('ui.categories') || '[]');
17
+ return Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x.trim()) : [];
18
+ } catch { return []; }
19
+ }
20
+ function setExtraCategories(list) {
21
+ kvSet('ui.categories', JSON.stringify([...new Set(list)]));
22
+ }
23
+ function categoryNames() {
24
+ const rows = db.prepare('SELECT DISTINCT COALESCE(NULLIF(category, \'\'), ?) AS cat FROM projects').all(DEFAULT_CATEGORY);
25
+ return [...new Set([...rows.map((r) => r.cat), ...extraCategories()])];
26
+ }
27
+
28
+ function scanFolders() {
29
+ let dirs;
30
+ try {
31
+ dirs = readdirSync(localRoot(), { withFileTypes: true })
32
+ .filter((d) => d.isDirectory() && !d.name.startsWith('.')).map((d) => d.name);
33
+ } catch { return { root: localRoot(), unregistered: [], missing: [] }; }
34
+ const projects = db.prepare('SELECT slug, name, path, archived FROM projects').all();
35
+ const underRoot = projects.filter((p) => p.path && p.path.startsWith(`${localRoot()}/`));
36
+ const registered = new Set(underRoot.map((p) => basename(p.path)));
37
+ const unregistered = dirs.filter((name) => !registered.has(name));
38
+ const missing = underRoot
39
+ .filter((p) => !p.archived && !existsSync(p.path))
40
+ .map((p) => ({ slug: p.slug, name: p.name, path: p.path }));
41
+ return { root: localRoot(), unregistered, missing };
42
+ }
43
+
44
+ function syncCategoryToPanel(project) {
45
+ if (!panelInfo()) return;
46
+ try {
47
+ const services = parsePm2Services(project.pm2_services);
48
+ if (!services.length || !existsSync(panelInfo())) return;
49
+ const info = JSON.parse(readFileSync(panelInfo(), 'utf8'));
50
+ let changed = false;
51
+ for (const name of services) {
52
+ if (info[name] && info[name].category !== project.category) {
53
+ info[name].category = project.category;
54
+ changed = true;
55
+ }
56
+ }
57
+ if (changed) writeFileSync(panelInfo(), JSON.stringify(info, null, 2) + '\n');
58
+ } catch { }
59
+ }
60
+
61
+ const PATCHABLE = ['name', 'prefix', 'path', 'server_path', 'deploy_skill', 'server', 'pm2_services', 'domain', 'category', 'description', 'pinned', 'archived'];
62
+
63
+ const FIELD_KIND = { pinned: 'flag', archived: 'flag', pm2_services: 'any' };
64
+ function badField(k, v) {
65
+ const kind = FIELD_KIND[k] || 'text';
66
+ if (kind === 'any') return null;
67
+ if (kind === 'flag') {
68
+ if (v === null || v === undefined) return `${k}: expected 0 or 1`;
69
+ if (typeof v === 'boolean' || v === 0 || v === 1 || v === '0' || v === '1') return null;
70
+ return `${k}: expected 0 or 1`;
71
+ }
72
+ if (v === null) return null;
73
+ if (typeof v === 'string' || typeof v === 'number') return null;
74
+ return `${k}: expected a string`;
75
+ }
76
+ const normField = (k, v) => {
77
+ if (FIELD_KIND[k] === 'flag') return (v === true || v === 1 || v === '1') ? 1 : 0;
78
+ if (FIELD_KIND[k] === 'any') return v;
79
+ return v === null ? null : String(v);
80
+ };
81
+
82
+ export function projectBySlug(slug) {
83
+ return db.prepare('SELECT * FROM projects WHERE slug = ?').get(slug);
84
+ }
85
+
86
+ function checkPrefix(raw, selfId) {
87
+ const pref = String(raw).toUpperCase();
88
+ if (!/^[A-Z0-9]{2,10}$/.test(pref)) return { error: 'prefix: 2-10 latin letters or digits' };
89
+ const clash = db.prepare('SELECT id FROM projects WHERE upper(prefix) = ? AND id != COALESCE(?, -1)').get(pref, selfId ?? null);
90
+ if (clash) return { error: 'prefix is taken by another project' };
91
+ return { pref };
92
+ }
93
+
94
+ export function resolveProject(key) {
95
+ if (!key) return null;
96
+ return projectBySlug(key)
97
+ || db.prepare('SELECT * FROM projects WHERE upper(prefix) = upper(?)').get(key);
98
+ }
99
+
100
+ let panelCache = { at: 0, data: null };
101
+
102
+ async function remoteStats() {
103
+ const res = await fetch(panelUrl(), { signal: AbortSignal.timeout(8000) });
104
+ const text = await res.text();
105
+ const clean = text.replace(new RegExp('[\\u0000-\\u001f]', 'g'), (c) => (c === '\n' ? '\\n' : c === '\t' ? '\\t' : ''));
106
+ const data = JSON.parse(clean);
107
+ const list = Array.isArray(data) ? data : data.pm2 || [];
108
+ return list.map((x) => ({
109
+ name: x.name, status: x.status, uptime: x.uptime,
110
+ restarts: x.restarts, memory: x.rssTotal ?? x.memory ?? 0, cpu: x.cpu ?? 0,
111
+ }));
112
+ }
113
+
114
+ function localPm2Stats() {
115
+ return new Promise((resolve, reject) => {
116
+ execFile('pm2', ['jlist'], { timeout: 8000, maxBuffer: 8 * 1024 * 1024 }, (err, stdout) => {
117
+ if (err) {
118
+ return reject(new Error(err.code === 'ENOENT'
119
+ ? 'no status source configured: local pm2 was not found and KB_PANEL_URL is not set'
120
+ : `pm2 jlist: ${String(err.message).slice(0, 120)}`));
121
+ }
122
+ try {
123
+ const raw = stdout.slice(stdout.indexOf('['));
124
+ resolve(JSON.parse(raw).map((p) => ({
125
+ name: p.name, status: p.pm2_env?.status, uptime: p.pm2_env?.pm_uptime,
126
+ restarts: p.pm2_env?.restart_time, memory: p.monit?.memory || 0, cpu: p.monit?.cpu || 0,
127
+ })));
128
+ } catch (e) { reject(new Error(`pm2 jlist: could not parse the output (${e.message})`)); }
129
+ });
130
+ });
131
+ }
132
+
133
+ async function serviceStats() {
134
+ if (Date.now() - panelCache.at < 30000) return panelCache.data;
135
+ const data = panelUrl() ? await remoteStats() : await localPm2Stats();
136
+ panelCache = { at: Date.now(), data };
137
+ return data;
138
+ }
139
+
140
+ export default async function projectRoutes(app) {
141
+ app.get('/api/projects', () => {
142
+ return db.prepare(`
143
+ SELECT p.*,
144
+ SUM(CASE WHEN t.status='backlog' THEN 1 ELSE 0 END) AS c_backlog,
145
+ SUM(CASE WHEN t.status='todo' THEN 1 ELSE 0 END) AS c_todo,
146
+ SUM(CASE WHEN t.status='prep' THEN 1 ELSE 0 END) AS c_prep,
147
+ SUM(CASE WHEN t.status='doing' THEN 1 ELSE 0 END) AS c_doing,
148
+ SUM(CASE WHEN t.status='deploy' THEN 1 ELSE 0 END) AS c_deploy,
149
+ SUM(CASE WHEN t.status='review' THEN 1 ELSE 0 END) AS c_review,
150
+ SUM(CASE WHEN t.status='done' THEN 1 ELSE 0 END) AS c_done,
151
+ SUM(CASE WHEN t.status='cancelled' THEN 1 ELSE 0 END) AS c_cancelled,
152
+ SUM(CASE WHEN t.blocked=1 AND t.status NOT IN ('done','cancelled') THEN 1 ELSE 0 END) AS c_blocked
153
+ FROM projects p
154
+ LEFT JOIN tasks t ON t.project_id = p.id
155
+ WHERE p.archived = 0
156
+ GROUP BY p.id
157
+ ORDER BY p.position, p.name COLLATE NOCASE
158
+ `).all();
159
+ });
160
+
161
+ app.get('/api/projects/archived', () => db.prepare(`
162
+ SELECT p.slug, p.name, p.category, p.archived,
163
+ (SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS tasks_n
164
+ FROM projects p WHERE p.archived = 1 ORDER BY p.name COLLATE NOCASE
165
+ `).all());
166
+
167
+ app.post('/api/projects/reorder', (req, reply) => {
168
+ const slugs = Array.isArray(req.body?.slugs) ? req.body.slugs : null;
169
+ if (!slugs) return reply.code(400).send({ error: 'slugs[] required' });
170
+ if (slugs.some((s) => typeof s !== 'string')) return reply.code(400).send({ error: 'slugs[]: expected strings' });
171
+ const upd = db.prepare('UPDATE projects SET position = ? WHERE slug = ?');
172
+ const tx = db.transaction((list) => list.forEach((s, i) => upd.run(i, s)));
173
+ tx(slugs);
174
+ return { ok: true };
175
+ });
176
+
177
+ app.get('/api/categories', () => {
178
+ const rows = db.prepare(`SELECT COALESCE(NULLIF(category, ''), ?) AS cat, COUNT(*) AS count
179
+ FROM projects WHERE archived = 0
180
+ GROUP BY COALESCE(NULLIF(category, ''), ?)
181
+ ORDER BY MIN(position), MIN(name) COLLATE NOCASE`).all(DEFAULT_CATEGORY, DEFAULT_CATEGORY);
182
+ const list = rows.map((r) => ({ name: r.cat, count: r.count, locked: LOCKED_CATEGORIES.has(r.cat) }));
183
+ const have = new Set(rows.map((r) => r.cat));
184
+ for (const name of extraCategories()) {
185
+ if (!have.has(name)) list.push({ name, count: 0, locked: LOCKED_CATEGORIES.has(name) });
186
+ }
187
+ return list;
188
+ });
189
+
190
+ app.post('/api/categories', (req, reply) => {
191
+ const name = String(req.body?.name || '').trim();
192
+ if (!name) return reply.code(400).send({ error: 'name required' });
193
+ if (name.length > 40) return reply.code(400).send({ error: 'category name: up to 40 characters' });
194
+ if (categoryNames().includes(name)) return reply.code(409).send({ error: 'that category already exists' });
195
+ setExtraCategories([...extraCategories(), name]);
196
+ emit('category.updated', { name });
197
+ return { ok: true, name };
198
+ });
199
+
200
+ app.patch('/api/categories/:name', (req, reply) => {
201
+ const from = req.params.name;
202
+ const to = String(req.body?.name || '').trim();
203
+ if (!to) return reply.code(400).send({ error: 'name required' });
204
+ if (to.length > 40) return reply.code(400).send({ error: 'category name: up to 40 characters' });
205
+ if (LOCKED_CATEGORIES.has(from)) return reply.code(400).send({ error: `the "${from}" category cannot be renamed` });
206
+ if (!categoryNames().includes(from)) return reply.code(404).send({ error: 'category not found' });
207
+ if (to !== from && categoryNames().includes(to)) return reply.code(409).send({ error: 'that category already exists' });
208
+ db.prepare('UPDATE projects SET category = ?, updated_at = datetime(\'now\') WHERE category = ?').run(to, from);
209
+ setExtraCategories(extraCategories().map((c) => (c === from ? to : c)));
210
+ emit('category.updated', { name: to, from });
211
+ return { ok: true, name: to };
212
+ });
213
+
214
+ app.delete('/api/categories/:name', (req, reply) => {
215
+ const name = req.params.name;
216
+ if (LOCKED_CATEGORIES.has(name)) return reply.code(400).send({ error: `the "${name}" category cannot be deleted` });
217
+ if (!categoryNames().includes(name)) return reply.code(404).send({ error: 'category not found' });
218
+ db.prepare('UPDATE projects SET category = NULL, updated_at = datetime(\'now\') WHERE category = ?').run(name);
219
+ setExtraCategories(extraCategories().filter((c) => c !== name));
220
+ emit('category.updated', { name, removed: true });
221
+ return { ok: true };
222
+ });
223
+
224
+ app.get('/api/projects/folders', () => scanFolders());
225
+
226
+ app.post('/api/projects/folders', (req, reply) => {
227
+ const raw = req.body?.name;
228
+ if (raw !== undefined && raw !== null && typeof raw !== 'string' && typeof raw !== 'number') {
229
+ return reply.code(400).send({ error: 'name: expected a string' });
230
+ }
231
+ const name = String(raw ?? '').trim();
232
+ if (!name) return reply.code(400).send({ error: 'name required' });
233
+ if (!/^[A-Za-z0-9._-]+$/.test(name) || name.startsWith('.')) {
234
+ return reply.code(400).send({ error: 'folder name: latin letters, digits, - and _, no slashes' });
235
+ }
236
+ const dir = join(localRoot(), name);
237
+ const existed = existsSync(dir);
238
+ if (!existed) {
239
+ try { mkdirSync(dir, { recursive: true }); } catch (e) {
240
+ logError('server', 'POST /api/projects/folders', `mkdir failed: ${e.message}`, dir);
241
+ return reply.code(500).send({ error: `mkdir failed: ${e.message}` });
242
+ }
243
+ }
244
+ if (db.prepare('SELECT slug FROM projects WHERE path = ?').get(dir)) {
245
+ return reply.code(409).send({ error: 'folder is already on the board' });
246
+ }
247
+ if (projectBySlug(name)) return reply.code(409).send({ error: 'slug is taken' });
248
+ db.prepare("INSERT INTO projects (slug, name, prefix, path, category) VALUES (?,?,?,?,'Local')")
249
+ .run(name, name, makePrefix(name), dir);
250
+ const p = projectBySlug(name);
251
+ emit('project.updated', p);
252
+ return reply.code(201).send({ ...p, created: !existed });
253
+ });
254
+
255
+ function badDeploySkill(body) {
256
+ const ds = body?.deploy_skill;
257
+ if (ds === undefined || ds === null || ds === '') return null;
258
+ if (SKILL_NAME_RE.test(String(ds))) return null;
259
+ return 'deploy_skill: the skill name only (latin letters, digits, hyphen, underscore), no paths';
260
+ }
261
+
262
+ app.get('/api/projects/:slug', (req, reply) => {
263
+ const p = resolveProject(req.params.slug);
264
+ if (!p) return reply.code(404).send({ error: 'project not found' });
265
+ return { ...p, deploy_skill_path: resolveSkillPath(p.deploy_skill) };
266
+ });
267
+
268
+ const PROJECT_DOCS = ['CLAUDE.md', 'CLAUDE.local.md', 'AGENTS.md', 'README.md', 'ARCHITECTURE.md'];
269
+ const DOC_MAX_BYTES = 512 * 1024;
270
+ function docPath(project, name) {
271
+ if (!project?.path || !PROJECT_DOCS.includes(name)) return null;
272
+ const base = resolve(project.path);
273
+ const p = resolve(base, name);
274
+ return p.startsWith(base + sep) ? p : null;
275
+ }
276
+
277
+ app.get('/api/projects/:slug/docs', (req, reply) => {
278
+ const p = resolveProject(req.params.slug);
279
+ if (!p) return reply.code(404).send({ error: 'project not found' });
280
+ const docs = PROJECT_DOCS.map((name) => {
281
+ const path = docPath(p, name);
282
+ let st = null;
283
+ try { st = path ? statSync(path) : null; } catch { }
284
+ return { name, path, exists: Boolean(st?.isFile()), size: st?.size ?? null, mtime: st ? new Date(st.mtimeMs).toISOString() : null };
285
+ })
286
+ .filter((d) => d.exists || d.name === 'CLAUDE.md');
287
+ const skill = p.deploy_skill ? skillInfo(p.deploy_skill) : null;
288
+ return {
289
+ path: p.path || null,
290
+ path_exists: Boolean(p.path && existsSync(p.path)),
291
+ docs,
292
+ skill: skill && { ...skill, generic: p.deploy_skill === GENERIC_DEPLOY_SKILL },
293
+ };
294
+ });
295
+
296
+ function docRealPath(path) {
297
+ try { return realpathSync(path); } catch { }
298
+ try { return join(realpathSync(join(path, '..')), path.split(sep).pop()); } catch { return path; }
299
+ }
300
+
301
+ app.get('/api/projects/:slug/docs/:name', (req, reply) => {
302
+ const p = resolveProject(req.params.slug);
303
+ if (!p) return reply.code(404).send({ error: 'project not found' });
304
+ const path = docPath(p, req.params.name);
305
+ if (!path) return reply.code(400).send({ error: 'unknown document' });
306
+ let buf;
307
+ try { buf = readFileSync(path); } catch { return reply.code(404).send({ error: 'the file is not there' }); }
308
+ return {
309
+ name: req.params.name,
310
+ path,
311
+ real_path: docRealPath(path),
312
+ text: buf.subarray(0, DOC_MAX_BYTES).toString('utf8'),
313
+ truncated: buf.length > DOC_MAX_BYTES,
314
+ };
315
+ });
316
+
317
+ app.put('/api/projects/:slug/docs/:name', (req, reply) => {
318
+ if (process.env.KB_PREVIEW_TTL_MS) return reply.code(403).send({ error: 'this is a check board opened from a backup — it does not write files' });
319
+ const p = resolveProject(req.params.slug);
320
+ if (!p) return reply.code(404).send({ error: 'project not found' });
321
+ const path = docPath(p, req.params.name);
322
+ if (!path) return reply.code(400).send({ error: 'unknown document' });
323
+ const text = req.body?.text;
324
+ if (typeof text !== 'string') return reply.code(400).send({ error: 'text is required' });
325
+ if (Buffer.byteLength(text) > DOC_MAX_BYTES) return reply.code(413).send({ error: `the file is larger than ${Math.round(DOC_MAX_BYTES / 1024)} KB` });
326
+ if (!existsSync(p.path)) return reply.code(400).send({ error: 'the project folder is not there' });
327
+ const real = docRealPath(path);
328
+ if (req.body?.confirm_path !== real) {
329
+ return reply.code(409).send({ error: 'the file on disk is not the one the page was editing', real_path: real });
330
+ }
331
+ const backup = snapshotFile('docs', `${p.slug}-${req.params.name}`, path);
332
+ writeFileSync(path, text);
333
+ const st = statSync(path);
334
+ return { name: req.params.name, path, real_path: real, size: st.size, mtime: new Date(st.mtimeMs).toISOString(), backup, backup_name: backup ? backup.split(sep).pop() : null };
335
+ });
336
+
337
+ app.get('/api/projects/:slug/status', async (req, reply) => {
338
+ const p = resolveProject(req.params.slug);
339
+ if (!p) return reply.code(404).send({ error: 'project not found' });
340
+ const services = parsePm2Services(p.pm2_services);
341
+ if (!services.length) return reply.code(404).send({ error: 'the project has no pm2 services' });
342
+ let stats;
343
+ try {
344
+ stats = await serviceStats();
345
+ } catch (e) {
346
+ return reply.code(502).send({ error: panelUrl() ? `status source (${panelUrl()}) is unavailable: ${e.message}` : e.message });
347
+ }
348
+ return services.map((name) => {
349
+ const proc = stats.find((x) => x.name === name);
350
+ if (!proc) return { name, status: 'not found' };
351
+ return {
352
+ name,
353
+ status: proc.status,
354
+ uptime_h: proc.uptime ? Math.round((Date.now() - proc.uptime) / 3600000) : null,
355
+ restarts: proc.restarts,
356
+ mem_mb: Math.round((proc.memory || 0) / 1048576),
357
+ cpu: proc.cpu,
358
+ };
359
+ });
360
+ });
361
+
362
+ app.post('/api/projects/clone', async (req, reply) => {
363
+ const url = String(req.body?.url || '').trim();
364
+ let name = String(req.body?.name || '').trim();
365
+ if (!/^(https:\/\/|git@)[\w.@:/~-]+$/.test(url)) return reply.code(400).send({ error: 'git URL: https://… or git@…' });
366
+ if (!name) name = url.split('/').pop().replace(/\.git$/, '');
367
+ if (!/^[A-Za-z0-9._-]+$/.test(name) || name.startsWith('.')) return reply.code(400).send({ error: 'folder name: latin letters, digits, - and _' });
368
+ const dir = join(localRoot(), name);
369
+ if (existsSync(dir)) return reply.code(409).send({ error: `folder ${name} already exists in ${localRoot()}` });
370
+ try {
371
+ await new Promise((resolve, reject) => execFile('git', ['clone', '--', url, dir], {
372
+ timeout: 180000,
373
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', SSH_ASKPASS: '' },
374
+ }, (err, _out, serr) => (err ? reject(new Error(String(serr || err.message).slice(0, 300))) : resolve())));
375
+ } catch (e) { return reply.code(502).send({ error: `git clone: ${e.message}` }); }
376
+ return { path: dir, name };
377
+ });
378
+
379
+ app.post('/api/projects/server-git', async (req, reply) => {
380
+ const server = String(req.body?.server || '').trim();
381
+ const path = String(req.body?.path || '').trim();
382
+ if (!server || !path) return reply.code(400).send({ error: 'server and path are required' });
383
+ if (!/^[\w.@][\w.@-]*$/.test(server)) return reply.code(400).send({ error: 'server: an ssh alias or host, no options or spaces' });
384
+ if (/["'`;|&$\\]/.test(path)) return reply.code(400).send({ error: 'path: invalid characters' });
385
+ const sshRun = (cmd) => new Promise((resolve) => execFile('ssh',
386
+ ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', '--', server, cmd],
387
+ { timeout: 20000 }, (err, out, serr) => resolve({ err, out: String(out || '').trim(), serr: String(serr || '').trim() })));
388
+ const dirCheck = await sshRun(`test -d "${path}" && echo yes`);
389
+ if (dirCheck.out !== 'yes') {
390
+ return reply.code(502).send({ error: `ssh/directory: ${dirCheck.serr || `no ${path} directory, or no access to ${server}`}` });
391
+ }
392
+ if (req.body?.init) {
393
+ const r = await sshRun(`git -C "${path}" init`);
394
+ if (r.err) return reply.code(502).send({ error: `git init: ${r.serr || 'failed'}` });
395
+ return { git: true, initialized: true };
396
+ }
397
+ const r = await sshRun(`git -C "${path}" rev-parse --is-inside-work-tree`);
398
+ return { git: !r.err && r.out === 'true' };
399
+ });
400
+
401
+ const DEMO_TEXTS = {
402
+ ru: {
403
+ name: 'Демо-проект',
404
+ about: 'Пример: как выглядит доска в работе. Удаляется в настройках проекта.',
405
+ rows: [
406
+ ['Идея из бэклога', 'Сюда падают все идеи. Кнопка копирования на колонке «Бэклог» копирует промпт для разбора бэклога Claude-ом.', 'backlog', 0, '[]', 0],
407
+ ['Задача с чек-листом', 'Открой карточку — под описанием чек-лист. Пункты добавляются иконкой в правом углу описания.', 'backlog', 1, '["enhancement"]', 0],
408
+ ['Готова к работе', 'Колонка «Сделать» — очередь для Claude: kb take возьмёт следующую отсюда. Кнопка копирования копирует готовый промпт.', 'todo', 3, '["feature"]', 0],
409
+ ['Связанная задача', 'Связи рисуются линией на доске (меню ⋯ в карточке → «Связать»). Эта связана со следующей.', 'todo', 2, '["ui"]', 0],
410
+ ['На проверке', 'Claude доводит задачу до «Проверяю» и останавливается — принимает или возвращает человек.', 'review', 2, '["bug"]', 1800],
411
+ ['Уже готова', 'Принятые задачи копятся в «Готово» и считаются в метриках дашборда.', 'done', 1, '[]', 3600],
412
+ ],
413
+ checklist: ['посмотреть колонки доски', 'открыть карточку и таймлайн', 'удалить демо, когда надоест'],
414
+ },
415
+ en: {
416
+ name: 'Demo project',
417
+ about: 'An example of the board at work. Delete it any time in the project settings.',
418
+ rows: [
419
+ ['An idea in the backlog', 'All ideas land here. The copy button on the Backlog column copies a grooming prompt for Claude.', 'backlog', 0, '[]', 0],
420
+ ['Task with a checklist', 'Open the card — there is a checklist under the description. Add items with the icon in the top-right corner of the description.', 'backlog', 1, '["enhancement"]', 0],
421
+ ['Ready to work on', 'The To do column is Claude\'s queue: kb take picks the next task from here. The copy button copies a ready-made prompt.', 'todo', 3, '["feature"]', 0],
422
+ ['A linked task', 'Links are drawn as lines on the board (card menu ⋯ → Link). This one is linked to the next task.', 'todo', 2, '["ui"]', 0],
423
+ ['In review', 'Claude takes a task up to Review and stops — accepting or returning it is always up to the human.', 'review', 2, '["bug"]', 1800],
424
+ ['Already done', 'Accepted tasks pile up in Done and feed the dashboard metrics.', 'done', 1, '[]', 3600],
425
+ ],
426
+ checklist: ['look through the board columns', 'open a card and its timeline', 'delete the demo when done with it'],
427
+ },
428
+ };
429
+
430
+ app.post('/api/projects/demo', (req, reply) => {
431
+ if (projectBySlug('demo')) return reply.code(409).send({ error: 'the demo project already exists' });
432
+ const T = DEMO_TEXTS[req.body?.lang === 'en' ? 'en' : 'ru'];
433
+ const used = usedPrefixes();
434
+ const prefix = used.has('DEMO') ? genPrefix('demo', used) : 'DEMO';
435
+ db.prepare("INSERT INTO projects (slug, name, prefix, category, description) VALUES ('demo', ?, ?, ?, ?)")
436
+ .run(T.name, prefix, 'Other', T.about);
437
+ const pid = projectBySlug('demo').id;
438
+ const ins = db.prepare(`INSERT INTO tasks (project_id, task_no, title, description, status, priority, labels, done_at, work_seconds)
439
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
440
+ const doneAt = new Date().toISOString().slice(0, 19).replace('T', ' ');
441
+ const tx = db.transaction(() => {
442
+ const ids = T.rows.map(([title, desc, status, pri, labels, work], i) =>
443
+ ins.run(pid, i + 1, title, desc, status, pri, labels, status === 'done' ? doneAt : null, work).lastInsertRowid);
444
+ db.prepare('UPDATE projects SET next_task_no = ? WHERE id = ?').run(T.rows.length + 1, pid);
445
+ db.prepare("INSERT INTO task_links (task_id, linked_task_id, kind) VALUES (?, ?, 'related')")
446
+ .run(Math.min(ids[2], ids[3]), Math.max(ids[2], ids[3]));
447
+ const chk = db.prepare('INSERT INTO task_checklist (task_id, text, done, position) VALUES (?, ?, ?, ?)');
448
+ T.checklist.forEach((text, i) => chk.run(ids[1], text, i === 0 ? 1 : 0, i + 1));
449
+ db.prepare("INSERT INTO task_events (task_id, status, created_at) VALUES (?, 'done', datetime('now'))").run(ids[5]);
450
+ db.prepare("INSERT INTO task_events (task_id, status, created_at) VALUES (?, 'review', datetime('now'))").run(ids[4]);
451
+ });
452
+ tx();
453
+ const p = projectBySlug('demo');
454
+ emit('project.updated', p);
455
+ return reply.code(201).send(p);
456
+ });
457
+
458
+ app.delete('/api/projects/demo', (req, reply) => {
459
+ const p = projectBySlug('demo');
460
+ if (!p) return reply.code(404).send({ error: 'there is no demo project' });
461
+ const ids = db.prepare('SELECT id FROM tasks WHERE project_id = ?').all(p.id).map((t) => t.id);
462
+ db.prepare('DELETE FROM tasks WHERE project_id = ?').run(p.id);
463
+ db.prepare('DELETE FROM projects WHERE id = ?').run(p.id);
464
+ for (const id of ids) rmSync(join(DATA_DIR, 'attachments', String(id)), { recursive: true, force: true });
465
+ emit('project.updated', { slug: 'demo', deleted: true });
466
+ return { ok: true };
467
+ });
468
+
469
+ app.post('/api/projects', (req, reply) => {
470
+ const { slug, name } = req.body || {};
471
+ if (!slug || !name) return reply.code(400).send({ error: 'slug and name required' });
472
+ if (typeof slug !== 'string' || typeof name !== 'string') {
473
+ return reply.code(400).send({ error: 'slug and name: expected strings' });
474
+ }
475
+ if (projectBySlug(slug)) return reply.code(409).send({ error: 'slug exists' });
476
+ const dsErr = badDeploySkill(req.body);
477
+ if (dsErr) return reply.code(400).send({ error: dsErr });
478
+ const fields = { slug, name, prefix: makePrefix(slug) };
479
+ for (const k of PATCHABLE) {
480
+ if (req.body[k] === undefined) continue;
481
+ const bad = badField(k, req.body[k]);
482
+ if (bad) return reply.code(400).send({ error: bad });
483
+ fields[k] = normField(k, req.body[k]);
484
+ }
485
+ // The field is documented as "comma-separated string <-> JSON", so store the canonical
486
+ // form instead of whatever the caller sent (readers stay tolerant for older rows).
487
+ if (fields.pm2_services !== undefined) fields.pm2_services = serializePm2Services(fields.pm2_services);
488
+ const pc = checkPrefix(fields.prefix, null);
489
+ if (pc.error) return reply.code(400).send({ error: pc.error });
490
+ fields.prefix = pc.pref;
491
+ const cols = Object.keys(fields);
492
+ db.prepare(`INSERT INTO projects (${cols.join(',')}) VALUES (${cols.map(() => '?').join(',')})`)
493
+ .run(...cols.map((k) => fields[k]));
494
+ const p = projectBySlug(slug);
495
+ emit('project.updated', p);
496
+ return reply.code(201).send(p);
497
+ });
498
+
499
+ app.patch('/api/projects/:slug', (req, reply) => {
500
+ const p = resolveProject(req.params.slug);
501
+ if (!p) return reply.code(404).send({ error: 'project not found' });
502
+ const dsErr = badDeploySkill(req.body);
503
+ if (dsErr) return reply.code(400).send({ error: dsErr });
504
+ if (req.body?.prefix !== undefined) {
505
+ const pc = checkPrefix(req.body.prefix, p.id);
506
+ if (pc.error) return reply.code(400).send({ error: pc.error });
507
+ req.body.prefix = pc.pref;
508
+ }
509
+ const updates = [];
510
+ const values = [];
511
+ for (const k of PATCHABLE) {
512
+ if (req.body && req.body[k] !== undefined) {
513
+ const bad = badField(k, req.body[k]);
514
+ if (bad) return reply.code(400).send({ error: bad });
515
+ updates.push(`${k} = ?`);
516
+ values.push(k === 'pm2_services' ? serializePm2Services(req.body[k]) : normField(k, req.body[k]));
517
+ }
518
+ }
519
+ if (updates.length) {
520
+ values.push(p.id);
521
+ db.prepare(`UPDATE projects SET ${updates.join(', ')}, updated_at = datetime('now') WHERE id = ?`).run(...values);
522
+ }
523
+ const fresh = resolveProject(req.params.slug);
524
+ if (req.body?.path !== undefined && req.body.path !== p.path) {
525
+ forgetRepoBase(p.path);
526
+ forgetRepoBase(fresh.path);
527
+ }
528
+ if (req.body?.category !== undefined && req.body.category !== p.category) syncCategoryToPanel(fresh);
529
+ emit('project.updated', fresh);
530
+ return fresh;
531
+ });
532
+ }
@@ -0,0 +1,116 @@
1
+
2
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { db, kvGet, kvSet } from '../db.js';
5
+ import { ROOT } from '../config.js';
6
+ import { gh as ghCli } from '../sync/github.js';
7
+ import { BOARD_SKILL, GENERIC_DEPLOY_SKILL, SKILL_MAX_BYTES, SKILL_NAME_RE, deleteSkill, listSkills, readSkill, skillInfo, skillRoots, writeSkill } from '../skills.js';
8
+
9
+ function upstreamRepo() {
10
+ try {
11
+ const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'));
12
+ const m = String(pkg.repository?.url || pkg.repository || '').match(/github\.com[:/]([\w.-]+)\/([\w.-]+?)(?:\.git)?$/);
13
+ return m ? `${m[1]}/${m[2]}` : null;
14
+ } catch { return null; }
15
+ }
16
+
17
+ async function fetchUpstream(name, lang) {
18
+ const repo = upstreamRepo();
19
+ const files = lang && lang !== 'en' ? [[`SKILL.${lang}.md`, lang], ['SKILL.md', 'en']] : [['SKILL.md', 'en']];
20
+ if (repo) {
21
+ for (const [file, got] of files) {
22
+ const path = `skills/${name}/${file}`;
23
+ const url = `https://raw.githubusercontent.com/${repo}/main/${path}`;
24
+ try {
25
+ const res = await fetch(url, { headers: { 'User-Agent': 'local-kanban' }, signal: AbortSignal.timeout(8000) });
26
+ if (res.ok) {
27
+ const text = await res.text();
28
+ if (text.length <= SKILL_MAX_BYTES) return { text, source: 'github', origin: url, lang: got };
29
+ }
30
+ } catch { }
31
+ try {
32
+ const text = await ghCli(['api', `repos/${repo}/contents/${path}`, '-H', 'Accept: application/vnd.github.raw'], { timeout: 15000 });
33
+ if (text && text.length <= SKILL_MAX_BYTES) return { text, source: 'github', origin: url, lang: got };
34
+ } catch { }
35
+ }
36
+ }
37
+ const local = join(ROOT, 'skills', name, 'SKILL.md');
38
+ if (existsSync(local)) return { text: readFileSync(local, 'utf8'), source: 'package', origin: local, lang: 'en' };
39
+ return null;
40
+ }
41
+
42
+ const OWN_KEY = 'skills.own';
43
+ function ownSkills() {
44
+ try {
45
+ const v = JSON.parse(kvGet(OWN_KEY) || '[]');
46
+ return Array.isArray(v) ? v.filter((x) => typeof x === 'string' && SKILL_NAME_RE.test(x)) : [];
47
+ } catch { return []; }
48
+ }
49
+ const rememberOwn = (name) => kvSet(OWN_KEY, JSON.stringify([...new Set([...ownSkills(), name])]));
50
+ const forgetOwn = (name) => kvSet(OWN_KEY, JSON.stringify(ownSkills().filter((x) => x !== name)));
51
+
52
+ function packagedSkills() {
53
+ try {
54
+ return readdirSync(join(ROOT, 'skills'))
55
+ .filter((name) => SKILL_NAME_RE.test(name) && existsSync(join(ROOT, 'skills', name, 'SKILL.md')))
56
+ .sort();
57
+ } catch { return []; }
58
+ }
59
+
60
+ export default async function skillRoutes(app) {
61
+ const usedBy = (name) => db.prepare('SELECT slug, name FROM projects WHERE deploy_skill = ? AND archived = 0 ORDER BY name').all(name);
62
+
63
+ app.get('/api/skills', () => ({
64
+ roots: skillRoots(),
65
+ board_skill: BOARD_SKILL,
66
+ generic_deploy_skill: GENERIC_DEPLOY_SKILL,
67
+ items: listSkills().map((s) => ({
68
+ ...s,
69
+ used_by: usedBy(s.name),
70
+ packaged: existsSync(join(ROOT, 'skills', s.name, 'SKILL.md')),
71
+ })),
72
+ board: skillInfo(BOARD_SKILL),
73
+ packaged_skills: packagedSkills(),
74
+ own_skills: ownSkills(),
75
+ }));
76
+
77
+ app.get('/api/skills/:name', (req, reply) => {
78
+ const { name } = req.params;
79
+ if (!SKILL_NAME_RE.test(name)) return reply.code(400).send({ error: 'skill name: latin letters, digits, hyphen, underscore only' });
80
+ return readSkill(name);
81
+ });
82
+
83
+ app.get('/api/skills/:name/upstream', async (req, reply) => {
84
+ const { name } = req.params;
85
+ if (!SKILL_NAME_RE.test(name)) return reply.code(400).send({ error: 'skill name: latin letters, digits, hyphen, underscore only' });
86
+ const lang = req.query?.lang === 'ru' ? 'ru' : 'en';
87
+ const got = await fetchUpstream(name, lang);
88
+ if (!got) return reply.code(502).send({ error: 'could not get the shared version — no network and the package does not ship this skill' });
89
+ return { name, ...got };
90
+ });
91
+
92
+ app.put('/api/skills/:name', (req, reply) => {
93
+ if (process.env.KB_PREVIEW_TTL_MS) return reply.code(403).send({ error: 'this is a check board opened from a backup — it does not write skills' });
94
+ const r = writeSkill(req.params.name, req.body?.text, req.body?.confirm_path);
95
+ if (r.error) return reply.code(r.code).send(r);
96
+ rememberOwn(req.params.name);
97
+ return r;
98
+ });
99
+
100
+ app.post('/api/skills/:name/adopt', (req, reply) => {
101
+ const { name } = req.params;
102
+ if (!SKILL_NAME_RE.test(name)) return reply.code(400).send({ error: 'skill name: latin letters, digits, hyphen, underscore only' });
103
+ const info = skillInfo(name);
104
+ if (!info.exists) return reply.code(404).send({ error: 'there is no such skill' });
105
+ rememberOwn(name);
106
+ return { ...info, adopted: true };
107
+ });
108
+
109
+ app.delete('/api/skills/:name', (req, reply) => {
110
+ if (process.env.KB_PREVIEW_TTL_MS) return reply.code(403).send({ error: 'this is a check board opened from a backup — it does not delete skills' });
111
+ const r = deleteSkill(req.params.name, req.body?.confirm_path);
112
+ if (r.error) return reply.code(r.code).send(r);
113
+ forgetOwn(req.params.name);
114
+ return r;
115
+ });
116
+ }