@versdotsh/reef 0.1.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 (83) hide show
  1. package/.github/workflows/test.yml +47 -0
  2. package/README.md +257 -0
  3. package/bun.lock +587 -0
  4. package/examples/services/board/board.test.ts +215 -0
  5. package/examples/services/board/index.ts +155 -0
  6. package/examples/services/board/routes.ts +335 -0
  7. package/examples/services/board/store.ts +329 -0
  8. package/examples/services/board/tools.ts +214 -0
  9. package/examples/services/commits/commits.test.ts +74 -0
  10. package/examples/services/commits/index.ts +14 -0
  11. package/examples/services/commits/routes.ts +43 -0
  12. package/examples/services/commits/store.ts +114 -0
  13. package/examples/services/feed/behaviors.ts +23 -0
  14. package/examples/services/feed/feed.test.ts +101 -0
  15. package/examples/services/feed/index.ts +117 -0
  16. package/examples/services/feed/routes.ts +224 -0
  17. package/examples/services/feed/store.ts +194 -0
  18. package/examples/services/feed/tools.ts +83 -0
  19. package/examples/services/journal/index.ts +15 -0
  20. package/examples/services/journal/journal.test.ts +57 -0
  21. package/examples/services/journal/routes.ts +45 -0
  22. package/examples/services/journal/store.ts +119 -0
  23. package/examples/services/journal/tools.ts +32 -0
  24. package/examples/services/log/index.ts +15 -0
  25. package/examples/services/log/log.test.ts +70 -0
  26. package/examples/services/log/routes.ts +44 -0
  27. package/examples/services/log/store.ts +105 -0
  28. package/examples/services/log/tools.ts +57 -0
  29. package/examples/services/registry/behaviors.ts +128 -0
  30. package/examples/services/registry/index.ts +37 -0
  31. package/examples/services/registry/registry.test.ts +135 -0
  32. package/examples/services/registry/routes.ts +76 -0
  33. package/examples/services/registry/store.ts +224 -0
  34. package/examples/services/registry/tools.ts +116 -0
  35. package/examples/services/reports/index.ts +14 -0
  36. package/examples/services/reports/reports.test.ts +75 -0
  37. package/examples/services/reports/routes.ts +42 -0
  38. package/examples/services/reports/store.ts +110 -0
  39. package/examples/services/ui/auth.ts +61 -0
  40. package/examples/services/ui/index.ts +16 -0
  41. package/examples/services/ui/routes.ts +160 -0
  42. package/examples/services/ui/static/app.js +369 -0
  43. package/examples/services/ui/static/index.html +42 -0
  44. package/examples/services/ui/static/style.css +157 -0
  45. package/examples/services/usage/behaviors.ts +166 -0
  46. package/examples/services/usage/index.ts +19 -0
  47. package/examples/services/usage/routes.ts +53 -0
  48. package/examples/services/usage/store.ts +341 -0
  49. package/examples/services/usage/tools.ts +75 -0
  50. package/examples/services/usage/usage.test.ts +91 -0
  51. package/package.json +29 -0
  52. package/services/agent/index.ts +465 -0
  53. package/services/board/index.ts +155 -0
  54. package/services/board/routes.ts +335 -0
  55. package/services/board/store.ts +329 -0
  56. package/services/board/tools.ts +214 -0
  57. package/services/docs/index.ts +391 -0
  58. package/services/feed/behaviors.ts +23 -0
  59. package/services/feed/index.ts +117 -0
  60. package/services/feed/routes.ts +224 -0
  61. package/services/feed/store.ts +194 -0
  62. package/services/feed/tools.ts +83 -0
  63. package/services/installer/index.ts +574 -0
  64. package/services/services/index.ts +165 -0
  65. package/services/ui/auth.ts +61 -0
  66. package/services/ui/index.ts +16 -0
  67. package/services/ui/routes.ts +160 -0
  68. package/services/ui/static/app.js +369 -0
  69. package/services/ui/static/index.html +42 -0
  70. package/services/ui/static/style.css +157 -0
  71. package/skills/create-service/SKILL.md +698 -0
  72. package/src/core/auth.ts +28 -0
  73. package/src/core/client.ts +99 -0
  74. package/src/core/discover.ts +152 -0
  75. package/src/core/events.ts +44 -0
  76. package/src/core/extension.ts +66 -0
  77. package/src/core/server.ts +262 -0
  78. package/src/core/testing.ts +155 -0
  79. package/src/core/types.ts +194 -0
  80. package/src/extension.ts +16 -0
  81. package/src/main.ts +11 -0
  82. package/tests/server.test.ts +1338 -0
  83. package/tsconfig.json +29 -0
