@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.
- package/LICENSE +661 -0
- package/README.md +284 -0
- package/package.json +31 -0
- package/src/__tests__/auth.test.ts +101 -0
- package/src/__tests__/auto-wire.test.ts +283 -0
- package/src/__tests__/cli.test.ts +192 -0
- package/src/__tests__/cloudflare-config.test.ts +54 -0
- package/src/__tests__/cloudflare-detect.test.ts +68 -0
- package/src/__tests__/cloudflare-state.test.ts +92 -0
- package/src/__tests__/cloudflare-tunnel.test.ts +207 -0
- package/src/__tests__/config.test.ts +18 -0
- package/src/__tests__/env-file.test.ts +125 -0
- package/src/__tests__/expose-auth-preflight.test.ts +201 -0
- package/src/__tests__/expose-cloudflare.test.ts +484 -0
- package/src/__tests__/expose-interactive.test.ts +703 -0
- package/src/__tests__/expose-last-provider.test.ts +113 -0
- package/src/__tests__/expose-off-auto.test.ts +269 -0
- package/src/__tests__/expose-state.test.ts +101 -0
- package/src/__tests__/expose.test.ts +1581 -0
- package/src/__tests__/hub-control.test.ts +346 -0
- package/src/__tests__/hub-server.test.ts +157 -0
- package/src/__tests__/hub.test.ts +116 -0
- package/src/__tests__/install.test.ts +1145 -0
- package/src/__tests__/lifecycle.test.ts +608 -0
- package/src/__tests__/migrate.test.ts +422 -0
- package/src/__tests__/notes-serve.test.ts +135 -0
- package/src/__tests__/port-assign.test.ts +178 -0
- package/src/__tests__/process-state.test.ts +140 -0
- package/src/__tests__/scribe-config.test.ts +193 -0
- package/src/__tests__/scribe-provider-interactive.test.ts +361 -0
- package/src/__tests__/services-manifest.test.ts +177 -0
- package/src/__tests__/status.test.ts +347 -0
- package/src/__tests__/tailscale-commands.test.ts +111 -0
- package/src/__tests__/tailscale-detect.test.ts +64 -0
- package/src/__tests__/vault-auth-status.test.ts +164 -0
- package/src/__tests__/vault-tokens-create-interactive.test.ts +183 -0
- package/src/__tests__/well-known.test.ts +214 -0
- package/src/auto-wire.ts +184 -0
- package/src/cli.ts +482 -0
- package/src/cloudflare/config.ts +58 -0
- package/src/cloudflare/detect.ts +58 -0
- package/src/cloudflare/state.ts +96 -0
- package/src/cloudflare/tunnel.ts +135 -0
- package/src/commands/auth.ts +69 -0
- package/src/commands/expose-auth-preflight.ts +217 -0
- package/src/commands/expose-cloudflare.ts +329 -0
- package/src/commands/expose-interactive.ts +428 -0
- package/src/commands/expose-off-auto.ts +199 -0
- package/src/commands/expose.ts +522 -0
- package/src/commands/install.ts +422 -0
- package/src/commands/lifecycle.ts +324 -0
- package/src/commands/migrate.ts +253 -0
- package/src/commands/scribe-provider-interactive.ts +269 -0
- package/src/commands/status.ts +238 -0
- package/src/commands/vault-tokens-create-interactive.ts +137 -0
- package/src/commands/vault.ts +17 -0
- package/src/config.ts +16 -0
- package/src/env-file.ts +76 -0
- package/src/expose-last-provider.ts +71 -0
- package/src/expose-state.ts +125 -0
- package/src/help.ts +279 -0
- package/src/hub-control.ts +254 -0
- package/src/hub-origin.ts +44 -0
- package/src/hub-server.ts +113 -0
- package/src/hub.ts +674 -0
- package/src/notes-serve.ts +135 -0
- package/src/port-assign.ts +125 -0
- package/src/process-state.ts +111 -0
- package/src/scribe-config.ts +149 -0
- package/src/service-spec.ts +296 -0
- package/src/services-manifest.ts +171 -0
- package/src/tailscale/commands.ts +41 -0
- package/src/tailscale/detect.ts +107 -0
- package/src/tailscale/run.ts +28 -0
- package/src/vault/auth-status.ts +179 -0
- 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
|
+
}
|