@hiroleague/taskmanager 0.0.3 → 0.0.4

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 (62) hide show
  1. package/README.md +1 -1
  2. package/dist/assets/index-BpzHnKdP.css +1 -0
  3. package/dist/assets/index-DmNErTAP.js +273 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/skills/hiro-task-manager-cli/SKILL.md +6 -4
  7. package/skills/hiro-task-manager-cli/reference/cli-access-policy.md +1 -0
  8. package/skills/hiro-task-manager-cli/reference/releases.md +14 -0
  9. package/src/cli/commands/query.ts +56 -56
  10. package/src/cli/commands/releases.ts +22 -0
  11. package/src/cli/handlers/boards.test.ts +669 -669
  12. package/src/cli/handlers/cli-wiring.test.ts +38 -1
  13. package/src/cli/handlers/releases.ts +15 -0
  14. package/src/cli/handlers/search.test.ts +374 -374
  15. package/src/cli/handlers/search.ts +17 -17
  16. package/src/cli/lib/cli-http-errors.test.ts +85 -85
  17. package/src/cli/lib/write/releases.ts +64 -1
  18. package/src/cli/lib/write-result.test.ts +3 -0
  19. package/src/cli/lib/write-result.ts +3 -0
  20. package/src/cli/lib/writeCommands.breadth.test.ts +143 -0
  21. package/src/cli/lib/writeCommands.ts +1 -0
  22. package/src/cli/subprocess.real-stack.test.ts +625 -611
  23. package/src/cli/subprocess.smoke.test.ts +954 -954
  24. package/src/client/api/useBoardChangeStream.ts +421 -168
  25. package/src/client/api/useBoardIndexStream.ts +35 -0
  26. package/src/client/components/board/BoardStatsChips.tsx +233 -233
  27. package/src/client/components/board/BoardStatsContext.tsx +41 -41
  28. package/src/client/components/board/boardHeaderButtonStyles.ts +38 -38
  29. package/src/client/components/board/shortcuts/useBoardShortcutKeydown.ts +49 -49
  30. package/src/client/components/board/useBoardCanvasPanScroll.ts +108 -108
  31. package/src/client/components/board/useBoardTaskContainerDroppableReact.ts +33 -33
  32. package/src/client/components/board/useBoardTaskSortableReact.ts +26 -26
  33. package/src/client/components/layout/AppShell.tsx +5 -2
  34. package/src/client/components/layout/NotificationToasts.tsx +38 -1
  35. package/src/client/components/multi-select.tsx +1206 -1206
  36. package/src/client/components/routing/BoardPage.tsx +20 -20
  37. package/src/client/components/routing/NavigationRegistrar.tsx +13 -13
  38. package/src/client/components/task/TaskCard.tsx +643 -643
  39. package/src/client/components/ui/badge.tsx +49 -49
  40. package/src/client/components/ui/button.tsx +65 -65
  41. package/src/client/components/ui/command.tsx +193 -193
  42. package/src/client/components/ui/dialog.tsx +163 -163
  43. package/src/client/components/ui/input-group.tsx +155 -155
  44. package/src/client/components/ui/input.tsx +19 -19
  45. package/src/client/components/ui/popover.tsx +87 -87
  46. package/src/client/components/ui/separator.tsx +28 -28
  47. package/src/client/components/ui/textarea.tsx +18 -18
  48. package/src/client/index.css +248 -248
  49. package/src/client/lib/appNavigate.ts +16 -16
  50. package/src/client/lib/taskCardDate.ts +111 -111
  51. package/src/client/lib/utils.ts +6 -6
  52. package/src/client/store/notificationUi.ts +14 -0
  53. package/src/server/auth.ts +351 -351
  54. package/src/server/events.ts +31 -4
  55. package/src/server/migrations/registry.ts +43 -43
  56. package/src/server/notificationEvents.ts +8 -1
  57. package/src/server/routes/boards.ts +15 -1
  58. package/src/server/routes/trash.ts +6 -1
  59. package/src/shared/boardEvents.ts +6 -0
  60. package/src/shared/runtimeConfig.ts +256 -256
  61. package/dist/assets/index-hMFTu7sr.css +0 -1
  62. package/dist/assets/index-oKG1C41_.js +0 -273
