@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,361 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ type InteractiveAvailability,
7
+ setupScribeProvider,
8
+ } from "../commands/scribe-provider-interactive.ts";
9
+ import { writePid } from "../process-state.ts";
10
+ import { scribeConfigPath, scribeEnvPath } from "../scribe-config.ts";
11
+
12
+ function makeHarness(): { dir: string; cleanup: () => void } {
13
+ const dir = mkdtempSync(join(tmpdir(), "pcli-scribepick-"));
14
+ return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
15
+ }
16
+
17
+ interface Stub {
18
+ availability: InteractiveAvailability;
19
+ asked: string[];
20
+ }
21
+
22
+ function scriptedAvailability(answers: string[]): Stub {
23
+ const asked: string[] = [];
24
+ let i = 0;
25
+ return {
26
+ asked,
27
+ availability: {
28
+ kind: "available",
29
+ prompt: async (q: string) => {
30
+ asked.push(q);
31
+ const next = answers[i++];
32
+ if (next === undefined) throw new Error(`prompt asked more than scripted (${q})`);
33
+ return next;
34
+ },
35
+ },
36
+ };
37
+ }
38
+
39
+ describe("setupScribeProvider — preselected flag path", () => {
40
+ test("--scribe-provider groq + --scribe-key writes both files, no prompt", async () => {
41
+ const h = makeHarness();
42
+ try {
43
+ const logs: string[] = [];
44
+ const stub = scriptedAvailability([]);
45
+ const result = await setupScribeProvider({
46
+ configDir: h.dir,
47
+ log: (l) => logs.push(l),
48
+ preselectProvider: "groq",
49
+ preselectKey: "gsk_abc123",
50
+ availability: stub.availability,
51
+ alive: () => false,
52
+ restartService: async () => 0,
53
+ });
54
+
55
+ expect(result.configured).toBe(true);
56
+ expect(result.provider).toBe("groq");
57
+ expect(result.wroteApiKey).toBe(true);
58
+ expect(result.skippedReason).toBe("preselected");
59
+ expect(stub.asked).toEqual([]);
60
+
61
+ const cfg = JSON.parse(readFileSync(scribeConfigPath(h.dir), "utf8"));
62
+ expect(cfg.transcribe).toEqual({ provider: "groq" });
63
+ expect(readFileSync(scribeEnvPath(h.dir), "utf8")).toContain("GROQ_API_KEY=gsk_abc123");
64
+ } finally {
65
+ h.cleanup();
66
+ }
67
+ });
68
+
69
+ test("--scribe-provider with local provider does not write a key even if --scribe-key passed", async () => {
70
+ const h = makeHarness();
71
+ try {
72
+ const result = await setupScribeProvider({
73
+ configDir: h.dir,
74
+ preselectProvider: "parakeet-mlx",
75
+ preselectKey: "should-be-ignored",
76
+ availability: { kind: "not-tty" },
77
+ alive: () => false,
78
+ restartService: async () => 0,
79
+ });
80
+ expect(result.configured).toBe(true);
81
+ expect(result.wroteApiKey).toBe(false);
82
+ expect(existsSync(scribeEnvPath(h.dir))).toBe(false);
83
+ } finally {
84
+ h.cleanup();
85
+ }
86
+ });
87
+
88
+ test("unknown --scribe-provider logs a warning and leaves config alone", async () => {
89
+ const h = makeHarness();
90
+ try {
91
+ const logs: string[] = [];
92
+ const result = await setupScribeProvider({
93
+ configDir: h.dir,
94
+ log: (l) => logs.push(l),
95
+ preselectProvider: "cloudflare",
96
+ availability: { kind: "not-tty" },
97
+ alive: () => false,
98
+ restartService: async () => 0,
99
+ });
100
+ expect(result.configured).toBe(false);
101
+ expect(result.skippedReason).toBe("preselected");
102
+ expect(existsSync(scribeConfigPath(h.dir))).toBe(false);
103
+ expect(logs.join("\n")).toMatch(/unknown --scribe-provider/);
104
+ } finally {
105
+ h.cleanup();
106
+ }
107
+ });
108
+ });
109
+
110
+ describe("setupScribeProvider — detect-skip", () => {
111
+ test("config with non-default provider already set: leave alone", async () => {
112
+ const h = makeHarness();
113
+ try {
114
+ mkdirSync(join(h.dir, "scribe"), { recursive: true });
115
+ writeFileSync(
116
+ scribeConfigPath(h.dir),
117
+ JSON.stringify({ transcribe: { provider: "openai" } }),
118
+ );
119
+ const logs: string[] = [];
120
+ const stub = scriptedAvailability([]);
121
+ const result = await setupScribeProvider({
122
+ configDir: h.dir,
123
+ log: (l) => logs.push(l),
124
+ availability: stub.availability,
125
+ alive: () => false,
126
+ restartService: async () => 0,
127
+ });
128
+ expect(result.configured).toBe(false);
129
+ expect(result.skippedReason).toBe("already-configured");
130
+ expect(stub.asked).toEqual([]);
131
+ expect(logs.join("\n")).toMatch(/already set to "openai"/);
132
+ } finally {
133
+ h.cleanup();
134
+ }
135
+ });
136
+
137
+ test("config with default provider parakeet-mlx still re-prompts", async () => {
138
+ const h = makeHarness();
139
+ try {
140
+ mkdirSync(join(h.dir, "scribe"), { recursive: true });
141
+ writeFileSync(
142
+ scribeConfigPath(h.dir),
143
+ JSON.stringify({ transcribe: { provider: "parakeet-mlx" } }),
144
+ );
145
+ const stub = scriptedAvailability(["s"]);
146
+ const result = await setupScribeProvider({
147
+ configDir: h.dir,
148
+ availability: stub.availability,
149
+ alive: () => false,
150
+ restartService: async () => 0,
151
+ });
152
+ expect(result.skippedReason).toBeUndefined();
153
+ // User skipped → nothing written.
154
+ expect(result.configured).toBe(false);
155
+ } finally {
156
+ h.cleanup();
157
+ }
158
+ });
159
+ });
160
+
161
+ describe("setupScribeProvider — non-TTY", () => {
162
+ test("no flag, no TTY: skips silently with non-interactive reason", async () => {
163
+ const h = makeHarness();
164
+ try {
165
+ const result = await setupScribeProvider({
166
+ configDir: h.dir,
167
+ availability: { kind: "not-tty" },
168
+ alive: () => false,
169
+ restartService: async () => 0,
170
+ });
171
+ expect(result.configured).toBe(false);
172
+ expect(result.skippedReason).toBe("non-interactive");
173
+ expect(existsSync(scribeConfigPath(h.dir))).toBe(false);
174
+ } finally {
175
+ h.cleanup();
176
+ }
177
+ });
178
+ });
179
+
180
+ describe("setupScribeProvider — interactive prompt", () => {
181
+ test("number selection chooses provider, then prompts for API key", async () => {
182
+ const h = makeHarness();
183
+ try {
184
+ const stub = scriptedAvailability(["4", "gsk_picked"]);
185
+ const result = await setupScribeProvider({
186
+ configDir: h.dir,
187
+ availability: stub.availability,
188
+ alive: () => false,
189
+ restartService: async () => 0,
190
+ });
191
+ expect(result.provider).toBe("groq");
192
+ expect(result.wroteApiKey).toBe(true);
193
+ expect(stub.asked.length).toBe(2);
194
+ expect(stub.asked[1]).toMatch(/GROQ_API_KEY/);
195
+ } finally {
196
+ h.cleanup();
197
+ }
198
+ });
199
+
200
+ test("name selection works (case-insensitive)", async () => {
201
+ const h = makeHarness();
202
+ try {
203
+ const stub = scriptedAvailability(["OpenAI", "sk-x"]);
204
+ const result = await setupScribeProvider({
205
+ configDir: h.dir,
206
+ availability: stub.availability,
207
+ alive: () => false,
208
+ restartService: async () => 0,
209
+ });
210
+ expect(result.provider).toBe("openai");
211
+ expect(result.wroteApiKey).toBe(true);
212
+ } finally {
213
+ h.cleanup();
214
+ }
215
+ });
216
+
217
+ test("local provider chosen: no key prompt", async () => {
218
+ const h = makeHarness();
219
+ try {
220
+ const stub = scriptedAvailability(["parakeet-mlx"]);
221
+ const result = await setupScribeProvider({
222
+ configDir: h.dir,
223
+ availability: stub.availability,
224
+ alive: () => false,
225
+ restartService: async () => 0,
226
+ });
227
+ expect(result.provider).toBe("parakeet-mlx");
228
+ expect(result.wroteApiKey).toBe(false);
229
+ expect(stub.asked.length).toBe(1);
230
+ } finally {
231
+ h.cleanup();
232
+ }
233
+ });
234
+
235
+ test("'s' / skip / blank exits the picker without writing", async () => {
236
+ const h = makeHarness();
237
+ try {
238
+ const stub = scriptedAvailability(["s"]);
239
+ const result = await setupScribeProvider({
240
+ configDir: h.dir,
241
+ availability: stub.availability,
242
+ alive: () => false,
243
+ restartService: async () => 0,
244
+ });
245
+ expect(result.configured).toBe(false);
246
+ expect(result.provider).toBeUndefined();
247
+ expect(existsSync(scribeConfigPath(h.dir))).toBe(false);
248
+ } finally {
249
+ h.cleanup();
250
+ }
251
+ });
252
+
253
+ test("retries on garbage then accepts a valid pick", async () => {
254
+ const h = makeHarness();
255
+ try {
256
+ const logs: string[] = [];
257
+ const stub = scriptedAvailability(["nope", "9999", "1"]);
258
+ const result = await setupScribeProvider({
259
+ configDir: h.dir,
260
+ log: (l) => logs.push(l),
261
+ availability: stub.availability,
262
+ alive: () => false,
263
+ restartService: async () => 0,
264
+ });
265
+ expect(result.provider).toBe("parakeet-mlx");
266
+ expect(stub.asked.length).toBe(3);
267
+ expect(logs.filter((l) => /Try again/.test(l)).length).toBe(2);
268
+ } finally {
269
+ h.cleanup();
270
+ }
271
+ });
272
+
273
+ test("blank API key answer logs hint and leaves env file untouched", async () => {
274
+ const h = makeHarness();
275
+ try {
276
+ const logs: string[] = [];
277
+ const stub = scriptedAvailability(["groq", ""]);
278
+ const result = await setupScribeProvider({
279
+ configDir: h.dir,
280
+ log: (l) => logs.push(l),
281
+ availability: stub.availability,
282
+ alive: () => false,
283
+ restartService: async () => 0,
284
+ });
285
+ expect(result.provider).toBe("groq");
286
+ expect(result.wroteApiKey).toBe(false);
287
+ expect(existsSync(scribeEnvPath(h.dir))).toBe(false);
288
+ expect(logs.join("\n")).toMatch(/Skipped GROQ_API_KEY/);
289
+ } finally {
290
+ h.cleanup();
291
+ }
292
+ });
293
+ });
294
+
295
+ describe("setupScribeProvider — restart on running scribe", () => {
296
+ test("running scribe → restart called", async () => {
297
+ const h = makeHarness();
298
+ try {
299
+ writePid("scribe", 4321, h.dir);
300
+ const restartCalls: string[] = [];
301
+ const result = await setupScribeProvider({
302
+ configDir: h.dir,
303
+ preselectProvider: "groq",
304
+ preselectKey: "gsk_x",
305
+ availability: { kind: "not-tty" },
306
+ alive: (pid) => pid === 4321,
307
+ restartService: async (svc) => {
308
+ restartCalls.push(svc);
309
+ return 0;
310
+ },
311
+ });
312
+ expect(result.restartedScribe).toBe(true);
313
+ expect(restartCalls).toEqual(["scribe"]);
314
+ } finally {
315
+ h.cleanup();
316
+ }
317
+ });
318
+
319
+ test("running scribe but restart fails: log warning, do not throw", async () => {
320
+ const h = makeHarness();
321
+ try {
322
+ writePid("scribe", 4321, h.dir);
323
+ const logs: string[] = [];
324
+ const result = await setupScribeProvider({
325
+ configDir: h.dir,
326
+ log: (l) => logs.push(l),
327
+ preselectProvider: "groq",
328
+ preselectKey: "gsk_x",
329
+ availability: { kind: "not-tty" },
330
+ alive: (pid) => pid === 4321,
331
+ restartService: async () => 1,
332
+ });
333
+ expect(result.restartedScribe).toBe(false);
334
+ expect(logs.join("\n")).toMatch(/scribe restart failed/);
335
+ } finally {
336
+ h.cleanup();
337
+ }
338
+ });
339
+
340
+ test("scribe not running: no restart attempt", async () => {
341
+ const h = makeHarness();
342
+ try {
343
+ let called = false;
344
+ const result = await setupScribeProvider({
345
+ configDir: h.dir,
346
+ preselectProvider: "groq",
347
+ preselectKey: "gsk_x",
348
+ availability: { kind: "not-tty" },
349
+ alive: () => false,
350
+ restartService: async () => {
351
+ called = true;
352
+ return 0;
353
+ },
354
+ });
355
+ expect(result.restartedScribe).toBe(false);
356
+ expect(called).toBe(false);
357
+ } finally {
358
+ h.cleanup();
359
+ }
360
+ });
361
+ });
@@ -0,0 +1,177 @@
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 {
6
+ type ServiceEntry,
7
+ ServicesManifestError,
8
+ findService,
9
+ readManifest,
10
+ removeService,
11
+ upsertService,
12
+ writeManifest,
13
+ } from "../services-manifest.ts";
14
+
15
+ function makeTempPath(): { path: string; cleanup: () => void } {
16
+ const dir = mkdtempSync(join(tmpdir(), "pcli-"));
17
+ const path = join(dir, "services.json");
18
+ return { path, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
19
+ }
20
+
21
+ const vault: ServiceEntry = {
22
+ name: "parachute-vault",
23
+ port: 1940,
24
+ paths: ["/"],
25
+ health: "/health",
26
+ version: "0.2.4",
27
+ };
28
+
29
+ const notes: ServiceEntry = {
30
+ name: "parachute-notes",
31
+ port: 5173,
32
+ paths: ["/notes"],
33
+ health: "/notes/health",
34
+ version: "0.0.1",
35
+ };
36
+
37
+ describe("services-manifest", () => {
38
+ test("readManifest returns empty when file missing", () => {
39
+ const { path, cleanup } = makeTempPath();
40
+ try {
41
+ expect(readManifest(path)).toEqual({ services: [] });
42
+ } finally {
43
+ cleanup();
44
+ }
45
+ });
46
+
47
+ test("writeManifest + readManifest round-trip", () => {
48
+ const { path, cleanup } = makeTempPath();
49
+ try {
50
+ writeManifest({ services: [vault] }, path);
51
+ expect(readManifest(path)).toEqual({ services: [vault] });
52
+ } finally {
53
+ cleanup();
54
+ }
55
+ });
56
+
57
+ test("upsertService adds a new entry", () => {
58
+ const { path, cleanup } = makeTempPath();
59
+ try {
60
+ const m = upsertService(vault, path);
61
+ expect(m.services).toHaveLength(1);
62
+ expect(m.services[0]).toEqual(vault);
63
+ expect(readManifest(path)).toEqual(m);
64
+ } finally {
65
+ cleanup();
66
+ }
67
+ });
68
+
69
+ test("upsertService updates by name, never duplicates", () => {
70
+ const { path, cleanup } = makeTempPath();
71
+ try {
72
+ upsertService(vault, path);
73
+ upsertService({ ...vault, version: "0.3.0", port: 1941 }, path);
74
+ const m = readManifest(path);
75
+ expect(m.services).toHaveLength(1);
76
+ expect(m.services[0]?.version).toBe("0.3.0");
77
+ expect(m.services[0]?.port).toBe(1941);
78
+ } finally {
79
+ cleanup();
80
+ }
81
+ });
82
+
83
+ test("upsertService preserves other services", () => {
84
+ const { path, cleanup } = makeTempPath();
85
+ try {
86
+ upsertService(vault, path);
87
+ upsertService(notes, path);
88
+ const m = readManifest(path);
89
+ expect(m.services).toHaveLength(2);
90
+ expect(m.services.map((s) => s.name).sort()).toEqual(["parachute-notes", "parachute-vault"]);
91
+ } finally {
92
+ cleanup();
93
+ }
94
+ });
95
+
96
+ test("removeService drops entry by name", () => {
97
+ const { path, cleanup } = makeTempPath();
98
+ try {
99
+ upsertService(vault, path);
100
+ upsertService(notes, path);
101
+ removeService("parachute-vault", path);
102
+ const m = readManifest(path);
103
+ expect(m.services).toHaveLength(1);
104
+ expect(m.services[0]?.name).toBe("parachute-notes");
105
+ } finally {
106
+ cleanup();
107
+ }
108
+ });
109
+
110
+ test("findService returns entry or undefined", () => {
111
+ const { path, cleanup } = makeTempPath();
112
+ try {
113
+ upsertService(vault, path);
114
+ expect(findService("parachute-vault", path)).toEqual(vault);
115
+ expect(findService("parachute-none", path)).toBeUndefined();
116
+ } finally {
117
+ cleanup();
118
+ }
119
+ });
120
+
121
+ test("readManifest throws on invalid JSON", () => {
122
+ const { path, cleanup } = makeTempPath();
123
+ try {
124
+ writeFileSync(path, "{ not json");
125
+ expect(() => readManifest(path)).toThrow(ServicesManifestError);
126
+ } finally {
127
+ cleanup();
128
+ }
129
+ });
130
+
131
+ test("readManifest throws on malformed entry", () => {
132
+ const { path, cleanup } = makeTempPath();
133
+ try {
134
+ writeFileSync(path, JSON.stringify({ services: [{ name: "x" }] }));
135
+ expect(() => readManifest(path)).toThrow(/port/);
136
+ } finally {
137
+ cleanup();
138
+ }
139
+ });
140
+
141
+ test("upsertService validates entry", () => {
142
+ const { path, cleanup } = makeTempPath();
143
+ try {
144
+ expect(() => upsertService({ ...vault, port: 99999 } as ServiceEntry, path)).toThrow(
145
+ ServicesManifestError,
146
+ );
147
+ } finally {
148
+ cleanup();
149
+ }
150
+ });
151
+
152
+ test("round-trips optional displayName and tagline", () => {
153
+ const { path, cleanup } = makeTempPath();
154
+ try {
155
+ const full: ServiceEntry = {
156
+ ...vault,
157
+ displayName: "Vault",
158
+ tagline: "Your notes, sovereign",
159
+ };
160
+ upsertService(full, path);
161
+ expect(readManifest(path).services[0]).toEqual(full);
162
+ } finally {
163
+ cleanup();
164
+ }
165
+ });
166
+
167
+ test("rejects non-string displayName", () => {
168
+ const { path, cleanup } = makeTempPath();
169
+ try {
170
+ expect(() => upsertService({ ...vault, displayName: 42 as unknown as string }, path)).toThrow(
171
+ /displayName/,
172
+ );
173
+ } finally {
174
+ cleanup();
175
+ }
176
+ });
177
+ });