@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,165 @@
1
+ /**
2
+ * Services manager module — runtime management of service modules.
3
+ *
4
+ * This is the service that manages all other services. It's loaded by the
5
+ * same discovery scan as everything else, but uses the enriched ServiceContext
6
+ * to add, update, and remove modules at runtime.
7
+ *
8
+ * GET /services — list loaded modules
9
+ * POST /services/reload — re-scan directory, load new & update changed
10
+ * POST /services/reload/:name — reload a specific module
11
+ * DELETE /services/:name — unload a module
12
+ */
13
+
14
+ import { readdirSync, existsSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { Hono } from "hono";
17
+ import type { ServiceModule, ServiceContext } from "../src/core/types.js";
18
+
19
+ let ctx: ServiceContext;
20
+
21
+ const routes = new Hono();
22
+
23
+ // List all loaded modules
24
+ routes.get("/", (c) => {
25
+ const modules = ctx.getModules().map((m) => ({
26
+ name: m.name,
27
+ description: m.description,
28
+ hasRoutes: !!m.routes,
29
+ hasTools: !!m.registerTools,
30
+ hasBehaviors: !!m.registerBehaviors,
31
+ hasWidget: !!m.widget,
32
+ mountAtRoot: !!m.mountAtRoot,
33
+ dependencies: m.dependencies ?? [],
34
+ }));
35
+ return c.json({ modules, count: modules.length });
36
+ });
37
+
38
+ // Reload all — re-scan directory, add new, update changed, remove deleted
39
+ routes.post("/reload", async (c) => {
40
+ const servicesDir = ctx.servicesDir;
41
+
42
+ if (!existsSync(servicesDir)) {
43
+ return c.json({ error: `Services directory not found: ${servicesDir}` }, 400);
44
+ }
45
+
46
+ const entries = readdirSync(servicesDir, { withFileTypes: true });
47
+ const results: Array<{ name: string; action: string }> = [];
48
+ const errors: Array<{ dir: string; error: string }> = [];
49
+
50
+ for (const entry of entries) {
51
+ if (!entry.isDirectory()) continue;
52
+ if (!existsSync(join(servicesDir, entry.name, "index.ts"))) continue;
53
+
54
+ try {
55
+ const result = await ctx.loadModule(entry.name);
56
+ results.push(result);
57
+ console.log(` [reload] /${result.name} — ${result.action}`);
58
+ } catch (err) {
59
+ const msg = err instanceof Error ? err.message : String(err);
60
+ errors.push({ dir: entry.name, error: msg });
61
+ console.error(` [reload] services/${entry.name}: ${msg}`);
62
+ }
63
+ }
64
+
65
+ // Remove modules whose directories no longer exist
66
+ const currentDirs = new Set(
67
+ entries.filter((e) => e.isDirectory()).map((e) => e.name),
68
+ );
69
+ for (const mod of ctx.getModules()) {
70
+ // Don't remove modules that still have a directory
71
+ if (currentDirs.has(mod.name)) continue;
72
+ // Don't let the services manager remove itself
73
+ if (mod.name === "services") continue;
74
+
75
+ await ctx.unloadModule(mod.name);
76
+ results.push({ name: mod.name, action: "removed" });
77
+ console.log(` [reload] /${mod.name} — removed`);
78
+ }
79
+
80
+ return c.json({ results, errors });
81
+ });
82
+
83
+ // Reload a specific module by name
84
+ routes.post("/reload/:name", async (c) => {
85
+ const name = c.req.param("name");
86
+
87
+ // Check if it exists as a directory
88
+ const dirPath = join(ctx.servicesDir, name);
89
+ if (!existsSync(join(dirPath, "index.ts"))) {
90
+ return c.json({ error: `No service directory "${name}" with index.ts found` }, 404);
91
+ }
92
+
93
+ try {
94
+ const result = await ctx.loadModule(name);
95
+ console.log(` [reload] /${result.name} — ${result.action}`);
96
+ return c.json(result);
97
+ } catch (err) {
98
+ const msg = err instanceof Error ? err.message : String(err);
99
+ return c.json({ error: msg }, 400);
100
+ }
101
+ });
102
+
103
+ // Export a module as a tarball
104
+ routes.get("/export/:name", async (c) => {
105
+ const name = c.req.param("name");
106
+ const mod = ctx.getModule(name);
107
+
108
+ if (!mod) {
109
+ return c.json({ error: `Module "${name}" not found` }, 404);
110
+ }
111
+
112
+ const dirPath = join(ctx.servicesDir, name);
113
+ if (!existsSync(dirPath)) {
114
+ return c.json({ error: `Service directory "${name}" not found on disk` }, 404);
115
+ }
116
+
117
+ try {
118
+ const { execSync } = await import("node:child_process");
119
+ const tarball = execSync(`tar -czf - -C "${ctx.servicesDir}" "${name}"`, {
120
+ maxBuffer: 50 * 1024 * 1024, // 50MB
121
+ });
122
+
123
+ return new Response(tarball, {
124
+ headers: {
125
+ "Content-Type": "application/gzip",
126
+ "Content-Disposition": `attachment; filename="${name}.tar.gz"`,
127
+ "X-Service-Name": mod.name,
128
+ "X-Service-Description": mod.description || "",
129
+ },
130
+ });
131
+ } catch (err) {
132
+ const msg = err instanceof Error ? err.message : String(err);
133
+ return c.json({ error: `Failed to export: ${msg}` }, 500);
134
+ }
135
+ });
136
+
137
+ // Unload a module
138
+ routes.delete("/:name", async (c) => {
139
+ const name = c.req.param("name");
140
+
141
+ if (name === "services") {
142
+ return c.json({ error: "Cannot unload the services manager" }, 400);
143
+ }
144
+
145
+ const mod = ctx.getModule(name);
146
+ if (!mod) {
147
+ return c.json({ error: `Module "${name}" not found` }, 404);
148
+ }
149
+
150
+ await ctx.unloadModule(name);
151
+ console.log(` [unload] /${name} — removed`);
152
+ return c.json({ name, action: "removed" });
153
+ });
154
+
155
+ const services: ServiceModule = {
156
+ name: "services",
157
+ description: "Service module manager",
158
+ routes,
159
+
160
+ init(serviceCtx: ServiceContext) {
161
+ ctx = serviceCtx;
162
+ },
163
+ };
164
+
165
+ export default services;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Magic link auth for the web UI.
3
+ *
4
+ * Flow: agent generates a magic link via POST /auth/magic-link (bearer auth),
5
+ * user opens it in browser, gets a session cookie, UI proxies API calls.
6
+ */
7
+
8
+ import { ulid } from "ulid";
9
+
10
+ interface MagicLink {
11
+ token: string;
12
+ expiresAt: string;
13
+ }
14
+
15
+ interface Session {
16
+ id: string;
17
+ createdAt: string;
18
+ expiresAt: string;
19
+ }
20
+
21
+ const magicLinks = new Map<string, MagicLink>();
22
+ const sessions = new Map<string, Session>();
23
+
24
+ const LINK_TTL_MS = 5 * 60 * 1000; // 5 minutes
25
+ const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
26
+
27
+ export function createMagicLink(): MagicLink {
28
+ const token = ulid();
29
+ const expiresAt = new Date(Date.now() + LINK_TTL_MS).toISOString();
30
+ const link: MagicLink = { token, expiresAt };
31
+ magicLinks.set(token, link);
32
+ return link;
33
+ }
34
+
35
+ export function consumeMagicLink(token: string): boolean {
36
+ const link = magicLinks.get(token);
37
+ if (!link) return false;
38
+ magicLinks.delete(token);
39
+ return new Date(link.expiresAt).getTime() > Date.now();
40
+ }
41
+
42
+ export function createSession(): Session {
43
+ const session: Session = {
44
+ id: ulid(),
45
+ createdAt: new Date().toISOString(),
46
+ expiresAt: new Date(Date.now() + SESSION_TTL_MS).toISOString(),
47
+ };
48
+ sessions.set(session.id, session);
49
+ return session;
50
+ }
51
+
52
+ export function validateSession(sessionId: string | undefined): boolean {
53
+ if (!sessionId) return false;
54
+ const session = sessions.get(sessionId);
55
+ if (!session) return false;
56
+ if (new Date(session.expiresAt).getTime() < Date.now()) {
57
+ sessions.delete(sessionId);
58
+ return false;
59
+ }
60
+ return true;
61
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * UI service module — web dashboard with magic link auth and API proxy.
3
+ */
4
+
5
+ import type { ServiceModule } from "../src/core/types.js";
6
+ import { createRoutes } from "./routes.js";
7
+
8
+ const ui: ServiceModule = {
9
+ name: "ui",
10
+ description: "Web dashboard",
11
+ routes: createRoutes(),
12
+ mountAtRoot: true, // Serves at /ui/*, /auth/* — handles its own session auth
13
+ requiresAuth: false,
14
+ };
15
+
16
+ export default ui;
@@ -0,0 +1,160 @@
1
+ /**
2
+ * UI routes — serves the dashboard, handles magic link auth, proxies API calls.
3
+ */
4
+
5
+ import { Hono } from "hono";
6
+ import { createMagicLink, consumeMagicLink, createSession, validateSession } from "./auth.js";
7
+ import { readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ const AUTH_TOKEN = process.env.VERS_AUTH_TOKEN || "test-token";
11
+
12
+ function getStaticDir(): string {
13
+ return join(import.meta.dir, "static");
14
+ }
15
+
16
+ function getSessionId(c: any): string | undefined {
17
+ const cookie = c.req.header("cookie") || "";
18
+ const match = cookie.match(/(?:^|;\s*)session=([^;]+)/);
19
+ return match?.[1];
20
+ }
21
+
22
+ function hasBearerAuth(c: any): boolean {
23
+ const auth = c.req.header("authorization") || "";
24
+ return auth === `Bearer ${AUTH_TOKEN}`;
25
+ }
26
+
27
+ export function createRoutes(): Hono {
28
+ const routes = new Hono();
29
+
30
+ // --- Auth ---
31
+
32
+ // Generate magic link (requires bearer auth)
33
+ routes.post("/auth/magic-link", (c) => {
34
+ if (!hasBearerAuth(c)) return c.json({ error: "Unauthorized" }, 401);
35
+
36
+ const link = createMagicLink();
37
+ const host = c.req.header("host") || "localhost:3000";
38
+ const proto = c.req.header("x-forwarded-proto") || "https";
39
+ const url = `${proto}://${host}/ui/login?token=${link.token}`;
40
+ return c.json({ url, expiresAt: link.expiresAt });
41
+ });
42
+
43
+ // Login page / magic link consumer
44
+ routes.get("/ui/login", (c) => {
45
+ const token = c.req.query("token");
46
+
47
+ if (token) {
48
+ const valid = consumeMagicLink(token);
49
+ if (valid) {
50
+ const session = createSession();
51
+ return c.html(
52
+ `<html><head><meta http-equiv="refresh" content="0;url=/ui/"></head></html>`,
53
+ 200,
54
+ { "Set-Cookie": `session=${session.id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400` },
55
+ );
56
+ }
57
+ return c.html(`
58
+ <html><body style="background:#0a0a0a;color:#f55;font-family:monospace;padding:2em">
59
+ <h2>Invalid or expired link</h2>
60
+ <p>Request a new magic link from the API.</p>
61
+ </body></html>
62
+ `, 401);
63
+ }
64
+
65
+ return c.html(`
66
+ <html><body style="background:#0a0a0a;color:#888;font-family:monospace;padding:2em">
67
+ <h2>Fleet Services Dashboard</h2>
68
+ <p>Access requires a magic link. Generate one via:</p>
69
+ <pre style="color:#4f9">POST /auth/magic-link</pre>
70
+ </body></html>
71
+ `);
72
+ });
73
+
74
+ // --- Session-protected UI routes ---
75
+
76
+ routes.use("/ui/*", async (c, next) => {
77
+ const path = new URL(c.req.url).pathname;
78
+ if (path === "/ui/login" || path.startsWith("/ui/static/")) return next();
79
+
80
+ // In dev mode (no auth token set), skip session check
81
+ if (!process.env.VERS_AUTH_TOKEN) return next();
82
+
83
+ const sessionId = getSessionId(c);
84
+ if (!validateSession(sessionId)) return c.redirect("/ui/login");
85
+ return next();
86
+ });
87
+
88
+ // Dashboard
89
+ routes.get("/ui/", (c) => {
90
+ try {
91
+ const html = readFileSync(join(getStaticDir(), "index.html"), "utf-8");
92
+ return c.html(html);
93
+ } catch {
94
+ return c.text("Dashboard files not found", 500);
95
+ }
96
+ });
97
+
98
+ // Static files
99
+ routes.get("/ui/static/:file", (c) => {
100
+ const file = c.req.param("file");
101
+ if (file.includes("..") || file.includes("/")) return c.text("Not found", 404);
102
+
103
+ try {
104
+ const content = readFileSync(join(getStaticDir(), file), "utf-8");
105
+ const ext = file.split(".").pop();
106
+ const contentType =
107
+ ext === "css" ? "text/css" :
108
+ ext === "js" ? "application/javascript" :
109
+ "text/plain";
110
+ return c.body(content, 200, { "Content-Type": contentType });
111
+ } catch {
112
+ return c.text("Not found", 404);
113
+ }
114
+ });
115
+
116
+ // --- API proxy (injects bearer token so browser never needs it) ---
117
+
118
+ routes.all("/ui/api/*", async (c) => {
119
+ const url = new URL(c.req.url);
120
+ const apiPath = url.pathname.replace(/^\/ui\/api/, "");
121
+ const queryString = url.search;
122
+
123
+ const port = process.env.PORT || "3000";
124
+ const internalUrl = `http://127.0.0.1:${port}${apiPath}${queryString}`;
125
+
126
+ const headers: Record<string, string> = {
127
+ Authorization: `Bearer ${AUTH_TOKEN}`,
128
+ };
129
+ const contentType = c.req.header("content-type");
130
+ if (contentType) headers["Content-Type"] = contentType;
131
+
132
+ const method = c.req.method;
133
+ const body = method !== "GET" && method !== "HEAD" ? await c.req.text() : undefined;
134
+
135
+ try {
136
+ const resp = await fetch(internalUrl, { method, headers, body });
137
+
138
+ // SSE passthrough
139
+ if (resp.headers.get("content-type")?.includes("text/event-stream")) {
140
+ return new Response(resp.body, {
141
+ status: resp.status,
142
+ headers: {
143
+ "Content-Type": "text/event-stream",
144
+ "Cache-Control": "no-cache",
145
+ Connection: "keep-alive",
146
+ },
147
+ });
148
+ }
149
+
150
+ const text = await resp.text();
151
+ return c.body(text, resp.status as any, {
152
+ "Content-Type": resp.headers.get("content-type") || "application/json",
153
+ });
154
+ } catch (e) {
155
+ return c.json({ error: "Proxy error", details: String(e) }, 502);
156
+ }
157
+ });
158
+
159
+ return routes;
160
+ }