@pi-unipi/kanboard 2.20.4 → 3.0.0-alpha.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/README.md +184 -66
- package/index.ts +206 -63
- package/package.json +18 -23
- package/skills/kanboard/SKILL.md +106 -0
- package/src/bin.ts +210 -0
- package/src/commands.ts +1371 -0
- package/src/guard.ts +143 -0
- package/src/runner.ts +817 -0
- package/src/settings.ts +158 -0
- package/src/shapes.ts +220 -0
- package/commands.ts +0 -83
- package/parser/checkbox-parser.ts +0 -165
- package/parser/frontmatter.ts +0 -26
- package/parser/index.ts +0 -94
- package/parser/milestones.ts +0 -110
- package/parser/plans.ts +0 -111
- package/server/index.ts +0 -266
- package/server/routes/milestone.ts +0 -38
- package/server/routes/workflow.ts +0 -41
- package/skills/kanboard-doctor/SKILL.md +0 -71
- package/types.ts +0 -67
- package/ui/components/checklist.ts +0 -58
- package/ui/components/copy-button.ts +0 -23
- package/ui/components/status-badge.ts +0 -21
- package/ui/layouts/base.ts +0 -68
- package/ui/milestone/page.ts +0 -140
- package/ui/static/app.js +0 -73
- package/ui/static/style.css +0 -729
- package/ui/workflow/page.ts +0 -182
package/parser/index.ts
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @pi-unipi/kanboard — Parser Registry
|
|
3
|
-
*
|
|
4
|
-
* Central registry for document parsers. Auto-detects doc type
|
|
5
|
-
* by file path and routes to the appropriate parser.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import * as fs from "node:fs";
|
|
9
|
-
import * as path from "node:path";
|
|
10
|
-
import type { DocParser, ParsedDoc } from "../types.js";
|
|
11
|
-
|
|
12
|
-
/** Parser registry — manages all document parsers */
|
|
13
|
-
export class ParserRegistry {
|
|
14
|
-
private parsers: DocParser[] = [];
|
|
15
|
-
|
|
16
|
-
/** Register a parser */
|
|
17
|
-
register(parser: DocParser): void {
|
|
18
|
-
this.parsers.push(parser);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** Parse a single file using the matching parser */
|
|
22
|
-
parse(filePath: string): ParsedDoc | null {
|
|
23
|
-
for (const parser of this.parsers) {
|
|
24
|
-
if (parser.canParse(filePath)) {
|
|
25
|
-
return parser.parse(filePath);
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Parse all matching files in a directory */
|
|
32
|
-
parseAll(dir: string): ParsedDoc[] {
|
|
33
|
-
const results: ParsedDoc[] = [];
|
|
34
|
-
const files = this.findDocFiles(dir);
|
|
35
|
-
|
|
36
|
-
for (const file of files) {
|
|
37
|
-
const doc = this.parse(file);
|
|
38
|
-
if (doc) {
|
|
39
|
-
results.push(doc);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
return results;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Recursively find .md files in docs directory */
|
|
47
|
-
private findDocFiles(dir: string): string[] {
|
|
48
|
-
const files: string[] = [];
|
|
49
|
-
|
|
50
|
-
if (!fs.existsSync(dir)) return files;
|
|
51
|
-
|
|
52
|
-
const walk = (currentDir: string) => {
|
|
53
|
-
let entries: fs.Dirent[];
|
|
54
|
-
try {
|
|
55
|
-
entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
56
|
-
} catch {
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
for (const entry of entries) {
|
|
61
|
-
const fullPath = path.join(currentDir, entry.name);
|
|
62
|
-
if (entry.isDirectory() && !entry.name.startsWith(".")) {
|
|
63
|
-
walk(fullPath);
|
|
64
|
-
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
65
|
-
files.push(fullPath);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
walk(dir);
|
|
71
|
-
return files;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Create a registry with all default parsers registered. */
|
|
76
|
-
export async function createDefaultRegistry(): Promise<ParserRegistry> {
|
|
77
|
-
const registry = new ParserRegistry();
|
|
78
|
-
|
|
79
|
-
// Register checkbox-driven parsers (spec, quick-work, debug, fix, chore, review)
|
|
80
|
-
const { CheckboxParser, CHECKBOX_DOC_CONFIGS } = await import("./checkbox-parser.js");
|
|
81
|
-
for (const config of CHECKBOX_DOC_CONFIGS) {
|
|
82
|
-
registry.register(new CheckboxParser(config));
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// Register plan parser (task-header + status-line format, not checkbox)
|
|
86
|
-
const { PlanParser } = await import("./plans.js");
|
|
87
|
-
registry.register(new PlanParser());
|
|
88
|
-
|
|
89
|
-
// Register milestone parser (inline frontmatter + phase headers)
|
|
90
|
-
const { MilestoneParser } = await import("./milestones.js");
|
|
91
|
-
registry.register(new MilestoneParser());
|
|
92
|
-
|
|
93
|
-
return registry;
|
|
94
|
-
}
|
package/parser/milestones.ts
DELETED
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @pi-unipi/kanboard — Milestone Parser
|
|
3
|
-
*
|
|
4
|
-
* Parses MILESTONES.md. Imports `parseMilestones` from `@pi-unipi/milestone`
|
|
5
|
-
* and converts MilestoneDoc to ParsedDoc format.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import * as fs from "node:fs";
|
|
9
|
-
import * as path from "node:path";
|
|
10
|
-
import type { DocParser, ParsedDoc, ParsedItem } from "../types.js";
|
|
11
|
-
|
|
12
|
-
/** Milestone parser — converts MilestoneDoc to ParsedDoc */
|
|
13
|
-
export class MilestoneParser implements DocParser {
|
|
14
|
-
canParse(filePath: string): boolean {
|
|
15
|
-
return /MILESTONES\.md$/i.test(filePath);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
parse(filePath: string): ParsedDoc {
|
|
19
|
-
const warnings: string[] = [];
|
|
20
|
-
const items: ParsedItem[] = [];
|
|
21
|
-
let content: string;
|
|
22
|
-
|
|
23
|
-
try {
|
|
24
|
-
content = fs.readFileSync(filePath, "utf-8");
|
|
25
|
-
} catch (err: unknown) {
|
|
26
|
-
warnings.push(`Could not read file: ${err instanceof Error ? err.message : String(err)}`);
|
|
27
|
-
return this.emptyDoc(filePath, warnings);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const lines = content.split("\n");
|
|
31
|
-
const fileName = path.basename(filePath);
|
|
32
|
-
|
|
33
|
-
// Inline parsing — extract phases and items directly
|
|
34
|
-
// This avoids requiring @pi-unipi/milestone as a runtime dependency
|
|
35
|
-
let currentPhase = "";
|
|
36
|
-
let inFrontmatter = false;
|
|
37
|
-
let frontmatterDone = false;
|
|
38
|
-
let title = "Project Milestones";
|
|
39
|
-
|
|
40
|
-
for (let i = 0; i < lines.length; i++) {
|
|
41
|
-
const line = lines[i];
|
|
42
|
-
const lineNum = i + 1;
|
|
43
|
-
|
|
44
|
-
// Frontmatter parsing
|
|
45
|
-
if (lineNum === 1 && line.trim() === "---") {
|
|
46
|
-
inFrontmatter = true;
|
|
47
|
-
continue;
|
|
48
|
-
}
|
|
49
|
-
if (inFrontmatter && line.trim() === "---") {
|
|
50
|
-
inFrontmatter = false;
|
|
51
|
-
frontmatterDone = true;
|
|
52
|
-
continue;
|
|
53
|
-
}
|
|
54
|
-
if (inFrontmatter) {
|
|
55
|
-
const titleMatch = line.match(/^title:\s*(.+)$/);
|
|
56
|
-
if (titleMatch) {
|
|
57
|
-
title = titleMatch[1].trim().replace(/^["']|["']$/g, "");
|
|
58
|
-
}
|
|
59
|
-
continue;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Phase headers (## Phase N: Name)
|
|
63
|
-
const phaseMatch = line.match(/^##\s+(.+)$/);
|
|
64
|
-
if (phaseMatch) {
|
|
65
|
-
currentPhase = phaseMatch[1].trim();
|
|
66
|
-
continue;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Checklist items (- [ ] or - [x])
|
|
70
|
-
const checkboxMatch = line.match(/^\s*-\s*\[([ xX])\]\s*(.*)$/);
|
|
71
|
-
if (checkboxMatch) {
|
|
72
|
-
const checked = checkboxMatch[1].toLowerCase() === "x";
|
|
73
|
-
const text = checkboxMatch[2].trim();
|
|
74
|
-
|
|
75
|
-
if (!text) {
|
|
76
|
-
warnings.push(`Line ${lineNum}: Empty checkbox text`);
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
items.push({
|
|
81
|
-
text: currentPhase ? `[${currentPhase}] ${text}` : text,
|
|
82
|
-
status: checked ? "done" : "todo",
|
|
83
|
-
lineNumber: lineNum,
|
|
84
|
-
sourceFile: fileName,
|
|
85
|
-
command: `/unipi:milestone-update`,
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return {
|
|
91
|
-
type: "milestone",
|
|
92
|
-
title,
|
|
93
|
-
filePath,
|
|
94
|
-
items,
|
|
95
|
-
metadata: {},
|
|
96
|
-
warnings,
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
private emptyDoc(filePath: string, warnings: string[]): ParsedDoc {
|
|
101
|
-
return {
|
|
102
|
-
type: "milestone",
|
|
103
|
-
title: "Project Milestones",
|
|
104
|
-
filePath,
|
|
105
|
-
items: [],
|
|
106
|
-
metadata: {},
|
|
107
|
-
warnings,
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
}
|
package/parser/plans.ts
DELETED
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @pi-unipi/kanboard — Plan Parser
|
|
3
|
-
*
|
|
4
|
-
* Parses plans for task statuses. Handles the format:
|
|
5
|
-
* ### Task N — Name
|
|
6
|
-
* - **Status:** unstarted|in-progress|completed|failed|awaiting_user|blocked|skipped
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import * as fs from "node:fs";
|
|
10
|
-
import * as path from "node:path";
|
|
11
|
-
import type { DocParser, ParsedDoc, ParsedItem, ItemStatus } from "../types.js";
|
|
12
|
-
import { parseFrontmatter } from "./frontmatter.js";
|
|
13
|
-
|
|
14
|
-
/** Parse frontmatter from markdown file */
|
|
15
|
-
/** Status keyword to ItemStatus mapping */
|
|
16
|
-
const STATUS_MAP: Record<string, ItemStatus> = {
|
|
17
|
-
unstarted: "todo",
|
|
18
|
-
"in-progress": "in-progress",
|
|
19
|
-
completed: "done",
|
|
20
|
-
failed: "todo",
|
|
21
|
-
awaiting_user: "in-progress",
|
|
22
|
-
blocked: "in-progress",
|
|
23
|
-
skipped: "done",
|
|
24
|
-
reviewed: "reviewed",
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
/** Task header pattern */
|
|
28
|
-
const TASK_HEADER_PATTERN = /^###\s+Task\s+\d+\s*[—–-]\s*(.+)$/;
|
|
29
|
-
|
|
30
|
-
/** Status line pattern: - **Status:** keyword */
|
|
31
|
-
const STATUS_LINE_PATTERN =
|
|
32
|
-
/^\s*-\s*\*\*Status:\*\*\s*(unstarted|in-progress|completed|failed|awaiting_user|blocked|skipped|reviewed)\s*$/;
|
|
33
|
-
|
|
34
|
-
/** Plan parser — extracts task statuses from plans */
|
|
35
|
-
export class PlanParser implements DocParser {
|
|
36
|
-
canParse(filePath: string): boolean {
|
|
37
|
-
return /\/plans\//.test(filePath) && filePath.endsWith(".md");
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
parse(filePath: string): ParsedDoc {
|
|
41
|
-
const warnings: string[] = [];
|
|
42
|
-
const items: ParsedItem[] = [];
|
|
43
|
-
let content: string;
|
|
44
|
-
|
|
45
|
-
try {
|
|
46
|
-
content = fs.readFileSync(filePath, "utf-8");
|
|
47
|
-
} catch (err: unknown) {
|
|
48
|
-
warnings.push(`Could not read file: ${err instanceof Error ? err.message : String(err)}`);
|
|
49
|
-
return this.emptyDoc(filePath, warnings);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const { metadata, bodyStart } = parseFrontmatter(content);
|
|
53
|
-
const lines = content.split("\n");
|
|
54
|
-
const fileName = path.basename(filePath);
|
|
55
|
-
|
|
56
|
-
let currentTaskName: string | null = null;
|
|
57
|
-
let currentTaskLine: number | null = null;
|
|
58
|
-
|
|
59
|
-
for (let i = bodyStart; i < lines.length; i++) {
|
|
60
|
-
const line = lines[i];
|
|
61
|
-
const lineNum = i + 1; // 1-indexed
|
|
62
|
-
|
|
63
|
-
// Match task headers: ### Task N — Name
|
|
64
|
-
const headerMatch = line.match(TASK_HEADER_PATTERN);
|
|
65
|
-
if (headerMatch) {
|
|
66
|
-
currentTaskName = headerMatch[1].trim();
|
|
67
|
-
currentTaskLine = lineNum;
|
|
68
|
-
continue;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Match status lines: - **Status:** keyword
|
|
72
|
-
const statusMatch = line.match(STATUS_LINE_PATTERN);
|
|
73
|
-
if (statusMatch && currentTaskName) {
|
|
74
|
-
const statusKey = statusMatch[1];
|
|
75
|
-
const status = STATUS_MAP[statusKey] ?? "todo";
|
|
76
|
-
|
|
77
|
-
items.push({
|
|
78
|
-
text: currentTaskName,
|
|
79
|
-
status,
|
|
80
|
-
lineNumber: currentTaskLine ?? lineNum,
|
|
81
|
-
sourceFile: fileName,
|
|
82
|
-
command: `/unipi:work plan:${fileName}`,
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
// Reset current task
|
|
86
|
-
currentTaskName = null;
|
|
87
|
-
currentTaskLine = null;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
return {
|
|
92
|
-
type: "plan",
|
|
93
|
-
title: metadata.title ?? fileName.replace(/\.md$/, ""),
|
|
94
|
-
filePath,
|
|
95
|
-
items,
|
|
96
|
-
metadata,
|
|
97
|
-
warnings,
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
private emptyDoc(filePath: string, warnings: string[]): ParsedDoc {
|
|
102
|
-
return {
|
|
103
|
-
type: "plan",
|
|
104
|
-
title: path.basename(filePath).replace(/\.md$/, ""),
|
|
105
|
-
filePath,
|
|
106
|
-
items: [],
|
|
107
|
-
metadata: {},
|
|
108
|
-
warnings,
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
}
|
package/server/index.ts
DELETED
|
@@ -1,266 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @pi-unipi/kanboard — HTTP Server
|
|
3
|
-
*
|
|
4
|
-
* Lightweight HTTP server with port allocation, PID management,
|
|
5
|
-
* static file serving, and route registration.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import * as http from "node:http";
|
|
9
|
-
import * as fs from "node:fs";
|
|
10
|
-
import * as path from "node:path";
|
|
11
|
-
import { KANBOARD_DEFAULTS, KANBOARD_DIRS } from "@pi-unipi/core";
|
|
12
|
-
import type { KanboardConfig } from "../types.js";
|
|
13
|
-
|
|
14
|
-
/** Content-type map for static files */
|
|
15
|
-
const CONTENT_TYPES: Record<string, string> = {
|
|
16
|
-
".html": "text/html; charset=utf-8",
|
|
17
|
-
".css": "text/css; charset=utf-8",
|
|
18
|
-
".js": "application/javascript; charset=utf-8",
|
|
19
|
-
".json": "application/json; charset=utf-8",
|
|
20
|
-
".png": "image/png",
|
|
21
|
-
".jpg": "image/jpeg",
|
|
22
|
-
".svg": "image/svg+xml",
|
|
23
|
-
".ico": "image/x-icon",
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
/** Route handler type */
|
|
27
|
-
type RouteHandler = (
|
|
28
|
-
req: http.IncomingMessage,
|
|
29
|
-
res: http.ServerResponse,
|
|
30
|
-
params: Record<string, string>,
|
|
31
|
-
) => void | Promise<void>;
|
|
32
|
-
|
|
33
|
-
/** Registered route */
|
|
34
|
-
interface Route {
|
|
35
|
-
method: string;
|
|
36
|
-
pattern: RegExp;
|
|
37
|
-
paramNames: string[];
|
|
38
|
-
handler: RouteHandler;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Kanboard server instance */
|
|
42
|
-
export class KanboardServer {
|
|
43
|
-
private server: http.Server | null = null;
|
|
44
|
-
private config: KanboardConfig;
|
|
45
|
-
private routes: Route[] = [];
|
|
46
|
-
private staticDir: string;
|
|
47
|
-
|
|
48
|
-
constructor(config?: Partial<KanboardConfig>) {
|
|
49
|
-
this.config = {
|
|
50
|
-
port: config?.port ?? KANBOARD_DEFAULTS.PORT,
|
|
51
|
-
maxPort: config?.maxPort ?? KANBOARD_DEFAULTS.MAX_PORT,
|
|
52
|
-
docsRoot: config?.docsRoot ?? ".unipi/docs",
|
|
53
|
-
pidFile: config?.pidFile ?? KANBOARD_DIRS.PID_FILE,
|
|
54
|
-
};
|
|
55
|
-
this.staticDir = path.resolve(
|
|
56
|
-
path.dirname(new URL(import.meta.url).pathname),
|
|
57
|
-
"..",
|
|
58
|
-
"ui",
|
|
59
|
-
"static",
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Register a route */
|
|
64
|
-
route(method: string, pattern: string, handler: RouteHandler): void {
|
|
65
|
-
const paramNames: string[] = [];
|
|
66
|
-
// Convert Express-style params (:name) to regex capture groups
|
|
67
|
-
const regexStr = pattern.replace(/:(\w+)/g, (_match, name) => {
|
|
68
|
-
paramNames.push(name);
|
|
69
|
-
return "([^/]+)";
|
|
70
|
-
});
|
|
71
|
-
this.routes.push({
|
|
72
|
-
method: method.toUpperCase(),
|
|
73
|
-
pattern: new RegExp(`^${regexStr}$`),
|
|
74
|
-
paramNames,
|
|
75
|
-
handler,
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** Start the server with port allocation */
|
|
80
|
-
async start(): Promise<{ port: number; url: string }> {
|
|
81
|
-
this.server = http.createServer((req, res) => this.handleRequest(req, res));
|
|
82
|
-
|
|
83
|
-
const port = await this.allocatePort();
|
|
84
|
-
if (port === null) {
|
|
85
|
-
throw new Error(
|
|
86
|
-
`[kanboard] Could not bind to any port ${this.config.port}-${this.config.maxPort}`,
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Write PID file
|
|
91
|
-
this.writePidFile();
|
|
92
|
-
|
|
93
|
-
// Graceful shutdown
|
|
94
|
-
const shutdown = () => {
|
|
95
|
-
// Removed console.log — shutdown is silent.
|
|
96
|
-
this.server?.close(() => {
|
|
97
|
-
this.removePidFile();
|
|
98
|
-
process.exit(0);
|
|
99
|
-
});
|
|
100
|
-
};
|
|
101
|
-
process.on("SIGINT", shutdown);
|
|
102
|
-
process.on("SIGTERM", shutdown);
|
|
103
|
-
|
|
104
|
-
const url = `http://localhost:${port}`;
|
|
105
|
-
// Removed console.log — server URL visible via /unipi:kanboard or info-screen.
|
|
106
|
-
return { port, url };
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** Stop the server */
|
|
110
|
-
stop(): void {
|
|
111
|
-
this.server?.close();
|
|
112
|
-
this.removePidFile();
|
|
113
|
-
this.server = null;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** Get the docs root directory */
|
|
117
|
-
getDocsRoot(): string {
|
|
118
|
-
return this.config.docsRoot;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** Try ports in range, return first available */
|
|
122
|
-
private async allocatePort(): Promise<number | null> {
|
|
123
|
-
for (let port = this.config.port; port <= this.config.maxPort; port++) {
|
|
124
|
-
try {
|
|
125
|
-
await this.listen(port);
|
|
126
|
-
return port;
|
|
127
|
-
} catch (err: unknown) {
|
|
128
|
-
if ((err instanceof Error && (err as NodeJS.ErrnoException).code === "EADDRINUSE")) {
|
|
129
|
-
// Removed console.log — port allocation is silent.
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
throw err;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** Bind server to a port */
|
|
139
|
-
private listen(port: number): Promise<void> {
|
|
140
|
-
return new Promise((resolve, reject) => {
|
|
141
|
-
this.server!.once("error", reject);
|
|
142
|
-
this.server!.listen(port, () => resolve());
|
|
143
|
-
});
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** Handle incoming HTTP request */
|
|
147
|
-
private async handleRequest(
|
|
148
|
-
req: http.IncomingMessage,
|
|
149
|
-
res: http.ServerResponse,
|
|
150
|
-
): Promise<void> {
|
|
151
|
-
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
152
|
-
const pathname = url.pathname;
|
|
153
|
-
|
|
154
|
-
// Static files
|
|
155
|
-
if (pathname.startsWith("/static/")) {
|
|
156
|
-
return this.serveStatic(pathname, res);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Match routes
|
|
160
|
-
for (const route of this.routes) {
|
|
161
|
-
if (req.method !== route.method) continue;
|
|
162
|
-
const match = pathname.match(route.pattern);
|
|
163
|
-
if (match) {
|
|
164
|
-
const params: Record<string, string> = {};
|
|
165
|
-
route.paramNames.forEach((name, i) => {
|
|
166
|
-
params[name] = match[i + 1];
|
|
167
|
-
});
|
|
168
|
-
try {
|
|
169
|
-
await route.handler(req, res, params);
|
|
170
|
-
} catch (_err: unknown) {
|
|
171
|
-
// Silently ignore — route handler error, send generic 500.
|
|
172
|
-
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
173
|
-
res.end("Internal Server Error");
|
|
174
|
-
}
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// 404
|
|
180
|
-
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
181
|
-
res.end("Not Found");
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/** Serve static files from ui/static/ */
|
|
185
|
-
private serveStatic(pathname: string, res: http.ServerResponse): void {
|
|
186
|
-
const filePath = path.join(this.staticDir, pathname.replace("/static/", ""));
|
|
187
|
-
const resolved = path.resolve(filePath);
|
|
188
|
-
|
|
189
|
-
// Prevent directory traversal
|
|
190
|
-
if (!resolved.startsWith(path.resolve(this.staticDir))) {
|
|
191
|
-
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
192
|
-
res.end("Forbidden");
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
if (!fs.existsSync(resolved)) {
|
|
197
|
-
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
198
|
-
res.end("Not Found");
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const ext = path.extname(resolved);
|
|
203
|
-
const contentType = CONTENT_TYPES[ext] ?? "application/octet-stream";
|
|
204
|
-
const content = fs.readFileSync(resolved);
|
|
205
|
-
res.writeHead(200, { "Content-Type": contentType });
|
|
206
|
-
res.end(content);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/** Check if an existing kanboard instance is running */
|
|
210
|
-
checkExistingInstance(): string | null {
|
|
211
|
-
try {
|
|
212
|
-
if (!fs.existsSync(this.config.pidFile)) return null;
|
|
213
|
-
const pid = parseInt(fs.readFileSync(this.config.pidFile, "utf-8").trim(), 10);
|
|
214
|
-
if (isNaN(pid)) return null;
|
|
215
|
-
// Check if process exists
|
|
216
|
-
process.kill(pid, 0);
|
|
217
|
-
// Process exists — return warning URL
|
|
218
|
-
return `http://localhost:${this.config.port} (PID: ${pid})`;
|
|
219
|
-
} catch {
|
|
220
|
-
// Process doesn't exist or can't access PID file
|
|
221
|
-
return null;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
/** Write current PID to file */
|
|
226
|
-
private writePidFile(): void {
|
|
227
|
-
try {
|
|
228
|
-
const dir = path.dirname(this.config.pidFile);
|
|
229
|
-
if (!fs.existsSync(dir)) {
|
|
230
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
231
|
-
}
|
|
232
|
-
fs.writeFileSync(this.config.pidFile, String(process.pid));
|
|
233
|
-
} catch (_err: unknown) {
|
|
234
|
-
// PID write failure — non-critical, kanboard still works.
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/** Remove PID file */
|
|
239
|
-
private removePidFile(): void {
|
|
240
|
-
try {
|
|
241
|
-
if (fs.existsSync(this.config.pidFile)) {
|
|
242
|
-
fs.unlinkSync(this.config.pidFile);
|
|
243
|
-
}
|
|
244
|
-
} catch {
|
|
245
|
-
// Ignore cleanup errors
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/** Create and start a kanboard server with default routes */
|
|
251
|
-
export async function startServer(
|
|
252
|
-
config?: Partial<KanboardConfig>,
|
|
253
|
-
): Promise<{ server: KanboardServer; port: number; url: string }> {
|
|
254
|
-
const server = new KanboardServer(config);
|
|
255
|
-
const docsRoot = server.getDocsRoot();
|
|
256
|
-
|
|
257
|
-
// Register route modules
|
|
258
|
-
const { registerMilestoneRoutes } = await import("./routes/milestone.js");
|
|
259
|
-
const { registerWorkflowRoutes } = await import("./routes/workflow.js");
|
|
260
|
-
|
|
261
|
-
registerMilestoneRoutes(server, docsRoot);
|
|
262
|
-
registerWorkflowRoutes(server, docsRoot);
|
|
263
|
-
|
|
264
|
-
const { port, url } = await server.start();
|
|
265
|
-
return { server, port, url };
|
|
266
|
-
}
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @pi-unipi/kanboard — Milestone Routes
|
|
3
|
-
*
|
|
4
|
-
* Routes for the milestone page and API.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import * as path from "node:path";
|
|
8
|
-
import type { KanboardServer } from "../index.js";
|
|
9
|
-
import { ParserRegistry } from "../../parser/index.js";
|
|
10
|
-
import { MilestoneParser } from "../../parser/milestones.js";
|
|
11
|
-
import { renderMilestonePage } from "../../ui/milestone/page.js";
|
|
12
|
-
|
|
13
|
-
/** Register milestone routes on the server */
|
|
14
|
-
export function registerMilestoneRoutes(
|
|
15
|
-
server: KanboardServer,
|
|
16
|
-
docsRoot: string,
|
|
17
|
-
): void {
|
|
18
|
-
const registry = new ParserRegistry();
|
|
19
|
-
registry.register(new MilestoneParser());
|
|
20
|
-
|
|
21
|
-
const milestonesPath = path.join(docsRoot, "MILESTONES.md");
|
|
22
|
-
|
|
23
|
-
// GET / — Milestone page
|
|
24
|
-
server.route("GET", "/", (_req, res) => {
|
|
25
|
-
const doc = registry.parse(milestonesPath);
|
|
26
|
-
const docs = doc ? [doc] : [];
|
|
27
|
-
const html = renderMilestonePage(docs);
|
|
28
|
-
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
29
|
-
res.end(html);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
// GET /api/milestones — Milestone JSON data
|
|
33
|
-
server.route("GET", "/api/milestones", (_req, res) => {
|
|
34
|
-
const doc = registry.parse(milestonesPath);
|
|
35
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
36
|
-
res.end(JSON.stringify(doc ?? { items: [] }));
|
|
37
|
-
});
|
|
38
|
-
}
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @pi-unipi/kanboard — Workflow Routes
|
|
3
|
-
*
|
|
4
|
-
* Routes for the workflow page and API.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import type { KanboardServer } from "../index.js";
|
|
8
|
-
import { ParserRegistry, createDefaultRegistry } from "../../parser/index.js";
|
|
9
|
-
import { renderWorkflowPage } from "../../ui/workflow/page.js";
|
|
10
|
-
|
|
11
|
-
/** Register workflow routes on the server */
|
|
12
|
-
export function registerWorkflowRoutes(
|
|
13
|
-
server: KanboardServer,
|
|
14
|
-
docsRoot: string,
|
|
15
|
-
): void {
|
|
16
|
-
let registry: ParserRegistry | null = null;
|
|
17
|
-
|
|
18
|
-
const getRegistry = async (): Promise<ParserRegistry> => {
|
|
19
|
-
if (!registry) {
|
|
20
|
-
registry = await createDefaultRegistry();
|
|
21
|
-
}
|
|
22
|
-
return registry;
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
// GET /workflow — Workflow page
|
|
26
|
-
server.route("GET", "/workflow", async (_req, res) => {
|
|
27
|
-
const reg = await getRegistry();
|
|
28
|
-
const docs = reg.parseAll(docsRoot);
|
|
29
|
-
const html = renderWorkflowPage(docs);
|
|
30
|
-
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
31
|
-
res.end(html);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
// GET /api/workflow — Workflow JSON data
|
|
35
|
-
server.route("GET", "/api/workflow", async (_req, res) => {
|
|
36
|
-
const reg = await getRegistry();
|
|
37
|
-
const docs = reg.parseAll(docsRoot);
|
|
38
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
39
|
-
res.end(JSON.stringify({ docs }));
|
|
40
|
-
});
|
|
41
|
-
}
|