@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/README.md CHANGED
@@ -62,23 +62,25 @@ Running `kanban` with no arguments is equivalent to `kanban board view`.
62
62
 
63
63
  All operations route through a provider backend. Set `KANBAN_PROVIDER` to choose one.
64
64
 
65
- | Variable | Default | Description |
66
- | ---------------------------- | ------------- | ------------------------------------------------------------------------ |
67
- | `KANBAN_PROVIDER` | `local` | `local`, `linear`, or `jira` |
68
- | `KANBAN_STORAGE` | `sqlite` | `sqlite` or `postgres` |
69
- | `KANBAN_DATABASE_URL` | — | Required when `KANBAN_STORAGE=postgres` |
70
- | `KANBAN_DB_PATH` | auto-resolved | SQLite database path |
71
- | `KANBAN_DEFAULT_COLUMNS` | — | Optional bootstrap column names for local/Postgres caches |
72
- | `KANBAN_DEFAULT_TASK_COLUMN` | — | Optional created-task column override for local/Postgres caches |
73
- | `KANBAN_SYNC_INTERVAL_MS` | `30000` | Polling sync interval for remote providers; integer milliseconds >= 1000 |
74
- | `LINEAR_API_KEY` | — | Required when `KANBAN_PROVIDER=linear` |
75
- | `LINEAR_TEAM_ID` | — | Required when `KANBAN_PROVIDER=linear` |
76
- | `JIRA_BASE_URL` | — | Required when `KANBAN_PROVIDER=jira` (e.g. `https://acme.atlassian.net`) |
77
- | `JIRA_EMAIL` | — | Required when `KANBAN_PROVIDER=jira` (Atlassian account email) |
78
- | `JIRA_API_TOKEN` | — | Required when `KANBAN_PROVIDER=jira` (Atlassian API token) |
79
- | `JIRA_PROJECT_KEY` | — | Required when `KANBAN_PROVIDER=jira` (e.g. `ENG`) |
80
- | `JIRA_BOARD_ID` | — | Optional when `KANBAN_PROVIDER=jira` (Agile board id for column order) |
81
- | `JIRA_ISSUE_TYPE` | `Task` | Optional when `KANBAN_PROVIDER=jira` (default issue type for new tasks) |
65
+ | Variable | Default | Description |
66
+ | ---------------------------- | ------------- | ------------------------------------------------------------------------------------------- |
67
+ | `KANBAN_PROVIDER` | `local` | `local`, `linear`, or `jira` |
68
+ | `KANBAN_STORAGE` | `sqlite` | `sqlite` or `postgres` |
69
+ | `KANBAN_DATABASE_URL` | — | Required when `KANBAN_STORAGE=postgres` |
70
+ | `KANBAN_DB_PATH` | auto-resolved | SQLite database path |
71
+ | `KANBAN_DEFAULT_COLUMNS` | — | Optional bootstrap column names for local/Postgres caches |
72
+ | `KANBAN_DEFAULT_TASK_COLUMN` | — | Optional created-task column override for local/Postgres caches |
73
+ | `KANBAN_SYNC_INTERVAL_MS` | `30000` | Polling sync interval for remote providers; integer milliseconds >= 1000 |
74
+ | `KANBAN_API_TOKEN` | — | When set, `kanban serve` requires `Authorization: Bearer <token>` (required for `--tunnel`) |
75
+ | `KANBAN_ALLOWED_ORIGIN` | — | Allowed CORS origin for `kanban serve`; unset means same-origin only |
76
+ | `LINEAR_API_KEY` | — | Required when `KANBAN_PROVIDER=linear` |
77
+ | `LINEAR_TEAM_ID` | — | Required when `KANBAN_PROVIDER=linear` |
78
+ | `JIRA_BASE_URL` | — | Required when `KANBAN_PROVIDER=jira` (e.g. `https://acme.atlassian.net`) |
79
+ | `JIRA_EMAIL` | — | Required when `KANBAN_PROVIDER=jira` (Atlassian account email) |
80
+ | `JIRA_API_TOKEN` | — | Required when `KANBAN_PROVIDER=jira` (Atlassian API token) |
81
+ | `JIRA_PROJECT_KEY` | | Required when `KANBAN_PROVIDER=jira` (e.g. `ENG`) |
82
+ | `JIRA_BOARD_ID` | — | Optional when `KANBAN_PROVIDER=jira` (Agile board id for column order) |
83
+ | `JIRA_ISSUE_TYPE` | `Task` | Optional when `KANBAN_PROVIDER=jira` (default issue type for new tasks) |
82
84
 
