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.
- package/CHANGELOG.md +381 -0
- package/LICENSE +21 -0
- package/README.md +144 -0
- package/README.ru.md +144 -0
- package/bin/kb +306 -0
- package/bin/local-kanban +217 -0
- package/ecosystem.example.cjs +25 -0
- package/package.json +52 -0
- package/public/app.js +18 -0
- package/public/boot-fallback.js +3 -0
- package/public/drawer-sync.js +14 -0
- package/public/favicon-180.png +0 -0
- package/public/favicon-32.png +0 -0
- package/public/favicon-64.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +6 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon-maskable-192.png +0 -0
- package/public/icon-maskable-512.png +0 -0
- package/public/index.html +138 -0
- package/public/js/board.js +698 -0
- package/public/js/chaos.js +247 -0
- package/public/js/core.js +821 -0
- package/public/js/dash.js +114 -0
- package/public/js/drawer.js +919 -0
- package/public/js/horizon.js +140 -0
- package/public/js/init.js +208 -0
- package/public/js/project.js +338 -0
- package/public/js/settings.js +905 -0
- package/public/js/sidebar.js +640 -0
- package/public/js/sse.js +386 -0
- package/public/manifest.webmanifest +32 -0
- package/public/offline.html +39 -0
- package/public/style.css +1247 -0
- package/public/sw.js +31 -0
- package/scripts/raycast/open-kanban.sh +18 -0
- package/scripts/update.js +43 -0
- package/skills/deploy/SKILL.md +70 -0
- package/skills/kanban/SKILL.md +217 -0
- package/src/backup.js +111 -0
- package/src/bus.js +10 -0
- package/src/config.js +40 -0
- package/src/db.js +321 -0
- package/src/error-handler.js +11 -0
- package/src/file-snapshot.js +22 -0
- package/src/origin-guard.js +32 -0
- package/src/pm2-services.js +31 -0
- package/src/query-params.js +17 -0
- package/src/repo-base.js +23 -0
- package/src/restore.js +153 -0
- package/src/routes/dashboard.js +132 -0
- package/src/routes/events.js +30 -0
- package/src/routes/horizons.js +66 -0
- package/src/routes/projects.js +532 -0
- package/src/routes/skills.js +116 -0
- package/src/routes/system.js +285 -0
- package/src/routes/tasks.js +477 -0
- package/src/schema.sql +135 -0
- package/src/server.js +100 -0
- package/src/skills.js +119 -0
- package/src/sync/github.js +265 -0
- package/src/sync/worker.js +136 -0
- package/src/task-shape.js +29 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { statSync, readdirSync, createReadStream, createWriteStream, existsSync, rmSync } from 'node:fs';
|
|
3
|
+
import { join, dirname, sep } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { db, DATA_DIR, kvGet, kvSet, ghOwner, ghRepo, syncConfigured, enqueue, logError, uiLang, SYNC_MAX_ATTEMPTS } from '../db.js';
|
|
7
|
+
import { emit } from '../bus.js';
|
|
8
|
+
import { ghState, kick, syncPaused } from '../sync/worker.js';
|
|
9
|
+
import { LABELS, MANAGED_LABELS, gh as ghCli } from '../sync/github.js';
|
|
10
|
+
import { listBackups, backupNow, backupPath, backupState, ATTACH_MIRROR } from '../backup.js';
|
|
11
|
+
import { inspectBackup, startPreview, stopPreview, currentPreview } from '../restore.js';
|
|
12
|
+
import { langOverride, ghOwnerEnv, ghRepoEnv } from '../config.js';
|
|
13
|
+
import { snapshot, TASK_SELECT } from './tasks.js';
|
|
14
|
+
|
|
15
|
+
const ROOT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
16
|
+
|
|
17
|
+
const UPLOAD_FLOOR = 64 * 1024 * 1024;
|
|
18
|
+
function uploadLimit() {
|
|
19
|
+
try { return Math.max(UPLOAD_FLOOR, statSync(join(DATA_DIR, 'kanban.db')).size * 4); } catch { return UPLOAD_FLOOR; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default async function systemRoutes(app) {
|
|
23
|
+
app.addContentTypeParser('application/octet-stream', (req, payload, done) => {
|
|
24
|
+
const limit = uploadLimit();
|
|
25
|
+
const file = join(tmpdir(), `kb-upload-${process.pid}-${Date.now()}.db`);
|
|
26
|
+
const out = createWriteStream(file);
|
|
27
|
+
let size = 0;
|
|
28
|
+
let failed = null;
|
|
29
|
+
const fail = (err) => {
|
|
30
|
+
if (failed) return;
|
|
31
|
+
failed = err;
|
|
32
|
+
payload.unpipe?.(out);
|
|
33
|
+
out.destroy();
|
|
34
|
+
rmSync(file, { force: true });
|
|
35
|
+
done(err);
|
|
36
|
+
};
|
|
37
|
+
payload.on('data', (chunk) => {
|
|
38
|
+
size += chunk.length;
|
|
39
|
+
if (size > limit) {
|
|
40
|
+
const e = new Error(`the file is larger than ${Math.round(limit / 1048576)} MB`);
|
|
41
|
+
e.statusCode = 413;
|
|
42
|
+
fail(e);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
payload.on('error', fail);
|
|
46
|
+
out.on('error', fail);
|
|
47
|
+
out.on('close', () => { if (!failed) done(null, { file, size }); });
|
|
48
|
+
payload.pipe(out);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
app.get('/api/labels', () => ({
|
|
52
|
+
palette: Object.fromEntries(Object.entries(LABELS).map(([name, hex]) => [name, `#${hex}`])),
|
|
53
|
+
selectable: Object.keys(LABELS).filter((name) => !MANAGED_LABELS.has(name)),
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
app.get('/api/sync', () => {
|
|
57
|
+
const pending = db.prepare(`SELECT COUNT(*) AS n FROM sync_queue WHERE attempts < ${SYNC_MAX_ATTEMPTS}`).get().n;
|
|
58
|
+
const failed = db.prepare(`SELECT id, op, task_id, last_error FROM sync_queue WHERE attempts >= ${SYNC_MAX_ATTEMPTS}`).all();
|
|
59
|
+
const errors = db.prepare(`
|
|
60
|
+
SELECT q.id, q.op, q.attempts, q.last_error, q.created_at, t.task_no, p.prefix
|
|
61
|
+
FROM sync_queue q
|
|
62
|
+
LEFT JOIN tasks t ON t.id = q.task_id
|
|
63
|
+
LEFT JOIN projects p ON p.id = t.project_id
|
|
64
|
+
WHERE q.last_error IS NOT NULL
|
|
65
|
+
ORDER BY q.id DESC LIMIT 10
|
|
66
|
+
`).all().map((r) => ({
|
|
67
|
+
id: r.id, op: r.op, attempts: r.attempts, last_error: r.last_error, created_at: r.created_at,
|
|
68
|
+
key: r.prefix && r.task_no ? `${r.prefix}-${r.task_no}` : null,
|
|
69
|
+
}));
|
|
70
|
+
return {
|
|
71
|
+
pending, failed, gh: ghState.available, errors, paused: syncPaused(), last_ok: kvGet('sync.last_ok'),
|
|
72
|
+
configured: syncConfigured(), owner: ghOwner(), repo: ghRepo(),
|
|
73
|
+
source: ghOwnerEnv() || ghRepoEnv() ? 'env' : (kvGet('gh.owner') || kvGet('gh.repo') ? 'kv' : null),
|
|
74
|
+
};
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
app.post('/api/sync/config', (req, reply) => {
|
|
78
|
+
const owner = String(req.body?.owner ?? '').trim();
|
|
79
|
+
const repo = String(req.body?.repo ?? '').trim();
|
|
80
|
+
const badGhName = (s) => !/^(?!-)[\w.-]{1,100}$/.test(s) || s === '.' || s === '..';
|
|
81
|
+
if (owner && badGhName(owner)) {
|
|
82
|
+
return reply.code(400).send({ error: 'owner: letters, digits and hyphens; cannot start with a hyphen' });
|
|
83
|
+
}
|
|
84
|
+
if (repo) {
|
|
85
|
+
const parts = repo.split('/');
|
|
86
|
+
if (parts.length !== 2 || parts.some(badGhName)) {
|
|
87
|
+
return reply.code(400).send({ error: 'repo: expected owner/name' });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const wasOn = syncConfigured();
|
|
91
|
+
kvSet('gh.owner', owner);
|
|
92
|
+
kvSet('gh.repo', repo);
|
|
93
|
+
let backfilled = 0;
|
|
94
|
+
if (!wasOn && syncConfigured()) {
|
|
95
|
+
const rows = db.prepare(`${TASK_SELECT} WHERE t.gh_issue_number IS NULL AND t.status NOT IN ('done','cancelled')`).all();
|
|
96
|
+
for (const t of rows) enqueue('create_issue', t.id, snapshot(t));
|
|
97
|
+
backfilled = rows.length;
|
|
98
|
+
}
|
|
99
|
+
if (syncConfigured()) kick();
|
|
100
|
+
emit('sync.status', { configured: syncConfigured() });
|
|
101
|
+
return { configured: syncConfigured(), owner: ghOwner(), repo: ghRepo(), backfilled };
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
app.post('/api/sync/retry', () => {
|
|
105
|
+
db.prepare("UPDATE sync_queue SET attempts = 0, next_attempt_at = datetime('now')").run();
|
|
106
|
+
kick();
|
|
107
|
+
return { ok: true };
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
app.post('/api/sync/pause', (req) => {
|
|
111
|
+
const paused = !!req.body?.paused;
|
|
112
|
+
kvSet('sync.paused', paused ? '1' : '0');
|
|
113
|
+
if (!paused) kick();
|
|
114
|
+
emit('sync.status', { paused });
|
|
115
|
+
return { paused };
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
app.get('/api/errors', (req) => {
|
|
119
|
+
const limit = Math.min(Number(req.query?.limit) || 100, 300);
|
|
120
|
+
return db.prepare(
|
|
121
|
+
'SELECT id, at, source, scope, message, substr(detail, 1, 300) AS detail, resolved_at, repeats FROM app_errors ORDER BY id DESC LIMIT ?',
|
|
122
|
+
).all(limit);
|
|
123
|
+
});
|
|
124
|
+
app.delete('/api/errors', () => { db.prepare('DELETE FROM app_errors').run(); return { ok: true }; });
|
|
125
|
+
const clientErrSeen = new Map();
|
|
126
|
+
const CLIENT_ERR_EVERY = 10000;
|
|
127
|
+
app.post('/api/errors', (req, reply) => {
|
|
128
|
+
const message = String(req.body?.message ?? '').trim();
|
|
129
|
+
if (!message) return reply.code(400).send({ error: 'message is required' });
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
if (now - (clientErrSeen.get(message) || 0) < CLIENT_ERR_EVERY) return { ok: true, skipped: true };
|
|
132
|
+
clientErrSeen.set(message, now);
|
|
133
|
+
if (clientErrSeen.size > 200) {
|
|
134
|
+
for (const [k, at] of clientErrSeen) if (now - at > CLIENT_ERR_EVERY) clientErrSeen.delete(k);
|
|
135
|
+
}
|
|
136
|
+
logError('client', req.body?.scope, message, req.body?.detail);
|
|
137
|
+
return { ok: true };
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const uiVersion = () => {
|
|
141
|
+
const roots = [join(ROOT_DIR, 'public'), join(ROOT_DIR, 'public', 'js')];
|
|
142
|
+
let newest = 0;
|
|
143
|
+
for (const dir of roots) {
|
|
144
|
+
let files = [];
|
|
145
|
+
try { files = readdirSync(dir); } catch { continue; }
|
|
146
|
+
for (const f of files) {
|
|
147
|
+
if (!f.endsWith('.js')) continue;
|
|
148
|
+
try { newest = Math.max(newest, statSync(join(dir, f)).mtimeMs); } catch { }
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return newest;
|
|
152
|
+
};
|
|
153
|
+
app.get('/api/ui-version', () => ({ mtime: uiVersion() }));
|
|
154
|
+
|
|
155
|
+
app.get('/api/about', async () => {
|
|
156
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
157
|
+
const stat = (p) => { try { return statSync(p); } catch { return null; } };
|
|
158
|
+
const commit = await new Promise((resolve) => {
|
|
159
|
+
execFile('git', ['log', '-1', '--format=%h %cs'], { cwd: ROOT }, (err, out) => resolve(err ? null : out.trim()));
|
|
160
|
+
});
|
|
161
|
+
const appMtime = uiVersion();
|
|
162
|
+
const dbPath = join(DATA_DIR, 'kanban.db');
|
|
163
|
+
return {
|
|
164
|
+
commit,
|
|
165
|
+
app_mtime: appMtime ? new Date(appMtime).toISOString() : null,
|
|
166
|
+
db_path: dbPath,
|
|
167
|
+
db_size: stat(dbPath)?.size ?? 0,
|
|
168
|
+
uptime: Math.floor(process.uptime()),
|
|
169
|
+
tasks: db.prepare('SELECT COUNT(*) AS n FROM tasks').get().n,
|
|
170
|
+
projects: db.prepare('SELECT COUNT(*) AS n FROM projects').get().n,
|
|
171
|
+
node: process.version,
|
|
172
|
+
packaged: ROOT.split(sep).includes('node_modules'),
|
|
173
|
+
};
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
let updCache = { at: 0, data: null };
|
|
177
|
+
let lastForced = 0;
|
|
178
|
+
const FORCE_EVERY = 15000;
|
|
179
|
+
app.get('/api/update-check', async (req) => {
|
|
180
|
+
const forced = Boolean(req.query?.refresh) && Date.now() - lastForced >= FORCE_EVERY;
|
|
181
|
+
if (forced) lastForced = Date.now();
|
|
182
|
+
if (!forced && updCache.data && Date.now() - updCache.at < 3600000) return updCache.data;
|
|
183
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
184
|
+
const git = (args) => new Promise((resolve) => {
|
|
185
|
+
execFile('git', args, { cwd: ROOT, timeout: 8000 }, (err, out) => resolve(err ? null : out.trim()));
|
|
186
|
+
});
|
|
187
|
+
const ghApi = async (path) => {
|
|
188
|
+
try {
|
|
189
|
+
const res = await fetch(`https://api.github.com/${path}`, {
|
|
190
|
+
headers: { 'User-Agent': 'local-kanban', Accept: 'application/vnd.github+json' },
|
|
191
|
+
signal: AbortSignal.timeout(8000),
|
|
192
|
+
});
|
|
193
|
+
if (res.ok) return await res.json();
|
|
194
|
+
} catch { }
|
|
195
|
+
try { return JSON.parse(await ghCli(['api', path], { timeout: 10000 })); }
|
|
196
|
+
catch { return null; }
|
|
197
|
+
};
|
|
198
|
+
let data = { update_available: null, local: null, remote: null, branch: null, tag: null, dev: null };
|
|
199
|
+
try {
|
|
200
|
+
const [local, branch, origin, tag] = await Promise.all([
|
|
201
|
+
git(['rev-parse', 'HEAD']),
|
|
202
|
+
git(['rev-parse', '--abbrev-ref', 'HEAD']),
|
|
203
|
+
git(['config', '--get', 'remote.origin.url']),
|
|
204
|
+
git(['describe', '--tags', '--abbrev=0']),
|
|
205
|
+
]);
|
|
206
|
+
const m = (origin || '').match(/github\.com[:/]([\w.-]+)\/([\w.-]+?)(?:\.git)?$/);
|
|
207
|
+
if (local && branch && m) {
|
|
208
|
+
const repo = `${m[1]}/${m[2]}`;
|
|
209
|
+
const head = await ghApi(`repos/${repo}/commits/${branch}`);
|
|
210
|
+
const remote = head?.sha || null;
|
|
211
|
+
if (remote) data = { update_available: remote !== local, local: local.slice(0, 7), remote: remote.slice(0, 7), branch, tag, dev: null };
|
|
212
|
+
else data = { ...data, branch, tag, local: local.slice(0, 7) };
|
|
213
|
+
if (branch !== 'dev') {
|
|
214
|
+
const cmp = await ghApi(`repos/${repo}/compare/${branch}...dev`);
|
|
215
|
+
if (cmp && typeof cmp.ahead_by === 'number') {
|
|
216
|
+
const tip = cmp.commits?.at(-1);
|
|
217
|
+
data.dev = {
|
|
218
|
+
ahead: cmp.ahead_by,
|
|
219
|
+
sha: (tip?.sha || cmp.merge_base_commit?.sha || '').slice(0, 7) || null,
|
|
220
|
+
date: tip?.commit?.committer?.date || null,
|
|
221
|
+
message: (tip?.commit?.message || '').split('\n')[0].slice(0, 90) || null,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
} catch { }
|
|
227
|
+
updCache = { at: Date.now(), data };
|
|
228
|
+
return data;
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
app.get('/api/lang', () => ({ lang: uiLang(), source: langOverride() ? 'env' : 'kv' }));
|
|
232
|
+
app.post('/api/lang', (req) => {
|
|
233
|
+
if (!langOverride()) kvSet('ui.lang', req.body?.lang === 'en' ? 'en' : 'ru');
|
|
234
|
+
return { lang: uiLang() };
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
app.get('/api/onboarding', () => ({ done: kvGet('onboarding.done') === '1' }));
|
|
238
|
+
app.post('/api/onboarding', (req) => {
|
|
239
|
+
kvSet('onboarding.done', req.body?.done ? '1' : '0');
|
|
240
|
+
return { done: kvGet('onboarding.done') === '1' };
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
app.get('/api/backups', () => ({
|
|
244
|
+
items: listBackups(),
|
|
245
|
+
last_ok: backupState.last_ok,
|
|
246
|
+
last_error: backupState.last_error,
|
|
247
|
+
last_error_at: backupState.last_error_at,
|
|
248
|
+
attachments: existsSync(ATTACH_MIRROR),
|
|
249
|
+
}));
|
|
250
|
+
app.post('/api/backups', async () => { await backupNow(); return { ok: true }; });
|
|
251
|
+
let uploading = false;
|
|
252
|
+
app.post('/api/backups/restore', async (req, reply) => {
|
|
253
|
+
const up = req.body;
|
|
254
|
+
if (!up?.file || !up.size) {
|
|
255
|
+
if (up?.file) rmSync(up.file, { force: true });
|
|
256
|
+
return reply.code(400).send({ error: 'empty file' });
|
|
257
|
+
}
|
|
258
|
+
if (uploading) { rmSync(up.file, { force: true }); return reply.code(409).send({ error: 'another backup is already being opened' }); }
|
|
259
|
+
uploading = true;
|
|
260
|
+
try {
|
|
261
|
+
const checked = inspectBackup(up.file);
|
|
262
|
+
if (!checked.ok) return reply.code(400).send({ error: checked.error });
|
|
263
|
+
try {
|
|
264
|
+
const preview = await startPreview(checked.dir, checked.stats);
|
|
265
|
+
return preview || reply.code(500).send({ error: 'the preview board failed to start' });
|
|
266
|
+
} catch (e) {
|
|
267
|
+
rmSync(checked.dir, { recursive: true, force: true });
|
|
268
|
+
return reply.code(500).send({ error: `could not start the preview board: ${e.message}` });
|
|
269
|
+
}
|
|
270
|
+
} finally {
|
|
271
|
+
rmSync(up.file, { force: true });
|
|
272
|
+
uploading = false;
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
app.get('/api/backups/restore', () => currentPreview() || { running: false });
|
|
276
|
+
app.delete('/api/backups/restore', () => ({ stopped: stopPreview() }));
|
|
277
|
+
|
|
278
|
+
app.get('/api/backups/:name', (req, reply) => {
|
|
279
|
+
const path = backupPath(req.params.name);
|
|
280
|
+
if (!path) return reply.code(404).send({ error: 'backup not found' });
|
|
281
|
+
reply.header('Content-Type', 'application/octet-stream');
|
|
282
|
+
reply.header('Content-Disposition', `attachment; filename="${req.params.name}"`);
|
|
283
|
+
return reply.send(createReadStream(path));
|
|
284
|
+
});
|
|
285
|
+
}
|