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.
- package/.editorconfig +9 -0
- package/.github/workflows/ci.yml +59 -0
- package/.github/workflows/release.yml +75 -0
- package/.prettierignore +6 -0
- package/.prettierrc.yaml +4 -0
- package/.vscode/extensions.json +3 -0
- package/.vscode/launch.json +17 -0
- package/.vscode/settings.json +21 -0
- package/.vscode/tasks.json +22 -0
- package/.vscodeignore +11 -0
- package/CHANGELOG.md +184 -0
- package/CLAUDE.md +58 -0
- package/CONTRIBUTING.md +114 -0
- package/LICENSE +22 -0
- package/README.md +482 -0
- package/SKILL.md +237 -0
- package/dist/cli.js +8716 -0
- package/dist/extension.js +8463 -0
- package/dist/mcp-server.js +1327 -0
- package/dist/standalone-webview/icons-Dx9MGYqN.js +180 -0
- package/dist/standalone-webview/icons-Dx9MGYqN.js.map +1 -0
- package/dist/standalone-webview/index.js +85 -0
- package/dist/standalone-webview/index.js.map +1 -0
- package/dist/standalone-webview/react-vendor-DkYdDBET.js +25 -0
- package/dist/standalone-webview/react-vendor-DkYdDBET.js.map +1 -0
- package/dist/standalone-webview/style.css +1 -0
- package/dist/standalone.js +7513 -0
- package/dist/webview/icons-Dx9MGYqN.js +180 -0
- package/dist/webview/icons-Dx9MGYqN.js.map +1 -0
- package/dist/webview/index.js +85 -0
- package/dist/webview/index.js.map +1 -0
- package/dist/webview/react-vendor-DkYdDBET.js +25 -0
- package/dist/webview/react-vendor-DkYdDBET.js.map +1 -0
- package/dist/webview/style.css +1 -0
- package/docs/images/board-overview.png +0 -0
- package/docs/images/editor-view.png +0 -0
- package/docs/plans/2026-02-20-kanban-json-config-design.md +74 -0
- package/docs/plans/2026-02-20-kanban-json-config.md +690 -0
- package/eslint.config.mjs +31 -0
- package/package.json +161 -0
- package/postcss.config.js +6 -0
- package/resources/icon-light.png +0 -0
- package/resources/icon-light.svg +105 -0
- package/resources/icon.png +0 -0
- package/resources/icon.svg +105 -0
- package/resources/kanban-dark.svg +21 -0
- package/resources/kanban-light.svg +21 -0
- package/resources/kanban.svg +21 -0
- package/src/cli/index.ts +846 -0
- package/src/extension/FeatureHeaderProvider.ts +370 -0
- package/src/extension/KanbanPanel.ts +973 -0
- package/src/extension/SidebarViewProvider.ts +507 -0
- package/src/extension/featureFileUtils.ts +82 -0
- package/src/extension/index.ts +234 -0
- package/src/mcp-server/index.ts +632 -0
- package/src/sdk/KanbanSDK.ts +349 -0
- package/src/sdk/__tests__/KanbanSDK.test.ts +468 -0
- package/src/sdk/__tests__/parser.test.ts +170 -0
- package/src/sdk/fileUtils.ts +76 -0
- package/src/sdk/index.ts +6 -0
- package/src/sdk/parser.ts +70 -0
- package/src/sdk/types.ts +15 -0
- package/src/shared/config.ts +113 -0
- package/src/shared/editorTypes.ts +14 -0
- package/src/shared/types.ts +120 -0
- package/src/standalone/__tests__/server.integration.test.ts +1916 -0
- package/src/standalone/__tests__/webhooks.test.ts +357 -0
- package/src/standalone/fileUtils.ts +70 -0
- package/src/standalone/index.ts +71 -0
- package/src/standalone/server.ts +1046 -0
- package/src/standalone/webhooks.ts +135 -0
- package/src/webview/App.tsx +469 -0
- package/src/webview/assets/main.css +329 -0
- package/src/webview/assets/standalone-theme.css +130 -0
- package/src/webview/components/ColumnDialog.tsx +119 -0
- package/src/webview/components/CreateFeatureDialog.tsx +524 -0
- package/src/webview/components/DatePicker.tsx +185 -0
- package/src/webview/components/FeatureCard.tsx +186 -0
- package/src/webview/components/FeatureEditor.tsx +623 -0
- package/src/webview/components/KanbanBoard.tsx +144 -0
- package/src/webview/components/KanbanColumn.tsx +159 -0
- package/src/webview/components/MarkdownEditor.tsx +291 -0
- package/src/webview/components/PrioritySelect.tsx +39 -0
- package/src/webview/components/QuickAddInput.tsx +72 -0
- package/src/webview/components/SettingsPanel.tsx +284 -0
- package/src/webview/components/Toolbar.tsx +175 -0
- package/src/webview/components/UndoToast.tsx +70 -0
- package/src/webview/index.html +12 -0
- package/src/webview/lib/utils.ts +6 -0
- package/src/webview/main.tsx +11 -0
- package/src/webview/standalone-main.tsx +13 -0
- package/src/webview/standalone-shim.ts +132 -0
- package/src/webview/standalone.html +12 -0
- package/src/webview/store/index.ts +241 -0
- package/tailwind.config.js +53 -0
- package/tsconfig.json +22 -0
- package/vite.config.ts +36 -0
- package/vite.standalone.config.ts +62 -0
- 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
|
+
})
|