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
package/src/db.js ADDED
@@ -0,0 +1,321 @@
1
+ import Database from 'better-sqlite3';
2
+ import { readFileSync, mkdirSync, readdirSync, renameSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { DATA_DIR, ROOT, langOverride, ghOwnerEnv, ghRepoEnv } from './config.js';
5
+
6
+ export { DATA_DIR };
7
+
8
+ mkdirSync(DATA_DIR, { recursive: true });
9
+
10
+ const MIGRATIONS = [
11
+ { v: 1, name: 'legacy-consolidation-2026-07', up: legacyMigrate, ownTx: true },
12
+ { v: 2, name: 'app-errors-resolved', up: () => {
13
+ const cols = db.prepare('PRAGMA table_info(app_errors)').all();
14
+ if (!cols.length) return;
15
+ const has = (col) => cols.some((c) => c.name === col);
16
+ if (!has('op_id')) db.exec('ALTER TABLE app_errors ADD COLUMN op_id INTEGER');
17
+ if (!has('resolved_at')) db.exec('ALTER TABLE app_errors ADD COLUMN resolved_at TEXT');
18
+ } },
19
+ { v: 3, name: 'locked-categories-en', up: () => {
20
+ const cols = db.prepare('PRAGMA table_info(projects)').all();
21
+ if (!cols.some((c) => c.name === 'category')) return;
22
+ db.prepare("UPDATE projects SET category = 'Local' WHERE category = 'Локальные'").run();
23
+ db.prepare("UPDATE projects SET category = 'Other' WHERE category = 'Прочее'").run();
24
+ } },
25
+ { v: 4, name: 'work-truncated-counter', up: () => {
26
+ const cols = db.prepare('PRAGMA table_info(tasks)').all();
27
+ if (!cols.length || cols.some((c) => c.name === 'work_truncated')) return;
28
+ db.exec('ALTER TABLE tasks ADD COLUMN work_truncated INTEGER NOT NULL DEFAULT 0');
29
+ } },
30
+ { v: 5, name: 'app-errors-repeats', up: () => {
31
+ const cols = db.prepare('PRAGMA table_info(app_errors)').all();
32
+ if (!cols.length || cols.some((c) => c.name === 'repeats')) return;
33
+ db.exec('ALTER TABLE app_errors ADD COLUMN repeats INTEGER NOT NULL DEFAULT 0');
34
+ } },
35
+ { v: 6, name: 'sync-queue-started-at', up: () => {
36
+ const cols = db.prepare('PRAGMA table_info(sync_queue)').all();
37
+ if (!cols.length || cols.some((c) => c.name === 'started_at')) return;
38
+ db.exec('ALTER TABLE sync_queue ADD COLUMN started_at TEXT');
39
+ } },
40
+ ];
41
+ export const SCHEMA_VERSION = MIGRATIONS.at(-1).v;
42
+
43
+ function dieOnBrokenDatabase(e) {
44
+ const dir = join(DATA_DIR, 'backups');
45
+ console.error(`\n The board could not open its database: ${e.message}\n`);
46
+ console.error(` The file is ${join(DATA_DIR, 'kanban.db')}`);
47
+ console.error(' What to do:');
48
+ console.error(` · restore the newest snapshot over it — they are in ${dir}`);
49
+ console.error(` · a snapshot taken before the last schema change is in ${join(dir, 'pre-migrate')}`);
50
+ console.error(' · stop the board first, then copy the file back and start it again\n');
51
+ process.exit(1);
52
+ }
53
+
54
+ let db;
55
+ try {
56
+ db = new Database(join(DATA_DIR, 'kanban.db'));
57
+ db.pragma('journal_mode = WAL');
58
+ db.pragma('foreign_keys = ON');
59
+ } catch (e) { dieOnBrokenDatabase(e); }
60
+ export { db };
61
+ db.function('kb_lower', { deterministic: true }, (s) => (s === null || s === undefined ? null : String(s).toLowerCase()));
62
+
63
+ try {
64
+ migrate();
65
+ db.exec(readFileSync(join(ROOT, 'src', 'schema.sql'), 'utf8'));
66
+ backfill();
67
+ } catch (e) { dieOnBrokenDatabase(e); }
68
+
69
+ export const SYNC_MAX_ATTEMPTS = 15;
70
+
71
+ export const WORK_SEGMENT_MAX_S = 2 * 3600;
72
+
73
+ export const STATUSES = ['backlog', 'todo', 'prep', 'doing', 'deploy', 'review', 'done', 'cancelled'];
74
+
75
+ function migrate() {
76
+ const from = db.pragma('user_version', { simple: true });
77
+ if (from >= SCHEMA_VERSION) return;
78
+ preMigrateBackup(from);
79
+ for (const m of MIGRATIONS) {
80
+ if (m.v <= from) continue;
81
+ if (m.ownTx) {
82
+ m.up();
83
+ db.pragma(`user_version = ${m.v}`);
84
+ } else {
85
+ db.transaction(() => {
86
+ m.up();
87
+ db.pragma(`user_version = ${m.v}`);
88
+ })();
89
+ }
90
+ }
91
+ }
92
+
93
+ function preMigrateBackup(fromVersion) {
94
+ if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' LIMIT 1").get()) return;
95
+ const dir = join(DATA_DIR, 'backups', 'pre-migrate');
96
+ mkdirSync(dir, { recursive: true });
97
+ const stamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-');
98
+ const target = join(dir, `kanban-v${fromVersion}-${stamp}.db`);
99
+ const tmp = `${target}.part`;
100
+ rmSync(tmp, { force: true });
101
+ try {
102
+ db.exec(`VACUUM INTO '${tmp.replace(/'/g, "''")}'`);
103
+ renameSync(tmp, target);
104
+ } catch (e) {
105
+ rmSync(tmp, { force: true });
106
+ throw e;
107
+ }
108
+ const files = readdirSync(dir).filter((f) => f.endsWith('.db')).sort();
109
+ for (const f of files.slice(0, -5)) rmSync(join(dir, f), { force: true });
110
+ }
111
+
112
+ function legacyMigrate() {
113
+ const tasksSql = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='tasks'").get()?.sql;
114
+ if (tasksSql && !tasksSql.includes('backlog')) {
115
+ db.pragma('foreign_keys = OFF');
116
+ db.exec(`
117
+ BEGIN;
118
+ CREATE TABLE new_tasks (
119
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
120
+ project_id INTEGER NOT NULL REFERENCES projects(id),
121
+ task_no INTEGER,
122
+ title TEXT NOT NULL,
123
+ description TEXT NOT NULL DEFAULT '',
124
+ status TEXT NOT NULL DEFAULT 'backlog'
125
+ CHECK (status IN ('backlog','todo','doing','deploy','review','done','cancelled')),
126
+ priority INTEGER NOT NULL DEFAULT 0 CHECK (priority BETWEEN 0 AND 3),
127
+ labels TEXT NOT NULL DEFAULT '[]',
128
+ blocked INTEGER NOT NULL DEFAULT 0,
129
+ blocked_reason TEXT,
130
+ position REAL NOT NULL DEFAULT 0,
131
+ gh_issue_number INTEGER,
132
+ gh_issue_url TEXT,
133
+ gh_item_id TEXT,
134
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
135
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
136
+ done_at TEXT
137
+ );
138
+ INSERT INTO new_tasks (id, project_id, title, description, status, priority, blocked, blocked_reason,
139
+ position, gh_issue_number, gh_issue_url, gh_item_id, created_at, updated_at, done_at)
140
+ SELECT id, project_id, title, description, status, priority, blocked, blocked_reason,
141
+ position, gh_issue_number, gh_issue_url, gh_item_id, created_at, updated_at, done_at
142
+ FROM tasks;
143
+ DROP TABLE tasks;
144
+ ALTER TABLE new_tasks RENAME TO tasks;
145
+ DROP INDEX IF EXISTS idx_tasks_proj_status;
146
+ COMMIT;
147
+ `);
148
+ db.pragma('foreign_keys = ON');
149
+ }
150
+ const projCols = db.prepare("SELECT name FROM pragma_table_info('projects')").all().map((c) => c.name);
151
+ if (projCols.length && !projCols.includes('prefix')) db.exec('ALTER TABLE projects ADD COLUMN prefix TEXT');
152
+
153
+ const taskCols = db.prepare("SELECT name FROM pragma_table_info('tasks')").all().map((c) => c.name);
154
+ if (taskCols.length && !taskCols.includes('work_seconds')) {
155
+ db.exec('ALTER TABLE tasks ADD COLUMN work_seconds INTEGER NOT NULL DEFAULT 0');
156
+ db.exec('ALTER TABLE tasks ADD COLUMN work_started_at TEXT');
157
+ }
158
+ if (projCols.length && !projCols.includes('next_task_no')) db.exec('ALTER TABLE projects ADD COLUMN next_task_no INTEGER');
159
+ if (projCols.length && !projCols.includes('description')) db.exec('ALTER TABLE projects ADD COLUMN description TEXT');
160
+ if (projCols.length && !projCols.includes('pinned')) db.exec('ALTER TABLE projects ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0');
161
+ if (projCols.length && !projCols.includes('position')) db.exec('ALTER TABLE projects ADD COLUMN position REAL NOT NULL DEFAULT 0');
162
+
163
+ const curTasksSql = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='tasks'").get()?.sql;
164
+ if (curTasksSql && !curTasksSql.includes("'prep'")) {
165
+ db.pragma('foreign_keys = OFF');
166
+ db.exec(`
167
+ BEGIN;
168
+ CREATE TABLE new_tasks (
169
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
170
+ project_id INTEGER NOT NULL REFERENCES projects(id),
171
+ task_no INTEGER,
172
+ title TEXT NOT NULL,
173
+ description TEXT NOT NULL DEFAULT '',
174
+ status TEXT NOT NULL DEFAULT 'backlog'
175
+ CHECK (status IN ('backlog','todo','prep','doing','deploy','review','done','cancelled')),
176
+ priority INTEGER NOT NULL DEFAULT 0 CHECK (priority BETWEEN 0 AND 3),
177
+ labels TEXT NOT NULL DEFAULT '[]',
178
+ blocked INTEGER NOT NULL DEFAULT 0,
179
+ blocked_reason TEXT,
180
+ position REAL NOT NULL DEFAULT 0,
181
+ work_seconds INTEGER NOT NULL DEFAULT 0,
182
+ work_started_at TEXT,
183
+ gh_issue_number INTEGER,
184
+ gh_issue_url TEXT,
185
+ gh_item_id TEXT,
186
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
187
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
188
+ done_at TEXT
189
+ );
190
+ INSERT INTO new_tasks (id, project_id, task_no, title, description, status, priority, labels,
191
+ blocked, blocked_reason, position, work_seconds, work_started_at,
192
+ gh_issue_number, gh_issue_url, gh_item_id, created_at, updated_at, done_at)
193
+ SELECT id, project_id, task_no, title, description, status, priority, labels,
194
+ blocked, blocked_reason, position, work_seconds, work_started_at,
195
+ gh_issue_number, gh_issue_url, gh_item_id, created_at, updated_at, done_at
196
+ FROM tasks;
197
+ DROP TABLE tasks;
198
+ ALTER TABLE new_tasks RENAME TO tasks;
199
+ COMMIT;
200
+ `);
201
+ db.pragma('foreign_keys = ON');
202
+ }
203
+
204
+ const tCols = db.prepare("SELECT name FROM pragma_table_info('tasks')").all().map((c) => c.name);
205
+ if (tCols.length && !tCols.includes('pinned')) db.exec('ALTER TABLE tasks ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0');
206
+ const lCols = db.prepare("SELECT name FROM pragma_table_info('task_links')").all().map((c) => c.name);
207
+ if (lCols.length && !lCols.includes('kind')) db.exec("ALTER TABLE task_links ADD COLUMN kind TEXT NOT NULL DEFAULT 'related'");
208
+ const cCols = db.prepare("SELECT name FROM pragma_table_info('task_comments')").all().map((c) => c.name);
209
+ if (cCols.length && !cCols.includes('image')) db.exec('ALTER TABLE task_comments ADD COLUMN image TEXT');
210
+ }
211
+
212
+ export function kvGet(key) {
213
+ return db.prepare('SELECT value FROM kv WHERE key = ?').get(key)?.value ?? null;
214
+ }
215
+ export function kvSet(key, value) {
216
+ db.prepare('INSERT INTO kv(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, String(value));
217
+ }
218
+
219
+ const ERRORS_KEEP = 300;
220
+
221
+ const DEDUP_MS = 10000;
222
+ const lastLogged = new Map();
223
+ export function logError(source, scope, message, detail, opId = null) {
224
+ try {
225
+ const scopeStr = scope ? String(scope).slice(0, 200) : null;
226
+ const msgStr = String(message).slice(0, 500);
227
+ const key = `${source}${scopeStr}${msgStr}`;
228
+ const now = Date.now();
229
+ const seen = lastLogged.get(key);
230
+ if (seen && now - seen.at < DEDUP_MS) {
231
+ db.prepare('UPDATE app_errors SET repeats = repeats + 1 WHERE id = ?').run(seen.id);
232
+ return;
233
+ }
234
+ for (const [k, v] of lastLogged) { if (now - v.at >= DEDUP_MS) lastLogged.delete(k); }
235
+ const info = db.prepare('INSERT INTO app_errors(source, scope, message, detail, op_id) VALUES(?, ?, ?, ?, ?)')
236
+ .run(String(source), scopeStr, msgStr, detail ? String(detail).slice(0, 2000) : null, opId ?? null);
237
+ lastLogged.set(key, { at: now, id: info.lastInsertRowid });
238
+ db.prepare('DELETE FROM app_errors WHERE id NOT IN (SELECT id FROM app_errors ORDER BY id DESC LIMIT ?)').run(ERRORS_KEEP);
239
+ } catch (e) {
240
+ console.error('logError failed:', e?.message || e, '|', source, scope, message);
241
+ }
242
+ }
243
+
244
+ export function resolveErrors(opId) {
245
+ if (!opId) return;
246
+ try {
247
+ db.prepare("UPDATE app_errors SET resolved_at = datetime('now') WHERE op_id = ? AND resolved_at IS NULL").run(opId);
248
+ } catch { }
249
+ }
250
+
251
+ export function uiLang() {
252
+ const v = langOverride() || kvGet('ui.lang') || 'ru';
253
+ return v === 'en' ? 'en' : 'ru';
254
+ }
255
+
256
+ export function ghOwner() { return ghOwnerEnv() || kvGet('gh.owner') || ''; }
257
+ export function ghRepo() { return ghRepoEnv() || kvGet('gh.repo') || ''; }
258
+ export function syncConfigured() { return Boolean(ghOwner() && ghRepo()); }
259
+
260
+ function backfill() {
261
+ const used = usedPrefixes();
262
+ for (const p of db.prepare('SELECT id, slug FROM projects WHERE prefix IS NULL').all()) {
263
+ const prefix = genPrefix(p.slug, used);
264
+ used.add(prefix);
265
+ db.prepare('UPDATE projects SET prefix = ? WHERE id = ?').run(prefix, p.id);
266
+ }
267
+ db.exec(`
268
+ UPDATE tasks SET task_no = (
269
+ SELECT COUNT(*) FROM tasks t2 WHERE t2.project_id = tasks.project_id AND t2.id <= tasks.id
270
+ ) WHERE task_no IS NULL;
271
+ `);
272
+ db.exec(`
273
+ INSERT INTO task_events (task_id, status, created_at)
274
+ SELECT id, status, done_at FROM tasks
275
+ WHERE done_at IS NOT NULL AND status IN ('done','cancelled')
276
+ AND NOT EXISTS (SELECT 1 FROM task_events e WHERE e.task_id = tasks.id);
277
+ `);
278
+ }
279
+
280
+ export function genPrefix(slug, used = new Set()) {
281
+ const words = slug.replace(/[^a-z0-9-]/gi, '').split('-').filter(Boolean);
282
+ let base = words.length >= 2
283
+ ? words.map((w) => w[0]).join('').slice(0, 3).toUpperCase()
284
+ : slug.slice(0, 3).toUpperCase();
285
+ if (base.length < 2) base = (base + 'XX').slice(0, 2);
286
+ let prefix = base;
287
+ let n = 2;
288
+ while (used.has(prefix)) prefix = base + n++;
289
+ return prefix;
290
+ }
291
+
292
+ export function nextPosition(projectId, status) {
293
+ return db.prepare('SELECT COALESCE(MAX(position),0) AS mx FROM tasks WHERE project_id=? AND status=?')
294
+ .get(projectId, status).mx + 1;
295
+ }
296
+
297
+ export function usedPrefixes() {
298
+ return new Set(db.prepare('SELECT upper(prefix) AS p FROM projects WHERE prefix IS NOT NULL').all().map((r) => r.p));
299
+ }
300
+ export const makePrefix = (slug) => genPrefix(slug, usedPrefixes());
301
+
302
+ export function nextTaskNo(projectId) {
303
+ db.prepare(`
304
+ UPDATE projects SET next_task_no = COALESCE(
305
+ next_task_no,
306
+ (SELECT COALESCE(MAX(task_no), 0) FROM tasks WHERE project_id = ?) + 1
307
+ ) WHERE id = ?
308
+ `).run(projectId, projectId);
309
+ const { n } = db.prepare('SELECT next_task_no AS n FROM projects WHERE id = ?').get(projectId);
310
+ db.prepare('UPDATE projects SET next_task_no = next_task_no + 1 WHERE id = ?').run(projectId);
311
+ return n;
312
+ }
313
+
314
+ export function enqueue(op, taskId, payload) {
315
+ if (!syncConfigured()) return;
316
+ if (taskId && ['set_status', 'set_priority', 'set_blocked', 'set_labels', 'update_issue'].includes(op)) {
317
+ db.prepare('DELETE FROM sync_queue WHERE task_id = ? AND op = ?').run(taskId, op);
318
+ }
319
+ db.prepare('INSERT INTO sync_queue (task_id, op, payload) VALUES (?, ?, ?)')
320
+ .run(taskId, op, JSON.stringify(payload));
321
+ }
@@ -0,0 +1,11 @@
1
+ import { logError } from './db.js';
2
+
3
+ export function errorHandler(err, req, reply) {
4
+ const code = err.statusCode || 500;
5
+ if (code >= 500) {
6
+ req.log.error(err);
7
+ logError('server', `${req.method} ${req.url.split('?')[0]}`, err.message, err.stack);
8
+ return reply.code(500).send({ error: 'internal error — details under Settings → Errors' });
9
+ }
10
+ return reply.send(err);
11
+ }
@@ -0,0 +1,22 @@
1
+
2
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { DATA_DIR } from './config.js';
5
+
6
+ const KEEP = 10;
7
+
8
+ export const snapshotDir = (kind) => join(DATA_DIR, 'backups', kind);
9
+
10
+ export function snapshotFile(kind, label, path) {
11
+ if (!existsSync(path)) return null;
12
+ const dir = snapshotDir(kind);
13
+ mkdirSync(dir, { recursive: true });
14
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
15
+ const dest = join(dir, `${label}-${stamp}.md`);
16
+ copyFileSync(path, dest);
17
+ const mine = readdirSync(dir).filter((f) => f.startsWith(`${label}-`) && f.endsWith('.md')).sort();
18
+ for (const old of mine.slice(0, Math.max(0, mine.length - KEEP))) {
19
+ try { rmSync(join(dir, old), { force: true }); } catch { }
20
+ }
21
+ return dest;
22
+ }
@@ -0,0 +1,32 @@
1
+ import { allowedHosts } from './config.js';
2
+
3
+ const envHosts = () => allowedHosts().map((h) => h.toLowerCase());
4
+
5
+ export function selfHosts(port) {
6
+ return new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`, ...envHosts()]);
7
+ }
8
+
9
+ export function selfOrigins(port) {
10
+ const out = new Set();
11
+ for (const h of selfHosts(port)) { out.add(`http://${h}`); out.add(`https://${h}`); }
12
+ return out;
13
+ }
14
+
15
+ export function makeOriginGuard(port) {
16
+ const hosts = selfHosts(port);
17
+ const origins = selfOrigins(port);
18
+ return function originGuard(req, reply, done) {
19
+ if (!hosts.has(String(req.headers.host || '').toLowerCase())) {
20
+ return reply.code(421).send({ error: 'request addressed to an unexpected host' });
21
+ }
22
+ const origin = req.headers.origin;
23
+ if (origin && !origins.has(String(origin).toLowerCase())) {
24
+ return reply.code(403).send({ error: 'cross-origin request rejected' });
25
+ }
26
+ const site = req.headers['sec-fetch-site'];
27
+ if (site && site !== 'same-origin' && site !== 'none') {
28
+ return reply.code(403).send({ error: 'cross-site request rejected' });
29
+ }
30
+ done();
31
+ };
32
+ }
@@ -0,0 +1,31 @@
1
+ // pm2_services: one tolerant parser for every reader.
2
+ // The column stores a JSON array of process names, but both the "add project" wizard and
3
+ // PATCH /api/projects document the field as "a comma-separated string <-> JSON", so a plain
4
+ // string is a legal value that really occurs in existing databases. Every reader used to call
5
+ // JSON.parse unconditionally, so such a row crashed `kb info` (exit 3, SyntaxError) and made
6
+ // GET /api/projects/:slug/status answer 500.
7
+
8
+ // Accepts a JSON array, a comma-separated string or an array; always returns string names.
9
+ export function parsePm2Services(raw) {
10
+ if (!raw) return [];
11
+ const list = Array.isArray(raw) ? raw : parseRaw(String(raw));
12
+ return list.map((s) => String(s).trim()).filter(Boolean);
13
+ }
14
+
15
+ function parseRaw(text) {
16
+ const trimmed = text.trim();
17
+ if (!trimmed) return [];
18
+ if (trimmed.startsWith('[')) {
19
+ try {
20
+ const parsed = JSON.parse(trimmed);
21
+ if (Array.isArray(parsed)) return parsed;
22
+ } catch { /* not JSON after all — fall through to the comma-separated form */ }
23
+ }
24
+ return trimmed.split(',');
25
+ }
26
+
27
+ // Canonical storage form. Returns null for an empty list so the column is cleared, not set to "[]".
28
+ export function serializePm2Services(raw) {
29
+ const list = parsePm2Services(raw);
30
+ return list.length ? JSON.stringify(list) : null;
31
+ }
@@ -0,0 +1,17 @@
1
+
2
+ export function scalarParam(value, name) {
3
+ if (value === undefined || value === null) return { value: undefined };
4
+ if (Array.isArray(value)) return { error: `${name}: given more than once` };
5
+ if (typeof value === 'object') return { error: `${name}: expected a single value` };
6
+ return { value: String(value) };
7
+ }
8
+
9
+ export function scalarQuery(query, names) {
10
+ const out = {};
11
+ for (const name of names) {
12
+ const r = scalarParam(query?.[name], name);
13
+ if (r.error) return { error: r.error };
14
+ out[name] = r.value;
15
+ }
16
+ return { values: out };
17
+ }
@@ -0,0 +1,23 @@
1
+ import { execFile } from 'node:child_process';
2
+
3
+ const cache = new Map();
4
+ const TTL = 5 * 60 * 1000;
5
+
6
+ const gitOut = (args) => new Promise((resolve) =>
7
+ execFile('git', args, { timeout: 3000 }, (err, out) => resolve(err ? null : String(out).trim())));
8
+
9
+ export function forgetRepoBase(path) {
10
+ if (path) cache.delete(path);
11
+ }
12
+
13
+ export async function repoHttpsBase(path) {
14
+ if (!path) return null;
15
+ const hit = cache.get(path);
16
+ if (hit && Date.now() - hit.at < TTL) return hit.base;
17
+ const url = await gitOut(['-C', path, 'remote', 'get-url', 'origin']);
18
+ // git@github.com:owner/repo.git | https://github.com/owner/repo
19
+ const m = url?.match(/github\.com[:/](.+?)(?:\.git)?$/);
20
+ const base = m ? `https://github.com/${m[1]}` : null;
21
+ cache.set(path, { base, at: Date.now() });
22
+ return base;
23
+ }
package/src/restore.js ADDED
@@ -0,0 +1,153 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { closeSync, cpSync, mkdtempSync, openSync, readSync, readdirSync, statSync, writeFileSync, rmSync, existsSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join, dirname } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { createServer } from 'node:net';
7
+ import Database from 'better-sqlite3';
8
+ import { logError } from './db.js';
9
+ import { ATTACH_MIRROR } from './backup.js';
10
+
11
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
12
+ const LIFETIME_MS = 30 * 60 * 1000;
13
+ let current = null; // { port, proc, dir, startedAt, timer, stats }
14
+
15
+ function readHead(path) {
16
+ const buf = Buffer.alloc(15);
17
+ let fd;
18
+ try { fd = openSync(path, 'r'); readSync(fd, buf, 0, 15, 0); } catch { return Buffer.alloc(0); } finally { if (fd !== undefined) closeSync(fd); }
19
+ return buf;
20
+ }
21
+
22
+ const freePort = () => new Promise((resolve, reject) => {
23
+ const s = createServer();
24
+ s.on('error', reject);
25
+ s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)); });
26
+ });
27
+
28
+ export function inspectBackup(input) {
29
+ const fromPath = typeof input === 'string';
30
+ const head = fromPath ? readHead(input) : input.subarray(0, 15);
31
+ const size = fromPath ? (statSync(input, { throwIfNoEntry: false })?.size ?? 0) : input.length;
32
+ if (size < 100 || head.toString('latin1') !== 'SQLite format 3') {
33
+ return { ok: false, error: 'not an SQLite database file' };
34
+ }
35
+ const dir = mkdtempSync(join(tmpdir(), 'kb-restore-'));
36
+ const file = join(dir, 'kanban.db');
37
+ if (fromPath) cpSync(input, file); else writeFileSync(file, input);
38
+ try {
39
+ const db = new Database(file, { readonly: true });
40
+ const integrity = db.pragma('integrity_check', { simple: true });
41
+ if (integrity !== 'ok') { db.close(); rmSync(dir, { recursive: true, force: true }); return { ok: false, error: `database is corrupted: ${integrity}` }; }
42
+ const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r) => r.name));
43
+ for (const need of ['tasks', 'projects']) {
44
+ if (!tables.has(need)) { db.close(); rmSync(dir, { recursive: true, force: true }); return { ok: false, error: `an SQLite database, but not a board: no ${need} table` }; }
45
+ }
46
+ const stats = {
47
+ tasks: db.prepare('SELECT COUNT(*) n FROM tasks').get().n,
48
+ projects: db.prepare('SELECT COUNT(*) n FROM projects').get().n,
49
+ version: db.pragma('user_version', { simple: true }),
50
+ };
51
+ db.close();
52
+ disarmSync(file);
53
+ return { ok: true, dir, stats };
54
+ } catch (e) {
55
+ rmSync(dir, { recursive: true, force: true });
56
+ return { ok: false, error: `could not read the database: ${e.message}` };
57
+ }
58
+ }
59
+
60
+ function disarmSync(file) {
61
+ const db = new Database(file);
62
+ try {
63
+ const has = (t) => Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(t));
64
+ if (has('kv')) {
65
+ db.prepare("DELETE FROM kv WHERE key IN ('gh.owner','gh.repo')").run();
66
+ db.prepare("INSERT INTO kv(key, value) VALUES('sync.paused','1') ON CONFLICT(key) DO UPDATE SET value='1'").run();
67
+ }
68
+ if (has('sync_queue')) db.prepare('DELETE FROM sync_queue').run();
69
+ } catch (e) {
70
+ logError('server', 'restore-preview', `could not disable the sync in the copy: ${e.message}`);
71
+ throw e;
72
+ } finally { db.close(); }
73
+ }
74
+
75
+ export function sweepStalePreviews() {
76
+ const base = tmpdir();
77
+ let left = 0;
78
+ try {
79
+ for (const name of readdirSync(base)) {
80
+ if (!name.startsWith('kb-restore-')) continue;
81
+ const dir = join(base, name);
82
+ try {
83
+ if (Date.now() - statSync(dir).mtimeMs < 3600 * 1000) continue;
84
+ rmSync(dir, { recursive: true, force: true });
85
+ left++;
86
+ } catch { }
87
+ }
88
+ } catch { }
89
+ return left;
90
+ }
91
+
92
+ export function previewProcessForTests() { return current; }
93
+
94
+ export function currentPreview() {
95
+ if (!current) return null;
96
+ const { port, startedAt, stats } = current;
97
+ return { port, url: `http://127.0.0.1:${port}`, started_at: startedAt, stats, expires_in: Math.max(0, LIFETIME_MS - (Date.now() - startedAt)) };
98
+ }
99
+
100
+ const onExit = () => { if (current) stopPreview(); };
101
+ const SIGNAL_HANDLERS = { SIGINT: () => { stopPreview(); process.exit(130); },
102
+ SIGTERM: () => { stopPreview(); process.exit(143); } };
103
+
104
+ function armCleanup() {
105
+ process.on('exit', onExit);
106
+ for (const [sig, fn] of Object.entries(SIGNAL_HANDLERS)) process.on(sig, fn);
107
+ }
108
+ function disarmCleanup() {
109
+ process.off('exit', onExit);
110
+ for (const [sig, fn] of Object.entries(SIGNAL_HANDLERS)) process.off(sig, fn);
111
+ }
112
+
113
+ export function stopPreview() {
114
+ if (!current) return false;
115
+ const { proc, dir, timer } = current;
116
+ clearTimeout(timer);
117
+ disarmCleanup();
118
+ try { proc.kill('SIGTERM'); } catch { }
119
+ try { setTimeout(() => { try { proc.kill('SIGKILL'); } catch { } }, 2000).unref(); } catch { }
120
+ try { rmSync(dir, { recursive: true, force: true }); } catch { }
121
+ current = null;
122
+ return true;
123
+ }
124
+
125
+ export async function startPreview(dir, stats) {
126
+ stopPreview();
127
+ try { if (existsSync(ATTACH_MIRROR)) cpSync(ATTACH_MIRROR, join(dir, 'attachments'), { recursive: true }); } catch (e) {
128
+ logError('server', 'restore-preview', `could not copy the attachments into the copy: ${e.message}`);
129
+ }
130
+ const port = await freePort();
131
+ const proc = spawn(process.execPath, [join(ROOT, 'src', 'server.js')], {
132
+ cwd: ROOT,
133
+ env: { ...process.env, PORT: String(port), KB_DATA_DIR: dir, KB_GH_OWNER: '', KB_GH_REPO: '', KB_PREVIEW_TTL_MS: String(LIFETIME_MS) },
134
+ stdio: ['pipe', 'ignore', 'ignore'],
135
+ detached: false,
136
+ });
137
+ proc.on('exit', (code) => { if (current && current.proc === proc && code) logError('server', 'restore-preview', `the preview board crashed, code ${code}`); });
138
+ const timer = setTimeout(() => stopPreview(), LIFETIME_MS);
139
+ current = { port, proc, dir, startedAt: Date.now(), timer, stats };
140
+ armCleanup();
141
+ const deadline = Date.now() + 15000;
142
+ let up = false;
143
+ while (Date.now() < deadline) {
144
+ if (proc.exitCode !== null) break;
145
+ try { const r = await fetch(`http://127.0.0.1:${port}/api/projects`); if (r.ok) { up = true; break; } } catch { }
146
+ await new Promise((r) => setTimeout(r, 250));
147
+ }
148
+ if (!up) {
149
+ stopPreview();
150
+ throw new Error('the preview board did not answer within 15 seconds');
151
+ }
152
+ return currentPreview();
153
+ }