@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,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
+ });