@@ -0,0 +1,135 @@
1
+ import { describe, test, expect, afterAll } from "bun:test";
2
+ import { createTestHarness, type TestHarness } from "../../../src/core/testing.js";
3
+ import registry from "./index.js";
4
+
5
+ let t: TestHarness;
6
+ const setup = (async () => {
7
+ t = await createTestHarness({ services: [registry] });
8
+ })();
9
+ afterAll(() => t?.cleanup());
10
+
11
+ describe("registry", () => {
12
+ test("registers a VM", async () => {
13
+ await setup;
14
+ const { status, data } = await t.json("/registry/vms", {
15
+ method: "POST",
16
+ auth: true,
17
+ body: {
18
+ id: "vm-001",
19
+ name: "worker-1",
20
+ role: "worker",
21
+ address: "vm-001.vm.vers.sh",
22
+ registeredBy: "coordinator",
23
+ },
24
+ });
25
+ expect(status).toBe(201);
26
+ expect(data.id).toBe("vm-001");
27
+ expect(data.name).toBe("worker-1");
28
+ expect(data.status).toBe("running");
29
+ });
30
+
31
+ test("lists VMs", async () => {
32
+ await setup;
33
+ const { status, data } = await t.json<{ vms: any[]; count: number }>("/registry/vms", {
34
+ auth: true,
35
+ });
36
+ expect(status).toBe(200);
37
+ expect(data.vms.length).toBeGreaterThanOrEqual(1);
38
+ expect(data.count).toBe(data.vms.length);
39
+ });
40
+
41
+ test("gets a VM by id", async () => {
42
+ await setup;
43
+ const { status, data } = await t.json("/registry/vms/vm-001", { auth: true });
44
+ expect(status).toBe(200);
45
+ expect(data.name).toBe("worker-1");
46
+ });
47
+
48
+ test("filters by role", async () => {
49
+ await setup;
50
+ await t.json("/registry/vms", {
51
+ method: "POST",
52
+ auth: true,
53
+ body: { id: "vm-lt", name: "lt-1", role: "lieutenant", address: "lt.vm", registeredBy: "test" },
54
+ });
55
+
56
+ const { data } = await t.json<{ vms: any[] }>("/registry/vms?role=lieutenant", { auth: true });
57
+ for (const vm of data.vms) {
58
+ expect(vm.role).toBe("lieutenant");
59
+ }
60
+ });
61
+
62
+ test("filters by status", async () => {
63
+ await setup;
64
+ const { data } = await t.json<{ vms: any[] }>("/registry/vms?status=running", { auth: true });
65
+ for (const vm of data.vms) {
66
+ expect(vm.status).toBe("running");
67
+ }
68
+ });
69
+
70
+ test("updates a VM", async () => {
71
+ await setup;
72
+ const { status, data } = await t.json("/registry/vms/vm-001", {
73
+ method: "PATCH",
74
+ auth: true,
75
+ body: { status: "paused", name: "worker-1-updated" },
76
+ });
77
+ expect(status).toBe(200);
78
+ expect(data.status).toBe("paused");
79
+ expect(data.name).toBe("worker-1-updated");
80
+ });
81
+
82
+ test("heartbeat updates lastSeen", async () => {
83
+ await setup;
84
+ const { status, data } = await t.json("/registry/vms/vm-001/heartbeat", {
85
+ method: "POST",
86
+ auth: true,
87
+ });
88
+ expect(status).toBe(200);
89
+ expect(data.lastSeen).toBeDefined();
90
+ });
91
+
92
+ test("discovers by role", async () => {
93
+ await setup;
94
+ // Reset vm-001 to running for discover
95
+ await t.json("/registry/vms/vm-001", {
96
+ method: "PATCH",
97
+ auth: true,
98
+ body: { status: "running" },
99
+ });
100
+
101
+ const { status, data } = await t.json<{ vms: any[] }>("/registry/discover/worker", { auth: true });
102
+ expect(status).toBe(200);
103
+ expect(data.vms.length).toBeGreaterThanOrEqual(1);
104
+ });
105
+
106
+ test("deletes a VM", async () => {
107
+ await setup;
108
+ await t.json("/registry/vms", {
109
+ method: "POST",
110
+ auth: true,
111
+ body: { id: "vm-delete-me", name: "delete", role: "worker", address: "x", registeredBy: "test" },
112
+ });
113
+
114
+ const { status } = await t.json("/registry/vms/vm-delete-me", {
115
+ method: "DELETE",
116
+ auth: true,
117
+ });
118
+ expect(status).toBe(200);
119
+
120
+ const { status: getStatus } = await t.json("/registry/vms/vm-delete-me", { auth: true });
121
+ expect(getStatus).toBe(404);
122
+ });
123
+
124
+ test("returns 404 for missing VM", async () => {
125
+ await setup;
126
+ const { status } = await t.json("/registry/vms/nonexistent", { auth: true });
127
+ expect(status).toBe(404);
128
+ });
129
+
130
+ test("requires auth", async () => {
131
+ await setup;
132
+ const { status } = await t.json("/registry/vms");
133
+ expect(status).toBe(401);
134
+ });
135
+ });
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Registry HTTP routes — VM registration, discovery, heartbeat.
3
+ */
4
+
5
+ import { Hono } from "hono";
6
+ import type { RegistryStore, VMFilters, VMRole, VMStatus } from "./store.js";
7
+ import { NotFoundError, ValidationError, ConflictError } from "./store.js";
8
+
9
+ export function createRoutes(store: RegistryStore): Hono {
10
+ const routes = new Hono();
11
+
12
+ routes.post("/vms", async (c) => {
13
+ try {
14
+ const body = await c.req.json();
15
+ const vm = store.register(body);
16
+ return c.json(vm, 201);
17
+ } catch (e) {
18
+ if (e instanceof ValidationError) return c.json({ error: e.message }, 400);
19
+ if (e instanceof ConflictError) return c.json({ error: e.message }, 409);
20
+ throw e;
21
+ }
22
+ });
23
+
24
+ routes.get("/vms", (c) => {
25
+ const filters: VMFilters = {};
26
+ const role = c.req.query("role");
27
+ const status = c.req.query("status");
28
+ if (role) filters.role = role as VMRole;
29
+ if (status) filters.status = status as VMStatus;
30
+
31
+ const vms = store.list(filters);
32
+ return c.json({ vms, count: vms.length });
33
+ });
34
+
35
+ routes.get("/vms/:id", (c) => {
36
+ const vm = store.get(c.req.param("id"));
37
+ if (!vm) return c.json({ error: "VM not found" }, 404);
38
+ return c.json(vm);
39
+ });
40
+
41
+ routes.patch("/vms/:id", async (c) => {
42
+ try {
43
+ const body = await c.req.json();
44
+ const vm = store.update(c.req.param("id"), body);
45
+ return c.json(vm);
46
+ } catch (e) {
47
+ if (e instanceof NotFoundError) return c.json({ error: e.message }, 404);
48
+ if (e instanceof ValidationError) return c.json({ error: e.message }, 400);
49
+ throw e;
50
+ }
51
+ });
52
+
53
+ routes.delete("/vms/:id", (c) => {
54
+ const deleted = store.deregister(c.req.param("id"));
55
+ if (!deleted) return c.json({ error: "VM not found" }, 404);
56
+ return c.json({ deleted: true });
57
+ });
58
+
59
+ routes.post("/vms/:id/heartbeat", (c) => {
60
+ try {
61
+ const vm = store.heartbeat(c.req.param("id"));
62
+ return c.json({ id: vm.id, lastSeen: vm.lastSeen });
63
+ } catch (e) {
64
+ if (e instanceof NotFoundError) return c.json({ error: e.message }, 404);
65
+ throw e;
66
+ }
67
+ });
68
+
69
+ routes.get("/discover/:role", (c) => {
70
+ const role = c.req.param("role") as VMRole;
71
+ const vms = store.discover(role);
72
+ return c.json({ vms, count: vms.length });
73
+ });
74
+
75
+ return routes;
76
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Registry store — VM service discovery with heartbeat-based liveness.
3
+ */
4
+
5
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
6
+ import { dirname } from "node:path";
7
+
8
+ // =============================================================================
9
+ // Types
10
+ // =============================================================================
11
+
12
+ export type VMRole = "infra" | "lieutenant" | "worker" | "golden" | "custom";
13
+ export type VMStatus = "running" | "paused" | "stopped";
14
+
15
+ export interface VMService {
16
+ name: string;
17
+ port: number;
18
+ protocol?: string;
19
+ }
20
+
21
+ export interface VM {
22
+ id: string;
23
+ name: string;
24
+ role: VMRole;
25
+ status: VMStatus;
26
+ address: string;
27
+ services: VMService[];
28
+ registeredBy: string;
29
+ registeredAt: string;
30
+ lastSeen: string;
31
+ metadata?: Record<string, unknown>;
32
+ }
33
+
34
+ export interface RegisterInput {
35
+ id: string;
36
+ name: string;
37
+ role: VMRole;
38
+ address: string;
39
+ services?: VMService[];
40
+ registeredBy: string;
41
+ metadata?: Record<string, unknown>;
42
+ }
43
+
44
+ export interface UpdateInput {
45
+ name?: string;
46
+ status?: VMStatus;
47
+ address?: string;
48
+ services?: VMService[];
49
+ metadata?: Record<string, unknown>;
50
+ }
51
+
52
+ export interface VMFilters {
53
+ role?: VMRole;
54
+ status?: VMStatus;
55
+ }
56
+
57
+ // =============================================================================
58
+ // Errors
59
+ // =============================================================================
60
+
61
+ export class NotFoundError extends Error {
62
+ constructor(message: string) { super(message); this.name = "NotFoundError"; }
63
+ }
64
+
65
+ export class ValidationError extends Error {
66
+ constructor(message: string) { super(message); this.name = "ValidationError"; }
67
+ }
68
+
69
+ export class ConflictError extends Error {
70
+ constructor(message: string) { super(message); this.name = "ConflictError"; }
71
+ }
72
+
73
+ // =============================================================================
74
+ // Constants
75
+ // =============================================================================
76
+
77
+ const VALID_ROLES = new Set<string>(["infra", "lieutenant", "worker", "golden", "custom"]);
78
+ const VALID_STATUSES = new Set<string>(["running", "paused", "stopped"]);
79
+ const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
80
+
81
+ // =============================================================================
82
+ // Store
83
+ // =============================================================================
84
+
85
+ export class RegistryStore {
86
+ private vms = new Map<string, VM>();
87
+ private filePath: string;
88
+ private writeTimer: ReturnType<typeof setTimeout> | null = null;
89
+
90
+ constructor(filePath = "data/registry.json") {
91
+ this.filePath = filePath;
92
+ this.load();
93
+ }
94
+
95
+ private load(): void {
96
+ try {
97
+ if (existsSync(this.filePath)) {
98
+ const raw = readFileSync(this.filePath, "utf-8");
99
+ const data = JSON.parse(raw);
100
+ if (Array.isArray(data.vms)) {
101
+ for (const v of data.vms) this.vms.set(v.id, v);
102
+ }
103
+ }
104
+ } catch {
105
+ this.vms = new Map();
106
+ }
107
+ }
108
+
109
+ private scheduleSave(): void {
110
+ if (this.writeTimer) return;
111
+ this.writeTimer = setTimeout(() => {
112
+ this.writeTimer = null;
113
+ this.flush();
114
+ }, 100);
115
+ }
116
+
117
+ flush(): void {
118
+ if (this.writeTimer) {
119
+ clearTimeout(this.writeTimer);
120
+ this.writeTimer = null;
121
+ }
122
+ const dir = dirname(this.filePath);
123
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
124
+ const data = JSON.stringify({ vms: Array.from(this.vms.values()) }, null, 2);
125
+ writeFileSync(this.filePath, data, "utf-8");
126
+ }
127
+
128
+ private isStale(vm: VM): boolean {
129
+ return Date.now() - new Date(vm.lastSeen).getTime() > STALE_THRESHOLD_MS;
130
+ }
131
+
132
+ register(input: RegisterInput): VM {
133
+ if (!input.id?.trim()) throw new ValidationError("id is required");
134
+ if (!input.name?.trim()) throw new ValidationError("name is required");
135
+ if (!input.role || !VALID_ROLES.has(input.role)) throw new ValidationError(`invalid role: ${input.role}`);
136
+ if (!input.address?.trim()) throw new ValidationError("address is required");
137
+ if (!input.registeredBy?.trim()) throw new ValidationError("registeredBy is required");
138
+
139
+ // Allow re-registration (upsert)
140
+ const now = new Date().toISOString();
141
+ const existing = this.vms.get(input.id);
142
+
143
+ const vm: VM = {
144
+ id: input.id.trim(),
145
+ name: input.name.trim(),
146
+ role: input.role,
147
+ status: "running",
148
+ address: input.address.trim(),
149
+ services: input.services || existing?.services || [],
150
+ registeredBy: input.registeredBy.trim(),
151
+ registeredAt: existing?.registeredAt || now,
152
+ lastSeen: now,
153
+ metadata: input.metadata || existing?.metadata,
154
+ };
155
+
156
+ this.vms.set(vm.id, vm);
157
+ this.scheduleSave();
158
+ return vm;
159
+ }
160
+
161
+ get(id: string): VM | undefined {
162
+ return this.vms.get(id);
163
+ }
164
+
165
+ list(filters?: VMFilters): VM[] {
166
+ let results = Array.from(this.vms.values());
167
+
168
+ if (filters?.role) results = results.filter((v) => v.role === filters.role);
169
+ if (filters?.status) {
170
+ if (filters.status === "running") {
171
+ // Exclude stale VMs from "running" filter
172
+ results = results.filter((v) => v.status === "running" && !this.isStale(v));
173
+ } else {
174
+ results = results.filter((v) => v.status === filters.status);
175
+ }
176
+ }
177
+
178
+ results.sort((a, b) => b.lastSeen.localeCompare(a.lastSeen));
179
+ return results;
180
+ }
181
+
182
+ update(id: string, input: UpdateInput): VM {
183
+ const vm = this.vms.get(id);
184
+ if (!vm) throw new NotFoundError("VM not found");
185
+
186
+ if (input.status !== undefined && !VALID_STATUSES.has(input.status)) {
187
+ throw new ValidationError(`invalid status: ${input.status}`);
188
+ }
189
+
190
+ if (input.name !== undefined) vm.name = input.name.trim();
191
+ if (input.status !== undefined) vm.status = input.status;
192
+ if (input.address !== undefined) vm.address = input.address.trim();
193
+ if (input.services !== undefined) vm.services = input.services;
194
+ if (input.metadata !== undefined) vm.metadata = input.metadata;
195
+
196
+ vm.lastSeen = new Date().toISOString();
197
+ this.vms.set(id, vm);
198
+ this.scheduleSave();
199
+ return vm;
200
+ }
201
+
202
+ deregister(id: string): boolean {
203
+ const existed = this.vms.delete(id);
204
+ if (existed) this.scheduleSave();
205
+ return existed;
206
+ }
207
+
208
+ heartbeat(id: string): VM {
209
+ const vm = this.vms.get(id);
210
+ if (!vm) throw new NotFoundError("VM not found");
211
+
212
+ vm.lastSeen = new Date().toISOString();
213
+ vm.status = "running";
214
+ this.vms.set(id, vm);
215
+ this.scheduleSave();
216
+ return vm;
217
+ }
218
+
219
+ discover(role: VMRole): VM[] {
220
+ return Array.from(this.vms.values()).filter(
221
+ (v) => v.role === role && v.status === "running" && !this.isStale(v),
222
+ );
223
+ }
224
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Registry tools — VM registration, discovery, heartbeat.
3
+ */
4
+
5
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
+ import type { FleetClient } from "../src/core/types.js";
7
+ import { Type } from "@sinclair/typebox";
8
+ import { StringEnum } from "@mariozechner/pi-ai";
9
+
10
+ const ROLE_ENUM = StringEnum(
11
+ ["infra", "lieutenant", "worker", "golden", "custom"] as const,
12
+ { description: "VM role in the swarm" },
13
+ );
14
+
15
+ export function registerTools(pi: ExtensionAPI, client: FleetClient) {
16
+ pi.registerTool({
17
+ name: "registry_list",
18
+ label: "Registry: List VMs",
19
+ description: "List VMs in the coordination registry. Optionally filter by role or status.",
20
+ parameters: Type.Object({
21
+ role: Type.Optional(ROLE_ENUM),
22
+ status: Type.Optional(
23
+ StringEnum(["running", "paused", "stopped"] as const, { description: "Filter by status" }),
24
+ ),
25
+ }),
26
+ async execute(_id, params) {
27
+ if (!client.getBaseUrl()) return client.noUrl();
28
+ try {
29
+ const qs = new URLSearchParams();
30
+ if (params.role) qs.set("role", params.role);
31
+ if (params.status) qs.set("status", params.status);
32
+ const query = qs.toString();
33
+ const result = await client.api("GET", `/registry/vms${query ? `?${query}` : ""}`);
34
+ return client.ok(JSON.stringify(result, null, 2), { result });
35
+ } catch (e: any) {
36
+ return client.err(e.message);
37
+ }
38
+ },
39
+ });
40
+
41
+ pi.registerTool({
42
+ name: "registry_register",
43
+ label: "Registry: Register VM",
44
+ description: "Register a VM so other agents can discover it.",
45
+ parameters: Type.Object({
46
+ id: Type.String({ description: "VM ID (from Vers)" }),
47
+ name: Type.String({ description: "Human-readable name" }),
48
+ role: ROLE_ENUM,
49
+ address: Type.String({ description: "Network address or endpoint" }),
50
+ services: Type.Optional(
51
+ Type.Array(
52
+ Type.Object({
53
+ name: Type.String(),
54
+ port: Type.Number(),
55
+ protocol: Type.Optional(Type.String()),
56
+ }),
57
+ { description: "Services exposed by this VM" },
58
+ ),
59
+ ),
60
+ }),
61
+ async execute(_id, params) {
62
+ if (!client.getBaseUrl()) return client.noUrl();
63
+ try {
64
+ const vm = await client.api("POST", "/registry/vms", {
65
+ ...params,
66
+ registeredBy: client.agentName,
67
+ });
68
+ return client.ok(JSON.stringify(vm, null, 2), { vm });
69
+ } catch (e: any) {
70
+ return client.err(e.message);
71
+ }
72
+ },
73
+ });
74
+
75
+ pi.registerTool({
76
+ name: "registry_discover",
77
+ label: "Registry: Discover VMs",
78
+ description: "Discover VMs by role — find workers, lieutenants, or other agents.",
79
+ parameters: Type.Object({
80
+ role: ROLE_ENUM,
81
+ }),
82
+ async execute(_id, params) {
83
+ if (!client.getBaseUrl()) return client.noUrl();
84
+ try {
85
+ const result = await client.api(
86
+ "GET",
87
+ `/registry/discover/${encodeURIComponent(params.role)}`,
88
+ );
89
+ return client.ok(JSON.stringify(result, null, 2), { result });
90
+ } catch (e: any) {
91
+ return client.err(e.message);
92
+ }
93
+ },
94
+ });
95
+
96
+ pi.registerTool({
97
+ name: "registry_heartbeat",
98
+ label: "Registry: Heartbeat",
99
+ description: "Send a heartbeat to keep a VM's registration active.",
100
+ parameters: Type.Object({
101
+ id: Type.String({ description: "VM ID to heartbeat" }),
102
+ }),
103
+ async execute(_id, params) {
104
+ if (!client.getBaseUrl()) return client.noUrl();
105
+ try {
106
+ const result = await client.api(
107
+ "POST",
108
+ `/registry/vms/${encodeURIComponent(params.id)}/heartbeat`,
109
+ );
110
+ return client.ok(JSON.stringify(result, null, 2), { result });
111
+ } catch (e: any) {
112
+ return client.err(e.message);
113
+ }
114
+ },
115
+ });
116
+ }
@@ -0,0 +1,14 @@
1
+ import type { ServiceModule } from "../src/core/types.js";
2
+ import { ReportsStore } from "./store.js";
3
+ import { createRoutes } from "./routes.js";
4
+
5
+ const store = new ReportsStore();
6
+
7
+ const reports: ServiceModule = {
8
+ name: "reports",
9
+ description: "Markdown reports",
10
+ routes: createRoutes(store),
11
+ store,
12
+ };
13
+
14
+ export default reports;
@@ -0,0 +1,75 @@
1
+ import { describe, test, expect, afterAll } from "bun:test";
2
+ import { createTestHarness, type TestHarness } from "../../../src/core/testing.js";
3
+ import reports from "./index.js";
4
+
5
+ let t: TestHarness;
6
+ const setup = (async () => {
7
+ t = await createTestHarness({ services: [reports] });
8
+ })();
9
+ afterAll(() => t?.cleanup());
10
+
11
+ describe("reports", () => {
12
+ test("creates a report", async () => {
13
+ await setup;
14
+ const { status, data } = await t.json("/reports", {
15
+ method: "POST",
16
+ auth: true,
17
+ body: {
18
+ title: "Sprint 1 Summary",
19
+ content: "# Sprint 1\n\nDone a lot of work.",
20
+ author: "coordinator",
21
+ tags: ["sprint", "summary"],
22
+ },
23
+ });
24
+ expect(status).toBe(201);
25
+ expect(data.title).toBe("Sprint 1 Summary");
26
+ expect(data.content).toContain("Sprint 1");
27
+ expect(data.id).toBeDefined();
28
+ });
29
+
30
+ test("lists reports", async () => {
31
+ await setup;
32
+ const { status, data } = await t.json<{ reports: any[]; count: number }>("/reports", {
33
+ auth: true,
34
+ });
35
+ expect(status).toBe(200);
36
+ expect(data.reports.length).toBeGreaterThanOrEqual(1);
37
+ });
38
+
39
+ test("gets a report by id", async () => {
40
+ await setup;
41
+ const { data: created } = await t.json<any>("/reports", {
42
+ method: "POST",
43
+ auth: true,
44
+ body: { title: "Get by ID", content: "test content", author: "test" },
45
+ });
46
+
47
+ const { status, data } = await t.json(`/reports/${created.id}`, { auth: true });
48
+ expect(status).toBe(200);
49
+ expect(data.title).toBe("Get by ID");
50
+ });
51
+
52
+ test("deletes a report", async () => {
53
+ await setup;
54
+ const { data: created } = await t.json<any>("/reports", {
55
+ method: "POST",
56
+ auth: true,
57
+ body: { title: "Delete me", content: "bye", author: "test" },
58
+ });
59
+
60
+ const { status } = await t.json(`/reports/${created.id}`, {
61
+ method: "DELETE",
62
+ auth: true,
63
+ });
64
+ expect(status).toBe(200);
65
+
66
+ const { status: getStatus } = await t.json(`/reports/${created.id}`, { auth: true });
67
+ expect(getStatus).toBe(404);
68
+ });
69
+
70
+ test("requires auth", async () => {
71
+ await setup;
72
+ const { status } = await t.json("/reports");
73
+ expect(status).toBe(401);
74
+ });
75
+ });
@@ -0,0 +1,42 @@
1
+ import { Hono } from "hono";
2
+ import type { ReportsStore } from "./store.js";
3
+ import { ValidationError } from "./store.js";
4
+
5
+ export function createRoutes(store: ReportsStore): Hono {
6
+ const routes = new Hono();
7
+
8
+ routes.post("/", async (c) => {
9
+ try {
10
+ const body = await c.req.json();
11
+ const report = store.create(body);
12
+ return c.json(report, 201);
13
+ } catch (e) {
14
+ if (e instanceof ValidationError) return c.json({ error: e.message }, 400);
15
+ throw e;
16
+ }
17
+ });
18
+
19
+ routes.get("/", (c) => {
20
+ const reports = store.list({
21
+ author: c.req.query("author") || undefined,
22
+ tag: c.req.query("tag") || undefined,
23
+ });
24
+ // Listing: omit content for lighter payload
25
+ const summaries = reports.map(({ content, ...rest }) => rest);
26
+ return c.json({ reports: summaries, count: summaries.length });
27
+ });
28
+
29
+ routes.get("/:id", (c) => {
30
+ const report = store.get(c.req.param("id"));
31
+ if (!report) return c.json({ error: "report not found" }, 404);
32
+ return c.json(report);
33
+ });
34
+
35
+ routes.delete("/:id", (c) => {
36
+ const deleted = store.delete(c.req.param("id"));
37
+ if (!deleted) return c.json({ error: "report not found" }, 404);
38
+ return c.json({ deleted: true });
39
+ });
40
+
41
+ return routes;
42
+ }