kabanos 1.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.
@@ -0,0 +1,109 @@
1
+ import { R as ResolvedKanbanConfig, S as SourceComment, a as StorageAdapter, B as BoardState, A as Actor, C as ColumnConfig } from './core-C0gUmthe.js';
2
+ export { b as AuthConfig, c as Card, d as CardStatus, e as CommentParser, K as KabanosInstance, f as KanbanConfig, N as NodeRequest, g as ScanConfig, h as StorageConfig, T as TagConfig, i as ThemeMode, j as ThemeTokens, k as createKabanos, l as defineConfig, r as resolveConfig } from './core-C0gUmthe.js';
3
+ import { Kysely } from 'kysely';
4
+ import 'node:http';
5
+
6
+ declare class KabanosError extends Error {
7
+ readonly code: string;
8
+ readonly status: number;
9
+ constructor(code: string, message: string, status?: number);
10
+ }
11
+
12
+ declare function extractComments(filePath: string, source: string, tags: string[]): SourceComment[];
13
+ declare function parseComments(config: ResolvedKanbanConfig, filePath: string, source: string): SourceComment[];
14
+ declare class ProjectScanner {
15
+ private cache;
16
+ scan(config: ResolvedKanbanConfig): Promise<SourceComment[]>;
17
+ }
18
+ declare function scanProject(config: ResolvedKanbanConfig): Promise<SourceComment[]>;
19
+
20
+ interface DB {
21
+ schema_migrations: {
22
+ version: number;
23
+ applied_at: string;
24
+ };
25
+ boards: {
26
+ id: string;
27
+ name: string;
28
+ created_at: string;
29
+ };
30
+ columns: {
31
+ id: string;
32
+ board_id: string;
33
+ name: string;
34
+ position: number;
35
+ completion: number;
36
+ color_token: string | null;
37
+ };
38
+ cards: {
39
+ id: string;
40
+ board_id: string;
41
+ column_id: string;
42
+ position: number;
43
+ tag: string;
44
+ title: string;
45
+ body: string;
46
+ notes: string;
47
+ assignee: string | null;
48
+ labels: string;
49
+ priority: string;
50
+ source_file_path: string;
51
+ source_line: number;
52
+ source_content_hash: string;
53
+ source_context_hash: string;
54
+ source_occurrence: number;
55
+ status: string;
56
+ created_at: string;
57
+ updated_at: string;
58
+ };
59
+ resolved_fingerprints: {
60
+ id: string;
61
+ board_id: string;
62
+ card_id: string;
63
+ file_path: string;
64
+ content_hash: string;
65
+ context_hash: string;
66
+ occurrence: number;
67
+ resolved_at: string;
68
+ resolved_by: string | null;
69
+ };
70
+ settings: {
71
+ board_id: string;
72
+ value: string;
73
+ };
74
+ activity_log: {
75
+ id: string;
76
+ card_id: string;
77
+ action: string;
78
+ from_status: string | null;
79
+ to_status: string | null;
80
+ actor: string | null;
81
+ created_at: string;
82
+ };
83
+ }
84
+ declare class SqlStorage implements StorageAdapter {
85
+ private readonly db;
86
+ private readonly config;
87
+ constructor(db: Kysely<DB>, config: ResolvedKanbanConfig);
88
+ migrate(): Promise<void>;
89
+ initialize(config: ResolvedKanbanConfig): Promise<void>;
90
+ getBoard(): Promise<BoardState>;
91
+ reconcile(comments: SourceComment[]): Promise<{
92
+ created: number;
93
+ updated: number;
94
+ archived: number;
95
+ }>;
96
+ moveCard(id: string, columnId: string, position: number, actor?: Actor): Promise<void>;
97
+ updateCard(id: string, patch: {
98
+ notes?: string;
99
+ assignee?: string | null;
100
+ labels?: string[];
101
+ }, actor?: Actor): Promise<void>;
102
+ getActivity(cardId: string): Promise<unknown[]>;
103
+ updateSettings(patch: Record<string, unknown>): Promise<void>;
104
+ replaceColumns(columns: ColumnConfig[], destinationColumnId?: string): Promise<void>;
105
+ close(): Promise<void>;
106
+ }
107
+ declare function createStorage(config: ResolvedKanbanConfig): Promise<SqlStorage>;
108
+
109
+ export { Actor, BoardState, ColumnConfig, KabanosError, ProjectScanner, ResolvedKanbanConfig, SourceComment, SqlStorage, StorageAdapter, createStorage, extractComments, parseComments, scanProject };
package/dist/index.js ADDED
@@ -0,0 +1,487 @@
1
+ // src/config.ts
2
+ import path from "path";
3
+ import { z } from "zod";
4
+ var schema = z.object({
5
+ projectRoot: z.string().optional(),
6
+ mountPath: z.string().regex(/^\/[\w/-]*$/).optional(),
7
+ boardName: z.string().min(1).optional(),
8
+ columns: z.array(z.union([z.string().min(1), z.object({ id: z.string().min(1), name: z.string().min(1), completion: z.boolean().optional(), colorToken: z.string().optional() })])).optional(),
9
+ scan: z.object({ include: z.array(z.string()).optional(), exclude: z.array(z.string()).optional(), tags: z.record(z.string(), z.object({ column: z.string(), priority: z.enum(["low", "normal", "high"]).optional(), label: z.string().optional() })).optional(), watch: z.boolean().optional(), intervalMs: z.number().int().nonnegative().optional(), maxFileSize: z.number().int().positive().optional(), parsers: z.array(z.custom((value) => Boolean(value) && typeof value === "object" && typeof value.parse === "function")).optional() }).optional(),
10
+ storage: z.object({ adapter: z.enum(["sqlite", "postgres", "mysql"]).optional(), connectionString: z.string().optional(), filename: z.string().optional() }).optional(),
11
+ theme: z.object({ default: z.enum(["light", "dark", "system"]).optional(), overrides: z.record(z.string(), z.string()).optional() }).optional(),
12
+ auth: z.object({ enabled: z.boolean().optional(), guard: z.function().optional(), actor: z.function().optional() }).optional()
13
+ });
14
+ function defineConfig(config) {
15
+ return config;
16
+ }
17
+ function resolveConfig(input = {}) {
18
+ const value = schema.parse(input);
19
+ const root = path.resolve(value.projectRoot ?? process.cwd());
20
+ const columns = (value.columns ?? ["Backlog", "In Progress", "Done"]).map((column, index, all) => typeof column === "string" ? { id: column.toLowerCase().replace(/[^a-z0-9]+/g, "-"), name: column, completion: index === all.length - 1 } : column);
21
+ if (columns.filter((column) => column.completion).length !== 1) throw new Error("Kabanos requires exactly one completion column.");
22
+ const enabled = value.auth?.enabled ?? true;
23
+ if (enabled && !value.auth?.guard) throw new Error("Kabanos requires auth.guard, or auth.enabled: false as an explicit public opt-out.");
24
+ return {
25
+ projectRoot: root,
26
+ mountPath: value.mountPath ?? "/admin/kb",
27
+ boardName: value.boardName ?? "Code board",
28
+ columns,
29
+ scan: {
30
+ include: value.scan?.include ?? ["**/*.{ts,tsx,js,jsx,mjs,cjs,py,go,rs,css,scss,html}"],
31
+ exclude: value.scan?.exclude ?? ["node_modules/**", "dist/**", "build/**", ".git/**", ".kanban/**", "**/*.min.*"],
32
+ tags: value.scan?.tags ?? { TODO: { column: "backlog", priority: "normal" }, FIXME: { column: "in-progress", priority: "high" } },
33
+ watch: value.scan?.watch ?? process.env.NODE_ENV === "development",
34
+ intervalMs: value.scan?.intervalMs ?? 0,
35
+ maxFileSize: value.scan?.maxFileSize ?? 1e6,
36
+ parsers: value.scan?.parsers ?? []
37
+ },
38
+ storage: { adapter: value.storage?.adapter ?? "sqlite", connectionString: value.storage?.connectionString ?? "", filename: path.resolve(root, value.storage?.filename ?? ".kanban/board.db") },
39
+ theme: { default: value.theme?.default ?? "system", overrides: value.theme?.overrides ?? {} },
40
+ auth: { enabled, ...value.auth }
41
+ };
42
+ }
43
+
44
+ // src/core.ts
45
+ import { readFile as readFile2 } from "fs/promises";
46
+ import path4 from "path";
47
+ import { fileURLToPath } from "url";
48
+ import chokidar from "chokidar";
49
+ import { z as z2 } from "zod";
50
+
51
+ // src/errors.ts
52
+ var KabanosError = class extends Error {
53
+ constructor(code, message, status = 400) {
54
+ super(message);
55
+ this.code = code;
56
+ this.status = status;
57
+ this.name = "KabanosError";
58
+ }
59
+ code;
60
+ status;
61
+ };
62
+
63
+ // src/scanner.ts
64
+ import { createHash } from "crypto";
65
+ import { readFile, stat } from "fs/promises";
66
+ import path2 from "path";
67
+ import fg from "fast-glob";
68
+ import ignore from "ignore";
69
+ var COMMENT_PATTERNS = {
70
+ js: [/\/\/([^\n]*)/g, /\/\*([\s\S]*?)\*\//g],
71
+ ts: [/\/\/([^\n]*)/g, /\/\*([\s\S]*?)\*\//g],
72
+ jsx: [/\/\/([^\n]*)/g, /\/\*([\s\S]*?)\*\//g],
73
+ tsx: [/\/\/([^\n]*)/g, /\/\*([\s\S]*?)\*\//g],
74
+ py: [/#([^\n]*)/g],
75
+ go: [/\/\/([^\n]*)/g, /\/\*([\s\S]*?)\*\//g],
76
+ rs: [/\/\/([^\n]*)/g, /\/\*([\s\S]*?)\*\//g],
77
+ css: [/\/\*([\s\S]*?)\*\//g],
78
+ scss: [/\/\/([^\n]*)/g, /\/\*([\s\S]*?)\*\//g],
79
+ html: [/<!--([\s\S]*?)-->/g],
80
+ mjs: [/\/\/([^\n]*)/g, /\/\*([\s\S]*?)\*\//g],
81
+ cjs: [/\/\/([^\n]*)/g, /\/\*([\s\S]*?)\*\//g]
82
+ };
83
+ var hash = (value) => createHash("sha256").update(value).digest("hex");
84
+ var normalize = (value) => value.replace(/^\s*[*#/-]+\s?/gm, "").replace(/\s+/g, " ").trim();
85
+ function extractComments(filePath, source, tags) {
86
+ const extension = path2.extname(filePath).slice(1).toLowerCase();
87
+ const patterns = COMMENT_PATTERNS[extension] ?? [];
88
+ const tagPattern = new RegExp(`\\b(${tags.map(escapeRegExp).join("|")})\\b(?:\\s*[:(-]?\\s*)?([\\s\\S]*)`, "i");
89
+ const candidates = [];
90
+ for (const pattern of patterns) {
91
+ pattern.lastIndex = 0;
92
+ for (const match of source.matchAll(pattern)) {
93
+ const raw = match[1] ?? match[0];
94
+ const normalized = normalize(raw);
95
+ const tagged = normalized.match(tagPattern);
96
+ if (!tagged || match.index === void 0) continue;
97
+ const before = source.slice(0, match.index);
98
+ const line = before.split("\n").length;
99
+ const endLine = line + match[0].split("\n").length - 1;
100
+ const tag = tagged[1].toUpperCase();
101
+ const text = (tagged[2] ?? "").trim() || tag;
102
+ const lines = source.split("\n");
103
+ const context = lines.slice(Math.max(0, line - 3), Math.min(lines.length, endLine + 2)).join("\n");
104
+ candidates.push({ filePath, line, endLine, tag, text, rawText: match[0], contentHash: hash(`${tag}:${text}`), contextHash: hash(context) });
105
+ }
106
+ }
107
+ candidates.sort((a, b) => a.line - b.line);
108
+ const occurrences = /* @__PURE__ */ new Map();
109
+ return candidates.map((comment) => {
110
+ const occurrence = occurrences.get(comment.contentHash) ?? 0;
111
+ occurrences.set(comment.contentHash, occurrence + 1);
112
+ return { ...comment, occurrence };
113
+ });
114
+ }
115
+ function parseComments(config, filePath, source) {
116
+ const extension = path2.extname(filePath).slice(1).toLowerCase();
117
+ const parser = config.scan.parsers.find((candidate) => candidate.extensions.map((value) => value.replace(/^\./, "").toLowerCase()).includes(extension));
118
+ return parser ? parser.parse(filePath, source, Object.keys(config.scan.tags)) : extractComments(filePath, source, Object.keys(config.scan.tags));
119
+ }
120
+ var ProjectScanner = class {
121
+ cache = /* @__PURE__ */ new Map();
122
+ async scan(config) {
123
+ const gitignore = ignore();
124
+ try {
125
+ gitignore.add(await readFile(path2.join(config.projectRoot, ".gitignore"), "utf8"));
126
+ } catch {
127
+ }
128
+ gitignore.add(config.scan.exclude);
129
+ const files = await fg(config.scan.include, { cwd: config.projectRoot, onlyFiles: true, followSymbolicLinks: false, dot: true, unique: true });
130
+ const active = /* @__PURE__ */ new Set();
131
+ const comments = [];
132
+ for (const relative of files.sort()) {
133
+ const safeRelative = relative.replaceAll("\\", "/");
134
+ if (gitignore.ignores(safeRelative)) continue;
135
+ const absolute = path2.resolve(config.projectRoot, relative);
136
+ if (!isWithin(config.projectRoot, absolute)) continue;
137
+ const info = await stat(absolute);
138
+ if (!info.isFile() || info.size > config.scan.maxFileSize) continue;
139
+ active.add(safeRelative);
140
+ const cached = this.cache.get(safeRelative);
141
+ if (cached && cached.mtimeMs === info.mtimeMs && cached.size === info.size) {
142
+ comments.push(...cached.comments);
143
+ continue;
144
+ }
145
+ const parsed = parseComments(config, safeRelative, await readFile(absolute, "utf8"));
146
+ this.cache.set(safeRelative, { mtimeMs: info.mtimeMs, size: info.size, comments: parsed });
147
+ comments.push(...parsed);
148
+ }
149
+ for (const key of this.cache.keys()) if (!active.has(key)) this.cache.delete(key);
150
+ return comments;
151
+ }
152
+ };
153
+ async function scanProject(config) {
154
+ return new ProjectScanner().scan(config);
155
+ }
156
+ function isWithin(root, candidate) {
157
+ const relative = path2.relative(path2.resolve(root), path2.resolve(candidate));
158
+ return relative !== ".." && !relative.startsWith(`..${path2.sep}`) && !path2.isAbsolute(relative);
159
+ }
160
+ function escapeRegExp(value) {
161
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
162
+ }
163
+
164
+ // src/storage.ts
165
+ import { randomUUID } from "crypto";
166
+ import { mkdir } from "fs/promises";
167
+ import path3 from "path";
168
+ import Database from "better-sqlite3";
169
+ import { Kysely, MysqlDialect, PostgresDialect, SqliteDialect, sql } from "kysely";
170
+ var SqlStorage = class {
171
+ constructor(db, config) {
172
+ this.db = db;
173
+ this.config = config;
174
+ }
175
+ db;
176
+ config;
177
+ async migrate() {
178
+ await this.db.schema.createTable("schema_migrations").ifNotExists().addColumn("version", "integer", (c) => c.primaryKey()).addColumn("applied_at", "varchar(32)", (c) => c.notNull()).execute();
179
+ const applied = new Set((await this.db.selectFrom("schema_migrations").select("version").execute()).map((r) => r.version));
180
+ const migrations = [
181
+ { version: 1, up: async (db) => {
182
+ await db.schema.createTable("boards").ifNotExists().addColumn("id", "varchar(64)", (c) => c.primaryKey()).addColumn("name", "varchar(255)", (c) => c.notNull()).addColumn("created_at", "varchar(32)", (c) => c.notNull()).execute();
183
+ await db.schema.createTable("columns").ifNotExists().addColumn("id", "varchar(64)", (c) => c.primaryKey()).addColumn("board_id", "varchar(64)", (c) => c.notNull()).addColumn("name", "varchar(255)", (c) => c.notNull()).addColumn("position", "integer", (c) => c.notNull()).addColumn("completion", "integer", (c) => c.notNull()).addColumn("color_token", "varchar(64)").execute();
184
+ await db.schema.createTable("cards").ifNotExists().addColumn("id", "varchar(64)", (c) => c.primaryKey()).addColumn("board_id", "varchar(64)", (c) => c.notNull()).addColumn("column_id", "varchar(64)", (c) => c.notNull()).addColumn("position", "integer", (c) => c.notNull()).addColumn("tag", "varchar(64)", (c) => c.notNull()).addColumn("title", "text", (c) => c.notNull()).addColumn("body", "text", (c) => c.notNull()).addColumn("notes", "text", (c) => c.notNull()).addColumn("assignee", "varchar(255)").addColumn("labels", "text", (c) => c.notNull()).addColumn("priority", "varchar(32)", (c) => c.notNull()).addColumn("source_file_path", "text", (c) => c.notNull()).addColumn("source_line", "integer", (c) => c.notNull()).addColumn("source_content_hash", "varchar(64)", (c) => c.notNull()).addColumn("source_context_hash", "varchar(64)", (c) => c.notNull()).addColumn("source_occurrence", "integer", (c) => c.notNull()).addColumn("status", "varchar(32)", (c) => c.notNull()).addColumn("created_at", "varchar(32)", (c) => c.notNull()).addColumn("updated_at", "varchar(32)", (c) => c.notNull()).execute();
185
+ await db.schema.createTable("resolved_fingerprints").ifNotExists().addColumn("id", "varchar(64)", (c) => c.primaryKey()).addColumn("board_id", "varchar(64)", (c) => c.notNull()).addColumn("card_id", "varchar(64)", (c) => c.notNull()).addColumn("file_path", "text", (c) => c.notNull()).addColumn("content_hash", "varchar(64)", (c) => c.notNull()).addColumn("context_hash", "varchar(64)", (c) => c.notNull()).addColumn("occurrence", "integer", (c) => c.notNull()).addColumn("resolved_at", "varchar(32)", (c) => c.notNull()).addColumn("resolved_by", "varchar(255)").execute();
186
+ await db.schema.createTable("settings").ifNotExists().addColumn("board_id", "varchar(64)", (c) => c.primaryKey()).addColumn("value", "text", (c) => c.notNull()).execute();
187
+ await db.schema.createTable("activity_log").ifNotExists().addColumn("id", "varchar(64)", (c) => c.primaryKey()).addColumn("card_id", "varchar(64)", (c) => c.notNull()).addColumn("action", "varchar(64)", (c) => c.notNull()).addColumn("from_status", "varchar(32)").addColumn("to_status", "varchar(32)").addColumn("actor", "varchar(255)").addColumn("created_at", "varchar(32)", (c) => c.notNull()).execute();
188
+ } }
189
+ ];
190
+ for (const migration of migrations) {
191
+ if (applied.has(migration.version)) continue;
192
+ await migration.up(this.db);
193
+ await this.db.insertInto("schema_migrations").values({ version: migration.version, applied_at: (/* @__PURE__ */ new Date()).toISOString() }).execute();
194
+ }
195
+ }
196
+ async initialize(config) {
197
+ await this.migrate();
198
+ const board = await this.db.selectFrom("boards").selectAll().where("id", "=", "default").executeTakeFirst();
199
+ if (board) return;
200
+ const now = (/* @__PURE__ */ new Date()).toISOString();
201
+ await this.db.transaction().execute(async (trx) => {
202
+ await trx.insertInto("boards").values({ id: "default", name: config.boardName, created_at: now }).execute();
203
+ await trx.insertInto("columns").values(config.columns.map((column, position) => ({ id: column.id, board_id: "default", name: column.name, position, completion: column.completion ? 1 : 0, color_token: column.colorToken ?? null }))).execute();
204
+ await trx.insertInto("settings").values({ board_id: "default", value: JSON.stringify({ theme: config.theme.default, tokenOverrides: config.theme.overrides, scan: { include: config.scan.include, exclude: config.scan.exclude } }) }).execute();
205
+ });
206
+ }
207
+ async getBoard() {
208
+ const [board, columns, cards, settings] = await Promise.all([
209
+ this.db.selectFrom("boards").selectAll().where("id", "=", "default").executeTakeFirstOrThrow(),
210
+ this.db.selectFrom("columns").selectAll().where("board_id", "=", "default").orderBy("position").execute(),
211
+ this.db.selectFrom("cards").selectAll().where("board_id", "=", "default").where("status", "!=", "archived").orderBy("position").execute(),
212
+ this.db.selectFrom("settings").selectAll().where("board_id", "=", "default").executeTakeFirst()
213
+ ]);
214
+ return { id: board.id, name: board.name, columns: columns.map((c) => ({ id: c.id, name: c.name, position: c.position, completion: Boolean(c.completion), ...c.color_token ? { colorToken: c.color_token } : {} })), cards: cards.map(toCard), settings: JSON.parse(settings?.value ?? "{}") };
215
+ }
216
+ async reconcile(comments) {
217
+ return this.db.transaction().execute(async (trx) => {
218
+ const [cards, resolved, columns] = await Promise.all([
219
+ trx.selectFrom("cards").selectAll().where("board_id", "=", "default").execute(),
220
+ trx.selectFrom("resolved_fingerprints").selectAll().where("board_id", "=", "default").execute(),
221
+ trx.selectFrom("columns").selectAll().where("board_id", "=", "default").execute()
222
+ ]);
223
+ const seen = /* @__PURE__ */ new Set();
224
+ const createdPerColumn = /* @__PURE__ */ new Map();
225
+ let updated = 0, archived = 0;
226
+ for (const comment of comments) {
227
+ const exact = cards.find((c) => c.source_file_path === comment.filePath && c.source_content_hash === comment.contentHash && c.source_occurrence === comment.occurrence && c.status !== "archived");
228
+ if (exact) {
229
+ seen.add(exact.id);
230
+ if (exact.source_line !== comment.line || exact.source_context_hash !== comment.contextHash) {
231
+ await trx.updateTable("cards").set({ source_line: comment.line, source_context_hash: comment.contextHash, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).where("id", "=", exact.id).execute();
232
+ updated++;
233
+ }
234
+ continue;
235
+ }
236
+ const suppressed = resolved.some((r) => r.file_path === comment.filePath && r.content_hash === comment.contentHash && r.occurrence === comment.occurrence);
237
+ if (suppressed) continue;
238
+ const changed = cards.filter((c) => c.source_file_path === comment.filePath && !seen.has(c.id) && c.status !== "resolved" && c.status !== "archived").sort((a, b) => Number(b.source_context_hash === comment.contextHash) - Number(a.source_context_hash === comment.contextHash) || Math.abs(a.source_line - comment.line) - Math.abs(b.source_line - comment.line))[0];
239
+ if (changed) {
240
+ seen.add(changed.id);
241
+ await trx.updateTable("cards").set({ tag: comment.tag, title: comment.text, body: comment.rawText, source_line: comment.line, source_content_hash: comment.contentHash, source_context_hash: comment.contextHash, status: "changed", updated_at: (/* @__PURE__ */ new Date()).toISOString() }).where("id", "=", changed.id).execute();
242
+ updated++;
243
+ continue;
244
+ }
245
+ const tag = this.config.scan.tags[comment.tag];
246
+ const column = columns.find((c) => c.id === tag?.column) ?? columns[0];
247
+ if (!column) throw new Error("Board has no columns.");
248
+ const colCreated = createdPerColumn.get(column.id) ?? 0;
249
+ const id = randomUUID(), now = (/* @__PURE__ */ new Date()).toISOString();
250
+ await trx.insertInto("cards").values({ id, board_id: "default", column_id: column.id, position: cards.filter((c) => c.column_id === column.id).length + colCreated, tag: comment.tag, title: comment.text, body: comment.rawText, notes: "", assignee: null, labels: JSON.stringify(tag?.label ? [tag.label] : []), priority: tag?.priority ?? "normal", source_file_path: comment.filePath, source_line: comment.line, source_content_hash: comment.contentHash, source_context_hash: comment.contextHash, source_occurrence: comment.occurrence, status: "open", created_at: now, updated_at: now }).execute();
251
+ await log(trx, id, "created", null, "open", void 0);
252
+ seen.add(id);
253
+ createdPerColumn.set(column.id, colCreated + 1);
254
+ }
255
+ for (const card of cards.filter((c) => c.status !== "archived" && !seen.has(c.id))) {
256
+ await trx.updateTable("cards").set({ status: "archived", updated_at: (/* @__PURE__ */ new Date()).toISOString() }).where("id", "=", card.id).execute();
257
+ await log(trx, card.id, "archived", card.status, "archived", void 0);
258
+ archived++;
259
+ }
260
+ const created = [...createdPerColumn.values()].reduce((a, b) => a + b, 0);
261
+ return { created, updated, archived };
262
+ });
263
+ }
264
+ async moveCard(id, columnId, position, actor) {
265
+ await this.db.transaction().execute(async (trx) => {
266
+ const [card, column] = await Promise.all([trx.selectFrom("cards").selectAll().where("id", "=", id).executeTakeFirst(), trx.selectFrom("columns").selectAll().where("id", "=", columnId).executeTakeFirst()]);
267
+ if (!card) throw new KabanosError("CARD_NOT_FOUND", "Card not found.", 404);
268
+ if (!column) throw new KabanosError("COLUMN_NOT_FOUND", "Column not found.", 404);
269
+ const status = column.completion ? "resolved" : "open", now = (/* @__PURE__ */ new Date()).toISOString();
270
+ const target = await trx.selectFrom("cards").selectAll().where("column_id", "=", columnId).where("status", "!=", "archived").orderBy("position").execute();
271
+ const ordered = target.filter((item) => item.id !== id);
272
+ ordered.splice(Math.min(position, ordered.length), 0, { ...card, column_id: columnId });
273
+ for (const [index, item] of ordered.entries()) await trx.updateTable("cards").set({ column_id: columnId, position: index, ...item.id === id ? { status, updated_at: now } : {} }).where("id", "=", item.id).execute();
274
+ if (card.column_id !== columnId) {
275
+ const source = await trx.selectFrom("cards").selectAll().where("column_id", "=", card.column_id).where("id", "!=", id).where("status", "!=", "archived").orderBy("position").execute();
276
+ for (const [index, item] of source.entries()) await trx.updateTable("cards").set({ position: index }).where("id", "=", item.id).execute();
277
+ }
278
+ await trx.deleteFrom("resolved_fingerprints").where("card_id", "=", id).execute();
279
+ if (status === "resolved") await trx.insertInto("resolved_fingerprints").values({ id: randomUUID(), board_id: "default", card_id: id, file_path: card.source_file_path, content_hash: card.source_content_hash, context_hash: card.source_context_hash, occurrence: card.source_occurrence, resolved_at: now, resolved_by: actor?.id ?? null }).execute();
280
+ await log(trx, id, status === "resolved" ? "resolved" : "moved", card.status, status, actor);
281
+ });
282
+ }
283
+ async updateCard(id, patch, actor) {
284
+ const values = { updated_at: (/* @__PURE__ */ new Date()).toISOString() };
285
+ if (patch.notes !== void 0) values.notes = patch.notes;
286
+ if (patch.assignee !== void 0) values.assignee = patch.assignee;
287
+ if (patch.labels !== void 0) values.labels = JSON.stringify(patch.labels);
288
+ const result = await this.db.updateTable("cards").set(values).where("id", "=", id).executeTakeFirst();
289
+ if (Number(result.numUpdatedRows) === 0) throw new KabanosError("CARD_NOT_FOUND", "Card not found.", 404);
290
+ await log(this.db, id, "updated", null, null, actor);
291
+ }
292
+ async getActivity(cardId) {
293
+ return this.db.selectFrom("activity_log").selectAll().where("card_id", "=", cardId).orderBy("created_at", "desc").execute();
294
+ }
295
+ async updateSettings(patch) {
296
+ const current = await this.db.selectFrom("settings").select("value").where("board_id", "=", "default").executeTakeFirst();
297
+ await this.db.updateTable("settings").set({ value: JSON.stringify({ ...JSON.parse(current?.value ?? "{}"), ...patch }) }).where("board_id", "=", "default").execute();
298
+ }
299
+ async replaceColumns(columns, destinationColumnId) {
300
+ if (columns.length === 0 || columns.filter((column) => column.completion).length !== 1) throw new KabanosError("INVALID_COLUMNS", "Exactly one completion column is required.");
301
+ if (new Set(columns.map((column) => column.id)).size !== columns.length) throw new KabanosError("INVALID_COLUMNS", "Column IDs must be unique.");
302
+ await this.db.transaction().execute(async (trx) => {
303
+ const existing = await trx.selectFrom("columns").selectAll().where("board_id", "=", "default").execute();
304
+ const removed = existing.filter((column) => !columns.some((next) => next.id === column.id));
305
+ if (removed.length) {
306
+ const count = await trx.selectFrom("cards").select(sql`count(*)`.as("count")).where("column_id", "in", removed.map((column) => column.id)).where("status", "!=", "archived").executeTakeFirst();
307
+ if (Number(count?.count ?? 0) > 0) {
308
+ if (!destinationColumnId || !columns.some((column) => column.id === destinationColumnId)) throw new KabanosError("COLUMN_DESTINATION_REQUIRED", "A valid destination column is required when deleting a populated column.", 409);
309
+ await trx.updateTable("cards").set({ column_id: destinationColumnId, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).where("column_id", "in", removed.map((column) => column.id)).execute();
310
+ }
311
+ }
312
+ await trx.deleteFrom("columns").where("board_id", "=", "default").execute();
313
+ await trx.insertInto("columns").values(columns.map((column, position) => ({ id: column.id, board_id: "default", name: column.name, position, completion: column.completion ? 1 : 0, color_token: column.colorToken ?? null }))).execute();
314
+ });
315
+ }
316
+ async close() {
317
+ await this.db.destroy();
318
+ }
319
+ };
320
+ async function createStorage(config) {
321
+ let dialect;
322
+ if (config.storage.adapter === "sqlite") {
323
+ await mkdir(path3.dirname(config.storage.filename), { recursive: true });
324
+ dialect = new SqliteDialect({ database: new Database(config.storage.filename) });
325
+ } else if (config.storage.adapter === "postgres") {
326
+ const { Pool } = await import("pg");
327
+ dialect = new PostgresDialect({ pool: new Pool({ connectionString: config.storage.connectionString }) });
328
+ } else {
329
+ const mysql = await import("mysql2");
330
+ dialect = new MysqlDialect({ pool: mysql.createPool(config.storage.connectionString) });
331
+ }
332
+ return new SqlStorage(new Kysely({ dialect }), config);
333
+ }
334
+ function toCard(row) {
335
+ return { id: row.id, columnId: row.column_id, position: row.position, tag: row.tag, title: row.title, body: row.body, notes: row.notes, ...row.assignee ? { assignee: row.assignee } : {}, labels: JSON.parse(row.labels), priority: row.priority, sourceFilePath: row.source_file_path, sourceLine: row.source_line, sourceContentHash: row.source_content_hash, sourceContextHash: row.source_context_hash, sourceOccurrence: row.source_occurrence, status: row.status, createdAt: row.created_at, updatedAt: row.updated_at };
336
+ }
337
+ async function log(db, cardId, action, from, to, actor) {
338
+ await db.insertInto("activity_log").values({ id: randomUUID(), card_id: cardId, action, from_status: from, to_status: to, actor: actor?.id ?? null, created_at: (/* @__PURE__ */ new Date()).toISOString() }).execute();
339
+ }
340
+
341
+ // src/core.ts
342
+ async function createKabanos(input) {
343
+ const config = resolveConfig(input);
344
+ const storage = await createStorage(config);
345
+ await storage.initialize(config);
346
+ let watcher;
347
+ let timer;
348
+ let scanning;
349
+ const scanner = new ProjectScanner();
350
+ const scan = () => scanning ??= storage.getBoard().then((board) => {
351
+ const overrides = board.settings.scan ?? {};
352
+ return scanner.scan({ ...config, scan: { ...config.scan, ...overrides } });
353
+ }).then((comments) => storage.reconcile(comments)).finally(() => {
354
+ scanning = void 0;
355
+ });
356
+ await scan();
357
+ if (config.scan.watch) {
358
+ watcher = chokidar.watch(config.scan.include, { cwd: config.projectRoot, ignored: config.scan.exclude, ignoreInitial: true }).on("all", () => {
359
+ void scan();
360
+ });
361
+ }
362
+ if (config.scan.intervalMs > 0) timer = setInterval(() => {
363
+ void scan();
364
+ }, config.scan.intervalMs).unref();
365
+ return {
366
+ config,
367
+ storage,
368
+ scan,
369
+ handler: (request, nativeRequest = request) => handleRequest(request, nativeRequest, config, storage, scan),
370
+ async close() {
371
+ if (timer) clearInterval(timer);
372
+ await watcher?.close();
373
+ await storage.close();
374
+ }
375
+ };
376
+ }
377
+ async function handleRequest(request, nativeRequest, config, storage, scan) {
378
+ if (config.auth.enabled && !await config.auth.guard?.(nativeRequest)) return json({ error: { code: "UNAUTHORIZED", message: "Access denied." } }, 401);
379
+ const url = new URL(request.url);
380
+ const mount = config.mountPath.replace(/\/$/, "");
381
+ if (url.pathname === mount) return Response.redirect(`${url.origin}${mount}/`, 308);
382
+ if (!url.pathname.startsWith(`${mount}/`)) return json({ error: { code: "NOT_FOUND", message: "Route not found." } }, 404);
383
+ const route = url.pathname.slice(mount.length);
384
+ try {
385
+ if (route === "/api/v1/board" && request.method === "GET") return json(await storage.getBoard());
386
+ if (route === "/api/v1/scan" && request.method === "POST") {
387
+ assertMutation(request);
388
+ return json(await scan());
389
+ }
390
+ const move = route.match(/^\/api\/v1\/cards\/([^/]+)\/move$/);
391
+ if (move && request.method === "PATCH") {
392
+ assertMutation(request);
393
+ const body = moveSchema.parse(await request.json());
394
+ await storage.moveCard(move[1], body.columnId, body.position, await config.auth.actor?.(nativeRequest));
395
+ return json({ ok: true });
396
+ }
397
+ const card = route.match(/^\/api\/v1\/cards\/([^/]+)$/);
398
+ if (card && request.method === "PATCH") {
399
+ assertMutation(request);
400
+ const body = cardSchema.parse(await request.json());
401
+ await storage.updateCard(card[1], body, await config.auth.actor?.(nativeRequest));
402
+ return json({ ok: true });
403
+ }
404
+ const activity = route.match(/^\/api\/v1\/cards\/([^/]+)\/activity$/);
405
+ if (activity && request.method === "GET") return json(await storage.getActivity(activity[1]));
406
+ const context = route.match(/^\/api\/v1\/cards\/([^/]+)\/context$/);
407
+ if (context && request.method === "GET") {
408
+ const board = await storage.getBoard();
409
+ const selected = board.cards.find((c) => c.id === context[1]);
410
+ if (!selected) return json({ error: { code: "NOT_FOUND", message: "Card not found." } }, 404);
411
+ const absolute = path4.resolve(config.projectRoot, selected.sourceFilePath);
412
+ if (!isWithin(config.projectRoot, absolute)) throw new Error("Unsafe source path.");
413
+ const lines = (await readFile2(absolute, "utf8")).split("\n");
414
+ return json({ filePath: selected.sourceFilePath, line: selected.sourceLine, lines: lines.slice(Math.max(0, selected.sourceLine - 4), selected.sourceLine + 3), startLine: Math.max(1, selected.sourceLine - 3) });
415
+ }
416
+ if (route === "/api/v1/settings" && request.method === "PATCH") {
417
+ assertMutation(request);
418
+ const body = settingsSchema.parse(await request.json());
419
+ await storage.updateSettings(body);
420
+ return json({ ok: true });
421
+ }
422
+ if (route === "/api/v1/settings/columns" && request.method === "PUT") {
423
+ assertMutation(request);
424
+ const body = columnsSchema.parse(await request.json());
425
+ await storage.replaceColumns(body.columns, body.destinationColumnId);
426
+ return json({ ok: true });
427
+ }
428
+ if (request.method === "GET") return serveUi(route);
429
+ return json({ error: { code: "NOT_FOUND", message: "Route not found." } }, 404);
430
+ } catch (error) {
431
+ if (error instanceof z2.ZodError) return json({ error: { code: "INVALID_REQUEST", message: "Request validation failed.", issues: error.issues } }, 400);
432
+ if (error instanceof KabanosError) return json({ error: { code: error.code, message: error.message } }, error.status);
433
+ console.error("[kabanos]", error);
434
+ return json({ error: { code: "INTERNAL_ERROR", message: "Kabanos could not complete the request." } }, 500);
435
+ }
436
+ }
437
+ var moveSchema = z2.object({ columnId: z2.string().min(1), position: z2.number().int().nonnegative() });
438
+ var cardSchema = z2.object({ notes: z2.string().max(2e4).optional(), assignee: z2.string().max(255).nullable().optional(), labels: z2.array(z2.string().max(64)).max(20).optional() });
439
+ var settingsSchema = z2.object({ theme: z2.enum(["light", "dark", "system"]).optional(), tokenOverrides: z2.record(z2.string(), z2.string()).optional(), scan: z2.object({ include: z2.array(z2.string()).optional(), exclude: z2.array(z2.string()).optional() }).optional() }).strict();
440
+ var columnsSchema = z2.object({ columns: z2.array(z2.object({ id: z2.string().regex(/^[a-z0-9-]+$/), name: z2.string().min(1).max(80), completion: z2.boolean().optional(), colorToken: z2.string().max(64).optional() })).min(1), destinationColumnId: z2.string().optional() }).refine((value) => value.columns.filter((column) => column.completion).length === 1, { message: "Exactly one completion column is required." });
441
+ function assertMutation(request) {
442
+ const content = request.headers.get("content-type") ?? "";
443
+ if (!content.toLowerCase().startsWith("application/json")) throw new z2.ZodError([{ code: "custom", path: ["content-type"], message: "Mutations require application/json." }]);
444
+ const origin = request.headers.get("origin");
445
+ if (origin && origin !== new URL(request.url).origin) throw new z2.ZodError([{ code: "custom", path: ["origin"], message: "Cross-origin mutation rejected." }]);
446
+ }
447
+ function json(value, status = 200) {
448
+ return Response.json(value, { status, headers: { "cache-control": "no-store", "x-content-type-options": "nosniff" } });
449
+ }
450
+ async function serveUi(route) {
451
+ const moduleRoot = path4.dirname(fileURLToPath(import.meta.url));
452
+ const uiRoot = moduleRoot.endsWith(`${path4.sep}src`) ? path4.resolve(moduleRoot, "../dist/ui") : path4.resolve(moduleRoot, "ui");
453
+ const requested = route === "/" ? "index.html" : route.replace(/^\//, "");
454
+ let target = path4.resolve(uiRoot, requested);
455
+ if (!isWithin(uiRoot, target)) return new Response("Not found", { status: 404 });
456
+ try {
457
+ const body = await readFile2(target);
458
+ return new Response(body, { headers: { "content-type": contentType(target), "cache-control": target.endsWith("index.html") ? "no-cache" : "public, max-age=31536000, immutable", "x-content-type-options": "nosniff" } });
459
+ } catch {
460
+ target = path4.join(uiRoot, "index.html");
461
+ try {
462
+ return new Response(await readFile2(target), { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-cache" } });
463
+ } catch {
464
+ return new Response("Kabanos UI has not been built.", { status: 503 });
465
+ }
466
+ }
467
+ }
468
+ function contentType(file) {
469
+ if (file.endsWith(".html")) return "text/html; charset=utf-8";
470
+ if (file.endsWith(".js")) return "text/javascript; charset=utf-8";
471
+ if (file.endsWith(".css")) return "text/css; charset=utf-8";
472
+ if (file.endsWith(".svg")) return "image/svg+xml";
473
+ return "application/octet-stream";
474
+ }
475
+ export {
476
+ KabanosError,
477
+ ProjectScanner,
478
+ SqlStorage,
479
+ createKabanos,
480
+ createStorage,
481
+ defineConfig,
482
+ extractComments,
483
+ parseComments,
484
+ resolveConfig,
485
+ scanProject
486
+ };
487
+ //# sourceMappingURL=index.js.map