@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,598 +1,611 @@
1
- /**
2
- * Opt-in: real TaskManager API + SQLite + subprocess hirotm (no stubs).
3
- * Isolated temp data/auth dirs and a child dev server — avoids touching dev `data/`.
4
- *
5
- * Enable: RUN_CLI_REAL_STACK=1 bun test ./src/cli/subprocess.real-stack.test.ts
6
- * Or: npm run test:cli:real-stack
7
- */
8
- import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
- import { Database } from "bun:sqlite";
10
- import { createServer } from "node:net";
11
- import {
12
- mkdirSync,
13
- rmSync,
14
- mkdtempSync,
15
- } from "node:fs";
16
- import { tmpdir } from "node:os";
17
- import path from "node:path";
18
-
19
- const repoRoot = path.resolve(import.meta.dir, "..", "..");
20
- const hirotmEntry = path.join(repoRoot, "src", "cli", "bin", "hirotm.ts");
21
- const prepareAuthScript = path.join(
22
- repoRoot,
23
- "src",
24
- "server",
25
- "scripts",
26
- "integrationPrepareAuth.ts",
27
- );
28
- const bootstrapDev = path.join(repoRoot, "src", "server", "bootstrapDev.ts");
29
-
30
- const runRealStack =
31
- process.env.RUN_CLI_REAL_STACK === "1" ||
32
- process.env.RUN_CLI_REAL_STACK === "true";
33
-
34
- function pickEphemeralPort(): Promise<number> {
35
- return new Promise((resolve, reject) => {
36
- const s = createServer();
37
- s.listen(0, "127.0.0.1", () => {
38
- const addr = s.address();
39
- if (addr && typeof addr === "object") {
40
- const p = addr.port;
41
- s.close(() => resolve(p));
42
- } else {
43
- s.close(() => reject(new Error("no port")));
44
- }
45
- });
46
- s.on("error", reject);
47
- });
48
- }
49
-
50
- async function readSubprocessStream(
51
- stream: ReturnType<typeof Bun.spawn>["stdout"],
52
- ): Promise<string> {
53
- if (stream == null || typeof stream === "number") return "";
54
- return await new Response(stream as ReadableStream<Uint8Array>).text();
55
- }
56
-
57
- async function waitForHealth(port: number, timeoutMs: number): Promise<void> {
58
- const deadline = Date.now() + timeoutMs;
59
- while (Date.now() < deadline) {
60
- try {
61
- const r = await fetch(`http://127.0.0.1:${port}/api/health`);
62
- if (r.ok) {
63
- const j = (await r.json()) as { ok?: unknown };
64
- if (j.ok === true) return;
65
- }
66
- } catch {
67
- /* connection refused until server is up */
68
- }
69
- await new Promise((r) => setTimeout(r, 50));
70
- }
71
- throw new Error(`Health check failed for port ${port} within ${timeoutMs}ms`);
72
- }
73
-
74
- function parseNdjsonLines(stdout: string): Record<string, unknown>[] {
75
- return stdout
76
- .trimEnd()
77
- .split("\n")
78
- .filter((l) => l.length > 0)
79
- .map((l) => JSON.parse(l) as Record<string, unknown>);
80
- }
81
-
82
- const CLIENT_NAME = ["--client-name", "Cursor Agent"];
83
-
84
- /** Real-stack DB seeds `cli_global_policy.create_board = 0`; CLI board creation requires 1 (see `cliCreateBoardDeniedError`). */
85
- function enableCliGlobalCreateBoard(dataDir: string): void {
86
- const dbPath = path.join(dataDir, "taskmanager.db");
87
- const db = new Database(dbPath);
88
- try {
89
- db.run(
90
- "INSERT OR REPLACE INTO cli_global_policy (id, create_board) VALUES (1, 1)",
91
- );
92
- } finally {
93
- db.close();
94
- }
95
- }
96
-
97
- describe.skipIf(!runRealStack)("hirotm real stack (API + SQLite + subprocess)", () => {
98
- let rootDir: string;
99
- let dataDir: string;
100
- let authDir: string;
101
- let port: number;
102
- let serverProc: ReturnType<typeof Bun.spawn> | null = null;
103
-
104
- beforeEach(async () => {
105
- rootDir = mkdtempSync(path.join(tmpdir(), "hirotm-real-stack-"));
106
- dataDir = path.join(rootDir, "data");
107
- authDir = path.join(rootDir, "auth");
108
- mkdirSync(dataDir, { recursive: true });
109
- mkdirSync(authDir, { recursive: true });
110
- port = await pickEphemeralPort();
111
-
112
- const env: NodeJS.ProcessEnv = {
113
- ...process.env,
114
- TASKMANAGER_DATA_DIR: dataDir,
115
- TASKMANAGER_AUTH_DIR: authDir,
116
- TASKMANAGER_PORT: String(port),
117
- TASKMANAGER_PROFILE: "default",
118
- HOME: rootDir,
119
- };
120
-
121
- const prep = Bun.spawn({
122
- cmd: ["bun", "run", prepareAuthScript],
123
- cwd: repoRoot,
124
- stdout: "pipe",
125
- stderr: "pipe",
126
- env,
127
- });
128
- const prepCode = await prep.exited;
129
- if (prepCode !== 0) {
130
- const errOut = await readSubprocessStream(prep.stderr);
131
- throw new Error(`integrationPrepareAuth failed (${prepCode}): ${errOut}`);
132
- }
133
-
134
- serverProc = Bun.spawn({
135
- cmd: ["bun", "run", bootstrapDev],
136
- cwd: repoRoot,
137
- stdout: "pipe",
138
- stderr: "pipe",
139
- env,
140
- });
141
-
142
- await waitForHealth(port, 30_000);
143
- enableCliGlobalCreateBoard(dataDir);
144
- });
145
-
146
- afterEach(() => {
147
- if (serverProc) {
148
- try {
149
- serverProc.kill();
150
- } catch {
151
- /* ignore */
152
- }
153
- serverProc = null;
154
- }
155
- try {
156
- rmSync(rootDir, { recursive: true, force: true });
157
- } catch {
158
- /* Windows may hold locks briefly */
159
- }
160
- });
161
-
162
- async function runHirotm(
163
- args: string[],
164
- ): Promise<{ code: number; stdout: string; stderr: string }> {
165
- const proc = Bun.spawn({
166
- cmd: ["bun", "run", hirotmEntry, ...args],
167
- cwd: repoRoot,
168
- stdout: "pipe",
169
- stderr: "pipe",
170
- env: {
171
- ...process.env,
172
- HOME: rootDir,
173
- TASKMANAGER_PROFILE: "default",
174
- TASKMANAGER_PORT: String(port),
175
- },
176
- });
177
- const [stdout, stderr] = await Promise.all([
178
- readSubprocessStream(proc.stdout),
179
- readSubprocessStream(proc.stderr),
180
- ]);
181
- return { code: await proc.exited, stdout, stderr };
182
- }
183
-
184
- test("boards list returns NDJSON (empty DB → no stdout lines) via real GET /api/boards", async () => {
185
- const proc = Bun.spawn({
186
- cmd: ["bun", "run", hirotmEntry, "boards", "list"],
187
- cwd: repoRoot,
188
- stdout: "pipe",
189
- stderr: "pipe",
190
- env: {
191
- ...process.env,
192
- HOME: rootDir,
193
- TASKMANAGER_PROFILE: "default",
194
- TASKMANAGER_PORT: String(port),
195
- },
196
- });
197
- const [stdout, stderr] = await Promise.all([
198
- readSubprocessStream(proc.stdout),
199
- readSubprocessStream(proc.stderr),
200
- ]);
201
- const code = await proc.exited;
202
-
203
- expect(code).toBe(0);
204
- expect(stderr.trim()).toBe("");
205
- expect(stdout.trim()).toBe("");
206
- });
207
-
208
- test("statuses list returns seeded workflow rows", async () => {
209
- const proc = Bun.spawn({
210
- cmd: ["bun", "run", hirotmEntry, "statuses", "list"],
211
- cwd: repoRoot,
212
- stdout: "pipe",
213
- stderr: "pipe",
214
- env: {
215
- ...process.env,
216
- HOME: rootDir,
217
- TASKMANAGER_PROFILE: "default",
218
- TASKMANAGER_PORT: String(port),
219
- },
220
- });
221
- const [stdout, stderr] = await Promise.all([
222
- readSubprocessStream(proc.stdout),
223
- readSubprocessStream(proc.stderr),
224
- ]);
225
- const code = await proc.exited;
226
-
227
- expect(code).toBe(0);
228
- expect(stderr.trim()).toBe("");
229
- const lines = stdout.trimEnd().split("\n").filter((l) => l.length > 0);
230
- const rows = lines.map((l) => JSON.parse(l) as { statusId: string });
231
- expect(rows.length).toBeGreaterThanOrEqual(3);
232
- const ids = new Set(rows.map((r) => r.statusId));
233
- expect(ids.has("open")).toBe(true);
234
- expect(ids.has("in-progress")).toBe(true);
235
- expect(ids.has("closed")).toBe(true);
236
- });
237
-
238
- test("board CRUD round-trip (add → list → describe → update → delete)", async () => {
239
- const u = `brd-${Date.now()}`;
240
- const name1 = `Board-${u}`;
241
- let r = await runHirotm(["boards", "add", name1, ...CLIENT_NAME]);
242
- expect(r.code).toBe(0);
243
-
244
- r = await runHirotm(["boards", "list"]);
245
- expect(r.code).toBe(0);
246
- const boards = parseNdjsonLines(r.stdout);
247
- const row = boards.find((b) => b.name === name1);
248
- expect(row).toBeDefined();
249
- const slug = String(row!.slug);
250
-
251
- r = await runHirotm(["boards", "describe", slug]);
252
- expect(r.code).toBe(0);
253
- const bline = parseNdjsonLines(r.stdout).find((x) => x.kind === "board");
254
- expect(bline?.name).toBe(name1);
255
-
256
- const name2 = `Updated-${u}`;
257
- r = await runHirotm(["boards", "update", slug, "--name", name2, ...CLIENT_NAME]);
258
- expect(r.code).toBe(0);
259
-
260
- r = await runHirotm(["boards", "list"]);
261
- const row2 = parseNdjsonLines(r.stdout).find((b) => b.name === name2);
262
- expect(row2).toBeDefined();
263
- const slug2 = String(row2!.slug);
264
-
265
- r = await runHirotm(["boards", "delete", slug2, "--yes", ...CLIENT_NAME]);
266
- expect(r.code).toBe(0);
267
- });
268
-
269
- test("task CRUD round-trip (add → list → update → move → delete)", async () => {
270
- const u = `tsk-${Date.now()}`;
271
- let r = await runHirotm(["boards", "add", `TB-${u}`, ...CLIENT_NAME]);
272
- expect(r.code).toBe(0);
273
- r = await runHirotm(["boards", "list"]);
274
- const slug = String(
275
- parseNdjsonLines(r.stdout).find((b) => b.name === `TB-${u}`)!.slug,
276
- );
277
-
278
- r = await runHirotm(["lists", "add", "Lane1", "--board", slug, ...CLIENT_NAME]);
279
- expect(r.code).toBe(0);
280
-
281
- r = await runHirotm(["lists", "list", "--board", slug]);
282
- expect(r.code).toBe(0);
283
- const listsA = parseNdjsonLines(r.stdout);
284
- const listAId = String(listsA[0].listId);
285
-
286
- r = await runHirotm(["boards", "describe", slug]);
287
- const descRows = parseNdjsonLines(r.stdout);
288
- const groups = descRows.filter((x) => x.kind === "group");
289
- const defaultG = groups.find((g) => g.default === true) ?? groups[0];
290
- expect(defaultG).toBeDefined();
291
- const groupId = String(defaultG!.groupId);
292
-
293
- r = await runHirotm([
294
- "tasks",
295
- "add",
296
- "--board",
297
- slug,
298
- "--list",
299
- listAId,
300
- "--group",
301
- groupId,
302
- "--title",
303
- "T1",
304
- ...CLIENT_NAME,
305
- ]);
306
- expect(r.code).toBe(0);
307
- const addEnv = JSON.parse(r.stdout.trim()) as {
308
- entity: { type: string; taskId: number };
309
- };
310
- expect(addEnv.entity.type).toBe("task");
311
- const taskId = String(addEnv.entity.taskId);
312
-
313
- r = await runHirotm(["tasks", "list", "--board", slug]);
314
- expect(r.code).toBe(0);
315
- expect(
316
- parseNdjsonLines(r.stdout).some(
317
- (t) => String(t.taskId) === taskId && t.title === "T1",
318
- ),
319
- ).toBe(true);
320
-
321
- r = await runHirotm([
322
- "tasks",
323
- "update",
324
- "--board",
325
- slug,
326
- taskId,
327
- "--title",
328
- "T2",
329
- ...CLIENT_NAME,
330
- ]);
331
- expect(r.code).toBe(0);
332
-
333
- r = await runHirotm(["lists", "add", "SecondCol", "--board", slug, ...CLIENT_NAME]);
334
- expect(r.code).toBe(0);
335
- r = await runHirotm(["lists", "list", "--board", slug]);
336
- const listsB = parseNdjsonLines(r.stdout);
337
- const second = listsB.find((l) => l.name === "SecondCol");
338
- expect(second).toBeDefined();
339
- const listBId = String(second!.listId);
340
-
341
- r = await runHirotm([
342
- "tasks",
343
- "move",
344
- "--board",
345
- slug,
346
- "--to-list",
347
- listBId,
348
- taskId,
349
- "--last",
350
- ...CLIENT_NAME,
351
- ]);
352
- expect(r.code).toBe(0);
353
-
354
- r = await runHirotm(["tasks", "list", "--board", slug, "--list", listBId]);
355
- expect(r.code).toBe(0);
356
- expect(
357
- parseNdjsonLines(r.stdout).some((t) => String(t.taskId) === taskId),
358
- ).toBe(true);
359
-
360
- r = await runHirotm(["tasks", "delete", "--board", slug, taskId, "--yes", ...CLIENT_NAME]);
361
- expect(r.code).toBe(0);
362
- });
363
-
364
- test("list CRUD round-trip (add → list → update → delete)", async () => {
365
- const u = `lst-${Date.now()}`;
366
- let r = await runHirotm(["boards", "add", `LB-${u}`, ...CLIENT_NAME]);
367
- expect(r.code).toBe(0);
368
- r = await runHirotm(["boards", "list"]);
369
- const slug = String(parseNdjsonLines(r.stdout)[0].slug);
370
-
371
- r = await runHirotm(["lists", "add", "MyColumn", "--board", slug, ...CLIENT_NAME]);
372
- expect(r.code).toBe(0);
373
-
374
- r = await runHirotm(["lists", "list", "--board", slug]);
375
- const myCol = parseNdjsonLines(r.stdout).find((l) => l.name === "MyColumn");
376
- expect(myCol).toBeDefined();
377
- const listId = String(myCol!.listId);
378
-
379
- r = await runHirotm([
380
- "lists",
381
- "update",
382
- "--board",
383
- slug,
384
- listId,
385
- "--name",
386
- "RenamedCol",
387
- ...CLIENT_NAME,
388
- ]);
389
- expect(r.code).toBe(0);
390
-
391
- r = await runHirotm(["lists", "list", "--board", slug]);
392
- expect(
393
- parseNdjsonLines(r.stdout).some((l) => l.name === "RenamedCol"),
394
- ).toBe(true);
395
-
396
- r = await runHirotm([
397
- "lists",
398
- "delete",
399
- "--board",
400
- slug,
401
- listId,
402
- "--yes",
403
- ...CLIENT_NAME,
404
- ]);
405
- expect(r.code).toBe(0);
406
- });
407
-
408
- test("release CRUD round-trip (add → list → show → update → delete)", async () => {
409
- const u = `rel-${Date.now()}`;
410
- let r = await runHirotm(["boards", "add", `RB-${u}`, ...CLIENT_NAME]);
411
- expect(r.code).toBe(0);
412
- r = await runHirotm(["boards", "list"]);
413
- const slug = String(parseNdjsonLines(r.stdout)[0].slug);
414
-
415
- r = await runHirotm([
416
- "releases",
417
- "add",
418
- "--board",
419
- slug,
420
- "--name",
421
- `v1-${u}`,
422
- ...CLIENT_NAME,
423
- ]);
424
- expect(r.code).toBe(0);
425
-
426
- r = await runHirotm(["releases", "list", "--board", slug]);
427
- expect(r.code).toBe(0);
428
- const relRow = parseNdjsonLines(r.stdout).find(
429
- (x) => x.name === `v1-${u}`,
430
- );
431
- expect(relRow).toBeDefined();
432
- const releaseId = String(relRow!.releaseId);
433
-
434
- r = await runHirotm(["releases", "show", "--board", slug, releaseId]);
435
- expect(r.code).toBe(0);
436
- const show = JSON.parse(r.stdout.trim()) as { name?: string };
437
- expect(show.name).toBe(`v1-${u}`);
438
-
439
- r = await runHirotm([
440
- "releases",
441
- "update",
442
- "--board",
443
- slug,
444
- releaseId,
445
- "--name",
446
- `v1.1-${u}`,
447
- ...CLIENT_NAME,
448
- ]);
449
- expect(r.code).toBe(0);
450
-
451
- r = await runHirotm([
452
- "releases",
453
- "delete",
454
- "--board",
455
- slug,
456
- releaseId,
457
- "--yes",
458
- ...CLIENT_NAME,
459
- ]);
460
- expect(r.code).toBe(0);
461
- });
462
-
463
- test("query search finds a task created on the stack", async () => {
464
- // FTS5 MATCH treats `-` as syntax; keep the query alphanumeric (see search route catch → 400).
465
- const u = `q${Date.now()}`;
466
- const token = `SearchableToken${u}`;
467
- let r = await runHirotm(["boards", "add", `QB-${u}`, ...CLIENT_NAME]);
468
- expect(r.code).toBe(0);
469
- r = await runHirotm(["boards", "list"]);
470
- const slug = String(
471
- parseNdjsonLines(r.stdout).find((b) => b.name === `QB-${u}`)!.slug,
472
- );
473
-
474
- r = await runHirotm(["lists", "add", "SearchLane", "--board", slug, ...CLIENT_NAME]);
475
- expect(r.code).toBe(0);
476
- r = await runHirotm(["lists", "list", "--board", slug]);
477
- expect(r.code).toBe(0);
478
- const listId = String(parseNdjsonLines(r.stdout)[0].listId);
479
-
480
- r = await runHirotm(["boards", "describe", slug]);
481
- const descRows = parseNdjsonLines(r.stdout);
482
- const groups = descRows.filter((x) => x.kind === "group");
483
- const groupId = String((groups.find((g) => g.default === true) ?? groups[0])!.groupId);
484
-
485
- r = await runHirotm([
486
- "tasks",
487
- "add",
488
- "--board",
489
- slug,
490
- "--list",
491
- listId,
492
- "--group",
493
- groupId,
494
- "--title",
495
- token,
496
- ...CLIENT_NAME,
497
- ]);
498
- expect(r.code).toBe(0);
499
-
500
- r = await runHirotm(["query", "search", token]);
501
- expect(r.code).toBe(0);
502
- expect(r.stdout).toContain(token);
503
- });
504
-
505
- test("trash restore round-trip for a board", async () => {
506
- const u = `tr-${Date.now()}`;
507
- let r = await runHirotm(["boards", "add", `TrashBoard-${u}`, ...CLIENT_NAME]);
508
- expect(r.code).toBe(0);
509
- r = await runHirotm(["boards", "list"]);
510
- const row = parseNdjsonLines(r.stdout).find(
511
- (b) => b.name === `TrashBoard-${u}`,
512
- );
513
- expect(row).toBeDefined();
514
- const slug = String(row!.slug);
515
- const boardId = String(row!.boardId);
516
-
517
- r = await runHirotm(["boards", "delete", slug, "--yes", ...CLIENT_NAME]);
518
- expect(r.code).toBe(0);
519
-
520
- r = await runHirotm(["trash", "list", "boards"]);
521
- expect(r.code).toBe(0);
522
- expect(parseNdjsonLines(r.stdout).some((b) => String(b.boardId) === boardId)).toBe(
523
- true,
524
- );
525
-
526
- r = await runHirotm(["boards", "restore", boardId, "--yes", ...CLIENT_NAME]);
527
- expect(r.code).toBe(0);
528
-
529
- r = await runHirotm(["boards", "list"]);
530
- expect(
531
- parseNdjsonLines(r.stdout).some((b) => String(b.boardId) === boardId),
532
- ).toBe(true);
533
- });
534
-
535
- test("boards list --format human prints a table for real data", async () => {
536
- const u = `hm-${Date.now()}`;
537
- let r = await runHirotm(["boards", "add", `Human-${u}`, ...CLIENT_NAME]);
538
- expect(r.code).toBe(0);
539
-
540
- r = await runHirotm(["boards", "list", "--format", "human"]);
541
- expect(r.code).toBe(0);
542
- expect(r.stderr.trim()).toBe("");
543
- expect(r.stdout).toContain(`Human-${u}`);
544
- });
545
-
546
- test("boards list --quiet prints slug only (plain text)", async () => {
547
- const u = `qt-${Date.now()}`;
548
- let r = await runHirotm(["boards", "add", `Quiet-${u}`, ...CLIENT_NAME]);
549
- expect(r.code).toBe(0);
550
- r = await runHirotm(["boards", "list"]);
551
- const slug = String(
552
- parseNdjsonLines(r.stdout).find((b) => b.name === `Quiet-${u}`)!.slug,
553
- );
554
-
555
- r = await runHirotm(["boards", "list", "--quiet"]);
556
- expect(r.code).toBe(0);
557
- expect(r.stderr.trim()).toBe("");
558
- expect(r.stdout.trim().split("\n").some((line) => line.trim() === slug)).toBe(
559
- true,
560
- );
561
- });
562
- });
563
-
564
- describe.skipIf(!runRealStack)("hirotm real stack — unreachable port", () => {
565
- test("boards list with no server on port exits 6 (server_unreachable)", async () => {
566
- const deadPort = await pickEphemeralPort();
567
- const rootDir = mkdtempSync(path.join(tmpdir(), "hirotm-unreachable-"));
568
- const origHome = process.env.HOME;
569
- process.env.HOME = rootDir;
570
- try {
571
- const proc = Bun.spawn({
572
- cmd: ["bun", "run", hirotmEntry, "boards", "list"],
573
- cwd: repoRoot,
574
- stdout: "pipe",
575
- stderr: "pipe",
576
- env: {
577
- ...process.env,
578
- HOME: rootDir,
579
- TASKMANAGER_PROFILE: "default",
580
- TASKMANAGER_PORT: String(deadPort),
581
- },
582
- });
583
- const stderr = await readSubprocessStream(proc.stderr);
584
- const code = await proc.exited;
585
- expect(code).toBe(6);
586
- const err = JSON.parse(stderr.trim()) as { code?: string };
587
- expect(err.code).toBe("server_unreachable");
588
- } finally {
589
- if (origHome !== undefined) process.env.HOME = origHome;
590
- else delete process.env.HOME;
591
- try {
592
- rmSync(rootDir, { recursive: true, force: true });
593
- } catch {
594
- /* ignore */
595
- }
596
- }
597
- });
598
- });
1
+ /**
2
+ * Opt-in: real TaskManager API + SQLite + subprocess hirotm (no stubs).
3
+ * Isolated temp data/auth dirs and a child dev server — avoids touching dev `data/`.
4
+ *
5
+ * Enable: RUN_CLI_REAL_STACK=1 bun test ./src/cli/subprocess.real-stack.test.ts
6
+ * Or: npm run test:cli:real-stack
7
+ */
8
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
+ import { createServer } from "node:net";
10
+ import {
11
+ mkdirSync,
12
+ rmSync,
13
+ mkdtempSync,
14
+ } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import path from "node:path";
17
+
18
+ const repoRoot = path.resolve(import.meta.dir, "..", "..");
19
+ const hirotmEntry = path.join(repoRoot, "src", "cli", "bin", "hirotm.ts");
20
+ const prepareAuthScript = path.join(
21
+ repoRoot,
22
+ "src",
23
+ "server",
24
+ "scripts",
25
+ "integrationPrepareAuth.ts",
26
+ );
27
+ const bootstrapDev = path.join(repoRoot, "src", "server", "bootstrapDev.ts");
28
+
29
+ /** Real-stack tests use an isolated HOME; pin profile and port via argv on each hirotm/server spawn. */
30
+ const PROFILE_ARGS = ["--profile", "default", "--dev"] as const;
31
+
32
+ const runRealStack =
33
+ process.env.RUN_CLI_REAL_STACK === "1" ||
34
+ process.env.RUN_CLI_REAL_STACK === "true";
35
+
36
+ function pickEphemeralPort(): Promise<number> {
37
+ return new Promise((resolve, reject) => {
38
+ const s = createServer();
39
+ s.listen(0, "127.0.0.1", () => {
40
+ const addr = s.address();
41
+ if (addr && typeof addr === "object") {
42
+ const p = addr.port;
43
+ s.close(() => resolve(p));
44
+ } else {
45
+ s.close(() => reject(new Error("no port")));
46
+ }
47
+ });
48
+ s.on("error", reject);
49
+ });
50
+ }
51
+
52
+ async function readSubprocessStream(
53
+ stream: ReturnType<typeof Bun.spawn>["stdout"],
54
+ ): Promise<string> {
55
+ if (stream == null || typeof stream === "number") return "";
56
+ return await new Response(stream as ReadableStream<Uint8Array>).text();
57
+ }
58
+
59
+ async function waitForHealth(port: number, timeoutMs: number): Promise<void> {
60
+ const deadline = Date.now() + timeoutMs;
61
+ while (Date.now() < deadline) {
62
+ try {
63
+ const r = await fetch(`http://127.0.0.1:${port}/api/health`);
64
+ if (r.ok) {
65
+ const j = (await r.json()) as { running?: unknown; port?: unknown };
66
+ if (j.running === true && j.port === port) return;
67
+ }
68
+ } catch {
69
+ /* connection refused until server is up */
70
+ }
71
+ await new Promise((r) => setTimeout(r, 50));
72
+ }
73
+ throw new Error(`Health check failed for port ${port} within ${timeoutMs}ms`);
74
+ }
75
+
76
+ function parseNdjsonLines(stdout: string): Record<string, unknown>[] {
77
+ return stdout
78
+ .trimEnd()
79
+ .split("\n")
80
+ .filter((l) => l.length > 0)
81
+ .map((l) => JSON.parse(l) as Record<string, unknown>);
82
+ }
83
+
84
+ const CLIENT_NAME = ["--client-name", "Cursor Agent"];
85
+
86
+ describe.skipIf(!runRealStack)("hirotm real stack (API + SQLite + subprocess)", () => {
87
+ let rootDir: string;
88
+ let dataDir: string;
89
+ let authDir: string;
90
+ let port: number;
91
+ let serverProc: ReturnType<typeof Bun.spawn> | null = null;
92
+
93
+ beforeEach(async () => {
94
+ rootDir = mkdtempSync(path.join(tmpdir(), "hirotm-real-stack-"));
95
+ dataDir = path.join(rootDir, "data");
96
+ authDir = path.join(rootDir, "auth");
97
+ mkdirSync(dataDir, { recursive: true });
98
+ mkdirSync(authDir, { recursive: true });
99
+ port = await pickEphemeralPort();
100
+
101
+ const env: NodeJS.ProcessEnv = {
102
+ ...process.env,
103
+ TASKMANAGER_DATA_DIR: dataDir,
104
+ TASKMANAGER_AUTH_DIR: authDir,
105
+ HOME: rootDir,
106
+ };
107
+
108
+ const prep = Bun.spawn({
109
+ cmd: ["bun", "run", prepareAuthScript],
110
+ cwd: repoRoot,
111
+ stdout: "pipe",
112
+ stderr: "pipe",
113
+ env,
114
+ });
115
+ const prepCode = await prep.exited;
116
+ if (prepCode !== 0) {
117
+ const errOut = await readSubprocessStream(prep.stderr);
118
+ throw new Error(`integrationPrepareAuth failed (${prepCode}): ${errOut}`);
119
+ }
120
+
121
+ serverProc = Bun.spawn({
122
+ cmd: ["bun", bootstrapDev, ...PROFILE_ARGS, "--port", String(port)],
123
+ cwd: repoRoot,
124
+ stdout: "pipe",
125
+ stderr: "pipe",
126
+ env,
127
+ });
128
+
129
+ await waitForHealth(port, 30_000);
130
+ });
131
+
132
+ afterEach(() => {
133
+ if (serverProc) {
134
+ try {
135
+ serverProc.kill();
136
+ } catch {
137
+ /* ignore */
138
+ }
139
+ serverProc = null;
140
+ }
141
+ try {
142
+ rmSync(rootDir, { recursive: true, force: true });
143
+ } catch {
144
+ /* Windows may hold locks briefly */
145
+ }
146
+ });
147
+
148
+ async function runHirotm(
149
+ args: string[],
150
+ ): Promise<{ code: number; stdout: string; stderr: string }> {
151
+ const proc = Bun.spawn({
152
+ cmd: [
153
+ "bun",
154
+ "run",
155
+ hirotmEntry,
156
+ ...PROFILE_ARGS,
157
+ "--port",
158
+ String(port),
159
+ ...args,
160
+ ],
161
+ cwd: repoRoot,
162
+ stdout: "pipe",
163
+ stderr: "pipe",
164
+ env: {
165
+ ...process.env,
166
+ HOME: rootDir,
167
+ },
168
+ });
169
+ const [stdout, stderr] = await Promise.all([
170
+ readSubprocessStream(proc.stdout),
171
+ readSubprocessStream(proc.stderr),
172
+ ]);
173
+ return { code: await proc.exited, stdout, stderr };
174
+ }
175
+
176
+ test("boards list returns NDJSON (empty DB → no stdout lines) via real GET /api/boards", async () => {
177
+ const proc = Bun.spawn({
178
+ cmd: [
179
+ "bun",
180
+ "run",
181
+ hirotmEntry,
182
+ ...PROFILE_ARGS,
183
+ "--port",
184
+ String(port),
185
+ "boards",
186
+ "list",
187
+ ],
188
+ cwd: repoRoot,
189
+ stdout: "pipe",
190
+ stderr: "pipe",
191
+ env: {
192
+ ...process.env,
193
+ HOME: rootDir,
194
+ },
195
+ });
196
+ const [stdout, stderr] = await Promise.all([
197
+ readSubprocessStream(proc.stdout),
198
+ readSubprocessStream(proc.stderr),
199
+ ]);
200
+ const code = await proc.exited;
201
+
202
+ expect(code).toBe(0);
203
+ expect(stderr.trim()).toBe("");
204
+ expect(stdout.trim()).toBe("");
205
+ });
206
+
207
+ test("statuses list returns seeded workflow rows", async () => {
208
+ const proc = Bun.spawn({
209
+ cmd: [
210
+ "bun",
211
+ "run",
212
+ hirotmEntry,
213
+ ...PROFILE_ARGS,
214
+ "--port",
215
+ String(port),
216
+ "statuses",
217
+ "list",
218
+ ],
219
+ cwd: repoRoot,
220
+ stdout: "pipe",
221
+ stderr: "pipe",
222
+ env: {
223
+ ...process.env,
224
+ HOME: rootDir,
225
+ },
226
+ });
227
+ const [stdout, stderr] = await Promise.all([
228
+ readSubprocessStream(proc.stdout),
229
+ readSubprocessStream(proc.stderr),
230
+ ]);
231
+ const code = await proc.exited;
232
+
233
+ expect(code).toBe(0);
234
+ expect(stderr.trim()).toBe("");
235
+ const lines = stdout.trimEnd().split("\n").filter((l) => l.length > 0);
236
+ const rows = lines.map((l) => JSON.parse(l) as { statusId: string });
237
+ expect(rows.length).toBeGreaterThanOrEqual(3);
238
+ const ids = new Set(rows.map((r) => r.statusId));
239
+ expect(ids.has("open")).toBe(true);
240
+ expect(ids.has("in-progress")).toBe(true);
241
+ expect(ids.has("closed")).toBe(true);
242
+ });
243
+
244
+ test("board CRUD round-trip (add → list → describe → update → delete)", async () => {
245
+ const u = `brd-${Date.now()}`;
246
+ const name1 = `Board-${u}`;
247
+ let r = await runHirotm(["boards", "add", name1, ...CLIENT_NAME]);
248
+ expect(r.code).toBe(0);
249
+
250
+ r = await runHirotm(["boards", "list"]);
251
+ expect(r.code).toBe(0);
252
+ const boards = parseNdjsonLines(r.stdout);
253
+ const row = boards.find((b) => b.name === name1);
254
+ expect(row).toBeDefined();
255
+ const slug = String(row!.slug);
256
+
257
+ r = await runHirotm(["boards", "describe", slug]);
258
+ expect(r.code).toBe(0);
259
+ const bline = parseNdjsonLines(r.stdout).find((x) => x.kind === "board");
260
+ expect(bline?.name).toBe(name1);
261
+
262
+ const name2 = `Updated-${u}`;
263
+ r = await runHirotm(["boards", "update", slug, "--name", name2, ...CLIENT_NAME]);
264
+ expect(r.code).toBe(0);
265
+
266
+ r = await runHirotm(["boards", "list"]);
267
+ const row2 = parseNdjsonLines(r.stdout).find((b) => b.name === name2);
268
+ expect(row2).toBeDefined();
269
+ const slug2 = String(row2!.slug);
270
+
271
+ r = await runHirotm(["boards", "delete", slug2, "--yes", ...CLIENT_NAME]);
272
+ expect(r.code).toBe(0);
273
+ });
274
+
275
+ test("task CRUD round-trip (add → list → update → move → delete)", async () => {
276
+ const u = `tsk-${Date.now()}`;
277
+ let r = await runHirotm(["boards", "add", `TB-${u}`, ...CLIENT_NAME]);
278
+ expect(r.code).toBe(0);
279
+ r = await runHirotm(["boards", "list"]);
280
+ const slug = String(
281
+ parseNdjsonLines(r.stdout).find((b) => b.name === `TB-${u}`)!.slug,
282
+ );
283
+
284
+ r = await runHirotm(["lists", "add", "Lane1", "--board", slug, ...CLIENT_NAME]);
285
+ expect(r.code).toBe(0);
286
+
287
+ r = await runHirotm(["lists", "list", "--board", slug]);
288
+ expect(r.code).toBe(0);
289
+ const listsA = parseNdjsonLines(r.stdout);
290
+ const listAId = String(listsA[0].listId);
291
+
292
+ r = await runHirotm(["boards", "describe", slug]);
293
+ const descRows = parseNdjsonLines(r.stdout);
294
+ const groups = descRows.filter((x) => x.kind === "group");
295
+ const defaultG = groups.find((g) => g.default === true) ?? groups[0];
296
+ expect(defaultG).toBeDefined();
297
+ const groupId = String(defaultG!.groupId);
298
+
299
+ r = await runHirotm([
300
+ "tasks",
301
+ "add",
302
+ "--board",
303
+ slug,
304
+ "--list",
305
+ listAId,
306
+ "--group",
307
+ groupId,
308
+ "--title",
309
+ "T1",
310
+ ...CLIENT_NAME,
311
+ ]);
312
+ expect(r.code).toBe(0);
313
+ const addEnv = JSON.parse(r.stdout.trim()) as {
314
+ entity: { type: string; taskId: number };
315
+ };
316
+ expect(addEnv.entity.type).toBe("task");
317
+ const taskId = String(addEnv.entity.taskId);
318
+
319
+ r = await runHirotm(["tasks", "list", "--board", slug]);
320
+ expect(r.code).toBe(0);
321
+ expect(
322
+ parseNdjsonLines(r.stdout).some(
323
+ (t) => String(t.taskId) === taskId && t.title === "T1",
324
+ ),
325
+ ).toBe(true);
326
+
327
+ r = await runHirotm([
328
+ "tasks",
329
+ "update",
330
+ "--board",
331
+ slug,
332
+ taskId,
333
+ "--title",
334
+ "T2",
335
+ ...CLIENT_NAME,
336
+ ]);
337
+ expect(r.code).toBe(0);
338
+
339
+ r = await runHirotm(["lists", "add", "SecondCol", "--board", slug, ...CLIENT_NAME]);
340
+ expect(r.code).toBe(0);
341
+ r = await runHirotm(["lists", "list", "--board", slug]);
342
+ const listsB = parseNdjsonLines(r.stdout);
343
+ const second = listsB.find((l) => l.name === "SecondCol");
344
+ expect(second).toBeDefined();
345
+ const listBId = String(second!.listId);
346
+
347
+ r = await runHirotm([
348
+ "tasks",
349
+ "move",
350
+ "--board",
351
+ slug,
352
+ "--to-list",
353
+ listBId,
354
+ taskId,
355
+ "--last",
356
+ ...CLIENT_NAME,
357
+ ]);
358
+ expect(r.code).toBe(0);
359
+
360
+ r = await runHirotm(["tasks", "list", "--board", slug, "--list", listBId]);
361
+ expect(r.code).toBe(0);
362
+ expect(
363
+ parseNdjsonLines(r.stdout).some((t) => String(t.taskId) === taskId),
364
+ ).toBe(true);
365
+
366
+ r = await runHirotm(["tasks", "delete", "--board", slug, taskId, "--yes", ...CLIENT_NAME]);
367
+ expect(r.code).toBe(0);
368
+ });
369
+
370
+ test("list CRUD round-trip (add → list → update → delete)", async () => {
371
+ const u = `lst-${Date.now()}`;
372
+ let r = await runHirotm(["boards", "add", `LB-${u}`, ...CLIENT_NAME]);
373
+ expect(r.code).toBe(0);
374
+ r = await runHirotm(["boards", "list"]);
375
+ const slug = String(parseNdjsonLines(r.stdout)[0].slug);
376
+
377
+ r = await runHirotm(["lists", "add", "MyColumn", "--board", slug, ...CLIENT_NAME]);
378
+ expect(r.code).toBe(0);
379
+
380
+ r = await runHirotm(["lists", "list", "--board", slug]);
381
+ const myCol = parseNdjsonLines(r.stdout).find((l) => l.name === "MyColumn");
382
+ expect(myCol).toBeDefined();
383
+ const listId = String(myCol!.listId);
384
+
385
+ r = await runHirotm([
386
+ "lists",
387
+ "update",
388
+ "--board",
389
+ slug,
390
+ listId,
391
+ "--name",
392
+ "RenamedCol",
393
+ ...CLIENT_NAME,
394
+ ]);
395
+ expect(r.code).toBe(0);
396
+
397
+ r = await runHirotm(["lists", "list", "--board", slug]);
398
+ expect(
399
+ parseNdjsonLines(r.stdout).some((l) => l.name === "RenamedCol"),
400
+ ).toBe(true);
401
+
402
+ r = await runHirotm([
403
+ "lists",
404
+ "delete",
405
+ "--board",
406
+ slug,
407
+ listId,
408
+ "--yes",
409
+ ...CLIENT_NAME,
410
+ ]);
411
+ expect(r.code).toBe(0);
412
+ });
413
+
414
+ test("release CRUD round-trip (add → list → show → update → delete)", async () => {
415
+ const u = `rel-${Date.now()}`;
416
+ let r = await runHirotm(["boards", "add", `RB-${u}`, ...CLIENT_NAME]);
417
+ expect(r.code).toBe(0);
418
+ r = await runHirotm(["boards", "list"]);
419
+ const slug = String(parseNdjsonLines(r.stdout)[0].slug);
420
+
421
+ r = await runHirotm([
422
+ "releases",
423
+ "add",
424
+ "--board",
425
+ slug,
426
+ "--name",
427
+ `v1-${u}`,
428
+ ...CLIENT_NAME,
429
+ ]);
430
+ expect(r.code).toBe(0);
431
+
432
+ r = await runHirotm(["releases", "list", "--board", slug]);
433
+ expect(r.code).toBe(0);
434
+ const relRow = parseNdjsonLines(r.stdout).find(
435
+ (x) => x.name === `v1-${u}`,
436
+ );
437
+ expect(relRow).toBeDefined();
438
+ const releaseId = String(relRow!.releaseId);
439
+
440
+ r = await runHirotm(["releases", "show", "--board", slug, releaseId]);
441
+ expect(r.code).toBe(0);
442
+ const show = JSON.parse(r.stdout.trim()) as { name?: string };
443
+ expect(show.name).toBe(`v1-${u}`);
444
+
445
+ r = await runHirotm([
446
+ "releases",
447
+ "update",
448
+ "--board",
449
+ slug,
450
+ releaseId,
451
+ "--name",
452
+ `v1.1-${u}`,
453
+ ...CLIENT_NAME,
454
+ ]);
455
+ expect(r.code).toBe(0);
456
+
457
+ r = await runHirotm([
458
+ "releases",
459
+ "delete",
460
+ "--board",
461
+ slug,
462
+ releaseId,
463
+ "--yes",
464
+ ...CLIENT_NAME,
465
+ ]);
466
+ expect(r.code).toBe(0);
467
+ });
468
+
469
+ test("query search finds a task created on the stack", async () => {
470
+ // FTS5 MATCH treats `-` as syntax; keep the query alphanumeric (see search route catch → 400).
471
+ const u = `q${Date.now()}`;
472
+ const token = `SearchableToken${u}`;
473
+ let r = await runHirotm(["boards", "add", `QB-${u}`, ...CLIENT_NAME]);
474
+ expect(r.code).toBe(0);
475
+ r = await runHirotm(["boards", "list"]);
476
+ const slug = String(
477
+ parseNdjsonLines(r.stdout).find((b) => b.name === `QB-${u}`)!.slug,
478
+ );
479
+
480
+ r = await runHirotm(["lists", "add", "SearchLane", "--board", slug, ...CLIENT_NAME]);
481
+ expect(r.code).toBe(0);
482
+ r = await runHirotm(["lists", "list", "--board", slug]);
483
+ expect(r.code).toBe(0);
484
+ const listId = String(parseNdjsonLines(r.stdout)[0].listId);
485
+
486
+ r = await runHirotm(["boards", "describe", slug]);
487
+ const descRows = parseNdjsonLines(r.stdout);
488
+ const groups = descRows.filter((x) => x.kind === "group");
489
+ const groupId = String((groups.find((g) => g.default === true) ?? groups[0])!.groupId);
490
+
491
+ r = await runHirotm([
492
+ "tasks",
493
+ "add",
494
+ "--board",
495
+ slug,
496
+ "--list",
497
+ listId,
498
+ "--group",
499
+ groupId,
500
+ "--title",
501
+ token,
502
+ ...CLIENT_NAME,
503
+ ]);
504
+ expect(r.code).toBe(0);
505
+
506
+ r = await runHirotm(["query", "search", token]);
507
+ expect(r.code).toBe(0);
508
+ expect(r.stdout).toContain(token);
509
+ });
510
+
511
+ test("trash restore round-trip for a board", async () => {
512
+ const u = `tr-${Date.now()}`;
513
+ let r = await runHirotm(["boards", "add", `TrashBoard-${u}`, ...CLIENT_NAME]);
514
+ expect(r.code).toBe(0);
515
+ r = await runHirotm(["boards", "list"]);
516
+ const row = parseNdjsonLines(r.stdout).find(
517
+ (b) => b.name === `TrashBoard-${u}`,
518
+ );
519
+ expect(row).toBeDefined();
520
+ const slug = String(row!.slug);
521
+ const boardId = String(row!.boardId);
522
+
523
+ r = await runHirotm(["boards", "delete", slug, "--yes", ...CLIENT_NAME]);
524
+ expect(r.code).toBe(0);
525
+
526
+ r = await runHirotm(["trash", "list", "boards"]);
527
+ expect(r.code).toBe(0);
528
+ expect(parseNdjsonLines(r.stdout).some((b) => String(b.boardId) === boardId)).toBe(
529
+ true,
530
+ );
531
+
532
+ r = await runHirotm(["boards", "restore", boardId, "--yes", ...CLIENT_NAME]);
533
+ expect(r.code).toBe(0);
534
+
535
+ r = await runHirotm(["boards", "list"]);
536
+ expect(
537
+ parseNdjsonLines(r.stdout).some((b) => String(b.boardId) === boardId),
538
+ ).toBe(true);
539
+ });
540
+
541
+ test("boards list --format human prints a table for real data", async () => {
542
+ const u = `hm-${Date.now()}`;
543
+ let r = await runHirotm(["boards", "add", `Human-${u}`, ...CLIENT_NAME]);
544
+ expect(r.code).toBe(0);
545
+
546
+ r = await runHirotm(["boards", "list", "--format", "human"]);
547
+ expect(r.code).toBe(0);
548
+ expect(r.stderr.trim()).toBe("");
549
+ expect(r.stdout).toContain(`Human-${u}`);
550
+ });
551
+
552
+ test("boards list --quiet prints slug only (plain text)", async () => {
553
+ const u = `qt-${Date.now()}`;
554
+ let r = await runHirotm(["boards", "add", `Quiet-${u}`, ...CLIENT_NAME]);
555
+ expect(r.code).toBe(0);
556
+ r = await runHirotm(["boards", "list"]);
557
+ const slug = String(
558
+ parseNdjsonLines(r.stdout).find((b) => b.name === `Quiet-${u}`)!.slug,
559
+ );
560
+
561
+ r = await runHirotm(["boards", "list", "--quiet"]);
562
+ expect(r.code).toBe(0);
563
+ expect(r.stderr.trim()).toBe("");
564
+ expect(r.stdout.trim().split("\n").some((line) => line.trim() === slug)).toBe(
565
+ true,
566
+ );
567
+ });
568
+ });
569
+
570
+ describe.skipIf(!runRealStack)("hirotm real stack — unreachable port", () => {
571
+ test("boards list with no server on port exits 6 (server_unreachable)", async () => {
572
+ const deadPort = await pickEphemeralPort();
573
+ const rootDir = mkdtempSync(path.join(tmpdir(), "hirotm-unreachable-"));
574
+ const origHome = process.env.HOME;
575
+ process.env.HOME = rootDir;
576
+ try {
577
+ const proc = Bun.spawn({
578
+ cmd: [
579
+ "bun",
580
+ "run",
581
+ hirotmEntry,
582
+ ...PROFILE_ARGS,
583
+ "--port",
584
+ String(deadPort),
585
+ "boards",
586
+ "list",
587
+ ],
588
+ cwd: repoRoot,
589
+ stdout: "pipe",
590
+ stderr: "pipe",
591
+ env: {
592
+ ...process.env,
593
+ HOME: rootDir,
594
+ },
595
+ });
596
+ const stderr = await readSubprocessStream(proc.stderr);
597
+ const code = await proc.exited;
598
+ expect(code).toBe(6);
599
+ const err = JSON.parse(stderr.trim()) as { code?: string };
600
+ expect(err.code).toBe("server_unreachable");
601
+ } finally {
602
+ if (origHome !== undefined) process.env.HOME = origHome;
603
+ else delete process.env.HOME;
604
+ try {
605
+ rmSync(rootDir, { recursive: true, force: true });
606
+ } catch {
607
+ /* ignore */
608
+ }
609
+ }
610
+ });
611
+ });