@openparachute/hub 0.3.0-rc.1

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 (76) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +284 -0
  3. package/package.json +31 -0
  4. package/src/__tests__/auth.test.ts +101 -0
  5. package/src/__tests__/auto-wire.test.ts +283 -0
  6. package/src/__tests__/cli.test.ts +192 -0
  7. package/src/__tests__/cloudflare-config.test.ts +54 -0
  8. package/src/__tests__/cloudflare-detect.test.ts +68 -0
  9. package/src/__tests__/cloudflare-state.test.ts +92 -0
  10. package/src/__tests__/cloudflare-tunnel.test.ts +207 -0
  11. package/src/__tests__/config.test.ts +18 -0
  12. package/src/__tests__/env-file.test.ts +125 -0
  13. package/src/__tests__/expose-auth-preflight.test.ts +201 -0
  14. package/src/__tests__/expose-cloudflare.test.ts +484 -0
  15. package/src/__tests__/expose-interactive.test.ts +703 -0
  16. package/src/__tests__/expose-last-provider.test.ts +113 -0
  17. package/src/__tests__/expose-off-auto.test.ts +269 -0
  18. package/src/__tests__/expose-state.test.ts +101 -0
  19. package/src/__tests__/expose.test.ts +1581 -0
  20. package/src/__tests__/hub-control.test.ts +346 -0
  21. package/src/__tests__/hub-server.test.ts +157 -0
  22. package/src/__tests__/hub.test.ts +116 -0
  23. package/src/__tests__/install.test.ts +1145 -0
  24. package/src/__tests__/lifecycle.test.ts +608 -0
  25. package/src/__tests__/migrate.test.ts +422 -0
  26. package/src/__tests__/notes-serve.test.ts +135 -0
  27. package/src/__tests__/port-assign.test.ts +178 -0
  28. package/src/__tests__/process-state.test.ts +140 -0
  29. package/src/__tests__/scribe-config.test.ts +193 -0
  30. package/src/__tests__/scribe-provider-interactive.test.ts +361 -0
  31. package/src/__tests__/services-manifest.test.ts +177 -0
  32. package/src/__tests__/status.test.ts +347 -0
  33. package/src/__tests__/tailscale-commands.test.ts +111 -0
  34. package/src/__tests__/tailscale-detect.test.ts +64 -0
  35. package/src/__tests__/vault-auth-status.test.ts +164 -0
  36. package/src/__tests__/vault-tokens-create-interactive.test.ts +183 -0
  37. package/src/__tests__/well-known.test.ts +214 -0
  38. package/src/auto-wire.ts +184 -0
  39. package/src/cli.ts +482 -0
  40. package/src/cloudflare/config.ts +58 -0
  41. package/src/cloudflare/detect.ts +58 -0
  42. package/src/cloudflare/state.ts +96 -0
  43. package/src/cloudflare/tunnel.ts +135 -0
  44. package/src/commands/auth.ts +69 -0
  45. package/src/commands/expose-auth-preflight.ts +217 -0
  46. package/src/commands/expose-cloudflare.ts +329 -0
  47. package/src/commands/expose-interactive.ts +428 -0
  48. package/src/commands/expose-off-auto.ts +199 -0
  49. package/src/commands/expose.ts +522 -0
  50. package/src/commands/install.ts +422 -0
  51. package/src/commands/lifecycle.ts +324 -0
  52. package/src/commands/migrate.ts +253 -0
  53. package/src/commands/scribe-provider-interactive.ts +269 -0
  54. package/src/commands/status.ts +238 -0
  55. package/src/commands/vault-tokens-create-interactive.ts +137 -0
  56. package/src/commands/vault.ts +17 -0
  57. package/src/config.ts +16 -0
  58. package/src/env-file.ts +76 -0
  59. package/src/expose-last-provider.ts +71 -0
  60. package/src/expose-state.ts +125 -0
  61. package/src/help.ts +279 -0
  62. package/src/hub-control.ts +254 -0
  63. package/src/hub-origin.ts +44 -0
  64. package/src/hub-server.ts +113 -0
  65. package/src/hub.ts +674 -0
  66. package/src/notes-serve.ts +135 -0
  67. package/src/port-assign.ts +125 -0
  68. package/src/process-state.ts +111 -0
  69. package/src/scribe-config.ts +149 -0
  70. package/src/service-spec.ts +296 -0
  71. package/src/services-manifest.ts +171 -0
  72. package/src/tailscale/commands.ts +41 -0
  73. package/src/tailscale/detect.ts +107 -0
  74. package/src/tailscale/run.ts +28 -0
  75. package/src/vault/auth-status.ts +179 -0
  76. package/src/well-known.ts +127 -0