@@ -1,17 +1,17 @@
1
- import { runSearch } from "../lib/read/search";
2
- import type { CliContext } from "./context";
3
-
4
- export async function handleSearch(
5
- ctx: CliContext,
6
- queryParts: string[],
7
- options: {
8
- board?: string;
9
- limit?: string;
10
- offset?: string;
11
- noPrefix?: boolean;
12
- pageAll?: boolean;
13
- fields?: string;
14
- },
15
- ): Promise<void> {
16
- await runSearch(ctx, queryParts, options);
17
- }
1
+ import { runSearch } from "../lib/read/search";
2
+ import type { CliContext } from "./context";
3
+
4
+ export async function handleSearch(
5
+ ctx: CliContext,
6
+ queryParts: string[],
7
+ options: {
8
+ board?: string;
9
+ limit?: string;
10
+ offset?: string;
11
+ noPrefix?: boolean;
12
+ pageAll?: boolean;
13
+ fields?: string;
14
+ },
15
+ ): Promise<void> {
16
+ await runSearch(ctx, queryParts, options);
17
+ }
@@ -1,85 +1,85 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { CLI_ERR } from "../types/errors";
3
- import { enrichNotFoundError, mapHttpStatusToCliFailure } from "./cli-http-errors";
4
- import { CliError } from "./output";
5
-
6
- describe("mapHttpStatusToCliFailure", () => {
7
- test.each([
8
- [400, CLI_ERR.badRequest, 9, undefined],
9
- [401, CLI_ERR.unauthenticated, 10, undefined],
10
- [403, CLI_ERR.forbidden, 4, undefined],
11
- [404, CLI_ERR.notFound, 3, undefined],
12
- [408, CLI_ERR.requestTimeout, 7, true],
13
- [409, CLI_ERR.conflict, 5, undefined],
14
- [422, CLI_ERR.badRequest, 9, undefined],
15
- [426, CLI_ERR.versionMismatch, 8, undefined],
16
- [429, CLI_ERR.rateLimited, 1, true],
17
- [502, CLI_ERR.internalError, 1, true],
18
- [418, CLI_ERR.httpError, 1, undefined],
19
- ] as const)(
20
- "status %i → code %s exit %i retryable %j",
21
- (status, code, exit, retryable) => {
22
- const { exitCode, details } = mapHttpStatusToCliFailure(status, {
23
- status,
24
- url: "http://127.0.0.1:1/api/x",
25
- });
26
- expect(exitCode).toBe(exit);
27
- expect(details.code).toBe(code);
28
- if (retryable === undefined) {
29
- expect(details.retryable).toBeUndefined();
30
- } else {
31
- expect(details.retryable).toBe(retryable);
32
- }
33
- },
34
- );
35
-
36
- test("599 maps to internal_error (5xx branch)", () => {
37
- const { exitCode, details } = mapHttpStatusToCliFailure(599, { status: 599 });
38
- expect(exitCode).toBe(1);
39
- expect(details.code).toBe(CLI_ERR.internalError);
40
- expect(details.retryable).toBe(true);
41
- });
42
-
43
- test("preserves API code as serverCode, not duplicate top-level code", () => {
44
- const { details } = mapHttpStatusToCliFailure(400, {
45
- code: "CUSTOM",
46
- status: 400,
47
- });
48
- expect(details.code).toBe(CLI_ERR.badRequest);
49
- expect(details.serverCode).toBe("CUSTOM");
50
- expect((details as Record<string, unknown>).code).toBe(CLI_ERR.badRequest);
51
- });
52
-
53
- test("non-string code in body is ignored for serverCode", () => {
54
- const { details } = mapHttpStatusToCliFailure(404, {
55
- code: 123,
56
- status: 404,
57
- } as Record<string, unknown>);
58
- expect(details.serverCode).toBeUndefined();
59
- });
60
- });
61
-
62
- describe("enrichNotFoundError", () => {
63
- test("merges context when details.code is not_found", () => {
64
- const err = new CliError("Whatever", 3, { code: CLI_ERR.notFound });
65
- try {
66
- enrichNotFoundError(err, { board: "my-board", taskId: 9 });
67
- expect.unreachable();
68
- } catch (e) {
69
- expect(e).toBeInstanceOf(CliError);
70
- const c = e as CliError;
71
- expect(c.message).toBe("Whatever");
72
- expect(c.exitCode).toBe(3);
73
- expect(c.details).toMatchObject({
74
- code: CLI_ERR.notFound,
75
- board: "my-board",
76
- taskId: 9,
77
- });
78
- }
79
- });
80
-
81
- test("rethrows unchanged when code is not not_found", () => {
82
- const err = new CliError("Nope", 4, { code: CLI_ERR.forbidden });
83
- expect(() => enrichNotFoundError(err, { board: "x" })).toThrow(err);
84
- });
85
- });
1
+ import { describe, expect, test } from "bun:test";
2
+ import { CLI_ERR } from "../types/errors";
3
+ import { enrichNotFoundError, mapHttpStatusToCliFailure } from "./cli-http-errors";
4
+ import { CliError } from "./output";
5
+
6
+ describe("mapHttpStatusToCliFailure", () => {
7
+ test.each([
8
+ [400, CLI_ERR.badRequest, 9, undefined],
9
+ [401, CLI_ERR.unauthenticated, 10, undefined],
10
+ [403, CLI_ERR.forbidden, 4, undefined],
11
+ [404, CLI_ERR.notFound, 3, undefined],
12
+ [408, CLI_ERR.requestTimeout, 7, true],
13
+ [409, CLI_ERR.conflict, 5, undefined],
14
+ [422, CLI_ERR.badRequest, 9, undefined],
15
+ [426, CLI_ERR.versionMismatch, 8, undefined],
16
+ [429, CLI_ERR.rateLimited, 1, true],
17
+ [502, CLI_ERR.internalError, 1, true],
18
+ [418, CLI_ERR.httpError, 1, undefined],
19
+ ] as const)(
20
+ "status %i → code %s exit %i retryable %j",
21
+ (status, code, exit, retryable) => {
22
+ const { exitCode, details } = mapHttpStatusToCliFailure(status, {
23
+ status,
24
+ url: "http://127.0.0.1:1/api/x",
25
+ });
26
+ expect(exitCode).toBe(exit);
27
+ expect(details.code).toBe(code);
28
+ if (retryable === undefined) {
29
+ expect(details.retryable).toBeUndefined();
30
+ } else {
31
+ expect(details.retryable).toBe(retryable);
32
+ }
33
+ },
34
+ );
35
+
36
+ test("599 maps to internal_error (5xx branch)", () => {
37
+ const { exitCode, details } = mapHttpStatusToCliFailure(599, { status: 599 });
38
+ expect(exitCode).toBe(1);
39
+ expect(details.code).toBe(CLI_ERR.internalError);
40
+ expect(details.retryable).toBe(true);
41
+ });
42
+
43
+ test("preserves API code as serverCode, not duplicate top-level code", () => {
44
+ const { details } = mapHttpStatusToCliFailure(400, {
45
+ code: "CUSTOM",
46
+ status: 400,
47
+ });
48
+ expect(details.code).toBe(CLI_ERR.badRequest);
49
+ expect(details.serverCode).toBe("CUSTOM");
50
+ expect((details as Record<string, unknown>).code).toBe(CLI_ERR.badRequest);
51
+ });
52
+
53
+ test("non-string code in body is ignored for serverCode", () => {
54
+ const { details } = mapHttpStatusToCliFailure(404, {
55
+ code: 123,
56
+ status: 404,
57
+ } as Record<string, unknown>);
58
+ expect(details.serverCode).toBeUndefined();
59
+ });
60
+ });
61
+
62
+ describe("enrichNotFoundError", () => {
63
+ test("merges context when details.code is not_found", () => {
64
+ const err = new CliError("Whatever", 3, { code: CLI_ERR.notFound });
65
+ try {
66
+ enrichNotFoundError(err, { board: "my-board", taskId: 9 });
67
+ expect.unreachable();
68
+ } catch (e) {
69
+ expect(e).toBeInstanceOf(CliError);
70
+ const c = e as CliError;
71
+ expect(c.message).toBe("Whatever");
72
+ expect(c.exitCode).toBe(3);
73
+ expect(c.details).toMatchObject({
74
+ code: CLI_ERR.notFound,
75
+ board: "my-board",
76
+ taskId: 9,
77
+ });
78
+ }
79
+ });
80
+
81
+ test("rethrows unchanged when code is not not_found", () => {
82
+ const err = new CliError("Nope", 4, { code: CLI_ERR.forbidden });
83
+ expect(() => enrichNotFoundError(err, { board: "x" })).toThrow(err);
84
+ });
85
+ });
@@ -3,7 +3,7 @@ import type {
3
3
  ReleaseMutationResult,
4
4
  } from "../../../shared/mutationResults";
