@hiroleague/taskmanager 0.0.1 → 0.0.3

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 (196) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -52
  3. package/dist/assets/architecture-YZFGNWBL-DoE0KxgG.js +1 -0
  4. package/dist/assets/architectureDiagram-Q4EWVU46-DeuBhy7X.js +36 -0
  5. package/dist/assets/{blockDiagram-DXYQGD6D-DfOGNphI.js → blockDiagram-DXYQGD6D-BDBy9ns9.js} +1 -1
  6. package/dist/assets/{c4Diagram-AHTNJAMY-B2Yfcwbo.js → c4Diagram-AHTNJAMY-CpqJj_8a.js} +1 -1
  7. package/dist/assets/channel-PHRyjspt.js +1 -0
  8. package/dist/assets/{chunk-2KRD3SAO-9yt00aGC.js → chunk-2KRD3SAO-DEpUsxdZ.js} +1 -1
  9. package/dist/assets/chunk-336JU56O-BGQvSwLk.js +2 -0
  10. package/dist/assets/chunk-426QAEUC-Cl9nQN9c.js +1 -0
  11. package/dist/assets/{chunk-4TB4RGXK-DF8yJBFl.js → chunk-4TB4RGXK-Dq7aiIrZ.js} +2 -2
  12. package/dist/assets/{chunk-5FUZZQ4R-XEga0hMC.js → chunk-5FUZZQ4R-B_HuuUjf.js} +1 -1
  13. package/dist/assets/{chunk-5PVQY5BW-BrmXs2Gs.js → chunk-5PVQY5BW-cGfZCZGU.js} +2 -2
  14. package/dist/assets/{chunk-67CJDMHE-5wFKo04G.js → chunk-67CJDMHE-BMYAVZfw.js} +1 -1
  15. package/dist/assets/{chunk-7N4EOEYR-BRRGX_NC.js → chunk-7N4EOEYR-Ct-EY7Nc.js} +1 -1
  16. package/dist/assets/{chunk-AA7GKIK3-DUZv_pNI.js → chunk-AA7GKIK3-Bd4HFpeo.js} +1 -1
  17. package/dist/assets/{chunk-CIAEETIT-mA5aM_d7.js → chunk-CIAEETIT-CrFUkPMT.js} +1 -1
  18. package/dist/assets/{chunk-EDXVE4YY-DxUqDyxy.js → chunk-EDXVE4YY-DMDyt0NF.js} +1 -1
  19. package/dist/assets/{chunk-ENJZ2VHE-BgZKYo1l.js → chunk-ENJZ2VHE-DrWzOrpd.js} +1 -1
  20. package/dist/assets/{chunk-FOC6F5B3-B-cqGCPC.js → chunk-FOC6F5B3-Bemzq96j.js} +1 -1
  21. package/dist/assets/{chunk-ICPOFSXX-BNR1V8rT.js → chunk-ICPOFSXX-DkUVjrLw.js} +5 -5
  22. package/dist/assets/{chunk-K5T4RW27-BLRDzioh.js → chunk-K5T4RW27-ALKIf000.js} +5 -5
  23. package/dist/assets/{chunk-KGLVRYIC-CTkQSeKy.js → chunk-KGLVRYIC-Bg6HNTZ-.js} +1 -1
  24. package/dist/assets/{chunk-LIHQZDEY-Cf34Nu3J.js → chunk-LIHQZDEY-DeyGongE.js} +1 -1
  25. package/dist/assets/{chunk-ORNJ4GCN-D3uXgbay.js → chunk-ORNJ4GCN-Bx83s1bJ.js} +1 -1
  26. package/dist/assets/{chunk-OYMX7WX6-syQho5jf.js → chunk-OYMX7WX6-BqRUtRpL.js} +1 -1
  27. package/dist/assets/{chunk-U2HBQHQK-DTJPeU7W.js → chunk-U2HBQHQK-DogcerR6.js} +1 -1
  28. package/dist/assets/{chunk-X2U36JSP-CrTnmMqG.js → chunk-X2U36JSP-CwVWdmZV.js} +1 -1
  29. package/dist/assets/chunk-XPW4576I-DQpNCogT.js +32 -0
  30. package/dist/assets/{chunk-YZCP3GAM-9wq0QKUn.js → chunk-YZCP3GAM-crQSbji9.js} +1 -1
  31. package/dist/assets/{chunk-ZZ45TVLE-D3I1kLlo.js → chunk-ZZ45TVLE-Bk1S1YtS.js} +1 -1
  32. package/dist/assets/classDiagram-6PBFFD2Q-B_TabGaU.js +1 -0
  33. package/dist/assets/classDiagram-v2-HSJHXN6E-CGnZkUWw.js +1 -0
  34. package/dist/assets/clone-D4ka472w.js +1 -0
  35. package/dist/assets/{cose-bilkent-S5V4N54A-BygGvZGW.js → cose-bilkent-S5V4N54A-RBTHUit8.js} +1 -1
  36. package/dist/assets/cytoscape.esm-BGJwlmkf.js +321 -0
  37. package/dist/assets/dagre-B32eYLtm.js +1 -0
  38. package/dist/assets/{dagre-KV5264BT-BBqulDtd.js → dagre-KV5264BT-nX7tuXXn.js} +1 -1
  39. package/dist/assets/diagram-5BDNPKRD-DRxMXlQr.js +10 -0
  40. package/dist/assets/diagram-G4DWMVQ6-CoojevGm.js +24 -0
  41. package/dist/assets/diagram-MMDJMWI5-CWtJyfVW.js +43 -0
  42. package/dist/assets/diagram-TYMM5635-CsDJC4Hq.js +24 -0
  43. package/dist/assets/{erDiagram-SMLLAGMA-BN5eJerP.js → erDiagram-SMLLAGMA-Cf7Xtd9A.js} +2 -2
  44. package/dist/assets/{flatten-C5NL-f24.js → flatten-CYX_pHZ7.js} +1 -1
  45. package/dist/assets/{flowDiagram-DWJPFMVM-CbFskc8S.js → flowDiagram-DWJPFMVM-DQaeR16a.js} +3 -3
  46. package/dist/assets/{ganttDiagram-T4ZO3ILL-OCTvbRxF.js → ganttDiagram-T4ZO3ILL-8EIcztcH.js} +1 -1
  47. package/dist/assets/gitGraph-7Q5UKJZL-BH9A1SAZ.js +1 -0
  48. package/dist/assets/{gitGraphDiagram-UUTBAWPF-wpqI2kyI.js → gitGraphDiagram-UUTBAWPF-DO9ODqYw.js} +1 -1
  49. package/dist/assets/graphlib-bPBqlJKT.js +1 -0
  50. package/dist/assets/identity-Me9aart9.js +1 -0
  51. package/dist/assets/index-oKG1C41_.js +273 -0
  52. package/dist/assets/info-OMHHGYJF-BvKR-zWh.js +1 -0
  53. package/dist/assets/infoDiagram-42DDH7IO-pRTXCm5C.js +2 -0
  54. package/dist/assets/isEmpty-Cu0k-j1j.js +1 -0
  55. package/dist/assets/{ishikawaDiagram-UXIWVN3A-Epc23N_0.js → ishikawaDiagram-UXIWVN3A-BP2YE5QI.js} +2 -2
  56. package/dist/assets/{journeyDiagram-VCZTEJTY-BkMxoaPq.js → journeyDiagram-VCZTEJTY-B3l2juoL.js} +1 -1
  57. package/dist/assets/{kanban-definition-6JOO6SKY-C8dW_26n.js → kanban-definition-6JOO6SKY-BpIpEOZZ.js} +4 -4
  58. package/dist/assets/{line-DNzQATGr.js → line-otOkzGl8.js} +1 -1
  59. package/dist/assets/mermaid-parser.core-xWsW24Gq.js +4 -0
  60. package/dist/assets/{mindmap-definition-QFDTVHPH-CvpNtrKT.js → mindmap-definition-QFDTVHPH-B9khyC7X.js} +3 -3
  61. package/dist/assets/packet-4T2RLAQJ-D8Dw3nmf.js +1 -0
  62. package/dist/assets/pie-ZZUOXDRM-ZghowlAE.js +1 -0
  63. package/dist/assets/{pieDiagram-DEJITSTG-eENymoXZ.js → pieDiagram-DEJITSTG-v32hL3i7.js} +1 -1
  64. package/dist/assets/{quadrantDiagram-34T5L4WZ-c0iZxo2I.js → quadrantDiagram-34T5L4WZ-DIL3GDFt.js} +1 -1
  65. package/dist/assets/radar-PYXPWWZC-D-PK3JOd.js +1 -0
  66. package/dist/assets/reduce-CImcgAcU.js +1 -0
  67. package/dist/assets/{requirementDiagram-MS252O5E-CmRO3hLp.js → requirementDiagram-MS252O5E-D8os2-4y.js} +2 -2
  68. package/dist/assets/{sankeyDiagram-XADWPNL6-woJZoQ58.js → sankeyDiagram-XADWPNL6-BV70D4l5.js} +1 -1
  69. package/dist/assets/{sequenceDiagram-FGHM5R23-B7qNcwNo.js → sequenceDiagram-FGHM5R23-Cwu8hQW1.js} +1 -1
  70. package/dist/assets/stateDiagram-FHFEXIEX-oYUWv7Fb.js +1 -0
  71. package/dist/assets/stateDiagram-v2-QKLJ7IA2-CFUTpFu-.js +1 -0
  72. package/dist/assets/{timeline-definition-GMOUNBTQ-CQWqDPGG.js → timeline-definition-GMOUNBTQ-CxSdKxpL.js} +1 -1
  73. package/dist/assets/treeView-SZITEDCU-uVgaJQzG.js +1 -0
  74. package/dist/assets/treemap-W4RFUUIX-Dcad_9AN.js +1 -0
  75. package/dist/assets/vennDiagram-DHZGUBPP-D4wgD7QI.js +34 -0
  76. package/dist/assets/wardley-RL74JXVD-CFXrK8mx.js +1 -0
  77. package/dist/assets/{wardleyDiagram-NUSXRM2D-DNhPIFCg.js → wardleyDiagram-NUSXRM2D-5Q201ea3.js} +1 -1
  78. package/dist/assets/{xychartDiagram-5P7HB3ND-BDblAZ11.js → xychartDiagram-5P7HB3ND-BPZv_axd.js} +3 -3
  79. package/dist/index.html +16 -12
  80. package/package.json +99 -92
  81. package/scripts/stubs/node-domexception/index.cjs +18 -0
  82. package/scripts/stubs/node-domexception/package.json +7 -0
  83. package/skills/hiro-task-manager-cli/SKILL.md +97 -0
  84. package/skills/hiro-task-manager-cli/reference/boards.md +143 -0
  85. package/skills/hiro-task-manager-cli/reference/cli-access-policy.md +72 -0
  86. package/skills/hiro-task-manager-cli/reference/errors.md +85 -0
  87. package/skills/hiro-task-manager-cli/reference/lists.md +106 -0
  88. package/skills/hiro-task-manager-cli/reference/releases.md +87 -0
  89. package/skills/hiro-task-manager-cli/reference/search.md +38 -0
  90. package/skills/hiro-task-manager-cli/reference/statuses.md +25 -0
  91. package/skills/hiro-task-manager-cli/reference/tasks.md +144 -0
  92. package/skills/hiro-task-manager-cli/reference/trash.md +50 -0
  93. package/src/cli/bootstrap/launcher.test.ts +66 -0
  94. package/src/cli/bootstrap/launcher.ts +389 -35
  95. package/src/cli/bootstrap/program.test.ts +46 -0
  96. package/src/cli/bootstrap/program.ts +54 -1
  97. package/src/cli/bootstrap/runtime.test.ts +15 -0
  98. package/src/cli/bootstrap/runtime.ts +27 -1
  99. package/src/cli/commands/query.ts +56 -56
  100. package/src/cli/commands/server.ts +27 -19
  101. package/src/cli/handlers/boards.test.ts +669 -669
  102. package/src/cli/handlers/cli-wiring.test.ts +1 -1
  103. package/src/cli/handlers/search.test.ts +374 -374
  104. package/src/cli/handlers/search.ts +17 -17
  105. package/src/cli/handlers/server.test.ts +55 -13
  106. package/src/cli/handlers/server.ts +16 -3
  107. package/src/cli/lib/api-client.test.ts +35 -2
  108. package/src/cli/lib/api-client.ts +43 -10
  109. package/src/cli/lib/cli-http-errors.test.ts +85 -85
  110. package/src/cli/lib/command-helpers.ts +161 -154
  111. package/src/cli/lib/config.ts +4 -0
  112. package/src/cli/lib/launcherUi.test.ts +74 -0
  113. package/src/cli/lib/launcherUi.ts +213 -0
  114. package/src/cli/lib/process.test.ts +24 -5
  115. package/src/cli/lib/process.ts +86 -55
  116. package/src/cli/ports/process.ts +8 -2
  117. package/src/cli/subprocess.real-stack.test.ts +611 -598
  118. package/src/cli/subprocess.smoke.test.ts +954 -969
  119. package/src/cli/types/config.ts +2 -6
  120. package/src/client/components/auth/AuthScreen.tsx +3 -3
  121. package/src/client/components/board/BoardStatsChips.tsx +233 -233
  122. package/src/client/components/board/BoardStatsContext.tsx +41 -41
  123. package/src/client/components/board/boardHeaderButtonStyles.ts +38 -38
  124. package/src/client/components/board/shortcuts/useBoardShortcutKeydown.ts +49 -49
  125. package/src/client/components/board/useBoardCanvasPanScroll.ts +108 -108
  126. package/src/client/components/board/useBoardTaskContainerDroppableReact.ts +33 -33
  127. package/src/client/components/board/useBoardTaskSortableReact.ts +26 -26
  128. package/src/client/components/multi-select.tsx +1206 -1206
  129. package/src/client/components/routing/BoardPage.tsx +20 -20
  130. package/src/client/components/routing/NavigationRegistrar.tsx +13 -13
  131. package/src/client/components/settings/SettingsPage.tsx +1 -1
  132. package/src/client/components/task/TaskCard.tsx +643 -643
  133. package/src/client/components/ui/badge.tsx +49 -49
  134. package/src/client/components/ui/button.tsx +65 -65
  135. package/src/client/components/ui/command.tsx +193 -193
  136. package/src/client/components/ui/dialog.tsx +163 -163
  137. package/src/client/components/ui/input-group.tsx +155 -155
  138. package/src/client/components/ui/input.tsx +19 -19
  139. package/src/client/components/ui/popover.tsx +87 -87
  140. package/src/client/components/ui/separator.tsx +28 -28
  141. package/src/client/components/ui/textarea.tsx +18 -18
  142. package/src/client/index.css +248 -248
  143. package/src/client/lib/appNavigate.ts +16 -16
  144. package/src/client/lib/taskCardDate.ts +111 -111
  145. package/src/client/lib/utils.ts +6 -6
  146. package/src/server/auth.ts +351 -302
  147. package/src/server/bootstrapDev.ts +11 -2
  148. package/src/server/bootstrapInstalled.ts +6 -1
  149. package/src/server/index.ts +33 -7
  150. package/src/server/migrations/013_cli_policy_and_provenance.ts +2 -2
  151. package/src/server/migrations/019_cli_global_create_board_default_on.ts +14 -0
  152. package/src/server/migrations/registry.ts +43 -41
  153. package/src/server/parseBootstrapProfile.ts +42 -0
  154. package/src/server/storage/cliPolicy.ts +2 -1
  155. package/src/shared/runtimeConfig.ts +256 -237
  156. package/src/shared/runtimeIdentity.test.ts +47 -0
  157. package/src/shared/runtimeIdentity.ts +35 -0
  158. package/src/shared/serverStatus.ts +21 -0
  159. package/src/shared/skillsInstall.ts +70 -0
  160. package/src/shared/terminalColors.ts +24 -0
  161. package/dist/assets/architecture-YZFGNWBL-3h1eIYfB.js +0 -1
  162. package/dist/assets/architectureDiagram-Q4EWVU46-DSQ1_74_.js +0 -36
  163. package/dist/assets/channel-yBmN_ln0.js +0 -1
  164. package/dist/assets/classDiagram-6PBFFD2Q-CotFZI8-.js +0 -1
  165. package/dist/assets/classDiagram-v2-HSJHXN6E-DAPzeDGn.js +0 -1
  166. package/dist/assets/clone-BRQpYu_n.js +0 -1
  167. package/dist/assets/cytoscape.esm-BIYWHPG0.js +0 -321
  168. package/dist/assets/dagre-rhyPjnsQ.js +0 -1
  169. package/dist/assets/diagram-5BDNPKRD-Ky3EXXj0.js +0 -10
  170. package/dist/assets/diagram-G4DWMVQ6-t7LbT0Uz.js +0 -24
  171. package/dist/assets/diagram-MMDJMWI5-CdnLXEMx.js +0 -43
  172. package/dist/assets/diagram-TYMM5635-CnzTqJBM.js +0 -24
  173. package/dist/assets/gitGraph-7Q5UKJZL-CG8f8JF7.js +0 -1
  174. package/dist/assets/graphlib-COiJG5Qv.js +0 -1
  175. package/dist/assets/identity-D4WOnl_h.js +0 -1
  176. package/dist/assets/index-lyyIVcc_.js +0 -304
  177. package/dist/assets/info-OMHHGYJF-C8_SHoRO.js +0 -1
  178. package/dist/assets/infoDiagram-42DDH7IO-BbvTdpSV.js +0 -2
  179. package/dist/assets/mermaid-parser.core-6Tn8epr_.js +0 -4
  180. package/dist/assets/packet-4T2RLAQJ-BvpAX0kJ.js +0 -1
  181. package/dist/assets/pie-ZZUOXDRM-Ow26Yf-E.js +0 -1
  182. package/dist/assets/radar-PYXPWWZC-e_ron5jQ.js +0 -1
  183. package/dist/assets/reduce-BDOBPIXr.js +0 -1
  184. package/dist/assets/stateDiagram-FHFEXIEX-CYfGMoR8.js +0 -1
  185. package/dist/assets/stateDiagram-v2-QKLJ7IA2-CO1W_n55.js +0 -1
  186. package/dist/assets/treeView-SZITEDCU-DsEr3xeq.js +0 -1
  187. package/dist/assets/treemap-W4RFUUIX-DV7nk2AB.js +0 -1
  188. package/dist/assets/vennDiagram-DHZGUBPP-BjTbuhcb.js +0 -34
  189. package/dist/assets/wardley-RL74JXVD-CrrFU9AE.js +0 -1
  190. /package/dist/assets/{chunk-4BX2VUAB-ean5NKtU.js → chunk-4BX2VUAB-C70mcfQR.js} +0 -0
  191. /package/dist/assets/{chunk-55IACEB6-CvSRyJqy.js → chunk-55IACEB6-CWfnqcLM.js} +0 -0
  192. /package/dist/assets/{chunk-BSJP7CBP-D8kBlJsf.js → chunk-BSJP7CBP-B0LrXV9y.js} +0 -0
  193. /package/dist/assets/{chunk-FMBD7UC4-DrNhFt1N.js → chunk-FMBD7UC4-_mV71Mwu.js} +0 -0
  194. /package/dist/assets/{chunk-QZHKN3VN-Csp3OYJY.js → chunk-QZHKN3VN-t2nrsegL.js} +0 -0
  195. /package/dist/assets/{katex-8mXVa4k3.js → katex-B2dtGfSp.js} +0 -0
  196. /package/dist/assets/{rough.esm-DtEqI08j.js → rough.esm-DEh6Frf9.js} +0 -0