83
85
  When `KANBAN_STORAGE=sqlite` and `KANBAN_DB_PATH` is unset, the local provider
84
86
  resolves the database in this order:
@@ -252,9 +254,30 @@ Default columns: `recurring`, `backlog`, `in-progress`, `review`, `done`.
252
254
  kanban serve # default port 3000
253
255
  kanban serve --port 8080
254
256
  kanban serve --sync-interval-ms 300000
255
- kanban serve --tunnel # optional public URL for webhook testing
257
+ kanban serve --token "$(openssl rand -hex 32)" # require an API token
258
+ kanban serve --tunnel --token "$SECRET" # public URL (token required)
256
259
  ```
257
260
 
261
+ #### Authentication & CORS
262
+
263
+ By default `serve` binds locally and leaves the API open for backward
264
+ compatibility. To require authentication, set a token via `--token` or the
265
+ `KANBAN_API_TOKEN` env var. When a token is set:
266
+
267
+ - All `/api/*` routes (reads and mutations) and the `/ws` upgrade require
268
+ `Authorization: Bearer <token>`. The WebSocket, which can't send headers,
269
+ accepts `?token=<token>` instead.
270
+ - `/api/health` stays public (liveness probe).
271
+ - `/api/webhooks/*` are **not** covered by this token — they authenticate with
272
+ the provider webhook secret (e.g. `JIRA_WEBHOOK_SECRET`).
273
+ - The bundled UI picks the token up once from `?token=` / `#token=` and stores
274
+ it in `localStorage`, so you can open `https://<host>/?token=<token>`.
275
+
276
+ `--tunnel` exposes the dashboard publicly, so it **refuses to start without a
277
+ token**. CORS is off by default (same-origin); set `KANBAN_ALLOWED_ORIGIN` (or
278
+ `--allowed-origin`) to allow a specific cross-origin browser client. CORS is
279
+ origin hygiene, not an auth control — the token is the security boundary.
280
+
258
281
  ### mcp
259
282
 
260
283
  ```bash
@@ -301,7 +324,7 @@ In Linear mode the dashboard hides unsupported actions and shows Linear issue id
301
324
  Starts a Bun HTTP server with:
302
325
 
303
326
  - **REST API** at `/api/*` — board, tasks, task comments, bootstrap/provider metadata, activity, metrics, config, and webhook endpoints
304
- - **WebSocket** at `/ws` — push notifications on board mutations (clients receive `task:upsert`, `task:delete`, or a fallback `refresh` event)
327
+ - **WebSocket** at `/ws` — push notifications on board mutations (clients receive `task:upsert` with `{ task, columnId }`, `task:delete` with `{ id }`, or a fallback `refresh` event)
305
328
  - **Static UI** served from `ui/dist/` (build with `bun run build:ui` or `bun run ui:build`)
306
329
  - **Health check** at `/api/health` — cheap process liveness only
307
330
  - **Readiness check** at `/api/ready` — reports whether the cache has warmed at least once
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andypai/agent-kanban",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Agent-friendly kanban board CLI. Manage tasks via bash commands, parse structured JSON output.",
5
5
  "homepage": "https://github.com/abpai/agent-kanban#readme",
6
6
  "repository": {
@@ -42,7 +42,8 @@
42
42
  "lint": "bunx eslint .",
43
43
  "format": "bunx prettier --write .",
44
44
  "typecheck": "bunx tsc --noEmit",
45
- "check": "bun run lint && bun run typecheck",
45
+ "ui:typecheck": "cd ui && bunx tsc --noEmit",
46
+ "check": "bun run lint && bun run typecheck && bun run ui:typecheck",
46
47
  "test": "bun test",
47
48
  "test:watch": "bun test --watch",
48
49
  "seed:test": "bun --env-file=.env.test scripts/seed-test-db.ts",
@@ -90,6 +90,18 @@ describe('activity logging', () => {
90
90
  expect(moves).toHaveLength(2)
91
91
  })
92
92
 
93
+ test('bulkMoveAll to the same column does not add moved activity or tracking rows', () => {
94
+ const task = addTask(db, 'A', { column: 'recurring' })
95
+
96
+ bulkMoveAll(db, 'recurring', 'recurring')
97
+
98
+ const activities = listActivity(db)
99
+ expect(activities.filter((a) => a.action === 'moved')).toHaveLength(0)
100
+ const entries = getColumnTimeEntries(task.id)
101
+ expect(entries).toHaveLength(1)
102
+ expect(entries[0]!.exited_at).toBeNull()
103
+ })
104
+
93
105
  test('bulkClearDone logs deleted activity for each task', () => {
94
106
  addTask(db, 'Done A', { column: 'done' })
95
107
  addTask(db, 'Done B', { column: 'done' })
@@ -132,7 +132,8 @@ describe('handleRequest', () => {
132
132
  expect(result.event?.type).toBe('task:upsert')
133
133
  if (result.event?.type !== 'task:upsert') throw new Error('unreachable')
134
134
  expect(result.event.task.title).toBe('Optimistic')
135
- expect(result.event.columnName.toLowerCase()).toBe('backlog')
135
+ expect('columnId' in result.event).toBe(true)
136
+ expect('columnName' in result.event).toBe(false)
136
137
  })
137
138
 
138
139
  test('emits task:upsert event on move across columns', async () => {
@@ -148,7 +149,8 @@ describe('handleRequest', () => {
148
149
  expect(result.event?.type).toBe('task:upsert')
149
150
  if (result.event?.type !== 'task:upsert') throw new Error('unreachable')
150
151
  expect(result.event.task.id).toBe(task.id)
151
- expect(result.event.columnName.toLowerCase()).toBe('in-progress')
152
+ expect('columnId' in result.event).toBe(true)
153
+ expect('columnName' in result.event).toBe(false)
152
154
  })
153
155
 
154
156
  test('emits task:delete event on delete', async () => {
@@ -95,11 +95,24 @@ describe('boardUtils', () => {
95
95
  const nextBoard = upsertTaskInColumn(
96
96
  board,
97
97
  makeTask({ id: 't-1', title: 'First edited', column_id: 'c-backlog', position: 0 }),
98
- 'backlog',
98
+ 'c-backlog',
99
99
  )
100
100
 
101
- expect(nextBoard.columns[0]!.tasks.map((task) => task.id)).toEqual(['t-1', 'tmp-1', 't-2'])
102
- expect(nextBoard.columns[0]!.tasks[0]!.title).toBe('First edited')
101
+ expect(nextBoard).not.toBeNull()
102
+ expect(nextBoard!.columns[0]!.tasks.map((task) => task.id)).toEqual(['t-1', 'tmp-1', 't-2'])
103
+ expect(nextBoard!.columns[0]!.tasks[0]!.title).toBe('First edited')
104
+ })
105
+
106
+ test('upsertTaskInColumn returns null when the target column is not on the board', () => {
107
+ const board = makeBoard()
108
+
109
+ const nextBoard = upsertTaskInColumn(
110
+ board,
111
+ makeTask({ id: 't-9', title: 'Unknown column', column_id: 'status:10001', position: 0 }),
112
+ 'status:10001',
113
+ )
114
+
115
+ expect(nextBoard).toBeNull()
103
116
  })
104
117
 
105
118
  test('moveTaskInBoard accepts a column id when column names repeat', () => {
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { selectDoneColumnIds, selectInProgressColumnIds } from '../column-roles'
3
+
4
+ const cols = (names: string[]) => names.map((name, i) => ({ id: `c${i}`, name, position: i }))
5
+
6
+ describe('selectDoneColumnIds', () => {
7
+ test('matches done synonyms regardless of case/separators', () => {
8
+ const columns = cols(['Backlog', 'In Progress', 'Done'])
9
+ expect(selectDoneColumnIds(columns)).toEqual(['c2'])
10
+ expect(selectDoneColumnIds(cols(['todo', 'completed']))).toEqual(['c1'])
11
+ expect(selectDoneColumnIds(cols(['Open', 'Merged']))).toEqual(['c1'])
12
+ })
13
+
14
+ test('falls back to the terminal column when no name matches', () => {
15
+ // Custom names with no recognizable "done" -> last column by position.
16
+ expect(selectDoneColumnIds(cols(['Todo', 'Doing', 'Shipping Soon']))).toEqual(['c2'])
17
+ })
18
+
19
+ test('returns empty for no columns', () => {
20
+ expect(selectDoneColumnIds([])).toEqual([])
21
+ })
22
+ })
23
+
24
+ describe('selectInProgressColumnIds', () => {
25
+ test('matches in-progress synonyms regardless of case/separators', () => {
26
+ expect(selectInProgressColumnIds(cols(['Todo', 'In Progress', 'Done']))).toEqual(['c1'])
27
+ expect(selectInProgressColumnIds(cols(['in-progress']))).toEqual(['c0'])
28
+ expect(selectInProgressColumnIds(cols(['WIP', 'Doing']))).toEqual(['c0', 'c1'])
29
+ })
30
+
31
+ test('returns empty when nothing matches (no positional fallback)', () => {
32
+ expect(selectInProgressColumnIds(cols(['Backlog', 'Done']))).toEqual([])
33
+ })
34
+ })
@@ -17,6 +17,13 @@ describe('boardInit', () => {
17
17
  expect(result.ok).toBe(true)
18
18
  })
19
19
 
20
+ test('initializes when the schema exists without seeded columns', () => {
21
+ initSchema(db)
22
+ const result = boardInit(db)
23
+ expect(result.ok).toBe(true)
24
+ expect(getBoardView(db).columns).toHaveLength(5)
25
+ })
26
+
20
27
  test('throws if already initialized', () => {
21
28
  initSchema(db)
22
29
  seedDefaultColumns(db)
@@ -1,6 +1,6 @@
1
1
  import { beforeEach, describe, expect, test } from 'bun:test'
2
2
  import { Database } from 'bun:sqlite'
3
- import { initSchema, seedDefaultColumns } from '../db'
3
+ import { initSchema, seedDefaultColumns, resolveColumn } from '../db'
4
4
  import { LocalProvider } from '../providers/local'
5
5
  import { ErrorCode, KanbanError } from '../errors'
6
6
 
@@ -61,4 +61,14 @@ describe('local provider conflict detection', () => {
61
61
  const v2 = await provider.updateTask(created.id, { title: 'b' })
62
62
  expect(v2.version).toBe('2')
63
63
  })
64
+
65
+ // moveTask(id, column) intentionally has no expectedVersion parameter, so moves
66
+ // are last-write-wins by design (drag-and-drop has no loaded version to check).
67
+ // This locks in that contract so a move is never rejected for a stale version.
68
+ test('moveTask has no expectedVersion contract (move after an update still succeeds)', async () => {
69
+ const created = await provider.createTask({ title: 'T1', column: 'backlog' })
70
+ await provider.updateTask(created.id, { title: 'bumped' })
71
+ const moved = await provider.moveTask(created.id, 'in-progress')
72
+ expect(moved.column_id).toBe(resolveColumn(db, 'in-progress').id)
73
+ })
64
74
  })
@@ -25,6 +25,9 @@ import {
25
25
  bulkClearDone,
26
26
  resetBoard,
27
27
  migrateSchema,
28
+ addComment,
29
+ updateComment,
30
+ listComments,
28
31
  } from '../db'
29
32
  import { KanbanError } from '../errors'
30
33
 
@@ -79,6 +82,27 @@ describe('schema', () => {
79
82
  expect(listColumns(db)).toHaveLength(5)
80
83
  })
81
84
 
85
+ test('seedDefaultColumns honors provided column names (KANBAN_DEFAULT_COLUMNS)', () => {
86
+ const fresh = new Database(':memory:')
87
+ initSchema(fresh)
88
+ seedDefaultColumns(fresh, ['Todo', 'In Progress', 'Done'])
89
+ const cols = listColumns(fresh)
90
+ expect(cols.map((c) => c.name)).toEqual(['Todo', 'In Progress', 'Done'])
91
+ expect(cols.map((c) => c.position)).toEqual([0, 1, 2])
92
+ })
93
+
94
+ test('seedDefaultColumns falls back to defaults for an empty list', () => {
95
+ const fresh = new Database(':memory:')
96
+ initSchema(fresh)
97
+ seedDefaultColumns(fresh, [])
98
+ expect(listColumns(fresh)).toHaveLength(5)
99
+ })
100
+
101
+ test('resetBoard reseeds with provided column names', () => {
102
+ resetBoard(db, ['Alpha', 'Beta'])
103
+ expect(listColumns(db).map((c) => c.name)).toEqual(['Alpha', 'Beta'])
104
+ })
105
+
82
106
  test('migrateSchema adds project and labels columns to legacy tasks table', () => {
83
107
  const legacy = new Database(':memory:')
84
108
  legacy.run(
@@ -308,6 +332,16 @@ describe('bulk operations', () => {
308
332
  expect(listTasks(db, { column: 'recurring' })).toHaveLength(0)
309
333
  })
310
334
 
335
+ test('bulkMoveAll to the same column is a no-op', () => {
336
+ const a = addTask(db, 'A', { column: 'recurring' })
337
+ const b = addTask(db, 'B', { column: 'recurring' })
338
+
339
+ const result = bulkMoveAll(db, 'recurring', 'recurring')
340
+
341
+ expect(result.moved).toBe(0)
342
+ expect(listTasks(db, { column: 'recurring' }).map((task) => task.id)).toEqual([a.id, b.id])
343
+ })
344
+
311
345
  test('bulkClearDone removes tasks in done column', () => {
312
346
  addTask(db, 'Finished', { column: 'done' })
313
347
  addTask(db, 'Also done', { column: 'done' })
@@ -326,3 +360,39 @@ describe('resetBoard', () => {
326
360
  expect(listTasks(db)).toHaveLength(0)
327
361
  })
328
362
  })
363
+
364
+ describe('comment atomicity', () => {
365
+ const countActivity = (taskId: string): number =>
366
+ (
367
+ db
368
+ .query('SELECT COUNT(*) AS count FROM activity_log WHERE task_id = $task_id')
369
+ .get({ $task_id: taskId }) as { count: number }
370
+ ).count
371
+
372
+ test('addComment writes the comment and its activity entry together', () => {
373
+ const task = addTask(db, 'Has comments')
374
+ const before = countActivity(task.id)
375
+ addComment(db, task.id, 'hello')
376
+ expect(listComments(db, task.id)).toHaveLength(1)
377
+ expect(countActivity(task.id)).toBe(before + 1)
378
+ })
379
+
380
+ test('addComment rolls back the comment if the activity write fails', () => {
381
+ const task = addTask(db, 'Atomic add')
382
+ // Force the second write (logActivity) to fail mid-transaction.
383
+ db.run('DROP TABLE activity_log')
384
+ expect(() => addComment(db, task.id, 'orphan?')).toThrow()
385
+ // The comment INSERT must not survive the failed transaction.
386
+ expect(listComments(db, task.id)).toHaveLength(0)
387
+ })
388
+
389
+ test('updateComment rolls back the body change if the activity write fails', () => {
390
+ const task = addTask(db, 'Atomic update')
391
+ const comment = addComment(db, task.id, 'original')
392
+ db.run('DROP TABLE activity_log')
393
+ expect(() => updateComment(db, task.id, comment.id, 'changed')).toThrow()
394
+ // The body must remain unchanged after the rolled-back transaction.
395
+ const [persisted] = listComments(db, task.id)
396
+ expect(persisted?.body).toBe('original')
397
+ })
398
+ })
@@ -47,7 +47,11 @@ async function expectKanbanError(
47
47
  describe('parseServeArgs', () => {
48
48
  test('defaults: no tunnel, port from PORT env or 3000', () => {
49
49
  const prev = process.env['PORT']
50
+ const prevToken = process.env['KANBAN_API_TOKEN']
51
+ const prevOrigin = process.env['KANBAN_ALLOWED_ORIGIN']
50
52
  delete process.env['PORT']
53
+ delete process.env['KANBAN_API_TOKEN']
54
+ delete process.env['KANBAN_ALLOWED_ORIGIN']
51
55
  try {
52
56
  expect(parseServeArgs(['serve'])).toEqual({ db: undefined, port: 3000, tunnel: false })
53
57
  process.env['PORT'] = '4001'
@@ -55,6 +59,10 @@ describe('parseServeArgs', () => {
55
59
  } finally {
56
60
  if (prev === undefined) delete process.env['PORT']
57
61
  else process.env['PORT'] = prev
62
+ if (prevToken === undefined) delete process.env['KANBAN_API_TOKEN']
63
+ else process.env['KANBAN_API_TOKEN'] = prevToken
64
+ if (prevOrigin === undefined) delete process.env['KANBAN_ALLOWED_ORIGIN']
65
+ else process.env['KANBAN_ALLOWED_ORIGIN'] = prevOrigin
58
66
  }
59
67
  })
60
68
 
@@ -82,9 +90,56 @@ describe('parseServeArgs', () => {
82
90
  expect(() => parseServeArgs(['serve', '--sync-interval-ms', raw])).toThrow(KanbanError)
83
91
  }
84
92
  })
93
+
94
+ test('--token / --allowed-origin flags and env are parsed; flags win over env', () => {
95
+ const prevToken = process.env['KANBAN_API_TOKEN']
96
+ const prevOrigin = process.env['KANBAN_ALLOWED_ORIGIN']
97
+ delete process.env['KANBAN_API_TOKEN']
98
+ delete process.env['KANBAN_ALLOWED_ORIGIN']
99
+ try {
100
+ const flags = parseServeArgs([
101
+ 'serve',
102
+ '--token',
103
+ 'flag-token',
104
+ '--allowed-origin',
105
+ 'https://flag.example',
106
+ ])
107
+ expect(flags.authToken).toBe('flag-token')
108
+ expect(flags.allowedOrigin).toBe('https://flag.example')
109
+
110
+ process.env['KANBAN_API_TOKEN'] = 'env-token'
111
+ process.env['KANBAN_ALLOWED_ORIGIN'] = 'https://env.example'
112
+ expect(parseServeArgs(['serve']).authToken).toBe('env-token')
113
+ expect(parseServeArgs(['serve']).allowedOrigin).toBe('https://env.example')
114
+ // Flag overrides env.
115
+ expect(parseServeArgs(['serve', '--token', 'flag-token']).authToken).toBe('flag-token')
116
+ } finally {
117
+ if (prevToken === undefined) delete process.env['KANBAN_API_TOKEN']
118
+ else process.env['KANBAN_API_TOKEN'] = prevToken
119
+ if (prevOrigin === undefined) delete process.env['KANBAN_ALLOWED_ORIGIN']
120
+ else process.env['KANBAN_ALLOWED_ORIGIN'] = prevOrigin
121
+ }
122
+ })
85
123
  })
86
124
 
87
125
  describe('run', () => {
126
+ test('initializes a fresh board through the CLI path', async () => {
127
+ await withTempDb(async (dbPath) => {
128
+ const result = expectOk<{ message: string }>(await run(['--db', dbPath, 'board', 'init']))
129
+ expect(result.message).toContain('Board initialized')
130
+
131
+ const db = new Database(dbPath)
132
+ const columns = db.query('SELECT COUNT(*) as count FROM columns').get() as { count: number }
133
+ expect(columns.count).toBeGreaterThan(0)
134
+ db.close()
135
+
136
+ await expectKanbanError(
137
+ run(['--db', dbPath, 'board', 'init']),
138
+ ErrorCode.BOARD_ALREADY_INITIALIZED,
139
+ )
140
+ })
141
+ })
142
+
88
143
  test('applies schema migration before task commands', async () => {
89
144
  await withTempDb(async (dbPath) => {
90
145
  const legacy = new Database(dbPath)
@@ -62,6 +62,62 @@ describe('jira-cache', () => {
62
62
  expect(decodeColumnStatusIds(col)).toEqual(['10001', '10002', '10003'])
63
63
  })
64
64
 
65
+ test('catalog replace prune=false upserts without deleting obsolete rows', () => {
66
+ // Seed a full catalog (prune defaults to true → full replace).
67
+ replaceJiraColumns(db, [
68
+ { id: 'status:1', name: 'To Do', position: 0, statusIds: ['1'], source: 'status' },
69
+ { id: 'status:2', name: 'Done', position: 1, statusIds: ['2'], source: 'status' },
70
+ ])
71
+ replaceJiraPriorities(db, [
72
+ { id: 'p1', name: 'High' },
73
+ { id: 'p2', name: 'Low' },
74
+ ])
75
+ replaceJiraIssueTypes(db, [
76
+ { id: 't1', name: 'Bug' },
77
+ { id: 't2', name: 'Task' },
78
+ ])
79
+
80
+ // A delta sync (prune=false) carries a partial snapshot: it should upsert the
81
+ // rows it sees and leave the others in place (self-healing), not delete them.
82
+ replaceJiraColumns(
83
+ db,
84
+ [
85
+ {
86
+ id: 'status:1',
87
+ name: 'To Do (renamed)',
88
+ position: 0,
89
+ statusIds: ['1'],
90
+ source: 'status',
91
+ },
92
+ ],
93
+ false,
94
+ )
95
+ replaceJiraPriorities(db, [{ id: 'p1', name: 'Highest' }], false)
96
+ replaceJiraIssueTypes(db, [{ id: 't1', name: 'Defect' }], false)
97
+
98
+ const columns = getCachedColumns(db)
99
+ expect(columns.map((c) => c.id).sort()).toEqual(['status:1', 'status:2'])
100
+ expect(columns.find((c) => c.id === 'status:1')?.name).toBe('To Do (renamed)')
101
+
102
+ const { priorities, issueTypes } = getCachedConfig(db)
103
+ expect(priorities.map((p) => p.id).sort()).toEqual(['p1', 'p2'])
104
+ expect(priorities.find((p) => p.id === 'p1')?.name).toBe('Highest')
105
+ expect(issueTypes.map((t) => t.id).sort()).toEqual(['t1', 't2'])
106
+ expect(issueTypes.find((t) => t.id === 't1')?.name).toBe('Defect')
107
+ })
108
+
109
+ test('catalog replace prune=true (default) removes obsolete rows', () => {
110
+ replaceJiraColumns(db, [
111
+ { id: 'status:1', name: 'To Do', position: 0, statusIds: ['1'], source: 'status' },
112
+ { id: 'status:2', name: 'Done', position: 1, statusIds: ['2'], source: 'status' },
113
+ ])
114
+ // A full reconcile (prune=true) replaces the catalog: status:2 is gone.
115
+ replaceJiraColumns(db, [
116
+ { id: 'status:1', name: 'To Do', position: 0, statusIds: ['1'], source: 'status' },
117
+ ])
118
+ expect(getCachedColumns(db).map((c) => c.id)).toEqual(['status:1'])
119
+ })
120
+
65
121
  test('decodeColumnStatusIds handles 3-element array without ordering loss', () => {
66
122
  expect(decodeColumnStatusIds({ status_ids: JSON.stringify(['c', 'a', 'b']) })).toEqual([
67
123
  'c',
@@ -0,0 +1,51 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { buildDeltaJql, assertSafeProjectKey, safeDeltaSince } from '../providers/jira-jql'
3
+ import { KanbanError } from '../errors'
4
+
5
+ describe('buildDeltaJql', () => {
6
+ test('preserves the existing query format for a full scan (null since)', () => {
7
+ expect(buildDeltaJql('ENG', null)).toBe(
8
+ 'project = ENG AND updated >= "1970-01-01 00:00" ORDER BY updated ASC',
9
+ )
10
+ })
11
+
12
+ test('interpolates a valid ISO timestamp unchanged', () => {
13
+ expect(buildDeltaJql('ENG', '2026-01-05T00:00:00Z')).toBe(
14
+ 'project = ENG AND updated >= "2026-01-05T00:00:00Z" ORDER BY updated ASC',
15
+ )
16
+ })
17
+
18
+ test('accepts the ISO offset form Jira returns in issue.fields.updated', () => {
19
+ expect(buildDeltaJql('ENG', '2026-06-08T12:34:56.789+0000')).toContain(
20
+ 'updated >= "2026-06-08T12:34:56.789+0000"',
21
+ )
22
+ })
23
+
24
+ test('rejects an injection attempt in the since cursor and falls back to a full scan', () => {
25
+ const malicious = '2026-01-01" OR project = OTHER ORDER BY updated ASC -- '
26
+ expect(buildDeltaJql('ENG', malicious)).toBe(
27
+ 'project = ENG AND updated >= "1970-01-01 00:00" ORDER BY updated ASC',
28
+ )
29
+ })
30
+
31
+ test('rejects an invalid project key', () => {
32
+ expect(() => buildDeltaJql('ENG" OR 1=1', null)).toThrow(KanbanError)
33
+ expect(() => assertSafeProjectKey('has space')).toThrow(KanbanError)
34
+ expect(assertSafeProjectKey('ENG')).toBe('ENG')
35
+ })
36
+ })
37
+
38
+ describe('safeDeltaSince', () => {
39
+ test('passes through valid cursors and null', () => {
40
+ expect(safeDeltaSince(null)).toBeNull()
41
+ expect(safeDeltaSince('2026-01-05T00:00:00Z')).toBe('2026-01-05T00:00:00Z')
42
+ expect(safeDeltaSince('1970-01-01 00:00')).toBe('1970-01-01 00:00')
43
+ })
44
+
45
+ test('nulls out an unsafe cursor so it is not carried forward or re-persisted', () => {
46
+ // A lexicographically-high injection value would otherwise survive the
47
+ // `issue.fields.updated > newestUpdatedAt` comparison and re-persist itself,
48
+ // trapping every future sync into a full scan.
49
+ expect(safeDeltaSince('9999" OR project = OTHER')).toBeNull()
50
+ })
51
+ })
@@ -845,4 +845,54 @@ describe('JiraProvider read path', () => {
845
845
  saveTeamInfo(db, null)
846
846
  expect(loadTeamInfo(db)).toBeNull()
847
847
  })
848
+
849
+ // Regression: in server (background-managed) mode, request-path reads must serve
850
+ // the warm cache instead of blocking on a Jira sync — otherwise a stale-throttle
851
+ // read triggers a foreground network sync that can exceed the HTTP idle timeout
852
+ // (observed as /api/board ERR_EMPTY_RESPONSE).
853
+ const seedSyncedMeta = () =>
854
+ saveJiraSyncMeta(db, {
855
+ projectKey: 'ENG',
856
+ lastSyncAt: '2026-01-01T00:00:00.000Z',
857
+ lastFullSyncAt: '2026-01-01T00:00:00.000Z',
858
+ lastIssueUpdatedAt: '2026-01-01T00:00:00.000Z',
859
+ })
860
+ const wellPastThrottle = () => {
861
+ Date.now = () => Date.parse('2026-01-01T01:00:00.000Z') // +1h, throttle long expired
862
+ }
863
+
864
+ test('background-managed reads do NOT foreground-sync once the cache is warm', async () => {
865
+ const { provider, calls } = makeProvider(standardRoutes({}))
866
+ provider.setBackgroundManaged(true)
867
+ seedSyncedMeta()
868
+ wellPastThrottle()
869
+
870
+ await provider.listTasks()
871
+
872
+ expect(calls.some((c) => c.url.includes('/rest/api/3/search/jql'))).toBe(false)
873
+ })
874
+
875
+ test('non-managed reads still foreground-sync when the throttle has expired', async () => {
876
+ const { provider, calls } = makeProvider(standardRoutes({}))
877
+ // backgroundManaged left false (CLI / default)
878
+ seedSyncedMeta()
879
+ wellPastThrottle()
880
+
881
+ await provider.listTasks()
882
+
883
+ expect(calls.some((c) => c.url.includes('/rest/api/3/search/jql'))).toBe(true)
884
+ })
885
+
886
+ test('background warmer (syncCache) still syncs when background-managed', async () => {
887
+ const { provider, calls } = makeProvider(standardRoutes({}))
888
+ provider.setBackgroundManaged(true)
889
+ seedSyncedMeta()
890
+ wellPastThrottle()
891
+
892
+ // syncCache bypasses the managed read-path gate (viaWarmer), so the warmer
893
+ // keeps refreshing the cache even though plain reads would be suppressed.
894
+ await provider.syncCache()
895
+
896
+ expect(calls.some((c) => c.url.includes('/rest/api/3/search/jql'))).toBe(true)
897
+ })
848
898
  })
@@ -85,7 +85,7 @@ function startTrackerServer(
85
85
  url,
86
86
  async close() {
87
87
  await tracker.close()
88
- httpServer.stop(true)
88
+ void httpServer.stop(true)
89
89
  },
90
90
  }
91
91
  }
@@ -246,7 +246,7 @@ describe('createTrackerMcpServer', () => {
246
246
  message: 'Tracker MCP server is closed',
247
247
  })
248
248
  } finally {
249
- runtime.httpServer.stop(true)
249
+ void runtime.httpServer.stop(true)
250
250
  }
251
251
  })
252
252
  })