kanban-lite 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/.editorconfig +9 -0
  2. package/.github/workflows/ci.yml +59 -0
  3. package/.github/workflows/release.yml +75 -0
  4. package/.prettierignore +6 -0
  5. package/.prettierrc.yaml +4 -0
  6. package/.vscode/extensions.json +3 -0
  7. package/.vscode/launch.json +17 -0
  8. package/.vscode/settings.json +21 -0
  9. package/.vscode/tasks.json +22 -0
  10. package/.vscodeignore +11 -0
  11. package/CHANGELOG.md +184 -0
  12. package/CLAUDE.md +58 -0
  13. package/CONTRIBUTING.md +114 -0
  14. package/LICENSE +22 -0
  15. package/README.md +482 -0
  16. package/SKILL.md +237 -0
  17. package/dist/cli.js +8716 -0
  18. package/dist/extension.js +8463 -0
  19. package/dist/mcp-server.js +1327 -0
  20. package/dist/standalone-webview/icons-Dx9MGYqN.js +180 -0
  21. package/dist/standalone-webview/icons-Dx9MGYqN.js.map +1 -0
  22. package/dist/standalone-webview/index.js +85 -0
  23. package/dist/standalone-webview/index.js.map +1 -0
  24. package/dist/standalone-webview/react-vendor-DkYdDBET.js +25 -0
  25. package/dist/standalone-webview/react-vendor-DkYdDBET.js.map +1 -0
  26. package/dist/standalone-webview/style.css +1 -0
  27. package/dist/standalone.js +7513 -0
  28. package/dist/webview/icons-Dx9MGYqN.js +180 -0
  29. package/dist/webview/icons-Dx9MGYqN.js.map +1 -0
  30. package/dist/webview/index.js +85 -0
  31. package/dist/webview/index.js.map +1 -0
  32. package/dist/webview/react-vendor-DkYdDBET.js +25 -0
  33. package/dist/webview/react-vendor-DkYdDBET.js.map +1 -0
  34. package/dist/webview/style.css +1 -0
  35. package/docs/images/board-overview.png +0 -0
  36. package/docs/images/editor-view.png +0 -0
  37. package/docs/plans/2026-02-20-kanban-json-config-design.md +74 -0
  38. package/docs/plans/2026-02-20-kanban-json-config.md +690 -0
  39. package/eslint.config.mjs +31 -0
  40. package/package.json +161 -0
  41. package/postcss.config.js +6 -0
  42. package/resources/icon-light.png +0 -0
  43. package/resources/icon-light.svg +105 -0
  44. package/resources/icon.png +0 -0
  45. package/resources/icon.svg +105 -0
  46. package/resources/kanban-dark.svg +21 -0
  47. package/resources/kanban-light.svg +21 -0
  48. package/resources/kanban.svg +21 -0
  49. package/src/cli/index.ts +846 -0
  50. package/src/extension/FeatureHeaderProvider.ts +370 -0
  51. package/src/extension/KanbanPanel.ts +973 -0
  52. package/src/extension/SidebarViewProvider.ts +507 -0
  53. package/src/extension/featureFileUtils.ts +82 -0
  54. package/src/extension/index.ts +234 -0
  55. package/src/mcp-server/index.ts +632 -0
  56. package/src/sdk/KanbanSDK.ts +349 -0
  57. package/src/sdk/__tests__/KanbanSDK.test.ts +468 -0
  58. package/src/sdk/__tests__/parser.test.ts +170 -0
  59. package/src/sdk/fileUtils.ts +76 -0
  60. package/src/sdk/index.ts +6 -0
  61. package/src/sdk/parser.ts +70 -0
  62. package/src/sdk/types.ts +15 -0
  63. package/src/shared/config.ts +113 -0
  64. package/src/shared/editorTypes.ts +14 -0
  65. package/src/shared/types.ts +120 -0
  66. package/src/standalone/__tests__/server.integration.test.ts +1916 -0
  67. package/src/standalone/__tests__/webhooks.test.ts +357 -0
  68. package/src/standalone/fileUtils.ts +70 -0
  69. package/src/standalone/index.ts +71 -0
  70. package/src/standalone/server.ts +1046 -0
  71. package/src/standalone/webhooks.ts +135 -0
  72. package/src/webview/App.tsx +469 -0
  73. package/src/webview/assets/main.css +329 -0
  74. package/src/webview/assets/standalone-theme.css +130 -0
  75. package/src/webview/components/ColumnDialog.tsx +119 -0
  76. package/src/webview/components/CreateFeatureDialog.tsx +524 -0
  77. package/src/webview/components/DatePicker.tsx +185 -0
  78. package/src/webview/components/FeatureCard.tsx +186 -0
  79. package/src/webview/components/FeatureEditor.tsx +623 -0
  80. package/src/webview/components/KanbanBoard.tsx +144 -0
  81. package/src/webview/components/KanbanColumn.tsx +159 -0
  82. package/src/webview/components/MarkdownEditor.tsx +291 -0
  83. package/src/webview/components/PrioritySelect.tsx +39 -0
  84. package/src/webview/components/QuickAddInput.tsx +72 -0
  85. package/src/webview/components/SettingsPanel.tsx +284 -0
  86. package/src/webview/components/Toolbar.tsx +175 -0
  87. package/src/webview/components/UndoToast.tsx +70 -0
  88. package/src/webview/index.html +12 -0
  89. package/src/webview/lib/utils.ts +6 -0
  90. package/src/webview/main.tsx +11 -0
  91. package/src/webview/standalone-main.tsx +13 -0
  92. package/src/webview/standalone-shim.ts +132 -0
  93. package/src/webview/standalone.html +12 -0
  94. package/src/webview/store/index.ts +241 -0
  95. package/tailwind.config.js +53 -0
  96. package/tsconfig.json +22 -0
  97. package/vite.config.ts +36 -0
  98. package/vite.standalone.config.ts +62 -0
  99. package/vitest.config.ts +15 -0
