@andypai/agent-kanban 0.5.1 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -19
- package/package.json +3 -2
- package/src/__tests__/activity.test.ts +12 -0
- package/src/__tests__/api.test.ts +4 -2
- package/src/__tests__/board-utils.test.ts +16 -3
- package/src/__tests__/column-roles.test.ts +34 -0
- package/src/__tests__/commands/board.test.ts +7 -0
- package/src/__tests__/conflict.test.ts +11 -1
- package/src/__tests__/db.test.ts +70 -0
- package/src/__tests__/index.test.ts +55 -0
- package/src/__tests__/jira-cache.test.ts +56 -0
- package/src/__tests__/jira-client.test.ts +98 -1
- package/src/__tests__/jira-jql.test.ts +51 -0
- package/src/__tests__/jira-provider-mutations.test.ts +84 -59
- package/src/__tests__/jira-provider-read.test.ts +227 -20
- package/src/__tests__/mcp-server.test.ts +2 -2
- package/src/__tests__/metrics.test.ts +37 -1
- package/src/__tests__/postgres-jira-provider.test.ts +155 -0
- package/src/__tests__/postgres-local-provider.test.ts +90 -1
- package/src/__tests__/server.test.ts +126 -0
- package/src/__tests__/use-cases.test.ts +77 -0
- package/src/__tests__/webhooks.test.ts +75 -22
- package/src/api.ts +58 -36
- package/src/column-roles.ts +52 -0
- package/src/commands/board.ts +4 -4
- package/src/db.ts +145 -114
- package/src/errors.ts +1 -0
- package/src/index.ts +84 -33
- package/src/mcp/core.ts +8 -7
- package/src/metrics.ts +48 -23
- package/src/provider-runtime.ts +9 -2
- package/src/providers/index.ts +4 -1
- package/src/providers/jira-cache.ts +23 -6
- package/src/providers/jira-client.ts +70 -4
- package/src/providers/jira-core.ts +921 -0
- package/src/providers/jira-jql.ts +48 -0
- package/src/providers/jira.ts +111 -677
- package/src/providers/linear-cache.ts +5 -3
- package/src/providers/linear-core.ts +688 -0
- package/src/providers/linear.ts +70 -554
- package/src/providers/postgres-jira-cache.ts +597 -0
- package/src/providers/postgres-jira.ts +15 -1188
- package/src/providers/postgres-linear-cache.ts +500 -0
- package/src/providers/postgres-linear.ts +22 -1088
- package/src/providers/postgres-local.ts +181 -74
- package/src/providers/types.ts +71 -2
- package/src/server.ts +118 -32
- package/src/use-cases.ts +139 -0
- package/src/webhooks.ts +26 -0
- package/ui/dist/assets/index-65LcV0R7.js +40 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-CFhtfqCn.js +0 -40
package/src/db.ts
CHANGED
|
@@ -137,22 +137,31 @@ export function migrateSchema(db: Database): void {
|
|
|
137
137
|
db.run('CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project)')
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
export function seedDefaultColumns(db: Database): void {
|
|
140
|
+
export function seedDefaultColumns(db: Database, columnNames?: string[]): void {
|
|
141
141
|
const existing = db.query('SELECT COUNT(*) as count FROM columns').get() as {
|
|
142
142
|
count: number
|
|
143
143
|
}
|
|
144
144
|
if (existing.count > 0) return
|
|
145
|
+
// Honor KANBAN_DEFAULT_COLUMNS (passed through as columnNames) when provided,
|
|
146
|
+
// matching the Postgres local provider; otherwise fall back to the built-in set.
|
|
147
|
+
const columns =
|
|
148
|
+
columnNames && columnNames.length > 0
|
|
149
|
+
? columnNames.map((name, position) => ({ name, position }))
|
|
150
|
+
: DEFAULT_COLUMNS
|
|
145
151
|
const stmt = db.prepare('INSERT INTO columns (id, name, position) VALUES ($id, $name, $position)')
|
|
146
|
-
for (const col of
|
|
152
|
+
for (const col of columns) {
|
|
147
153
|
stmt.run({ $id: generateId('c'), $name: col.name, $position: col.position })
|
|
148
154
|
}
|
|
149
155
|
}
|
|
150
156
|
|
|
151
157
|
export function isInitialized(db: Database): boolean {
|
|
152
|
-
const
|
|
158
|
+
const table = db
|
|
153
159
|
.query("SELECT COUNT(*) as count FROM sqlite_master WHERE type='table' AND name='columns'")
|
|
154
160
|
.get() as { count: number }
|
|
155
|
-
|
|
161
|
+
if (table.count === 0) return false
|
|
162
|
+
|
|
163
|
+
const rows = db.query('SELECT COUNT(*) as count FROM columns').get() as { count: number }
|
|
164
|
+
return rows.count > 0
|
|
156
165
|
}
|
|
157
166
|
|
|
158
167
|
// --- Column CRUD ---
|
|
@@ -309,23 +318,25 @@ export function addTask(
|
|
|
309
318
|
.query('SELECT COALESCE(MAX(position), -1) + 1 as next FROM tasks WHERE column_id = $col')
|
|
310
319
|
.get({ $col: column.id }) as { next: number }
|
|
311
320
|
|
|
312
|
-
db.
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
321
|
+
db.transaction(() => {
|
|
322
|
+
db.query(
|
|
323
|
+
`INSERT INTO tasks (id, title, description, column_id, position, priority, assignee, project, labels, metadata)
|
|
324
|
+
VALUES ($id, $title, $desc, $col, $pos, $pri, $assignee, $project, $labels, $meta)`,
|
|
325
|
+
).run({
|
|
326
|
+
$id: id,
|
|
327
|
+
$title: title,
|
|
328
|
+
$desc: opts.description ?? '',
|
|
329
|
+
$col: column.id,
|
|
330
|
+
$pos: maxPos.next,
|
|
331
|
+
$pri: opts.priority ?? 'medium',
|
|
332
|
+
$assignee: opts.assignee ?? '',
|
|
333
|
+
$project: opts.project ?? '',
|
|
334
|
+
$labels: JSON.stringify(labels),
|
|
335
|
+
$meta: opts.metadata ?? '{}',
|
|
336
|
+
})
|
|
337
|
+
logActivity(db, id, 'created', { new_value: title })
|
|
338
|
+
enterColumn(db, id, column.id)
|
|
339
|
+
})()
|
|
329
340
|
return getTask(db, id)
|
|
330
341
|
}
|
|
331
342
|
|
|
@@ -453,53 +464,57 @@ export function updateTask(
|
|
|
453
464
|
params['$meta'] = updates.metadata
|
|
454
465
|
}
|
|
455
466
|
|
|
456
|
-
db.
|
|
467
|
+
db.transaction(() => {
|
|
468
|
+
db.query(`UPDATE tasks SET ${sets.join(', ')} WHERE id = $id`).run(params)
|
|
457
469
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
logActivity(db, id, 'updated', {
|
|
474
|
-
field: 'project',
|
|
475
|
-
old_value: existing.project || null,
|
|
476
|
-
new_value: updates.project,
|
|
477
|
-
})
|
|
478
|
-
}
|
|
479
|
-
const fieldsToLog: Array<{ key: keyof typeof updates; field: string }> = [
|
|
480
|
-
{ key: 'title', field: 'title' },
|
|
481
|
-
{ key: 'description', field: 'description' },
|
|
482
|
-
{ key: 'metadata', field: 'metadata' },
|
|
483
|
-
]
|
|
484
|
-
for (const { key, field } of fieldsToLog) {
|
|
485
|
-
if (updates[key] !== undefined && updates[key] !== existing[key as keyof TaskWithColumn]) {
|
|
470
|
+
if (updates.assignee !== undefined && updates.assignee !== existing.assignee) {
|
|
471
|
+
logActivity(db, id, 'assigned', {
|
|
472
|
+
field: 'assignee',
|
|
473
|
+
old_value: existing.assignee || null,
|
|
474
|
+
new_value: updates.assignee,
|
|
475
|
+
})
|
|
476
|
+
}
|
|
477
|
+
if (updates.priority !== undefined && updates.priority !== existing.priority) {
|
|
478
|
+
logActivity(db, id, 'prioritized', {
|
|
479
|
+
field: 'priority',
|
|
480
|
+
old_value: existing.priority,
|
|
481
|
+
new_value: updates.priority,
|
|
482
|
+
})
|
|
483
|
+
}
|
|
484
|
+
if (updates.project !== undefined && updates.project !== existing.project) {
|
|
486
485
|
logActivity(db, id, 'updated', {
|
|
487
|
-
field,
|
|
488
|
-
old_value:
|
|
489
|
-
new_value:
|
|
486
|
+
field: 'project',
|
|
487
|
+
old_value: existing.project || null,
|
|
488
|
+
new_value: updates.project,
|
|
490
489
|
})
|
|
491
490
|
}
|
|
492
|
-
|
|
491
|
+
const fieldsToLog: Array<{ key: keyof typeof updates; field: string }> = [
|
|
492
|
+
{ key: 'title', field: 'title' },
|
|
493
|
+
{ key: 'description', field: 'description' },
|
|
494
|
+
{ key: 'metadata', field: 'metadata' },
|
|
495
|
+
]
|
|
496
|
+
for (const { key, field } of fieldsToLog) {
|
|
497
|
+
if (updates[key] !== undefined && updates[key] !== existing[key as keyof TaskWithColumn]) {
|
|
498
|
+
logActivity(db, id, 'updated', {
|
|
499
|
+
field,
|
|
500
|
+
old_value: String(existing[key as keyof TaskWithColumn] ?? ''),
|
|
501
|
+
new_value: String(updates[key]),
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
})()
|
|
493
506
|
|
|
494
507
|
return getTask(db, id)
|
|
495
508
|
}
|
|
496
509
|
|
|
497
510
|
export function deleteTask(db: Database, id: string): TaskWithColumn {
|
|
498
511
|
const task = getTask(db, id)
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
512
|
+
db.transaction(() => {
|
|
513
|
+
exitColumn(db, id, task.column_id)
|
|
514
|
+
logActivity(db, id, 'deleted', { old_value: task.title })
|
|
515
|
+
db.query('DELETE FROM tasks WHERE id = $id').run({ $id: id })
|
|
516
|
+
renumberTasksInColumn(db, task.column_id)
|
|
517
|
+
})()
|
|
503
518
|
return task
|
|
504
519
|
}
|
|
505
520
|
|
|
@@ -557,19 +572,21 @@ export function addComment(
|
|
|
557
572
|
): TaskComment {
|
|
558
573
|
getTask(db, taskId)
|
|
559
574
|
const id = generateId('cm')
|
|
560
|
-
db.
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
575
|
+
db.transaction(() => {
|
|
576
|
+
db.query(
|
|
577
|
+
`INSERT INTO comments (id, task_id, body, author)
|
|
578
|
+
VALUES ($id, $task_id, $body, $author)`,
|
|
579
|
+
).run({
|
|
580
|
+
$id: id,
|
|
581
|
+
$task_id: taskId,
|
|
582
|
+
$body: body,
|
|
583
|
+
$author: author,
|
|
584
|
+
})
|
|
585
|
+
logActivity(db, taskId, 'updated', {
|
|
586
|
+
field: 'comment',
|
|
587
|
+
new_value: body,
|
|
588
|
+
})
|
|
589
|
+
})()
|
|
573
590
|
return getComment(db, taskId, id)
|
|
574
591
|
}
|
|
575
592
|
|
|
@@ -580,21 +597,23 @@ export function updateComment(
|
|
|
580
597
|
body: string,
|
|
581
598
|
): TaskComment {
|
|
582
599
|
const existing = getComment(db, taskId, commentId)
|
|
583
|
-
db.
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
600
|
+
db.transaction(() => {
|
|
601
|
+
db.query(
|
|
602
|
+
`UPDATE comments
|
|
603
|
+
SET body = $body,
|
|
604
|
+
updated_at = datetime('now')
|
|
605
|
+
WHERE id = $id AND task_id = $task_id`,
|
|
606
|
+
).run({
|
|
607
|
+
$id: commentId,
|
|
608
|
+
$task_id: taskId,
|
|
609
|
+
$body: body,
|
|
610
|
+
})
|
|
611
|
+
logActivity(db, taskId, 'updated', {
|
|
612
|
+
field: 'comment',
|
|
613
|
+
old_value: existing.body,
|
|
614
|
+
new_value: body,
|
|
615
|
+
})
|
|
616
|
+
})()
|
|
598
617
|
return getComment(db, taskId, commentId)
|
|
599
618
|
}
|
|
600
619
|
|
|
@@ -602,23 +621,29 @@ export function moveTask(db: Database, id: string, columnIdOrName: string): Task
|
|
|
602
621
|
const task = getTask(db, id)
|
|
603
622
|
const column = resolveColumn(db, columnIdOrName)
|
|
604
623
|
|
|
624
|
+
// No-op move to the same column: skip the write so we don't fragment
|
|
625
|
+
// column-time tracking (spurious exit/enter) or re-append the task.
|
|
626
|
+
if (column.id === task.column_id) return task
|
|
627
|
+
|
|
605
628
|
const maxPos = db
|
|
606
629
|
.query('SELECT COALESCE(MAX(position), -1) + 1 as next FROM tasks WHERE column_id = $col')
|
|
607
630
|
.get({ $col: column.id }) as { next: number }
|
|
608
631
|
|
|
609
632
|
const oldColumnId = task.column_id
|
|
610
|
-
db.
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
633
|
+
db.transaction(() => {
|
|
634
|
+
db.query(
|
|
635
|
+
"UPDATE tasks SET column_id = $col, position = $pos, updated_at = datetime('now'), revision = revision + 1 WHERE id = $id",
|
|
636
|
+
).run({ $col: column.id, $pos: maxPos.next, $id: id })
|
|
637
|
+
renumberTasksInColumn(db, oldColumnId)
|
|
638
|
+
|
|
639
|
+
exitColumn(db, id, oldColumnId)
|
|
640
|
+
enterColumn(db, id, column.id)
|
|
641
|
+
logActivity(db, id, 'moved', {
|
|
642
|
+
field: 'column',
|
|
643
|
+
old_value: task.column_name,
|
|
644
|
+
new_value: column.name,
|
|
645
|
+
})
|
|
646
|
+
})()
|
|
622
647
|
|
|
623
648
|
return getTask(db, id)
|
|
624
649
|
}
|
|
@@ -661,6 +686,7 @@ export function bulkMoveAll(
|
|
|
661
686
|
): { moved: number } {
|
|
662
687
|
const fromCol = resolveColumn(db, fromIdOrName)
|
|
663
688
|
const toCol = resolveColumn(db, toIdOrName)
|
|
689
|
+
if (fromCol.id === toCol.id) return { moved: 0 }
|
|
664
690
|
|
|
665
691
|
const maxPos = db
|
|
666
692
|
.query('SELECT COALESCE(MAX(position), -1) + 1 as next FROM tasks WHERE column_id = $col')
|
|
@@ -673,16 +699,19 @@ export function bulkMoveAll(
|
|
|
673
699
|
const stmt = db.prepare(
|
|
674
700
|
"UPDATE tasks SET column_id = $toCol, position = $pos, updated_at = datetime('now') WHERE id = $id",
|
|
675
701
|
)
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
702
|
+
db.transaction(() => {
|
|
703
|
+
tasks.forEach(({ id }, i) => {
|
|
704
|
+
stmt.run({ $toCol: toCol.id, $pos: maxPos.next + i, $id: id })
|
|
705
|
+
exitColumn(db, id, fromCol.id)
|
|
706
|
+
enterColumn(db, id, toCol.id)
|
|
707
|
+
logActivity(db, id, 'moved', {
|
|
708
|
+
field: 'column',
|
|
709
|
+
old_value: fromCol.name,
|
|
710
|
+
new_value: toCol.name,
|
|
711
|
+
})
|
|
684
712
|
})
|
|
685
|
-
|
|
713
|
+
renumberTasksInColumn(db, toCol.id)
|
|
714
|
+
})()
|
|
686
715
|
|
|
687
716
|
return { moved: tasks.length }
|
|
688
717
|
}
|
|
@@ -697,20 +726,22 @@ export function bulkClearDone(db: Database): { deleted: number } {
|
|
|
697
726
|
const tasks = db
|
|
698
727
|
.query('SELECT id, title FROM tasks WHERE column_id = $col')
|
|
699
728
|
.all({ $col: doneCol.id }) as { id: string; title: string }[]
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
729
|
+
db.transaction(() => {
|
|
730
|
+
for (const task of tasks) {
|
|
731
|
+
exitColumn(db, task.id, doneCol.id)
|
|
732
|
+
logActivity(db, task.id, 'deleted', { old_value: task.title })
|
|
733
|
+
}
|
|
734
|
+
db.query('DELETE FROM tasks WHERE column_id = $col').run({ $col: doneCol.id })
|
|
735
|
+
})()
|
|
705
736
|
return { deleted: tasks.length }
|
|
706
737
|
}
|
|
707
738
|
|
|
708
|
-
export function resetBoard(db: Database): void {
|
|
739
|
+
export function resetBoard(db: Database, columnNames?: string[]): void {
|
|
709
740
|
db.run('DROP TABLE IF EXISTS comments')
|
|
710
741
|
db.run('DROP TABLE IF EXISTS column_time_tracking')
|
|
711
742
|
db.run('DROP TABLE IF EXISTS activity_log')
|
|
712
743
|
db.run('DROP TABLE IF EXISTS tasks')
|
|
713
744
|
db.run('DROP TABLE IF EXISTS columns')
|
|
714
745
|
initSchema(db)
|
|
715
|
-
seedDefaultColumns(db)
|
|
746
|
+
seedDefaultColumns(db, columnNames)
|
|
716
747
|
}
|
package/src/errors.ts
CHANGED
|
@@ -4,6 +4,7 @@ export const ErrorCode = {
|
|
|
4
4
|
TASK_NOT_FOUND: 'TASK_NOT_FOUND',
|
|
5
5
|
COMMENT_NOT_FOUND: 'COMMENT_NOT_FOUND',
|
|
6
6
|
COLUMN_NOT_FOUND: 'COLUMN_NOT_FOUND',
|
|
7
|
+
NOT_FOUND: 'NOT_FOUND',
|
|
7
8
|
COLUMN_NOT_EMPTY: 'COLUMN_NOT_EMPTY',
|
|
8
9
|
COLUMN_NAME_EXISTS: 'COLUMN_NAME_EXISTS',
|
|
9
10
|
INVALID_PRIORITY: 'INVALID_PRIORITY',
|
package/src/index.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { openKanbanRuntime } from './provider-runtime'
|
|
|
15
15
|
import { trackerConfigFromEnv } from './tracker-config'
|
|
16
16
|
import type { KanbanProvider } from './providers/types'
|
|
17
17
|
import { resolvePollingSyncIntervalMs } from './sync-config'
|
|
18
|
-
import
|
|
18
|
+
import * as useCases from './use-cases'
|
|
19
19
|
|
|
20
20
|
interface ParsedArgs {
|
|
21
21
|
values: Record<string, unknown>
|
|
@@ -64,21 +64,21 @@ async function routeTask(
|
|
|
64
64
|
const title = positionals[2]
|
|
65
65
|
if (!title) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Task title is required')
|
|
66
66
|
return success(
|
|
67
|
-
await
|
|
67
|
+
await useCases.createTask(provider, {
|
|
68
68
|
title,
|
|
69
69
|
description: values.d as string | undefined,
|
|
70
70
|
column: values.c as string | undefined,
|
|
71
71
|
priority: values.p as Priority | undefined,
|
|
72
72
|
assignee: values.a as string | undefined,
|
|
73
73
|
project: values.project as string | undefined,
|
|
74
|
-
labels:
|
|
74
|
+
labels: [values.label, values.labels],
|
|
75
75
|
metadata: values.m as string | undefined,
|
|
76
76
|
}),
|
|
77
77
|
)
|
|
78
78
|
}
|
|
79
79
|
case 'list':
|
|
80
80
|
return success(
|
|
81
|
-
await
|
|
81
|
+
await useCases.listTasks(provider, {
|
|
82
82
|
column: values.c as string | undefined,
|
|
83
83
|
priority: values.p as string | undefined,
|
|
84
84
|
assignee: values.a as string | undefined,
|
|
@@ -90,13 +90,13 @@ async function routeTask(
|
|
|
90
90
|
case 'view': {
|
|
91
91
|
const id = positionals[2]
|
|
92
92
|
if (!id) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Task ID is required')
|
|
93
|
-
return success(await
|
|
93
|
+
return success(await useCases.getTask(provider, id))
|
|
94
94
|
}
|
|
95
95
|
case 'update': {
|
|
96
96
|
const id = positionals[2]
|
|
97
97
|
if (!id) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Task ID is required')
|
|
98
98
|
return success(
|
|
99
|
-
await
|
|
99
|
+
await useCases.updateTask(provider, id, {
|
|
100
100
|
title: values.title as string | undefined,
|
|
101
101
|
description: values.d as string | undefined,
|
|
102
102
|
priority: values.p as Priority | undefined,
|
|
@@ -109,7 +109,7 @@ async function routeTask(
|
|
|
109
109
|
case 'delete': {
|
|
110
110
|
const id = positionals[2]
|
|
111
111
|
if (!id) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Task ID is required')
|
|
112
|
-
return success(await
|
|
112
|
+
return success(await useCases.deleteTask(provider, id))
|
|
113
113
|
}
|
|
114
114
|
case 'move': {
|
|
115
115
|
const id = positionals[2]
|
|
@@ -117,7 +117,7 @@ async function routeTask(
|
|
|
117
117
|
if (!id || !column) {
|
|
118
118
|
throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Usage: kanban task move <id> <column>')
|
|
119
119
|
}
|
|
120
|
-
return success(await
|
|
120
|
+
return success(await useCases.moveTask(provider, id, column))
|
|
121
121
|
}
|
|
122
122
|
case 'assign': {
|
|
123
123
|
const id = positionals[2]
|
|
@@ -128,7 +128,7 @@ async function routeTask(
|
|
|
128
128
|
'Usage: kanban task assign <id> <assignee>',
|
|
129
129
|
)
|
|
130
130
|
}
|
|
131
|
-
return success(await
|
|
131
|
+
return success(await useCases.updateTask(provider, id, { assignee }))
|
|
132
132
|
}
|
|
133
133
|
case 'prioritize': {
|
|
134
134
|
const id = positionals[2]
|
|
@@ -139,7 +139,7 @@ async function routeTask(
|
|
|
139
139
|
'Usage: kanban task prioritize <id> <level>',
|
|
140
140
|
)
|
|
141
141
|
}
|
|
142
|
-
return success(await
|
|
142
|
+
return success(await useCases.updateTask(provider, id, { priority: priority as Priority }))
|
|
143
143
|
}
|
|
144
144
|
default:
|
|
145
145
|
throw new KanbanError(ErrorCode.UNKNOWN_COMMAND, `Unknown task command '${action}'`)
|
|
@@ -155,7 +155,7 @@ async function routeComment(
|
|
|
155
155
|
case 'list': {
|
|
156
156
|
const id = positionals[2]
|
|
157
157
|
if (!id) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Task ID is required')
|
|
158
|
-
return success(await
|
|
158
|
+
return success(await useCases.listComments(provider, id))
|
|
159
159
|
}
|
|
160
160
|
case 'add': {
|
|
161
161
|
const id = positionals[2]
|
|
@@ -166,7 +166,7 @@ async function routeComment(
|
|
|
166
166
|
'Usage: kanban comment add <task-id> <body>',
|
|
167
167
|
)
|
|
168
168
|
}
|
|
169
|
-
return success(await
|
|
169
|
+
return success(await useCases.addComment(provider, id, body))
|
|
170
170
|
}
|
|
171
171
|
case 'update': {
|
|
172
172
|
const id = positionals[2]
|
|
@@ -178,7 +178,7 @@ async function routeComment(
|
|
|
178
178
|
'Usage: kanban comment update <task-id> <comment-id> <body>',
|
|
179
179
|
)
|
|
180
180
|
}
|
|
181
|
-
return success(await
|
|
181
|
+
return success(await useCases.updateComment(provider, id, commentId, body))
|
|
182
182
|
}
|
|
183
183
|
default:
|
|
184
184
|
throw new KanbanError(ErrorCode.UNKNOWN_COMMAND, `Unknown comment command '${action}'`)
|
|
@@ -239,7 +239,7 @@ async function routeConfig(
|
|
|
239
239
|
): Promise<CliOutput> {
|
|
240
240
|
if (provider.type !== 'local') {
|
|
241
241
|
if (action === 'show' || action === undefined) {
|
|
242
|
-
return success(await
|
|
242
|
+
return success(await useCases.getConfig(provider))
|
|
243
243
|
}
|
|
244
244
|
unsupportedOperation('Config mutation is only available in local mode')
|
|
245
245
|
}
|
|
@@ -250,7 +250,7 @@ async function routeConfig(
|
|
|
250
250
|
switch (action) {
|
|
251
251
|
case 'show':
|
|
252
252
|
case undefined:
|
|
253
|
-
return success(await
|
|
253
|
+
return success(await useCases.getConfig(provider))
|
|
254
254
|
case 'set-member': {
|
|
255
255
|
const name = positionals[2]
|
|
256
256
|
if (!name) throw new KanbanError(ErrorCode.MISSING_ARGUMENT, 'Member name is required')
|
|
@@ -297,21 +297,22 @@ async function routeBoard(
|
|
|
297
297
|
db: Database,
|
|
298
298
|
provider: KanbanProvider,
|
|
299
299
|
action: string | undefined,
|
|
300
|
+
columnNames?: string[],
|
|
300
301
|
): Promise<CliOutput> {
|
|
301
302
|
switch (action) {
|
|
302
303
|
case 'init':
|
|
303
304
|
requireLocalProvider(provider.type, 'Board initialization')
|
|
304
|
-
return boardInit(db)
|
|
305
|
+
return boardInit(db, columnNames)
|
|
305
306
|
case 'view':
|
|
306
307
|
case undefined:
|
|
307
308
|
if (provider.type === 'local') {
|
|
308
309
|
initSchema(db)
|
|
309
|
-
seedDefaultColumns(db)
|
|
310
|
+
seedDefaultColumns(db, columnNames)
|
|
310
311
|
}
|
|
311
|
-
return success(await
|
|
312
|
+
return success(await useCases.getBoard(provider))
|
|
312
313
|
case 'reset':
|
|
313
314
|
requireLocalProvider(provider.type, 'Board reset')
|
|
314
|
-
return boardReset(db)
|
|
315
|
+
return boardReset(db, columnNames)
|
|
315
316
|
default:
|
|
316
317
|
throw new KanbanError(ErrorCode.UNKNOWN_COMMAND, `Unknown board command '${action}'`)
|
|
317
318
|
}
|
|
@@ -323,27 +324,36 @@ async function run(argv: string[]): Promise<{ output: CliOutput; exitCode: numbe
|
|
|
323
324
|
return { output: { ok: true, data: { message: HELP_TEXT } }, exitCode: 0 }
|
|
324
325
|
}
|
|
325
326
|
|
|
327
|
+
const actionRequiresExplicitInit = positionals[0] === 'board' && positionals[1] === 'init'
|
|
326
328
|
const runtime = await openKanbanRuntime({
|
|
327
329
|
dbPath: (values.db as string | undefined) ?? getDbPath(),
|
|
330
|
+
seedLocalColumns: actionRequiresExplicitInit ? false : undefined,
|
|
328
331
|
})
|
|
329
332
|
|
|
330
333
|
try {
|
|
331
|
-
const { provider, sqliteDb, dbPath } = runtime
|
|
334
|
+
const { provider, sqliteDb, dbPath, trackerConfig } = runtime
|
|
332
335
|
const group = positionals[0]
|
|
333
336
|
const action = positionals[1]
|
|
337
|
+
const defaultColumns =
|
|
338
|
+
trackerConfig.provider === 'local' ? trackerConfig.defaultColumns : undefined
|
|
334
339
|
|
|
335
340
|
if (!group) {
|
|
336
|
-
if (sqliteDb)
|
|
337
|
-
|
|
341
|
+
if (sqliteDb)
|
|
342
|
+
return {
|
|
343
|
+
output: await routeBoard(sqliteDb, provider, undefined, defaultColumns),
|
|
344
|
+
exitCode: 0,
|
|
345
|
+
}
|
|
346
|
+
return { output: success(await useCases.getBoard(provider)), exitCode: 0 }
|
|
338
347
|
}
|
|
339
348
|
|
|
340
349
|
let output: CliOutput
|
|
341
350
|
switch (group) {
|
|
342
351
|
case 'board':
|
|
343
352
|
if (sqliteDb) {
|
|
344
|
-
output = await routeBoard(sqliteDb, provider, action)
|
|
353
|
+
output = await routeBoard(sqliteDb, provider, action, defaultColumns)
|
|
345
354
|
} else {
|
|
346
|
-
if (action === 'view' || action === undefined)
|
|
355
|
+
if (action === 'view' || action === undefined)
|
|
356
|
+
output = success(await useCases.getBoard(provider))
|
|
347
357
|
else unsupportedOperation(`board ${action} is not available with KANBAN_STORAGE=postgres`)
|
|
348
358
|
}
|
|
349
359
|
break
|
|
@@ -368,7 +378,7 @@ async function run(argv: string[]): Promise<{ output: CliOutput; exitCode: numbe
|
|
|
368
378
|
output = await routeConfig(provider, dbPath, action, positionals, values)
|
|
369
379
|
} else {
|
|
370
380
|
if (action === 'show' || action === undefined)
|
|
371
|
-
output = success(await
|
|
381
|
+
output = success(await useCases.getConfig(provider))
|
|
372
382
|
else
|
|
373
383
|
unsupportedOperation(`config ${action} is not available with KANBAN_STORAGE=postgres`)
|
|
374
384
|
}
|
|
@@ -435,6 +445,8 @@ export interface ServeOptions {
|
|
|
435
445
|
port: number
|
|
436
446
|
syncIntervalMs?: number
|
|
437
447
|
tunnel: boolean
|
|
448
|
+
authToken?: string
|
|
449
|
+
allowedOrigin?: string
|
|
438
450
|
}
|
|
439
451
|
|
|
440
452
|
export function parseServeArgs(argv: string[]): ServeOptions {
|
|
@@ -445,6 +457,8 @@ export function parseServeArgs(argv: string[]): ServeOptions {
|
|
|
445
457
|
port: { type: 'string' },
|
|
446
458
|
'sync-interval-ms': { type: 'string' },
|
|
447
459
|
tunnel: { type: 'boolean', default: false },
|
|
460
|
+
token: { type: 'string' },
|
|
461
|
+
'allowed-origin': { type: 'string' },
|
|
448
462
|
},
|
|
449
463
|
strict: false,
|
|
450
464
|
allowPositionals: true,
|
|
@@ -452,6 +466,10 @@ export function parseServeArgs(argv: string[]): ServeOptions {
|
|
|
452
466
|
const port = values.port
|
|
453
467
|
? parseInt(values.port as string, 10)
|
|
454
468
|
: parseInt(process.env['PORT'] || '3000', 10)
|
|
469
|
+
// Flags win over env so a one-off `--token` can override the ambient config.
|
|
470
|
+
const authToken = (values.token as string | undefined) || process.env['KANBAN_API_TOKEN']
|
|
471
|
+
const allowedOrigin =
|
|
472
|
+
(values['allowed-origin'] as string | undefined) || process.env['KANBAN_ALLOWED_ORIGIN']
|
|
455
473
|
return {
|
|
456
474
|
db: values.db as string | undefined,
|
|
457
475
|
port,
|
|
@@ -459,6 +477,8 @@ export function parseServeArgs(argv: string[]): ServeOptions {
|
|
|
459
477
|
? { syncIntervalMs: parseSyncIntervalMs(values['sync-interval-ms'] as string) }
|
|
460
478
|
: {}),
|
|
461
479
|
tunnel: Boolean(values.tunnel),
|
|
480
|
+
...(authToken ? { authToken } : {}),
|
|
481
|
+
...(allowedOrigin ? { allowedOrigin } : {}),
|
|
462
482
|
}
|
|
463
483
|
}
|
|
464
484
|
|
|
@@ -495,6 +515,16 @@ if (import.meta.main) {
|
|
|
495
515
|
} else if (argv[0] === 'serve') {
|
|
496
516
|
const opts = parseServeArgs(argv)
|
|
497
517
|
|
|
518
|
+
// A tunnel exposes the dashboard publicly, so refuse to start one without a
|
|
519
|
+
// token. Plain localhost serve stays open for backward compatibility.
|
|
520
|
+
if (opts.tunnel && !opts.authToken) {
|
|
521
|
+
console.error(
|
|
522
|
+
'Refusing to start a public tunnel without an API token. ' +
|
|
523
|
+
'Set KANBAN_API_TOKEN or pass --token <token>.',
|
|
524
|
+
)
|
|
525
|
+
process.exit(1)
|
|
526
|
+
}
|
|
527
|
+
|
|
498
528
|
const runtime = await openKanbanRuntime({
|
|
499
529
|
dbPath: opts.db ?? getDbPath(),
|
|
500
530
|
...(opts.syncIntervalMs !== undefined
|
|
@@ -507,22 +537,43 @@ if (import.meta.main) {
|
|
|
507
537
|
: {}),
|
|
508
538
|
})
|
|
509
539
|
const { startServer } = await import('./server')
|
|
510
|
-
startServer(runtime.provider, opts.port, {
|
|
540
|
+
const server = startServer(runtime.provider, opts.port, {
|
|
541
|
+
syncIntervalMs: runtime.syncIntervalMs,
|
|
542
|
+
...(opts.authToken ? { authToken: opts.authToken } : {}),
|
|
543
|
+
...(opts.allowedOrigin ? { allowedOrigin: opts.allowedOrigin } : {}),
|
|
544
|
+
})
|
|
511
545
|
|
|
546
|
+
let tunnelHandle: { stop: () => void } | null = null
|
|
512
547
|
if (opts.tunnel) {
|
|
513
548
|
const { startCloudflareTunnel } = await import('./tunnel')
|
|
514
549
|
try {
|
|
515
|
-
|
|
516
|
-
const shutdown = (): void => {
|
|
517
|
-
handle.stop()
|
|
518
|
-
process.exit(0)
|
|
519
|
-
}
|
|
520
|
-
process.on('SIGINT', shutdown)
|
|
521
|
-
process.on('SIGTERM', shutdown)
|
|
550
|
+
tunnelHandle = startCloudflareTunnel(opts.port)
|
|
522
551
|
} catch {
|
|
523
552
|
// startCloudflareTunnel already logged a friendly message
|
|
524
553
|
}
|
|
525
554
|
}
|
|
555
|
+
|
|
556
|
+
// Shut down gracefully on signals regardless of tunnel mode: stop the tunnel
|
|
557
|
+
// and server (which clears the background-sync timer), then close the runtime
|
|
558
|
+
// so the Postgres pool / SQLite handle is released.
|
|
559
|
+
let shuttingDown = false
|
|
560
|
+
const shutdown = async (): Promise<void> => {
|
|
561
|
+
if (shuttingDown) return
|
|
562
|
+
shuttingDown = true
|
|
563
|
+
// try/finally so a cleanup failure (e.g. runtime.close() rejecting) still
|
|
564
|
+
// exits the process instead of leaving an unhandled rejection and a hang.
|
|
565
|
+
try {
|
|
566
|
+
tunnelHandle?.stop()
|
|
567
|
+
server.stop()
|
|
568
|
+
await runtime.close()
|
|
569
|
+
} catch (err) {
|
|
570
|
+
console.error('Error during shutdown:', err instanceof Error ? err.message : err)
|
|
571
|
+
} finally {
|
|
572
|
+
process.exit(0)
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
process.on('SIGINT', () => void shutdown())
|
|
576
|
+
process.on('SIGTERM', () => void shutdown())
|
|
526
577
|
} else {
|
|
527
578
|
let exitCode = 0
|
|
528
579
|
const pretty = argv.includes('--pretty')
|