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,357 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import * as fs from 'fs'
3
+ import * as path from 'path'
4
+ import * as os from 'os'
5
+ import * as http from 'http'
6
+ import { loadWebhooks, saveWebhooks, createWebhook, deleteWebhook, fireWebhooks } from '../webhooks'
7
+ import type { Webhook } from '../webhooks'
8
+
9
+ function createTempDir(): string {
10
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'kanban-webhooks-test-'))
11
+ }
12
+
13
+ describe('Webhooks Module', () => {
14
+ let tempDir: string
15
+
16
+ beforeEach(() => {
17
+ tempDir = createTempDir()
18
+ })
19
+
20
+ afterEach(() => {
21
+ fs.rmSync(tempDir, { recursive: true, force: true })
22
+ })
23
+
24
+ // ── loadWebhooks ──
25
+
26
+ describe('loadWebhooks', () => {
27
+ it('should return empty array when no webhooks file exists', () => {
28
+ const webhooks = loadWebhooks(tempDir)
29
+ expect(webhooks).toEqual([])
30
+ })
31
+
32
+ it('should return empty array for invalid JSON', () => {
33
+ fs.writeFileSync(path.join(tempDir, '.kanban-webhooks.json'), 'not json{', 'utf-8')
34
+ const webhooks = loadWebhooks(tempDir)
35
+ expect(webhooks).toEqual([])
36
+ })
37
+
38
+ it('should return empty array for non-array JSON', () => {
39
+ fs.writeFileSync(path.join(tempDir, '.kanban-webhooks.json'), '{"not": "array"}', 'utf-8')
40
+ const webhooks = loadWebhooks(tempDir)
41
+ expect(webhooks).toEqual([])
42
+ })
43
+
44
+ it('should load webhooks from file', () => {
45
+ const data: Webhook[] = [
46
+ { id: 'wh_test1', url: 'https://example.com/hook1', events: ['*'], active: true },
47
+ { id: 'wh_test2', url: 'https://example.com/hook2', events: ['task.created'], secret: 'mysecret', active: true }
48
+ ]
49
+ fs.writeFileSync(path.join(tempDir, '.kanban-webhooks.json'), JSON.stringify(data), 'utf-8')
50
+
51
+ const webhooks = loadWebhooks(tempDir)
52
+ expect(webhooks.length).toBe(2)
53
+ expect(webhooks[0].id).toBe('wh_test1')
54
+ expect(webhooks[1].secret).toBe('mysecret')
55
+ })
56
+ })
57
+
58
+ // ── saveWebhooks ──
59
+
60
+ describe('saveWebhooks', () => {
61
+ it('should persist webhooks to file', () => {
62
+ const data: Webhook[] = [
63
+ { id: 'wh_save', url: 'https://example.com/save', events: ['task.created'], active: true }
64
+ ]
65
+ saveWebhooks(tempDir, data)
66
+
67
+ const raw = fs.readFileSync(path.join(tempDir, '.kanban-webhooks.json'), 'utf-8')
68
+ const parsed = JSON.parse(raw)
69
+ expect(parsed.length).toBe(1)
70
+ expect(parsed[0].id).toBe('wh_save')
71
+ })
72
+
73
+ it('should overwrite existing webhooks', () => {
74
+ const initial: Webhook[] = [
75
+ { id: 'wh_old', url: 'https://old.com', events: ['*'], active: true }
76
+ ]
77
+ saveWebhooks(tempDir, initial)
78
+
79
+ const updated: Webhook[] = [
80
+ { id: 'wh_new', url: 'https://new.com', events: ['task.moved'], active: true }
81
+ ]
82
+ saveWebhooks(tempDir, updated)
83
+
84
+ const webhooks = loadWebhooks(tempDir)
85
+ expect(webhooks.length).toBe(1)
86
+ expect(webhooks[0].id).toBe('wh_new')
87
+ })
88
+ })
89
+
90
+ // ── createWebhook ──
91
+
92
+ describe('createWebhook', () => {
93
+ it('should create a webhook with generated ID', () => {
94
+ const webhook = createWebhook(tempDir, {
95
+ url: 'https://example.com/hook',
96
+ events: ['task.created', 'task.moved']
97
+ })
98
+
99
+ expect(webhook.id).toMatch(/^wh_[0-9a-f]+$/)
100
+ expect(webhook.url).toBe('https://example.com/hook')
101
+ expect(webhook.events).toEqual(['task.created', 'task.moved'])
102
+ expect(webhook.active).toBe(true)
103
+ expect(webhook.secret).toBeUndefined()
104
+ })
105
+
106
+ it('should store secret when provided', () => {
107
+ const webhook = createWebhook(tempDir, {
108
+ url: 'https://example.com/hook',
109
+ events: ['*'],
110
+ secret: 'my-secret-key'
111
+ })
112
+
113
+ expect(webhook.secret).toBe('my-secret-key')
114
+ })
115
+
116
+ it('should persist to file', () => {
117
+ createWebhook(tempDir, {
118
+ url: 'https://example.com/hook',
119
+ events: ['*']
120
+ })
121
+
122
+ const webhooks = loadWebhooks(tempDir)
123
+ expect(webhooks.length).toBe(1)
124
+ })
125
+
126
+ it('should append to existing webhooks', () => {
127
+ createWebhook(tempDir, { url: 'https://one.com', events: ['*'] })
128
+ createWebhook(tempDir, { url: 'https://two.com', events: ['*'] })
129
+
130
+ const webhooks = loadWebhooks(tempDir)
131
+ expect(webhooks.length).toBe(2)
132
+ })
133
+ })
134
+
135
+ // ── deleteWebhook ──
136
+
137
+ describe('deleteWebhook', () => {
138
+ it('should remove a webhook by ID', () => {
139
+ const webhook = createWebhook(tempDir, {
140
+ url: 'https://example.com/hook',
141
+ events: ['*']
142
+ })
143
+
144
+ const result = deleteWebhook(tempDir, webhook.id)
145
+ expect(result).toBe(true)
146
+
147
+ const webhooks = loadWebhooks(tempDir)
148
+ expect(webhooks.length).toBe(0)
149
+ })
150
+
151
+ it('should return false for non-existent ID', () => {
152
+ const result = deleteWebhook(tempDir, 'wh_nonexistent')
153
+ expect(result).toBe(false)
154
+ })
155
+
156
+ it('should only remove the targeted webhook', () => {
157
+ const wh1 = createWebhook(tempDir, { url: 'https://one.com', events: ['*'] })
158
+ createWebhook(tempDir, { url: 'https://two.com', events: ['*'] })
159
+
160
+ deleteWebhook(tempDir, wh1.id)
161
+
162
+ const webhooks = loadWebhooks(tempDir)
163
+ expect(webhooks.length).toBe(1)
164
+ expect(webhooks[0].url).toBe('https://two.com')
165
+ })
166
+ })
167
+
168
+ // ── fireWebhooks ──
169
+
170
+ describe('fireWebhooks', () => {
171
+ it('should POST to matching webhooks', async () => {
172
+ // Create a simple HTTP server to receive the webhook
173
+ let receivedBody = ''
174
+ let receivedHeaders: http.IncomingHttpHeaders = {}
175
+ const receiver = http.createServer((req, res) => {
176
+ let body = ''
177
+ req.on('data', (chunk) => body += chunk)
178
+ req.on('end', () => {
179
+ receivedBody = body
180
+ receivedHeaders = req.headers
181
+ res.writeHead(200)
182
+ res.end()
183
+ })
184
+ })
185
+
186
+ await new Promise<void>(resolve => receiver.listen(0, resolve))
187
+ const receiverPort = (receiver.address() as { port: number }).port
188
+
189
+ try {
190
+ createWebhook(tempDir, {
191
+ url: `http://localhost:${receiverPort}/hook`,
192
+ events: ['task.created']
193
+ })
194
+
195
+ fireWebhooks(tempDir, 'task.created', { id: 'test-task', status: 'todo' })
196
+
197
+ // Wait for async delivery
198
+ await new Promise(r => setTimeout(r, 500))
199
+
200
+ const parsed = JSON.parse(receivedBody)
201
+ expect(parsed.event).toBe('task.created')
202
+ expect(parsed.data.id).toBe('test-task')
203
+ expect(receivedHeaders['x-webhook-event']).toBe('task.created')
204
+ expect(receivedHeaders['content-type']).toBe('application/json')
205
+ } finally {
206
+ await new Promise<void>(resolve => receiver.close(() => resolve()))
207
+ }
208
+ })
209
+
210
+ it('should not POST to webhooks that do not match the event', async () => {
211
+ let called = false
212
+ const receiver = http.createServer((_req, res) => {
213
+ called = true
214
+ res.writeHead(200)
215
+ res.end()
216
+ })
217
+
218
+ await new Promise<void>(resolve => receiver.listen(0, resolve))
219
+ const receiverPort = (receiver.address() as { port: number }).port
220
+
221
+ try {
222
+ createWebhook(tempDir, {
223
+ url: `http://localhost:${receiverPort}/hook`,
224
+ events: ['task.deleted']
225
+ })
226
+
227
+ fireWebhooks(tempDir, 'task.created', { id: 'test' })
228
+
229
+ await new Promise(r => setTimeout(r, 300))
230
+ expect(called).toBe(false)
231
+ } finally {
232
+ await new Promise<void>(resolve => receiver.close(() => resolve()))
233
+ }
234
+ })
235
+
236
+ it('should POST to wildcard webhooks for any event', async () => {
237
+ let receivedBody = ''
238
+ const receiver = http.createServer((req, res) => {
239
+ let body = ''
240
+ req.on('data', (chunk) => body += chunk)
241
+ req.on('end', () => {
242
+ receivedBody = body
243
+ res.writeHead(200)
244
+ res.end()
245
+ })
246
+ })
247
+
248
+ await new Promise<void>(resolve => receiver.listen(0, resolve))
249
+ const receiverPort = (receiver.address() as { port: number }).port
250
+
251
+ try {
252
+ createWebhook(tempDir, {
253
+ url: `http://localhost:${receiverPort}/hook`,
254
+ events: ['*']
255
+ })
256
+
257
+ fireWebhooks(tempDir, 'column.deleted', { id: 'test-col' })
258
+
259
+ await new Promise(r => setTimeout(r, 500))
260
+
261
+ const parsed = JSON.parse(receivedBody)
262
+ expect(parsed.event).toBe('column.deleted')
263
+ expect(parsed.data.id).toBe('test-col')
264
+ } finally {
265
+ await new Promise<void>(resolve => receiver.close(() => resolve()))
266
+ }
267
+ })
268
+
269
+ it('should include HMAC signature when secret is configured', async () => {
270
+ let receivedHeaders: http.IncomingHttpHeaders = {}
271
+ let receivedBody = ''
272
+ const receiver = http.createServer((req, res) => {
273
+ let body = ''
274
+ req.on('data', (chunk) => body += chunk)
275
+ req.on('end', () => {
276
+ receivedHeaders = req.headers
277
+ receivedBody = body
278
+ res.writeHead(200)
279
+ res.end()
280
+ })
281
+ })
282
+
283
+ await new Promise<void>(resolve => receiver.listen(0, resolve))
284
+ const receiverPort = (receiver.address() as { port: number }).port
285
+
286
+ try {
287
+ createWebhook(tempDir, {
288
+ url: `http://localhost:${receiverPort}/hook`,
289
+ events: ['*'],
290
+ secret: 'test-secret'
291
+ })
292
+
293
+ fireWebhooks(tempDir, 'task.created', { id: 'sig-test' })
294
+
295
+ await new Promise(r => setTimeout(r, 500))
296
+
297
+ const signature = receivedHeaders['x-webhook-signature'] as string
298
+ expect(signature).toBeDefined()
299
+ expect(signature).toMatch(/^sha256=[0-9a-f]+$/)
300
+
301
+ // Verify the signature
302
+ const crypto = await import('crypto')
303
+ const expected = crypto
304
+ .createHmac('sha256', 'test-secret')
305
+ .update(receivedBody)
306
+ .digest('hex')
307
+ expect(signature).toBe(`sha256=${expected}`)
308
+ } finally {
309
+ await new Promise<void>(resolve => receiver.close(() => resolve()))
310
+ }
311
+ })
312
+
313
+ it('should not fire inactive webhooks', async () => {
314
+ let called = false
315
+ const receiver = http.createServer((_req, res) => {
316
+ called = true
317
+ res.writeHead(200)
318
+ res.end()
319
+ })
320
+
321
+ await new Promise<void>(resolve => receiver.listen(0, resolve))
322
+ const receiverPort = (receiver.address() as { port: number }).port
323
+
324
+ try {
325
+ // Create an active webhook then manually mark it inactive
326
+ const wh = createWebhook(tempDir, {
327
+ url: `http://localhost:${receiverPort}/hook`,
328
+ events: ['*']
329
+ })
330
+
331
+ const webhooks = loadWebhooks(tempDir)
332
+ webhooks[0].active = false
333
+ saveWebhooks(tempDir, webhooks)
334
+
335
+ fireWebhooks(tempDir, 'task.created', { id: wh.id })
336
+
337
+ await new Promise(r => setTimeout(r, 300))
338
+ expect(called).toBe(false)
339
+ } finally {
340
+ await new Promise<void>(resolve => receiver.close(() => resolve()))
341
+ }
342
+ })
343
+
344
+ it('should handle delivery failure gracefully', () => {
345
+ // Point to a port that is not listening
346
+ createWebhook(tempDir, {
347
+ url: 'http://localhost:1/hook',
348
+ events: ['*']
349
+ })
350
+
351
+ // Should not throw
352
+ expect(() => {
353
+ fireWebhooks(tempDir, 'task.created', { id: 'fail-test' })
354
+ }).not.toThrow()
355
+ })
356
+ })
357
+ })
@@ -0,0 +1,70 @@
1
+ import * as fs from 'fs'
2
+ import * as path from 'path'
3
+
4
+ export function getFeatureFilePath(featuresDir: string, status: string, filename: string): string {
5
+ return path.join(featuresDir, status, `${filename}.md`)
6
+ }
7
+
8
+ export function ensureStatusSubfolders(featuresDir: string, statuses: string[]): void {
9
+ for (const status of statuses) {
10
+ fs.mkdirSync(path.join(featuresDir, status), { recursive: true })
11
+ }
12
+ }
13
+
14
+ export function moveFeatureFile(
15
+ currentPath: string,
16
+ featuresDir: string,
17
+ newStatus: string,
18
+ attachments?: string[]
19
+ ): string {
20
+ const filename = path.basename(currentPath)
21
+ const targetDir = path.join(featuresDir, newStatus)
22
+ let targetPath = path.join(targetDir, filename)
23
+
24
+ if (currentPath === targetPath) return currentPath
25
+
26
+ const ext = path.extname(filename)
27
+ const base = path.basename(filename, ext)
28
+ let counter = 1
29
+ while (fs.existsSync(targetPath)) {
30
+ targetPath = path.join(targetDir, `${base}-${counter}${ext}`)
31
+ counter++
32
+ }
33
+
34
+ fs.mkdirSync(targetDir, { recursive: true })
35
+ fs.renameSync(currentPath, targetPath)
36
+
37
+ if (attachments && attachments.length > 0) {
38
+ const sourceDir = path.dirname(currentPath)
39
+ for (const attachment of attachments) {
40
+ const srcAttach = path.join(sourceDir, attachment)
41
+ const destAttach = path.join(targetDir, attachment)
42
+ try {
43
+ if (fs.existsSync(srcAttach)) {
44
+ fs.renameSync(srcAttach, destAttach)
45
+ }
46
+ } catch {
47
+ // Best effort -- skip failed attachment moves
48
+ }
49
+ }
50
+ }
51
+
52
+ return targetPath
53
+ }
54
+
55
+ export function renameFeatureFile(currentPath: string, newFilename: string): string {
56
+ const dir = path.dirname(currentPath)
57
+ const newPath = path.join(dir, `${newFilename}.md`)
58
+ if (currentPath === newPath) return currentPath
59
+ fs.renameSync(currentPath, newPath)
60
+ return newPath
61
+ }
62
+
63
+ export function getStatusFromPath(filePath: string, featuresDir: string): string | null {
64
+ const relative = path.relative(featuresDir, filePath)
65
+ const parts = relative.split(path.sep)
66
+ if (parts.length === 2) {
67
+ return parts[0]
68
+ }
69
+ return null
70
+ }
@@ -0,0 +1,71 @@
1
+ import { startServer } from './server'
2
+
3
+ function parseArgs(args: string[]): { dir: string; port: number; noBrowser: boolean } {
4
+ let dir = '.kanban'
5
+ let port = 3000
6
+ let noBrowser = false
7
+
8
+ for (let i = 0; i < args.length; i++) {
9
+ switch (args[i]) {
10
+ case '--dir':
11
+ case '-d':
12
+ dir = args[++i]
13
+ break
14
+ case '--port':
15
+ case '-p':
16
+ port = parseInt(args[++i], 10)
17
+ break
18
+ case '--no-browser':
19
+ noBrowser = true
20
+ break
21
+ case '--help':
22
+ case '-h':
23
+ console.log(`
24
+ Usage: kanban-md [options]
25
+
26
+ Options:
27
+ -d, --dir <path> Features directory (default: .kanban)
28
+ -p, --port <number> Port to listen on (default: 3000)
29
+ --no-browser Don't open browser automatically
30
+ -h, --help Show this help message
31
+
32
+ REST API available at http://localhost:<port>/api
33
+ Tasks: GET/POST /api/tasks, GET/PUT/DELETE /api/tasks/:id
34
+ Move: PATCH /api/tasks/:id/move
35
+ Columns: GET/POST /api/columns, PUT/DELETE /api/columns/:id
36
+ Settings: GET/PUT /api/settings
37
+ Webhooks: GET/POST /api/webhooks, DELETE /api/webhooks/:id
38
+ `)
39
+ process.exit(0)
40
+ }
41
+ }
42
+
43
+ return { dir, port, noBrowser }
44
+ }
45
+
46
+ const { dir, port, noBrowser } = parseArgs(process.argv.slice(2))
47
+
48
+ const server = startServer(dir, port)
49
+
50
+ if (!noBrowser) {
51
+ server.on('listening', async () => {
52
+ try {
53
+ const open = (await import('open')).default
54
+ open(`http://localhost:${port}`)
55
+ } catch {
56
+ // open is optional, just print the URL
57
+ }
58
+ })
59
+ }
60
+
61
+ // Graceful shutdown
62
+ process.on('SIGINT', () => {
63
+ console.log('\nShutting down...')
64
+ server.close()
65
+ process.exit(0)
66
+ })
67
+
68
+ process.on('SIGTERM', () => {
69
+ server.close()
70
+ process.exit(0)
71
+ })