@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,422 @@
1
+ import { lstatSync, readFileSync } from "node:fs";
2
+ import { createConnection } from "node:net";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { autoWireScribeAuth } from "../auto-wire.ts";
6
+ import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
7
+ import { assignServicePort } from "../port-assign.ts";
8
+ import {
9
+ CANONICAL_PORT_MAX,
10
+ CANONICAL_PORT_MIN,
11
+ getSpec,
12
+ isCanonicalPort,
13
+ knownServices,
14
+ } from "../service-spec.ts";
15
+ import { findService, readManifest, upsertService } from "../services-manifest.ts";
16
+ import { start as lifecycleStart } from "./lifecycle.ts";
17
+ import { migrateNotice } from "./migrate.ts";
18
+ import {
19
+ type InteractiveAvailability,
20
+ type SetupScribeProviderOpts,
21
+ setupScribeProvider,
22
+ } from "./scribe-provider-interactive.ts";
23
+
24
+ export type Runner = (cmd: readonly string[]) => Promise<number>;
25
+
26
+ /**
27
+ * Transition aliases for services that were renamed. Accepted for one
28
+ * release cycle with a rename notice, then removed. `lens → notes`
29
+ * exists because the frontend was briefly renamed Notes → Lens (Apr 19)
30
+ * and then reverted (Apr 22) on launch eve. Anyone who ran `parachute
31
+ * install lens` during the ~3-day window keeps working. Remove after
32
+ * launch sinks in and `parachute install lens` has stopped appearing
33
+ * in support threads.
34
+ */
35
+ const SERVICE_ALIASES: Record<string, string> = {
36
+ lens: "notes",
37
+ };
38
+
39
+ export interface InstallOpts {
40
+ runner?: Runner;
41
+ manifestPath?: string;
42
+ configDir?: string;
43
+ now?: () => Date;
44
+ log?: (line: string) => void;
45
+ /**
46
+ * True when the package is already globally linked (via `bun link`) so
47
+ * `bun add -g` would be redundant — or worse, fail with a 404 for a
48
+ * package that isn't published to npm yet (the scribe case on 2026-04-19).
49
+ * Defaults to a symlink check against bun's global node_modules prefix.
50
+ */
51
+ isLinked?: (pkg: string) => boolean;
52
+ /**
53
+ * Optional npm dist-tag or exact version to install. When set, the
54
+ * `bun add -g` call is composed as `<package>@<tag>` so RC testers can
55
+ * pin a pre-release channel. `isLinked` still short-circuits — if the
56
+ * package is bun-linked locally, the tag is moot.
57
+ */
58
+ tag?: string;
59
+ /**
60
+ * Override the random-token source for the vault↔scribe auto-wire.
61
+ * Tests pass a deterministic string; production uses crypto.randomBytes.
62
+ */
63
+ randomToken?: () => string;
64
+ /**
65
+ * Probe whether `pkg` is present at bun's global node_modules (returns the
66
+ * package.json path on hit, null on miss). Used after `bun add -g` returns
67
+ * non-zero to distinguish a real failure from bun 1.2.x's noisy
68
+ * lockfile-recovery path — where the package *is* actually installed
69
+ * despite the exit code. Defaults to a filesystem probe against
70
+ * `bunGlobalPrefixes()`.
71
+ */
72
+ findGlobalInstall?: (pkg: string) => string | null;
73
+ /**
74
+ * Skip the post-install daemon start. The launch-day default is to leave
75
+ * the service running so users don't have to remember the second command;
76
+ * pass `true` for piped / CI installs that own their own process model.
77
+ */
78
+ noStart?: boolean;
79
+ /**
80
+ * Test seam: lifecycle start hook used by the post-install auto-start.
81
+ * Defaults to `lifecycle.start(short, …)`. Tests inject a fake to assert
82
+ * the call without spawning a real child.
83
+ */
84
+ startService?: (short: string) => Promise<number>;
85
+ /**
86
+ * `parachute install scribe` only: pre-pick the transcription provider so
87
+ * the prompt doesn't fire. Validated against scribe's known providers — an
88
+ * unknown name is logged and the config is left at default.
89
+ */
90
+ scribeProvider?: string;
91
+ /**
92
+ * `parachute install scribe` only: pre-supply the API key for the chosen
93
+ * provider. Ignored for local providers (parakeet-mlx / onnx-asr / whisper).
94
+ */
95
+ scribeKey?: string;
96
+ /**
97
+ * Test seam for the scribe provider picker. Tests pass `{ kind: "available",
98
+ * prompt: ... }` to drive the prompt without a real TTY; production lets
99
+ * the default sense `process.stdin.isTTY`.
100
+ */
101
+ scribeAvailability?: InteractiveAvailability;
102
+ /**
103
+ * Test seam for the canonical-slot TCP probe. Production probes
104
+ * `127.0.0.1:<port>` with a short timeout; tests inject deterministic
105
+ * answers. Always returns false in tests so canonical slots stay free
106
+ * unless the test populates services.json directly.
107
+ */
108
+ portProbe?: (port: number) => Promise<boolean>;
109
+ }
110
+
111
+ async function defaultRunner(cmd: readonly string[]): Promise<number> {
112
+ const proc = Bun.spawn([...cmd], { stdio: ["inherit", "inherit", "inherit"] });
113
+ return await proc.exited;
114
+ }
115
+
116
+ function bunGlobalPrefixes(): string[] {
117
+ const prefixes: string[] = [];
118
+ const fromEnv = process.env.BUN_INSTALL;
119
+ if (fromEnv) prefixes.push(join(fromEnv, "install", "global", "node_modules"));
120
+ prefixes.push(join(homedir(), ".bun", "install", "global", "node_modules"));
121
+ return prefixes;
122
+ }
123
+
124
+ function defaultIsLinked(pkg: string): boolean {
125
+ for (const prefix of bunGlobalPrefixes()) {
126
+ const path = join(prefix, ...pkg.split("/"));
127
+ try {
128
+ if (lstatSync(path).isSymbolicLink()) return true;
129
+ } catch {
130
+ // Not present at this prefix; try the next.
131
+ }
132
+ }
133
+ return false;
134
+ }
135
+
136
+ /**
137
+ * Short-timeout TCP probe of `127.0.0.1:<port>`. Used by `parachute install`
138
+ * to detect canonical slots that something else is already on. Fail-open:
139
+ * timeouts and errors return `false` so a flaky probe never blocks an
140
+ * install.
141
+ */
142
+ async function defaultPortProbe(port: number): Promise<boolean> {
143
+ return new Promise((resolve) => {
144
+ let settled = false;
145
+ const finish = (taken: boolean) => {
146
+ if (settled) return;
147
+ settled = true;
148
+ resolve(taken);
149
+ };
150
+ try {
151
+ const socket = createConnection({ host: "127.0.0.1", port });
152
+ socket.setTimeout(150, () => {
153
+ socket.destroy();
154
+ finish(false);
155
+ });
156
+ socket.on("connect", () => {
157
+ socket.end();
158
+ finish(true);
159
+ });
160
+ socket.on("error", () => finish(false));
161
+ } catch {
162
+ finish(false);
163
+ }
164
+ });
165
+ }
166
+
167
+ async function collectOccupiedPorts(
168
+ manifestPath: string,
169
+ selfManifestName: string,
170
+ selfPort: number | undefined,
171
+ probe: (port: number) => Promise<boolean>,
172
+ ): Promise<Set<number>> {
173
+ const ports = new Set<number>();
174
+ try {
175
+ const manifest = readManifest(manifestPath);
176
+ for (const svc of manifest.services) {
177
+ if (svc.name === selfManifestName) continue;
178
+ ports.add(svc.port);
179
+ }
180
+ } catch {
181
+ // Manifest missing or malformed — fall back to the TCP probe alone.
182
+ }
183
+ for (let p = CANONICAL_PORT_MIN; p <= CANONICAL_PORT_MAX; p++) {
184
+ if (selfPort !== undefined && p === selfPort) continue;
185
+ try {
186
+ if (await probe(p)) ports.add(p);
187
+ } catch {
188
+ // Probe error — fail-open per CLI port-authority policy.
189
+ }
190
+ }
191
+ return ports;
192
+ }
193
+
194
+ function defaultFindGlobalInstall(pkg: string): string | null {
195
+ for (const prefix of bunGlobalPrefixes()) {
196
+ const pkgJsonPath = join(prefix, ...pkg.split("/"), "package.json");
197
+ try {
198
+ const parsed = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
199
+ if (typeof parsed?.name === "string" && typeof parsed?.version === "string") {
200
+ return pkgJsonPath;
201
+ }
202
+ } catch {
203
+ // Not present / not valid at this prefix; try the next.
204
+ }
205
+ }
206
+ return null;
207
+ }
208
+
209
+ export async function install(service: string, opts: InstallOpts = {}): Promise<number> {
210
+ const runner = opts.runner ?? defaultRunner;
211
+ const manifestPath = opts.manifestPath ?? SERVICES_MANIFEST_PATH;
212
+ const configDir = opts.configDir ?? CONFIG_DIR;
213
+ const now = opts.now ?? (() => new Date());
214
+ const log = opts.log ?? ((line) => console.log(line));
215
+ const isLinked = opts.isLinked ?? defaultIsLinked;
216
+ const findGlobalInstall = opts.findGlobalInstall ?? defaultFindGlobalInstall;
217
+
218
+ const aliased = SERVICE_ALIASES[service];
219
+ if (aliased !== undefined) {
220
+ log(`"${service}" has been renamed to "${aliased}"; installing ${aliased}.`);
221
+ }
222
+ const resolvedService = aliased ?? service;
223
+
224
+ const spec = getSpec(resolvedService);
225
+ if (!spec) {
226
+ log(`unknown service: "${resolvedService}"`);
227
+ log(`known services: ${knownServices().join(", ")}`);
228
+ return 1;
229
+ }
230
+
231
+ if (isLinked(spec.package)) {
232
+ log(`${spec.package} is already linked globally (bun link) — skipping bun add.`);
233
+ } else {
234
+ const addSpec = opts.tag ? `${spec.package}@${opts.tag}` : spec.package;
235
+ log(`Installing ${addSpec}…`);
236
+ const addCode = await runner(["bun", "add", "-g", addSpec]);
237
+ if (addCode !== 0) {
238
+ // Bun 1.2.x has a noisy lockfile-recovery path where `bun add -g` prints
239
+ // InvalidPackageResolution + "Failed to install 1 package" and exits 1,
240
+ // *even though the package is successfully installed* (you can see
241
+ // "installed @openparachute/<foo> with binaries" in the same output).
242
+ // Bailing here on exit code alone means the caller-visible install
243
+ // fails and downstream init/seed never runs — so probe the global
244
+ // prefix before treating non-zero as fatal.
245
+ const foundAt = findGlobalInstall(spec.package);
246
+ if (foundAt) {
247
+ log(`bun add reported exit ${addCode} but ${spec.package} is installed at ${foundAt}.`);
248
+ log(
249
+ "Known bun 1.2.x lockfile quirk — the package landed despite the warning. Proceeding. `bun upgrade` to 1.3.x avoids it.",
250
+ );
251
+ } else {
252
+ // Make the failure mode legible: enumerating the prefixes we probed
253
+ // turns "bun add -g failed" into something an operator on a non-
254
+ // standard bun layout can act on. (Surfaced by parachute-hub#44 — a
255
+ // bun 1.2.x report where `notes` never registered; if the same
256
+ // failure mode ever manifests via findGlobalInstall returning null,
257
+ // the log tells us where to look.)
258
+ log(`bun add -g ${addSpec} failed (exit ${addCode})`);
259
+ log(` probed bun globals at: ${bunGlobalPrefixes().join(", ")}`);
260
+ return addCode;
261
+ }
262
+ }
263
+ }
264
+
265
+ if (spec.init) {
266
+ log(`Running ${spec.init.join(" ")}…`);
267
+ const initCode = await runner(spec.init);
268
+ if (initCode !== 0) {
269
+ log(`${spec.init.join(" ")} exited ${initCode}`);
270
+ return initCode;
271
+ }
272
+ }
273
+
274
+ // CLI-as-port-authority (#53): pick the service's port now and persist it
275
+ // via `~/.parachute/<svc>/.env`. lifecycle.start merges that .env into the
276
+ // spawn env (PR #50), so the next daemon boot binds the port we picked.
277
+ // Idempotent — an existing PORT in .env wins, so re-installs and
278
+ // user-edited ports survive across upgrades. Compiled-in service-side
279
+ // fallbacks (vault → 1940 etc.) stay; this just adds a CLI-managed
280
+ // override.
281
+ const preInitEntry = findService(spec.manifestName, manifestPath);
282
+ const probe = opts.portProbe ?? defaultPortProbe;
283
+ const occupied = await collectOccupiedPorts(
284
+ manifestPath,
285
+ spec.manifestName,
286
+ preInitEntry?.port,
287
+ probe,
288
+ );
289
+ const envPath = join(configDir, resolvedService, ".env");
290
+ const canonicalPort = spec.seedEntry?.().port ?? preInitEntry?.port;
291
+ const portResult = assignServicePort({
292
+ envPath,
293
+ canonical: canonicalPort,
294
+ occupied,
295
+ });
296
+ if (portResult.warning) {
297
+ log(`⚠ ${portResult.warning}`);
298
+ }
299
+ if (portResult.written) {
300
+ log(`Wrote PORT=${portResult.port} to ${envPath}.`);
301
+ }
302
+
303
+ // Find-or-seed the manifest entry. Re-read after the seed write so a silent
304
+ // upsert failure (filesystem permission, races against an external writer)
305
+ // surfaces as a loud log line instead of a phantom "registered" claim.
306
+ // parachute-hub#44 reported notes not appearing in services.json on a fresh
307
+ // bun 1.2.x install; the gate logic was already correct, but a verify-step
308
+ // turns silent loss into something an operator can spot.
309
+ let entry = findService(spec.manifestName, manifestPath);
310
+ if (!entry && spec.seedEntry) {
311
+ const seedBase = spec.seedEntry();
312
+ const seed =
313
+ seedBase.port === portResult.port ? seedBase : { ...seedBase, port: portResult.port };
314
+ upsertService(seed, manifestPath);
315
+ entry = findService(spec.manifestName, manifestPath);
316
+ if (entry) {
317
+ log(
318
+ `Seeded services.json entry for ${spec.manifestName} (placeholder; service's own boot will overwrite).`,
319
+ );
320
+ } else {
321
+ log(
322
+ `⚠ tried to seed services.json entry for ${spec.manifestName}, but the readback came back empty.`,
323
+ );
324
+ log(` manifest path: ${manifestPath}`);
325
+ log(" Re-run `parachute install` once the underlying issue is resolved.");
326
+ }
327
+ } else if (entry && entry.port !== portResult.port) {
328
+ // init wrote an entry on the canonical port but the CLI assigned a
329
+ // different one (collision). Reflect the CLI's choice so the hub and
330
+ // status views stay consistent with the .env we just wrote.
331
+ upsertService({ ...entry, port: portResult.port }, manifestPath);
332
+ entry = findService(spec.manifestName, manifestPath);
333
+ log(
334
+ `Updated services.json port to ${portResult.port} for ${spec.manifestName} (was ${preInitEntry?.port ?? "—"}).`,
335
+ );
336
+ }
337
+
338
+ if (!entry) {
339
+ log(
340
+ `Installed, but no services.json entry for "${spec.manifestName}" yet. Run \`parachute status\` after the service has started.`,
341
+ );
342
+ } else {
343
+ log(`✓ ${spec.manifestName} registered on port ${entry.port}`);
344
+ if (!isCanonicalPort(entry.port)) {
345
+ log(
346
+ `⚠ port ${entry.port} is outside the canonical Parachute range (${CANONICAL_PORT_MIN}–${CANONICAL_PORT_MAX}); may conflict with other software.`,
347
+ );
348
+ }
349
+ }
350
+
351
+ // Auto-wire the vault↔scribe shared secret + SCRIBE_URL when both services
352
+ // end up installed. Fires from either install order (scribe then vault, or
353
+ // vault then scribe). Idempotent — preserves any pre-existing values in
354
+ // vault .env. Restarts vault if it's running so the worker re-reads .env.
355
+ if (spec.manifestName === "parachute-vault" || spec.manifestName === "parachute-scribe") {
356
+ const vaultPresent = !!findService("parachute-vault", manifestPath);
357
+ const scribePresent = !!findService("parachute-scribe", manifestPath);
358
+ if (vaultPresent && scribePresent) {
359
+ const autoWireOpts: Parameters<typeof autoWireScribeAuth>[0] = { configDir, log };
360
+ if (opts.randomToken) autoWireOpts.randomToken = opts.randomToken;
361
+ await autoWireScribeAuth(autoWireOpts);
362
+ }
363
+ }
364
+
365
+ // Scribe-only: prompt for transcription provider (or accept --scribe-provider
366
+ // / --scribe-key). Has to land before auto-start so the very first scribe
367
+ // boot reads the right provider — and inside the prompt we restart scribe
368
+ // ourselves if it was already running, mirroring the auto-wire pattern.
369
+ // Failure here doesn't fail the install: a flaky restart shouldn't undo a
370
+ // successful `bun add`.
371
+ if (resolvedService === "scribe") {
372
+ const setupOpts: SetupScribeProviderOpts = { configDir, log };
373
+ if (opts.scribeProvider) setupOpts.preselectProvider = opts.scribeProvider;
374
+ if (opts.scribeKey) setupOpts.preselectKey = opts.scribeKey;
375
+ if (opts.scribeAvailability) setupOpts.availability = opts.scribeAvailability;
376
+ await setupScribeProvider(setupOpts);
377
+ }
378
+
379
+ const notice = migrateNotice(configDir, now());
380
+ if (notice) log(notice);
381
+
382
+ // Auto-start: vault and notes' inits historically left a daemon running, but
383
+ // scribe (and any service without a daemon-launching init) didn't — so
384
+ // launch-day `install scribe` ended with a silent install and the user
385
+ // wondering why nothing happened. Always end with the daemon running unless
386
+ // the caller opted out (CI / piped scripts). Idempotent: if the service is
387
+ // already up, lifecycle.start no-ops via the existing PID-file check.
388
+ if (!opts.noStart) {
389
+ const startService =
390
+ opts.startService ??
391
+ ((short: string) => lifecycleStart(short, { manifestPath, configDir, log }));
392
+ const startCode = await startService(resolvedService);
393
+ if (startCode !== 0) {
394
+ log(
395
+ `⚠ ${resolvedService} didn't start cleanly. Run manually: parachute start ${resolvedService}`,
396
+ );
397
+ }
398
+ }
399
+
400
+ // Per-service install footer — canonical next-step URLs and configuration
401
+ // hints. Vault prints its own (richer) footer from `parachute-vault init`
402
+ // (PR #166), so the spec leaves vault out and we don't double up here.
403
+ const footer = spec.postInstallFooter?.();
404
+ if (footer) {
405
+ for (const line of footer) log(line);
406
+ }
407
+
408
+ // Final registration check — the service may have written its own
409
+ // authoritative entry during init or first boot, replacing the seed (or
410
+ // filling a gap when the service had no seedEntry). Re-read at exit so the
411
+ // last line of the install always reflects ground truth, not an early
412
+ // snapshot. Surfaced by parachute-hub#44 — defensive logging that turns a
413
+ // missing entry into a visible failure rather than a silent one.
414
+ const finalEntry = findService(spec.manifestName, manifestPath);
415
+ if (!finalEntry) {
416
+ log(
417
+ `⚠ ${spec.manifestName} is not in services.json after install. \`parachute status\` won't see it. Re-run install or file a bug.`,
418
+ );
419
+ }
420
+
421
+ return 0;
422
+ }