5
5
  import type { PaginatedListBody } from "../../../shared/pagination";
6
- import type { ReleaseDefinition } from "../../../shared/models";
6
+ import type { Board, ReleaseDefinition } from "../../../shared/models";
7
7
  import type { CliContext } from "../../types/context";
8
8
  import { CLI_ERR } from "../../types/errors";
9
9
  import { CLI_DEFAULTS } from "../constants";
@@ -19,6 +19,7 @@ import { fetchAllPages } from "../paginatedFetch";
19
19
  import { CliError } from "../output";
20
20
  import { assertMutuallyExclusive } from "../validation";
21
21
  import {
22
+ compactBoardEntity,
22
23
  compactReleaseEntity,
23
24
  writeReleaseDelete,
24
25
  writeSuccess,
@@ -306,3 +307,65 @@ export async function runReleasesDelete(
306
307
  enrichNotFoundError(e, { board: boardId, releaseId: rid });
307
308
  }
308
309
  }
310
+
311
+ /**
312
+ * Set or clear the board default release (PATCH board `defaultReleaseId`).
313
+ * Validates the release exists on the board before PATCH so errors are clearer than a generic 404.
314
+ */
315
+ export async function runReleasesSetDefault(
316
+ ctx: CliContext,
317
+ opts: {
318
+ port?: number;
319
+ board: string | undefined;
320
+ releaseId: string | undefined;
321
+ clear: boolean;
322
+ },
323
+ ): Promise<void> {
324
+ const boardId = opts.board?.trim();
325
+ if (!boardId) {
326
+ throw new CliError("Missing required option: --board", 2, {
327
+ code: CLI_ERR.missingRequired,
328
+ });
329
+ }
330
+ assertMutuallyExclusive([
331
+ ["<release-id>", opts.releaseId, "--clear", opts.clear],
332
+ ]);
333
+ const ridRaw = opts.releaseId?.trim();
334
+ if (!opts.clear && (ridRaw === undefined || ridRaw === "")) {
335
+ throw new CliError("Provide <release-id> or use --clear", 2, {
336
+ code: CLI_ERR.missingRequired,
337
+ });
338
+ }
339
+ if (!opts.clear) {
340
+ const rid = parsePositiveInt("releaseId", ridRaw);
341
+ if (rid === undefined) {
342
+ throw new CliError("Invalid release id", 2, {
343
+ code: CLI_ERR.invalidValue,
344
+ releaseId: ridRaw,
345
+ });
346
+ }
347
+ const rows = await fetchAllBoardReleases(ctx, opts.port, boardId);
348
+ if (!rows.some((r) => r.releaseId === rid)) {
349
+ throw new CliError("Release not found", 3, {
350
+ code: CLI_ERR.notFound,
351
+ board: boardId,
352
+ releaseId: rid,
353
+ });
354
+ }
355
+ }
356
+
357
+ const patch: Record<string, unknown> = {
358
+ defaultReleaseId: opts.clear ? null : Number(ridRaw),
359
+ };
360
+
361
+ try {
362
+ const board = await ctx.fetchApiMutate<Board>(
363
+ `/boards/${encodeURIComponent(boardId)}`,
364
+ { method: "PATCH", body: patch },
365
+ { port: opts.port },
366
+ );
367
+ ctx.printJson(writeSuccess(board, compactBoardEntity(board)));
368
+ } catch (e) {
369
+ enrichNotFoundError(e, { board: boardId });
370
+ }
371
+ }
@@ -29,6 +29,7 @@ describe("compact*Entity", () => {
29
29
  emoji: null,
30
30
  createdAt: "c",
31
31
  updatedAt: "u",
32
+ defaultReleaseId: null,
32
33
  });