@@ -0,0 +1,346 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ type HubPortProbe,
7
+ type HubSpawner,
8
+ clearHubPort,
9
+ ensureHubRunning,
10
+ hubPortPath,
11
+ readHubPort,
12
+ stopHub,
13
+ writeHubPort,
14
+ } from "../hub-control.ts";
15
+ import { pidPath, readPid, writePid } from "../process-state.ts";
16
+
17
+ interface Harness {
18
+ configDir: string;
19
+ wellKnownDir: string;
20
+ cleanup: () => void;
21
+ }
22
+
23
+ function makeHarness(): Harness {
24
+ const dir = mkdtempSync(join(tmpdir(), "pcli-hub-ctl-"));
25
+ return {
26
+ configDir: dir,
27
+ wellKnownDir: join(dir, "well-known"),
28
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
29
+ };
30
+ }
31
+
32
+ interface SpawnerStub {
33
+ spawn: HubSpawner["spawn"];
34
+ calls: Array<{ cmd: readonly string[]; logFile: string }>;
35
+ }
36
+
37
+ function makeSpawner(pid: number): SpawnerStub {
38
+ const calls: Array<{ cmd: readonly string[]; logFile: string }> = [];
39
+ return {
40
+ calls,
41
+ spawn(cmd, logFile) {
42
+ calls.push({ cmd: [...cmd], logFile });
43
+ return pid;
44
+ },
45
+ };
46
+ }
47
+
48
+ /** Probe that claims every port in a set is taken. */
49
+ function probeTaken(taken: Set<number>): HubPortProbe {
50
+ return async (p) => !taken.has(p);
51
+ }
52
+
53
+ describe("port persistence helpers", () => {
54
+ test("writeHubPort + readHubPort round-trip", () => {
55
+ const h = makeHarness();
56
+ try {
57
+ writeHubPort(1942, h.configDir);
58
+ expect(readHubPort(h.configDir)).toBe(1942);
59
+ expect(existsSync(hubPortPath(h.configDir))).toBe(true);
60
+ clearHubPort(h.configDir);
61
+ expect(readHubPort(h.configDir)).toBeUndefined();
62
+ } finally {
63
+ h.cleanup();
64
+ }
65
+ });
66
+ });
67
+
68
+ describe("ensureHubRunning", () => {
69
+ test("spawns with --port + --well-known-dir, writes pid + port files", async () => {
70
+ const h = makeHarness();
71
+ try {
72
+ const spawner = makeSpawner(5555);
73
+ const result = await ensureHubRunning({
74
+ configDir: h.configDir,
75
+ wellKnownDir: h.wellKnownDir,
76
+ spawner,
77
+ alive: () => true,
78
+ probe: probeTaken(new Set()),
79
+ readyWaitMs: 0,
80
+ });
81
+ expect(result.started).toBe(true);
82
+ expect(result.pid).toBe(5555);
83
+ expect(result.port).toBe(1939);
84
+ expect(spawner.calls).toHaveLength(1);
85
+ const cmd = spawner.calls[0]?.cmd ?? [];
86
+ expect(cmd[0]).toBe("bun");
87
+ expect(cmd).toContain("--port");
88
+ expect(cmd).toContain("1939");
89
+ expect(cmd).toContain("--well-known-dir");
90
+ expect(cmd).toContain(h.wellKnownDir);
91
+ expect(readPid("hub", h.configDir)).toBe(5555);
92
+ expect(readHubPort(h.configDir)).toBe(1939);
93
+ } finally {
94
+ h.cleanup();
95
+ }
96
+ });
97
+
98
+ test("default fallback is 1 slot: fails when 1939 is taken", async () => {
99
+ // Canonical layout pins hub to 1939. Walking up would collide with the
100
+ // next service's slot, so the default is to fail and let the user unblock
101
+ // the port — not quietly land somewhere else.
102
+ const h = makeHarness();
103
+ try {
104
+ const spawner = makeSpawner(7777);
105
+ await expect(
106
+ ensureHubRunning({
107
+ configDir: h.configDir,
108
+ wellKnownDir: h.wellKnownDir,
109
+ spawner,
110
+ alive: () => true,
111
+ probe: probeTaken(new Set([1939])),
112
+ readyWaitMs: 0,
113
+ }),
114
+ ).rejects.toThrow(/lsof -iTCP:1939/);
115
+ } finally {
116
+ h.cleanup();
117
+ }
118
+ });
119
+
120
+ test("fallback walks up when caller widens the range (debug/tests only)", async () => {
121
+ const h = makeHarness();
122
+ try {
123
+ const spawner = makeSpawner(7777);
124
+ const result = await ensureHubRunning({
125
+ configDir: h.configDir,
126
+ wellKnownDir: h.wellKnownDir,
127
+ spawner,
128
+ alive: () => true,
129
+ probe: probeTaken(new Set([1939, 1940])),
130
+ readyWaitMs: 0,
131
+ fallbackRange: 5,
132
+ });
133
+ expect(result.port).toBe(1941);
134
+ expect(readHubPort(h.configDir)).toBe(1941);
135
+ } finally {
136
+ h.cleanup();
137
+ }
138
+ });
139
+
140
+ test("idempotent: returns existing pid + port when hub is already running", async () => {
141
+ const h = makeHarness();
142
+ try {
143
+ writePid("hub", 12345, h.configDir);
144
+ writeHubPort(1944, h.configDir);
145
+ const spawner = makeSpawner(9999);
146
+ const result = await ensureHubRunning({
147
+ configDir: h.configDir,
148
+ wellKnownDir: h.wellKnownDir,
149
+ spawner,
150
+ alive: () => true,
151
+ probe: probeTaken(new Set()),
152
+ readyWaitMs: 0,
153
+ });
154
+ expect(result.started).toBe(false);
155
+ expect(result.pid).toBe(12345);
156
+ expect(result.port).toBe(1944);
157
+ expect(spawner.calls).toHaveLength(0);
158
+ } finally {
159
+ h.cleanup();
160
+ }
161
+ });
162
+
163
+ test("stale pid (process gone) is cleared and a fresh hub is spawned", async () => {
164
+ const h = makeHarness();
165
+ try {
166
+ writePid("hub", 99, h.configDir);
167
+ writeHubPort(1939, h.configDir);
168
+ const spawner = makeSpawner(100);
169
+ const result = await ensureHubRunning({
170
+ configDir: h.configDir,
171
+ wellKnownDir: h.wellKnownDir,
172
+ spawner,
173
+ alive: () => false,
174
+ probe: probeTaken(new Set()),
175
+ readyWaitMs: 0,
176
+ });
177
+ expect(result.started).toBe(true);
178
+ expect(result.pid).toBe(100);
179
+ expect(spawner.calls).toHaveLength(1);
180
+ } finally {
181
+ h.cleanup();
182
+ }
183
+ });
184
+
185
+ test("throws when no port in the fallback range is free", async () => {
186
+ const h = makeHarness();
187
+ try {
188
+ const spawner = makeSpawner(1);
189
+ await expect(
190
+ ensureHubRunning({
191
+ configDir: h.configDir,
192
+ wellKnownDir: h.wellKnownDir,
193
+ spawner,
194
+ alive: () => true,
195
+ probe: async () => false,
196
+ readyWaitMs: 0,
197
+ fallbackRange: 3,
198
+ }),
199
+ ).rejects.toThrow(/unavailable/);
200
+ } finally {
201
+ h.cleanup();
202
+ }
203
+ });
204
+
205
+ test("skips reserved service ports during fallback (widened range)", async () => {
206
+ // Fallback is off by default (range=1). When a caller opens it up for
207
+ // debug, reservedPorts must still be honored so the hub never steals a
208
+ // registered service's slot even if the service isn't yet bound.
209
+ const h = makeHarness();
210
+ try {
211
+ const spawner = makeSpawner(3333);
212
+ const result = await ensureHubRunning({
213
+ configDir: h.configDir,
214
+ wellKnownDir: h.wellKnownDir,
215
+ spawner,
216
+ alive: () => true,
217
+ probe: probeTaken(new Set([1939])), // default port is held
218
+ reservedPorts: [1940], // vault's reservation
219
+ readyWaitMs: 0,
220
+ fallbackRange: 5,
221
+ });
222
+ // 1939 is taken, 1940 is reserved → we get 1941.
223
+ expect(result.port).toBe(1941);
224
+ expect(readHubPort(h.configDir)).toBe(1941);
225
+ } finally {
226
+ h.cleanup();
227
+ }
228
+ });
229
+
230
+ test("honors startPort override", async () => {
231
+ const h = makeHarness();
232
+ try {
233
+ const spawner = makeSpawner(2222);
234
+ const result = await ensureHubRunning({
235
+ configDir: h.configDir,
236
+ wellKnownDir: h.wellKnownDir,
237
+ spawner,
238
+ alive: () => true,
239
+ probe: probeTaken(new Set()),
240
+ readyWaitMs: 0,
241
+ startPort: 18080,
242
+ });
243
+ expect(result.port).toBe(18080);
244
+ } finally {
245
+ h.cleanup();
246
+ }
247
+ });
248
+ });
249
+
250
+ describe("stopHub", () => {
251
+ test("SIGTERMs running hub, clears pid + port", async () => {
252
+ const h = makeHarness();
253
+ try {
254
+ writePid("hub", 4242, h.configDir);
255
+ writeHubPort(1939, h.configDir);
256
+ let aliveNow = true;
257
+ const signals: NodeJS.Signals[] = [];
258
+ const stopped = await stopHub({
259
+ configDir: h.configDir,
260
+ kill: (_pid, sig) => {
261
+ signals.push(sig as NodeJS.Signals);
262
+ aliveNow = false;
263
+ },
264
+ alive: () => aliveNow,
265
+ sleep: async () => {},
266
+ now: () => 0,
267
+ });
268
+ expect(stopped).toBe(true);
269
+ expect(signals).toEqual(["SIGTERM"]);
270
+ expect(existsSync(pidPath("hub", h.configDir))).toBe(false);
271
+ expect(readHubPort(h.configDir)).toBeUndefined();
272
+ } finally {
273
+ h.cleanup();
274
+ }
275
+ });
276
+
277
+ test("escalates to SIGKILL when SIGTERM doesn't land", async () => {
278
+ const h = makeHarness();
279
+ try {
280
+ writePid("hub", 4242, h.configDir);
281
+ writeHubPort(1939, h.configDir);
282
+ let t = 0;
283
+ const signals: NodeJS.Signals[] = [];
284
+ const stopped = await stopHub({
285
+ configDir: h.configDir,
286
+ kill: (_pid, sig) => {
287
+ signals.push(sig as NodeJS.Signals);
288
+ },
289
+ alive: () => true,
290
+ sleep: async () => {
291
+ t += 1000;
292
+ },
293
+ now: () => t,
294
+ killWaitMs: 100,
295
+ pollIntervalMs: 10,
296
+ });
297
+ expect(stopped).toBe(true);
298
+ expect(signals).toEqual(["SIGTERM", "SIGKILL"]);
299
+ } finally {
300
+ h.cleanup();
301
+ }
302
+ });
303
+
304
+ test("no-op + cleans port file when no pid recorded", async () => {
305
+ const h = makeHarness();
306
+ try {
307
+ writeHubPort(1939, h.configDir);
308
+ const stopped = await stopHub({
309
+ configDir: h.configDir,
310
+ kill: () => {
311
+ throw new Error("must not be called");
312
+ },
313
+ alive: () => true,
314
+ sleep: async () => {},
315
+ now: () => 0,
316
+ });
317
+ expect(stopped).toBe(false);
318
+ expect(readHubPort(h.configDir)).toBeUndefined();
319
+ } finally {
320
+ h.cleanup();
321
+ }
322
+ });
323
+
324
+ test("stale pid (process already gone) clears state without killing", async () => {
325
+ const h = makeHarness();
326
+ try {
327
+ writePid("hub", 77, h.configDir);
328
+ writeHubPort(1939, h.configDir);
329
+ let killCalled = false;
330
+ const stopped = await stopHub({
331
+ configDir: h.configDir,
332
+ kill: () => {
333
+ killCalled = true;
334
+ },
335
+ alive: () => false,
336
+ sleep: async () => {},
337
+ now: () => 0,
338
+ });
339
+ expect(stopped).toBe(false);
340
+ expect(killCalled).toBe(false);
341
+ expect(existsSync(pidPath("hub", h.configDir))).toBe(false);
342
+ } finally {
343
+ h.cleanup();
344
+ }
345
+ });
346
+ });
@@ -0,0 +1,157 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { hubFetch } from "../hub-server.ts";
6
+
7
+ interface Harness {
8
+ dir: string;
9
+ cleanup: () => void;
10
+ }
11
+
12
+ function makeHarness(): Harness {
13
+ const dir = mkdtempSync(join(tmpdir(), "pcli-hub-server-"));
14
+ return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
15
+ }
16
+
17
+ function req(path: string, init?: RequestInit): Request {
18
+ return new Request(`http://127.0.0.1/${path.replace(/^\//, "")}`, init);
19
+ }
20
+
21
+ describe("hubFetch routing", () => {
22
+ test("/ serves hub.html with text/html content-type", async () => {
23
+ const h = makeHarness();
24
+ try {
25
+ writeFileSync(join(h.dir, "hub.html"), "<html><body>hi</body></html>");
26
+ const res = hubFetch(h.dir)(req("/"));
27
+ expect(res.status).toBe(200);
28
+ expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
29
+ expect(await res.text()).toContain("<html>");
30
+ } finally {
31
+ h.cleanup();
32
+ }
33
+ });
34
+
35
+ test("/hub.html serves the same file as /", async () => {
36
+ const h = makeHarness();
37
+ try {
38
+ writeFileSync(join(h.dir, "hub.html"), "<html>x</html>");
39
+ const res = hubFetch(h.dir)(req("/hub.html"));
40
+ expect(res.status).toBe(200);
41
+ expect(await res.text()).toBe("<html>x</html>");
42
+ } finally {
43
+ h.cleanup();
44
+ }
45
+ });
46
+
47
+ test("/.well-known/parachute.json serves JSON with application/json", async () => {
48
+ const h = makeHarness();
49
+ try {
50
+ writeFileSync(join(h.dir, "parachute.json"), '{"vaults":[]}\n');
51
+ const res = hubFetch(h.dir)(req("/.well-known/parachute.json"));
52
+ expect(res.status).toBe(200);
53
+ expect(res.headers.get("content-type")).toBe("application/json");
54
+ expect(await res.text()).toBe('{"vaults":[]}\n');
55
+ } finally {
56
+ h.cleanup();
57
+ }
58
+ });
59
+
60
+ // CORS on the well-known doc: browsers running the Notes UI on
61
+ // http://localhost:1942 fetch this manifest cross-origin to auto-discover
62
+ // the user's vault. Without these headers the browser blocks the response
63
+ // body and the auto-discover flow silently falls back to manual paste.
64
+ // The doc itself is public (no secrets, no PII), so wildcard origin is OK.
65
+ test("/.well-known/parachute.json includes wildcard CORS headers on GET", async () => {
66
+ const h = makeHarness();
67
+ try {
68
+ writeFileSync(join(h.dir, "parachute.json"), '{"vaults":[]}\n');
69
+ const res = hubFetch(h.dir)(req("/.well-known/parachute.json"));
70
+ expect(res.status).toBe(200);
71
+ expect(res.headers.get("access-control-allow-origin")).toBe("*");
72
+ expect(res.headers.get("access-control-allow-methods")).toBe("GET, OPTIONS");
73
+ } finally {
74
+ h.cleanup();
75
+ }
76
+ });
77
+
78
+ test("OPTIONS preflight on /.well-known/parachute.json returns 204 + CORS", async () => {
79
+ const h = makeHarness();
80
+ try {
81
+ // Note: no parachute.json on disk — preflight must not depend on it.
82
+ const res = hubFetch(h.dir)(req("/.well-known/parachute.json", { method: "OPTIONS" }));
83
+ expect(res.status).toBe(204);
84
+ expect(res.headers.get("access-control-allow-origin")).toBe("*");
85
+ expect(res.headers.get("access-control-allow-methods")).toBe("GET, OPTIONS");
86
+ } finally {
87
+ h.cleanup();
88
+ }
89
+ });
90
+
91
+ test("missing parachute.json still returns CORS headers on the 404", async () => {
92
+ // If the response 404s without CORS, the browser treats it as a network
93
+ // error and the consumer can't even tell the server is reachable.
94
+ const h = makeHarness();
95
+ try {
96
+ const res = hubFetch(h.dir)(req("/.well-known/parachute.json"));
97
+ expect(res.status).toBe(404);
98
+ expect(res.headers.get("access-control-allow-origin")).toBe("*");
99
+ } finally {
100
+ h.cleanup();
101
+ }
102
+ });
103
+
104
+ test("unknown paths return 404", async () => {
105
+ const h = makeHarness();
106
+ try {
107
+ writeFileSync(join(h.dir, "hub.html"), "<html/>");
108
+ const res = hubFetch(h.dir)(req("/nope"));
109
+ expect(res.status).toBe(404);
110
+ } finally {
111
+ h.cleanup();
112
+ }
113
+ });
114
+
115
+ test("missing hub.html returns 404 rather than crashing", async () => {
116
+ const h = makeHarness();
117
+ try {
118
+ // dir exists but no files in it
119
+ const res = hubFetch(h.dir)(req("/"));
120
+ expect(res.status).toBe(404);
121
+ } finally {
122
+ h.cleanup();
123
+ }
124
+ });
125
+
126
+ test("missing parachute.json returns 404 rather than crashing", async () => {
127
+ const h = makeHarness();
128
+ try {
129
+ const res = hubFetch(h.dir)(req("/.well-known/parachute.json"));
130
+ expect(res.status).toBe(404);
131
+ } finally {
132
+ h.cleanup();
133
+ }
134
+ });
135
+
136
+ test("live Bun.serve round-trip: / and /.well-known resolve", async () => {
137
+ const h = makeHarness();
138
+ try {
139
+ writeFileSync(join(h.dir, "hub.html"), "<html>live</html>");
140
+ writeFileSync(join(h.dir, "parachute.json"), '{"services":[]}');
141
+ const server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: hubFetch(h.dir) });
142
+ try {
143
+ const base = `http://127.0.0.1:${server.port}`;
144
+ const r1 = await fetch(`${base}/`);
145
+ expect(r1.status).toBe(200);
146
+ expect(await r1.text()).toBe("<html>live</html>");
147
+ const r2 = await fetch(`${base}/.well-known/parachute.json`);
148
+ expect(r2.headers.get("content-type")).toBe("application/json");
149
+ expect(await r2.json()).toEqual({ services: [] });
150
+ } finally {
151
+ server.stop(true);
152
+ }
153
+ } finally {
154
+ h.cleanup();
155
+ }
156
+ });
157
+ });
@@ -0,0 +1,116 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { renderHub, writeHubFile } from "../hub.ts";
6
+
7
+ describe("renderHub", () => {
8
+ const html = renderHub();
9
+
10
+ test("is a self-contained HTML document with inline styles and script", () => {
11
+ expect(html).toStartWith("<!doctype html>");
12
+ expect(html).toContain("<style>");
13
+ expect(html).toContain("<script>");
14
+ });
15
+
16
+ test("fetches /.well-known/parachute.json and iterates services[]", () => {
17
+ expect(html).toContain("/.well-known/parachute.json");
18
+ expect(html).toContain("doc.services");
19
+ expect(html).toContain("infoUrl");
20
+ });
21
+
22
+ test("uses parachute.computer sage palette and serif/sans fonts", () => {
23
+ expect(html).toContain("#4a7c59");
24
+ expect(html).toContain("#faf8f4");
25
+ expect(html).toContain("Instrument Serif");
26
+ expect(html).toContain("DM Sans");
27
+ });
28
+
29
+ test("supports prefers-color-scheme dark", () => {
30
+ expect(html).toContain("prefers-color-scheme: dark");
31
+ });
32
+
33
+ test("falls back to a generic icon when service has none", () => {
34
+ expect(html).toContain("fallbackIcon");
35
+ });
36
+
37
+ test("branches card rendering on info.kind (api/tool → interactive, else link)", () => {
38
+ // Script picks the element type and wires up toggling based on info.kind.
39
+ expect(html).toContain("isInteractiveKind");
40
+ expect(html).toContain("'api'");
41
+ expect(html).toContain("'tool'");
42
+ expect(html).toContain("'frontend'");
43
+ });
44
+
45
+ test("interactive cards get keyboard + aria affordances", () => {
46
+ expect(html).toContain("role");
47
+ expect(html).toContain("tabindex");
48
+ expect(html).toContain("aria-expanded");
49
+ expect(html).toContain("Enter");
50
+ });
51
+
52
+ test("detail panel surfaces OAuth discovery, MCP, open-in-Notes, service URL", () => {
53
+ expect(html).toContain("/.well-known/oauth-authorization-server");
54
+ expect(html).toContain("info.mcpUrl");
55
+ expect(html).toContain("info.openInNotesUrl");
56
+ expect(html).toContain("Service URL");
57
+ expect(html).toContain("OAuth discovery");
58
+ });
59
+
60
+ test("details panel is hidden until the card is expanded", () => {
61
+ expect(html).toContain(".details {");
62
+ expect(html).toContain("display: none");
63
+ expect(html).toContain(".card.expanded .details");
64
+ });
65
+
66
+ test("lazy-fetches /.parachute/config/schema + /.parachute/config on first expand", () => {
67
+ expect(html).toContain("fetchConfig");
68
+ expect(html).toContain("/.parachute/config/schema");
69
+ expect(html).toContain("/.parachute/config");
70
+ // Lazy: fetch happens inside the toggle, guarded by configLoaded.
71
+ expect(html).toContain("configLoaded");
72
+ });
73
+
74
+ test("renders config form fields with disabled inputs (read-only in this launch)", () => {
75
+ expect(html).toContain("renderConfigField");
76
+ expect(html).toContain("input.disabled = true");
77
+ expect(html).toContain("aria-readonly");
78
+ // Hint text tells users where to edit instead.
79
+ expect(html).toContain("read-only in this launch");
80
+ });
81
+
82
+ test("config field types: enum→select, boolean→checkbox, number→number, uri→url", () => {
83
+ expect(html).toContain("schema.enum");
84
+ expect(html).toContain("'checkbox'");
85
+ expect(html).toContain("'number'");
86
+ expect(html).toContain("'url'");
87
+ });
88
+
89
+ test("writeOnly fields render a bullet placeholder instead of the raw value", () => {
90
+ expect(html).toContain("writeOnly");
91
+ // Six bullets as the placeholder (template literal resolves \u2022 → •).
92
+ expect(html).toContain("\u2022\u2022\u2022\u2022\u2022\u2022");
93
+ });
94
+
95
+ test("schema 404 path renders nothing (no error surfaced)", () => {
96
+ // fetchConfig returns null on non-ok; caller skips render.
97
+ expect(html).toContain("if (!schemaResp || !schemaResp.ok) return null");
98
+ expect(html).toContain("if (data)");
99
+ });
100
+ });
101
+
102
+ describe("writeHubFile", () => {
103
+ test("writes the rendered HTML to the given path, creating parent dirs", () => {
104
+ const dir = mkdtempSync(join(tmpdir(), "pcli-hub-"));
105
+ try {
106
+ const path = join(dir, "well-known", "hub.html");
107
+ const written = writeHubFile(path);
108
+ expect(written).toBe(path);
109
+ expect(existsSync(path)).toBe(true);
110
+ const content = readFileSync(path, "utf8");
111
+ expect(content).toBe(renderHub());
112
+ } finally {
113
+ rmSync(dir, { recursive: true, force: true });
114
+ }
115
+ });
116
+ });