@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.
- package/.github/workflows/test.yml +47 -0
- package/README.md +257 -0
- package/bun.lock +587 -0
- package/examples/services/board/board.test.ts +215 -0
- package/examples/services/board/index.ts +155 -0
- package/examples/services/board/routes.ts +335 -0
- package/examples/services/board/store.ts +329 -0
- package/examples/services/board/tools.ts +214 -0
- package/examples/services/commits/commits.test.ts +74 -0
- package/examples/services/commits/index.ts +14 -0
- package/examples/services/commits/routes.ts +43 -0
- package/examples/services/commits/store.ts +114 -0
- package/examples/services/feed/behaviors.ts +23 -0
- package/examples/services/feed/feed.test.ts +101 -0
- package/examples/services/feed/index.ts +117 -0
- package/examples/services/feed/routes.ts +224 -0
- package/examples/services/feed/store.ts +194 -0
- package/examples/services/feed/tools.ts +83 -0
- package/examples/services/journal/index.ts +15 -0
- package/examples/services/journal/journal.test.ts +57 -0
- package/examples/services/journal/routes.ts +45 -0
- package/examples/services/journal/store.ts +119 -0
- package/examples/services/journal/tools.ts +32 -0
- package/examples/services/log/index.ts +15 -0
- package/examples/services/log/log.test.ts +70 -0
- package/examples/services/log/routes.ts +44 -0
- package/examples/services/log/store.ts +105 -0
- package/examples/services/log/tools.ts +57 -0
- package/examples/services/registry/behaviors.ts +128 -0
- package/examples/services/registry/index.ts +37 -0
- package/examples/services/registry/registry.test.ts +135 -0
- package/examples/services/registry/routes.ts +76 -0
- package/examples/services/registry/store.ts +224 -0
- package/examples/services/registry/tools.ts +116 -0
- package/examples/services/reports/index.ts +14 -0
- package/examples/services/reports/reports.test.ts +75 -0
- package/examples/services/reports/routes.ts +42 -0
- package/examples/services/reports/store.ts +110 -0
- package/examples/services/ui/auth.ts +61 -0
- package/examples/services/ui/index.ts +16 -0
- package/examples/services/ui/routes.ts +160 -0
- package/examples/services/ui/static/app.js +369 -0
- package/examples/services/ui/static/index.html +42 -0
- package/examples/services/ui/static/style.css +157 -0
- package/examples/services/usage/behaviors.ts +166 -0
- package/examples/services/usage/index.ts +19 -0
- package/examples/services/usage/routes.ts +53 -0
- package/examples/services/usage/store.ts +341 -0
- package/examples/services/usage/tools.ts +75 -0
- package/examples/services/usage/usage.test.ts +91 -0
- package/package.json +29 -0
- package/services/agent/index.ts +465 -0
- package/services/board/index.ts +155 -0
- package/services/board/routes.ts +335 -0
- package/services/board/store.ts +329 -0
- package/services/board/tools.ts +214 -0
- package/services/docs/index.ts +391 -0
- package/services/feed/behaviors.ts +23 -0
- package/services/feed/index.ts +117 -0
- package/services/feed/routes.ts +224 -0
- package/services/feed/store.ts +194 -0
- package/services/feed/tools.ts +83 -0
- package/services/installer/index.ts +574 -0
- package/services/services/index.ts +165 -0
- package/services/ui/auth.ts +61 -0
- package/services/ui/index.ts +16 -0
- package/services/ui/routes.ts +160 -0
- package/services/ui/static/app.js +369 -0
- package/services/ui/static/index.html +42 -0
- package/services/ui/static/style.css +157 -0
- package/skills/create-service/SKILL.md +698 -0
- package/src/core/auth.ts +28 -0
- package/src/core/client.ts +99 -0
- package/src/core/discover.ts +152 -0
- package/src/core/events.ts +44 -0
- package/src/core/extension.ts +66 -0
- package/src/core/server.ts +262 -0
- package/src/core/testing.ts +155 -0
- package/src/core/types.ts +194 -0
- package/src/extension.ts +16 -0
- package/src/main.ts +11 -0
- package/tests/server.test.ts +1338 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installer service module — install, update, and remove service modules
|
|
3
|
+
* from git repos, local paths, or other reef instances.
|
|
4
|
+
*
|
|
5
|
+
* POST /installer/install — install a service from a source
|
|
6
|
+
* POST /installer/update — pull latest and reload
|
|
7
|
+
* POST /installer/remove — unload and delete
|
|
8
|
+
* GET /installer/installed — list installed packages with source info
|
|
9
|
+
*
|
|
10
|
+
* Sources:
|
|
11
|
+
* { source: "https://github.com/user/repo" } — clone via git
|
|
12
|
+
* { source: "git@github.com:user/repo" } — clone via SSH
|
|
13
|
+
* { source: "/absolute/path/to/service" } — symlink a local directory
|
|
14
|
+
* { from: "http://host:3000", name: "feed" } — pull from another instance
|
|
15
|
+
*
|
|
16
|
+
* Installed services are tracked in .installer.json so we know
|
|
17
|
+
* which services came from external sources vs. built-in.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { Hono } from "hono";
|
|
21
|
+
import {
|
|
22
|
+
existsSync,
|
|
23
|
+
mkdirSync,
|
|
24
|
+
readFileSync,
|
|
25
|
+
writeFileSync,
|
|
26
|
+
rmSync,
|
|
27
|
+
symlinkSync,
|
|
28
|
+
lstatSync,
|
|
29
|
+
} from "node:fs";
|
|
30
|
+
import { join, basename, dirname, resolve } from "node:path";
|
|
31
|
+
import { execSync } from "node:child_process";
|
|
32
|
+
import type { ServiceModule, ServiceContext } from "../src/core/types.js";
|
|
33
|
+
|
|
34
|
+
let ctx: ServiceContext;
|
|
35
|
+
|
|
36
|
+
// =============================================================================
|
|
37
|
+
// Registry — tracks what was installed and how
|
|
38
|
+
// =============================================================================
|
|
39
|
+
|
|
40
|
+
interface InstalledEntry {
|
|
41
|
+
/** Directory name under services/ */
|
|
42
|
+
dirName: string;
|
|
43
|
+
/** Original source (git URL, local path, or fleet base URL) */
|
|
44
|
+
source: string;
|
|
45
|
+
/** How it was installed */
|
|
46
|
+
type: "git" | "local" | "fleet";
|
|
47
|
+
/** When it was installed */
|
|
48
|
+
installedAt: string;
|
|
49
|
+
/** Git ref if pinned */
|
|
50
|
+
ref?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function registryPath(): string {
|
|
54
|
+
return join(ctx.servicesDir, ".installer.json");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function loadRegistry(): InstalledEntry[] {
|
|
58
|
+
try {
|
|
59
|
+
const p = registryPath();
|
|
60
|
+
if (existsSync(p)) {
|
|
61
|
+
return JSON.parse(readFileSync(p, "utf-8")).installed ?? [];
|
|
62
|
+
}
|
|
63
|
+
} catch {}
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function saveRegistry(entries: InstalledEntry[]): void {
|
|
68
|
+
writeFileSync(registryPath(), JSON.stringify({ installed: entries }, null, 2));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function findEntry(
|
|
72
|
+
entries: InstalledEntry[],
|
|
73
|
+
nameOrSource: string,
|
|
74
|
+
): InstalledEntry | undefined {
|
|
75
|
+
return entries.find(
|
|
76
|
+
(e) => e.dirName === nameOrSource || e.source === nameOrSource,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// =============================================================================
|
|
81
|
+
// Source parsing
|
|
82
|
+
// =============================================================================
|
|
83
|
+
|
|
84
|
+
interface ParsedSource {
|
|
85
|
+
type: "git" | "local";
|
|
86
|
+
url: string;
|
|
87
|
+
ref?: string;
|
|
88
|
+
dirName: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Parse a source string into a structured object.
|
|
93
|
+
*
|
|
94
|
+
* Supported formats:
|
|
95
|
+
* /absolute/path → local
|
|
96
|
+
* ./relative/path → local
|
|
97
|
+
* ../relative/path → local
|
|
98
|
+
* user/repo → git (GitHub shorthand)
|
|
99
|
+
* user/repo@v1.0 → git (GitHub shorthand + ref)
|
|
100
|
+
* github.com/user/repo → git (HTTPS)
|
|
101
|
+
* https://github.com/user/repo → git (HTTPS)
|
|
102
|
+
* git@github.com:user/repo → git (SSH)
|
|
103
|
+
* https://example.com/repo.git@main → git (HTTPS + ref)
|
|
104
|
+
*/
|
|
105
|
+
export function parseSource(source: string): ParsedSource {
|
|
106
|
+
// Local path — starts with / or ./ or ../
|
|
107
|
+
if (source.startsWith("/") || source.startsWith("./") || source.startsWith("../")) {
|
|
108
|
+
const resolved = resolve(source);
|
|
109
|
+
if (!existsSync(resolved)) {
|
|
110
|
+
throw new Error(`Local path not found: ${resolved}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// If it's a bare git repo or a .git path, treat as git clone source
|
|
114
|
+
const isBareGitRepo = existsSync(join(resolved, "HEAD")) && existsSync(join(resolved, "objects"));
|
|
115
|
+
if (isBareGitRepo || resolved.endsWith(".git")) {
|
|
116
|
+
const repoName = basename(resolved).replace(/\.git$/, "");
|
|
117
|
+
return { type: "git", url: resolved, dirName: repoName };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
type: "local",
|
|
122
|
+
url: resolved,
|
|
123
|
+
dirName: basename(resolved),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Everything else is git. Extract optional @ref suffix first.
|
|
128
|
+
let raw = source;
|
|
129
|
+
let ref: string | undefined;
|
|
130
|
+
|
|
131
|
+
// Match @ref at the end, but not the @ in git@github.com:...
|
|
132
|
+
// Strategy: find the last @ that comes after a repo-name-like segment
|
|
133
|
+
// SSH pattern: git@host:user/repo[@ref]
|
|
134
|
+
// HTTPS pattern: https://host/user/repo[@ref]
|
|
135
|
+
// Shorthand: user/repo[@ref]
|
|
136
|
+
|
|
137
|
+
const sshMatch = raw.match(/^(git@[^:]+:.+?)(?:@([^@/]+))?$/);
|
|
138
|
+
const httpsMatch = raw.match(/^(https?:\/\/.+?)(?:@([^@/]+))?$/);
|
|
139
|
+
const bareHostMatch = raw.match(/^([a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\/.+?)(?:@([^@/]+))?$/);
|
|
140
|
+
const shorthandMatch = raw.match(/^([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)(?:@([^@/]+))?$/);
|
|
141
|
+
|
|
142
|
+
let url: string;
|
|
143
|
+
|
|
144
|
+
if (sshMatch) {
|
|
145
|
+
// git@github.com:user/repo or git@github.com:user/repo@v1.0
|
|
146
|
+
url = sshMatch[1];
|
|
147
|
+
ref = sshMatch[2];
|
|
148
|
+
} else if (httpsMatch) {
|
|
149
|
+
// https://github.com/user/repo or https://github.com/user/repo@main
|
|
150
|
+
url = httpsMatch[1];
|
|
151
|
+
ref = httpsMatch[2];
|
|
152
|
+
} else if (bareHostMatch) {
|
|
153
|
+
// github.com/user/repo → https://github.com/user/repo
|
|
154
|
+
url = `https://${bareHostMatch[1]}`;
|
|
155
|
+
ref = bareHostMatch[2];
|
|
156
|
+
} else if (shorthandMatch) {
|
|
157
|
+
// user/repo → https://github.com/user/repo
|
|
158
|
+
url = `https://github.com/${shorthandMatch[1]}`;
|
|
159
|
+
ref = shorthandMatch[2];
|
|
160
|
+
} else {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`Cannot parse source: "${source}". Expected a local path, git URL, or user/repo shorthand.`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Extract repo name: last path segment, strip .git
|
|
167
|
+
const repoName = basename(url.replace(/\.git$/, "").replace(/:([^/])/, "/$1"));
|
|
168
|
+
|
|
169
|
+
return { type: "git", url, ref, dirName: repoName };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// =============================================================================
|
|
173
|
+
// Operations
|
|
174
|
+
// =============================================================================
|
|
175
|
+
|
|
176
|
+
function exec(cmd: string, cwd?: string): string {
|
|
177
|
+
return execSync(cmd, {
|
|
178
|
+
cwd,
|
|
179
|
+
encoding: "utf-8",
|
|
180
|
+
timeout: 120_000,
|
|
181
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
182
|
+
}).trim();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function installFromGit(
|
|
186
|
+
parsed: ParsedSource,
|
|
187
|
+
servicesDir: string,
|
|
188
|
+
): Promise<string> {
|
|
189
|
+
const targetDir = join(servicesDir, parsed.dirName);
|
|
190
|
+
|
|
191
|
+
if (existsSync(targetDir)) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`Directory "${parsed.dirName}" already exists. Use update to pull latest, or remove first.`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Clone
|
|
198
|
+
const refArg = parsed.ref ? `--branch ${parsed.ref} --single-branch` : "";
|
|
199
|
+
exec(`git clone ${refArg} ${parsed.url} ${targetDir}`);
|
|
200
|
+
|
|
201
|
+
// Install dependencies if package.json exists
|
|
202
|
+
if (existsSync(join(targetDir, "package.json"))) {
|
|
203
|
+
const hasBun = (() => {
|
|
204
|
+
try { exec("bun --version"); return true; } catch { return false; }
|
|
205
|
+
})();
|
|
206
|
+
exec(hasBun ? "bun install" : "npm install", targetDir);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return targetDir;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function installFromLocal(
|
|
213
|
+
parsed: ParsedSource,
|
|
214
|
+
servicesDir: string,
|
|
215
|
+
): string {
|
|
216
|
+
const targetDir = join(servicesDir, parsed.dirName);
|
|
217
|
+
|
|
218
|
+
if (existsSync(targetDir)) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`Directory "${parsed.dirName}" already exists. Remove first.`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Symlink so changes to the source are reflected immediately
|
|
225
|
+
symlinkSync(parsed.url, targetDir);
|
|
226
|
+
return targetDir;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function installFromFleet(
|
|
230
|
+
baseUrl: string,
|
|
231
|
+
serviceName: string,
|
|
232
|
+
servicesDir: string,
|
|
233
|
+
authToken?: string,
|
|
234
|
+
): Promise<string> {
|
|
235
|
+
const targetDir = join(servicesDir, serviceName);
|
|
236
|
+
|
|
237
|
+
if (existsSync(targetDir)) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
`Directory "${serviceName}" already exists. Remove first.`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Fetch the tarball from the remote instance's export endpoint
|
|
244
|
+
const exportUrl = `${baseUrl.replace(/\/$/, "")}/services/export/${serviceName}`;
|
|
245
|
+
const headers: Record<string, string> = {};
|
|
246
|
+
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
|
|
247
|
+
|
|
248
|
+
const response = await fetch(exportUrl, { headers });
|
|
249
|
+
|
|
250
|
+
if (!response.ok) {
|
|
251
|
+
const body = await response.text().catch(() => "");
|
|
252
|
+
throw new Error(
|
|
253
|
+
`Failed to fetch "${serviceName}" from ${baseUrl}: ${response.status} ${body}`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Write tarball to a temp file and extract
|
|
258
|
+
const tarball = await response.arrayBuffer();
|
|
259
|
+
const tmpTar = join(servicesDir, `.${serviceName}.tar.gz`);
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
writeFileSync(tmpTar, Buffer.from(tarball));
|
|
263
|
+
mkdirSync(targetDir, { recursive: true });
|
|
264
|
+
exec(`tar -xzf "${tmpTar}" -C "${servicesDir}"`);
|
|
265
|
+
} finally {
|
|
266
|
+
if (existsSync(tmpTar)) rmSync(tmpTar);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Verify extraction worked
|
|
270
|
+
if (!existsSync(join(targetDir, "index.ts"))) {
|
|
271
|
+
rmSync(targetDir, { recursive: true, force: true });
|
|
272
|
+
throw new Error(
|
|
273
|
+
`Extracted tarball for "${serviceName}" has no index.ts`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Install dependencies if needed
|
|
278
|
+
if (existsSync(join(targetDir, "package.json"))) {
|
|
279
|
+
const hasBun = (() => {
|
|
280
|
+
try { exec("bun --version"); return true; } catch { return false; }
|
|
281
|
+
})();
|
|
282
|
+
exec(hasBun ? "bun install" : "npm install", targetDir);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return targetDir;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function updateGit(entry: InstalledEntry, servicesDir: string): Promise<void> {
|
|
289
|
+
const targetDir = join(servicesDir, entry.dirName);
|
|
290
|
+
|
|
291
|
+
if (!existsSync(targetDir)) {
|
|
292
|
+
throw new Error(`Directory "${entry.dirName}" not found`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Check if it's a symlink (local install) — can't update those
|
|
296
|
+
if (lstatSync(targetDir).isSymbolicLink()) {
|
|
297
|
+
throw new Error(`"${entry.dirName}" is a local symlink, not a git clone. Nothing to update.`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
exec("git pull", targetDir);
|
|
301
|
+
|
|
302
|
+
// Reinstall dependencies
|
|
303
|
+
if (existsSync(join(targetDir, "package.json"))) {
|
|
304
|
+
const hasBun = (() => {
|
|
305
|
+
try { exec("bun --version"); return true; } catch { return false; }
|
|
306
|
+
})();
|
|
307
|
+
exec(hasBun ? "bun install" : "npm install", targetDir);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// =============================================================================
|
|
312
|
+
// Routes
|
|
313
|
+
// =============================================================================
|
|
314
|
+
|
|
315
|
+
const routes = new Hono();
|
|
316
|
+
|
|
317
|
+
routes.post("/install", async (c) => {
|
|
318
|
+
const body = await c.req.json().catch(() => ({}));
|
|
319
|
+
const source = (body.source as string)?.trim();
|
|
320
|
+
const from = (body.from as string)?.trim();
|
|
321
|
+
const name = (body.name as string)?.trim();
|
|
322
|
+
const token = (body.token as string)?.trim();
|
|
323
|
+
|
|
324
|
+
// Fleet install: { from: "http://host:3000", name: "feed" }
|
|
325
|
+
if (from) {
|
|
326
|
+
if (!name) {
|
|
327
|
+
return c.json({ error: '"name" is required when using "from"' }, 400);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
const registry = loadRegistry();
|
|
332
|
+
|
|
333
|
+
if (findEntry(registry, name)) {
|
|
334
|
+
return c.json(
|
|
335
|
+
{ error: `"${name}" is already installed. Use update or remove first.` },
|
|
336
|
+
409,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
await installFromFleet(from, name, ctx.servicesDir, token);
|
|
341
|
+
console.log(` [install] Pulled ${name} from ${from} → services/${name}`);
|
|
342
|
+
|
|
343
|
+
registry.push({
|
|
344
|
+
dirName: name,
|
|
345
|
+
source: `${from}#${name}`,
|
|
346
|
+
type: "fleet",
|
|
347
|
+
installedAt: new Date().toISOString(),
|
|
348
|
+
});
|
|
349
|
+
saveRegistry(registry);
|
|
350
|
+
|
|
351
|
+
const result = await ctx.loadModule(name);
|
|
352
|
+
console.log(` [install] /${result.name} — loaded`);
|
|
353
|
+
|
|
354
|
+
return c.json({
|
|
355
|
+
name: result.name,
|
|
356
|
+
dirName: name,
|
|
357
|
+
from,
|
|
358
|
+
type: "fleet",
|
|
359
|
+
action: "installed",
|
|
360
|
+
}, 201);
|
|
361
|
+
} catch (err) {
|
|
362
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
363
|
+
return c.json({ error: msg }, 400);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Git / local install: { source: "..." }
|
|
368
|
+
if (!source) {
|
|
369
|
+
return c.json({ error: '"source" or "from"+"name" is required' }, 400);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
try {
|
|
373
|
+
const parsed = parseSource(source);
|
|
374
|
+
const registry = loadRegistry();
|
|
375
|
+
|
|
376
|
+
if (findEntry(registry, parsed.dirName)) {
|
|
377
|
+
return c.json(
|
|
378
|
+
{ error: `"${parsed.dirName}" is already installed. Use update or remove first.` },
|
|
379
|
+
409,
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const servicesDir = ctx.servicesDir;
|
|
384
|
+
|
|
385
|
+
if (parsed.type === "git") {
|
|
386
|
+
await installFromGit(parsed, servicesDir);
|
|
387
|
+
console.log(` [install] Cloned ${parsed.url} → services/${parsed.dirName}`);
|
|
388
|
+
} else {
|
|
389
|
+
installFromLocal(parsed, servicesDir);
|
|
390
|
+
console.log(` [install] Linked ${parsed.url} → services/${parsed.dirName}`);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Verify it has an index.ts
|
|
394
|
+
const indexPath = join(servicesDir, parsed.dirName, "index.ts");
|
|
395
|
+
if (!existsSync(indexPath)) {
|
|
396
|
+
rmSync(join(servicesDir, parsed.dirName), { recursive: true, force: true });
|
|
397
|
+
return c.json(
|
|
398
|
+
{ error: `No index.ts found in ${parsed.dirName}. Not a valid service module.` },
|
|
399
|
+
400,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
registry.push({
|
|
404
|
+
dirName: parsed.dirName,
|
|
405
|
+
source,
|
|
406
|
+
type: parsed.type,
|
|
407
|
+
installedAt: new Date().toISOString(),
|
|
408
|
+
ref: parsed.ref,
|
|
409
|
+
});
|
|
410
|
+
saveRegistry(registry);
|
|
411
|
+
|
|
412
|
+
const result = await ctx.loadModule(parsed.dirName);
|
|
413
|
+
console.log(` [install] /${result.name} — loaded`);
|
|
414
|
+
|
|
415
|
+
return c.json({
|
|
416
|
+
name: result.name,
|
|
417
|
+
dirName: parsed.dirName,
|
|
418
|
+
source,
|
|
419
|
+
type: parsed.type,
|
|
420
|
+
action: "installed",
|
|
421
|
+
}, 201);
|
|
422
|
+
} catch (err) {
|
|
423
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
424
|
+
return c.json({ error: msg }, 400);
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
routes.post("/update", async (c) => {
|
|
429
|
+
const body = await c.req.json().catch(() => ({}));
|
|
430
|
+
const name = (body.name as string)?.trim();
|
|
431
|
+
const token = (body.token as string)?.trim();
|
|
432
|
+
|
|
433
|
+
if (!name) {
|
|
434
|
+
return c.json({ error: "name is required (dirName or source)" }, 400);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
try {
|
|
438
|
+
const registry = loadRegistry();
|
|
439
|
+
const entry = findEntry(registry, name);
|
|
440
|
+
|
|
441
|
+
if (!entry) {
|
|
442
|
+
return c.json({ error: `"${name}" is not installed via the installer` }, 404);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (entry.type === "local") {
|
|
446
|
+
return c.json({ error: `"${entry.dirName}" is a local link — updates are automatic` }, 400);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (entry.type === "fleet") {
|
|
450
|
+
// Re-pull from the same remote instance
|
|
451
|
+
const [baseUrl, serviceName] = entry.source.split("#");
|
|
452
|
+
const targetDir = join(ctx.servicesDir, entry.dirName);
|
|
453
|
+
if (existsSync(targetDir)) rmSync(targetDir, { recursive: true, force: true });
|
|
454
|
+
await installFromFleet(baseUrl, serviceName, ctx.servicesDir, token);
|
|
455
|
+
console.log(` [update] services/${entry.dirName} — re-pulled from ${baseUrl}`);
|
|
456
|
+
} else {
|
|
457
|
+
await updateGit(entry, ctx.servicesDir);
|
|
458
|
+
console.log(` [update] services/${entry.dirName} — pulled latest`);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Reload the module
|
|
462
|
+
const result = await ctx.loadModule(entry.dirName);
|
|
463
|
+
console.log(` [update] /${result.name} — reloaded`);
|
|
464
|
+
|
|
465
|
+
return c.json({
|
|
466
|
+
name: result.name,
|
|
467
|
+
dirName: entry.dirName,
|
|
468
|
+
action: "updated",
|
|
469
|
+
});
|
|
470
|
+
} catch (err) {
|
|
471
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
472
|
+
return c.json({ error: msg }, 400);
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
routes.post("/remove", async (c) => {
|
|
477
|
+
const body = await c.req.json().catch(() => ({}));
|
|
478
|
+
const name = (body.name as string)?.trim();
|
|
479
|
+
|
|
480
|
+
if (!name) {
|
|
481
|
+
return c.json({ error: "name is required (dirName or source)" }, 400);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
try {
|
|
485
|
+
const registry = loadRegistry();
|
|
486
|
+
const entry = findEntry(registry, name);
|
|
487
|
+
|
|
488
|
+
if (!entry) {
|
|
489
|
+
return c.json({ error: `"${name}" is not installed via the installer` }, 404);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Unload from the server
|
|
493
|
+
const mod = ctx.getModules().find((m) => m.name === entry.dirName);
|
|
494
|
+
if (mod) {
|
|
495
|
+
await ctx.unloadModule(mod.name);
|
|
496
|
+
console.log(` [remove] /${mod.name} — unloaded`);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Delete the directory (or symlink)
|
|
500
|
+
const targetDir = join(ctx.servicesDir, entry.dirName);
|
|
501
|
+
if (existsSync(targetDir)) {
|
|
502
|
+
rmSync(targetDir, { recursive: true, force: true });
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Remove from registry
|
|
506
|
+
const updated = registry.filter((e) => e.dirName !== entry.dirName);
|
|
507
|
+
saveRegistry(updated);
|
|
508
|
+
|
|
509
|
+
console.log(` [remove] services/${entry.dirName} — deleted`);
|
|
510
|
+
|
|
511
|
+
return c.json({
|
|
512
|
+
dirName: entry.dirName,
|
|
513
|
+
action: "removed",
|
|
514
|
+
});
|
|
515
|
+
} catch (err) {
|
|
516
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
517
|
+
return c.json({ error: msg }, 400);
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
routes.get("/installed", (c) => {
|
|
522
|
+
const registry = loadRegistry();
|
|
523
|
+
return c.json({ installed: registry, count: registry.length });
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
// =============================================================================
|
|
527
|
+
// Module
|
|
528
|
+
// =============================================================================
|
|
529
|
+
|
|
530
|
+
const installer: ServiceModule = {
|
|
531
|
+
name: "installer",
|
|
532
|
+
description: "Install, update, and remove service modules from git or local paths",
|
|
533
|
+
routes,
|
|
534
|
+
|
|
535
|
+
init(serviceCtx: ServiceContext) {
|
|
536
|
+
ctx = serviceCtx;
|
|
537
|
+
},
|
|
538
|
+
|
|
539
|
+
routeDocs: {
|
|
540
|
+
"POST /install": {
|
|
541
|
+
summary: "Install a service module from a source",
|
|
542
|
+
detail: "Three modes: git clone (source), local symlink (source), or pull from another reef instance (from + name). Installs dependencies and hot-loads the module.",
|
|
543
|
+
body: {
|
|
544
|
+
source: { type: "string", required: false, description: "Git URL, local path, or user/repo shorthand. Append @ref to pin." },
|
|
545
|
+
from: { type: "string", required: false, description: "Base URL of another reef instance (e.g. http://host:3000)" },
|
|
546
|
+
name: { type: "string", required: false, description: "Service name to pull (required with 'from')" },
|
|
547
|
+
token: { type: "string", required: false, description: "Auth token for the remote instance (if needed)" },
|
|
548
|
+
},
|
|
549
|
+
response: "{ name, dirName, source|from, type, action: 'installed' }",
|
|
550
|
+
},
|
|
551
|
+
"POST /update": {
|
|
552
|
+
summary: "Pull latest from git and reload",
|
|
553
|
+
detail: "Only works for git-installed services. Local symlinks update automatically.",
|
|
554
|
+
body: {
|
|
555
|
+
name: { type: "string", required: true, description: "Directory name or original source URL" },
|
|
556
|
+
},
|
|
557
|
+
response: "{ name, dirName, action: 'updated' }",
|
|
558
|
+
},
|
|
559
|
+
"POST /remove": {
|
|
560
|
+
summary: "Unload and delete an installed service",
|
|
561
|
+
detail: "Unloads the module from the server, deletes the directory (or symlink), and removes from the registry.",
|
|
562
|
+
body: {
|
|
563
|
+
name: { type: "string", required: true, description: "Directory name or original source URL" },
|
|
564
|
+
},
|
|
565
|
+
response: "{ dirName, action: 'removed' }",
|
|
566
|
+
},
|
|
567
|
+
"GET /installed": {
|
|
568
|
+
summary: "List all services installed via the installer",
|
|
569
|
+
response: "{ installed: [{ dirName, source, type, installedAt, ref? }], count }",
|
|
570
|
+
},
|
|
571
|
+
},
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
export default installer;
|