@@ -1,969 +1,954 @@
1
- /**
2
- * Aspect 4 — integration depth: subprocess smoke.
3
- * Spawns the real `hirotm` entry (argv → Commander → handlers) to catch wiring issues
4
- * that in-process handler tests miss.
5
- */
6
- import { describe, expect, test } from "bun:test";
7
- import path from "node:path";
8
- import { TASK_MANAGER_CLIENT_NAME_HEADER } from "../shared/boardCliAccess";
9
-
10
- const repoRoot = path.resolve(import.meta.dir, "..", "..");
11
- const hirotmEntry = path.join(repoRoot, "src", "cli", "bin", "hirotm.ts");
12
-
13
- async function readSubprocessStream(
14
- stream: ReturnType<typeof Bun.spawn>["stdout"],
15
- ): Promise<string> {
16
- // When `stdout: "pipe"`, Bun exposes a ReadableStream; types also allow fd numbers.
17
- if (stream == null || typeof stream === "number") return "";
18
- return await new Response(stream as ReadableStream<Uint8Array>).text();
19
- }
20
-
21
- function spawnHirotm(
22
- args: string[],
23
- envOverrides?: Record<string, string>,
24
- ): ReturnType<typeof Bun.spawn> {
25
- return Bun.spawn({
26
- cmd: ["bun", "run", hirotmEntry, ...args],
27
- cwd: repoRoot,
28
- stdout: "pipe",
29
- stderr: "pipe",
30
- env:
31
- envOverrides == null
32
- ? process.env
33
- : { ...process.env, ...envOverrides },
34
- });
35
- }
36
-
37
- describe("hirotm subprocess smoke (aspect 4)", () => {
38
- test("boards list hits stub API and prints NDJSON stdout (empty page → no lines) (exit 0)", async () => {
39
- const server = Bun.serve({
40
- port: 0,
41
- fetch(req) {
42
- const u = new URL(req.url);
43
- if (u.pathname === "/api/boards" && req.method === "GET") {
44
- return new Response(
45
- JSON.stringify({
46
- items: [],
47
- total: 0,
48
- limit: 0,
49
- offset: 0,
50
- }),
51
- {
52
- status: 200,
53
- headers: { "content-type": "application/json" },
54
- },
55
- );
56
- }
57
- return new Response("not found", { status: 404 });
58
- },
59
- });
60
-
61
- try {
62
- const port = server.port;
63
- const proc = spawnHirotm(["boards", "list"], { TASKMANAGER_PORT: String(port) });
64
- const [stdout, stderr] = await Promise.all([
65
- readSubprocessStream(proc.stdout),
66
- readSubprocessStream(proc.stderr),
67
- ]);
68
- const code = await proc.exited;
69
-
70
- expect(code).toBe(0);
71
- expect(stderr.trim()).toBe("");
72
- // Default list output is NDJSON; empty page emits no lines.
73
- expect(stdout.trim()).toBe("");
74
- } finally {
75
- server.stop();
76
- }
77
- });
78
-
79
- test("boards list --format human fixed-width table stdout (exit 0)", async () => {
80
- const server = Bun.serve({
81
- port: 0,
82
- fetch(req) {
83
- const u = new URL(req.url);
84
- if (u.pathname === "/api/boards" && req.method === "GET") {
85
- return new Response(
86
- JSON.stringify({
87
- items: [
88
- {
89
- boardId: 1,
90
- slug: "a",
91
- name: "Alpha",
92
- emoji: null,
93
- },
94
- ],
95
- total: 1,
96
- limit: 1,
97
- offset: 0,
98
- }),
99
- {
100
- status: 200,
101
- headers: { "content-type": "application/json" },
102
- },
103
- );
104
- }
105
- return new Response("not found", { status: 404 });
106
- },
107
- });
108
-
109
- try {
110
- const port = server.port;
111
- const proc = spawnHirotm(
112
- ["boards", "list", "--format", "human"],
113
- { TASKMANAGER_PORT: String(port) },
114
- );
115
- const [stdout, stderr] = await Promise.all([
116
- readSubprocessStream(proc.stdout),
117
- readSubprocessStream(proc.stderr),
118
- ]);
119
- const code = await proc.exited;
120
-
121
- expect(code).toBe(0);
122
- expect(stderr.trim()).toBe("");
123
- expect(stdout).toContain("Slug");
124
- expect(stdout).toContain("Alpha");
125
- expect(stdout).toContain("total 1");
126
- } finally {
127
- server.stop();
128
- }
129
- });
130
-
131
- test("boards list --quiet → one slug per line (exit 0)", async () => {
132
- const server = Bun.serve({
133
- port: 0,
134
- fetch(req) {
135
- const u = new URL(req.url);
136
- if (u.pathname === "/api/boards" && req.method === "GET") {
137
- return new Response(
138
- JSON.stringify({
139
- items: [
140
- {
141
- boardId: 1,
142
- slug: "a",
143
- name: "Alpha",
144
- emoji: null,
145
- },
146
- ],
147
- total: 1,
148
- limit: 1,
149
- offset: 0,
150
- }),
151
- {
152
- status: 200,
153
- headers: { "content-type": "application/json" },
154
- },
155
- );
156
- }
157
- return new Response("not found", { status: 404 });
158
- },
159
- });
160
-
161
- try {
162
- const port = server.port;
163
- const proc = spawnHirotm(
164
- ["--quiet", "boards", "list"],
165
- { TASKMANAGER_PORT: String(port) },
166
- );
167
- const [stdout, stderr] = await Promise.all([
168
- readSubprocessStream(proc.stdout),
169
- readSubprocessStream(proc.stderr),
170
- ]);
171
- const code = await proc.exited;
172
-
173
- expect(code).toBe(0);
174
- expect(stderr.trim()).toBe("");
175
- expect(stdout.trimEnd()).toBe("a");
176
- } finally {
177
- server.stop();
178
- }
179
- });
180
-
181
- test("boards list --quiet --format human → exit 2 (stdout not JSON)", async () => {
182
- const server = Bun.serve({
183
- port: 0,
184
- fetch() {
185
- return new Response("unused", { status: 500 });
186
- },
187
- });
188
-
189
- try {
190
- const proc = spawnHirotm(
191
- ["--quiet", "--format", "human", "boards", "list"],
192
- { TASKMANAGER_PORT: String(server.port) },
193
- );
194
- const [stdout, stderr] = await Promise.all([
195
- readSubprocessStream(proc.stdout),
196
- readSubprocessStream(proc.stderr),
197
- ]);
198
- const code = await proc.exited;
199
-
200
- expect(code).toBe(2);
201
- expect(stdout.trim()).toBe("");
202
- // Global --format human → stderr is plain text, not JSON (same as other human-mode errors).
203
- expect(stderr).toContain("--quiet");
204
- expect(stderr).toContain("ndjson");
205
- } finally {
206
- server.stop();
207
- }
208
- });
209
-
210
- test("boards list with no server → exit 6 and stderr JSON contract", async () => {
211
- const proc = spawnHirotm(["boards", "list"], {
212
- TASKMANAGER_PORT: "59123",
213
- });
214
- const [stdout, stderr] = await Promise.all([
215
- readSubprocessStream(proc.stdout),
216
- readSubprocessStream(proc.stderr),
217
- ]);
218
- const code = await proc.exited;
219
-
220
- expect(code).toBe(6);
221
- expect(stdout.trim()).toBe("");
222
- expect(stderr.trim().split("\n").length).toBe(1);
223
- const err = JSON.parse(stderr.trim()) as Record<string, unknown>;
224
- expect(err.error).toBeDefined();
225
- expect(err.code).toBe("server_unreachable");
226
- expect(err.retryable).toBe(true);
227
- expect(String(err.hint ?? "")).toContain("hirotm");
228
- });
229
-
230
- test("--help exits 0 (bootstrap + Commander)", async () => {
231
- const proc = spawnHirotm(["--help"]);
232
- const stdout = await readSubprocessStream(proc.stdout);
233
- const code = await proc.exited;
234
-
235
- expect(code).toBe(0);
236
- expect(stdout).toContain("Usage:");
237
- expect(stdout.toLowerCase()).toContain("hirotm");
238
- expect(stdout).toContain("--format");
239
- expect(stdout).toContain("--quiet");
240
- });
241
-
242
- test("boards --help and query search --help (aspect 3 discoverability spot-check)", async () => {
243
- const boards = spawnHirotm(["boards", "--help"]);
244
- const boardsOut = await readSubprocessStream(boards.stdout);
245
- expect(await boards.exited).toBe(0);
246
- expect(boardsOut).toContain("Usage:");
247
- expect(boardsOut.toLowerCase()).toContain("boards");
248
-
249
- const qsearch = spawnHirotm(["query", "search", "--help"]);
250
- const qsOut = await readSubprocessStream(qsearch.stdout);
251
- expect(await qsearch.exited).toBe(0);
252
- expect(qsOut).toContain("Usage:");
253
- expect(qsOut).toContain("--board");
254
- });
255
-
256
- test("handler validation: empty query → exit 2 and stderr JSON (no server)", async () => {
257
- const proc = spawnHirotm(["query", "search", ""], {
258
- TASKMANAGER_PORT: "59998",
259
- });
260
- const [stdout, stderr] = await Promise.all([
261
- readSubprocessStream(proc.stdout),
262
- readSubprocessStream(proc.stderr),
263
- ]);
264
- const code = await proc.exited;
265
-
266
- expect(code).toBe(2);
267
- expect(stdout.trim()).toBe("");
268
- const err = JSON.parse(stderr.trim()) as Record<string, unknown>;
269
- expect(err.error).toBe("Query required");
270
- expect(err.code).toBe("missing_required");
271
- });
272
-
273
- test("Commander missing required argument → exit 1 (boards describe)", async () => {
274
- const proc = spawnHirotm(["boards", "describe"]);
275
- const [stdout, stderr] = await Promise.all([
276
- readSubprocessStream(proc.stdout),
277
- readSubprocessStream(proc.stderr),
278
- ]);
279
- const code = await proc.exited;
280
-
281
- expect(code).toBe(1);
282
- expect(stdout.trim()).toBe("");
283
- // Commander prints plain text; project contract prefers exit 2 for usage (see docs/cli-error-handling.md).
284
- expect(stderr).toContain("missing required argument");
285
- });
286
-
287
- test("boards delete without --yes (piped stdin) → exit 2 confirmation_required", async () => {
288
- const server = Bun.serve({
289
- port: 0,
290
- fetch() {
291
- return new Response("unused", { status: 500 });
292
- },
293
- });
294
-
295
- try {
296
- const proc = spawnHirotm(["boards", "delete", "x"], {
297
- TASKMANAGER_PORT: String(server.port),
298
- });
299
- const [stdout, stderr] = await Promise.all([
300
- readSubprocessStream(proc.stdout),
301
- readSubprocessStream(proc.stderr),
302
- ]);
303
- const code = await proc.exited;
304
-
305
- expect(code).toBe(2);
306
- expect(stdout.trim()).toBe("");
307
- expect(stderr).toContain("boards delete");
308
- expect(stderr).toContain("Trash");
309
- const lines = stderr.trim().split("\n").filter((l) => l.length > 0);
310
- const jsonLine = lines[lines.length - 1]!;
311
- const err = JSON.parse(jsonLine) as Record<string, unknown>;
312
- expect(err.code).toBe("confirmation_required");
313
- expect(String(err.hint ?? "")).toContain("--yes");
314
- } finally {
315
- server.stop();
316
- }
317
- });
318
-
319
- test("boards delete --yes with stub API → exit 0 and stdout JSON", async () => {
320
- const server = Bun.serve({
321
- port: 0,
322
- fetch(req) {
323
- const u = new URL(req.url);
324
- if (u.pathname === "/api/boards/x" && req.method === "GET") {
325
- return new Response(
326
- JSON.stringify({
327
- boardId: 1,
328
- slug: "x",
329
- name: "X",
330
- emoji: null,
331
- description: "",
332
- lists: [],
333
- tasks: [],
334
- }),
335
- { status: 200, headers: { "content-type": "application/json" } },
336
- );
337
- }
338
- if (u.pathname === "/api/boards/x" && req.method === "DELETE") {
339
- return new Response(null, { status: 204 });
340
- }
341
- return new Response("not found", { status: 404 });
342
- },
343
- });
344
-
345
- try {
346
- const proc = spawnHirotm(["boards", "delete", "x", "--yes"], {
347
- TASKMANAGER_PORT: String(server.port),
348
- });
349
- const [stdout, stderr] = await Promise.all([
350
- readSubprocessStream(proc.stdout),
351
- readSubprocessStream(proc.stderr),
352
- ]);
353
- const code = await proc.exited;
354
-
355
- expect(code).toBe(0);
356
- expect(stderr.trim()).toBe("");
357
- const line = stdout.trim().split("\n").filter((l) => l.length > 0);
358
- expect(line.length).toBeGreaterThanOrEqual(1);
359
- const row = JSON.parse(line[0]!) as Record<string, unknown>;
360
- expect(row.ok).toBe(true);
361
- } finally {
362
- server.stop();
363
- }
364
- });
365
-
366
- test("boards list with stub 403 → exit 4 and stderr JSON (aspect 3 + 6)", async () => {
367
- const server = Bun.serve({
368
- port: 0,
369
- fetch(req) {
370
- const u = new URL(req.url);
371
- if (u.pathname === "/api/boards" && req.method === "GET") {
372
- return new Response(
373
- JSON.stringify({ error: "denied", code: "forbidden" }),
374
- {
375
- status: 403,
376
- headers: { "content-type": "application/json" },
377
- },
378
- );
379
- }
380
- return new Response("not found", { status: 404 });
381
- },
382
- });
383
-
384
- try {
385
- const proc = spawnHirotm(["boards", "list"], { TASKMANAGER_PORT: String(server.port) });
386
- const [stdout, stderr] = await Promise.all([
387
- readSubprocessStream(proc.stdout),
388
- readSubprocessStream(proc.stderr),
389
- ]);
390
- const code = await proc.exited;
391
-
392
- expect(code).toBe(4);
393
- expect(stdout.trim()).toBe("");
394
- const err = JSON.parse(stderr.trim()) as Record<string, unknown>;
395
- expect(err.error).toBe("denied");
396
- expect(err.code).toBe("forbidden");
397
- } finally {
398
- server.stop();
399
- }
400
- });
401
-
402
- test("boards list + stub 401 → exit 10, stderr JSON unauthenticated", async () => {
403
- const server = Bun.serve({
404
- port: 0,
405
- fetch(req) {
406
- const u = new URL(req.url);
407
- if (u.pathname === "/api/boards" && req.method === "GET") {
408
- return new Response(
409
- JSON.stringify({
410
- error: "unauthenticated",
411
- code: "unauthenticated",
412
- }),
413
- {
414
- status: 401,
415
- headers: { "content-type": "application/json" },
416
- },
417
- );
418
- }
419
- return new Response("not found", { status: 404 });
420
- },
421
- });
422
-
423
- try {
424
- const proc = spawnHirotm(["boards", "list"], { TASKMANAGER_PORT: String(server.port) });
425
- const [stdout, stderr] = await Promise.all([
426
- readSubprocessStream(proc.stdout),
427
- readSubprocessStream(proc.stderr),
428
- ]);
429
- const code = await proc.exited;
430
-
431
- expect(code).toBe(10);
432
- expect(stdout.trim()).toBe("");
433
- const err = JSON.parse(stderr.trim()) as Record<string, unknown>;
434
- expect(err.code).toBe("unauthenticated");
435
- } finally {
436
- server.stop();
437
- }
438
- });
439
-
440
- test("boards list + stub 404 → exit 3, stderr JSON not_found", async () => {
441
- const server = Bun.serve({
442
- port: 0,
443
- fetch(req) {
444
- const u = new URL(req.url);
445
- if (u.pathname === "/api/boards" && req.method === "GET") {
446
- return new Response(
447
- JSON.stringify({ error: "not found", code: "not_found" }),
448
- {
449
- status: 404,
450
- headers: { "content-type": "application/json" },
451
- },
452
- );
453
- }
454
- return new Response("not found", { status: 404 });
455
- },
456
- });
457
-
458
- try {
459
- const proc = spawnHirotm(["boards", "list"], { TASKMANAGER_PORT: String(server.port) });
460
- const [stdout, stderr] = await Promise.all([
461
- readSubprocessStream(proc.stdout),
462
- readSubprocessStream(proc.stderr),
463
- ]);
464
- const code = await proc.exited;
465
-
466
- expect(code).toBe(3);
467
- expect(stdout.trim()).toBe("");
468
- const err = JSON.parse(stderr.trim()) as Record<string, unknown>;
469
- expect(err.code).toBe("not_found");
470
- } finally {
471
- server.stop();
472
- }
473
- });
474
-
475
- test("boards list + stub 409 → exit 5, stderr JSON conflict", async () => {
476
- const server = Bun.serve({
477
- port: 0,
478
- fetch(req) {
479
- const u = new URL(req.url);
480
- if (u.pathname === "/api/boards" && req.method === "GET") {
481
- return new Response(
482
- JSON.stringify({ error: "conflict", code: "conflict" }),
483
- {
484
- status: 409,
485
- headers: { "content-type": "application/json" },
486
- },
487
- );
488
- }
489
- return new Response("not found", { status: 404 });
490
- },
491
- });
492
-
493
- try {
494
- const proc = spawnHirotm(["boards", "list"], { TASKMANAGER_PORT: String(server.port) });
495
- const [stdout, stderr] = await Promise.all([
496
- readSubprocessStream(proc.stdout),
497
- readSubprocessStream(proc.stderr),
498
- ]);
499
- const code = await proc.exited;
500
-
501
- expect(code).toBe(5);
502
- expect(stdout.trim()).toBe("");
503
- const err = JSON.parse(stderr.trim()) as Record<string, unknown>;
504
- expect(err.code).toBe("conflict");
505
- } finally {
506
- server.stop();
507
- }
508
- });
509
-
510
- test("tasks add + stub POST 200 → exit 0, stdout ok true", async () => {
511
- const server = Bun.serve({
512
- port: 0,
513
- fetch(req) {
514
- const u = new URL(req.url);
515
- if (
516
- u.pathname === "/api/boards/b/tasks" &&
517
- req.method === "POST"
518
- ) {
519
- return new Response(
520
- JSON.stringify({
521
- boardId: 1,
522
- boardSlug: "b",
523
- boardUpdatedAt: "2026-01-02T00:00:00.000Z",
524
- entity: {
525
- taskId: 42,
526
- listId: 1,
527
- groupId: 1,
528
- title: "Hello",
529
- body: "",
530
- priorityId: 1,
531
- status: "open",
532
- order: 0,
533
- createdAt: "2026-01-01T00:00:00.000Z",
534
- updatedAt: "2026-01-01T00:00:00.000Z",
535
- },
536
- }),
537
- {
538
- status: 200,
539
- headers: { "content-type": "application/json" },
540
- },
541
- );
542
- }
543
- return new Response("not found", { status: 404 });
544
- },
545
- });
546
-
547
- try {
548
- const proc = spawnHirotm(
549
- [
550
- "tasks",
551
- "add",
552
- "--board",
553
- "b",
554
- "--list",
555
- "1",
556
- "--group",
557
- "1",
558
- "--title",
559
- "Hello",
560
- ],
561
- { TASKMANAGER_PORT: String(server.port) },
562
- );
563
- const [stdout, stderr] = await Promise.all([
564
- readSubprocessStream(proc.stdout),
565
- readSubprocessStream(proc.stderr),
566
- ]);
567
- const code = await proc.exited;
568
-
569
- expect(code).toBe(0);
570
- expect(stderr.trim()).toBe("");
571
- const row = JSON.parse(stdout.trim()) as Record<string, unknown>;
572
- expect(row.ok).toBe(true);
573
- } finally {
574
- server.stop();
575
- }
576
- });
577
-
578
- test("lists add + stub POST 200 → exit 0, stdout envelope", async () => {
579
- const server = Bun.serve({
580
- port: 0,
581
- fetch(req) {
582
- const u = new URL(req.url);
583
- if (u.pathname === "/api/boards/b/lists" && req.method === "POST") {
584
- return new Response(
585
- JSON.stringify({
586
- boardId: 1,
587
- boardSlug: "b",
588
- boardUpdatedAt: "2026-01-02T00:00:00.000Z",
589
- entity: {
590
- listId: 9,
591
- name: "Backlog",
592
- order: 0,
593
- emoji: null,
594
- },
595
- }),
596
- {
597
- status: 200,
598
- headers: { "content-type": "application/json" },
599
- },
600
- );
601
- }
602
- return new Response("not found", { status: 404 });
603
- },
604
- });
605
-
606
- try {
607
- const proc = spawnHirotm(
608
- ["lists", "add", "--board", "b", "Backlog"],
609
- { TASKMANAGER_PORT: String(server.port) },
610
- );
611
- const [stdout, stderr] = await Promise.all([
612
- readSubprocessStream(proc.stdout),
613
- readSubprocessStream(proc.stderr),
614
- ]);
615
- const code = await proc.exited;
616
-
617
- expect(code).toBe(0);
618
- expect(stderr.trim()).toBe("");
619
- const row = JSON.parse(stdout.trim()) as Record<string, unknown>;
620
- expect(row.ok).toBe(true);
621
- expect((row.entity as Record<string, unknown>).type).toBe("list");
622
- } finally {
623
- server.stop();
624
- }
625
- });
626
-
627
- test("releases list + stub GET paginated → exit 0, NDJSON lines", async () => {
628
- const rel = {
629
- releaseId: 1,
630
- name: "1.0",
631
- createdAt: "2026-01-01T00:00:00.000Z",
632
- };
633
- const server = Bun.serve({
634
- port: 0,
635
- fetch(req) {
636
- const u = new URL(req.url);
637
- if (
638
- u.pathname === "/api/boards/b/releases" &&
639
- req.method === "GET"
640
- ) {
641
- return new Response(
642
- JSON.stringify({
643
- items: [rel],
644
- total: 1,
645
- limit: 50,
646
- offset: 0,
647
- }),
648
- {
649
- status: 200,
650
- headers: { "content-type": "application/json" },
651
- },
652
- );
653
- }
654
- return new Response("not found", { status: 404 });
655
- },
656
- });
657
-
658
- try {
659
- const proc = spawnHirotm(
660
- ["releases", "list", "--board", "b"],
661
- { TASKMANAGER_PORT: String(server.port) },
662
- );
663
- const [stdout, stderr] = await Promise.all([
664
- readSubprocessStream(proc.stdout),
665
- readSubprocessStream(proc.stderr),
666
- ]);
667
- const code = await proc.exited;
668
-
669
- expect(code).toBe(0);
670
- expect(stderr.trim()).toBe("");
671
- const lines = stdout.trimEnd().split("\n").filter((l) => l.length > 0);
672
- expect(lines.length).toBe(1);
673
- expect(JSON.parse(lines[0]!)).toMatchObject({ releaseId: 1, name: "1.0" });
674
- } finally {
675
- server.stop();
676
- }
677
- });
678
-
679
- test("statuses list + stub GET array → exit 0, NDJSON lines", async () => {
680
- const row = {
681
- statusId: "open",
682
- label: "Open",
683
- sortOrder: 0,
684
- isClosed: false,
685
- };
686
- const server = Bun.serve({
687
- port: 0,
688
- fetch(req) {
689
- const u = new URL(req.url);
690
- if (u.pathname === "/api/statuses" && req.method === "GET") {
691
- return new Response(JSON.stringify([row]), {
692
- status: 200,
693
- headers: { "content-type": "application/json" },
694
- });
695
- }
696
- return new Response("not found", { status: 404 });
697
- },
698
- });
699
-
700
- try {
701
- const proc = spawnHirotm(["statuses", "list"], { TASKMANAGER_PORT: String(server.port) });
702
- const [stdout, stderr] = await Promise.all([
703
- readSubprocessStream(proc.stdout),
704
- readSubprocessStream(proc.stderr),
705
- ]);
706
- const code = await proc.exited;
707
-
708
- expect(code).toBe(0);
709
- expect(stderr.trim()).toBe("");
710
- const lines = stdout.trimEnd().split("\n").filter((l) => l.length > 0);
711
- expect(lines.length).toBe(1);
712
- expect(JSON.parse(lines[0]!)).toMatchObject({ statusId: "open" });
713
- } finally {
714
- server.stop();
715
- }
716
- });
717
-
718
- test("query search human + stub GET /search → exit 0, table has Board header", async () => {
719
- const hit = {
720
- taskId: 1,
721
- boardId: 1,
722
- boardSlug: "b",
723
- boardName: "B",
724
- listId: 1,
725
- listName: "L",
726
- title: "Task",
727
- snippet: "…test…",
728
- score: 0.1,
729
- };
730
- const server = Bun.serve({
731
- port: 0,
732
- fetch(req) {
733
- const u = new URL(req.url);
734
- if (u.pathname === "/api/search" && req.method === "GET") {
735
- return new Response(
736
- JSON.stringify({
737
- items: [hit],
738
- total: 1,
739
- limit: 20,
740
- offset: 0,
741
- }),
742
- {
743
- status: 200,
744
- headers: { "content-type": "application/json" },
745
- },
746
- );
747
- }
748
- return new Response("not found", { status: 404 });
749
- },
750
- });
751
-
752
- try {
753
- const proc = spawnHirotm(
754
- ["query", "search", "test", "--format", "human"],
755
- { TASKMANAGER_PORT: String(server.port) },
756
- );
757
- const [stdout, stderr] = await Promise.all([
758
- readSubprocessStream(proc.stdout),
759
- readSubprocessStream(proc.stderr),
760
- ]);
761
- const code = await proc.exited;
762
-
763
- expect(code).toBe(0);
764
- expect(stderr.trim()).toBe("");
765
- expect(stdout).toContain("Board");
766
- expect(stdout).toContain("b");
767
- } finally {
768
- server.stop();
769
- }
770
- });
771
-
772
- test("tasks list --board b + stub GET paginated → exit 0, NDJSON", async () => {
773
- const task = {
774
- taskId: 7,
775
- listId: 1,
776
- groupId: 1,
777
- title: "T",
778
- body: "",
779
- priorityId: 1,
780
- status: "open",
781
- order: 0,
782
- createdAt: "2026-01-01T00:00:00.000Z",
783
- updatedAt: "2026-01-01T00:00:00.000Z",
784
- };
785
- const server = Bun.serve({
786
- port: 0,
787
- fetch(req) {
788
- const u = new URL(req.url);
789
- if (
790
- u.pathname === "/api/boards/b/tasks" &&
791
- req.method === "GET"
792
- ) {
793
- return new Response(
794
- JSON.stringify({
795
- items: [task],
796
- total: 1,
797
- limit: 500,
798
- offset: 0,
799
- }),
800
- {
801
- status: 200,
802
- headers: { "content-type": "application/json" },
803
- },
804
- );
805
- }
806
- return new Response("not found", { status: 404 });
807
- },
808
- });
809
-
810
- try {
811
- const proc = spawnHirotm(
812
- ["tasks", "list", "--board", "b"],
813
- { TASKMANAGER_PORT: String(server.port) },
814
- );
815
- const [stdout, stderr] = await Promise.all([
816
- readSubprocessStream(proc.stdout),
817
- readSubprocessStream(proc.stderr),
818
- ]);
819
- const code = await proc.exited;
820
-
821
- expect(code).toBe(0);
822
- expect(stderr.trim()).toBe("");
823
- const lines = stdout.trimEnd().split("\n").filter((l) => l.length > 0);
824
- expect(lines.length).toBe(1);
825
- expect(JSON.parse(lines[0]!)).toMatchObject({ taskId: 7, title: "T" });
826
- } finally {
827
- server.stop();
828
- }
829
- });
830
-
831
- test("boards list --fields boardId → NDJSON lines only project boardId", async () => {
832
- const server = Bun.serve({
833
- port: 0,
834
- fetch(req) {
835
- const u = new URL(req.url);
836
- if (u.pathname === "/api/boards" && req.method === "GET") {
837
- return new Response(
838
- JSON.stringify({
839
- items: [
840
- {
841
- boardId: 1,
842
- slug: "a",
843
- name: "Alpha",
844
- emoji: null,
845
- },
846
- ],
847
- total: 1,
848
- limit: 1,
849
- offset: 0,
850
- }),
851
- {
852
- status: 200,
853
- headers: { "content-type": "application/json" },
854
- },
855
- );
856
- }
857
- return new Response("not found", { status: 404 });
858
- },
859
- });
860
-
861
- try {
862
- const proc = spawnHirotm(
863
- ["boards", "list", "--fields", "boardId"],
864
- { TASKMANAGER_PORT: String(server.port) },
865
- );
866
- const [stdout, stderr] = await Promise.all([
867
- readSubprocessStream(proc.stdout),
868
- readSubprocessStream(proc.stderr),
869
- ]);
870
- const code = await proc.exited;
871
-
872
- expect(code).toBe(0);
873
- expect(stderr.trim()).toBe("");
874
- const lines = stdout.trimEnd().split("\n").filter((l) => l.length > 0);
875
- expect(lines.length).toBe(1);
876
- const obj = JSON.parse(lines[0]!) as Record<string, unknown>;
877
- expect(Object.keys(obj).sort()).toEqual(["boardId"]);
878
- expect(obj.boardId).toBe(1);
879
- } finally {
880
- server.stop();
881
- }
882
- });
883
-
884
- test("--client-name is sent as X-TaskManager-Client-Name on API requests", async () => {
885
- const server = Bun.serve({
886
- port: 0,
887
- fetch(req) {
888
- const u = new URL(req.url);
889
- if (u.pathname === "/api/boards" && req.method === "GET") {
890
- const name = req.headers.get(TASK_MANAGER_CLIENT_NAME_HEADER);
891
- expect(name).toBe("Agent");
892
- return new Response(
893
- JSON.stringify({
894
- items: [],
895
- total: 0,
896
- limit: 0,
897
- offset: 0,
898
- }),
899
- {
900
- status: 200,
901
- headers: { "content-type": "application/json" },
902
- },
903
- );
904
- }
905
- return new Response("not found", { status: 404 });
906
- },
907
- });
908
-
909
- try {
910
- const proc = spawnHirotm(
911
- ["--client-name", "Agent", "boards", "list"],
912
- { TASKMANAGER_PORT: String(server.port) },
913
- );
914
- const [stdout, stderr] = await Promise.all([
915
- readSubprocessStream(proc.stdout),
916
- readSubprocessStream(proc.stderr),
917
- ]);
918
- const code = await proc.exited;
919
-
920
- expect(code).toBe(0);
921
- expect(stderr.trim()).toBe("");
922
- expect(stdout.trim()).toBe("");
923
- } finally {
924
- server.stop();
925
- }
926
- });
927
-
928
- test("boards list --format human + empty stub → stdout contains No rows.", async () => {
929
- const server = Bun.serve({
930
- port: 0,
931
- fetch(req) {
932
- const u = new URL(req.url);
933
- if (u.pathname === "/api/boards" && req.method === "GET") {
934
- return new Response(
935
- JSON.stringify({
936
- items: [],
937
- total: 0,
938
- limit: 0,
939
- offset: 0,
940
- }),
941
- {
942
- status: 200,
943
- headers: { "content-type": "application/json" },
944
- },
945
- );
946
- }
947
- return new Response("not found", { status: 404 });
948
- },
949
- });
950
-
951
- try {
952
- const proc = spawnHirotm(
953
- ["boards", "list", "--format", "human"],
954
- { TASKMANAGER_PORT: String(server.port) },
955
- );
956
- const [stdout, stderr] = await Promise.all([
957
- readSubprocessStream(proc.stdout),
958
- readSubprocessStream(proc.stderr),
959
- ]);
960
- const code = await proc.exited;
961
-
962
- expect(code).toBe(0);
963
- expect(stderr.trim()).toBe("");
964
- expect(stdout).toContain("No rows.");
965
- } finally {
966
- server.stop();
967
- }
968
- });
969
- });
1
+ /**
2
+ * Aspect 4 — integration depth: subprocess smoke.
3
+ * Spawns the real `hirotm` entry (argv → Commander → handlers) to catch wiring issues
4
+ * that in-process handler tests miss.
5
+ */
6
+ import { describe, expect, test } from "bun:test";
7
+ import path from "node:path";
8
+ import { TASK_MANAGER_CLIENT_NAME_HEADER } from "../shared/boardCliAccess";
9
+
10
+ const repoRoot = path.resolve(import.meta.dir, "..", "..");
11
+ const hirotmEntry = path.join(repoRoot, "src", "cli", "bin", "hirotm.ts");
12
+
13
+ function withPort(port: number | undefined, args: string[]): string[] {
14
+ if (port === undefined || !Number.isFinite(port)) {
15
+ throw new Error("withPort: expected a listening port from Bun.serve");
16
+ }
17
+ return ["--port", String(port), ...args];
18
+ }
19
+
20
+ async function readSubprocessStream(
21
+ stream: ReturnType<typeof Bun.spawn>["stdout"],
22
+ ): Promise<string> {
23
+ // When `stdout: "pipe"`, Bun exposes a ReadableStream; types also allow fd numbers.
24
+ if (stream == null || typeof stream === "number") return "";
25
+ return await new Response(stream as ReadableStream<Uint8Array>).text();
26
+ }
27
+
28
+ function spawnHirotm(
29
+ args: string[],
30
+ envOverrides?: Record<string, string>,
31
+ ): ReturnType<typeof Bun.spawn> {
32
+ return Bun.spawn({
33
+ cmd: ["bun", "run", hirotmEntry, ...args],
34
+ cwd: repoRoot,
35
+ stdout: "pipe",
36
+ stderr: "pipe",
37
+ env:
38
+ envOverrides == null
39
+ ? process.env
40
+ : { ...process.env, ...envOverrides },
41
+ });
42
+ }
43
+
44
+ describe("hirotm subprocess smoke (aspect 4)", () => {
45
+ test("boards list hits stub API and prints NDJSON stdout (empty page → no lines) (exit 0)", async () => {
46
+ const server = Bun.serve({
47
+ port: 0,
48
+ fetch(req) {
49
+ const u = new URL(req.url);
50
+ if (u.pathname === "/api/boards" && req.method === "GET") {
51
+ return new Response(
52
+ JSON.stringify({
53
+ items: [],
54
+ total: 0,
55
+ limit: 0,
56
+ offset: 0,
57
+ }),
58
+ {
59
+ status: 200,
60
+ headers: { "content-type": "application/json" },
61
+ },
62
+ );
63
+ }
64
+ return new Response("not found", { status: 404 });
65
+ },
66
+ });
67
+
68
+ try {
69
+ const port = server.port;
70
+ const proc = spawnHirotm(withPort(port, ["boards", "list"]));
71
+ const [stdout, stderr] = await Promise.all([
72
+ readSubprocessStream(proc.stdout),
73
+ readSubprocessStream(proc.stderr),
74
+ ]);
75
+ const code = await proc.exited;
76
+
77
+ expect(code).toBe(0);
78
+ expect(stderr.trim()).toBe("");
79
+ // Default list output is NDJSON; empty page emits no lines.
80
+ expect(stdout.trim()).toBe("");
81
+ } finally {
82
+ server.stop();
83
+ }
84
+ });
85
+
86
+ test("boards list --format human → fixed-width table stdout (exit 0)", async () => {
87
+ const server = Bun.serve({
88
+ port: 0,
89
+ fetch(req) {
90
+ const u = new URL(req.url);
91
+ if (u.pathname === "/api/boards" && req.method === "GET") {
92
+ return new Response(
93
+ JSON.stringify({
94
+ items: [
95
+ {
96
+ boardId: 1,
97
+ slug: "a",
98
+ name: "Alpha",
99
+ emoji: null,
100
+ },
101
+ ],
102
+ total: 1,
103
+ limit: 1,
104
+ offset: 0,
105
+ }),
106
+ {
107
+ status: 200,
108
+ headers: { "content-type": "application/json" },
109
+ },
110
+ );
111
+ }
112
+ return new Response("not found", { status: 404 });
113
+ },
114
+ });
115
+
116
+ try {
117
+ const port = server.port;
118
+ const proc = spawnHirotm(withPort(port, ["boards", "list", "--format", "human"]));
119
+ const [stdout, stderr] = await Promise.all([
120
+ readSubprocessStream(proc.stdout),
121
+ readSubprocessStream(proc.stderr),
122
+ ]);
123
+ const code = await proc.exited;
124
+
125
+ expect(code).toBe(0);
126
+ expect(stderr.trim()).toBe("");
127
+ expect(stdout).toContain("Slug");
128
+ expect(stdout).toContain("Alpha");
129
+ expect(stdout).toContain("total 1");
130
+ } finally {
131
+ server.stop();
132
+ }
133
+ });
134
+
135
+ test("boards list --quiet one slug per line (exit 0)", async () => {
136
+ const server = Bun.serve({
137
+ port: 0,
138
+ fetch(req) {
139
+ const u = new URL(req.url);
140
+ if (u.pathname === "/api/boards" && req.method === "GET") {
141
+ return new Response(
142
+ JSON.stringify({
143
+ items: [
144
+ {
145
+ boardId: 1,
146
+ slug: "a",
147
+ name: "Alpha",
148
+ emoji: null,
149
+ },
150
+ ],
151
+ total: 1,
152
+ limit: 1,
153
+ offset: 0,
154
+ }),
155
+ {
156
+ status: 200,
157
+ headers: { "content-type": "application/json" },
158
+ },
159
+ );
160
+ }
161
+ return new Response("not found", { status: 404 });
162
+ },
163
+ });
164
+
165
+ try {
166
+ const port = server.port;
167
+ const proc = spawnHirotm(withPort(port, ["--quiet", "boards", "list"]));
168
+ const [stdout, stderr] = await Promise.all([
169
+ readSubprocessStream(proc.stdout),
170
+ readSubprocessStream(proc.stderr),
171
+ ]);
172
+ const code = await proc.exited;
173
+
174
+ expect(code).toBe(0);
175
+ expect(stderr.trim()).toBe("");
176
+ expect(stdout.trimEnd()).toBe("a");
177
+ } finally {
178
+ server.stop();
179
+ }
180
+ });
181
+
182
+ test("boards list --quiet --format human → exit 2 (stdout not JSON)", async () => {
183
+ const server = Bun.serve({
184
+ port: 0,
185
+ fetch() {
186
+ return new Response("unused", { status: 500 });
187
+ },
188
+ });
189
+
190
+ try {
191
+ const proc = spawnHirotm(
192
+ withPort(server.port, ["--quiet", "--format", "human", "boards", "list"]),
193
+ );
194
+ const [stdout, stderr] = await Promise.all([
195
+ readSubprocessStream(proc.stdout),
196
+ readSubprocessStream(proc.stderr),
197
+ ]);
198
+ const code = await proc.exited;
199
+
200
+ expect(code).toBe(2);
201
+ expect(stdout.trim()).toBe("");
202
+ // Global --format human → stderr is plain text, not JSON (same as other human-mode errors).
203
+ expect(stderr).toContain("--quiet");
204
+ expect(stderr).toContain("ndjson");
205
+ } finally {
206
+ server.stop();
207
+ }
208
+ });
209
+
210
+ test("boards list with no server → exit 6 and stderr JSON contract", async () => {
211
+ const proc = spawnHirotm(withPort(59123, ["boards", "list"]));
212
+ const [stdout, stderr] = await Promise.all([
213
+ readSubprocessStream(proc.stdout),
214
+ readSubprocessStream(proc.stderr),
215
+ ]);
216
+ const code = await proc.exited;
217
+
218
+ expect(code).toBe(6);
219
+ expect(stdout.trim()).toBe("");
220
+ expect(stderr.trim().split("\n").length).toBe(1);
221
+ const err = JSON.parse(stderr.trim()) as Record<string, unknown>;
222
+ expect(err.error).toBeDefined();
223
+ expect(err.code).toBe("server_unreachable");
224
+ expect(err.retryable).toBe(true);
225
+ expect(String(err.hint ?? "")).toContain("hirotm");
226
+ });
227
+
228
+ test("--help exits 0 (bootstrap + Commander)", async () => {
229
+ const proc = spawnHirotm(["--help"]);
230
+ const stdout = await readSubprocessStream(proc.stdout);
231
+ const code = await proc.exited;
232
+
233
+ expect(code).toBe(0);
234
+ expect(stdout).toContain("Usage:");
235
+ expect(stdout.toLowerCase()).toContain("hirotm");
236
+ expect(stdout).toContain("--format");
237
+ expect(stdout).toContain("--quiet");
238
+ expect(stdout).toContain("--port");
239
+ });
240
+
241
+ test("boards --help and query search --help (aspect 3 discoverability spot-check)", async () => {
242
+ const boards = spawnHirotm(["boards", "--help"]);
243
+ const boardsOut = await readSubprocessStream(boards.stdout);
244
+ expect(await boards.exited).toBe(0);
245
+ expect(boardsOut).toContain("Usage:");
246
+ expect(boardsOut.toLowerCase()).toContain("boards");
247
+
248
+ const qsearch = spawnHirotm(["query", "search", "--help"]);
249
+ const qsOut = await readSubprocessStream(qsearch.stdout);
250
+ expect(await qsearch.exited).toBe(0);
251
+ expect(qsOut).toContain("Usage:");
252
+ expect(qsOut).toContain("--board");
253
+ });
254
+
255
+ test("handler validation: empty query → exit 2 and stderr JSON (no server)", async () => {
256
+ const proc = spawnHirotm(withPort(59998, ["query", "search", ""]));
257
+ const [stdout, stderr] = await Promise.all([
258
+ readSubprocessStream(proc.stdout),
259
+ readSubprocessStream(proc.stderr),
260
+ ]);
261
+ const code = await proc.exited;
262
+
263
+ expect(code).toBe(2);
264
+ expect(stdout.trim()).toBe("");
265
+ const err = JSON.parse(stderr.trim()) as Record<string, unknown>;
266
+ expect(err.error).toBe("Query required");
267
+ expect(err.code).toBe("missing_required");
268
+ });
269
+
270
+ test("Commander missing required argument → exit 1 (boards describe)", async () => {
271
+ const proc = spawnHirotm(["boards", "describe"]);
272
+ const [stdout, stderr] = await Promise.all([
273
+ readSubprocessStream(proc.stdout),
274
+ readSubprocessStream(proc.stderr),
275
+ ]);
276
+ const code = await proc.exited;
277
+
278
+ expect(code).toBe(1);
279
+ expect(stdout.trim()).toBe("");
280
+ // Commander prints plain text; project contract prefers exit 2 for usage (see docs/cli-error-handling.md).
281
+ expect(stderr).toContain("missing required argument");
282
+ });
283
+
284
+ test("boards delete without --yes (piped stdin) → exit 2 confirmation_required", async () => {
285
+ const server = Bun.serve({
286
+ port: 0,
287
+ fetch() {
288
+ return new Response("unused", { status: 500 });
289
+ },
290
+ });
291
+
292
+ try {
293
+ const proc = spawnHirotm(withPort(server.port, ["boards", "delete", "x"]));
294
+ const [stdout, stderr] = await Promise.all([
295
+ readSubprocessStream(proc.stdout),
296
+ readSubprocessStream(proc.stderr),
297
+ ]);
298
+ const code = await proc.exited;
299
+
300
+ expect(code).toBe(2);
301
+ expect(stdout.trim()).toBe("");
302
+ expect(stderr).toContain("boards delete");
303
+ expect(stderr).toContain("Trash");
304
+ const lines = stderr.trim().split("\n").filter((l) => l.length > 0);
305
+ const jsonLine = lines[lines.length - 1]!;
306
+ const err = JSON.parse(jsonLine) as Record<string, unknown>;
307
+ expect(err.code).toBe("confirmation_required");
308
+ expect(String(err.hint ?? "")).toContain("--yes");
309
+ } finally {
310
+ server.stop();
311
+ }
312
+ });
313
+
314
+ test("boards delete --yes with stub API → exit 0 and stdout JSON", async () => {
315
+ const server = Bun.serve({
316
+ port: 0,
317
+ fetch(req) {
318
+ const u = new URL(req.url);
319
+ if (u.pathname === "/api/boards/x" && req.method === "GET") {
320
+ return new Response(
321
+ JSON.stringify({
322
+ boardId: 1,
323
+ slug: "x",
324
+ name: "X",
325
+ emoji: null,
326
+ description: "",
327
+ lists: [],
328
+ tasks: [],
329
+ }),
330
+ { status: 200, headers: { "content-type": "application/json" } },
331
+ );
332
+ }
333
+ if (u.pathname === "/api/boards/x" && req.method === "DELETE") {
334
+ return new Response(null, { status: 204 });
335
+ }
336
+ return new Response("not found", { status: 404 });
337
+ },
338
+ });
339
+
340
+ try {
341
+ const proc = spawnHirotm(withPort(server.port, ["boards", "delete", "x", "--yes"]));
342
+ const [stdout, stderr] = await Promise.all([
343
+ readSubprocessStream(proc.stdout),
344
+ readSubprocessStream(proc.stderr),
345
+ ]);
346
+ const code = await proc.exited;
347
+
348
+ expect(code).toBe(0);
349
+ expect(stderr.trim()).toBe("");
350
+ const line = stdout.trim().split("\n").filter((l) => l.length > 0);
351
+ expect(line.length).toBeGreaterThanOrEqual(1);
352
+ const row = JSON.parse(line[0]!) as Record<string, unknown>;
353
+ expect(row.ok).toBe(true);
354
+ } finally {
355
+ server.stop();
356
+ }
357
+ });
358
+
359
+ test("boards list with stub 403 → exit 4 and stderr JSON (aspect 3 + 6)", async () => {
360
+ const server = Bun.serve({
361
+ port: 0,
362
+ fetch(req) {
363
+ const u = new URL(req.url);
364
+ if (u.pathname === "/api/boards" && req.method === "GET") {
365
+ return new Response(
366
+ JSON.stringify({ error: "denied", code: "forbidden" }),
367
+ {
368
+ status: 403,
369
+ headers: { "content-type": "application/json" },
370
+ },
371
+ );
372
+ }
373
+ return new Response("not found", { status: 404 });
374
+ },
375
+ });
376
+
377
+ try {
378
+ const proc = spawnHirotm(withPort(server.port, ["boards", "list"]));
379
+ const [stdout, stderr] = await Promise.all([
380
+ readSubprocessStream(proc.stdout),
381
+ readSubprocessStream(proc.stderr),
382
+ ]);
383
+ const code = await proc.exited;
384
+
385
+ expect(code).toBe(4);
386
+ expect(stdout.trim()).toBe("");
387
+ const err = JSON.parse(stderr.trim()) as Record<string, unknown>;
388
+ expect(err.error).toBe("denied");
389
+ expect(err.code).toBe("forbidden");
390
+ } finally {
391
+ server.stop();
392
+ }
393
+ });
394
+
395
+ test("boards list + stub 401 → exit 10, stderr JSON unauthenticated", async () => {
396
+ const server = Bun.serve({
397
+ port: 0,
398
+ fetch(req) {
399
+ const u = new URL(req.url);
400
+ if (u.pathname === "/api/boards" && req.method === "GET") {
401
+ return new Response(
402
+ JSON.stringify({
403
+ error: "unauthenticated",
404
+ code: "unauthenticated",
405
+ }),
406
+ {
407
+ status: 401,
408
+ headers: { "content-type": "application/json" },
409
+ },
410
+ );
411
+ }
412
+ return new Response("not found", { status: 404 });
413
+ },
414
+ });
415
+
416
+ try {
417
+ const proc = spawnHirotm(withPort(server.port, ["boards", "list"]));
418
+ const [stdout, stderr] = await Promise.all([
419
+ readSubprocessStream(proc.stdout),
420
+ readSubprocessStream(proc.stderr),
421
+ ]);
422
+ const code = await proc.exited;
423
+
424
+ expect(code).toBe(10);
425
+ expect(stdout.trim()).toBe("");
426
+ const err = JSON.parse(stderr.trim()) as Record<string, unknown>;
427
+ expect(err.code).toBe("unauthenticated");
428
+ } finally {
429
+ server.stop();
430
+ }
431
+ });
432
+
433
+ test("boards list + stub 404 → exit 3, stderr JSON not_found", async () => {
434
+ const server = Bun.serve({
435
+ port: 0,
436
+ fetch(req) {
437
+ const u = new URL(req.url);
438
+ if (u.pathname === "/api/boards" && req.method === "GET") {
439
+ return new Response(
440
+ JSON.stringify({ error: "not found", code: "not_found" }),
441
+ {
442
+ status: 404,
443
+ headers: { "content-type": "application/json" },
444
+ },
445
+ );
446
+ }
447
+ return new Response("not found", { status: 404 });
448
+ },
449
+ });
450
+
451
+ try {
452
+ const proc = spawnHirotm(withPort(server.port, ["boards", "list"]));
453
+ const [stdout, stderr] = await Promise.all([
454
+ readSubprocessStream(proc.stdout),
455
+ readSubprocessStream(proc.stderr),
456
+ ]);
457
+ const code = await proc.exited;
458
+
459
+ expect(code).toBe(3);
460
+ expect(stdout.trim()).toBe("");
461
+ const err = JSON.parse(stderr.trim()) as Record<string, unknown>;
462
+ expect(err.code).toBe("not_found");
463
+ } finally {
464
+ server.stop();
465
+ }
466
+ });
467
+
468
+ test("boards list + stub 409 → exit 5, stderr JSON conflict", async () => {
469
+ const server = Bun.serve({
470
+ port: 0,
471
+ fetch(req) {
472
+ const u = new URL(req.url);
473
+ if (u.pathname === "/api/boards" && req.method === "GET") {
474
+ return new Response(
475
+ JSON.stringify({ error: "conflict", code: "conflict" }),
476
+ {
477
+ status: 409,
478
+ headers: { "content-type": "application/json" },
479
+ },
480
+ );
481
+ }
482
+ return new Response("not found", { status: 404 });
483
+ },
484
+ });
485
+
486
+ try {
487
+ const proc = spawnHirotm(withPort(server.port, ["boards", "list"]));
488
+ const [stdout, stderr] = await Promise.all([
489
+ readSubprocessStream(proc.stdout),
490
+ readSubprocessStream(proc.stderr),
491
+ ]);
492
+ const code = await proc.exited;
493
+
494
+ expect(code).toBe(5);
495
+ expect(stdout.trim()).toBe("");
496
+ const err = JSON.parse(stderr.trim()) as Record<string, unknown>;
497
+ expect(err.code).toBe("conflict");
498
+ } finally {
499
+ server.stop();
500
+ }
501
+ });
502
+
503
+ test("tasks add + stub POST 200 → exit 0, stdout ok true", async () => {
504
+ const server = Bun.serve({
505
+ port: 0,
506
+ fetch(req) {
507
+ const u = new URL(req.url);
508
+ if (
509
+ u.pathname === "/api/boards/b/tasks" &&
510
+ req.method === "POST"
511
+ ) {
512
+ return new Response(
513
+ JSON.stringify({
514
+ boardId: 1,
515
+ boardSlug: "b",
516
+ boardUpdatedAt: "2026-01-02T00:00:00.000Z",
517
+ entity: {
518
+ taskId: 42,
519
+ listId: 1,
520
+ groupId: 1,
521
+ title: "Hello",
522
+ body: "",
523
+ priorityId: 1,
524
+ status: "open",
525
+ order: 0,
526
+ createdAt: "2026-01-01T00:00:00.000Z",
527
+ updatedAt: "2026-01-01T00:00:00.000Z",
528
+ },
529
+ }),
530
+ {
531
+ status: 200,
532
+ headers: { "content-type": "application/json" },
533
+ },
534
+ );
535
+ }
536
+ return new Response("not found", { status: 404 });
537
+ },
538
+ });
539
+
540
+ try {
541
+ const proc = spawnHirotm(
542
+ withPort(server.port, [
543
+ "tasks",
544
+ "add",
545
+ "--board",
546
+ "b",
547
+ "--list",
548
+ "1",
549
+ "--group",
550
+ "1",
551
+ "--title",
552
+ "Hello",
553
+ ]),
554
+ );
555
+ const [stdout, stderr] = await Promise.all([
556
+ readSubprocessStream(proc.stdout),
557
+ readSubprocessStream(proc.stderr),
558
+ ]);
559
+ const code = await proc.exited;
560
+
561
+ expect(code).toBe(0);
562
+ expect(stderr.trim()).toBe("");
563
+ const row = JSON.parse(stdout.trim()) as Record<string, unknown>;
564
+ expect(row.ok).toBe(true);
565
+ } finally {
566
+ server.stop();
567
+ }
568
+ });
569
+
570
+ test("lists add + stub POST 200 → exit 0, stdout envelope", async () => {
571
+ const server = Bun.serve({
572
+ port: 0,
573
+ fetch(req) {
574
+ const u = new URL(req.url);
575
+ if (u.pathname === "/api/boards/b/lists" && req.method === "POST") {
576
+ return new Response(
577
+ JSON.stringify({
578
+ boardId: 1,
579
+ boardSlug: "b",
580
+ boardUpdatedAt: "2026-01-02T00:00:00.000Z",
581
+ entity: {
582
+ listId: 9,
583
+ name: "Backlog",
584
+ order: 0,
585
+ emoji: null,
586
+ },
587
+ }),
588
+ {
589
+ status: 200,
590
+ headers: { "content-type": "application/json" },
591
+ },
592
+ );
593
+ }
594
+ return new Response("not found", { status: 404 });
595
+ },
596
+ });
597
+
598
+ try {
599
+ const proc = spawnHirotm(
600
+ withPort(server.port, ["lists", "add", "--board", "b", "Backlog"]),
601
+ );
602
+ const [stdout, stderr] = await Promise.all([
603
+ readSubprocessStream(proc.stdout),
604
+ readSubprocessStream(proc.stderr),
605
+ ]);
606
+ const code = await proc.exited;
607
+
608
+ expect(code).toBe(0);
609
+ expect(stderr.trim()).toBe("");
610
+ const row = JSON.parse(stdout.trim()) as Record<string, unknown>;
611
+ expect(row.ok).toBe(true);
612
+ expect((row.entity as Record<string, unknown>).type).toBe("list");
613
+ } finally {
614
+ server.stop();
615
+ }
616
+ });
617
+
618
+ test("releases list + stub GET paginated → exit 0, NDJSON lines", async () => {
619
+ const rel = {
620
+ releaseId: 1,
621
+ name: "1.0",
622
+ createdAt: "2026-01-01T00:00:00.000Z",
623
+ };
624
+ const server = Bun.serve({
625
+ port: 0,
626
+ fetch(req) {
627
+ const u = new URL(req.url);
628
+ if (
629
+ u.pathname === "/api/boards/b/releases" &&
630
+ req.method === "GET"
631
+ ) {
632
+ return new Response(
633
+ JSON.stringify({
634
+ items: [rel],
635
+ total: 1,
636
+ limit: 50,
637
+ offset: 0,
638
+ }),
639
+ {
640
+ status: 200,
641
+ headers: { "content-type": "application/json" },
642
+ },
643
+ );
644
+ }
645
+ return new Response("not found", { status: 404 });
646
+ },
647
+ });
648
+
649
+ try {
650
+ const proc = spawnHirotm(
651
+ withPort(server.port, ["releases", "list", "--board", "b"]),
652
+ );
653
+ const [stdout, stderr] = await Promise.all([
654
+ readSubprocessStream(proc.stdout),
655
+ readSubprocessStream(proc.stderr),
656
+ ]);
657
+ const code = await proc.exited;
658
+
659
+ expect(code).toBe(0);
660
+ expect(stderr.trim()).toBe("");
661
+ const lines = stdout.trimEnd().split("\n").filter((l) => l.length > 0);
662
+ expect(lines.length).toBe(1);
663
+ expect(JSON.parse(lines[0]!)).toMatchObject({ releaseId: 1, name: "1.0" });
664
+ } finally {
665
+ server.stop();
666
+ }
667
+ });
668
+
669
+ test("statuses list + stub GET array → exit 0, NDJSON lines", async () => {
670
+ const row = {
671
+ statusId: "open",
672
+ label: "Open",
673
+ sortOrder: 0,
674
+ isClosed: false,
675
+ };
676
+ const server = Bun.serve({
677
+ port: 0,
678
+ fetch(req) {
679
+ const u = new URL(req.url);
680
+ if (u.pathname === "/api/statuses" && req.method === "GET") {
681
+ return new Response(JSON.stringify([row]), {
682
+ status: 200,
683
+ headers: { "content-type": "application/json" },
684
+ });
685
+ }
686
+ return new Response("not found", { status: 404 });
687
+ },
688
+ });
689
+
690
+ try {
691
+ const proc = spawnHirotm(withPort(server.port, ["statuses", "list"]));
692
+ const [stdout, stderr] = await Promise.all([
693
+ readSubprocessStream(proc.stdout),
694
+ readSubprocessStream(proc.stderr),
695
+ ]);
696
+ const code = await proc.exited;
697
+
698
+ expect(code).toBe(0);
699
+ expect(stderr.trim()).toBe("");
700
+ const lines = stdout.trimEnd().split("\n").filter((l) => l.length > 0);
701
+ expect(lines.length).toBe(1);
702
+ expect(JSON.parse(lines[0]!)).toMatchObject({ statusId: "open" });
703
+ } finally {
704
+ server.stop();
705
+ }
706
+ });
707
+
708
+ test("query search human + stub GET /search → exit 0, table has Board header", async () => {
709
+ const hit = {
710
+ taskId: 1,
711
+ boardId: 1,
712
+ boardSlug: "b",
713
+ boardName: "B",
714
+ listId: 1,
715
+ listName: "L",
716
+ title: "Task",
717
+ snippet: "…test…",
718
+ score: 0.1,
719
+ };
720
+ const server = Bun.serve({
721
+ port: 0,
722
+ fetch(req) {
723
+ const u = new URL(req.url);
724
+ if (u.pathname === "/api/search" && req.method === "GET") {
725
+ return new Response(
726
+ JSON.stringify({
727
+ items: [hit],
728
+ total: 1,
729
+ limit: 20,
730
+ offset: 0,
731
+ }),
732
+ {
733
+ status: 200,
734
+ headers: { "content-type": "application/json" },
735
+ },
736
+ );
737
+ }
738
+ return new Response("not found", { status: 404 });
739
+ },
740
+ });
741
+
742
+ try {
743
+ const proc = spawnHirotm(
744
+ withPort(server.port, ["query", "search", "test", "--format", "human"]),
745
+ );
746
+ const [stdout, stderr] = await Promise.all([
747
+ readSubprocessStream(proc.stdout),
748
+ readSubprocessStream(proc.stderr),
749
+ ]);
750
+ const code = await proc.exited;
751
+
752
+ expect(code).toBe(0);
753
+ expect(stderr.trim()).toBe("");
754
+ expect(stdout).toContain("Board");
755
+ expect(stdout).toContain("b");
756
+ } finally {
757
+ server.stop();
758
+ }
759
+ });
760
+
761
+ test("tasks list --board b + stub GET paginated → exit 0, NDJSON", async () => {
762
+ const task = {
763
+ taskId: 7,
764
+ listId: 1,
765
+ groupId: 1,
766
+ title: "T",
767
+ body: "",
768
+ priorityId: 1,
769
+ status: "open",
770
+ order: 0,
771
+ createdAt: "2026-01-01T00:00:00.000Z",
772
+ updatedAt: "2026-01-01T00:00:00.000Z",
773
+ };
774
+ const server = Bun.serve({
775
+ port: 0,
776
+ fetch(req) {
777
+ const u = new URL(req.url);
778
+ if (
779
+ u.pathname === "/api/boards/b/tasks" &&
780
+ req.method === "GET"
781
+ ) {
782
+ return new Response(
783
+ JSON.stringify({
784
+ items: [task],
785
+ total: 1,
786
+ limit: 500,
787
+ offset: 0,
788
+ }),
789
+ {
790
+ status: 200,
791
+ headers: { "content-type": "application/json" },
792
+ },
793
+ );
794
+ }
795
+ return new Response("not found", { status: 404 });
796
+ },
797
+ });
798
+
799
+ try {
800
+ const proc = spawnHirotm(
801
+ withPort(server.port, ["tasks", "list", "--board", "b"]),
802
+ );
803
+ const [stdout, stderr] = await Promise.all([
804
+ readSubprocessStream(proc.stdout),
805
+ readSubprocessStream(proc.stderr),
806
+ ]);
807
+ const code = await proc.exited;
808
+
809
+ expect(code).toBe(0);
810
+ expect(stderr.trim()).toBe("");
811
+ const lines = stdout.trimEnd().split("\n").filter((l) => l.length > 0);
812
+ expect(lines.length).toBe(1);
813
+ expect(JSON.parse(lines[0]!)).toMatchObject({ taskId: 7, title: "T" });
814
+ } finally {
815
+ server.stop();
816
+ }
817
+ });
818
+
819
+ test("boards list --fields boardId → NDJSON lines only project boardId", async () => {
820
+ const server = Bun.serve({
821
+ port: 0,
822
+ fetch(req) {
823
+ const u = new URL(req.url);
824
+ if (u.pathname === "/api/boards" && req.method === "GET") {
825
+ return new Response(
826
+ JSON.stringify({
827
+ items: [
828
+ {
829
+ boardId: 1,
830
+ slug: "a",
831
+ name: "Alpha",
832
+ emoji: null,
833
+ },
834
+ ],
835
+ total: 1,
836
+ limit: 1,
837
+ offset: 0,
838
+ }),
839
+ {
840
+ status: 200,
841
+ headers: { "content-type": "application/json" },
842
+ },
843
+ );
844
+ }
845
+ return new Response("not found", { status: 404 });
846
+ },
847
+ });
848
+
849
+ try {
850
+ const proc = spawnHirotm(
851
+ withPort(server.port, ["boards", "list", "--fields", "boardId"]),
852
+ );
853
+ const [stdout, stderr] = await Promise.all([
854
+ readSubprocessStream(proc.stdout),
855
+ readSubprocessStream(proc.stderr),
856
+ ]);
857
+ const code = await proc.exited;
858
+
859
+ expect(code).toBe(0);
860
+ expect(stderr.trim()).toBe("");
861
+ const lines = stdout.trimEnd().split("\n").filter((l) => l.length > 0);
862
+ expect(lines.length).toBe(1);
863
+ const obj = JSON.parse(lines[0]!) as Record<string, unknown>;
864
+ expect(Object.keys(obj).sort()).toEqual(["boardId"]);
865
+ expect(obj.boardId).toBe(1);
866
+ } finally {
867
+ server.stop();
868
+ }
869
+ });
870
+
871
+ test("--client-name is sent as X-TaskManager-Client-Name on API requests", async () => {
872
+ const server = Bun.serve({
873
+ port: 0,
874
+ fetch(req) {
875
+ const u = new URL(req.url);
876
+ if (u.pathname === "/api/boards" && req.method === "GET") {
877
+ const name = req.headers.get(TASK_MANAGER_CLIENT_NAME_HEADER);
878
+ expect(name).toBe("Agent");
879
+ return new Response(
880
+ JSON.stringify({
881
+ items: [],
882
+ total: 0,
883
+ limit: 0,
884
+ offset: 0,
885
+ }),
886
+ {
887
+ status: 200,
888
+ headers: { "content-type": "application/json" },
889
+ },
890
+ );
891
+ }
892
+ return new Response("not found", { status: 404 });
893
+ },
894
+ });
895
+
896
+ try {
897
+ const proc = spawnHirotm(
898
+ withPort(server.port, ["--client-name", "Agent", "boards", "list"]),
899
+ );
900
+ const [stdout, stderr] = await Promise.all([
901
+ readSubprocessStream(proc.stdout),
902
+ readSubprocessStream(proc.stderr),
903
+ ]);
904
+ const code = await proc.exited;
905
+
906
+ expect(code).toBe(0);
907
+ expect(stderr.trim()).toBe("");
908
+ expect(stdout.trim()).toBe("");
909
+ } finally {
910
+ server.stop();
911
+ }
912
+ });
913
+
914
+ test("boards list --format human + empty stub → stdout contains No rows.", async () => {
915
+ const server = Bun.serve({
916
+ port: 0,
917
+ fetch(req) {
918
+ const u = new URL(req.url);
919
+ if (u.pathname === "/api/boards" && req.method === "GET") {
920
+ return new Response(
921
+ JSON.stringify({
922
+ items: [],
923
+ total: 0,
924
+ limit: 0,
925
+ offset: 0,
926
+ }),
927
+ {
928
+ status: 200,
929
+ headers: { "content-type": "application/json" },
930
+ },
931
+ );
932
+ }
933
+ return new Response("not found", { status: 404 });
934
+ },
935
+ });
936
+
937
+ try {
938
+ const proc = spawnHirotm(
939
+ withPort(server.port, ["boards", "list", "--format", "human"]),
940
+ );
941
+ const [stdout, stderr] = await Promise.all([
942
+ readSubprocessStream(proc.stdout),
943
+ readSubprocessStream(proc.stderr),
944
+ ]);
945
+ const code = await proc.exited;
946
+
947
+ expect(code).toBe(0);
948
+ expect(stderr.trim()).toBe("");
949
+ expect(stdout).toContain("No rows.");
950
+ } finally {
951
+ server.stop();
952
+ }
953
+ });
954
+ });