@hiroleague/taskmanager 0.0.1 → 0.0.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 (146) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -52
  3. package/dist/assets/architecture-YZFGNWBL-C1MoQeSs.js +1 -0
  4. package/dist/assets/{architectureDiagram-Q4EWVU46-DSQ1_74_.js → architectureDiagram-Q4EWVU46-DUEfvDBu.js} +1 -1
  5. package/dist/assets/{blockDiagram-DXYQGD6D-DfOGNphI.js → blockDiagram-DXYQGD6D-DQzEOPT2.js} +1 -1
  6. package/dist/assets/{chunk-2KRD3SAO-9yt00aGC.js → chunk-2KRD3SAO-C2e-_49I.js} +1 -1
  7. package/dist/assets/{chunk-4TB4RGXK-DF8yJBFl.js → chunk-4TB4RGXK-AZq3s1Dh.js} +1 -1
  8. package/dist/assets/{chunk-67CJDMHE-5wFKo04G.js → chunk-67CJDMHE-B1-M78qu.js} +1 -1
  9. package/dist/assets/{chunk-7N4EOEYR-BRRGX_NC.js → chunk-7N4EOEYR-D7mYFpz-.js} +1 -1
  10. package/dist/assets/{chunk-AA7GKIK3-DUZv_pNI.js → chunk-AA7GKIK3-VWI9k39i.js} +1 -1
  11. package/dist/assets/{chunk-CIAEETIT-mA5aM_d7.js → chunk-CIAEETIT-hnu4zamm.js} +1 -1
  12. package/dist/assets/{chunk-FOC6F5B3-B-cqGCPC.js → chunk-FOC6F5B3-BJsh9nO9.js} +1 -1
  13. package/dist/assets/{chunk-K5T4RW27-BLRDzioh.js → chunk-K5T4RW27-BLIPdXaZ.js} +1 -1
  14. package/dist/assets/{chunk-KGLVRYIC-CTkQSeKy.js → chunk-KGLVRYIC-DvaW2TkT.js} +1 -1
  15. package/dist/assets/{chunk-LIHQZDEY-Cf34Nu3J.js → chunk-LIHQZDEY-CUsM0M11.js} +1 -1
  16. package/dist/assets/{chunk-ORNJ4GCN-D3uXgbay.js → chunk-ORNJ4GCN-CfluNV0_.js} +1 -1
  17. package/dist/assets/{chunk-OYMX7WX6-syQho5jf.js → chunk-OYMX7WX6-CkWzw4JX.js} +1 -1
  18. package/dist/assets/{classDiagram-6PBFFD2Q-CotFZI8-.js → classDiagram-6PBFFD2Q-Dx_f-9b7.js} +1 -1
  19. package/dist/assets/{classDiagram-v2-HSJHXN6E-DAPzeDGn.js → classDiagram-v2-HSJHXN6E-CSfvZ-nt.js} +1 -1
  20. package/dist/assets/clone-CXokakwV.js +1 -0
  21. package/dist/assets/{dagre-rhyPjnsQ.js → dagre-Do0eD9eI.js} +1 -1
  22. package/dist/assets/{dagre-KV5264BT-BBqulDtd.js → dagre-KV5264BT-lveZDhBf.js} +1 -1
  23. package/dist/assets/{diagram-5BDNPKRD-Ky3EXXj0.js → diagram-5BDNPKRD-Dq5yM_uY.js} +1 -1
  24. package/dist/assets/{diagram-G4DWMVQ6-t7LbT0Uz.js → diagram-G4DWMVQ6-D-SYOmKm.js} +1 -1
  25. package/dist/assets/{diagram-MMDJMWI5-CdnLXEMx.js → diagram-MMDJMWI5-lU5t9BZA.js} +1 -1
  26. package/dist/assets/{diagram-TYMM5635-CnzTqJBM.js → diagram-TYMM5635-6tfUbY3R.js} +1 -1
  27. package/dist/assets/{erDiagram-SMLLAGMA-BN5eJerP.js → erDiagram-SMLLAGMA-dx09stuy.js} +1 -1
  28. package/dist/assets/{flatten-C5NL-f24.js → flatten-B2BZ0pzY.js} +1 -1
  29. package/dist/assets/{flowDiagram-DWJPFMVM-CbFskc8S.js → flowDiagram-DWJPFMVM-CJi2WISS.js} +1 -1
  30. package/dist/assets/gitGraph-7Q5UKJZL-BXTuQaDM.js +1 -0
  31. package/dist/assets/{gitGraphDiagram-UUTBAWPF-wpqI2kyI.js → gitGraphDiagram-UUTBAWPF-Bjj94M12.js} +1 -1
  32. package/dist/assets/{graphlib-COiJG5Qv.js → graphlib-BIlXYGdM.js} +1 -1
  33. package/dist/assets/{index-lyyIVcc_.js → index-CZZuue3D.js} +5 -5
  34. package/dist/assets/info-OMHHGYJF-BeeKt8-X.js +1 -0
  35. package/dist/assets/{infoDiagram-42DDH7IO-BbvTdpSV.js → infoDiagram-42DDH7IO-wq_opQKO.js} +1 -1
  36. package/dist/assets/{ishikawaDiagram-UXIWVN3A-Epc23N_0.js → ishikawaDiagram-UXIWVN3A-Cnc1bwBo.js} +1 -1
  37. package/dist/assets/{kanban-definition-6JOO6SKY-C8dW_26n.js → kanban-definition-6JOO6SKY-CwHbIze0.js} +1 -1
  38. package/dist/assets/{mermaid-parser.core-6Tn8epr_.js → mermaid-parser.core-DrLhKJ48.js} +2 -2
  39. package/dist/assets/{mindmap-definition-QFDTVHPH-CvpNtrKT.js → mindmap-definition-QFDTVHPH-DswAJiEd.js} +1 -1
  40. package/dist/assets/packet-4T2RLAQJ-DQ-H9_jd.js +1 -0
  41. package/dist/assets/pie-ZZUOXDRM-BSj0Jsyj.js +1 -0
  42. package/dist/assets/{pieDiagram-DEJITSTG-eENymoXZ.js → pieDiagram-DEJITSTG-DgQTCddl.js} +1 -1
  43. package/dist/assets/radar-PYXPWWZC-B7-oRPFL.js +1 -0
  44. package/dist/assets/{reduce-BDOBPIXr.js → reduce-Uumu9GdR.js} +1 -1
  45. package/dist/assets/{requirementDiagram-MS252O5E-CmRO3hLp.js → requirementDiagram-MS252O5E-D1moa23Z.js} +1 -1
  46. package/dist/assets/{sequenceDiagram-FGHM5R23-B7qNcwNo.js → sequenceDiagram-FGHM5R23-Dvhj7HGn.js} +1 -1
  47. package/dist/assets/{stateDiagram-FHFEXIEX-CYfGMoR8.js → stateDiagram-FHFEXIEX-Dx5CjenB.js} +1 -1
  48. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-CO1W_n55.js → stateDiagram-v2-QKLJ7IA2-C_PkrTdc.js} +1 -1
  49. package/dist/assets/{timeline-definition-GMOUNBTQ-CQWqDPGG.js → timeline-definition-GMOUNBTQ-z-IncVmK.js} +1 -1
  50. package/dist/assets/treeView-SZITEDCU-CFXle9Az.js +1 -0
  51. package/dist/assets/treemap-W4RFUUIX-CAW3vWh8.js +1 -0
  52. package/dist/assets/{vennDiagram-DHZGUBPP-BjTbuhcb.js → vennDiagram-DHZGUBPP-CT1ehozU.js} +1 -1
  53. package/dist/assets/wardley-RL74JXVD-7q3ju4kc.js +1 -0
  54. package/dist/assets/{wardleyDiagram-NUSXRM2D-DNhPIFCg.js → wardleyDiagram-NUSXRM2D-D-kouujI.js} +1 -1
  55. package/dist/assets/{xychartDiagram-5P7HB3ND-BDblAZ11.js → xychartDiagram-5P7HB3ND-D1lnM0pL.js} +1 -1
  56. package/dist/index.html +1 -1
  57. package/package.json +101 -92
  58. package/scripts/postinstall-message.mjs +160 -0
  59. package/scripts/stubs/node-domexception/index.cjs +18 -0
  60. package/scripts/stubs/node-domexception/package.json +7 -0
  61. package/skills/hiro-task-manager-cli/SKILL.md +97 -0
  62. package/skills/hiro-task-manager-cli/reference/boards.md +143 -0
  63. package/skills/hiro-task-manager-cli/reference/cli-access-policy.md +72 -0
  64. package/skills/hiro-task-manager-cli/reference/errors.md +85 -0
  65. package/skills/hiro-task-manager-cli/reference/lists.md +106 -0
  66. package/skills/hiro-task-manager-cli/reference/releases.md +87 -0
  67. package/skills/hiro-task-manager-cli/reference/search.md +38 -0
  68. package/skills/hiro-task-manager-cli/reference/statuses.md +25 -0
  69. package/skills/hiro-task-manager-cli/reference/tasks.md +144 -0
  70. package/skills/hiro-task-manager-cli/reference/trash.md +50 -0
  71. package/src/cli/bootstrap/launcher.test.ts +66 -0
  72. package/src/cli/bootstrap/launcher.ts +375 -35
  73. package/src/cli/bootstrap/program.ts +4 -0
  74. package/src/cli/bootstrap/runtime.test.ts +15 -0
  75. package/src/cli/bootstrap/runtime.ts +27 -1
  76. package/src/cli/commands/query.ts +56 -56
  77. package/src/cli/commands/server.ts +27 -19
  78. package/src/cli/handlers/boards.test.ts +669 -669
  79. package/src/cli/handlers/cli-wiring.test.ts +1 -1
  80. package/src/cli/handlers/search.test.ts +374 -374
  81. package/src/cli/handlers/search.ts +17 -17
  82. package/src/cli/handlers/server.test.ts +55 -13
  83. package/src/cli/handlers/server.ts +16 -3
  84. package/src/cli/lib/api-client.test.ts +35 -2
  85. package/src/cli/lib/api-client.ts +43 -10
  86. package/src/cli/lib/cli-http-errors.test.ts +85 -85
  87. package/src/cli/lib/command-helpers.ts +161 -154
  88. package/src/cli/lib/config.ts +4 -0
  89. package/src/cli/lib/launcherUi.ts +166 -0
  90. package/src/cli/lib/process.test.ts +24 -5
  91. package/src/cli/lib/process.ts +86 -55
  92. package/src/cli/ports/process.ts +8 -2
  93. package/src/cli/subprocess.real-stack.test.ts +611 -598
  94. package/src/cli/subprocess.smoke.test.ts +954 -969
  95. package/src/cli/types/config.ts +2 -6
  96. package/src/client/components/auth/AuthScreen.tsx +3 -3
  97. package/src/client/components/board/BoardStatsChips.tsx +233 -233
  98. package/src/client/components/board/BoardStatsContext.tsx +41 -41
  99. package/src/client/components/board/boardHeaderButtonStyles.ts +38 -38
  100. package/src/client/components/board/shortcuts/useBoardShortcutKeydown.ts +49 -49
  101. package/src/client/components/board/useBoardCanvasPanScroll.ts +108 -108
  102. package/src/client/components/board/useBoardTaskContainerDroppableReact.ts +33 -33
  103. package/src/client/components/board/useBoardTaskSortableReact.ts +26 -26
  104. package/src/client/components/multi-select.tsx +1206 -1206
  105. package/src/client/components/routing/BoardPage.tsx +20 -20
  106. package/src/client/components/routing/NavigationRegistrar.tsx +13 -13
  107. package/src/client/components/settings/SettingsPage.tsx +1 -1
  108. package/src/client/components/task/TaskCard.tsx +643 -643
  109. package/src/client/components/ui/badge.tsx +49 -49
  110. package/src/client/components/ui/button.tsx +65 -65
  111. package/src/client/components/ui/command.tsx +193 -193
  112. package/src/client/components/ui/dialog.tsx +163 -163
  113. package/src/client/components/ui/input-group.tsx +155 -155
  114. package/src/client/components/ui/input.tsx +19 -19
  115. package/src/client/components/ui/popover.tsx +87 -87
  116. package/src/client/components/ui/separator.tsx +28 -28
  117. package/src/client/components/ui/textarea.tsx +18 -18
  118. package/src/client/index.css +248 -248
  119. package/src/client/lib/appNavigate.ts +16 -16
  120. package/src/client/lib/taskCardDate.ts +111 -111
  121. package/src/client/lib/utils.ts +6 -6
  122. package/src/server/auth.ts +351 -302
  123. package/src/server/bootstrapDev.ts +11 -2
  124. package/src/server/bootstrapInstalled.ts +6 -1
  125. package/src/server/index.ts +33 -7
  126. package/src/server/migrations/013_cli_policy_and_provenance.ts +2 -2
  127. package/src/server/migrations/019_cli_global_create_board_default_on.ts +14 -0
  128. package/src/server/migrations/registry.ts +43 -41
  129. package/src/server/parseBootstrapProfile.ts +42 -0
  130. package/src/server/storage/cliPolicy.ts +2 -1
  131. package/src/shared/runtimeConfig.ts +256 -237
  132. package/src/shared/runtimeIdentity.test.ts +47 -0
  133. package/src/shared/runtimeIdentity.ts +35 -0
  134. package/src/shared/serverStatus.ts +21 -0
  135. package/src/shared/skillsInstall.ts +71 -0
  136. package/src/shared/terminalColors.ts +24 -0
  137. package/dist/assets/architecture-YZFGNWBL-3h1eIYfB.js +0 -1
  138. package/dist/assets/clone-BRQpYu_n.js +0 -1
  139. package/dist/assets/gitGraph-7Q5UKJZL-CG8f8JF7.js +0 -1
  140. package/dist/assets/info-OMHHGYJF-C8_SHoRO.js +0 -1
  141. package/dist/assets/packet-4T2RLAQJ-BvpAX0kJ.js +0 -1
  142. package/dist/assets/pie-ZZUOXDRM-Ow26Yf-E.js +0 -1
  143. package/dist/assets/radar-PYXPWWZC-e_ron5jQ.js +0 -1
  144. package/dist/assets/treeView-SZITEDCU-DsEr3xeq.js +0 -1
  145. package/dist/assets/treemap-W4RFUUIX-DV7nk2AB.js +0 -1
  146. package/dist/assets/wardley-RL74JXVD-CrrFU9AE.js +0 -1
@@ -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
+ });