@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,296 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import type { ServiceEntry } from "./services-manifest.ts";
3
+
4
+ /**
5
+ * Canonical Parachute port range. Every ecosystem service reserves a slot in
6
+ * 1939–1949; third-party integrators are expected to avoid it.
7
+ *
8
+ * 1939 parachute-hub internal static + proxy, CLI-managed
9
+ * 1940 parachute-vault committed core
10
+ * 1941 parachute-channel exploration (may retire)
11
+ * 1942 parachute-notes committed core (PWA bundle)
12
+ * 1943 parachute-scribe committed core
13
+ * 1944–1949 unassigned
14
+ *
15
+ * Hub pins 1939: `parachute expose` composes hub targets as
16
+ * `http://127.0.0.1:1939/` and that URL has to be stable across machines for
17
+ * tailscale serve to proxy it correctly. The hub-port fallback range is 1
18
+ * (see hub-control.ts) — if something else is on 1939 we fail loudly rather
19
+ * than walking up into a service's slot.
20
+ *
21
+ * **CLI is the port authority.** `parachute install <svc>` picks the port at
22
+ * install time and writes `PORT=<port>` into `~/.parachute/<svc>/.env`.
23
+ * lifecycle.start merges that .env into the spawn env, so the next daemon
24
+ * boot binds the port the CLI assigned. Algorithm (see port-assign.ts):
25
+ *
26
+ * 1. Prefer the canonical slot (`spec.seedEntry().port`).
27
+ * 2. On collision, walk the unassigned range (1944–1949 today).
28
+ * 3. Range exhausted: assign past 1949 with a warning.
29
+ *
30
+ * Idempotent: an existing `PORT=` in .env wins, so re-installs and
31
+ * operator-edited ports survive across upgrades. Services keep their
32
+ * compiled-in fallbacks (vault → 1940 etc.) so a stand-alone `bun run`
33
+ * still works without a CLI-managed .env, but the CLI's PORT wins on any
34
+ * install it manages.
35
+ *
36
+ * **No speculative reservations.** Future first-party modules claim a slot
37
+ * the moment they ship, not before — pre-reservation for unbuilt things has
38
+ * proven a hold-place we kept reshaping.
39
+ */
40
+ export const CANONICAL_PORT_MIN = 1939;
41
+ export const CANONICAL_PORT_MAX = 1949;
42
+
43
+ export interface PortReservation {
44
+ readonly port: number;
45
+ readonly name: string;
46
+ readonly status: "assigned" | "reserved";
47
+ }
48
+
49
+ export const PORT_RESERVATIONS: readonly PortReservation[] = [
50
+ { port: 1939, name: "parachute-hub", status: "assigned" },
51
+ { port: 1940, name: "parachute-vault", status: "assigned" },
52
+ { port: 1941, name: "parachute-channel", status: "assigned" },
53
+ { port: 1942, name: "parachute-notes", status: "assigned" },
54
+ { port: 1943, name: "parachute-scribe", status: "assigned" },
55
+ { port: 1944, name: "unassigned", status: "reserved" },
56
+ { port: 1945, name: "unassigned", status: "reserved" },
57
+ { port: 1946, name: "unassigned", status: "reserved" },
58
+ { port: 1947, name: "unassigned", status: "reserved" },
59
+ { port: 1948, name: "unassigned", status: "reserved" },
60
+ { port: 1949, name: "unassigned", status: "reserved" },
61
+ ];
62
+
63
+ export function isCanonicalPort(port: number): boolean {
64
+ return port >= CANONICAL_PORT_MIN && port <= CANONICAL_PORT_MAX;
65
+ }
66
+
67
+ /**
68
+ * Broad shape of a service. Matches the hub's card-kind taxonomy.
69
+ * "frontend" a user-facing UI (notes). Safe to expose by default.
70
+ * "api" a programmatic surface (vault, channel, scribe). Whether
71
+ * it's safe to expose depends on `hasAuth`.
72
+ * "tool" like "api" but specifically MCP-shaped / agent-callable.
73
+ * Treated the same as "api" for exposure defaults.
74
+ */
75
+ export type ServiceKind = "api" | "tool" | "frontend";
76
+
77
+ export interface ServiceSpec {
78
+ readonly package: string;
79
+ readonly manifestName: string;
80
+ readonly init?: readonly string[];
81
+ /**
82
+ * Command to spawn for `parachute start <svc>`. Receives the services.json
83
+ * entry so commands that need per-install data (e.g., the notes static-serve
84
+ * shim needs the configured port) can pull it from there.
85
+ *
86
+ * Returns `undefined` to declare "lifecycle not supported for this service."
87
+ * That never applies today but leaves a seam for future services that
88
+ * shouldn't be managed by `parachute start`.
89
+ */
90
+ readonly startCmd?: (entry: ServiceEntry) => readonly string[] | undefined;
91
+ /**
92
+ * Canonical initial services.json entry used when the service hasn't
93
+ * written its own entry yet. Fires post-install only if `findService`
94
+ * returns undefined — normal npm installs hit this almost never (the
95
+ * service's init or first boot writes the authoritative entry first).
96
+ *
97
+ * Main use case: `bun link` local-dev installs where the service hasn't
98
+ * run yet but `parachute expose` / `parachute start` need an entry to
99
+ * plan against. First service boot overwrites the seed with its own
100
+ * authoritative version.
101
+ */
102
+ readonly seedEntry?: () => ServiceEntry;
103
+ /**
104
+ * Declares the service's broad shape. Drives exposure defaults: api/tool
105
+ * services without auth fall back to `publicExposure: "auth-required"`
106
+ * (treated as loopback at launch); frontends default to "allowed".
107
+ */
108
+ readonly kind?: ServiceKind;
109
+ /**
110
+ * Does the service gate its endpoints behind auth today? Used together with
111
+ * `kind` to pick a safe default when the services.json entry omits
112
+ * `publicExposure`. True for vault/channel (owner-authenticated);
113
+ * conservatively false for scribe until its auth-gate ships.
114
+ */
115
+ readonly hasAuth?: boolean;
116
+ /**
117
+ * Canonical reachable URL for the service given its manifest entry. Drives
118
+ * the URL column in `parachute status` and any other place we need to
119
+ * render "where do I point a client?". Most services use port + paths[0],
120
+ * but some need to append a fixed suffix (vault's MCP endpoint lives at
121
+ * `/vault/<name>/mcp`, not the bare mount path).
122
+ *
123
+ * Returns undefined when the entry doesn't carry enough info — callers
124
+ * should fall back to the bare `http://127.0.0.1:<port>` form.
125
+ */
126
+ readonly urlForEntry?: (entry: ServiceEntry) => string | undefined;
127
+ /**
128
+ * Lines printed at the end of `parachute install <svc>` so the user has a
129
+ * clear next step. Vault's footer comes from `parachute-vault init` itself
130
+ * (PR #166) — richer because it can read the freshly-minted API token —
131
+ * so vault's spec leaves this off.
132
+ */
133
+ readonly postInstallFooter?: () => readonly string[];
134
+ }
135
+
136
+ const NOTES_SERVE_PATH = fileURLToPath(new URL("./notes-serve.ts", import.meta.url));
137
+
138
+ /**
139
+ * Seed entries land in services.json as placeholder rows when a freshly
140
+ * installed service hasn't written its own. Version `"0.0.0-linked"`
141
+ * telegraphs the state: the row is a stopgap, and the service's first boot
142
+ * will overwrite with its own authoritative write.
143
+ */
144
+ const SEED_VERSION = "0.0.0-linked";
145
+
146
+ function pathBasedUrl(entry: ServiceEntry): string {
147
+ const first = entry.paths[0] ?? "";
148
+ // Strip a trailing slash so concatenation never doubles up.
149
+ const path = first.replace(/\/+$/, "");
150
+ return `http://127.0.0.1:${entry.port}${path}`;
151
+ }
152
+
153
+ export const SERVICE_SPECS: Record<string, ServiceSpec> = {
154
+ vault: {
155
+ package: "@openparachute/vault",
156
+ manifestName: "parachute-vault",
157
+ init: ["parachute-vault", "init"],
158
+ startCmd: () => ["parachute-vault", "serve"],
159
+ kind: "api",
160
+ hasAuth: true,
161
+ seedEntry: () => ({
162
+ name: "parachute-vault",
163
+ port: 1940,
164
+ paths: ["/vault/default"],
165
+ health: "/vault/default/health",
166
+ version: SEED_VERSION,
167
+ }),
168
+ // Vault's MCP endpoint lives one segment past the mount path. The bare
169
+ // `/vault/<name>` URL is the discovery shape; clients (claude.ai et al.)
170
+ // need `/vault/<name>/mcp` to actually open the stream.
171
+ urlForEntry: (entry) => `${pathBasedUrl(entry)}/mcp`,
172
+ },
173
+ notes: {
174
+ // Frontend product name is "Notes". vault's internal `/api/notes` endpoint
175
+ // is unrelated — different concept (vault data primitive vs. PWA brand).
176
+ package: "@openparachute/notes",
177
+ manifestName: "parachute-notes",
178
+ startCmd: (entry) => {
179
+ const first = entry.paths[0] ?? "/notes";
180
+ const mount = first === "/" ? "" : first.replace(/\/+$/, "");
181
+ return ["bun", NOTES_SERVE_PATH, "--port", String(entry.port), "--mount", mount];
182
+ },
183
+ kind: "frontend",
184
+ seedEntry: () => ({
185
+ name: "parachute-notes",
186
+ port: 1942,
187
+ paths: ["/notes"],
188
+ health: "/notes/health",
189
+ version: SEED_VERSION,
190
+ }),
191
+ urlForEntry: pathBasedUrl,
192
+ postInstallFooter: () => [
193
+ "",
194
+ "Open your Notes UI at http://localhost:1942/notes — paste the vault URL",
195
+ " http://127.0.0.1:1940/vault/default",
196
+ "and the API token from your vault install.",
197
+ ],
198
+ },
199
+ scribe: {
200
+ package: "@openparachute/scribe",
201
+ manifestName: "parachute-scribe",
202
+ startCmd: () => ["parachute-scribe", "serve"],
203
+ // No auth gate today. Scribe's launch PR adds optional SCRIBE_AUTH_TOKEN;
204
+ // once it lands and scribe writes `publicExposure: "allowed"` when a token
205
+ // is configured, that explicit declaration overrides this default.
206
+ kind: "api",
207
+ hasAuth: false,
208
+ seedEntry: () => ({
209
+ name: "parachute-scribe",
210
+ port: 1943,
211
+ paths: ["/scribe"],
212
+ health: "/scribe/health",
213
+ version: SEED_VERSION,
214
+ }),
215
+ // Scribe's API is at the root, not under `/scribe`. The path prefix only
216
+ // shows up in the health endpoint; clients hit the bare port.
217
+ urlForEntry: (entry) => `http://127.0.0.1:${entry.port}`,
218
+ postInstallFooter: () => [
219
+ "",
220
+ "Scribe is listening on http://127.0.0.1:1943.",
221
+ "Vault will auto-call this for transcription (SCRIBE_URL has been wired to the vault env).",
222
+ "Provider config lives at ~/.parachute/scribe/config.json (key: transcribe.provider);",
223
+ "API keys live at ~/.parachute/scribe/.env. Available: parakeet-mlx (default), onnx-asr,",
224
+ "whisper, groq, openai.",
225
+ ],
226
+ },
227
+ channel: {
228
+ package: "@openparachute/channel",
229
+ manifestName: "parachute-channel",
230
+ startCmd: () => ["parachute-channel", "daemon"],
231
+ kind: "api",
232
+ hasAuth: true,
233
+ seedEntry: () => ({
234
+ name: "parachute-channel",
235
+ port: 1941,
236
+ paths: ["/channel"],
237
+ health: "/channel/health",
238
+ version: SEED_VERSION,
239
+ }),
240
+ urlForEntry: pathBasedUrl,
241
+ },
242
+ };
243
+
244
+ /**
245
+ * Effective publicExposure for a service, given what's on its services.json
246
+ * entry. Explicit wins. If absent, derive from the spec: known api/tool
247
+ * services without declared auth fall back to "auth-required" (treated as
248
+ * loopback at launch); everything else defaults to "allowed" — so vault,
249
+ * notes, channel and unknown third-party services continue to be exposed
250
+ * without needing to opt in.
251
+ */
252
+ export function effectivePublicExposure(
253
+ entry: ServiceEntry,
254
+ ): "allowed" | "loopback" | "auth-required" {
255
+ if (entry.publicExposure !== undefined) return entry.publicExposure;
256
+ const short = shortNameForManifest(entry.name);
257
+ const spec = short !== undefined ? SERVICE_SPECS[short] : undefined;
258
+ if (spec && (spec.kind === "api" || spec.kind === "tool") && spec.hasAuth === false) {
259
+ return "auth-required";
260
+ }
261
+ return "allowed";
262
+ }
263
+
264
+ export function knownServices(): string[] {
265
+ return Object.keys(SERVICE_SPECS);
266
+ }
267
+
268
+ export function getSpec(service: string): ServiceSpec | undefined {
269
+ return SERVICE_SPECS[service];
270
+ }
271
+
272
+ /**
273
+ * Legacy manifest names kept so `parachute start` / `stop` / `logs` keep
274
+ * working on an already-installed services.json that still carries the
275
+ * old name.
276
+ *
277
+ * `parachute-notes` was the original; it became `parachute-lens` for ~3
278
+ * days during the Lens rebrand window (2026-04-19 → 2026-04-22), then
279
+ * reverted. Users who installed during that window have `parachute-lens`
280
+ * in their services.json and need lifecycle commands to keep finding
281
+ * their install — without this alias, `parachute start/stop/logs/status`
282
+ * silently skip those rows. Remove after launch, alongside the `lens →
283
+ * notes` install alias.
284
+ */
285
+ const LEGACY_MANIFEST_ALIASES: Record<string, string> = {
286
+ "parachute-lens": "notes",
287
+ };
288
+
289
+ /** Short name (the key into SERVICE_SPECS) for a given manifest name, e.g.
290
+ * `parachute-vault` → `vault`. Returns undefined for unknown manifests. */
291
+ export function shortNameForManifest(manifestName: string): string | undefined {
292
+ for (const [short, spec] of Object.entries(SERVICE_SPECS)) {
293
+ if (spec.manifestName === manifestName) return short;
294
+ }
295
+ return LEGACY_MANIFEST_ALIASES[manifestName];
296
+ }
@@ -0,0 +1,171 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { SERVICES_MANIFEST_PATH } from "./config.ts";
4
+
5
+ /**
6
+ * Whether the service is safe to mount on public-facing expose layers.
7
+ *
8
+ * "allowed" mount on every layer (tailnet + public). Use when the
9
+ * service gates its own endpoints with auth.
10
+ * "loopback" never mount on tailnet/funnel — only reachable at
11
+ * http://127.0.0.1:<port>. For internal services that
12
+ * shouldn't leave the box.
13
+ * "auth-required" the service wants auth but isn't guaranteed to have it
14
+ * configured (e.g., scribe without SCRIBE_AUTH_TOKEN set).
15
+ * At launch this is treated the same as "loopback"; future
16
+ * work can flip to "allowed" once the service reports its
17
+ * auth state over `/.parachute/info`.
18
+ *
19
+ * Absent field: the CLI derives a safe default from the service's ServiceSpec
20
+ * (known api/tool services without declared auth → "auth-required"; everything
21
+ * else → "allowed"). Unknown services default to "allowed" for back-compat.
22
+ */
23
+ export type PublicExposure = "allowed" | "loopback" | "auth-required";
24
+
25
+ export interface ServiceEntry {
26
+ name: string;
27
+ port: number;
28
+ paths: string[];
29
+ health: string;
30
+ version: string;
31
+ /** Human-readable name for the hub page. Falls back to the short manifest name. */
32
+ displayName?: string;
33
+ /** One-line subtitle for the hub page card. */
34
+ tagline?: string;
35
+ /** Opt-in or opt-out of public-facing expose layers. See PublicExposure. */
36
+ publicExposure?: PublicExposure;
37
+ }
38
+
39
+ export interface ServicesManifest {
40
+ services: ServiceEntry[];
41
+ }
42
+
43
+ export class ServicesManifestError extends Error {
44
+ override name = "ServicesManifestError";
45
+ }
46
+
47
+ const EMPTY: ServicesManifest = { services: [] };
48
+
49
+ function validateEntry(raw: unknown, where: string): ServiceEntry {
50
+ if (!raw || typeof raw !== "object") {
51
+ throw new ServicesManifestError(`${where}: expected object, got ${typeof raw}`);
52
+ }
53
+ const e = raw as Record<string, unknown>;
54
+ const name = e.name;
55
+ const port = e.port;
56
+ const paths = e.paths;
57
+ const health = e.health;
58
+ const version = e.version;
59
+ if (typeof name !== "string" || name.length === 0) {
60
+ throw new ServicesManifestError(`${where}: "name" must be a non-empty string`);
61
+ }
62
+ if (typeof port !== "number" || !Number.isInteger(port) || port <= 0 || port > 65535) {
63
+ throw new ServicesManifestError(`${where}: "port" must be an integer 1..65535`);
64
+ }
65
+ if (!Array.isArray(paths) || paths.some((p) => typeof p !== "string")) {
66
+ throw new ServicesManifestError(`${where}: "paths" must be an array of strings`);
67
+ }
68
+ if (typeof health !== "string" || !health.startsWith("/")) {
69
+ throw new ServicesManifestError(`${where}: "health" must be a path starting with "/"`);
70
+ }
71
+ if (typeof version !== "string") {
72
+ throw new ServicesManifestError(`${where}: "version" must be a string`);
73
+ }
74
+ const displayName = e.displayName;
75
+ const tagline = e.tagline;
76
+ const publicExposure = e.publicExposure;
77
+ if (displayName !== undefined && typeof displayName !== "string") {
78
+ throw new ServicesManifestError(`${where}: "displayName" must be a string if present`);
79
+ }
80
+ if (tagline !== undefined && typeof tagline !== "string") {
81
+ throw new ServicesManifestError(`${where}: "tagline" must be a string if present`);
82
+ }
83
+ if (
84
+ publicExposure !== undefined &&
85
+ publicExposure !== "allowed" &&
86
+ publicExposure !== "loopback" &&
87
+ publicExposure !== "auth-required"
88
+ ) {
89
+ throw new ServicesManifestError(
90
+ `${where}: "publicExposure" must be "allowed" | "loopback" | "auth-required" if present`,
91
+ );
92
+ }
93
+ const entry: ServiceEntry = { name, port, paths: paths as string[], health, version };
94
+ if (displayName !== undefined) entry.displayName = displayName;
95
+ if (tagline !== undefined) entry.tagline = tagline;
96
+ if (publicExposure !== undefined) entry.publicExposure = publicExposure as PublicExposure;
97
+ return entry;
98
+ }
99
+
100
+ function validateManifest(raw: unknown, where: string): ServicesManifest {
101
+ if (!raw || typeof raw !== "object") {
102
+ throw new ServicesManifestError(`${where}: root must be an object`);
103
+ }
104
+ const services = (raw as Record<string, unknown>).services;
105
+ if (!Array.isArray(services)) {
106
+ throw new ServicesManifestError(`${where}: "services" must be an array`);
107
+ }
108
+ return {
109
+ services: services.map((s, i) => validateEntry(s, `${where} services[${i}]`)),
110
+ };
111
+ }
112
+
113
+ export function readManifest(path: string = SERVICES_MANIFEST_PATH): ServicesManifest {
114
+ if (!existsSync(path)) return { services: [] };
115
+ let raw: unknown;
116
+ try {
117
+ raw = JSON.parse(readFileSync(path, "utf8"));
118
+ } catch (err) {
119
+ throw new ServicesManifestError(
120
+ `failed to parse ${path}: ${err instanceof Error ? err.message : String(err)}`,
121
+ );
122
+ }
123
+ return validateManifest(raw, path);
124
+ }
125
+
126
+ export function writeManifest(
127
+ manifest: ServicesManifest,
128
+ path: string = SERVICES_MANIFEST_PATH,
129
+ ): void {
130
+ mkdirSync(dirname(path), { recursive: true });
131
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
132
+ writeFileSync(tmp, `${JSON.stringify(manifest, null, 2)}\n`);
133
+ renameSync(tmp, path);
134
+ }
135
+
136
+ export function upsertService(
137
+ entry: ServiceEntry,
138
+ path: string = SERVICES_MANIFEST_PATH,
139
+ ): ServicesManifest {
140
+ validateEntry(entry, "entry");
141
+ const current = existsSync(path) ? readManifest(path) : structuredClone(EMPTY);
142
+ const idx = current.services.findIndex((s) => s.name === entry.name);
143
+ if (idx >= 0) {
144
+ current.services[idx] = entry;
145
+ } else {
146
+ current.services.push(entry);
147
+ }
148
+ writeManifest(current, path);
149
+ return current;
150
+ }
151
+
152
+ export function removeService(
153
+ name: string,
154
+ path: string = SERVICES_MANIFEST_PATH,
155
+ ): ServicesManifest {
156
+ if (!existsSync(path)) return structuredClone(EMPTY);
157
+ const current = readManifest(path);
158
+ const next: ServicesManifest = {
159
+ services: current.services.filter((s) => s.name !== name),
160
+ };
161
+ writeManifest(next, path);
162
+ return next;
163
+ }
164
+
165
+ export function findService(
166
+ name: string,
167
+ path: string = SERVICES_MANIFEST_PATH,
168
+ ): ServiceEntry | undefined {
169
+ if (!existsSync(path)) return undefined;
170
+ return readManifest(path).services.find((s) => s.name === name);
171
+ }
@@ -0,0 +1,41 @@
1
+ export interface ServeEntry {
2
+ kind: "proxy" | "file";
3
+ mount: string;
4
+ target: string;
5
+ service: string;
6
+ }
7
+
8
+ export interface BringupOpts {
9
+ funnel?: boolean;
10
+ port?: number;
11
+ }
12
+
13
+ /**
14
+ * Funnel was a flag on `tailscale serve` through ~1.80; from 1.82 onward
15
+ * it's a separate `tailscale funnel` subcommand with the same syntax minus
16
+ * the `--funnel` flag. Modern tailscale (1.82+) rejects `serve --funnel`
17
+ * outright: "flag provided but not defined: -funnel". Pick the subcommand
18
+ * up-front; we don't support the pre-split syntax.
19
+ */
20
+ function serveVerb(funnel: boolean): string {
21
+ return funnel ? "funnel" : "serve";
22
+ }
23
+
24
+ export function bringupCommand(entry: ServeEntry, opts: BringupOpts = {}): string[] {
25
+ const port = opts.port ?? 443;
26
+ const funnel = opts.funnel === true;
27
+ return [
28
+ "tailscale",
29
+ serveVerb(funnel),
30
+ "--bg",
31
+ `--https=${port}`,
32
+ `--set-path=${entry.mount}`,
33
+ entry.target,
34
+ ];
35
+ }
36
+
37
+ export function teardownCommand(entry: ServeEntry, opts: BringupOpts = {}): string[] {
38
+ const port = opts.port ?? 443;
39
+ const funnel = opts.funnel === true;
40
+ return ["tailscale", serveVerb(funnel), `--https=${port}`, `--set-path=${entry.mount}`, "off"];
41
+ }
@@ -0,0 +1,107 @@
1
+ import type { Runner } from "./run.ts";
2
+ import { TailscaleError } from "./run.ts";
3
+
4
+ /** ACL capability keys Tailscale emits on `Self.CapMap` when the node is
5
+ * allowed to run Funnel. Modern tailscaled (≥ ~1.96) emits the bare
6
+ * `"funnel"` key; older builds emit the URL form. Accept either — the probe
7
+ * is best-effort (see {@link getTailscaleStatus}) and we'd rather cross
8
+ * versions than over-nag users whose ACL is correctly granted. */
9
+ export const FUNNEL_CAP_KEYS = ["funnel", "https://tailscale.com/cap/funnel"] as const;
10
+
11
+ export async function isTailscaleInstalled(runner: Runner): Promise<boolean> {
12
+ try {
13
+ const { code } = await runner(["tailscale", "version"]);
14
+ return code === 0;
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Consolidated read of `tailscale status --json`, returning everything the
22
+ * readiness check needs in one subprocess call:
23
+ *
24
+ * - `loggedIn` — Self.DNSName is present and non-empty. False on `Logged out`,
25
+ * `Stopped`, install/PATH errors, or parse failures — callers use this to
26
+ * decide whether to prompt the user to run `tailscale up` before anything
27
+ * else.
28
+ * - `funnelCapable` — best-effort probe for whether this node is allowed to
29
+ * expose Funnel, via any key in {@link FUNNEL_CAP_KEYS} on `Self.CapMap`.
30
+ *
31
+ * Caveat on `funnelCapable`: `CapMap` is a semi-internal field whose shape
32
+ * Tailscale can shift across versions. This probe is not load-bearing — a
33
+ * false negative only means we'll point the user at the admin console when
34
+ * they don't actually need to do anything. The downstream `tailscale funnel`
35
+ * call is the real gate; this just lets us nudge the user earlier in the flow.
36
+ *
37
+ * Any error (non-zero exit, parse failure) returns `{ loggedIn: false,
38
+ * funnelCapable: false }` rather than throwing; the readiness check is an
39
+ * advisory pre-flight, not a hard gate.
40
+ */
41
+ export async function getTailscaleStatus(
42
+ runner: Runner,
43
+ ): Promise<{ loggedIn: boolean; funnelCapable: boolean }> {
44
+ try {
45
+ const result = await runner(["tailscale", "status", "--json"]);
46
+ if (result.code !== 0) return { loggedIn: false, funnelCapable: false };
47
+ const parsed = JSON.parse(result.stdout) as {
48
+ Self?: { DNSName?: unknown; CapMap?: Record<string, unknown> };
49
+ };
50
+ const dnsName = parsed.Self?.DNSName;
51
+ const loggedIn = typeof dnsName === "string" && dnsName.length > 0;
52
+ const capMap = parsed.Self?.CapMap;
53
+ const funnelCapable =
54
+ loggedIn &&
55
+ !!capMap &&
56
+ typeof capMap === "object" &&
57
+ FUNNEL_CAP_KEYS.some((k) => k in capMap);
58
+ return { loggedIn, funnelCapable };
59
+ } catch {
60
+ return { loggedIn: false, funnelCapable: false };
61
+ }
62
+ }
63
+
64
+ export async function getFqdn(runner: Runner): Promise<string> {
65
+ const result = await runner(["tailscale", "status", "--json"]);
66
+ if (result.code !== 0) {
67
+ throw new TailscaleError(
68
+ `tailscale status --json exited ${result.code}: ${result.stderr.trim()}`,
69
+ ["tailscale", "status", "--json"],
70
+ result,
71
+ );
72
+ }
73
+ let parsed: unknown;
74
+ try {
75
+ parsed = JSON.parse(result.stdout);
76
+ } catch (err) {
77
+ throw new TailscaleError(
78
+ `failed to parse tailscale status JSON: ${err instanceof Error ? err.message : String(err)}`,
79
+ ["tailscale", "status", "--json"],
80
+ result,
81
+ );
82
+ }
83
+ const self = (parsed as { Self?: { DNSName?: unknown } }).Self;
84
+ const dnsName = self?.DNSName;
85
+ if (typeof dnsName !== "string" || dnsName.length === 0) {
86
+ throw new TailscaleError(
87
+ "tailscale status did not return Self.DNSName — is this machine logged in?",
88
+ ["tailscale", "status", "--json"],
89
+ result,
90
+ );
91
+ }
92
+ return dnsName.replace(/\.$/, "");
93
+ }
94
+
95
+ /**
96
+ * Detect whether wildcard MagicDNS is active — i.e. whether subdomains of the
97
+ * current machine (vault.<fqdn>, notes.<fqdn>, …) resolve back to this node.
98
+ *
99
+ * Tailscale's standard MagicDNS gives each machine a single hostname and does
100
+ * not auto-resolve arbitrary subdomains; wildcard MagicDNS exists in the
101
+ * Services feature but requires explicit advertisement. For launch we return
102
+ * false (path-routing) and let a later PR add real detection once the
103
+ * subdomain-per-service path is supported end-to-end.
104
+ */
105
+ export async function detectWildcardMagicDNS(_runner: Runner): Promise<boolean> {
106
+ return false;
107
+ }
@@ -0,0 +1,28 @@
1
+ export interface CommandResult {
2
+ code: number;
3
+ stdout: string;
4
+ stderr: string;
5
+ }
6
+
7
+ export type Runner = (cmd: readonly string[]) => Promise<CommandResult>;
8
+
9
+ export async function defaultRunner(cmd: readonly string[]): Promise<CommandResult> {
10
+ const proc = Bun.spawn([...cmd], { stdout: "pipe", stderr: "pipe" });
11
+ const [stdout, stderr, code] = await Promise.all([
12
+ new Response(proc.stdout).text(),
13
+ new Response(proc.stderr).text(),
14
+ proc.exited,
15
+ ]);
16
+ return { code, stdout, stderr };
17
+ }
18
+
19
+ export class TailscaleError extends Error {
20
+ override name = "TailscaleError";
21
+ constructor(
22
+ message: string,
23
+ public readonly cmd: readonly string[],
24
+ public readonly result: CommandResult,
25
+ ) {
26
+ super(message);
27
+ }
28
+ }