33
34
  });
34
35
 
@@ -103,6 +104,7 @@ describe("writeSuccess / trashedEntity / writeTrashMove", () => {
103
104
  emoji: null,
104
105
  createdAt: "c",
105
106
  updatedAt: "u",
107
+ defaultReleaseId: null,
106
108
  },
107
109
  );
108
110
  expect(envelope).toEqual({
@@ -118,6 +120,7 @@ describe("writeSuccess / trashedEntity / writeTrashMove", () => {
118
120
  emoji: null,
119
121
  createdAt: "c",
120
122
  updatedAt: "u",
123
+ defaultReleaseId: null,
121
124
  },
122
125
  });
123
126
  });
@@ -9,6 +9,8 @@ export type WriteBoardEntity = {
9
9
  emoji: string | null;
10
10
  createdAt: string;
11
11
  updatedAt: string;
12
+ /** Board default release for new tasks (when set). */
13
+ defaultReleaseId?: number | null;
12
14
  };
13
15
 
14
16
  export type WriteListEntity = {
@@ -91,6 +93,7 @@ export function compactBoardEntity(board: Board): WriteBoardEntity {
91
93
  emoji: board.emoji ?? null,
92
94
  createdAt: board.createdAt,
93
95
  updatedAt: board.updatedAt,
96
+ defaultReleaseId: board.defaultReleaseId ?? null,
94
97
  };
95
98
  }
