@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,669 +1,669 @@
1
- import { afterEach, describe, expect, test } from "bun:test";
2
- import type { PaginatedListBody } from "../../shared/pagination";
3
- import type { BoardIndexEntry, Task } from "../../shared/models";
4
- import { RELEASE_FILTER_UNTAGGED } from "../../shared/boardFilters";
5
- import type { BoardDescribeResponse } from "../../shared/boardDescribe";
6
- import { CLI_DEFAULTS } from "../lib/constants";
7
- import { syncCliOutputFormatFromGlobals } from "../lib/cliFormat";
8
- import { createTestCliRuntime } from "../lib/runtime";
9
- import { resetCliOutputFormat, CliError } from "../lib/output";
10
- import { captureStdout } from "../lib/testHelpers";
11
- import { createDefaultCliContext } from "./context";
12
- import { CLI_ERR } from "../types/errors";
13
- import {
14
- handleBoardsDescribe,
15
- handleBoardsList,
16
- handleBoardsTasks,
17
- } from "./boards";
18
- import type { CliContext } from "./context";
19
-
20
- const defaultCliPolicy: BoardIndexEntry["cliPolicy"] = {
21
- readBoard: true,
22
- createTasks: true,
23
- manageCliCreatedTasks: true,
24
- manageAnyTasks: false,
25
- createLists: true,
26
- manageCliCreatedLists: true,
27
- manageAnyLists: false,
28
- manageStructure: false,
29
- deleteBoard: false,
30
- editBoard: false,
31
- };
32
-
33
- function boardEntry(overrides: Partial<BoardIndexEntry> = {}): BoardIndexEntry {
34
- return {
35
- boardId: 1,
36
- slug: "alpha",
37
- name: "Alpha",
38
- emoji: null,
39
- description: "",
40
- cliPolicy: defaultCliPolicy,
41
- createdAt: "2026-01-01T00:00:00.000Z",
42
- ...overrides,
43
- };
44
- }
45
-
46
- function mockContext(overrides: Partial<CliContext> = {}): CliContext {
47
- return {
48
- ...createDefaultCliContext(),
49
- resolvePort: () => 3002,
50
- fetchApi: async () => {
51
- throw new Error("fetchApi not stubbed");
52
- },
53
- fetchApiMutate: async () => {
54
- throw new Error("fetchApiMutate not stubbed");
55
- },
56
- fetchApiTrashMutate: async () => {
57
- throw new Error("fetchApiTrashMutate not stubbed");
58
- },
59
- printJson: () => {},
60
- getRuntime: () => createTestCliRuntime({ port: 3002 }),
61
- ...overrides,
62
- };
63
- }
64
-
65
- describe("handleBoardsList (CliContext)", () => {
66
- afterEach(() => {
67
- resetCliOutputFormat();
68
- });
69
-
70
- test("uses injected fetchApi and prints NDJSON lines to stdout", async () => {
71
- const sample: BoardIndexEntry[] = [boardEntry()];
72
- const envelope: PaginatedListBody<BoardIndexEntry> = {
73
- items: sample,
74
- total: sample.length,
75
- limit: sample.length,
76
- offset: 0,
77
- };
78
- const ctx = mockContext({
79
- fetchApi: (async () => envelope) as CliContext["fetchApi"],
80
- });
81
-
82
- const out = await captureStdout(() => handleBoardsList(ctx, {}));
83
-
84
- const lines = out.trimEnd().split("\n").filter((l) => l.length > 0);
85
- expect(lines.length).toBe(1);
86
- expect(JSON.parse(lines[0]!)).toEqual(sample[0]);
87
- });
88
-
89
- test("with global --quiet prints slug per line (not JSON)", async () => {
90
- syncCliOutputFormatFromGlobals({ format: "ndjson", quiet: true });
91
- const sample: BoardIndexEntry[] = [boardEntry()];
92
- const envelope: PaginatedListBody<BoardIndexEntry> = {
93
- items: sample,
94
- total: sample.length,
95
- limit: sample.length,
96
- offset: 0,
97
- };
98
- const ctx = mockContext({
99
- fetchApi: (async () => envelope) as CliContext["fetchApi"],
100
- });
101
-
102
- const out = await captureStdout(() => handleBoardsList(ctx, {}));
103
-
104
- expect(out.trimEnd()).toBe("alpha");
105
- });
106
-
107
- test("--format human — table headers and footer", async () => {
108
- syncCliOutputFormatFromGlobals({ format: "human", quiet: false });
109
- const sample: BoardIndexEntry[] = [boardEntry({ boardId: 9, slug: "s", name: "N" })];
110
- const envelope: PaginatedListBody<BoardIndexEntry> = {
111
- items: sample,
112
- total: 1,
113
- limit: 1,
114
- offset: 0,
115
- };
116
- const ctx = mockContext({
117
- fetchApi: (async () => envelope) as CliContext["fetchApi"],
118
- });
119
-
120
- const out = await captureStdout(() => handleBoardsList(ctx, {}));
121
-
122
- expect(out).toContain("Slug");
123
- expect(out).toContain("Id");
124
- expect(out).toContain("N");
125
- expect(out).toContain("total 1");
126
- });
127
-
128
- test("--fields boardId,name — projected NDJSON lines", async () => {
129
- syncCliOutputFormatFromGlobals({ format: "ndjson", quiet: false });
130
- const sample: BoardIndexEntry[] = [boardEntry()];
131
- const envelope: PaginatedListBody<BoardIndexEntry> = {
132
- items: sample,
133
- total: 1,
134
- limit: 1,
135
- offset: 0,
136
- };
137
- const ctx = mockContext({
138
- fetchApi: (async () => envelope) as CliContext["fetchApi"],
139
- });
140
-
141
- const out = await captureStdout(() =>
142
- handleBoardsList(ctx, { fields: "boardId,name" }),
143
- );
144
-
145
- const row = JSON.parse(out.trim().split("\n")[0]!) as Record<string, unknown>;
146
- expect(Object.keys(row).sort()).toEqual(["boardId", "name"]);
147
- });
148
-
149
- test("--fields slug with --quiet — one value per line", async () => {
150
- syncCliOutputFormatFromGlobals({ format: "ndjson", quiet: true });
151
- const sample: BoardIndexEntry[] = [boardEntry()];
152
- const envelope: PaginatedListBody<BoardIndexEntry> = {
153
- items: sample,
154
- total: 1,
155
- limit: 1,
156
- offset: 0,
157
- };
158
- const ctx = mockContext({
159
- fetchApi: (async () => envelope) as CliContext["fetchApi"],
160
- });
161
-
162
- const out = await captureStdout(() =>
163
- handleBoardsList(ctx, { fields: "slug" }),
164
- );
165
-
166
- expect(out.trimEnd()).toBe("alpha");
167
- });
168
-
169
- test("--page-all merges two pages", async () => {
170
- syncCliOutputFormatFromGlobals({ format: "ndjson", quiet: false });
171
- let call = 0;
172
- const ctx = mockContext({
173
- fetchApi: (async (path: string) => {
174
- if (call++ === 0) {
175
- expect(path).toContain(`limit=${CLI_DEFAULTS.MAX_PAGE_LIMIT}`);
176
- expect(path).not.toContain("offset=");
177
- return {
178
- items: [boardEntry({ boardId: 1, slug: "a", name: "A" })],
179
- total: 2,
180
- limit: CLI_DEFAULTS.MAX_PAGE_LIMIT,
181
- offset: 0,
182
- } satisfies PaginatedListBody<BoardIndexEntry>;
183
- }
184
- expect(path).toContain(`offset=${CLI_DEFAULTS.MAX_PAGE_LIMIT}`);
185
- return {
186
- items: [boardEntry({ boardId: 2, slug: "b", name: "B" })],
187
- total: 2,
188
- limit: CLI_DEFAULTS.MAX_PAGE_LIMIT,
189
- offset: 0,
190
- } satisfies PaginatedListBody<BoardIndexEntry>;
191
- }) as CliContext["fetchApi"],
192
- });
193
-
194
- const out = await captureStdout(() =>
195
- handleBoardsList(ctx, { pageAll: true }),
196
- );
197
-
198
- const lines = out.trimEnd().split("\n").filter((l) => l.length > 0);
199
- expect(lines.length).toBe(2);
200
- });
201
-
202
- test("empty result — NDJSON no lines; human No rows.", async () => {
203
- const empty: PaginatedListBody<BoardIndexEntry> = {
204
- items: [],
205
- total: 0,
206
- limit: 0,
207
- offset: 0,
208
- };
209
- syncCliOutputFormatFromGlobals({ format: "ndjson", quiet: false });
210
- const ctxNd = mockContext({
211
- fetchApi: (async () => empty) as CliContext["fetchApi"],
212
- });
213
- const outNd = await captureStdout(() => handleBoardsList(ctxNd, {}));
214
- expect(outNd.trim()).toBe("");
215
-
216
- syncCliOutputFormatFromGlobals({ format: "human", quiet: false });
217
- const ctxHu = mockContext({
218
- fetchApi: (async () => empty) as CliContext["fetchApi"],
219
- });
220
- const outHu = await captureStdout(() => handleBoardsList(ctxHu, {}));
221
- expect(outHu).toContain("No rows.");
222
- });
223
-
224
- test("API 403 — CliError exit 4 forbidden", async () => {
225
- const ctx = mockContext({
226
- fetchApi: async () => {
227
- throw new CliError("denied", 4, { code: CLI_ERR.forbidden });
228
- },
229
- });
230
- await expect(handleBoardsList(ctx, {})).rejects.toMatchObject({
231
- exitCode: 4,
232
- details: expect.objectContaining({ code: CLI_ERR.forbidden }),
233
- });
234
- });
235
-
236
- test("API 401 — CliError exit 10 unauthenticated", async () => {
237
- const ctx = mockContext({
238
- fetchApi: async () => {
239
- throw new CliError("no", 10, { code: CLI_ERR.unauthenticated });
240
- },
241
- });
242
- await expect(handleBoardsList(ctx, {})).rejects.toMatchObject({
243
- exitCode: 10,
244
- details: expect.objectContaining({ code: CLI_ERR.unauthenticated }),
245
- });
246
- });
247
- });
248
-
249
- describe("handleBoardsDescribe", () => {
250
- afterEach(() => {
251
- resetCliOutputFormat();
252
- });
253
-
254
- const minimalBoard: BoardDescribeResponse["board"] = {
255
- boardId: 1,
256
- slug: "b",
257
- name: "B",
258
- description: "",
259
- cliPolicy: {
260
- readBoard: true,
261
- createTasks: false,
262
- manageCliCreatedTasks: false,
263
- manageAnyTasks: false,
264
- createLists: false,
265
- manageCliCreatedLists: false,
266
- manageAnyLists: false,
267
- manageStructure: false,
268
- deleteBoard: false,
269
- editBoard: false,
270
- },
271
- };
272
-
273
- test("fetches describe; ndjson prints kind board line to stdout", async () => {
274
- syncCliOutputFormatFromGlobals({ format: "ndjson", quiet: false });
275
- const sample: BoardDescribeResponse = {
276
- board: minimalBoard,
277
- };
278
- let path = "";
279
- const ctx = mockContext({
280
- fetchApi: (async (p) => {
281
- path = p;
282
- return sample;
283
- }) as CliContext["fetchApi"],
284
- });
285
-
286
- const out = await captureStdout(() => handleBoardsDescribe(ctx, "my-slug", {}));
287
-
288
- expect(path).toBe("/boards/my-slug/describe");
289
- const lines = out.trimEnd().split("\n");
290
- const row0 = JSON.parse(lines[0]!) as { kind: string; boardId: number };
291
- expect(row0.kind).toBe("board");
292
- expect(row0.boardId).toBe(1);
293
- expect(JSON.parse(lines[1]!).kind).toBe("policy");
294
-
295
- const out2 = await captureStdout(() =>
296
- handleBoardsDescribe(ctx, "x", { entities: "list,group" }),
297
- );
298
- expect(path).toBe("/boards/x/describe?entities=group%2Clist");
299
- const lines2 = out2.trim().split("\n");
300
- expect(JSON.parse(lines2[0]!).kind).toBe("board");
301
- expect(JSON.parse(lines2[1]!).kind).toBe("policy");
302
- });
303
-
304
- test("entities meta — includes kind meta line", async () => {
305
- syncCliOutputFormatFromGlobals({ format: "ndjson", quiet: false });
306
- const meta = {
307
- lists: { truncated: false, total: 1, shown: 1 },
308
- groups: { truncated: false, total: 0, shown: 0 },
309
- priorities: { truncated: false, total: 0, shown: 0 },
310
- releases: { truncated: false, total: 0, shown: 0 },
311
- statuses: { truncated: false, total: 0, shown: 0 },
312
- };
313
- const sample: BoardDescribeResponse = {
314
- board: minimalBoard,
315
- meta,
316
- };
317
- const ctx = mockContext({
318
- fetchApi: (async () => sample) as CliContext["fetchApi"],
319
- });
320
-
321
- const out = await captureStdout(() =>
322
- handleBoardsDescribe(ctx, "b", { entities: "meta" }),
323
- );
324
-
325
- const metaLine = out
326
- .trimEnd()
327
- .split("\n")
328
- .map((l) => JSON.parse(l) as { kind?: string })
329
- .find((r) => r.kind === "meta");
330
- expect(metaLine).toBeDefined();
331
- expect(metaLine).toMatchObject({ kind: "meta" });
332
- });
333
-
334
- test("entities all sections — row kinds in order", async () => {
335
- syncCliOutputFormatFromGlobals({ format: "ndjson", quiet: false });
336
- const sample: BoardDescribeResponse = {
337
- board: minimalBoard,
338
- lists: { items: [{ listId: 1, name: "L" }] },
339
- groups: { items: [{ groupId: 1, label: "G", default: true }] },
340
- priorities: {
341
- items: [{ priorityId: 1, label: "P", value: 0 }],
342
- },
343
- releases: {
344
- items: [
345
- {
346
- releaseId: 1,
347
- name: "R",
348
- releaseDate: null,
349
- default: false,
350
- },
351
- ],
352
- },
353
- statuses: { items: [{ statusId: "open", label: "Open" }] },
354
- meta: {
355
- lists: { truncated: false, total: 1, shown: 1 },
356
- groups: { truncated: false, total: 1, shown: 1 },
357
- priorities: { truncated: false, total: 1, shown: 1 },
358
- releases: { truncated: false, total: 1, shown: 1 },
359
- statuses: { truncated: false, total: 1, shown: 1 },
360
- },
361
- };
362
- const ctx = mockContext({
363
- fetchApi: (async () => sample) as CliContext["fetchApi"],
364
- });
365
-
366
- const out = await captureStdout(() =>
367
- handleBoardsDescribe(ctx, "b", {
368
- entities: "list,group,priority,release,status,meta",
369
- }),
370
- );
371
-
372
- const kinds = out
373
- .trimEnd()
374
- .split("\n")
375
- .map((l) => (JSON.parse(l) as { kind: string }).kind);
376
- expect(kinds).toEqual([
377
- "board",
378
- "policy",
379
- "list",
380
- "group",
381
- "priority",
382
- "release",
383
- "status",
384
- "meta",
385
- ]);
386
- });
387
-
388
- test("--format human — section titles and tables", async () => {
389
- syncCliOutputFormatFromGlobals({ format: "human", quiet: false });
390
- const sample: BoardDescribeResponse = {
391
- board: minimalBoard,
392
- lists: { items: [{ listId: 1, name: "Todo" }] },
393
- };
394
- const ctx = mockContext({
395
- fetchApi: (async () => sample) as CliContext["fetchApi"],
396
- });
397
-
398
- const out = await captureStdout(() => handleBoardsDescribe(ctx, "b", {}));
399
-
400
- expect(out).toContain("Board\n");
401
- expect(out).toContain("Lists\n");
402
- expect(out).toContain("Todo");
403
- expect(out).toContain("CLI policy\n");
404
- });
405
-
406
- test("--quiet — exit 2 (not supported for describe)", async () => {
407
- syncCliOutputFormatFromGlobals({ format: "ndjson", quiet: true });
408
- const ctx = mockContext({
409
- fetchApi: (async () => ({ board: minimalBoard })) as CliContext["fetchApi"],
410
- });
411
-
412
- await expect(handleBoardsDescribe(ctx, "b", {})).rejects.toMatchObject({
413
- exitCode: 2,
414
- details: expect.objectContaining({ code: CLI_ERR.invalidValue }),
415
- });
416
- });
417
-
418
- test("board not found — 404 maps to exit 3 not_found", async () => {
419
- syncCliOutputFormatFromGlobals({ format: "ndjson", quiet: false });
420
- const ctx = mockContext({
421
- fetchApi: async () => {
422
- throw new CliError("missing", 3, { code: CLI_ERR.notFound });
423
- },
424
- });
425
-
426
- await expect(handleBoardsDescribe(ctx, "gone", {})).rejects.toMatchObject({
427
- exitCode: 3,
428
- details: expect.objectContaining({ code: CLI_ERR.notFound }),
429
- });
430
- });
431
- });
432
-
433
- describe("handleBoardsTasks", () => {
434
- afterEach(() => {
435
- resetCliOutputFormat();
436
- });
437
-
438
- test("builds query string for filters", async () => {
439
- const envelope: PaginatedListBody<Task> = {
440
- items: [],
441
- total: 0,
442
- limit: 0,
443
- offset: 0,
444
- };
445
- let path = "";
446
- const ctx = mockContext({
447
- fetchApi: (async (p) => {
448
- path = p;
449
- return envelope;
450
- }) as CliContext["fetchApi"],
451
- printJson: () => {},
452
- });
453
-
454
- await handleBoardsTasks(ctx, "brd", {
455
- list: "5",
456
- group: ["1", "2"],
457
- priority: ["10"],
458
- status: ["open"],
459
- releaseId: ["7"],
460
- untagged: true,
461
- dateMode: "updated",
462
- from: "2026-01-01",
463
- to: "2026-02-01",
464
- });
465
-
466
- expect(path).toContain("/boards/brd/tasks?");
467
- expect(path).toContain("listId=5");
468
- expect(path).toContain("groupId=1");
469
- expect(path).toContain("groupId=2");
470
- expect(path).toContain("priorityId=10");
471
- expect(path).toContain("status=open");
472
- expect(path).toContain("releaseId=7");
473
- expect(path).toContain(`releaseId=${encodeURIComponent(RELEASE_FILTER_UNTAGGED)}`);
474
- expect(path).toContain("dateMode=updated");
475
- expect(path).toContain("from=2026-01-01");
476
- expect(path).toContain("to=2026-02-01");
477
- });
478
-
479
- test("repeated releaseId and group append params", async () => {
480
- const envelope: PaginatedListBody<Task> = {
481
- items: [],
482
- total: 0,
483
- limit: 0,
484
- offset: 0,
485
- };
486
- let path = "";
487
- const ctx = mockContext({
488
- fetchApi: (async (p) => {
489
- path = p;
490
- return envelope;
491
- }) as CliContext["fetchApi"],
492
- });
493
-
494
- await handleBoardsTasks(ctx, "brd", {
495
- group: ["1", "2"],
496
- releaseId: ["7", "8"],
497
- });
498
-
499
- const u = new URL(path, "http://127.0.0.1");
500
- expect(u.searchParams.getAll("groupId")).toEqual(["1", "2"]);
501
- expect(u.searchParams.getAll("releaseId")).toEqual(["7", "8"]);
502
- });
503
-
504
- test("NDJSON prints one task JSON per line", async () => {
505
- syncCliOutputFormatFromGlobals({ format: "ndjson", quiet: false });
506
- const task: Task = {
507
- taskId: 1,
508
- listId: 1,
509
- groupId: 1,
510
- title: "T",
511
- body: "",
512
- priorityId: 1,
513
- status: "open",
514
- order: 0,
515
- createdAt: "2026-01-01T00:00:00.000Z",
516
- updatedAt: "2026-01-01T00:00:00.000Z",
517
- };
518
- const envelope: PaginatedListBody<Task> = {
519
- items: [task],
520
- total: 1,
521
- limit: 1,
522
- offset: 0,
523
- };
524
- const ctx = mockContext({
525
- fetchApi: (async () => envelope) as CliContext["fetchApi"],
526
- });
527
-
528
- const out = await captureStdout(() => handleBoardsTasks(ctx, "b", {}));
529
-
530
- expect(JSON.parse(out.trim().split("\n")[0]!)).toMatchObject({
531
- taskId: 1,
532
- title: "T",
533
- });
534
- });
535
-
536
- test("--format human — task table columns", async () => {
537
- syncCliOutputFormatFromGlobals({ format: "human", quiet: false });
538
- const task: Task = {
539
- taskId: 1,
540
- listId: 1,
541
- groupId: 1,
542
- title: "T",
543
- body: "",
544
- priorityId: 1,
545
- status: "open",
546
- order: 0,
547
- createdAt: "2026-01-01T00:00:00.000Z",
548
- updatedAt: "2026-01-01T00:00:00.000Z",
549
- };
550
- const envelope: PaginatedListBody<Task> = {
551
- items: [task],
552
- total: 1,
553
- limit: 1,
554
- offset: 0,
555
- };
556
- const ctx = mockContext({
557
- fetchApi: (async () => envelope) as CliContext["fetchApi"],
558
- });
559
-
560
- const out = await captureStdout(() => handleBoardsTasks(ctx, "b", {}));
561
-
562
- expect(out).toContain("Title");
563
- expect(out).toContain("Task");
564
- expect(out).toContain("total 1");
565
- });
566
-
567
- test("--quiet — taskId per line", async () => {
568
- syncCliOutputFormatFromGlobals({ format: "ndjson", quiet: true });
569
- const task: Task = {
570
- taskId: 42,
571
- listId: 1,
572
- groupId: 1,
573
- title: "T",
574
- body: "",
575
- priorityId: 1,
576
- status: "open",
577
- order: 0,
578
- createdAt: "2026-01-01T00:00:00.000Z",
579
- updatedAt: "2026-01-01T00:00:00.000Z",
580
- };
581
- const envelope: PaginatedListBody<Task> = {
582
- items: [task],
583
- total: 1,
584
- limit: 1,
585
- offset: 0,
586
- };
587
- const ctx = mockContext({
588
- fetchApi: (async () => envelope) as CliContext["fetchApi"],
589
- });
590
-
591
- const out = await captureStdout(() => handleBoardsTasks(ctx, "b", {}));
592
-
593
- expect(out.trimEnd()).toBe("42");
594
- });
595
-
596
- test("--fields taskId,title — projected", async () => {
597
- syncCliOutputFormatFromGlobals({ format: "ndjson", quiet: false });
598
- const task: Task = {
599
- taskId: 1,
600
- listId: 9,
601
- groupId: 1,
602
- title: "T",
603
- body: "x",
604
- priorityId: 1,
605
- status: "open",
606
- order: 0,
607
- createdAt: "2026-01-01T00:00:00.000Z",
608
- updatedAt: "2026-01-01T00:00:00.000Z",
609
- };
610
- const envelope: PaginatedListBody<Task> = {
611
- items: [task],
612
- total: 1,
613
- limit: 1,
614
- offset: 0,
615
- };
616
- const ctx = mockContext({
617
- fetchApi: (async () => envelope) as CliContext["fetchApi"],
618
- });
619
-
620
- const out = await captureStdout(() =>
621
- handleBoardsTasks(ctx, "b", { fields: "taskId,title" }),
622
- );
623
-
624
- const row = JSON.parse(out.trim()) as Record<string, unknown>;
625
- expect(Object.keys(row).sort()).toEqual(["taskId", "title"]);
626
- });
627
-
628
- test("--page-all merges pages", async () => {
629
- syncCliOutputFormatFromGlobals({ format: "ndjson", quiet: false });
630
- let call = 0;
631
- const mkTask = (id: number): Task => ({
632
- taskId: id,
633
- listId: 1,
634
- groupId: 1,
635
- title: "T",
636
- body: "",
637
- priorityId: 1,
638
- status: "open",
639
- order: 0,
640
- createdAt: "2026-01-01T00:00:00.000Z",
641
- updatedAt: "2026-01-01T00:00:00.000Z",
642
- });
643
- const ctx = mockContext({
644
- fetchApi: (async () => {
645
- if (call++ === 0) {
646
- return {
647
- items: [mkTask(1)],
648
- total: 2,
649
- limit: 2,
650
- offset: 0,
651
- } satisfies PaginatedListBody<Task>;
652
- }
653
- return {
654
- items: [mkTask(2)],
655
- total: 2,
656
- limit: 2,
657
- offset: 0,
658
- } satisfies PaginatedListBody<Task>;
659
- }) as CliContext["fetchApi"],
660
- });
661
-
662
- const out = await captureStdout(() =>
663
- handleBoardsTasks(ctx, "b", { pageAll: true, limit: "2" }),
664
- );
665
-
666
- const lines = out.trimEnd().split("\n").filter((l) => l.length > 0);
667
- expect(lines.length).toBe(2);
668
- });
669
- });
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import type { PaginatedListBody } from "../../shared/pagination";
3
+ import type { BoardIndexEntry, Task } from "../../shared/models";
4
+ import { RELEASE_FILTER_UNTAGGED } from "../../shared/boardFilters";
5
+ import type { BoardDescribeResponse } from "../../shared/boardDescribe";
6
+ import { CLI_DEFAULTS } from "../lib/constants";
7
+ import { syncCliOutputFormatFromGlobals } from "../lib/cliFormat";
8
+ import { createTestCliRuntime } from "../lib/runtime";
9
+ import { resetCliOutputFormat, CliError } from "../lib/output";
10
+ import { captureStdout } from "../lib/testHelpers";
11
+ import { createDefaultCliContext } from "./context";
12
+ import { CLI_ERR } from "../types/errors";
13
+ import {
14
+ handleBoardsDescribe,
15
+ handleBoardsList,
16
+ handleBoardsTasks,
17
+ } from "./boards";
18
+ import type { CliContext } from "./context";
19
+
20
+ const defaultCliPolicy: BoardIndexEntry["cliPolicy"] = {
21
+ readBoard: true,
22
+ createTasks: true,
23
+ manageCliCreatedTasks: true,
24
+ manageAnyTasks: false,
25
+ createLists: true,
26
+ manageCliCreatedLists: true,
27
+ manageAnyLists: false,
28
+ manageStructure: false,
29
+ deleteBoard: false,
30
+ editBoard: false,
31
+ };
32
+
33
+ function boardEntry(overrides: Partial<BoardIndexEntry> = {}): BoardIndexEntry {
34
+ return {
35
+ boardId: 1,
36
+ slug: "alpha",
37
+ name: "Alpha",
38
+ emoji: null,
39
+ description: "",
40
+ cliPolicy: defaultCliPolicy,
41
+ createdAt: "2026-01-01T00:00:00.000Z",
42
+ ...overrides,
43
+ };
44
+ }
45
+
46
+ function mockContext(overrides: Partial<CliContext> = {}): CliContext {
47
+ return {
48
+ ...createDefaultCliContext(),
49
+ resolvePort: () => 3002,
50
+ fetchApi: async () => {
51
+ throw new Error("fetchApi not stubbed");
52
+ },
53
+ fetchApiMutate: async () => {
54
+ throw new Error("fetchApiMutate not stubbed");
55
+ },
56
+ fetchApiTrashMutate: async () => {
57
+ throw new Error("fetchApiTrashMutate not stubbed");
58
+ },
59
+ printJson: () => {},
60
+ getRuntime: () => createTestCliRuntime({ port: 3002 }),
61
+ ...overrides,
62
+ };
63
+ }
64
+
65
+ describe("handleBoardsList (CliContext)", () => {
66
+ afterEach(() => {
67
+ resetCliOutputFormat();
68
+ });
69
+
70
+ test("uses injected fetchApi and prints NDJSON lines to stdout", async () => {
71
+ const sample: BoardIndexEntry[] = [boardEntry()];
72
+ const envelope: PaginatedListBody<BoardIndexEntry> = {
73
+ items: sample,
74
+ total: sample.length,
75
+ limit: sample.length,
76
+ offset: 0,
77
+ };
78
+ const ctx = mockContext({
79
+ fetchApi: (async () => envelope) as CliContext["fetchApi"],
80
+ });
81
+
82
+ const out = await captureStdout(() => handleBoardsList(ctx, {}));
83
+
84
+ const lines = out.trimEnd().split("\n").filter((l) => l.length > 0);
85
+ expect(lines.length).toBe(1);
86
+ expect(JSON.parse(lines[0]!)).toEqual(sample[0]);
87
+ });
88
+
89
+ test("with global --quiet prints slug per line (not JSON)", async () => {
90
+ syncCliOutputFormatFromGlobals({ format: "ndjson", quiet: true });
91
+ const sample: BoardIndexEntry[] = [boardEntry()];
92
+ const envelope: PaginatedListBody<BoardIndexEntry> = {
93
+ items: sample,
94
+ total: sample.length,
95
+ limit: sample.length,
96
+ offset: 0,
97
+ };
98
+ const ctx = mockContext({
99
+ fetchApi: (async () => envelope) as CliContext["fetchApi"],
100
+ });
101
+
102
+ const out = await captureStdout(() => handleBoardsList(ctx, {}));
103
+
104
+ expect(out.trimEnd()).toBe("alpha");
105
+ });
106
+
107
+ test("--format human — table headers and footer", async () => {
108
+ syncCliOutputFormatFromGlobals({ format: "human", quiet: false });
109
+ const sample: BoardIndexEntry[] = [boardEntry({ boardId: 9, slug: "s", name: "N" })];
110
+ const envelope: PaginatedListBody<BoardIndexEntry> = {
111
+ items: sample,
112
+ total: 1,
113
+ limit: 1,
114
+ offset: 0,
115
+ };
116
+ const ctx = mockContext({
117
+ fetchApi: (async () => envelope) as CliContext["fetchApi"],
118
+ });
119
+
120
+ const out = await captureStdout(() => handleBoardsList(ctx, {}));
121
+
122
+ expect(out).toContain("Slug");
123
+ expect(out).toContain("Id");
124
+ expect(out).toContain("N");
125
+ expect(out).toContain("total 1");
126
+ });
127
+
128
+ test("--fields boardId,name — projected NDJSON lines", async () => {
129
+ syncCliOutputFormatFromGlobals({ format: "ndjson", quiet: false });
130
+ const sample: BoardIndexEntry[] = [boardEntry()];
131
+ const envelope: PaginatedListBody<BoardIndexEntry> = {
132
+ items: sample,
133
+ total: 1,
134
+ limit: 1,
135
+ offset: 0,
136
+ };
137
+ const ctx = mockContext({
138
+ fetchApi: (async () => envelope) as CliContext["fetchApi"],
139
+ });
140
+
141
+ const out = await captureStdout(() =>
142
+ handleBoardsList(ctx, { fields: "boardId,name" }),
143
+ );
144
+
145
+ const row = JSON.parse(out.trim().split("\n")[0]!) as Record<string, unknown>;
146
+ expect(Object.keys(row).sort()).toEqual(["boardId", "name"]);
147
+ });
148
+
149
+ test("--fields slug with --quiet — one value per line", async () => {
150
+ syncCliOutputFormatFromGlobals({ format: "ndjson", quiet: true });
151
+ const sample: BoardIndexEntry[] = [boardEntry()];
152
+ const envelope: PaginatedListBody<BoardIndexEntry> = {
153
+ items: sample,
154
+ total: 1,
155
+ limit: 1,
156
+ offset: 0,
157
+ };
158
+ const ctx = mockContext({
159
+ fetchApi: (async () => envelope) as CliContext["fetchApi"],
160
+ });
161
+
162
+ const out = await captureStdout(() =>
163
+ handleBoardsList(ctx, { fields: "slug" }),
164
+ );
165
+
166
+ expect(out.trimEnd()).toBe("alpha");
167
+ });
168
+
169
+ test("--page-all merges two pages", async () => {
170
+ syncCliOutputFormatFromGlobals({ format: "ndjson", quiet: false });
171
+ let call = 0;
172
+ const ctx = mockContext({
173
+ fetchApi: (async (path: string) => {
174
+ if (call++ === 0) {
175
+ expect(path).toContain(`limit=${CLI_DEFAULTS.MAX_PAGE_LIMIT}`);
176
+ expect(path).not.toContain("offset=");
177
+ return {
178
+ items: [boardEntry({ boardId: 1, slug: "a", name: "A" })],
179
+ total: 2,
180
+ limit: CLI_DEFAULTS.MAX_PAGE_LIMIT,
181
+ offset: 0,
182
+ } satisfies PaginatedListBody<BoardIndexEntry>;
183
+ }
184
+ expect(path).toContain(`offset=${CLI_DEFAULTS.MAX_PAGE_LIMIT}`);
185
+ return {
186
+ items: [boardEntry({ boardId: 2, slug: "b", name: "B" })],
187
+ total: 2,
188
+ limit: CLI_DEFAULTS.MAX_PAGE_LIMIT,
189
+ offset: 0,
190
+ } satisfies PaginatedListBody<BoardIndexEntry>;
191
+ }) as CliContext["fetchApi"],
192
+ });
193
+
194
+ const out = await captureStdout(() =>
195
+ handleBoardsList(ctx, { pageAll: true }),
196
+ );
197
+
198
+ const lines = out.trimEnd().split("\n").filter((l) => l.length > 0);
199
+ expect(lines.length).toBe(2);
200
+ });
201
+
202
+ test("empty result — NDJSON no lines; human No rows.", async () => {
203
+ const empty: PaginatedListBody<BoardIndexEntry> = {
204
+ items: [],
205
+ total: 0,
206
+ limit: 0,
207
+ offset: 0,
208
+ };
209
+ syncCliOutputFormatFromGlobals({ format: "ndjson", quiet: false });
210
+ const ctxNd = mockContext({
211
+ fetchApi: (async () => empty) as CliContext["fetchApi"],
212
+ });
213
+ const outNd = await captureStdout(() => handleBoardsList(ctxNd, {}));
214
+ expect(outNd.trim()).toBe("");
215
+
216
+ syncCliOutputFormatFromGlobals({ format: "human", quiet: false });
217
+ const ctxHu = mockContext({
218
+ fetchApi: (async () => empty) as CliContext["fetchApi"],
219
+ });
220
+ const outHu = await captureStdout(() => handleBoardsList(ctxHu, {}));
221
+ expect(outHu).toContain("No rows.");
222
+ });
223
+
224
+ test("API 403 — CliError exit 4 forbidden", async () => {
225
+ const ctx = mockContext({
226
+ fetchApi: async () => {
227
+ throw new CliError("denied", 4, { code: CLI_ERR.forbidden });
228
+ },
229
+ });
230
+ await expect(handleBoardsList(ctx, {})).rejects.toMatchObject({
231
+ exitCode: 4,
232
+ details: expect.objectContaining({ code: CLI_ERR.forbidden }),
233
+ });
234
+ });
235
+
236
+ test("API 401 — CliError exit 10 unauthenticated", async () => {
237
+ const ctx = mockContext({
238
+ fetchApi: async () => {
239
+ throw new CliError("no", 10, { code: CLI_ERR.unauthenticated });
240
+ },
241
+ });
242
+ await expect(handleBoardsList(ctx, {})).rejects.toMatchObject({
243
+ exitCode: 10,
244
+ details: expect.objectContaining({ code: CLI_ERR.unauthenticated }),
245
+ });
246
+ });
247
+ });
248
+
249
+ describe("handleBoardsDescribe", () => {
250
+ afterEach(() => {
251
+ resetCliOutputFormat();
252
+ });
253
+
254
+ const minimalBoard: BoardDescribeResponse["board"] = {
255
+ boardId: 1,
256
+ slug: "b",
257
+ name: "B",
258
+ description: "",
259
+ cliPolicy: {
260
+ readBoard: true,
261
+ createTasks: false,
262
+ manageCliCreatedTasks: false,
263
+ manageAnyTasks: false,
264
+ createLists: false,
265
+ manageCliCreatedLists: false,
266
+ manageAnyLists: false,
267
+ manageStructure: false,
268
+ deleteBoard: false,
269
+ editBoard: false,
270
+ },
271
+ };
272
+
273
+ test("fetches describe; ndjson prints kind board line to stdout", async () => {
274
+ syncCliOutputFormatFromGlobals({ format: "ndjson", quiet: false });
275
+ const sample: BoardDescribeResponse = {
276
+ board: minimalBoard,
277
+ };
278
+ let path = "";
279
+ const ctx = mockContext({
280
+ fetchApi: (async (p) => {
281
+ path = p;
282
+ return sample;
283
+ }) as CliContext["fetchApi"],
284
+ });
285
+
286
+ const out = await captureStdout(() => handleBoardsDescribe(ctx, "my-slug", {}));
287
+
288
+ expect(path).toBe("/boards/my-slug/describe");
289
+ const lines = out.trimEnd().split("\n");
290
+ const row0 = JSON.parse(lines[0]!) as { kind: string; boardId: number };
291
+ expect(row0.kind).toBe("board");
292
+ expect(row0.boardId).toBe(1);
293
+ expect(JSON.parse(lines[1]!).kind).toBe("policy");
294
+
295
+ const out2 = await captureStdout(() =>
296
+ handleBoardsDescribe(ctx, "x", { entities: "list,group" }),
297
+ );
298
+ expect(path).toBe("/boards/x/describe?entities=group%2Clist");
299
+ const lines2 = out2.trim().split("\n");
300
+ expect(JSON.parse(lines2[0]!).kind).toBe("board");
301
+ expect(JSON.parse(lines2[1]!).kind).toBe("policy");
302
+ });
303
+
304
+ test("entities meta — includes kind meta line", async () => {
305
+ syncCliOutputFormatFromGlobals({ format: "ndjson", quiet: false });
306
+ const meta = {
307
+ lists: { truncated: false, total: 1, shown: 1 },
308
+ groups: { truncated: false, total: 0, shown: 0 },
309
+ priorities: { truncated: false, total: 0, shown: 0 },
310
+ releases: { truncated: false, total: 0, shown: 0 },
311
+ statuses: { truncated: false, total: 0, shown: 0 },
312
+ };
313
+ const sample: BoardDescribeResponse = {
314
+ board: minimalBoard,
315
+ meta,
316
+ };
317
+ const ctx = mockContext({
318
+ fetchApi: (async () => sample) as CliContext["fetchApi"],
319
+ });
320
+
321
+ const out = await captureStdout(() =>
322
+ handleBoardsDescribe(ctx, "b", { entities: "meta" }),
323
+ );
324
+
325
+ const metaLine = out
326
+ .trimEnd()
327
+ .split("\n")
328
+ .map((l) => JSON.parse(l) as { kind?: string })
329
+ .find((r) => r.kind === "meta");
330
+ expect(metaLine).toBeDefined();
331
+ expect(metaLine).toMatchObject({ kind: "meta" });
332
+ });
333
+
334
+ test("entities all sections — row kinds in order", async () => {
335
+ syncCliOutputFormatFromGlobals({ format: "ndjson", quiet: false });
336
+ const sample: BoardDescribeResponse = {
337
+ board: minimalBoard,
338
+ lists: { items: [{ listId: 1, name: "L" }] },
339
+ groups: { items: [{ groupId: 1, label: "G", default: true }] },
340
+ priorities: {
341
+ items: [{ priorityId: 1, label: "P", value: 0 }],
342
+ },
343
+ releases: {
344
+ items: [
345
+ {
346
+ releaseId: 1,
347
+ name: "R",
348
+ releaseDate: null,
349
+ default: false,
350
+ },
351
+ ],
352
+ },
353
+ statuses: { items: [{ statusId: "open", label: "Open" }] },
354
+ meta: {
355
+ lists: { truncated: false, total: 1, shown: 1 },
356
+ groups: { truncated: false, total: 1, shown: 1 },
357
+ priorities: { truncated: false, total: 1, shown: 1 },
358
+ releases: { truncated: false, total: 1, shown: 1 },
359
+ statuses: { truncated: false, total: 1, shown: 1 },
360
+ },
361
+ };
362
+ const ctx = mockContext({
363
+ fetchApi: (async () => sample) as CliContext["fetchApi"],
364
+ });
365
+
366
+ const out = await captureStdout(() =>
367
+ handleBoardsDescribe(ctx, "b", {
368
+ entities: "list,group,priority,release,status,meta",
369
+ }),
370
+ );
371
+
372
+ const kinds = out
373
+ .trimEnd()
374
+ .split("\n")
375
+ .map((l) => (JSON.parse(l) as { kind: string }).kind);
376
+ expect(kinds).toEqual([
377
+ "board",
378
+ "policy",
379
+ "list",
380
+ "group",
381
+ "priority",
382
+ "release",
383
+ "status",
384
+ "meta",
385
+ ]);
386
+ });
387
+
388
+ test("--format human — section titles and tables", async () => {
389
+ syncCliOutputFormatFromGlobals({ format: "human", quiet: false });
390
+ const sample: BoardDescribeResponse = {
391
+ board: minimalBoard,
392
+ lists: { items: [{ listId: 1, name: "Todo" }] },
393
+ };
394
+ const ctx = mockContext({
395
+ fetchApi: (async () => sample) as CliContext["fetchApi"],
396
+ });
397
+
398
+ const out = await captureStdout(() => handleBoardsDescribe(ctx, "b", {}));
399
+
400
+ expect(out).toContain("Board\n");
401
+ expect(out).toContain("Lists\n");
402
+ expect(out).toContain("Todo");
403
+ expect(out).toContain("CLI policy\n");
404
+ });
405
+
406
+ test("--quiet — exit 2 (not supported for describe)", async () => {
407
+ syncCliOutputFormatFromGlobals({ format: "ndjson", quiet: true });
408
+ const ctx = mockContext({
409
+ fetchApi: (async () => ({ board: minimalBoard })) as CliContext["fetchApi"],
410
+ });
411
+
412
+ await expect(handleBoardsDescribe(ctx, "b", {})).rejects.toMatchObject({
413
+ exitCode: 2,
414
+ details: expect.objectContaining({ code: CLI_ERR.invalidValue }),
415
+ });
416
+ });
417
+
418
+ test("board not found — 404 maps to exit 3 not_found", async () => {
419
+ syncCliOutputFormatFromGlobals({ format: "ndjson", quiet: false });
420
+ const ctx = mockContext({
421
+ fetchApi: async () => {
422
+ throw new CliError("missing", 3, { code: CLI_ERR.notFound });
423
+ },
424
+ });
425
+
426
+ await expect(handleBoardsDescribe(ctx, "gone", {})).rejects.toMatchObject({
427
+ exitCode: 3,
428
+ details: expect.objectContaining({ code: CLI_ERR.notFound }),
429
+ });
430
+ });
431
+ });
432
+
433
+ describe("handleBoardsTasks", () => {
434
+ afterEach(() => {
435
+ resetCliOutputFormat();
436
+ });
437
+
438
+ test("builds query string for filters", async () => {
439
+ const envelope: PaginatedListBody<Task> = {
440
+ items: [],
441
+ total: 0,
442
+ limit: 0,
443
+ offset: 0,
444
+ };
445
+ let path = "";
446
+ const ctx = mockContext({
447
+ fetchApi: (async (p) => {
448
+ path = p;
449
+ return envelope;
450
+ }) as CliContext["fetchApi"],
451
+ printJson: () => {},
452
+ });
453
+
454
+ await handleBoardsTasks(ctx, "brd", {
455
+ list: "5",
456
+ group: ["1", "2"],
457
+ priority: ["10"],
458
+ status: ["open"],
459
+ releaseId: ["7"],
460
+ untagged: true,
461
+ dateMode: "updated",
462
+ from: "2026-01-01",
463
+ to: "2026-02-01",
464
+ });
465
+
466
+ expect(path).toContain("/boards/brd/tasks?");
467
+ expect(path).toContain("listId=5");
468
+ expect(path).toContain("groupId=1");
469
+ expect(path).toContain("groupId=2");
470
+ expect(path).toContain("priorityId=10");
471
+ expect(path).toContain("status=open");
472
+ expect(path).toContain("releaseId=7");
473
+ expect(path).toContain(`releaseId=${encodeURIComponent(RELEASE_FILTER_UNTAGGED)}`);
474
+ expect(path).toContain("dateMode=updated");
475
+ expect(path).toContain("from=2026-01-01");
476
+ expect(path).toContain("to=2026-02-01");
477
+ });
478
+
479
+ test("repeated releaseId and group append params", async () => {
480
+ const envelope: PaginatedListBody<Task> = {
481
+ items: [],
482
+ total: 0,
483
+ limit: 0,
484
+ offset: 0,
485
+ };
486
+ let path = "";
487
+ const ctx = mockContext({
488
+ fetchApi: (async (p) => {
489
+ path = p;
490
+ return envelope;
491
+ }) as CliContext["fetchApi"],
492
+ });
493
+
494
+ await handleBoardsTasks(ctx, "brd", {
495
+ group: ["1", "2"],
496
+ releaseId: ["7", "8"],
497
+ });
498
+
499
+ const u = new URL(path, "http://127.0.0.1");
500
+ expect(u.searchParams.getAll("groupId")).toEqual(["1", "2"]);
501
+ expect(u.searchParams.getAll("releaseId")).toEqual(["7", "8"]);
502
+ });
503
+
504
+ test("NDJSON prints one task JSON per line", async () => {
505
+ syncCliOutputFormatFromGlobals({ format: "ndjson", quiet: false });
506
+ const task: Task = {
507
+ taskId: 1,
508
+ listId: 1,
509
+ groupId: 1,
510
+ title: "T",
511
+ body: "",
512
+ priorityId: 1,
513
+ status: "open",
514
+ order: 0,
515
+ createdAt: "2026-01-01T00:00:00.000Z",
516
+ updatedAt: "2026-01-01T00:00:00.000Z",
517
+ };
518
+ const envelope: PaginatedListBody<Task> = {
519
+ items: [task],
520
+ total: 1,
521
+ limit: 1,
522
+ offset: 0,
523
+ };
524
+ const ctx = mockContext({
525
+ fetchApi: (async () => envelope) as CliContext["fetchApi"],
526
+ });
527
+
528
+ const out = await captureStdout(() => handleBoardsTasks(ctx, "b", {}));
529
+
530
+ expect(JSON.parse(out.trim().split("\n")[0]!)).toMatchObject({
531
+ taskId: 1,
532
+ title: "T",
533
+ });
534
+ });
535
+
536
+ test("--format human — task table columns", async () => {
537
+ syncCliOutputFormatFromGlobals({ format: "human", quiet: false });
538
+ const task: Task = {
539
+ taskId: 1,
540
+ listId: 1,
541
+ groupId: 1,
542
+ title: "T",
543
+ body: "",
544
+ priorityId: 1,
545
+ status: "open",
546
+ order: 0,
547
+ createdAt: "2026-01-01T00:00:00.000Z",
548
+ updatedAt: "2026-01-01T00:00:00.000Z",
549
+ };
550
+ const envelope: PaginatedListBody<Task> = {
551
+ items: [task],
552
+ total: 1,
553
+ limit: 1,
554
+ offset: 0,
555
+ };
556
+ const ctx = mockContext({
557
+ fetchApi: (async () => envelope) as CliContext["fetchApi"],
558
+ });
559
+
560
+ const out = await captureStdout(() => handleBoardsTasks(ctx, "b", {}));
561
+
562
+ expect(out).toContain("Title");
563
+ expect(out).toContain("Task");
564
+ expect(out).toContain("total 1");
565
+ });
566
+
567
+ test("--quiet — taskId per line", async () => {
568
+ syncCliOutputFormatFromGlobals({ format: "ndjson", quiet: true });
569
+ const task: Task = {
570
+ taskId: 42,
571
+ listId: 1,
572
+ groupId: 1,
573
+ title: "T",
574
+ body: "",
575
+ priorityId: 1,
576
+ status: "open",
577
+ order: 0,
578
+ createdAt: "2026-01-01T00:00:00.000Z",
579
+ updatedAt: "2026-01-01T00:00:00.000Z",
580
+ };
581
+ const envelope: PaginatedListBody<Task> = {
582
+ items: [task],
583
+ total: 1,
584
+ limit: 1,
585
+ offset: 0,
586
+ };
587
+ const ctx = mockContext({
588
+ fetchApi: (async () => envelope) as CliContext["fetchApi"],
589
+ });
590
+
591
+ const out = await captureStdout(() => handleBoardsTasks(ctx, "b", {}));
592
+
593
+ expect(out.trimEnd()).toBe("42");
594
+ });
595
+
596
+ test("--fields taskId,title — projected", async () => {
597
+ syncCliOutputFormatFromGlobals({ format: "ndjson", quiet: false });
598
+ const task: Task = {
599
+ taskId: 1,
600
+ listId: 9,
601
+ groupId: 1,
602
+ title: "T",
603
+ body: "x",
604
+ priorityId: 1,
605
+ status: "open",
606
+ order: 0,
607
+ createdAt: "2026-01-01T00:00:00.000Z",
608
+ updatedAt: "2026-01-01T00:00:00.000Z",
609
+ };
610
+ const envelope: PaginatedListBody<Task> = {
611
+ items: [task],
612
+ total: 1,
613
+ limit: 1,
614
+ offset: 0,
615
+ };
616
+ const ctx = mockContext({
617
+ fetchApi: (async () => envelope) as CliContext["fetchApi"],
618
+ });
619
+
620
+ const out = await captureStdout(() =>
621
+ handleBoardsTasks(ctx, "b", { fields: "taskId,title" }),
622
+ );
623
+
624
+ const row = JSON.parse(out.trim()) as Record<string, unknown>;
625
+ expect(Object.keys(row).sort()).toEqual(["taskId", "title"]);
626
+ });
627
+
628
+ test("--page-all merges pages", async () => {
629
+ syncCliOutputFormatFromGlobals({ format: "ndjson", quiet: false });
630
+ let call = 0;
631
+ const mkTask = (id: number): Task => ({
632
+ taskId: id,
633
+ listId: 1,
634
+ groupId: 1,
635
+ title: "T",
636
+ body: "",
637
+ priorityId: 1,
638
+ status: "open",
639
+ order: 0,
640
+ createdAt: "2026-01-01T00:00:00.000Z",
641
+ updatedAt: "2026-01-01T00:00:00.000Z",
642
+ });
643
+ const ctx = mockContext({
644
+ fetchApi: (async () => {
645
+ if (call++ === 0) {
646
+ return {
647
+ items: [mkTask(1)],
648
+ total: 2,
649
+ limit: 2,
650
+ offset: 0,
651
+ } satisfies PaginatedListBody<Task>;
652
+ }
653
+ return {
654
+ items: [mkTask(2)],
655
+ total: 2,
656
+ limit: 2,
657
+ offset: 0,
658
+ } satisfies PaginatedListBody<Task>;
659
+ }) as CliContext["fetchApi"],
660
+ });
661
+
662
+ const out = await captureStdout(() =>
663
+ handleBoardsTasks(ctx, "b", { pageAll: true, limit: "2" }),
664
+ );
665
+
666
+ const lines = out.trimEnd().split("\n").filter((l) => l.length > 0);
667
+ expect(lines.length).toBe(2);
668
+ });
669
+ });