@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,1338 @@
1
+ /**
2
+ * Server tests — discovery, dynamic dispatch, reload, unload, and install.
3
+ *
4
+ * Each test gets an isolated services directory with temporary modules.
5
+ * No port binding — tests use app.fetch() directly.
6
+ */
7
+
8
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
9
+ import {
10
+ mkdirSync,
11
+ rmSync,
12
+ writeFileSync,
13
+ existsSync,
14
+ readFileSync,
15
+ lstatSync,
16
+ } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { execSync } from "node:child_process";
19
+ import { createServer } from "../src/core/server.js";
20
+ import { parseSource } from "../services/installer/index.js";
21
+
22
+ // =============================================================================
23
+ // Helpers
24
+ // =============================================================================
25
+
26
+ const TEST_DIR = join(import.meta.dir, ".tmp-services");
27
+
28
+ /** Write a minimal service module to a directory */
29
+ function writeService(
30
+ name: string,
31
+ opts: {
32
+ response?: Record<string, unknown>;
33
+ requiresAuth?: boolean;
34
+ dependencies?: string[];
35
+ description?: string;
36
+ mountAtRoot?: boolean;
37
+ } = {},
38
+ ) {
39
+ const dir = join(TEST_DIR, name);
40
+ mkdirSync(dir, { recursive: true });
41
+
42
+ const response = JSON.stringify(opts.response ?? { name, ok: true });
43
+ const authLine = opts.requiresAuth === false ? "requiresAuth: false," : "";
44
+ const depsLine = opts.dependencies?.length
45
+ ? `dependencies: ${JSON.stringify(opts.dependencies)},`
46
+ : "";
47
+ const descLine = opts.description
48
+ ? `description: "${opts.description}",`
49
+ : "";
50
+ const mountLine = opts.mountAtRoot ? "mountAtRoot: true," : "";
51
+
52
+ writeFileSync(
53
+ join(dir, "index.ts"),
54
+ `
55
+ import { Hono } from "hono";
56
+
57
+ const routes = new Hono();
58
+ routes.get("/", (c) => c.json(${response}));
59
+ routes.post("/echo", async (c) => {
60
+ const body = await c.req.json();
61
+ return c.json({ echoed: body });
62
+ });
63
+
64
+ export default {
65
+ name: "${name}",
66
+ ${descLine}
67
+ routes,
68
+ ${authLine}
69
+ ${depsLine}
70
+ ${mountLine}
71
+ };
72
+ `,
73
+ );
74
+ }
75
+
76
+ /** Remove a service directory */
77
+ function removeService(name: string) {
78
+ const dir = join(TEST_DIR, name);
79
+ if (existsSync(dir)) rmSync(dir, { recursive: true });
80
+ }
81
+
82
+ /** Make a Request to the app */
83
+ function req(
84
+ app: { fetch: (req: Request) => Promise<Response> },
85
+ path: string,
86
+ opts: {
87
+ method?: string;
88
+ body?: unknown;
89
+ auth?: string;
90
+ } = {},
91
+ ): Promise<Response> {
92
+ const headers: Record<string, string> = {};
93
+ if (opts.body) headers["Content-Type"] = "application/json";
94
+ if (opts.auth) headers["Authorization"] = `Bearer ${opts.auth}`;
95
+
96
+ return app.fetch(
97
+ new Request(`http://localhost${path}`, {
98
+ method: opts.method ?? "GET",
99
+ headers,
100
+ body: opts.body ? JSON.stringify(opts.body) : undefined,
101
+ }),
102
+ );
103
+ }
104
+
105
+ /** Shorthand to fetch JSON */
106
+ async function json(
107
+ app: { fetch: (req: Request) => Promise<Response> },
108
+ path: string,
109
+ opts: Parameters<typeof req>[2] = {},
110
+ ) {
111
+ const res = await req(app, path, opts);
112
+ return { status: res.status, data: await res.json() };
113
+ }
114
+
115
+ // =============================================================================
116
+ // Setup / Teardown
117
+ // =============================================================================
118
+
119
+ const AUTH_TOKEN = "test-token-12345";
120
+ const originalToken = process.env.VERS_AUTH_TOKEN;
121
+
122
+ beforeEach(() => {
123
+ process.env.VERS_AUTH_TOKEN = AUTH_TOKEN;
124
+ rmSync(TEST_DIR, { recursive: true, force: true });
125
+ mkdirSync(TEST_DIR, { recursive: true });
126
+ });
127
+
128
+ afterEach(() => {
129
+ rmSync(TEST_DIR, { recursive: true, force: true });
130
+ if (originalToken) {
131
+ process.env.VERS_AUTH_TOKEN = originalToken;
132
+ } else {
133
+ delete process.env.VERS_AUTH_TOKEN;
134
+ }
135
+ });
136
+
137
+ // =============================================================================
138
+ // Tests
139
+ // =============================================================================
140
+
141
+ describe("discovery", () => {
142
+ test("discovers modules from services directory", async () => {
143
+ writeService("alpha", { requiresAuth: false });
144
+ writeService("beta", { requiresAuth: false });
145
+
146
+ const { app } = await createServer({ servicesDir: TEST_DIR });
147
+
148
+ const { status, data } = await json(app, "/health");
149
+ expect(status).toBe(200);
150
+ expect(data.services).toContain("alpha");
151
+ expect(data.services).toContain("beta");
152
+ });
153
+
154
+ test("handles empty services directory", async () => {
155
+ const { app } = await createServer({ servicesDir: TEST_DIR });
156
+
157
+ const { status, data } = await json(app, "/health");
158
+ expect(status).toBe(200);
159
+ expect(data.services).toEqual([]);
160
+ });
161
+
162
+ test("skips directories without index.ts", async () => {
163
+ writeService("good", { requiresAuth: false });
164
+ mkdirSync(join(TEST_DIR, "empty-dir"), { recursive: true });
165
+
166
+ const { app } = await createServer({ servicesDir: TEST_DIR });
167
+
168
+ const { data } = await json(app, "/health");
169
+ expect(data.services).toContain("good");
170
+ expect(data.services).not.toContain("empty-dir");
171
+ });
172
+
173
+ test("respects dependency ordering", async () => {
174
+ writeService("base", { requiresAuth: false });
175
+ writeService("dependent", {
176
+ requiresAuth: false,
177
+ dependencies: ["base"],
178
+ });
179
+
180
+ const { liveModules } = await createServer({ servicesDir: TEST_DIR });
181
+
182
+ const names = Array.from(liveModules.keys());
183
+ expect(names.indexOf("base")).toBeLessThan(names.indexOf("dependent"));
184
+ });
185
+ });
186
+
187
+ describe("dynamic dispatch", () => {
188
+ test("routes requests to the correct module", async () => {
189
+ writeService("foo", {
190
+ requiresAuth: false,
191
+ response: { service: "foo" },
192
+ });
193
+ writeService("bar", {
194
+ requiresAuth: false,
195
+ response: { service: "bar" },
196
+ });
197
+
198
+ const { app } = await createServer({ servicesDir: TEST_DIR });
199
+
200
+ const foo = await json(app, "/foo");
201
+ expect(foo.data.service).toBe("foo");
202
+
203
+ const bar = await json(app, "/bar");
204
+ expect(bar.data.service).toBe("bar");
205
+ });
206
+
207
+ test("returns 404 for unknown services", async () => {
208
+ const { app } = await createServer({ servicesDir: TEST_DIR });
209
+
210
+ const { status } = await json(app, "/nonexistent");
211
+ expect(status).toBe(404);
212
+ });
213
+
214
+ test("dispatches sub-paths", async () => {
215
+ writeService("api", { requiresAuth: false });
216
+
217
+ const { app } = await createServer({ servicesDir: TEST_DIR });
218
+
219
+ const { status, data } = await json(app, "/api/echo", {
220
+ method: "POST",
221
+ body: { hello: "world" },
222
+ });
223
+ expect(status).toBe(200);
224
+ expect(data.echoed).toEqual({ hello: "world" });
225
+ });
226
+
227
+ test("health endpoint is always accessible", async () => {
228
+ writeService("health-imposter", { requiresAuth: false });
229
+
230
+ const { app } = await createServer({ servicesDir: TEST_DIR });
231
+
232
+ const { status, data } = await json(app, "/health");
233
+ expect(status).toBe(200);
234
+ expect(data.status).toBe("ok");
235
+ expect(data.uptime).toBeGreaterThan(0);
236
+ });
237
+ });
238
+
239
+ describe("auth", () => {
240
+ test("blocks unauthenticated requests to protected modules", async () => {
241
+ writeService("guarded", { requiresAuth: true });
242
+
243
+ const { app } = await createServer({ servicesDir: TEST_DIR });
244
+
245
+ const { status } = await json(app, "/guarded");
246
+ expect(status).toBe(401);
247
+ });
248
+
249
+ test("allows authenticated requests to protected modules", async () => {
250
+ writeService("private-api", { response: { secret: true } });
251
+
252
+ const { app } = await createServer({ servicesDir: TEST_DIR });
253
+
254
+ const { status, data } = await json(app, "/private-api", {
255
+ auth: AUTH_TOKEN,
256
+ });
257
+ expect(status).toBe(200);
258
+ expect(data.secret).toBe(true);
259
+ });
260
+
261
+ test("skips auth for modules with requiresAuth: false", async () => {
262
+ writeService("public-mod", {
263
+ requiresAuth: false,
264
+ response: { public: true },
265
+ });
266
+
267
+ const { app } = await createServer({ servicesDir: TEST_DIR });
268
+
269
+ const { status, data } = await json(app, "/public-mod");
270
+ expect(status).toBe(200);
271
+ expect(data.public).toBe(true);
272
+ });
273
+ });
274
+
275
+ describe("error handling", () => {
276
+ test("bad module at startup doesn't take down other services", async () => {
277
+ writeService("good-svc", { requiresAuth: false, response: { good: true } });
278
+
279
+ // Write a module whose init() throws
280
+ const badDir = join(TEST_DIR, "bad-init");
281
+ mkdirSync(badDir, { recursive: true });
282
+ writeFileSync(
283
+ join(badDir, "index.ts"),
284
+ `
285
+ import { Hono } from "hono";
286
+ export default {
287
+ name: "bad-init",
288
+ routes: new Hono(),
289
+ requiresAuth: false,
290
+ init() { throw new Error("kaboom"); },
291
+ };
292
+ `,
293
+ );
294
+
295
+ const { app } = await createServer({ servicesDir: TEST_DIR });
296
+
297
+ // Good service still works
298
+ const { status, data } = await json(app, "/good-svc");
299
+ expect(status).toBe(200);
300
+ expect(data.good).toBe(true);
301
+
302
+ // Bad service was removed from the registry
303
+ const health = await json(app, "/health");
304
+ expect(health.data.services).toContain("good-svc");
305
+ expect(health.data.services).not.toContain("bad-init");
306
+
307
+ // Bad service returns 404, not 500
308
+ const bad = await json(app, "/bad-init");
309
+ expect(bad.status).toBe(404);
310
+ });
311
+
312
+ test("module that throws at import time is skipped", async () => {
313
+ writeService("survivor", { requiresAuth: false });
314
+
315
+ const badDir = join(TEST_DIR, "bad-import");
316
+ mkdirSync(badDir, { recursive: true });
317
+ writeFileSync(
318
+ join(badDir, "index.ts"),
319
+ `throw new Error("module-level explosion");`,
320
+ );
321
+
322
+ const { app } = await createServer({ servicesDir: TEST_DIR });
323
+
324
+ const { status } = await json(app, "/survivor");
325
+ expect(status).toBe(200);
326
+
327
+ const health = await json(app, "/health");
328
+ expect(health.data.services).not.toContain("bad-import");
329
+ });
330
+
331
+ test("module route handler that throws returns 500", async () => {
332
+ const badDir = join(TEST_DIR, "throws-at-runtime");
333
+ mkdirSync(badDir, { recursive: true });
334
+ writeFileSync(
335
+ join(badDir, "index.ts"),
336
+ `
337
+ import { Hono } from "hono";
338
+ const routes = new Hono();
339
+ routes.get("/", (c) => { throw new Error("runtime kaboom"); });
340
+ export default {
341
+ name: "throws-at-runtime",
342
+ routes,
343
+ requiresAuth: false,
344
+ };
345
+ `,
346
+ );
347
+
348
+ const { app } = await createServer({ servicesDir: TEST_DIR });
349
+
350
+ const { status, data } = await json(app, "/throws-at-runtime");
351
+ expect(status).toBe(500);
352
+ expect(data.error).toBe("internal service error");
353
+ });
354
+
355
+ test("loadModule rolls back on init() failure", async () => {
356
+ const { app, ctx } = await createServer({ servicesDir: TEST_DIR });
357
+
358
+ const badDir = join(TEST_DIR, "bad-runtime-init");
359
+ mkdirSync(badDir, { recursive: true });
360
+ writeFileSync(
361
+ join(badDir, "index.ts"),
362
+ `
363
+ import { Hono } from "hono";
364
+ export default {
365
+ name: "bad-runtime-init",
366
+ routes: new Hono(),
367
+ requiresAuth: false,
368
+ init() { throw new Error("init failed"); },
369
+ };
370
+ `,
371
+ );
372
+
373
+ await expect(ctx.loadModule("bad-runtime-init")).rejects.toThrow(
374
+ "init() failed",
375
+ );
376
+
377
+ // Should not be in the registry
378
+ expect(ctx.getModule("bad-runtime-init")).toBeUndefined();
379
+
380
+ // Should 404
381
+ const { status } = await json(app, "/bad-runtime-init");
382
+ expect(status).toBe(404);
383
+ });
384
+ });
385
+
386
+ describe("service context", () => {
387
+ test("getModules returns all loaded modules", async () => {
388
+ writeService("ctx-one", { requiresAuth: false });
389
+ writeService("ctx-two", { requiresAuth: false });
390
+
391
+ const { ctx } = await createServer({ servicesDir: TEST_DIR });
392
+
393
+ const modules = ctx.getModules();
394
+ const names = modules.map((m) => m.name);
395
+ expect(names).toContain("ctx-one");
396
+ expect(names).toContain("ctx-two");
397
+ });
398
+
399
+ test("getModule returns a specific module", async () => {
400
+ writeService("ctx-target", { requiresAuth: false, description: "the target" });
401
+
402
+ const { ctx } = await createServer({ servicesDir: TEST_DIR });
403
+
404
+ const mod = ctx.getModule("ctx-target");
405
+ expect(mod).toBeDefined();
406
+ expect(mod!.name).toBe("ctx-target");
407
+ });
408
+
409
+ test("getModule returns undefined for unknown modules", async () => {
410
+ const { ctx } = await createServer({ servicesDir: TEST_DIR });
411
+ expect(ctx.getModule("nope")).toBeUndefined();
412
+ });
413
+
414
+ test("loadModule adds a new module", async () => {
415
+ const { app, ctx } = await createServer({ servicesDir: TEST_DIR });
416
+
417
+ expect(ctx.getModules().length).toBe(0);
418
+
419
+ writeService("ctx-dynamic", {
420
+ requiresAuth: false,
421
+ response: { dynamic: true },
422
+ });
423
+ const result = await ctx.loadModule("ctx-dynamic");
424
+
425
+ expect(result.name).toBe("ctx-dynamic");
426
+ expect(result.action).toBe("added");
427
+
428
+ const { status, data } = await json(app, "/ctx-dynamic");
429
+ expect(status).toBe(200);
430
+ expect(data.dynamic).toBe(true);
431
+ });
432
+
433
+ test("loadModule updates an existing module", async () => {
434
+ writeService("ctx-mutable", {
435
+ requiresAuth: false,
436
+ response: { version: 1 },
437
+ });
438
+
439
+ const { app, ctx } = await createServer({ servicesDir: TEST_DIR });
440
+
441
+ let { data } = await json(app, "/ctx-mutable");
442
+ expect(data.version).toBe(1);
443
+
444
+ writeService("ctx-mutable", {
445
+ requiresAuth: false,
446
+ response: { version: 2 },
447
+ });
448
+ const result = await ctx.loadModule("ctx-mutable");
449
+
450
+ expect(result.action).toBe("updated");
451
+
452
+ ({ data } = await json(app, "/ctx-mutable"));
453
+ expect(data.version).toBe(2);
454
+ });
455
+
456
+ test("unloadModule removes a module", async () => {
457
+ writeService("ctx-removable", { requiresAuth: false });
458
+
459
+ const { app, ctx } = await createServer({ servicesDir: TEST_DIR });
460
+
461
+ let { status } = await json(app, "/ctx-removable");
462
+ expect(status).toBe(200);
463
+
464
+ await ctx.unloadModule("ctx-removable");
465
+
466
+ ({ status } = await json(app, "/ctx-removable"));
467
+ expect(status).toBe(404);
468
+ });
469
+
470
+ test("loadModule rejects missing dependencies", async () => {
471
+ writeService("ctx-orphan", {
472
+ requiresAuth: false,
473
+ dependencies: ["nonexistent"],
474
+ });
475
+
476
+ const { ctx } = await createServer({ servicesDir: TEST_DIR });
477
+
478
+ await expect(ctx.loadModule("ctx-orphan")).rejects.toThrow(
479
+ 'Missing dependency "nonexistent"',
480
+ );
481
+ });
482
+
483
+ test("getStore accesses another module's store", async () => {
484
+ const dir = join(TEST_DIR, "ctx-stateful");
485
+ mkdirSync(dir, { recursive: true });
486
+ writeFileSync(
487
+ join(dir, "index.ts"),
488
+ `
489
+ import { Hono } from "hono";
490
+
491
+ const myStore = { data: [1, 2, 3], flush() {}, close() {} };
492
+
493
+ export default {
494
+ name: "ctx-stateful",
495
+ routes: new Hono(),
496
+ store: myStore,
497
+ requiresAuth: false,
498
+ };
499
+ `,
500
+ );
501
+
502
+ const { ctx } = await createServer({ servicesDir: TEST_DIR });
503
+
504
+ const store = ctx.getStore<{ data: number[] }>("ctx-stateful");
505
+ expect(store).toBeDefined();
506
+ expect(store!.data).toEqual([1, 2, 3]);
507
+ });
508
+ });
509
+
510
+ describe("services manager module", () => {
511
+ async function createWithManager() {
512
+ const managerSrc = join(import.meta.dir, "..", "services", "services");
513
+ const managerDst = join(TEST_DIR, "services");
514
+ mkdirSync(managerDst, { recursive: true });
515
+
516
+ const indexContent = readFileSync(join(managerSrc, "index.ts"), "utf-8");
517
+ const fixed = indexContent.replace(
518
+ '"../src/core/types.js"',
519
+ `"${join(import.meta.dir, "..", "src", "core", "types.js")}"`,
520
+ );
521
+ writeFileSync(join(managerDst, "index.ts"), fixed);
522
+
523
+ return createServer({ servicesDir: TEST_DIR });
524
+ }
525
+
526
+ test("GET /services lists modules", async () => {
527
+ writeService("mgr-alpha", { requiresAuth: false });
528
+
529
+ const { app } = await createWithManager();
530
+
531
+ const { status, data } = await json(app, "/services", {
532
+ auth: AUTH_TOKEN,
533
+ });
534
+ expect(status).toBe(200);
535
+ expect(data.modules.map((m: any) => m.name)).toContain("mgr-alpha");
536
+ expect(data.modules.map((m: any) => m.name)).toContain("services");
537
+ });
538
+
539
+ test("POST /services/reload/:name reloads a module", async () => {
540
+ writeService("mgr-reloadable", {
541
+ requiresAuth: false,
542
+ response: { v: 1 },
543
+ });
544
+
545
+ const { app } = await createWithManager();
546
+
547
+ let { data } = await json(app, "/mgr-reloadable");
548
+ expect(data.v).toBe(1);
549
+
550
+ writeService("mgr-reloadable", {
551
+ requiresAuth: false,
552
+ response: { v: 2 },
553
+ });
554
+
555
+ const reload = await json(app, "/services/reload/mgr-reloadable", {
556
+ method: "POST",
557
+ auth: AUTH_TOKEN,
558
+ });
559
+ expect(reload.data.action).toBe("updated");
560
+
561
+ ({ data } = await json(app, "/mgr-reloadable"));
562
+ expect(data.v).toBe(2);
563
+ });
564
+
565
+ test("DELETE /services/:name unloads a module", async () => {
566
+ writeService("mgr-doomed", { requiresAuth: false });
567
+
568
+ const { app } = await createWithManager();
569
+
570
+ let { status } = await json(app, "/mgr-doomed");
571
+ expect(status).toBe(200);
572
+
573
+ const del = await json(app, "/services/mgr-doomed", {
574
+ method: "DELETE",
575
+ auth: AUTH_TOKEN,
576
+ });
577
+ expect(del.data.action).toBe("removed");
578
+
579
+ ({ status } = await json(app, "/mgr-doomed"));
580
+ expect(status).toBe(404);
581
+ });
582
+
583
+ test("DELETE /services/services is rejected", async () => {
584
+ const { app } = await createWithManager();
585
+
586
+ const { status, data } = await json(app, "/services/services", {
587
+ method: "DELETE",
588
+ auth: AUTH_TOKEN,
589
+ });
590
+ expect(status).toBe(400);
591
+ expect(data.error).toContain("Cannot unload");
592
+ });
593
+
594
+ test("POST /services/reload re-scans everything", async () => {
595
+ writeService("mgr-existing", { requiresAuth: false });
596
+
597
+ const { app } = await createWithManager();
598
+
599
+ writeService("mgr-newcomer", { requiresAuth: false });
600
+
601
+ const reload = await json(app, "/services/reload", {
602
+ method: "POST",
603
+ auth: AUTH_TOKEN,
604
+ });
605
+
606
+ const names = reload.data.results.map((r: any) => r.name);
607
+ expect(names).toContain("mgr-newcomer");
608
+
609
+ const { status } = await json(app, "/mgr-newcomer");
610
+ expect(status).toBe(200);
611
+ });
612
+
613
+ test("POST /services/reload removes deleted services", async () => {
614
+ writeService("mgr-temporary", { requiresAuth: false });
615
+
616
+ const { app } = await createWithManager();
617
+
618
+ let { status } = await json(app, "/mgr-temporary");
619
+ expect(status).toBe(200);
620
+
621
+ removeService("mgr-temporary");
622
+
623
+ const reload = await json(app, "/services/reload", {
624
+ method: "POST",
625
+ auth: AUTH_TOKEN,
626
+ });
627
+
628
+ const removed = reload.data.results.find(
629
+ (r: any) => r.name === "mgr-temporary",
630
+ );
631
+ expect(removed?.action).toBe("removed");
632
+
633
+ ({ status } = await json(app, "/mgr-temporary"));
634
+ expect(status).toBe(404);
635
+ });
636
+
637
+ test("management endpoints require auth", async () => {
638
+ const { app } = await createWithManager();
639
+
640
+ const list = await req(app, "/services");
641
+ expect(list.status).toBe(401);
642
+
643
+ const reload = await req(app, "/services/reload", { method: "POST" });
644
+ expect(reload.status).toBe(401);
645
+
646
+ const del = await req(app, "/services/foo", { method: "DELETE" });
647
+ expect(del.status).toBe(401);
648
+ });
649
+ });
650
+
651
+ describe("installer module", () => {
652
+ const EXTERNAL_DIR = join(import.meta.dir, ".tmp-external");
653
+
654
+ /** Create an external service directory (simulating a repo or local package) */
655
+ function writeExternal(name: string, response: Record<string, unknown> = { external: true }) {
656
+ const dir = join(EXTERNAL_DIR, name);
657
+ mkdirSync(dir, { recursive: true });
658
+ writeFileSync(
659
+ join(dir, "index.ts"),
660
+ `
661
+ import { Hono } from "hono";
662
+
663
+ const routes = new Hono();
664
+ routes.get("/", (c) => c.json(${JSON.stringify(response)}));
665
+
666
+ export default {
667
+ name: "${name}",
668
+ description: "External ${name}",
669
+ routes,
670
+ requiresAuth: false,
671
+ };
672
+ `,
673
+ );
674
+ }
675
+
676
+ async function createWithInstaller() {
677
+ // Copy the installer module into the test services dir
678
+ const installerSrc = join(import.meta.dir, "..", "services", "installer");
679
+ const installerDst = join(TEST_DIR, "installer");
680
+ mkdirSync(installerDst, { recursive: true });
681
+
682
+ const indexContent = readFileSync(join(installerSrc, "index.ts"), "utf-8");
683
+ const fixed = indexContent.replace(
684
+ '"../src/core/types.js"',
685
+ `"${join(import.meta.dir, "..", "src", "core", "types.js")}"`,
686
+ );
687
+ writeFileSync(join(installerDst, "index.ts"), fixed);
688
+
689
+ return createServer({ servicesDir: TEST_DIR });
690
+ }
691
+
692
+ beforeEach(() => {
693
+ rmSync(EXTERNAL_DIR, { recursive: true, force: true });
694
+ mkdirSync(EXTERNAL_DIR, { recursive: true });
695
+ });
696
+
697
+ afterEach(() => {
698
+ rmSync(EXTERNAL_DIR, { recursive: true, force: true });
699
+ });
700
+
701
+ test("POST /installer/install from local path", async () => {
702
+ writeExternal("my-plugin", { plugin: true, v: 1 });
703
+
704
+ const { app } = await createWithInstaller();
705
+
706
+ // Install
707
+ const install = await json(app, "/installer/install", {
708
+ method: "POST",
709
+ auth: AUTH_TOKEN,
710
+ body: { source: join(EXTERNAL_DIR, "my-plugin") },
711
+ });
712
+ expect(install.status).toBe(201);
713
+ expect(install.data.action).toBe("installed");
714
+ expect(install.data.type).toBe("local");
715
+ expect(install.data.name).toBe("my-plugin");
716
+
717
+ // It's live immediately
718
+ const { status, data } = await json(app, "/my-plugin");
719
+ expect(status).toBe(200);
720
+ expect(data.plugin).toBe(true);
721
+ });
722
+
723
+ test("local install creates a symlink", async () => {
724
+ writeExternal("symlink-test");
725
+
726
+ const { app } = await createWithInstaller();
727
+
728
+ await json(app, "/installer/install", {
729
+ method: "POST",
730
+ auth: AUTH_TOKEN,
731
+ body: { source: join(EXTERNAL_DIR, "symlink-test") },
732
+ });
733
+
734
+ const linkPath = join(TEST_DIR, "symlink-test");
735
+ expect(existsSync(linkPath)).toBe(true);
736
+ expect(lstatSync(linkPath).isSymbolicLink()).toBe(true);
737
+ });
738
+
739
+ test("GET /installer/installed lists installed packages", async () => {
740
+ writeExternal("pkg-a");
741
+ writeExternal("pkg-b");
742
+
743
+ const { app } = await createWithInstaller();
744
+
745
+ await json(app, "/installer/install", {
746
+ method: "POST",
747
+ auth: AUTH_TOKEN,
748
+ body: { source: join(EXTERNAL_DIR, "pkg-a") },
749
+ });
750
+ await json(app, "/installer/install", {
751
+ method: "POST",
752
+ auth: AUTH_TOKEN,
753
+ body: { source: join(EXTERNAL_DIR, "pkg-b") },
754
+ });
755
+
756
+ const { data } = await json(app, "/installer/installed", {
757
+ auth: AUTH_TOKEN,
758
+ });
759
+ expect(data.count).toBe(2);
760
+ expect(data.installed.map((e: any) => e.dirName)).toContain("pkg-a");
761
+ expect(data.installed.map((e: any) => e.dirName)).toContain("pkg-b");
762
+ expect(data.installed[0].type).toBe("local");
763
+ expect(data.installed[0].installedAt).toBeDefined();
764
+ });
765
+
766
+ test("duplicate install is rejected", async () => {
767
+ writeExternal("dupe-test");
768
+
769
+ const { app } = await createWithInstaller();
770
+
771
+ const first = await json(app, "/installer/install", {
772
+ method: "POST",
773
+ auth: AUTH_TOKEN,
774
+ body: { source: join(EXTERNAL_DIR, "dupe-test") },
775
+ });
776
+ expect(first.status).toBe(201);
777
+
778
+ const second = await json(app, "/installer/install", {
779
+ method: "POST",
780
+ auth: AUTH_TOKEN,
781
+ body: { source: join(EXTERNAL_DIR, "dupe-test") },
782
+ });
783
+ expect(second.status).toBe(409);
784
+ expect(second.data.error).toContain("already installed");
785
+ });
786
+
787
+ test("install rejects directories without index.ts", async () => {
788
+ const emptyDir = join(EXTERNAL_DIR, "no-index");
789
+ mkdirSync(emptyDir, { recursive: true });
790
+ writeFileSync(join(emptyDir, "README.md"), "not a service");
791
+
792
+ const { app } = await createWithInstaller();
793
+
794
+ const { status, data } = await json(app, "/installer/install", {
795
+ method: "POST",
796
+ auth: AUTH_TOKEN,
797
+ body: { source: emptyDir },
798
+ });
799
+ expect(status).toBe(400);
800
+ expect(data.error).toContain("No index.ts");
801
+
802
+ // Directory should be cleaned up
803
+ expect(existsSync(join(TEST_DIR, "no-index"))).toBe(false);
804
+ });
805
+
806
+ test("install rejects nonexistent paths", async () => {
807
+ const { app } = await createWithInstaller();
808
+
809
+ const { status, data } = await json(app, "/installer/install", {
810
+ method: "POST",
811
+ auth: AUTH_TOKEN,
812
+ body: { source: "/nonexistent/path/to/service" },
813
+ });
814
+ expect(status).toBe(400);
815
+ expect(data.error).toContain("not found");
816
+ });
817
+
818
+ test("install requires source field", async () => {
819
+ const { app } = await createWithInstaller();
820
+
821
+ const { status, data } = await json(app, "/installer/install", {
822
+ method: "POST",
823
+ auth: AUTH_TOKEN,
824
+ body: {},
825
+ });
826
+ expect(status).toBe(400);
827
+ expect(data.error).toContain("required");
828
+ });
829
+
830
+ test("POST /installer/remove unloads and deletes", async () => {
831
+ writeExternal("removable-pkg", { removable: true });
832
+
833
+ const { app } = await createWithInstaller();
834
+
835
+ // Install
836
+ await json(app, "/installer/install", {
837
+ method: "POST",
838
+ auth: AUTH_TOKEN,
839
+ body: { source: join(EXTERNAL_DIR, "removable-pkg") },
840
+ });
841
+
842
+ // Verify it's live
843
+ let { status } = await json(app, "/removable-pkg");
844
+ expect(status).toBe(200);
845
+
846
+ // Remove
847
+ const remove = await json(app, "/installer/remove", {
848
+ method: "POST",
849
+ auth: AUTH_TOKEN,
850
+ body: { name: "removable-pkg" },
851
+ });
852
+ expect(remove.data.action).toBe("removed");
853
+
854
+ // Gone from routing
855
+ ({ status } = await json(app, "/removable-pkg"));
856
+ expect(status).toBe(404);
857
+
858
+ // Gone from disk
859
+ expect(existsSync(join(TEST_DIR, "removable-pkg"))).toBe(false);
860
+
861
+ // Gone from registry
862
+ const { data } = await json(app, "/installer/installed", {
863
+ auth: AUTH_TOKEN,
864
+ });
865
+ expect(data.installed.map((e: any) => e.dirName)).not.toContain(
866
+ "removable-pkg",
867
+ );
868
+ });
869
+
870
+ test("remove rejects unknown packages", async () => {
871
+ const { app } = await createWithInstaller();
872
+
873
+ const { status, data } = await json(app, "/installer/remove", {
874
+ method: "POST",
875
+ auth: AUTH_TOKEN,
876
+ body: { name: "never-installed" },
877
+ });
878
+ expect(status).toBe(404);
879
+ expect(data.error).toContain("not installed");
880
+ });
881
+
882
+ test("installed service shows in health check", async () => {
883
+ writeExternal("health-visible");
884
+
885
+ const { app } = await createWithInstaller();
886
+
887
+ await json(app, "/installer/install", {
888
+ method: "POST",
889
+ auth: AUTH_TOKEN,
890
+ body: { source: join(EXTERNAL_DIR, "health-visible") },
891
+ });
892
+
893
+ const { data } = await json(app, "/health");
894
+ expect(data.services).toContain("health-visible");
895
+ });
896
+
897
+ test("install and remove round-trip leaves clean state", async () => {
898
+ writeExternal("round-trip");
899
+
900
+ const { app } = await createWithInstaller();
901
+
902
+ // Install
903
+ await json(app, "/installer/install", {
904
+ method: "POST",
905
+ auth: AUTH_TOKEN,
906
+ body: { source: join(EXTERNAL_DIR, "round-trip") },
907
+ });
908
+
909
+ // Remove
910
+ await json(app, "/installer/remove", {
911
+ method: "POST",
912
+ auth: AUTH_TOKEN,
913
+ body: { name: "round-trip" },
914
+ });
915
+
916
+ // Can install again
917
+ const reinstall = await json(app, "/installer/install", {
918
+ method: "POST",
919
+ auth: AUTH_TOKEN,
920
+ body: { source: join(EXTERNAL_DIR, "round-trip") },
921
+ });
922
+ expect(reinstall.status).toBe(201);
923
+ expect(reinstall.data.action).toBe("installed");
924
+ });
925
+
926
+ test("install from local git repo (clone, not symlink)", async () => {
927
+ // Create a bare git repo with a service module in it
928
+ const bareRepo = join(EXTERNAL_DIR, "my-git-service.git");
929
+ const workTree = join(EXTERNAL_DIR, "my-git-service-work");
930
+
931
+ execSync(`git init --bare ${bareRepo}`);
932
+ mkdirSync(workTree, { recursive: true });
933
+
934
+ writeFileSync(
935
+ join(workTree, "index.ts"),
936
+ `
937
+ import { Hono } from "hono";
938
+
939
+ const routes = new Hono();
940
+ routes.get("/", (c) => c.json({ from: "git", v: 1 }));
941
+
942
+ export default {
943
+ name: "my-git-service",
944
+ description: "Installed from git",
945
+ routes,
946
+ requiresAuth: false,
947
+ };
948
+ `,
949
+ );
950
+
951
+ execSync("git init && git add -A && git commit -m 'init'", {
952
+ cwd: workTree,
953
+ env: {
954
+ ...process.env,
955
+ GIT_AUTHOR_NAME: "test",
956
+ GIT_AUTHOR_EMAIL: "test@test.com",
957
+ GIT_COMMITTER_NAME: "test",
958
+ GIT_COMMITTER_EMAIL: "test@test.com",
959
+ },
960
+ });
961
+ // Push to master (the bare repo's default branch)
962
+ execSync(`git push ${bareRepo} HEAD:master`, { cwd: workTree });
963
+
964
+ const { app } = await createWithInstaller();
965
+
966
+ // Install from the bare repo (a real git clone)
967
+ const install = await json(app, "/installer/install", {
968
+ method: "POST",
969
+ auth: AUTH_TOKEN,
970
+ body: { source: bareRepo },
971
+ });
972
+ expect(install.status).toBe(201);
973
+ expect(install.data.type).toBe("git");
974
+ expect(install.data.name).toBe("my-git-service");
975
+
976
+ // It should be a real directory, not a symlink
977
+ const clonedDir = join(TEST_DIR, "my-git-service");
978
+ expect(existsSync(clonedDir)).toBe(true);
979
+ expect(lstatSync(clonedDir).isSymbolicLink()).toBe(false);
980
+ expect(existsSync(join(clonedDir, ".git"))).toBe(true);
981
+
982
+ // It's live
983
+ const { status, data } = await json(app, "/my-git-service");
984
+ expect(status).toBe(200);
985
+ expect(data.from).toBe("git");
986
+ });
987
+
988
+ test("update pulls latest from git repo", async () => {
989
+ // Create bare repo + working tree
990
+ const bareRepo = join(EXTERNAL_DIR, "updatable-svc.git");
991
+ const workTree = join(EXTERNAL_DIR, "updatable-svc-work");
992
+ const gitEnv = {
993
+ ...process.env,
994
+ GIT_AUTHOR_NAME: "test",
995
+ GIT_AUTHOR_EMAIL: "test@test.com",
996
+ GIT_COMMITTER_NAME: "test",
997
+ GIT_COMMITTER_EMAIL: "test@test.com",
998
+ };
999
+
1000
+ execSync(`git init --bare ${bareRepo}`);
1001
+ mkdirSync(workTree, { recursive: true });
1002
+
1003
+ writeFileSync(
1004
+ join(workTree, "index.ts"),
1005
+ `
1006
+ import { Hono } from "hono";
1007
+ const routes = new Hono();
1008
+ routes.get("/", (c) => c.json({ version: 1 }));
1009
+ export default { name: "updatable-svc", routes, requiresAuth: false };
1010
+ `,
1011
+ );
1012
+ execSync("git init && git add -A && git commit -m 'v1'", {
1013
+ cwd: workTree, env: gitEnv,
1014
+ });
1015
+ execSync(`git push ${bareRepo} HEAD:master`, { cwd: workTree });
1016
+
1017
+ const { app } = await createWithInstaller();
1018
+
1019
+ // Install v1
1020
+ await json(app, "/installer/install", {
1021
+ method: "POST",
1022
+ auth: AUTH_TOKEN,
1023
+ body: { source: bareRepo },
1024
+ });
1025
+
1026
+ let res = await json(app, "/updatable-svc");
1027
+ expect(res.data.version).toBe(1);
1028
+
1029
+ // Push v2 to the bare repo
1030
+ writeFileSync(
1031
+ join(workTree, "index.ts"),
1032
+ `
1033
+ import { Hono } from "hono";
1034
+ const routes = new Hono();
1035
+ routes.get("/", (c) => c.json({ version: 2 }));
1036
+ export default { name: "updatable-svc", routes, requiresAuth: false };
1037
+ `,
1038
+ );
1039
+ execSync("git add -A && git commit -m 'v2'", {
1040
+ cwd: workTree, env: gitEnv,
1041
+ });
1042
+ execSync(`git push ${bareRepo} HEAD:master`, { cwd: workTree });
1043
+
1044
+ // Update via the installer
1045
+ const update = await json(app, "/installer/update", {
1046
+ method: "POST",
1047
+ auth: AUTH_TOKEN,
1048
+ body: { name: "updatable-svc" },
1049
+ });
1050
+ expect(update.data.action).toBe("updated");
1051
+
1052
+ // Module should serve the new version
1053
+ res = await json(app, "/updatable-svc");
1054
+ expect(res.data.version).toBe(2);
1055
+ });
1056
+
1057
+ test("update rejects local-linked services", async () => {
1058
+ writeExternal("local-only");
1059
+
1060
+ const { app } = await createWithInstaller();
1061
+
1062
+ await json(app, "/installer/install", {
1063
+ method: "POST",
1064
+ auth: AUTH_TOKEN,
1065
+ body: { source: join(EXTERNAL_DIR, "local-only") },
1066
+ });
1067
+
1068
+ const { status, data } = await json(app, "/installer/update", {
1069
+ method: "POST",
1070
+ auth: AUTH_TOKEN,
1071
+ body: { name: "local-only" },
1072
+ });
1073
+ expect(status).toBe(400);
1074
+ expect(data.error).toContain("local link");
1075
+ });
1076
+ });
1077
+
1078
+ describe("fleet-to-fleet install", () => {
1079
+ const SOURCE_DIR = join(import.meta.dir, ".tmp-source-services");
1080
+ const DEST_DIR = join(import.meta.dir, ".tmp-dest-services");
1081
+ let sourceServer: ReturnType<typeof Bun.serve> | undefined;
1082
+
1083
+ /** Write the services manager + a test service into the source dir */
1084
+ function setupSourceServer() {
1085
+ mkdirSync(SOURCE_DIR, { recursive: true });
1086
+
1087
+ // Copy the services manager module (needed for /services/export/:name)
1088
+ const managerSrc = join(import.meta.dir, "..", "services", "services");
1089
+ const managerDst = join(SOURCE_DIR, "services");
1090
+ mkdirSync(managerDst, { recursive: true });
1091
+ const managerContent = readFileSync(join(managerSrc, "index.ts"), "utf-8")
1092
+ .replace('"../src/core/types.js"', `"${join(import.meta.dir, "..", "src", "core", "types.js")}"`);
1093
+ writeFileSync(join(managerDst, "index.ts"), managerContent);
1094
+
1095
+ // Write a service to export
1096
+ const svcDir = join(SOURCE_DIR, "exportable");
1097
+ mkdirSync(svcDir, { recursive: true });
1098
+ writeFileSync(join(svcDir, "index.ts"), `
1099
+ import { Hono } from "hono";
1100
+ const routes = new Hono();
1101
+ routes.get("/", (c) => c.json({ pulled: true, origin: "source" }));
1102
+ export default {
1103
+ name: "exportable",
1104
+ description: "A service that can be exported",
1105
+ routes,
1106
+ requiresAuth: false,
1107
+ };
1108
+ `);
1109
+ // Add an extra file to make sure multi-file services transfer
1110
+ writeFileSync(join(svcDir, "helpers.ts"), `export const VERSION = 1;`);
1111
+ }
1112
+
1113
+ async function setupDestServer() {
1114
+ mkdirSync(DEST_DIR, { recursive: true });
1115
+
1116
+ // Copy the installer module
1117
+ const installerSrc = join(import.meta.dir, "..", "services", "installer");
1118
+ const installerDst = join(DEST_DIR, "installer");
1119
+ mkdirSync(installerDst, { recursive: true });
1120
+ const installerContent = readFileSync(join(installerSrc, "index.ts"), "utf-8")
1121
+ .replace('"../src/core/types.js"', `"${join(import.meta.dir, "..", "src", "core", "types.js")}"`);
1122
+ writeFileSync(join(installerDst, "index.ts"), installerContent);
1123
+
1124
+ return createServer({ servicesDir: DEST_DIR });
1125
+ }
1126
+
1127
+ beforeEach(() => {
1128
+ rmSync(SOURCE_DIR, { recursive: true, force: true });
1129
+ rmSync(DEST_DIR, { recursive: true, force: true });
1130
+ });
1131
+
1132
+ afterEach(() => {
1133
+ sourceServer?.stop();
1134
+ sourceServer = undefined;
1135
+ rmSync(SOURCE_DIR, { recursive: true, force: true });
1136
+ rmSync(DEST_DIR, { recursive: true, force: true });
1137
+ });
1138
+
1139
+ test("install a service from another instance", async () => {
1140
+ setupSourceServer();
1141
+
1142
+ // Start source server on a real port
1143
+ const source = await createServer({ servicesDir: SOURCE_DIR });
1144
+ sourceServer = Bun.serve({
1145
+ fetch: source.app.fetch,
1146
+ port: 0, // random available port
1147
+ });
1148
+ const sourceUrl = `http://localhost:${sourceServer.port}`;
1149
+
1150
+ // Verify source has the export endpoint
1151
+ const exportCheck = await fetch(`${sourceUrl}/services/export/exportable`, {
1152
+ headers: { Authorization: `Bearer ${AUTH_TOKEN}` },
1153
+ });
1154
+ expect(exportCheck.status).toBe(200);
1155
+ expect(exportCheck.headers.get("Content-Type")).toBe("application/gzip");
1156
+
1157
+ // Set up destination server
1158
+ const { app } = await setupDestServer();
1159
+
1160
+ // Install from the source
1161
+ const install = await json(app, "/installer/install", {
1162
+ method: "POST",
1163
+ auth: AUTH_TOKEN,
1164
+ body: { from: sourceUrl, name: "exportable", token: AUTH_TOKEN },
1165
+ });
1166
+ expect(install.status).toBe(201);
1167
+ expect(install.data.type).toBe("fleet");
1168
+ expect(install.data.from).toBe(sourceUrl);
1169
+ expect(install.data.name).toBe("exportable");
1170
+
1171
+ // Service is live on the destination
1172
+ const { status, data } = await json(app, "/exportable");
1173
+ expect(status).toBe(200);
1174
+ expect(data.pulled).toBe(true);
1175
+ expect(data.origin).toBe("source");
1176
+
1177
+ // Multi-file transfer worked
1178
+ expect(existsSync(join(DEST_DIR, "exportable", "helpers.ts"))).toBe(true);
1179
+
1180
+ // Shows up in the installed list as fleet type
1181
+ const installed = await json(app, "/installer/installed", { auth: AUTH_TOKEN });
1182
+ const entry = installed.data.installed.find((e: any) => e.dirName === "exportable");
1183
+ expect(entry.type).toBe("fleet");
1184
+ expect(entry.source).toBe(`${sourceUrl}#exportable`);
1185
+ });
1186
+
1187
+ test("from requires name", async () => {
1188
+ const { app } = await setupDestServer();
1189
+
1190
+ const { status, data } = await json(app, "/installer/install", {
1191
+ method: "POST",
1192
+ auth: AUTH_TOKEN,
1193
+ body: { from: "http://localhost:9999" },
1194
+ });
1195
+ expect(status).toBe(400);
1196
+ expect(data.error).toContain("name");
1197
+ });
1198
+
1199
+ test("fails gracefully when remote is unreachable", async () => {
1200
+ const { app } = await setupDestServer();
1201
+
1202
+ const { status, data } = await json(app, "/installer/install", {
1203
+ method: "POST",
1204
+ auth: AUTH_TOKEN,
1205
+ body: { from: "http://localhost:19999", name: "nope" },
1206
+ });
1207
+ expect(status).toBe(400);
1208
+ expect(data.error).toBeDefined();
1209
+ });
1210
+
1211
+ test("fails gracefully when remote service doesn't exist", async () => {
1212
+ setupSourceServer();
1213
+
1214
+ const source = await createServer({ servicesDir: SOURCE_DIR });
1215
+ sourceServer = Bun.serve({
1216
+ fetch: source.app.fetch,
1217
+ port: 0,
1218
+ });
1219
+ const sourceUrl = `http://localhost:${sourceServer.port}`;
1220
+
1221
+ const { app } = await setupDestServer();
1222
+
1223
+ const { status, data } = await json(app, "/installer/install", {
1224
+ method: "POST",
1225
+ auth: AUTH_TOKEN,
1226
+ body: { from: sourceUrl, name: "nonexistent", token: AUTH_TOKEN },
1227
+ });
1228
+ expect(status).toBe(400);
1229
+ expect(data.error).toContain("404");
1230
+ });
1231
+
1232
+ test("update re-pulls from the same remote", async () => {
1233
+ setupSourceServer();
1234
+
1235
+ const source = await createServer({ servicesDir: SOURCE_DIR });
1236
+ sourceServer = Bun.serve({
1237
+ fetch: source.app.fetch,
1238
+ port: 0,
1239
+ });
1240
+ const sourceUrl = `http://localhost:${sourceServer.port}`;
1241
+
1242
+ const { app } = await setupDestServer();
1243
+
1244
+ // Install
1245
+ await json(app, "/installer/install", {
1246
+ method: "POST",
1247
+ auth: AUTH_TOKEN,
1248
+ body: { from: sourceUrl, name: "exportable", token: AUTH_TOKEN },
1249
+ });
1250
+
1251
+ // Update (needs token for the remote)
1252
+ const update = await json(app, "/installer/update", {
1253
+ method: "POST",
1254
+ auth: AUTH_TOKEN,
1255
+ body: { name: "exportable", token: AUTH_TOKEN },
1256
+ });
1257
+ expect(update.data.action).toBe("updated");
1258
+ });
1259
+ });
1260
+
1261
+ describe("source parsing", () => {
1262
+
1263
+ test("GitHub shorthand: user/repo", () => {
1264
+ const parsed = parseSource("acme/cool-service");
1265
+ expect(parsed.type).toBe("git");
1266
+ expect(parsed.url).toBe("https://github.com/acme/cool-service");
1267
+ expect(parsed.dirName).toBe("cool-service");
1268
+ expect(parsed.ref).toBeUndefined();
1269
+ });
1270
+
1271
+ test("GitHub shorthand with ref: user/repo@v1.0", () => {
1272
+ const parsed = parseSource("acme/cool-service@v1.0");
1273
+ expect(parsed.type).toBe("git");
1274
+ expect(parsed.url).toBe("https://github.com/acme/cool-service");
1275
+ expect(parsed.ref).toBe("v1.0");
1276
+ expect(parsed.dirName).toBe("cool-service");
1277
+ });
1278
+
1279
+ test("HTTPS URL: https://github.com/user/repo", () => {
1280
+ const parsed = parseSource("https://github.com/acme/my-service");
1281
+ expect(parsed.type).toBe("git");
1282
+ expect(parsed.url).toBe("https://github.com/acme/my-service");
1283
+ expect(parsed.dirName).toBe("my-service");
1284
+ });
1285
+
1286
+ test("HTTPS URL with .git suffix", () => {
1287
+ const parsed = parseSource("https://github.com/acme/my-service.git");
1288
+ expect(parsed.type).toBe("git");
1289
+ expect(parsed.url).toBe("https://github.com/acme/my-service.git");
1290
+ expect(parsed.dirName).toBe("my-service");
1291
+ });
1292
+
1293
+ test("HTTPS URL with ref", () => {
1294
+ const parsed = parseSource("https://github.com/acme/my-service@main");
1295
+ expect(parsed.type).toBe("git");
1296
+ expect(parsed.url).toBe("https://github.com/acme/my-service");
1297
+ expect(parsed.ref).toBe("main");
1298
+ });
1299
+
1300
+ test("SSH URL: git@github.com:user/repo", () => {
1301
+ const parsed = parseSource("git@github.com:acme/my-service");
1302
+ expect(parsed.type).toBe("git");
1303
+ expect(parsed.url).toBe("git@github.com:acme/my-service");
1304
+ expect(parsed.dirName).toBe("my-service");
1305
+ });
1306
+
1307
+ test("SSH URL with ref", () => {
1308
+ const parsed = parseSource("git@github.com:acme/my-service@v2.0");
1309
+ expect(parsed.type).toBe("git");
1310
+ expect(parsed.url).toBe("git@github.com:acme/my-service");
1311
+ expect(parsed.ref).toBe("v2.0");
1312
+ });
1313
+
1314
+ test("bare host: github.com/user/repo", () => {
1315
+ const parsed = parseSource("github.com/acme/my-service");
1316
+ expect(parsed.type).toBe("git");
1317
+ expect(parsed.url).toBe("https://github.com/acme/my-service");
1318
+ expect(parsed.dirName).toBe("my-service");
1319
+ });
1320
+
1321
+ test("bare host with ref", () => {
1322
+ const parsed = parseSource("github.com/acme/my-service@feat-branch");
1323
+ expect(parsed.type).toBe("git");
1324
+ expect(parsed.url).toBe("https://github.com/acme/my-service");
1325
+ expect(parsed.ref).toBe("feat-branch");
1326
+ });
1327
+
1328
+ test("non-GitHub host: gitlab.com/user/repo", () => {
1329
+ const parsed = parseSource("gitlab.com/team/service");
1330
+ expect(parsed.type).toBe("git");
1331
+ expect(parsed.url).toBe("https://gitlab.com/team/service");
1332
+ expect(parsed.dirName).toBe("service");
1333
+ });
1334
+
1335
+ test("rejects unparseable input", () => {
1336
+ expect(() => parseSource("just-a-word")).toThrow("Cannot parse source");
1337
+ });
1338
+ });