@ashikahmad/kanbanmd 0.0.0-stage → 2.0.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/.claude/skills/markdown-task-manager/SKILL.md +77 -0
- package/LICENSE +373 -0
- package/README.md +77 -2
- package/cli/init.js +139 -0
- package/cli/kanbanmd.js +192 -0
- package/cli/serve.js +161 -0
- package/core/backlog-spec.js +48 -0
- package/core/board-url.js +35 -0
- package/core/board.js +548 -0
- package/core/changes.js +130 -0
- package/core/dates.js +14 -0
- package/core/housekeeping-spec.js +62 -0
- package/core/housekeeping.js +81 -0
- package/core/index.js +14 -0
- package/core/links.js +55 -0
- package/core/migrate.js +69 -0
- package/core/ops.js +750 -0
- package/core/patch.js +438 -0
- package/core/read.js +252 -0
- package/core/share.js +41 -0
- package/core/text.js +145 -0
- package/io/node-fs.js +188 -0
- package/kanbanmd.html +1 -0
- package/mcp/server.js +25 -0
- package/mcp/tools.js +201 -0
- package/package.json +50 -4
package/cli/kanbanmd.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// kanbanmd: command line front end for the board core. Every command prints one JSON document.
|
|
3
|
+
// kanbanmd [--dir DIR] [--dry-run] [--actor NAME] <command> [args]
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { resolve } from 'node:path';
|
|
7
|
+
import { getBoard, getTask, getConfig, listArchive, getStats, summarizeChanges } from '../core/read.js';
|
|
8
|
+
import { etagOf } from '../core/text.js';
|
|
9
|
+
import { getHousekeeping, archiveCandidates } from '../core/housekeeping.js';
|
|
10
|
+
import { parseHousekeepingSpec } from '../core/housekeeping-spec.js';
|
|
11
|
+
import { BoardError } from '../core/text.js';
|
|
12
|
+
import { TASK_FIELDS } from '../core/ops.js';
|
|
13
|
+
import { runInit, parseAgents, promptAgents, AGENT_NAMES } from './init.js';
|
|
14
|
+
import { startServer, openBrowser, DEFAULT_PORT } from './serve.js';
|
|
15
|
+
import { readWorkspace, readActivity, mutate, migrateProject } from '../io/node-fs.js';
|
|
16
|
+
|
|
17
|
+
const BOOL = new Set(['dry-run', 'at-end', 'overdue', 'reverse', 'apply', 'enable', 'disable', 'no-open', 'force', 'yes', 'no-mcp', 'no-skill', 'no-instructions', 'no-board']);
|
|
18
|
+
function parseArgs(argv) {
|
|
19
|
+
const flags = {}, pos = [];
|
|
20
|
+
for (let i = 0; i < argv.length; i++) {
|
|
21
|
+
const a = argv[i];
|
|
22
|
+
if (a.startsWith('--')) {
|
|
23
|
+
const k = a.slice(2);
|
|
24
|
+
if (BOOL.has(k)) flags[k] = true;
|
|
25
|
+
else flags[k] = argv[++i];
|
|
26
|
+
} else pos.push(a);
|
|
27
|
+
}
|
|
28
|
+
return { flags, pos };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const HELP = `kanbanmd [--dir DIR] [--dry-run] [--actor NAME] <command>
|
|
32
|
+
|
|
33
|
+
housekeeping [--apply] [--set SPEC|off|reset] what to archive or review; --apply archives the finished ones in one edit;
|
|
34
|
+
--set "done=done after=30d | backlog=backlog after=90d | max=200" edits the policy
|
|
35
|
+
serve [--port N] [--no-open] serve the page and this folder on 127.0.0.1 so any browser can edit it (Ctrl-C stops)
|
|
36
|
+
init [--agent claude,cursor,vscode,gemini,codex,generic|all] [--force] [--yes] [--no-mcp] [--no-skill] [--no-instructions] [--no-board]
|
|
37
|
+
set this folder up for AI agents: MCP config, skill, instruction block, a starter board (--dry-run previews)
|
|
38
|
+
mcp [--read-only] run the MCP server (stdio) for this folder; this is what an agent's MCP config starts
|
|
39
|
+
migrate rewrite kanban.md / archive.md in the current format (backups kept)
|
|
40
|
+
board [filters] compact task list in display order. (alias: list)
|
|
41
|
+
task <id> [--fields a,b] one task in full, with its etag. (alias: show)
|
|
42
|
+
config valid columns, priorities, categories, users, tags
|
|
43
|
+
archived [filters] [--reverse] archived tasks (to archive one: archive <id>)
|
|
44
|
+
backlog [filters] tasks in the backlog file; backlog --enable [--column ID] [--file NAME.md] / --disable
|
|
45
|
+
moves the backlog column's tasks into (or back out of) their own file
|
|
46
|
+
stats counts by status / assignee / priority, overdue and due soon
|
|
47
|
+
changes [--since ISO] edits made through the tools (the page's edits are not logged)
|
|
48
|
+
filters: --status a,b --assignee @x --tag t --priority P --category C --text "words"
|
|
49
|
+
--due-before YYYY-MM-DD --overdue --limit N --offset N --fields title,status,progress (replaces the default columns; id is always kept)
|
|
50
|
+
apply <json | -> apply one op as JSON
|
|
51
|
+
create --title T [--status ID] [--priority P] [--category C] [--assignees a,b] [--tags x,y]
|
|
52
|
+
[--due YYYY-MM-DD] [--description D]
|
|
53
|
+
update <id> --<field> <value> ... fields: ${TASK_FIELDS.join(', ')}
|
|
54
|
+
move <id> <status> [--before ID | --at-end] in place by default; a position relocates the block
|
|
55
|
+
sort --mode blocks|ids|positions|clear [--order asc|desc] [--scope board|archive|both] (mode is required)
|
|
56
|
+
blocks: reorder file blocks by id. ids / positions: write Order keys from id order / current order.
|
|
57
|
+
clear: remove all Order keys.
|
|
58
|
+
toggle <id> <index|text> subtask <id> <text> note <id> <text>
|
|
59
|
+
archive <id> restore <id> delete <id>
|
|
60
|
+
config <categories|users|priorities|tags> a,b,c
|
|
61
|
+
columns <json> [{"name","id"}], optional "reassign" via apply`;
|
|
62
|
+
|
|
63
|
+
function summary(t) {
|
|
64
|
+
return { id: t.id, title: t.title, status: t.status, ...(t.order != null && { order: t.order }), priority: t.priority, assignees: t.assignees, due: t.due, tags: t.tags };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function main() {
|
|
68
|
+
const { flags, pos } = parseArgs(process.argv.slice(2));
|
|
69
|
+
const dir = resolve(flags.dir || process.env.KANBANMD_DIR || '.');
|
|
70
|
+
const [cmd, ...a] = pos;
|
|
71
|
+
const opt = { dryRun: !!flags['dry-run'], actor: flags.actor || 'cli' };
|
|
72
|
+
const need = (n, what) => { if (a.length < n) throw new BoardError('usage', `Missing ${what}`, { hint: 'Run kanbanmd --help' }); };
|
|
73
|
+
|
|
74
|
+
if (!cmd || cmd === 'help' || flags.help !== undefined) { console.log(HELP); return; }
|
|
75
|
+
|
|
76
|
+
if (cmd === 'backlog' && (flags.enable || flags.disable)) {
|
|
77
|
+
const op = flags.disable ? { type: 'setBacklogFile', enabled: false } : { type: 'setBacklogFile', enabled: true, column: flags.column, file: flags.file };
|
|
78
|
+
console.log(JSON.stringify({ ok: true, ...(await mutate(dir, op, opt)) }));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (cmd === 'housekeeping') {
|
|
83
|
+
if (flags.set !== undefined) {
|
|
84
|
+
const spec = String(flags.set).trim();
|
|
85
|
+
const h = parseHousekeepingSpec(spec) || {};
|
|
86
|
+
const op = spec === 'reset' ? { type: 'setHousekeeping', reset: true }
|
|
87
|
+
: spec.toLowerCase() === 'off' ? { type: 'setHousekeeping', off: true }
|
|
88
|
+
: { type: 'setHousekeeping', done: h.done, backlog: h.backlog, max: h.max };
|
|
89
|
+
console.log(JSON.stringify({ ok: true, ...(await mutate(dir, op, opt)) }));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const ws = await readWorkspace(dir);
|
|
93
|
+
if (flags.apply) {
|
|
94
|
+
const ids = archiveCandidates(ws);
|
|
95
|
+
if (!ids.length) { console.log(JSON.stringify({ ok: true, archived: 0, note: 'nothing to archive' })); return; }
|
|
96
|
+
console.log(JSON.stringify({ ok: true, archived: ids.length, ...(await mutate(dir, { type: 'archiveTasks', ids }, opt)) }));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
console.log(JSON.stringify({ ok: true, ...getHousekeeping(ws) }));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (cmd === 'mcp') {
|
|
104
|
+
await import('../mcp/server.js'); // loaded only here, so the other commands never need the MCP SDK
|
|
105
|
+
await new Promise(() => {}); // the server keeps the process alive over stdio
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (cmd === 'init') {
|
|
109
|
+
const pkg = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')).name;
|
|
110
|
+
let agents;
|
|
111
|
+
if (flags.agent !== undefined) agents = parseAgents(flags.agent);
|
|
112
|
+
else if (process.stdin.isTTY && !flags.yes) agents = await promptAgents();
|
|
113
|
+
else agents = ['claude'];
|
|
114
|
+
const report = runInit({ dir, agents, pkg, dryRun: opt.dryRun, force: !!flags.force, mcp: !flags['no-mcp'], skill: !flags['no-skill'], instructions: !flags['no-instructions'], board: !flags['no-board'] });
|
|
115
|
+
console.log(JSON.stringify({ ok: true, ...report }, null, 2));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (cmd === 'serve') {
|
|
120
|
+
const html = readFileSync(fileURLToPath(new URL('../kanbanmd.html', import.meta.url)), 'utf8');
|
|
121
|
+
const svc = await startServer({ dir, html, port: flags.port !== undefined ? Number(flags.port) : DEFAULT_PORT });
|
|
122
|
+
console.log(`Kanban.md is serving ${dir}\n\n ${svc.url}\n\nOpen that address (it carries the access key for this run). Ctrl-C to stop.`);
|
|
123
|
+
if (!flags['no-open']) openBrowser(svc.url);
|
|
124
|
+
await new Promise((r) => { process.once('SIGINT', r); process.once('SIGTERM', r); });
|
|
125
|
+
await svc.close();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (cmd === 'migrate') {
|
|
130
|
+
console.log(JSON.stringify({ ok: true, ...(await migrateProject(dir, { dryRun: opt.dryRun, actor: opt.actor })) }));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const READS = ['board', 'list', 'task', 'show', 'config', 'archived', 'backlog', 'stats', 'changes'];
|
|
135
|
+
// `config` alone shows the config; `config <list> a,b,c` writes a list.
|
|
136
|
+
if (READS.includes(cmd) && !(cmd === 'config' && a.length > 0)) {
|
|
137
|
+
const ws = await readWorkspace(dir);
|
|
138
|
+
const q = { status: flags.status, assignee: flags.assignee, tag: flags.tag, priority: flags.priority, category: flags.category,
|
|
139
|
+
text: flags.text, dueBefore: flags['due-before'], overdue: flags.overdue, limit: flags.limit, offset: flags.offset,
|
|
140
|
+
fields: flags.fields, reverse: flags.reverse };
|
|
141
|
+
let out;
|
|
142
|
+
if (cmd === 'board' || cmd === 'list') out = getBoard(ws, q);
|
|
143
|
+
else if (cmd === 'task' || cmd === 'show') { need(1, 'task id'); out = { task: getTask(ws, a[0], q) }; }
|
|
144
|
+
else if (cmd === 'config') out = getConfig(ws);
|
|
145
|
+
else if (cmd === 'archived') out = listArchive(ws, q);
|
|
146
|
+
else if (cmd === 'backlog') out = getBoard(ws, { ...q, backlog: true });
|
|
147
|
+
else if (cmd === 'stats') out = getStats(ws);
|
|
148
|
+
else out = summarizeChanges(await readActivity(dir), { since: flags.since, rev: etagOf(ws.board) });
|
|
149
|
+
console.log(JSON.stringify({ ok: true, ...out }));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let op;
|
|
154
|
+
if (cmd === 'apply') {
|
|
155
|
+
need(1, 'a JSON op, or "-" for stdin');
|
|
156
|
+
op = JSON.parse(a[0] === '-' ? readFileSync(0, 'utf8') : a[0]);
|
|
157
|
+
} else if (cmd === 'create') {
|
|
158
|
+
const task = {};
|
|
159
|
+
for (const f of TASK_FIELDS) if (flags[f] !== undefined) task[f] = flags[f];
|
|
160
|
+
if (task.subtasks) task.subtasks = JSON.parse(task.subtasks);
|
|
161
|
+
op = { type: 'createTask', task };
|
|
162
|
+
} else if (cmd === 'update') {
|
|
163
|
+
need(1, 'task id');
|
|
164
|
+
const set = {};
|
|
165
|
+
for (const f of TASK_FIELDS) if (flags[f] !== undefined) set[f] = f === 'subtasks' ? JSON.parse(flags[f]) : flags[f];
|
|
166
|
+
op = { type: 'updateTask', id: a[0], set, ifMatch: flags['if-match'] };
|
|
167
|
+
} else if (cmd === 'move') {
|
|
168
|
+
need(2, 'task id and status');
|
|
169
|
+
op = { type: 'moveTask', id: a[0], status: a[1], before: flags.before, atEnd: flags['at-end'], ifMatch: flags['if-match'] };
|
|
170
|
+
} else if (cmd === 'sort') {
|
|
171
|
+
op = { type: 'sortTasks', mode: flags.mode, scope: flags.scope, order: flags.order };
|
|
172
|
+
} else if (cmd === 'toggle') {
|
|
173
|
+
need(2, 'task id and subtask index or text');
|
|
174
|
+
op = /^\d+$/.test(a[1]) ? { type: 'toggleSubtask', id: a[0], index: +a[1] } : { type: 'toggleSubtask', id: a[0], text: a[1] };
|
|
175
|
+
} else if (cmd === 'subtask') { need(2, 'task id and text'); op = { type: 'addSubtask', id: a[0], text: a.slice(1).join(' ') }; }
|
|
176
|
+
else if (cmd === 'note') { need(2, 'task id and text'); op = { type: 'appendNote', id: a[0], text: a.slice(1).join(' ') }; }
|
|
177
|
+
else if (cmd === 'archive') { need(1, 'task id'); op = { type: 'archiveTask', id: a[0] }; }
|
|
178
|
+
else if (cmd === 'restore') { need(1, 'task id'); op = { type: 'restoreTask', id: a[0] }; }
|
|
179
|
+
else if (cmd === 'delete') { need(1, 'task id'); op = { type: 'deleteTask', id: a[0] }; }
|
|
180
|
+
else if (cmd === 'config') { need(2, 'list name and comma separated values'); op = { type: 'setConfigList', name: a[0], values: a[1].split(',') }; }
|
|
181
|
+
else if (cmd === 'columns') { need(1, 'a JSON array of { name, id }'); op = { type: 'setColumns', columns: JSON.parse(a[0]) }; }
|
|
182
|
+
else throw new BoardError('usage', `Unknown command "${cmd}"`, { hint: 'Run kanbanmd --help' });
|
|
183
|
+
|
|
184
|
+
const out = await mutate(dir, op, opt);
|
|
185
|
+
console.log(JSON.stringify({ ok: true, ...out }));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
main().catch((e) => {
|
|
189
|
+
const err = e instanceof BoardError ? e.toJSON() : { code: 'internal', message: e.message };
|
|
190
|
+
console.log(JSON.stringify({ ok: false, error: err }));
|
|
191
|
+
process.exitCode = 1;
|
|
192
|
+
});
|
package/cli/serve.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// kanbanmd serve: a local HTTP service that serves the page (kanbanmd.html) and a tiny file API for one
|
|
2
|
+
// project folder, so any browser (Firefox, Safari, Chrome) can edit the real files. Zero dependencies.
|
|
3
|
+
//
|
|
4
|
+
// Access: the server listens on 127.0.0.1 only. `/?t=<token>` (the URL it prints) sets an HttpOnly,
|
|
5
|
+
// SameSite=Strict session cookie and redirects to `/`. Every request must carry the right Host, a matching
|
|
6
|
+
// Origin when the browser sends one, and (for the API) the session cookie; writes also need the X-Kanbanmd
|
|
7
|
+
// header, which no other site can add without a CORS preflight that this server never approves.
|
|
8
|
+
// Only kanban.md, archive.md, the board's backlog file and the v1 backup files can be read or written.
|
|
9
|
+
import http from 'node:http';
|
|
10
|
+
import { promises as fs } from 'node:fs';
|
|
11
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
12
|
+
import { join, sep } from 'node:path';
|
|
13
|
+
import { spawn } from 'node:child_process';
|
|
14
|
+
import { backlogFileNameOf } from '../core/backlog-spec.js';
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_PORT = 4777;
|
|
17
|
+
const MAX_BODY = 5 * 1024 * 1024;
|
|
18
|
+
|
|
19
|
+
function sameToken(a, b) {
|
|
20
|
+
const x = Buffer.from(String(a)), y = Buffer.from(String(b));
|
|
21
|
+
return x.length === y.length && timingSafeEqual(x, y);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function cookieOf(req, name) {
|
|
25
|
+
for (const part of String(req.headers.cookie || '').split(';')) {
|
|
26
|
+
const i = part.indexOf('=');
|
|
27
|
+
if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim();
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const etagOf = (st) => `"${Math.round(st.mtimeMs)}-${st.size}"`;
|
|
33
|
+
|
|
34
|
+
// Start the service. `html` is the page's text. Resolves to { url, port, token, close }.
|
|
35
|
+
// `port: 0` picks a free port; a busy named port falls through to the next ones.
|
|
36
|
+
export async function startServer({ dir, html, port = DEFAULT_PORT, token = randomBytes(24).toString('base64url') }) {
|
|
37
|
+
const sessions = new Set();
|
|
38
|
+
// The label the page shows: the last two parts of the path ("Kanban.md/board"), since a folder name alone
|
|
39
|
+
// (board, docs) says little. The full path is its tooltip.
|
|
40
|
+
const name = dir.split(sep).filter(Boolean).slice(-2).join('/') || dir;
|
|
41
|
+
let boundPort = 0;
|
|
42
|
+
const cookieName = () => `kanbanmd_${boundPort}`;
|
|
43
|
+
const page = () => {
|
|
44
|
+
const inject = `<script>window.KANBANMD_SERVICE=${JSON.stringify({ api: '/api', name, path: dir }).replace(/</g, '\\u003c')};</script>`;
|
|
45
|
+
const i = html.indexOf('<script');
|
|
46
|
+
return i < 0 ? html + inject : html.slice(0, i) + inject + html.slice(i);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const send = (res, status, body, headers = {}) => {
|
|
50
|
+
res.writeHead(status, { 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', 'Content-Type': 'text/plain; charset=utf-8', ...headers });
|
|
51
|
+
res.end(body);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Which file names the API accepts.
|
|
55
|
+
async function allowedName(n) {
|
|
56
|
+
if (n === 'kanban.md' || n === 'archive.md') return true;
|
|
57
|
+
if (/^(kanban|archive)\.v\d+-backup\.md$/.test(n)) return true;
|
|
58
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*\.md$/.test(n)) return false;
|
|
59
|
+
try { return n === backlogFileNameOf(await fs.readFile(join(dir, 'kanban.md'), 'utf8')); }
|
|
60
|
+
catch { return false; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function readBody(req) {
|
|
64
|
+
const chunks = []; let size = 0;
|
|
65
|
+
for await (const c of req) {
|
|
66
|
+
size += c.length;
|
|
67
|
+
if (size > MAX_BODY) { const e = new Error('too large'); e.status = 413; throw e; }
|
|
68
|
+
chunks.push(c);
|
|
69
|
+
}
|
|
70
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function api(req, res, url) {
|
|
74
|
+
const m = /^\/api\/file\/([^/]+)$/.exec(url.pathname);
|
|
75
|
+
if (!m) return send(res, 404, 'Not found');
|
|
76
|
+
let n;
|
|
77
|
+
try { n = decodeURIComponent(m[1]); } catch { return send(res, 400, 'Bad name'); }
|
|
78
|
+
if (!(await allowedName(n))) return send(res, 404, 'Not found');
|
|
79
|
+
const path = join(dir, n);
|
|
80
|
+
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
81
|
+
let st;
|
|
82
|
+
try { st = await fs.stat(path); } catch { return send(res, 404, 'Not found'); }
|
|
83
|
+
const headers = { ETag: etagOf(st), 'Content-Type': 'text/markdown; charset=utf-8' };
|
|
84
|
+
if (req.method === 'HEAD') return send(res, 200, '', headers);
|
|
85
|
+
return send(res, 200, await fs.readFile(path), headers);
|
|
86
|
+
}
|
|
87
|
+
if (req.method === 'PUT') {
|
|
88
|
+
const text = await readBody(req);
|
|
89
|
+
const want = req.headers['if-match'];
|
|
90
|
+
if (want) {
|
|
91
|
+
let cur = null;
|
|
92
|
+
try { cur = etagOf(await fs.stat(path)); } catch { /* no file */ }
|
|
93
|
+
if (cur !== want) return send(res, 412, 'The file changed');
|
|
94
|
+
}
|
|
95
|
+
const tmp = join(dir, `.${n}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`);
|
|
96
|
+
try { await fs.writeFile(tmp, text); await fs.rename(tmp, path); }
|
|
97
|
+
catch (e) { await fs.rm(tmp, { force: true }); throw e; }
|
|
98
|
+
return send(res, 200, '', { ETag: etagOf(await fs.stat(path)) });
|
|
99
|
+
}
|
|
100
|
+
return send(res, 405, 'Method not allowed', { Allow: 'GET, HEAD, PUT' });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const server = http.createServer(async (req, res) => {
|
|
104
|
+
try {
|
|
105
|
+
// Host and Origin: refuse anything that is not this server addressed as itself.
|
|
106
|
+
const host = String(req.headers.host || '').toLowerCase();
|
|
107
|
+
const hosts = [`127.0.0.1:${boundPort}`, `localhost:${boundPort}`];
|
|
108
|
+
if (!hosts.includes(host)) return send(res, 403, 'Wrong host');
|
|
109
|
+
const origin = req.headers.origin;
|
|
110
|
+
if (origin && !hosts.some((h) => origin === `http://${h}`)) return send(res, 403, 'Wrong origin');
|
|
111
|
+
|
|
112
|
+
const url = new URL(req.url, `http://${host}`);
|
|
113
|
+
const sid = cookieOf(req, cookieName());
|
|
114
|
+
const authed = !!sid && sessions.has(sid);
|
|
115
|
+
|
|
116
|
+
if (url.pathname === '/' && (req.method === 'GET' || req.method === 'HEAD')) {
|
|
117
|
+
const t = url.searchParams.get('t');
|
|
118
|
+
if (t !== null) {
|
|
119
|
+
if (!sameToken(t, token)) return send(res, 401, 'This link is not valid. Use the address printed by kanbanmd serve.');
|
|
120
|
+
const id = randomBytes(24).toString('base64url');
|
|
121
|
+
sessions.add(id);
|
|
122
|
+
return send(res, 302, '', { Location: '/', 'Set-Cookie': `${cookieName()}=${id}; HttpOnly; SameSite=Strict; Path=/` });
|
|
123
|
+
}
|
|
124
|
+
if (!authed) return send(res, 401, 'Open the address that kanbanmd serve printed in the terminal (it carries the access key).');
|
|
125
|
+
return send(res, 200, req.method === 'HEAD' ? '' : page(), { 'Content-Type': 'text/html; charset=utf-8' });
|
|
126
|
+
}
|
|
127
|
+
if (url.pathname.startsWith('/api/')) {
|
|
128
|
+
if (!authed) return send(res, 401, 'Not signed in');
|
|
129
|
+
if (req.method !== 'GET' && req.method !== 'HEAD' && req.headers['x-kanbanmd'] !== '1') return send(res, 403, 'Missing header');
|
|
130
|
+
return await api(req, res, url);
|
|
131
|
+
}
|
|
132
|
+
return send(res, 404, 'Not found');
|
|
133
|
+
} catch (e) {
|
|
134
|
+
send(res, e.status || 500, e.status ? e.message : 'Server error');
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const tries = port === 0 ? 1 : 20;
|
|
139
|
+
for (let i = 0; i < tries; i++) {
|
|
140
|
+
const p = port === 0 ? 0 : port + i;
|
|
141
|
+
try {
|
|
142
|
+
await new Promise((ok, fail) => { server.once('error', fail); server.listen(p, '127.0.0.1', () => { server.off('error', fail); ok(); }); });
|
|
143
|
+
break;
|
|
144
|
+
} catch (e) {
|
|
145
|
+
if (e.code !== 'EADDRINUSE' || i === tries - 1) throw e;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
boundPort = server.address().port;
|
|
149
|
+
return {
|
|
150
|
+
port: boundPort, token, sessions,
|
|
151
|
+
url: `http://127.0.0.1:${boundPort}/?t=${token}`,
|
|
152
|
+
close: () => new Promise((ok) => { server.close(ok); server.closeAllConnections?.(); }),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Open a URL in the default browser (best effort).
|
|
157
|
+
export function openBrowser(url) {
|
|
158
|
+
const [cmd, args] = process.platform === 'darwin' ? ['open', [url]]
|
|
159
|
+
: process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]];
|
|
160
|
+
try { spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref(); } catch { /* the URL is printed anyway */ }
|
|
161
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The optional "**Backlog file**:" config line. Pure, no imports besides the housekeeping spec.
|
|
2
|
+
//
|
|
3
|
+
// **Backlog file**: backlog.md (backlog)
|
|
4
|
+
//
|
|
5
|
+
// The file name, and in brackets the id of the column whose tasks live in that file (like the
|
|
6
|
+
// "name (id)" convention of the Columns line). Without brackets the column is guessed from the
|
|
7
|
+
// column names, as the housekeeping policy does. No line = no backlog file.
|
|
8
|
+
//
|
|
9
|
+
// The invariant: with a backlog file configured, a task lives in that file exactly when its status is
|
|
10
|
+
// that column. Every op re-establishes it, so promoting or demoting a task is just changing its status.
|
|
11
|
+
import { resolveHousekeeping } from './housekeeping-spec.js';
|
|
12
|
+
|
|
13
|
+
export const BACKLOG_DEFAULT_FILE = 'backlog.md';
|
|
14
|
+
const RESERVED = new Set(['kanban.md', 'archive.md']);
|
|
15
|
+
|
|
16
|
+
export const isValidStoreFileName = (name) => /^[\w.-]+\.md$/.test(name) && !name.includes('..') && !RESERVED.has(name.toLowerCase());
|
|
17
|
+
|
|
18
|
+
// "backlog.md (backlog)" | "backlog.md" | "off" -> { file, column? } | null
|
|
19
|
+
export function parseBacklogFileSpec(text) {
|
|
20
|
+
if (text === undefined || text === null) return null;
|
|
21
|
+
const s = String(text).trim();
|
|
22
|
+
if (!s || /^off$/i.test(s)) return null;
|
|
23
|
+
const m = /^(\S+?)\s*(?:\(([^)]+)\))?\s*$/.exec(s);
|
|
24
|
+
if (!m || !isValidStoreFileName(m[1])) return null;
|
|
25
|
+
return { file: m[1], ...(m[2] && { column: m[2].trim() }) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function formatBacklogFileSpec(b) {
|
|
29
|
+
if (!b) return 'off';
|
|
30
|
+
return b.column ? `${b.file} (${b.column})` : b.file;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The usable setting: the file and an existing column (explicit or guessed), or null when there is none.
|
|
34
|
+
export function resolveBacklogFile(config) {
|
|
35
|
+
const b = config.backlogFile;
|
|
36
|
+
if (!b) return null;
|
|
37
|
+
const cols = config.columns || [];
|
|
38
|
+
const column = (b.column && cols.some((c) => c.id === b.column) ? b.column : null) || (b.column ? null : resolveHousekeeping({ ...config, housekeeping: null }).backlog.column);
|
|
39
|
+
return column ? { file: b.file, column } : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The backlog file's name as the board text declares it (or the default), found without a full parse:
|
|
43
|
+
// the I/O layer needs it to know which file to read.
|
|
44
|
+
export function backlogFileNameOf(boardText) {
|
|
45
|
+
const m = /\*\*Backlog file\*\*:\s*(.+)/.exec(boardText);
|
|
46
|
+
const spec = m && parseBacklogFileSpec(m[1].trim());
|
|
47
|
+
return spec ? spec.file : BACKLOG_DEFAULT_FILE;
|
|
48
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Opening a board from a web address (the page's `?board=<url>`): which URL to fetch, whether it is on the
|
|
2
|
+
// page's own site, where the board's other files are, and whether a fetched text is a board at all. Pure:
|
|
3
|
+
// the fetching is the page's web source.
|
|
4
|
+
|
|
5
|
+
// `param` is the ?board= value, `pageHref` the page's own address (relative values resolve against it).
|
|
6
|
+
// Returns { href, origin, sameOrigin, label } or null when it is not an http(s) address.
|
|
7
|
+
// - A github.com "blob" link (the page that shows a file) becomes the raw file on raw.githubusercontent.com.
|
|
8
|
+
// - A folder address (ending in "/") means the kanban.md in it.
|
|
9
|
+
export function resolveBoardUrl(param, pageHref) {
|
|
10
|
+
const text = String(param ?? '').trim();
|
|
11
|
+
if (!text) return null;
|
|
12
|
+
let url;
|
|
13
|
+
try { url = new URL(text, pageHref); } catch (e) { return null; }
|
|
14
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
|
|
15
|
+
const blob = url.hostname === 'github.com' && /^\/([^/]+)\/([^/]+)\/blob\/(.+)$/.exec(url.pathname);
|
|
16
|
+
if (blob) url = new URL(`https://raw.githubusercontent.com/${blob[1]}/${blob[2]}/${blob[3]}`);
|
|
17
|
+
if (url.pathname.endsWith('/')) url.pathname += 'kanban.md';
|
|
18
|
+
url.hash = '';
|
|
19
|
+
let pageOrigin = null;
|
|
20
|
+
try { pageOrigin = new URL(pageHref).origin; } catch (e) { /* no page address: never same-origin */ }
|
|
21
|
+
return { href: url.href, origin: url.origin, sameOrigin: url.origin === pageOrigin, label: url.host + url.pathname };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The address of another file of the board (archive.md, the backlog file), next to kanban.md. Only plain
|
|
25
|
+
// file names are allowed, so a board cannot point the page at another folder or site. null = not allowed.
|
|
26
|
+
export function boardFileUrl(boardHref, name) {
|
|
27
|
+
if (!/^\w[\w.-]*\.md$/.test(String(name)) || name.includes('..')) return null;
|
|
28
|
+
return new URL(name, boardHref).href;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Whether a fetched text is a Kanban.md board (and not, say, an HTML page or some other Markdown file).
|
|
32
|
+
export function looksLikeBoard(text) {
|
|
33
|
+
const s = String(text ?? '');
|
|
34
|
+
return /<!-- Config: Last Task ID: \d+ -->/.test(s) || /^\*\*Columns\*\*:/m.test(s) || /^###\s+TASK-\d+\s*\|/m.test(s);
|
|
35
|
+
}
|