@andypai/agent-kanban 0.6.0 → 0.6.2

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 (48) hide show
  1. package/README.md +42 -19
  2. package/package.json +3 -2
  3. package/src/__tests__/activity.test.ts +12 -0
  4. package/src/__tests__/api.test.ts +4 -2
  5. package/src/__tests__/board-utils.test.ts +16 -3
  6. package/src/__tests__/column-roles.test.ts +34 -0
  7. package/src/__tests__/commands/board.test.ts +7 -0
  8. package/src/__tests__/conflict.test.ts +11 -1
  9. package/src/__tests__/db.test.ts +70 -0
  10. package/src/__tests__/index.test.ts +55 -0
  11. package/src/__tests__/jira-cache.test.ts +56 -0
  12. package/src/__tests__/jira-jql.test.ts +51 -0
  13. package/src/__tests__/jira-provider-read.test.ts +50 -0
  14. package/src/__tests__/mcp-server.test.ts +2 -2
  15. package/src/__tests__/metrics.test.ts +37 -1
  16. package/src/__tests__/postgres-local-provider.test.ts +90 -1
  17. package/src/__tests__/server.test.ts +126 -0
  18. package/src/__tests__/use-cases.test.ts +77 -0
  19. package/src/__tests__/webhooks.test.ts +91 -22
  20. package/src/api.ts +58 -36
  21. package/src/column-roles.ts +52 -0
  22. package/src/commands/board.ts +4 -4
  23. package/src/db.ts +145 -114
  24. package/src/errors.ts +1 -0
  25. package/src/index.ts +84 -33
  26. package/src/mcp/core.ts +8 -7
  27. package/src/metrics.ts +48 -23
  28. package/src/provider-runtime.ts +9 -2
  29. package/src/providers/index.ts +4 -1
  30. package/src/providers/jira-cache.ts +23 -6
  31. package/src/providers/jira-core.ts +926 -0
  32. package/src/providers/jira-jql.ts +48 -0
  33. package/src/providers/jira.ts +111 -759
  34. package/src/providers/linear-cache.ts +5 -3
  35. package/src/providers/linear-core.ts +693 -0
  36. package/src/providers/linear.ts +70 -554
  37. package/src/providers/postgres-jira-cache.ts +597 -0
  38. package/src/providers/postgres-jira.ts +15 -1312
  39. package/src/providers/postgres-linear-cache.ts +500 -0
  40. package/src/providers/postgres-linear.ts +22 -1088
  41. package/src/providers/postgres-local.ts +181 -74
  42. package/src/providers/types.ts +71 -2
  43. package/src/server.ts +118 -32
  44. package/src/use-cases.ts +139 -0
  45. package/src/webhooks.ts +19 -0
  46. package/ui/dist/assets/index-65LcV0R7.js +40 -0
  47. package/ui/dist/index.html +1 -1
  48. 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 DEFAULT_COLUMNS) {
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 result = db
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
- return result.count > 0
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.query(
313
- `INSERT INTO tasks (id, title, description, column_id, position, priority, assignee, project, labels, metadata)
314
- VALUES ($id, $title, $desc, $col, $pos, $pri, $assignee, $project, $labels, $meta)`,
315
- ).run({
316
- $id: id,
317
- $title: title,
318
- $desc: opts.description ?? '',
319
- $col: column.id,
320
- $pos: maxPos.next,
321
- $pri: opts.priority ?? 'medium',
322
- $assignee: opts.assignee ?? '',
323
- $project: opts.project ?? '',
324
- $labels: JSON.stringify(labels),
325
- $meta: opts.metadata ?? '{}',
326
- })
327
- logActivity(db, id, 'created', { new_value: title })
328
- enterColumn(db, id, column.id)
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.query(`UPDATE tasks SET ${sets.join(', ')} WHERE id = $id`).run(params)
467
+ db.transaction(() => {
468
+ db.query(`UPDATE tasks SET ${sets.join(', ')} WHERE id = $id`).run(params)
457
469
 
458
- if (updates.assignee !== undefined && updates.assignee !== existing.assignee) {
459
- logActivity(db, id, 'assigned', {
460
- field: 'assignee',
461
- old_value: existing.assignee || null,
462
- new_value: updates.assignee,
463
- })
464
- }
465
- if (updates.priority !== undefined && updates.priority !== existing.priority) {
466
- logActivity(db, id, 'prioritized', {
467
- field: 'priority',
468
- old_value: existing.priority,
469
- new_value: updates.priority,
470
- })
471
- }
472
- if (updates.project !== undefined && updates.project !== existing.project) {
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: String(existing[key as keyof TaskWithColumn] ?? ''),
489
- new_value: String(updates[key]),
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
- exitColumn(db, id, task.column_id)
500
- logActivity(db, id, 'deleted', { old_value: task.title })
501
- db.query('DELETE FROM tasks WHERE id = $id').run({ $id: id })
502
- renumberTasksInColumn(db, task.column_id)
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.query(
561
- `INSERT INTO comments (id, task_id, body, author)
562
- VALUES ($id, $task_id, $body, $author)`,
563
- ).run({
564
- $id: id,
565
- $task_id: taskId,
566
- $body: body,
567
- $author: author,
568
- })
569
- logActivity(db, taskId, 'updated', {
570
- field: 'comment',
571
- new_value: body,
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.query(
584
- `UPDATE comments
585
- SET body = $body,
586
- updated_at = datetime('now')
587
- WHERE id = $id AND task_id = $task_id`,
588
- ).run({
589
- $id: commentId,
590
- $task_id: taskId,
591
- $body: body,
592
- })
593
- logActivity(db, taskId, 'updated', {
594
- field: 'comment',
595
- old_value: existing.body,
596
- new_value: body,
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.query(
611
- "UPDATE tasks SET column_id = $col, position = $pos, updated_at = datetime('now'), revision = revision + 1 WHERE id = $id",
612
- ).run({ $col: column.id, $pos: maxPos.next, $id: id })
613
- renumberTasksInColumn(db, oldColumnId)
614
-
615
- exitColumn(db, id, oldColumnId)
616
- enterColumn(db, id, column.id)
617
- logActivity(db, id, 'moved', {
618
- field: 'column',
619
- old_value: task.column_name,
620
- new_value: column.name,
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
- tasks.forEach(({ id }, i) => {
677
- stmt.run({ $toCol: toCol.id, $pos: maxPos.next + i, $id: id })
678
- exitColumn(db, id, fromCol.id)
679
- enterColumn(db, id, toCol.id)
680
- logActivity(db, id, 'moved', {
681
- field: 'column',
682
- old_value: fromCol.name,
683
- new_value: toCol.name,
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
- for (const task of tasks) {
701
- exitColumn(db, task.id, doneCol.id)
702
- logActivity(db, task.id, 'deleted', { old_value: task.title })
703
- }
704
- db.query('DELETE FROM tasks WHERE column_id = $col').run({ $col: doneCol.id })
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 { normalizeLabels } from './labels'
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 provider.createTask({
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: normalizeLabels([values.label, values.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 provider.listTasks({
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 provider.getTask(id))
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 provider.updateTask(id, {
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 provider.deleteTask(id))
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 provider.moveTask(id, column))
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 provider.updateTask(id, { assignee }))
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 provider.updateTask(id, { priority: priority as Priority }))
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 provider.listComments(id))
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 provider.comment(id, body))
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 provider.updateComment(id, commentId, body))
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 provider.getConfig())
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 provider.getConfig())
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 provider.getBoard())
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) return { output: await routeBoard(sqliteDb, provider, undefined), exitCode: 0 }
337
- return { output: success(await provider.getBoard()), exitCode: 0 }
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) output = success(await provider.getBoard())
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 provider.getConfig())
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, { syncIntervalMs: runtime.syncIntervalMs })
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
- const handle = startCloudflareTunnel(opts.port)
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')