@@ -0,0 +1,1046 @@
1
+ import * as http from 'http'
2
+ import * as fs from 'fs'
3
+ import * as path from 'path'
4
+ import { WebSocketServer, WebSocket } from 'ws'
5
+ import chokidar from 'chokidar'
6
+ import { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing'
7
+ import { getTitleFromContent, generateFeatureFilename, extractNumericId } from '../shared/types'
8
+ import type { Feature, FeatureStatus, Priority, KanbanColumn, FeatureFrontmatter, CardDisplaySettings } from '../shared/types'
9
+ import { readConfig, writeConfig, configToSettings, settingsToConfig, allocateCardId, syncCardIdCounter } from '../shared/config'
10
+ import type { KanbanConfig } from '../shared/config'
11
+ import { parseFeatureFile, serializeFeature } from '../sdk/parser'
12
+ import { ensureStatusSubfolders, moveFeatureFile, renameFeatureFile, getFeatureFilePath, getStatusFromPath } from './fileUtils'
13
+ import { fireWebhooks, loadWebhooks, createWebhook, deleteWebhook } from './webhooks'
14
+
15
+ interface CreateFeatureData {
16
+ status: FeatureStatus
17
+ priority: Priority
18
+ content: string
19
+ assignee: string | null
20
+ dueDate: string | null
21
+ labels: string[]
22
+ }
23
+
24
+ const MIME_TYPES: Record<string, string> = {
25
+ '.html': 'text/html',
26
+ '.js': 'text/javascript',
27
+ '.css': 'text/css',
28
+ '.json': 'application/json',
29
+ '.png': 'image/png',
30
+ '.svg': 'image/svg+xml',
31
+ '.ico': 'image/x-icon',
32
+ '.map': 'application/json'
33
+ }
34
+
35
+
36
+ export function startServer(featuresDir: string, port: number, webviewDir?: string): http.Server {
37
+ const absoluteFeaturesDir = path.resolve(featuresDir)
38
+ let features: Feature[] = []
39
+ let migrating = false
40
+ let currentEditingFeatureId: string | null = null
41
+ let lastWrittenContent = ''
42
+
43
+ // Resolve webview static files directory
44
+ const resolvedWebviewDir = webviewDir || path.join(__dirname, 'standalone-webview')
45
+
46
+ // Derive workspace root from features directory
47
+ const workspaceRoot = path.dirname(absoluteFeaturesDir)
48
+
49
+ function getConfig(): KanbanConfig {
50
+ return readConfig(workspaceRoot)
51
+ }
52
+
53
+ function saveConfigFile(config: KanbanConfig): void {
54
+ writeConfig(workspaceRoot, config)
55
+ }
56
+
57
+ // --- Helpers ---
58
+
59
+ function sanitizeFeature(feature: Feature): Omit<Feature, 'filePath'> {
60
+ const { filePath: _, ...rest } = feature
61
+ return rest
62
+ }
63
+
64
+ function readBody(req: http.IncomingMessage): Promise<Record<string, unknown>> {
65
+ return new Promise((resolve, reject) => {
66
+ const chunks: Buffer[] = []
67
+ req.on('data', (chunk: Buffer) => chunks.push(chunk))
68
+ req.on('end', () => {
69
+ try {
70
+ const text = Buffer.concat(chunks).toString('utf-8')
71
+ resolve(text ? JSON.parse(text) : {})
72
+ } catch (err) {
73
+ reject(err)
74
+ }
75
+ })
76
+ req.on('error', reject)
77
+ })
78
+ }
79
+
80
+ function matchRoute(
81
+ expectedMethod: string,
82
+ actualMethod: string,
83
+ pathname: string,
84
+ pattern: string
85
+ ): Record<string, string> | null {
86
+ if (expectedMethod !== actualMethod) return null
87
+ const patternParts = pattern.split('/')
88
+ const pathParts = pathname.split('/')
89
+ if (patternParts.length !== pathParts.length) return null
90
+ const params: Record<string, string> = {}
91
+ for (let i = 0; i < patternParts.length; i++) {
92
+ if (patternParts[i].startsWith(':')) {
93
+ params[patternParts[i].slice(1)] = decodeURIComponent(pathParts[i])
94
+ } else if (patternParts[i] !== pathParts[i]) {
95
+ return null
96
+ }
97
+ }
98
+ return params
99
+ }
100
+
101
+ function jsonOk(res: http.ServerResponse, data: unknown, status = 200): void {
102
+ res.writeHead(status, {
103
+ 'Content-Type': 'application/json',
104
+ 'Access-Control-Allow-Origin': '*'
105
+ })
106
+ res.end(JSON.stringify({ ok: true, data }))
107
+ }
108
+
109
+ function jsonError(res: http.ServerResponse, status: number, error: string): void {
110
+ res.writeHead(status, {
111
+ 'Content-Type': 'application/json',
112
+ 'Access-Control-Allow-Origin': '*'
113
+ })
114
+ res.end(JSON.stringify({ ok: false, error }))
115
+ }
116
+
117
+ // --- HTML template ---
118
+ const indexHtml = `<!DOCTYPE html>
119
+ <html lang="en">
120
+ <head>
121
+ <meta charset="UTF-8">
122
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
123
+ <link href="/style.css" rel="stylesheet">
124
+ <title>Kanban Board</title>
125
+ </head>
126
+ <body>
127
+ <div id="root"></div>
128
+ <script type="module" src="/index.js"></script>
129
+ </body>
130
+ </html>`
131
+
132
+ // --- Feature loading ---
133
+
134
+ function loadFeatures(): void {
135
+ fs.mkdirSync(absoluteFeaturesDir, { recursive: true })
136
+ ensureStatusSubfolders(absoluteFeaturesDir, getConfig().columns.map(c => c.id))
137
+
138
+ // Phase 1: Migrate flat root .md files into their status subfolder
139
+ migrating = true
140
+ try {
141
+ const rootEntries = fs.readdirSync(absoluteFeaturesDir, { withFileTypes: true })
142
+ for (const entry of rootEntries) {
143
+ if (!entry.isFile() || !entry.name.endsWith('.md')) continue
144
+ const filePath = path.join(absoluteFeaturesDir, entry.name)
145
+ try {
146
+ const content = fs.readFileSync(filePath, 'utf-8')
147
+ const feature = parseFeatureFile(content, filePath)
148
+ if (feature) {
149
+ moveFeatureFile(filePath, absoluteFeaturesDir, feature.status, feature.attachments)
150
+ }
151
+ } catch {
152
+ // skip
153
+ }
154
+ }
155
+ } finally {
156
+ migrating = false
157
+ }
158
+
159
+ // Phase 2: Load .md files from ALL subdirectories
160
+ const loaded: Feature[] = []
161
+ const topEntries = fs.readdirSync(absoluteFeaturesDir, { withFileTypes: true })
162
+ for (const entry of topEntries) {
163
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue
164
+ const subdir = path.join(absoluteFeaturesDir, entry.name)
165
+ try {
166
+ const subEntries = fs.readdirSync(subdir, { withFileTypes: true })
167
+ for (const sub of subEntries) {
168
+ if (!sub.isFile() || !sub.name.endsWith('.md')) continue
169
+ const filePath = path.join(subdir, sub.name)
170
+ const content = fs.readFileSync(filePath, 'utf-8')
171
+ const feature = parseFeatureFile(content, filePath)
172
+ if (feature) loaded.push(feature)
173
+ }
174
+ } catch {
175
+ // skip unreadable directories
176
+ }
177
+ }
178
+
179
+ // Phase 3: Reconcile status ↔ folder mismatches
180
+ migrating = true
181
+ try {
182
+ for (const feature of loaded) {
183
+ const pathStatus = getStatusFromPath(feature.filePath, absoluteFeaturesDir)
184
+ if (pathStatus !== null && pathStatus !== feature.status) {
185
+ try {
186
+ feature.filePath = moveFeatureFile(feature.filePath, absoluteFeaturesDir, feature.status, feature.attachments)
187
+ } catch { /* retry next load */ }
188
+ }
189
+ }
190
+ } finally {
191
+ migrating = false
192
+ }
193
+
194
+ // Migrate legacy integer order → fractional indices
195
+ const hasLegacyOrder = loaded.some(f => /^\d+$/.test(f.order))
196
+ if (hasLegacyOrder) {
197
+ const byStatus = new Map<string, Feature[]>()
198
+ for (const f of loaded) {
199
+ const list = byStatus.get(f.status) || []
200
+ list.push(f)
201
+ byStatus.set(f.status, list)
202
+ }
203
+
204
+ for (const columnFeatures of byStatus.values()) {
205
+ columnFeatures.sort((a, b) => parseInt(a.order) - parseInt(b.order))
206
+ const keys = generateNKeysBetween(null, null, columnFeatures.length)
207
+ for (let i = 0; i < columnFeatures.length; i++) {
208
+ columnFeatures[i].order = keys[i]
209
+ const content = serializeFeature(columnFeatures[i])
210
+ fs.writeFileSync(columnFeatures[i].filePath, content, 'utf-8')
211
+ }
212
+ }
213
+ }
214
+
215
+ // Sync ID counter with existing cards
216
+ const numericIds = loaded
217
+ .map(f => parseInt(f.id, 10))
218
+ .filter(n => !Number.isNaN(n))
219
+ if (numericIds.length > 0) {
220
+ syncCardIdCounter(workspaceRoot, numericIds)
221
+ }
222
+
223
+ features = loaded.sort((a, b) => (a.order < b.order ? -1 : a.order > b.order ? 1 : 0))
224
+ }
225
+
226
+ // --- Message building & broadcast ---
227
+
228
+ function buildInitMessage(): unknown {
229
+ const config = getConfig()
230
+ const settings = configToSettings(config)
231
+ settings.showBuildWithAI = false
232
+ settings.markdownEditorMode = false
233
+ return {
234
+ type: 'init',
235
+ features,
236
+ columns: config.columns,
237
+ settings
238
+ }
239
+ }
240
+
241
+ function broadcast(message: unknown): void {
242
+ const json = JSON.stringify(message)
243
+ for (const client of wss.clients) {
244
+ if (client.readyState === WebSocket.OPEN) {
245
+ client.send(json)
246
+ }
247
+ }
248
+ }
249
+
250
+ // --- Mutation functions ---
251
+ // Shared by both WebSocket handlers and REST API routes.
252
+
253
+ function doCreateFeature(data: CreateFeatureData): Feature {
254
+ fs.mkdirSync(absoluteFeaturesDir, { recursive: true })
255
+ ensureStatusSubfolders(absoluteFeaturesDir, getConfig().columns.map(c => c.id))
256
+
257
+ const title = getTitleFromContent(data.content)
258
+ const numericId = allocateCardId(workspaceRoot)
259
+ const filename = generateFeatureFilename(numericId, title)
260
+ const now = new Date().toISOString()
261
+ const featuresInStatus = features
262
+ .filter(f => f.status === data.status)
263
+ .sort((a, b) => (a.order < b.order ? -1 : a.order > b.order ? 1 : 0))
264
+ const lastOrder = featuresInStatus.length > 0 ? featuresInStatus[featuresInStatus.length - 1].order : null
265
+
266
+ const feature: Feature = {
267
+ id: String(numericId),
268
+ status: data.status,
269
+ priority: data.priority,
270
+ assignee: data.assignee,
271
+ dueDate: data.dueDate,
272
+ created: now,
273
+ modified: now,
274
+ completedAt: data.status === 'done' ? now : null,
275
+ labels: data.labels,
276
+ attachments: [],
277
+ order: generateKeyBetween(lastOrder, null),
278
+ content: data.content,
279
+ filePath: getFeatureFilePath(absoluteFeaturesDir, data.status, filename)
280
+ }
281
+
282
+ fs.mkdirSync(path.dirname(feature.filePath), { recursive: true })
283
+ const content = serializeFeature(feature)
284
+ fs.writeFileSync(feature.filePath, content, 'utf-8')
285
+
286
+ features.push(feature)
287
+ broadcast(buildInitMessage())
288
+ fireWebhooks(workspaceRoot, 'task.created', sanitizeFeature(feature))
289
+ return feature
290
+ }
291
+
292
+ function doMoveFeature(featureId: string, newStatus: string, newOrder: number): Feature | null {
293
+ const feature = features.find(f => f.id === featureId)
294
+ if (!feature) return null
295
+
296
+ const oldStatus = feature.status
297
+ const statusChanged = oldStatus !== newStatus
298
+
299
+ feature.status = newStatus as FeatureStatus
300
+ feature.modified = new Date().toISOString()
301
+ if (statusChanged) {
302
+ feature.completedAt = newStatus === 'done' ? new Date().toISOString() : null
303
+ }
304
+
305
+ const targetColumnFeatures = features
306
+ .filter(f => f.status === newStatus && f.id !== featureId)
307
+ .sort((a, b) => (a.order < b.order ? -1 : a.order > b.order ? 1 : 0))
308
+
309
+ const clampedOrder = Math.max(0, Math.min(newOrder, targetColumnFeatures.length))
310
+ const before = clampedOrder > 0 ? targetColumnFeatures[clampedOrder - 1].order : null
311
+ const after = clampedOrder < targetColumnFeatures.length ? targetColumnFeatures[clampedOrder].order : null
312
+ feature.order = generateKeyBetween(before, after)
313
+
314
+ const content = serializeFeature(feature)
315
+ fs.writeFileSync(feature.filePath, content, 'utf-8')
316
+
317
+ if (statusChanged) {
318
+ migrating = true
319
+ try {
320
+ feature.filePath = moveFeatureFile(feature.filePath, absoluteFeaturesDir, newStatus, feature.attachments)
321
+ } catch {
322
+ // retry next load
323
+ } finally {
324
+ migrating = false
325
+ }
326
+ }
327
+
328
+ broadcast(buildInitMessage())
329
+ fireWebhooks(workspaceRoot, 'task.moved', {
330
+ ...sanitizeFeature(feature),
331
+ previousStatus: oldStatus
332
+ })
333
+ return feature
334
+ }
335
+
336
+ function doUpdateFeature(featureId: string, updates: Partial<Feature>): Feature | null {
337
+ const feature = features.find(f => f.id === featureId)
338
+ if (!feature) return null
339
+
340
+ const oldStatus = feature.status
341
+ const oldTitle = getTitleFromContent(feature.content)
342
+ const { filePath: _fp, id: _id, ...safeUpdates } = updates
343
+ Object.assign(feature, safeUpdates)
344
+ feature.modified = new Date().toISOString()
345
+ if (oldStatus !== feature.status) {
346
+ feature.completedAt = feature.status === 'done' ? new Date().toISOString() : null
347
+ }
348
+
349
+ const content = serializeFeature(feature)
350
+ fs.writeFileSync(feature.filePath, content, 'utf-8')
351
+
352
+ // Rename file if title changed (numeric-ID cards only)
353
+ const newTitle = getTitleFromContent(feature.content)
354
+ const numId = extractNumericId(feature.id)
355
+ if (numId !== null && newTitle !== oldTitle) {
356
+ const newFilename = generateFeatureFilename(numId, newTitle)
357
+ migrating = true
358
+ try {
359
+ feature.filePath = renameFeatureFile(feature.filePath, newFilename)
360
+ } catch { /* retry next load */ } finally { migrating = false }
361
+ }
362
+
363
+ if (oldStatus !== feature.status) {
364
+ migrating = true
365
+ try {
366
+ feature.filePath = moveFeatureFile(feature.filePath, absoluteFeaturesDir, feature.status, feature.attachments)
367
+ } catch {
368
+ // retry next load
369
+ } finally {
370
+ migrating = false
371
+ }
372
+ }
373
+
374
+ broadcast(buildInitMessage())
375
+ fireWebhooks(workspaceRoot, 'task.updated', sanitizeFeature(feature))
376
+ return feature
377
+ }
378
+
379
+ function doDeleteFeature(featureId: string): boolean {
380
+ const feature = features.find(f => f.id === featureId)
381
+ if (!feature) return false
382
+
383
+ try {
384
+ fs.unlinkSync(feature.filePath)
385
+ const deleted = sanitizeFeature(feature)
386
+ features = features.filter(f => f.id !== featureId)
387
+ broadcast(buildInitMessage())
388
+ fireWebhooks(workspaceRoot, 'task.deleted', deleted)
389
+ return true
390
+ } catch (err) {
391
+ console.error('Failed to delete feature:', err)
392
+ return false
393
+ }
394
+ }
395
+
396
+ function doAddColumn(name: string, color: string): KanbanColumn {
397
+ const config = getConfig()
398
+ const columns = config.columns
399
+ const id = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
400
+ let uniqueId = id
401
+ let counter = 1
402
+ while (columns.some(c => c.id === uniqueId)) {
403
+ uniqueId = `${id}-${counter++}`
404
+ }
405
+ const column: KanbanColumn = { id: uniqueId, name, color }
406
+ columns.push(column)
407
+ saveConfigFile({ ...config, columns })
408
+ broadcast(buildInitMessage())
409
+ fireWebhooks(workspaceRoot, 'column.created', column)
410
+ return column
411
+ }
412
+
413
+ function doEditColumn(columnId: string, updates: { name: string; color: string }): KanbanColumn | null {
414
+ const config = getConfig()
415
+ const col = config.columns.find(c => c.id === columnId)
416
+ if (!col) return null
417
+ const columns = config.columns.map(c =>
418
+ c.id === columnId ? { ...c, name: updates.name, color: updates.color } : c
419
+ )
420
+ saveConfigFile({ ...config, columns })
421
+ const updated = columns.find(c => c.id === columnId) ?? col
422
+ broadcast(buildInitMessage())
423
+ fireWebhooks(workspaceRoot, 'column.updated', updated)
424
+ return updated
425
+ }
426
+
427
+ function doRemoveColumn(columnId: string): { removed: boolean; error?: string } {
428
+ const hasFeatures = features.some(f => f.status === columnId)
429
+ if (hasFeatures) return { removed: false, error: 'Column has tasks' }
430
+ const config = getConfig()
431
+ const col = config.columns.find(c => c.id === columnId)
432
+ if (!col) return { removed: false, error: 'Column not found' }
433
+ const columns = config.columns.filter(c => c.id !== columnId)
434
+ if (columns.length === 0) return { removed: false, error: 'Cannot remove last column' }
435
+ saveConfigFile({ ...config, columns })
436
+ broadcast(buildInitMessage())
437
+ fireWebhooks(workspaceRoot, 'column.deleted', col)
438
+ return { removed: true }
439
+ }
440
+
441
+ function doSaveSettings(newSettings: CardDisplaySettings): void {
442
+ const config = getConfig()
443
+ saveConfigFile(settingsToConfig(config, newSettings))
444
+ broadcast(buildInitMessage())
445
+ }
446
+
447
+ function doAddAttachment(featureId: string, filename: string, fileData: Buffer): boolean {
448
+ const feature = features.find(f => f.id === featureId)
449
+ if (!feature) return false
450
+
451
+ const featureDir = path.dirname(feature.filePath)
452
+ const destPath = path.join(featureDir, filename)
453
+ fs.writeFileSync(destPath, fileData)
454
+
455
+ if (!feature.attachments) feature.attachments = []
456
+ if (!feature.attachments.includes(filename)) {
457
+ feature.attachments.push(filename)
458
+ }
459
+ feature.modified = new Date().toISOString()
460
+ const content = serializeFeature(feature)
461
+ lastWrittenContent = content
462
+ fs.writeFileSync(feature.filePath, content, 'utf-8')
463
+ return true
464
+ }
465
+
466
+ function doRemoveAttachment(featureId: string, attachment: string): Feature | null {
467
+ const feature = features.find(f => f.id === featureId)
468
+ if (!feature) return null
469
+
470
+ feature.attachments = (feature.attachments || []).filter(a => a !== attachment)
471
+ feature.modified = new Date().toISOString()
472
+ const fileContent = serializeFeature(feature)
473
+ lastWrittenContent = fileContent
474
+ fs.writeFileSync(feature.filePath, fileContent, 'utf-8')
475
+
476
+ broadcast(buildInitMessage())
477
+ return feature
478
+ }
479
+
480
+ // --- WebSocket message handling ---
481
+
482
+ async function handleMessage(ws: WebSocket, message: unknown): Promise<void> {
483
+ const msg = message as Record<string, unknown>
484
+ switch (msg.type) {
485
+ case 'ready':
486
+ loadFeatures()
487
+ ws.send(JSON.stringify(buildInitMessage()))
488
+ break
489
+
490
+ case 'createFeature':
491
+ doCreateFeature(msg.data as CreateFeatureData)
492
+ break
493
+
494
+ case 'moveFeature':
495
+ doMoveFeature(msg.featureId as string, msg.newStatus as string, msg.newOrder as number)
496
+ break
497
+
498
+ case 'deleteFeature':
499
+ doDeleteFeature(msg.featureId as string)
500
+ break
501
+
502
+ case 'updateFeature':
503
+ doUpdateFeature(msg.featureId as string, msg.updates as Partial<Feature>)
504
+ break
505
+
506
+ case 'openFeature': {
507
+ const featureId = msg.featureId as string
508
+ const feature = features.find(f => f.id === featureId)
509
+ if (!feature) break
510
+
511
+ currentEditingFeatureId = featureId
512
+ const frontmatter: FeatureFrontmatter = {
513
+ id: feature.id, status: feature.status, priority: feature.priority,
514
+ assignee: feature.assignee, dueDate: feature.dueDate, created: feature.created,
515
+ modified: feature.modified, completedAt: feature.completedAt,
516
+ labels: feature.labels, attachments: feature.attachments, order: feature.order
517
+ }
518
+ ws.send(JSON.stringify({ type: 'featureContent', featureId: feature.id, content: feature.content, frontmatter }))
519
+ break
520
+ }
521
+
522
+ case 'saveFeatureContent': {
523
+ const featureId = msg.featureId as string
524
+ const newContent = msg.content as string
525
+ const fm = msg.frontmatter as FeatureFrontmatter
526
+ const feature = features.find(f => f.id === featureId)
527
+ if (!feature) break
528
+
529
+ const oldStatus = feature.status
530
+ const oldTitle = getTitleFromContent(feature.content)
531
+ feature.content = newContent
532
+ feature.status = fm.status
533
+ feature.priority = fm.priority
534
+ feature.assignee = fm.assignee
535
+ feature.dueDate = fm.dueDate
536
+ feature.labels = fm.labels
537
+ feature.attachments = fm.attachments || feature.attachments || []
538
+ feature.modified = new Date().toISOString()
539
+ if (oldStatus !== feature.status) {
540
+ feature.completedAt = feature.status === 'done' ? new Date().toISOString() : null
541
+ }
542
+
543
+ const fileContent = serializeFeature(feature)
544
+ lastWrittenContent = fileContent
545
+ fs.writeFileSync(feature.filePath, fileContent, 'utf-8')
546
+
547
+ // Rename file if title changed (numeric-ID cards only)
548
+ const saveNewTitle = getTitleFromContent(feature.content)
549
+ const saveNumId = extractNumericId(feature.id)
550
+ if (saveNumId !== null && saveNewTitle !== oldTitle) {
551
+ const newFilename = generateFeatureFilename(saveNumId, saveNewTitle)
552
+ migrating = true
553
+ try {
554
+ feature.filePath = renameFeatureFile(feature.filePath, newFilename)
555
+ } catch { /* retry next load */ } finally { migrating = false }
556
+ }
557
+
558
+ if (oldStatus !== feature.status) {
559
+ migrating = true
560
+ try {
561
+ feature.filePath = moveFeatureFile(feature.filePath, absoluteFeaturesDir, feature.status, feature.attachments)
562
+ } catch { /* retry next load */ } finally { migrating = false }
563
+ }
564
+
565
+ broadcast(buildInitMessage())
566
+ fireWebhooks(workspaceRoot, 'task.updated', sanitizeFeature(feature))
567
+ break
568
+ }
569
+
570
+ case 'closeFeature':
571
+ currentEditingFeatureId = null
572
+ break
573
+
574
+ case 'openSettings': {
575
+ const config = getConfig()
576
+ const settings = configToSettings(config)
577
+ settings.showBuildWithAI = false
578
+ settings.markdownEditorMode = false
579
+ ws.send(JSON.stringify({ type: 'showSettings', settings }))
580
+ break
581
+ }
582
+
583
+ case 'saveSettings':
584
+ doSaveSettings(msg.settings as CardDisplaySettings)
585
+ break
586
+
587
+ case 'addColumn': {
588
+ const col = msg.column as { name: string; color: string }
589
+ doAddColumn(col.name, col.color)
590
+ break
591
+ }
592
+
593
+ case 'editColumn':
594
+ doEditColumn(msg.columnId as string, msg.updates as { name: string; color: string })
595
+ break
596
+
597
+ case 'removeColumn':
598
+ doRemoveColumn(msg.columnId as string)
599
+ break
600
+
601
+ case 'removeAttachment': {
602
+ const featureId = msg.featureId as string
603
+ const feature = doRemoveAttachment(featureId, msg.attachment as string)
604
+ if (feature && currentEditingFeatureId === featureId) {
605
+ const frontmatter: FeatureFrontmatter = {
606
+ id: feature.id, status: feature.status, priority: feature.priority,
607
+ assignee: feature.assignee, dueDate: feature.dueDate, created: feature.created,
608
+ modified: feature.modified, completedAt: feature.completedAt,
609
+ labels: feature.labels, attachments: feature.attachments, order: feature.order
610
+ }
611
+ ws.send(JSON.stringify({ type: 'featureContent', featureId: feature.id, content: feature.content, frontmatter }))
612
+ }
613
+ break
614
+ }
615
+
616
+ // VSCode-specific actions — no-ops in standalone
617
+ case 'openFile':
618
+ case 'focusMenuBar':
619
+ case 'startWithAI':
620
+ case 'addAttachment':
621
+ case 'openAttachment':
622
+ break
623
+ }
624
+ }
625
+
626
+ // --- HTTP server ---
627
+
628
+ const server = http.createServer(async (req, res) => {
629
+ const url = new URL(req.url || '/', `http://${req.headers.host}`)
630
+ const { pathname } = url
631
+ const method = req.method || 'GET'
632
+
633
+ // CORS preflight
634
+ if (method === 'OPTIONS') {
635
+ res.writeHead(204, {
636
+ 'Access-Control-Allow-Origin': '*',
637
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
638
+ 'Access-Control-Allow-Headers': 'Content-Type',
639
+ 'Access-Control-Max-Age': '86400'
640
+ })
641
+ res.end()
642
+ return
643
+ }
644
+
645
+ // Route helper: match and extract params, then handle
646
+ const route = (expectedMethod: string, pattern: string): Record<string, string> | null =>
647
+ matchRoute(expectedMethod, method, pathname, pattern)
648
+
649
+ // ==================== TASKS API ====================
650
+
651
+ let params = route('GET', '/api/tasks')
652
+ if (params) {
653
+ loadFeatures()
654
+ let result = features.map(sanitizeFeature)
655
+ const status = url.searchParams.get('status')
656
+ if (status) result = result.filter(f => f.status === status)
657
+ const priority = url.searchParams.get('priority')
658
+ if (priority) result = result.filter(f => f.priority === priority)
659
+ const assignee = url.searchParams.get('assignee')
660
+ if (assignee) result = result.filter(f => f.assignee === assignee)
661
+ const label = url.searchParams.get('label')
662
+ if (label) result = result.filter(f => f.labels.includes(label))
663
+ return jsonOk(res, result)
664
+ }
665
+
666
+ params = route('POST', '/api/tasks')
667
+ if (params) {
668
+ try {
669
+ const body = await readBody(req)
670
+ const data: CreateFeatureData = {
671
+ content: (body.content as string) || '',
672
+ status: (body.status as FeatureStatus) || 'backlog',
673
+ priority: (body.priority as Priority) || 'medium',
674
+ assignee: (body.assignee as string) || null,
675
+ dueDate: (body.dueDate as string) || null,
676
+ labels: (body.labels as string[]) || []
677
+ }
678
+ if (!data.content) return jsonError(res, 400, 'content is required')
679
+ const feature = doCreateFeature(data)
680
+ return jsonOk(res, sanitizeFeature(feature), 201)
681
+ } catch (err) {
682
+ return jsonError(res, 400, String(err))
683
+ }
684
+ }
685
+
686
+ params = route('GET', '/api/tasks/:id')
687
+ if (params) {
688
+ const { id } = params
689
+ const feature = features.find(f => f.id === id)
690
+ if (!feature) return jsonError(res, 404, 'Task not found')
691
+ return jsonOk(res, sanitizeFeature(feature))
692
+ }
693
+
694
+ params = route('PUT', '/api/tasks/:id')
695
+ if (params) {
696
+ try {
697
+ const { id } = params
698
+ const body = await readBody(req)
699
+ const feature = doUpdateFeature(id, body as Partial<Feature>)
700
+ if (!feature) return jsonError(res, 404, 'Task not found')
701
+ return jsonOk(res, sanitizeFeature(feature))
702
+ } catch (err) {
703
+ return jsonError(res, 400, String(err))
704
+ }
705
+ }
706
+
707
+ params = route('PATCH', '/api/tasks/:id/move')
708
+ if (params) {
709
+ try {
710
+ const { id } = params
711
+ const body = await readBody(req)
712
+ const newStatus = body.status as string
713
+ const position = body.position as number ?? 0
714
+ if (!newStatus) return jsonError(res, 400, 'status is required')
715
+ const feature = doMoveFeature(id, newStatus, position)
716
+ if (!feature) return jsonError(res, 404, 'Task not found')
717
+ return jsonOk(res, sanitizeFeature(feature))
718
+ } catch (err) {
719
+ return jsonError(res, 400, String(err))
720
+ }
721
+ }
722
+
723
+ params = route('DELETE', '/api/tasks/:id')
724
+ if (params) {
725
+ const { id } = params
726
+ const ok = doDeleteFeature(id)
727
+ if (!ok) return jsonError(res, 404, 'Task not found')
728
+ return jsonOk(res, { deleted: true })
729
+ }
730
+
731
+ // ==================== ATTACHMENTS API ====================
732
+
733
+ params = route('POST', '/api/tasks/:id/attachments')
734
+ if (params) {
735
+ try {
736
+ const { id } = params
737
+ const body = await readBody(req)
738
+ const files = body.files as { name: string; data: string }[]
739
+ if (!Array.isArray(files)) return jsonError(res, 400, 'files array is required')
740
+ for (const file of files) {
741
+ const buf = Buffer.from(file.data, 'base64')
742
+ doAddAttachment(id, file.name, buf)
743
+ }
744
+ broadcast(buildInitMessage())
745
+ const feature = features.find(f => f.id === id)
746
+ if (!feature) return jsonError(res, 404, 'Task not found')
747
+ return jsonOk(res, sanitizeFeature(feature))
748
+ } catch (err) {
749
+ return jsonError(res, 400, String(err))
750
+ }
751
+ }
752
+
753
+ params = route('GET', '/api/tasks/:id/attachments/:filename')
754
+ if (params) {
755
+ const { id, filename: attachName } = params
756
+ const feature = features.find(f => f.id === id)
757
+ if (!feature) return jsonError(res, 404, 'Task not found')
758
+ const featureDir = path.dirname(feature.filePath)
759
+ const attachmentPath = path.resolve(featureDir, attachName)
760
+ if (!attachmentPath.startsWith(absoluteFeaturesDir)) {
761
+ res.writeHead(403, { 'Content-Type': 'text/plain' })
762
+ res.end('Forbidden')
763
+ return
764
+ }
765
+ const ext = path.extname(attachName)
766
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream'
767
+ fs.readFile(attachmentPath, (err, data) => {
768
+ if (err) { res.writeHead(404); res.end('File not found'); return }
769
+ res.writeHead(200, {
770
+ 'Content-Type': contentType,
771
+ 'Content-Disposition': `inline; filename="${attachName}"`,
772
+ 'Access-Control-Allow-Origin': '*'
773
+ })
774
+ res.end(data)
775
+ })
776
+ return
777
+ }
778
+
779
+ params = route('DELETE', '/api/tasks/:id/attachments/:filename')
780
+ if (params) {
781
+ const { id, filename: attachName } = params
782
+ const feature = doRemoveAttachment(id, attachName)
783
+ if (!feature) return jsonError(res, 404, 'Task not found')
784
+ return jsonOk(res, sanitizeFeature(feature))
785
+ }
786
+
787
+ // ==================== COLUMNS API ====================
788
+
789
+ params = route('GET', '/api/columns')
790
+ if (params) {
791
+ return jsonOk(res, getConfig().columns)
792
+ }
793
+
794
+ params = route('POST', '/api/columns')
795
+ if (params) {
796
+ try {
797
+ const body = await readBody(req)
798
+ const name = body.name as string
799
+ const color = body.color as string
800
+ if (!name) return jsonError(res, 400, 'name is required')
801
+ const column = doAddColumn(name, color || '#6b7280')
802
+ return jsonOk(res, column, 201)
803
+ } catch (err) {
804
+ return jsonError(res, 400, String(err))
805
+ }
806
+ }
807
+
808
+ params = route('PUT', '/api/columns/:id')
809
+ if (params) {
810
+ try {
811
+ const { id } = params
812
+ const body = await readBody(req)
813
+ const column = doEditColumn(id, { name: body.name as string, color: body.color as string })
814
+ if (!column) return jsonError(res, 404, 'Column not found')
815
+ return jsonOk(res, column)
816
+ } catch (err) {
817
+ return jsonError(res, 400, String(err))
818
+ }
819
+ }
820
+
821
+ params = route('DELETE', '/api/columns/:id')
822
+ if (params) {
823
+ const { id } = params
824
+ const result = doRemoveColumn(id)
825
+ if (!result.removed) return jsonError(res, 400, result.error || 'Cannot remove column')
826
+ return jsonOk(res, { deleted: true })
827
+ }
828
+
829
+ // ==================== SETTINGS API ====================
830
+
831
+ params = route('GET', '/api/settings')
832
+ if (params) {
833
+ const config = getConfig()
834
+ const settings = configToSettings(config)
835
+ settings.showBuildWithAI = false
836
+ settings.markdownEditorMode = false
837
+ return jsonOk(res, settings)
838
+ }
839
+
840
+ params = route('PUT', '/api/settings')
841
+ if (params) {
842
+ try {
843
+ const body = await readBody(req)
844
+ doSaveSettings(body as unknown as CardDisplaySettings)
845
+ const config = getConfig()
846
+ return jsonOk(res, configToSettings(config))
847
+ } catch (err) {
848
+ return jsonError(res, 400, String(err))
849
+ }
850
+ }
851
+
852
+ // ==================== WEBHOOKS API ====================
853
+
854
+ params = route('GET', '/api/webhooks')
855
+ if (params) {
856
+ return jsonOk(res, loadWebhooks(workspaceRoot))
857
+ }
858
+
859
+ params = route('POST', '/api/webhooks')
860
+ if (params) {
861
+ try {
862
+ const body = await readBody(req)
863
+ const webhookUrl = body.url as string
864
+ const events = body.events as string[]
865
+ const secret = body.secret as string | undefined
866
+ if (!webhookUrl) return jsonError(res, 400, 'url is required')
867
+ if (!events || !Array.isArray(events) || events.length === 0) {
868
+ return jsonError(res, 400, 'events array is required')
869
+ }
870
+ const webhook = createWebhook(workspaceRoot, { url: webhookUrl, events, secret })
871
+ return jsonOk(res, webhook, 201)
872
+ } catch (err) {
873
+ return jsonError(res, 400, String(err))
874
+ }
875
+ }
876
+
877
+ params = route('DELETE', '/api/webhooks/:id')
878
+ if (params) {
879
+ const { id } = params
880
+ const ok = deleteWebhook(workspaceRoot, id)
881
+ if (!ok) return jsonError(res, 404, 'Webhook not found')
882
+ return jsonOk(res, { deleted: true })
883
+ }
884
+
885
+ // ==================== WORKSPACE API ====================
886
+
887
+ params = route('GET', '/api/workspace')
888
+ if (params) {
889
+ return jsonOk(res, { path: workspaceRoot })
890
+ }
891
+
892
+ // ==================== LEGACY API (backwards compat) ====================
893
+
894
+ if (method === 'POST' && pathname === '/api/upload-attachment') {
895
+ try {
896
+ const body = await readBody(req)
897
+ const featureId = body.featureId as string
898
+ const files = body.files as { name: string; data: string }[]
899
+ if (!featureId || !Array.isArray(files)) return jsonError(res, 400, 'Missing featureId or files')
900
+
901
+ for (const file of files) {
902
+ const buf = Buffer.from(file.data, 'base64')
903
+ doAddAttachment(featureId, file.name, buf)
904
+ }
905
+
906
+ broadcast(buildInitMessage())
907
+ const feature = features.find(f => f.id === featureId)
908
+ if (feature && currentEditingFeatureId === featureId) {
909
+ const frontmatter: FeatureFrontmatter = {
910
+ id: feature.id, status: feature.status, priority: feature.priority,
911
+ assignee: feature.assignee, dueDate: feature.dueDate, created: feature.created,
912
+ modified: feature.modified, completedAt: feature.completedAt,
913
+ labels: feature.labels, attachments: feature.attachments, order: feature.order
914
+ }
915
+ broadcast({ type: 'featureContent', featureId: feature.id, content: feature.content, frontmatter })
916
+ }
917
+ res.writeHead(200, { 'Content-Type': 'application/json' })
918
+ res.end(JSON.stringify({ ok: true }))
919
+ } catch (err) {
920
+ res.writeHead(500, { 'Content-Type': 'application/json' })
921
+ res.end(JSON.stringify({ error: String(err) }))
922
+ }
923
+ return
924
+ }
925
+
926
+ if (method === 'GET' && pathname === '/api/attachment') {
927
+ const featureId = url.searchParams.get('featureId')
928
+ const filename = url.searchParams.get('filename')
929
+ if (!featureId || !filename) {
930
+ res.writeHead(400, { 'Content-Type': 'text/plain' }); res.end('Missing featureId or filename'); return
931
+ }
932
+ const feature = features.find(f => f.id === featureId)
933
+ if (!feature) { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Feature not found'); return }
934
+ const featureDir = path.dirname(feature.filePath)
935
+ const attachmentPath = path.resolve(featureDir, filename)
936
+ if (!attachmentPath.startsWith(absoluteFeaturesDir)) {
937
+ res.writeHead(403, { 'Content-Type': 'text/plain' }); res.end('Forbidden'); return
938
+ }
939
+ const ext = path.extname(filename)
940
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream'
941
+ fs.readFile(attachmentPath, (err, data) => {
942
+ if (err) { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('File not found'); return }
943
+ res.writeHead(200, { 'Content-Type': contentType, 'Content-Disposition': `inline; filename="${filename}"` })
944
+ res.end(data)
945
+ })
946
+ return
947
+ }
948
+
949
+ // Catch-all for unmatched /api/* routes
950
+ if (pathname.startsWith('/api/')) {
951
+ return jsonError(res, 404, 'Not found')
952
+ }
953
+
954
+ // ==================== STATIC FILES ====================
955
+
956
+ const filePath = path.join(resolvedWebviewDir, pathname === '/' ? 'index.html' : pathname)
957
+
958
+ if (!path.extname(filePath)) {
959
+ res.writeHead(200, { 'Content-Type': 'text/html' })
960
+ res.end(indexHtml)
961
+ return
962
+ }
963
+
964
+ const ext = path.extname(filePath)
965
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream'
966
+
967
+ fs.readFile(filePath, (err, data) => {
968
+ if (err) {
969
+ res.writeHead(200, { 'Content-Type': 'text/html' })
970
+ res.end(indexHtml)
971
+ return
972
+ }
973
+ res.writeHead(200, { 'Content-Type': contentType })
974
+ res.end(data)
975
+ })
976
+ })
977
+
978
+ // --- WebSocket server ---
979
+
980
+ const wss = new WebSocketServer({ server, path: '/ws' })
981
+
982
+ wss.on('connection', (ws) => {
983
+ ws.on('message', (data) => {
984
+ try {
985
+ const message = JSON.parse(data.toString())
986
+ handleMessage(ws, message)
987
+ } catch (err) {
988
+ console.error('Failed to handle message:', err)
989
+ }
990
+ })
991
+ })
992
+
993
+ // --- File watcher ---
994
+
995
+ let debounceTimer: ReturnType<typeof setTimeout> | undefined
996
+
997
+ fs.mkdirSync(absoluteFeaturesDir, { recursive: true })
998
+
999
+ const watcher = chokidar.watch(absoluteFeaturesDir, {
1000
+ ignoreInitial: true,
1001
+ awaitWriteFinish: { stabilityThreshold: 100 }
1002
+ })
1003
+
1004
+ const handleFileChange = (changedPath?: string) => {
1005
+ if (changedPath && !changedPath.endsWith('.md')) return
1006
+ if (migrating) return
1007
+ if (debounceTimer) clearTimeout(debounceTimer)
1008
+ debounceTimer = setTimeout(() => {
1009
+ loadFeatures()
1010
+ broadcast(buildInitMessage())
1011
+
1012
+ if (currentEditingFeatureId && changedPath) {
1013
+ const editingFeature = features.find(f => f.id === currentEditingFeatureId)
1014
+ if (editingFeature && editingFeature.filePath === changedPath) {
1015
+ const currentContent = serializeFeature(editingFeature)
1016
+ if (currentContent !== lastWrittenContent) {
1017
+ const frontmatter: FeatureFrontmatter = {
1018
+ id: editingFeature.id, status: editingFeature.status, priority: editingFeature.priority,
1019
+ assignee: editingFeature.assignee, dueDate: editingFeature.dueDate, created: editingFeature.created,
1020
+ modified: editingFeature.modified, completedAt: editingFeature.completedAt,
1021
+ labels: editingFeature.labels, attachments: editingFeature.attachments, order: editingFeature.order
1022
+ }
1023
+ broadcast({ type: 'featureContent', featureId: editingFeature.id, content: editingFeature.content, frontmatter })
1024
+ }
1025
+ }
1026
+ }
1027
+ }, 100)
1028
+ }
1029
+
1030
+ watcher.on('change', handleFileChange)
1031
+ watcher.on('add', handleFileChange)
1032
+ watcher.on('unlink', handleFileChange)
1033
+
1034
+ server.on('close', () => {
1035
+ watcher.close()
1036
+ wss.close()
1037
+ })
1038
+
1039
+ server.listen(port, () => {
1040
+ console.log(`Kanban board running at http://localhost:${port}`)
1041
+ console.log(`API available at http://localhost:${port}/api`)
1042
+ console.log(`Features directory: ${absoluteFeaturesDir}`)
1043
+ })
1044
+
1045
+ return server
1046
+ }