@andypai/agent-kanban 0.7.0 → 0.8.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 CHANGED
@@ -20,11 +20,12 @@ That buys you a few things that are easy to miss at first:
20
20
 
21
21
  ## Documentation
22
22
 
23
- - [`docs/readme.md`](docs/readme.md) for the documentation index
24
- - [`docs/workflow.md`](docs/workflow.md) for a common day-to-day workflow
25
- - [`docs/mcp.md`](docs/mcp.md) for the reusable tracker MCP module
26
- - [`docs/providers/linear.md`](docs/providers/linear.md) for Linear provider details
27
- - [`docs/providers/jira.md`](docs/providers/jira.md) for Jira provider details
23
+ - [`docs/INDEX.md`](https://github.com/abpai/agent-kanban/blob/main/docs/INDEX.md) for the documentation index
24
+ - [`docs/SPEC_CONTRACT.md`](https://github.com/abpai/agent-kanban/blob/main/docs/SPEC_CONTRACT.md) for the repo proof menu and spec contract
25
+ - [`docs/workflow.md`](https://github.com/abpai/agent-kanban/blob/main/docs/workflow.md) for a common day-to-day workflow
26
+ - [`docs/mcp.md`](https://github.com/abpai/agent-kanban/blob/main/docs/mcp.md) for the reusable tracker MCP module
27
+ - [`docs/providers/linear.md`](https://github.com/abpai/agent-kanban/blob/main/docs/providers/linear.md) for Linear provider details
28
+ - [`docs/providers/jira.md`](https://github.com/abpai/agent-kanban/blob/main/docs/providers/jira.md) for Jira provider details
28
29
  - [`SKILL.md`](SKILL.md) for agent-specific repo usage instructions
29
30
 
30
31
  ## Install
@@ -176,6 +177,8 @@ Default columns: `recurring`, `backlog`, `in-progress`, `review`, `done`.
176
177
  | `-p <level>` | Priority: `low`, `medium`, `high`, `urgent` (default: `medium`) |
177
178
  | `-a <user>` | Assignee |
178
179
  | `--project <name>` | Project tag |
180
+ | `--label <name>` | Label; repeatable and comma-separated values are accepted |
181
+ | `--labels <names>` | Label alias; repeatable and comma-separated values are accepted |
179
182
  | `-m <json>` | Arbitrary metadata (must be valid JSON) |
180
183
 
181
184
  **Flags for `task list`:**
@@ -269,14 +272,18 @@ compatibility. To require authentication, set a token via `--token` or the
269
272
  accepts `?token=<token>` instead.
270
273
  - `/api/health` stays public (liveness probe).
271
274
  - `/api/webhooks/*` are **not** covered by this token — they authenticate with
272
- the provider webhook secret (e.g. `JIRA_WEBHOOK_SECRET`).
275
+ the provider webhook secret when one is configured. Without that secret,
276
+ Linear/Jira webhooks run in local open dev mode and accept unsigned payloads.
273
277
  - The bundled UI picks the token up once from `?token=` / `#token=` and stores
274
278
  it in `localStorage`, so you can open `https://<host>/?token=<token>`.
275
279
 
276
280
  `--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.
281
+ token**. In Linear or Jira mode it also refuses to start unless the matching
282
+ webhook secret is set, so auth-exempt webhook routes cannot accept unsigned
283
+ public writes. CORS is off by default (same-origin); set
284
+ `KANBAN_ALLOWED_ORIGIN` (or `--allowed-origin`) to allow a specific cross-origin
285
+ browser client. CORS is origin hygiene, not an auth control — the token is the
286
+ security boundary.
280
287
 
281
288
  ### mcp
282
289
 
@@ -286,7 +293,8 @@ kanban mcp --db /path/to/board.db
286
293
  ```
287
294
 
288
295
  Runs the bundled MCP server over stdio for local MCP clients such as Claude
289
- Desktop. See [`docs/mcp.md`](docs/mcp.md) for the tool surface and caveats.
296
+ Desktop. See [`docs/mcp.md`](https://github.com/abpai/agent-kanban/blob/main/docs/mcp.md)
297
+ for the tool surface and caveats.
290
298
 
291
299
  ## Global flags
292
300
 
@@ -350,7 +358,8 @@ There are two ways to use it today:
350
358
  - run `kanban mcp` for a bundled stdio MCP server
351
359
  - import the helpers in `src/mcp/` from a sibling workspace or in-repo consumer
352
360
 
353
- See [`docs/mcp.md`](docs/mcp.md) for the current default tool set, the auth and
361
+ See [`docs/mcp.md`](https://github.com/abpai/agent-kanban/blob/main/docs/mcp.md)
362
+ for the current default tool set, the auth and
354
363
  policy model, and the caveats around source-level imports and `kanban serve`.
355
364
 
356
365
  ## Scripts
@@ -435,7 +444,8 @@ If you want to contribute or report an issue, start with these guides:
435
444
  - [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)
436
445
  - [SECURITY.md](SECURITY.md)
437
446
 
438
- Longer product and workflow docs live under [`docs/`](docs/readme.md).
447
+ Longer product and workflow docs live under
448
+ [`docs/`](https://github.com/abpai/agent-kanban/blob/main/docs/readme.md).
439
449
 
440
450
  ## License
441
451
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@andypai/agent-kanban",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
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": {
@@ -45,6 +45,8 @@
45
45
  "access": "public"
46
46
  },
47
47
  "scripts": {
48
+ "bootstrap": "bun install && cd ui && bun install",
49
+ "smoke": "bun src/index.ts --help",
48
50
  "dev": "bun --watch src/index.ts",
49
51
  "start": "bun src/index.ts",
50
52
  "build": "bun build ./src/index.ts --target bun --outdir ./dist",
@@ -56,6 +58,10 @@
56
58
  "check": "bun run lint && bun run typecheck && bun run ui:typecheck",
57
59
  "test": "bun test",
58
60
  "test:watch": "bun test --watch",
61
+ "knip": "knip",
62
+ "test:pg": "DATABASE_URL=${DATABASE_URL:-postgres://postgres:postgres@localhost:${KANBAN_PG_PORT:-5432}/kanban_test} bun test",
63
+ "pg:up": "docker compose -f docker-compose.postgres.yml up -d --wait",
64
+ "pg:down": "docker compose -f docker-compose.postgres.yml down -v",
59
65
  "seed:test": "bun --env-file=.env.test scripts/seed-test-db.ts",
60
66
  "serve": "bun src/index.ts serve",
61
67
  "ui:dev": "cd ui && bun run dev",
@@ -83,6 +89,7 @@
83
89
  "eslint-config-prettier": "^10.1.8",
84
90
  "eslint-plugin-prettier": "^5.5.4",
85
91
  "husky": "^8.0.3",
92
+ "knip": "^6.24.0",
86
93
  "lint-staged": "^15.2.0",
87
94
  "prettier": "^3.7.4",
88
95
  "typescript": "^5.7.2"
@@ -1,6 +1,10 @@
1
1
  import { beforeEach, describe, expect, test } from 'bun:test'
2
2
  import { Database } from 'bun:sqlite'
3
+ import { mkdtempSync, rmSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
3
6
  import { initSchema, seedDefaultColumns, addTask } from '../db'
7
+ import { KanbanError, ErrorCode } from '../errors'
4
8
  import { handleRequest } from '../api'
5
9
  import { createProvider } from '../providers/index'
6
10
  import type { KanbanProvider } from '../providers/types'
@@ -223,4 +227,220 @@ describe('handleRequest', () => {
223
227
  expect(body.data.provider).toBe('local')
224
228
  expect(body.data.capabilities.taskDelete).toBe(true)
225
229
  })
230
+
231
+ test('F22: GET /api/activity returns an ok envelope wrapping an array', async () => {
232
+ addTask(db, 'Generates activity')
233
+ const req = new Request('http://localhost/api/activity?limit=5', { method: 'GET' })
234
+ const result = await handleRequest(provider, req)
235
+ const body = (await result.response.json()) as { ok: boolean; data: unknown[] }
236
+ expect(result.response.status).toBe(200)
237
+ expect(result.mutated).toBe(false)
238
+ expect(body.ok).toBe(true)
239
+ expect(Array.isArray(body.data)).toBe(true)
240
+ })
241
+
242
+ test('F22: GET /api/activity rejects an invalid limit through the envelope', async () => {
243
+ const req = new Request('http://localhost/api/activity?limit=0', { method: 'GET' })
244
+ const result = await handleRequest(provider, req)
245
+ const body = (await result.response.json()) as { ok: boolean; error: { code: string } }
246
+ expect(result.response.status).toBe(400)
247
+ expect(body.error.code).toBe('INVALID_ARGUMENT')
248
+ })
249
+
250
+ test('F23: GET /api/metrics returns the metrics envelope', async () => {
251
+ const req = new Request('http://localhost/api/metrics', { method: 'GET' })
252
+ const result = await handleRequest(provider, req)
253
+ const body = (await result.response.json()) as {
254
+ ok: boolean
255
+ data: { totalTasks: number; tasksByColumn: unknown[] }
256
+ }
257
+ expect(result.response.status).toBe(200)
258
+ expect(result.mutated).toBe(false)
259
+ expect(body.ok).toBe(true)
260
+ expect(typeof body.data.totalTasks).toBe('number')
261
+ expect(Array.isArray(body.data.tasksByColumn)).toBe(true)
262
+ })
263
+
264
+ test('F24: GET /api/config returns config', async () => {
265
+ const getReq = new Request('http://localhost/api/config', { method: 'GET' })
266
+ const getRes = await handleRequest(provider, getReq)
267
+ const getBody = (await getRes.response.json()) as { ok: boolean; data: { provider: string } }
268
+ expect(getRes.response.status).toBe(200)
269
+ expect(getRes.mutated).toBe(false)
270
+ expect(getBody.data.provider).toBe('local')
271
+ })
272
+
273
+ test('F24: PATCH /api/config mutates without a precise WsEvent', async () => {
274
+ // PATCH persists the config sidecar (config.json) next to the db path, so use
275
+ // a hermetic temp dir instead of the shared ':memory:' provider, which would
276
+ // write ./config.json into the checkout.
277
+ const dir = mkdtempSync(join(tmpdir(), 'kanban-api-config-'))
278
+ const cfgDb = new Database(':memory:')
279
+ cfgDb.run('PRAGMA foreign_keys = ON')
280
+ initSchema(cfgDb)
281
+ seedDefaultColumns(cfgDb)
282
+ const cfgProvider = createProvider(cfgDb, { provider: 'local' }, join(dir, 'board.db'))
283
+ try {
284
+ const patchReq = new Request('http://localhost/api/config', {
285
+ method: 'PATCH',
286
+ headers: { 'Content-Type': 'application/json' },
287
+ body: JSON.stringify({ members: [{ name: 'alice', role: 'human' }] }),
288
+ })
289
+ const patchRes = await handleRequest(cfgProvider, patchReq)
290
+ const patchBody = (await patchRes.response.json()) as {
291
+ ok: boolean
292
+ data: { members: { name: string }[] }
293
+ }
294
+ expect(patchRes.response.status).toBe(200)
295
+ expect(patchRes.mutated).toBe(true)
296
+ // No precise WsEvent → the server falls back to a 'refresh' broadcast.
297
+ expect(patchRes.event).toBeUndefined()
298
+ expect(patchBody.data.members.map((m) => m.name)).toContain('alice')
299
+ } finally {
300
+ cfgDb.close()
301
+ rmSync(dir, { recursive: true, force: true })
302
+ }
303
+ })
304
+ })
305
+
306
+ // Minimal provider whose only relevant field is `type` plus an overridable
307
+ // handleWebhook — the webhook branch of handleRequest is the surface under test.
308
+ function webhookProvider(
309
+ type: string,
310
+ handleWebhook?: KanbanProvider['handleWebhook'],
311
+ ): KanbanProvider {
312
+ const p: Partial<KanbanProvider> = { type: type as KanbanProvider['type'] }
313
+ if (handleWebhook) p.handleWebhook = handleWebhook
314
+ return p as KanbanProvider
315
+ }
316
+
317
+ function webhookRequest(target: string): Request {
318
+ return new Request(`http://localhost/api/webhooks/${target}`, {
319
+ method: 'POST',
320
+ headers: { 'Content-Type': 'application/json' },
321
+ body: '{}',
322
+ })
323
+ }
324
+
325
+ describe('handleRequest webhook route (F25)', () => {
326
+ test('target that does not match the active provider → 400 UNSUPPORTED_OPERATION, not mutated', async () => {
327
+ const result = await handleRequest(
328
+ webhookProvider('local', async () => ({ handled: true })),
329
+ webhookRequest('jira'),
330
+ )
331
+ const body = (await result.response.json()) as { ok: boolean; error: { code: string } }
332
+ expect(result.response.status).toBe(400)
333
+ expect(result.mutated).toBe(false)
334
+ expect(body.error.code).toBe('UNSUPPORTED_OPERATION')
335
+ })
336
+
337
+ test('provider without handleWebhook → 400 UNSUPPORTED_OPERATION', async () => {
338
+ const result = await handleRequest(webhookProvider('local'), webhookRequest('local'))
339
+ const body = (await result.response.json()) as { ok: boolean; error: { code: string } }
340
+ expect(result.response.status).toBe(400)
341
+ expect(result.mutated).toBe(false)
342
+ expect(body.error.code).toBe('UNSUPPORTED_OPERATION')
343
+ })
344
+
345
+ test('unauthorized result → 401 PROVIDER_AUTH_FAILED, not mutated', async () => {
346
+ const result = await handleRequest(
347
+ webhookProvider('local', async () => ({ handled: false, unauthorized: true })),
348
+ webhookRequest('local'),
349
+ )
350
+ const body = (await result.response.json()) as { ok: boolean; error: { code: string } }
351
+ expect(result.response.status).toBe(401)
352
+ expect(result.mutated).toBe(false)
353
+ expect(body.error.code).toBe('PROVIDER_AUTH_FAILED')
354
+ })
355
+
356
+ test('handled result → 200, mutated true', async () => {
357
+ const result = await handleRequest(
358
+ webhookProvider('local', async () => ({ handled: true, message: 'ok' })),
359
+ webhookRequest('local'),
360
+ )
361
+ const body = (await result.response.json()) as { ok: boolean; data: { handled: boolean } }
362
+ expect(result.response.status).toBe(200)
363
+ expect(result.mutated).toBe(true)
364
+ expect(body.data.handled).toBe(true)
365
+ })
366
+
367
+ test('skipped (handled:false) result → 200, NOT mutated (no broadcast)', async () => {
368
+ const result = await handleRequest(
369
+ webhookProvider('local', async () => ({ handled: false, message: 'ignored' })),
370
+ webhookRequest('local'),
371
+ )
372
+ expect(result.response.status).toBe(200)
373
+ expect(result.mutated).toBe(false)
374
+ })
375
+ })
376
+
377
+ describe('handleRequest webhook route error containment (F55 regression)', () => {
378
+ test('a throwing handleWebhook is enveloped as 500 INTERNAL_ERROR, never escapes as a rejection', async () => {
379
+ const provider = webhookProvider('local', async () => {
380
+ throw new Error('boom from provider.handleWebhook')
381
+ })
382
+ // Must NOT reject — before the fix this threw out of handleRequest.
383
+ const result = await handleRequest(provider, webhookRequest('local'))
384
+ const body = (await result.response.json()) as {
385
+ ok: boolean
386
+ error: { code: string; message: string }
387
+ }
388
+ expect(result.response.status).toBe(500)
389
+ expect(result.mutated).toBe(false)
390
+ expect(body.ok).toBe(false)
391
+ expect(body.error.code).toBe('INTERNAL_ERROR')
392
+ expect(body.error.message).toContain('boom')
393
+ })
394
+
395
+ test('a thrown KanbanError keeps its mapped status + code through the envelope', async () => {
396
+ const provider = webhookProvider('local', async () => {
397
+ throw new KanbanError(ErrorCode.CONFLICT, 'version conflict during webhook apply')
398
+ })
399
+ const result = await handleRequest(provider, webhookRequest('local'))
400
+ const body = (await result.response.json()) as { ok: boolean; error: { code: string } }
401
+ expect(result.response.status).toBe(409)
402
+ expect(result.mutated).toBe(false)
403
+ expect(body.error.code).toBe('CONFLICT')
404
+ })
405
+ })
406
+
407
+ describe('handleRequest malformed path encoding (D2 regression)', () => {
408
+ test('malformed %-encoding in a task id → 400 INVALID_ARGUMENT, never thrown', async () => {
409
+ const req = new Request('http://localhost/api/tasks/%E0%A4%A', { method: 'GET' })
410
+ // Must not reject — before the fix decodeURIComponent threw a URIError that
411
+ // escaped handleRequest.
412
+ const result = await handleRequest(provider, req)
413
+ const body = (await result.response.json()) as { ok: boolean; error: { code: string } }
414
+ expect(result.response.status).toBe(400)
415
+ expect(result.mutated).toBe(false)
416
+ expect(body.error.code).toBe('INVALID_ARGUMENT')
417
+ })
418
+
419
+ test('malformed %-encoding in a webhook target → 400 INVALID_ARGUMENT', async () => {
420
+ const result = await handleRequest(webhookProvider('local'), webhookRequest('%E0%A4%A'))
421
+ const body = (await result.response.json()) as { ok: boolean; error: { code: string } }
422
+ expect(result.response.status).toBe(400)
423
+ expect(result.mutated).toBe(false)
424
+ expect(body.error.code).toBe('INVALID_ARGUMENT')
425
+ })
426
+ })
427
+
428
+ describe('statusForCode server-side mapping (D3 regression)', () => {
429
+ const cases: { code: keyof typeof ErrorCode; status: number }[] = [
430
+ { code: 'PROVIDER_UPSTREAM_ERROR', status: 502 },
431
+ { code: 'PROVIDER_SYNC_REQUIRED', status: 503 },
432
+ { code: 'INTERNAL_ERROR', status: 500 },
433
+ ]
434
+ for (const { code, status } of cases) {
435
+ test(`${code} → ${status} (not the default 400)`, async () => {
436
+ const provider = webhookProvider('local', async () => {
437
+ throw new KanbanError(ErrorCode[code], `${code} from provider`)
438
+ })
439
+ const result = await handleRequest(provider, webhookRequest('local'))
440
+ const body = (await result.response.json()) as { ok: boolean; error: { code: string } }
441
+ expect(result.response.status).toBe(status)
442
+ expect(body.error.code).toBe(code)
443
+ expect(result.mutated).toBe(false)
444
+ })
445
+ }
226
446
  })