96
99
 
@@ -25,6 +25,7 @@ import {
25
25
  runListsUpdate,
26
26
  runReleasesAdd,
27
27
  runReleasesDelete,
28
+ runReleasesSetDefault,
28
29
  runReleasesUpdate,
29
30
  runTasksAdd,
30
31
  runTasksDelete,
@@ -118,6 +119,48 @@ describe("writeCommands breadth — validation", () => {
118
119
  });
119
120
  });
120
121
 
122
+ test("runReleasesSetDefault throws without board", async () => {
123
+ await expect(
124
+ runReleasesSetDefault(ctx, {
125
+ port: 1,
126
+ board: undefined,
127
+ releaseId: "1",
128
+ clear: false,
129
+ }),
130
+ ).rejects.toMatchObject({
131
+ exitCode: 2,
132
+ details: expect.objectContaining({ code: CLI_ERR.missingRequired }),
133
+ });
134
+ });
135
+
136
+ test("runReleasesSetDefault throws without release id and without --clear", async () => {
137
+ await expect(
138
+ runReleasesSetDefault(ctx, {
139
+ port: 1,
140
+ board: "b",
141
+ releaseId: undefined,
142
+ clear: false,
143
+ }),
144
+ ).rejects.toMatchObject({
145
+ exitCode: 2,
146
+ details: expect.objectContaining({ code: CLI_ERR.missingRequired }),
147
+ });
148
+ });
149
+
150
+ test("runReleasesSetDefault throws when release id and --clear together", async () => {
151
+ await expect(
152
+ runReleasesSetDefault(ctx, {
153
+ port: 1,
154
+ board: "b",
155
+ releaseId: "2",
156
+ clear: true,
157
+ }),
158
+ ).rejects.toMatchObject({
159
+ exitCode: 2,
160
+ details: expect.objectContaining({ code: CLI_ERR.mutuallyExclusiveOptions }),
161
+ });
162
+ });
163
+
121
164
  test("runListsMove throws on multiple placement flags", async () => {
122
165
  await expect(
123
166
  runListsMove(ctx, {
@@ -687,6 +730,106 @@ describe("writeCommands breadth — mock fetch happy paths", () => {
687
730
  });
688
731
  });
689
732
 
733
+ test("runReleasesSetDefault GETs releases then PATCHes board defaultReleaseId", async () => {
734
+ setMockFetch(async (input, init) => {
735
+ const u = reqUrl(input);
736
+ if (
737
+ u.includes("/boards/b/releases") &&
738
+ (init?.method === "GET" || init?.method === undefined)
739
+ ) {
740
+ return new Response(
741
+ JSON.stringify({
742
+ items: [
743
+ {
744
+ releaseId: 2,
745
+ name: "v1",
746
+ createdAt: "2026-01-01T00:00:00.000Z",
747
+ },
748
+ ],
749
+ total: 1,
750
+ limit: 500,
751
+ offset: 0,
752
+ }),
753
+ { status: 200, headers: { "content-type": "application/json" } },
754
+ );
755
+ }
756
+ if (u.includes("/api/boards/b") && !u.includes("/releases") && init?.method === "PATCH") {
757
+ expect(JSON.parse(String(init?.body))).toEqual({ defaultReleaseId: 2 });
758
+ return new Response(
759
+ JSON.stringify({
760
+ boardId: 1,
761
+ slug: "b",
762
+ name: "B",
763
+ emoji: null,
764
+ createdAt: "2026-01-01T00:00:00.000Z",
765
+ updatedAt: "2026-01-02T00:00:00.000Z",
766
+ defaultReleaseId: 2,
767
+ releases: [],
768
+ lists: [],
769
+ taskGroups: [],
770
+ taskPriorities: [],
771
+ defaultTaskGroupId: 1,
772
+ autoAssignReleaseOnCreateUi: false,
773
+ autoAssignReleaseOnCreateCli: false,
774
+ } as unknown as Board),
775
+ { status: 200, headers: { "content-type": "application/json" } },
776
+ );
777
+ }
778
+ throw new Error(`unexpected fetch: ${u} ${init?.method}`);
779
+ });
780
+ const out = await captureStdout(() =>
781
+ runReleasesSetDefault(ctx, {
782
+ port: 22025,
783
+ board: "b",
784
+ releaseId: "2",
785
+ clear: false,
786
+ }),
787
+ );
788
+ expect(JSON.parse(out.trim())).toMatchObject({
789
+ ok: true,
790
+ entity: { type: "board", defaultReleaseId: 2 },
791
+ });
792
+ });
793
+
794
+ test("runReleasesSetDefault --clear PATCHes defaultReleaseId null", async () => {
795
+ setMockFetch(async (input, init) => {
796
+ expect(reqUrl(input)).toContain("/api/boards/b");
797
+ expect(init?.method).toBe("PATCH");
798
+ expect(JSON.parse(String(init?.body))).toEqual({ defaultReleaseId: null });
799
+ return new Response(
800
+ JSON.stringify({
801
+ boardId: 1,
802
+ slug: "b",
803
+ name: "B",
804
+ emoji: null,
805
+ createdAt: "2026-01-01T00:00:00.000Z",
806
+ updatedAt: "2026-01-02T00:00:00.000Z",
807
+ defaultReleaseId: null,
808
+ releases: [],
809
+ lists: [],
810
+ taskGroups: [],
811
+ taskPriorities: [],
812
+ defaultTaskGroupId: 1,
813
+ autoAssignReleaseOnCreateUi: false,
814
+ autoAssignReleaseOnCreateCli: false,
815
+ } as unknown as Board),
816
+ { status: 200, headers: { "content-type": "application/json" } },
817
+ );
818
+ });
819
+ const out = await captureStdout(() =>
820
+ runReleasesSetDefault(ctx, {
821
+ port: 22026,
822
+ board: "b",
823
+ releaseId: undefined,
824
+ clear: true,
825
+ }),
826
+ );
827
+ expect(JSON.parse(out.trim())).toMatchObject({
828
+ ok: true,
829
+ entity: { type: "board", defaultReleaseId: null },
830
+ });
831
+ });
832
+
690
833
  test("mutating fetch sends runtime client name header", async () => {
691
834
  setRuntimeCliClientName("Cursor Agent");
692
835
  setMockFetch(async (_input, init) => {
@@ -27,6 +27,7 @@ export {
27
27
  runReleasesAdd,
28
28
  runReleasesDelete,
29
29
  runReleasesList,
30
+ runReleasesSetDefault,
30
31
  runReleasesShow,
31
32
  runReleasesUpdate,
32
33
  } from "./write/releases";