1/**
2 * `t3 pair` - mint a pairing token for an already-running server and print it
3 * as a QR code, without restarting anything.
4 *
5 * Discovery reads the `server-runtime.json` a live server persists next to its
6 * database, then confirms the process is actually answering by fetching its
7 * public environment descriptor. Inside a linked git worktree the worktree's
8 * own `.t3` is checked first (matching dev-runner precedence); otherwise the
9 * shared T3 home. `--tailscale` publishes the server over Tailscale Serve
10 * HTTPS and pairs through the tailnet URL instead.
11 */
12import {
13 type AuthEnvironmentScope,
14 AuthStandardClientScopes,
15 ExecutionEnvironmentDescriptor,
16 PortSchema,
17} from "@t3tools/contracts";
18import { resolveWorktreeT3Home } from "@t3tools/shared/devHome";
19import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
20import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
21import {
22 buildTailscaleHttpsBaseUrl,
23 DEFAULT_TAILSCALE_SERVE_PORT,
24 ensureTailscaleServe,
25 readTailscaleStatus,
26} from "@t3tools/tailscale";
27import * as Config from "effect/Config";
28import * as Console from "effect/Console";
29import * as DateTime from "effect/DateTime";
30import * as Duration from "effect/Duration";
31import * as Effect from "effect/Effect";
32import * as Layer from "effect/Layer";
33import * as Option from "effect/Option";
34import * as References from "effect/References";
35import * as Schema from "effect/Schema";
36import { Command, Flag, GlobalFlag } from "effect/cli";
37import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
38
39import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
40import * as ServerConfig from "../config.ts";
41import { resolveBaseDir } from "../os-jank.ts";
42import {
43 type PersistedServerRuntimeState,
44 isProcessAlive,
45 readPersistedServerRuntimeState,
46} from "../serverRuntimeState.ts";
47import {
48 buildPairingUrl,
49 formatHostForUrl,
50 isLoopbackHost,
51 isWildcardHost,
52 renderTerminalQrCode,
53 resolveHeadlessConnectionString,
54} from "../startupAccess.ts";
55import { authScopesFlag } from "./authScopes.ts";
56import { baseDirFlag, DurationFromString } from "./config.ts";
57
58const WELL_KNOWN_ENVIRONMENT_PATH = "/.well-known/t3/environment";
59const PAIR_PROBE_TIMEOUT = Duration.millis(2_500);
60// Tailscale provisions an HTTPS certificate on the first request to a fresh
61// serve mapping, which can take a few seconds.
62const TAILSCALE_PROBE_ATTEMPTS = 5;
63const TAILSCALE_PROBE_RETRY_DELAY = Duration.seconds(1);
64
65export type PairStateVariant = "userdata" | "dev";
66
67// deriveServerPaths only checks devUrl for undefined-ness when picking the
68// dev-vs-userdata state directory; the value itself is not used.
69const DEV_VARIANT_PLACEHOLDER_URL = new URL("http://localhost");
70
71export class NoRunningServerError extends Schema.TaggedError<NoRunningServerError>()(
72 "NoRunningServerError",
73 {
74 checkedStatePaths: Schema.Array(Schema.String),
75 },
76) {
77 override get message(): string {
78 return [
79 "No running T3 Code server found.",
80 ...this.checkedStatePaths.map((statePath) => ` checked ${statePath}`),
81 "Start one with `npx t3 serve`, or connect this machine with T3 Connect: `npx t3 connect`.",
82 ].join("\n");
83 }
84}
85
86// Each tailscale failure gets its own class (same reasoning as
87// scripts/lib/dev-share.ts): distinct caller-visible message, distinct remedy.
88export class TailscaleUnavailableError extends Schema.TaggedError<TailscaleUnavailableError>()(
89 "TailscaleUnavailableError",
90 { cause: Schema.Defect() },
91) {
92 override get message(): string {
93 return "Could not talk to Tailscale. Is tailscaled running? Try `tailscale status`.";
94 }
95}
96
97export class MagicDnsNameMissingError extends Schema.TaggedError<MagicDnsNameMissingError>()(
98 "MagicDnsNameMissingError",
99 {},
100) {
101 override get message(): string {
102 return "This machine has no MagicDNS name. Run `tailscale up` and enable MagicDNS.";
103 }
104}
105
106export class ServesOtherEnvironmentError extends Schema.TaggedError<ServesOtherEnvironmentError>()(
107 "ServesOtherEnvironmentError",
108 { servePort: Schema.Number },
109) {
110 override get message(): string {
111 return `Tailscale Serve on HTTPS port ${String(this.servePort)} already fronts a different T3 Code server. Pass --tailscale-serve-port to publish this one on another port.`;
112 }
113}
114
115export class TailscaleServeFailedError extends Schema.TaggedError<TailscaleServeFailedError>()(
116 "TailscaleServeFailedError",
117 { servePort: Schema.Number, cause: Schema.Defect() },
118) {
119 override get message(): string {
120 return `tailscale serve failed for HTTPS port ${String(this.servePort)}. Run \`tailscale serve --https=${String(this.servePort)} --bg <local-url>\` by hand to see why.`;
121 }
122}
123
124export class ServePortOccupiedError extends Schema.TaggedError<ServePortOccupiedError>()(
125 "ServePortOccupiedError",
126 { servePort: Schema.Number },
127) {
128 override get message(): string {
129 return `HTTPS port ${String(this.servePort)} on the tailnet already serves something that is not a T3 Code server. Pass --tailscale-serve-port to publish this one on another port.`;
130 }
131}
132
133/** The URL a browser or phone should pair through, absent Tailscale. */
134export const resolveDirectPairingBaseUrl = (state: PersistedServerRuntimeState): string =>
135 state.devUrl ?? resolveHeadlessConnectionString(state.host, state.port);
136
137export class DevServerNotProxiableError extends Schema.TaggedError<DevServerNotProxiableError>()(
138 "DevServerNotProxiableError",
139 { devUrl: Schema.String },
140) {
141 override get message(): string {
142 return `Tailscale Serve can only proxy plain-HTTP local targets, and this dev server runs at ${this.devUrl}. Pair without --tailscale instead.`;
143 }
144}
145
146const isDevServerNotProxiableError = Schema.is(DevServerNotProxiableError);
147
148/**
149 * The local endpoint Tailscale Serve should proxy to. Dev servers are
150 * single-origin, so the web dev server's port is the one to publish; the
151 * backend rides along behind Vite's proxy. Serve targets are always plain
152 * HTTP, so an HTTPS dev URL cannot be proxied and is rejected.
153 */
154export const resolveTailscaleLocalTarget = (
155 state: PersistedServerRuntimeState,
156): { readonly localPort: number; readonly localHost?: string } | DevServerNotProxiableError => {
157 if (state.devUrl !== undefined) {
158 const devUrl = new URL(state.devUrl);
159 if (devUrl.protocol !== "http:") {
160 return new DevServerNotProxiableError({ devUrl: state.devUrl });
161 }
162 const localPort = devUrl.port.length > 0 ? Number.parseInt(devUrl.port, 10) : 80;
163 return isLoopbackHost(devUrl.hostname)
164 ? { localPort }
165 : { localPort, localHost: devUrl.hostname };
166 }
167 // A server bound to one specific interface does not answer on loopback, so
168 // the proxy has to target that interface directly.
169 if (state.host !== undefined && !isWildcardHost(state.host) && !isLoopbackHost(state.host)) {
170 return { localPort: state.port, localHost: formatHostForUrl(state.host) };
171 }
172 return { localPort: state.port };
173};
174
175const formatPairOutput = (input: {
176 readonly serverLabel: string;
177 readonly origin: string;
178 readonly pairingUrl: string;
179 readonly token: string;
180 readonly expiresAt: DateTime.Utc;
181 readonly notes: ReadonlyArray<string>;
182}): string =>
183 [
184 `Pairing with ${input.serverLabel} (${input.origin}).`,
185 "",
186 renderTerminalQrCode(input.pairingUrl),
187 "",
188 `Pairing URL: ${input.pairingUrl}`,
189 `Token: ${input.token}`,
190 `Expires: ${DateTime.formatIso(input.expiresAt)}`,
191 ...input.notes.flatMap((note) => ["", `Note: ${note}`]),
192 "",
193 ].join("\n");
194
195/**
196 * Three outcomes, because they drive different decisions: a T3 descriptor
197 * (pair with it), nothing answering (safe to configure Tailscale Serve), or
198 * something answering that is not a T3 server (do NOT overwrite its mapping).
199 */
200type EnvironmentProbeResult =
201 | { readonly _tag: "descriptor"; readonly descriptor: ExecutionEnvironmentDescriptor }
202 | { readonly _tag: "unreachable" }
203 | { readonly _tag: "not-a-t3-server" };
204
205const probeEnvironmentDescriptor = (
206 baseUrl: string,
207): Effect.Effect<EnvironmentProbeResult, never, HttpClient.HttpClient> =>
208 Effect.gen(function* () {
209 const client = yield* HttpClient.HttpClient;
210 const request = HttpClientRequest.get(new URL(WELL_KNOWN_ENVIRONMENT_PATH, baseUrl).toString());
211 const response = yield* client.execute(request).pipe(
212 Effect.timeout(PAIR_PROBE_TIMEOUT),
213 // Transport failure or timeout: nothing (reachable) is listening there.
214 Effect.mapError(() => ({ _tag: "unreachable" }) as const),
215 );
216 // Bad-gateway family means a proxy (Tailscale Serve) answered for a
217 // backend that is gone — a stale mapping, not a live occupant. Treating
218 // it as unreachable lets `t3 pair --tailscale` repair its own mapping
219 // after the server's port changed.
220 if (response.status === 502 || response.status === 503 || response.status === 504) {
221 return { _tag: "unreachable" } as const;
222 }
223 // Anything else that answered HTTP but not with a valid descriptor is
224 // some other service.
225 const descriptor = yield* HttpClientResponse.filterStatusOk(response).pipe(
226 Effect.flatMap(HttpClientResponse.schemaBodyJson(ExecutionEnvironmentDescriptor)),
227 Effect.mapError(() => ({ _tag: "not-a-t3-server" }) as const),
228 );
229 return { _tag: "descriptor", descriptor } as const;
230 }).pipe(Effect.catch((outcome) => Effect.succeed(outcome)));
231
232interface DiscoveredPairTarget {
233 readonly baseDir: string;
234 readonly variant: PairStateVariant;
235 readonly state: PersistedServerRuntimeState;
236 readonly descriptor: ExecutionEnvironmentDescriptor;
237}
238
239const discoverPairTarget = Effect.fn("pair.discoverPairTarget")(function* (
240 explicitBaseDir: string | undefined,
241) {
242 const bases: Array<string> = [];
243 if (explicitBaseDir !== undefined && explicitBaseDir.trim().length > 0) {
244 bases.push(yield* resolveBaseDir(explicitBaseDir));
245 } else {
246 // Same precedence as dev-runner: inside a linked worktree its own `.t3`
247 // outranks the shared home, so `t3 pair` in a worktree pairs with the dev
248 // server under test rather than the daily-driver install.
249 const worktreeHome = yield* resolveWorktreeT3Home(process.cwd());
250 if (worktreeHome !== undefined) {
251 bases.push(worktreeHome);
252 }
253 const envHome = yield* Config.String("T3CODE_HOME").pipe(Config.option);
254 bases.push(yield* resolveBaseDir(Option.getOrUndefined(envHome)));
255 }
256
257 const checkedStatePaths: Array<string> = [];
258 for (const baseDir of new Set(bases)) {
259 for (const variant of ["userdata", "dev"] as const) {
260 const derivedPaths = yield* ServerConfig.deriveServerPaths(
261 baseDir,
262 variant === "dev" ? DEV_VARIANT_PLACEHOLDER_URL : undefined,
263 {},
264 );
265 const statePath = derivedPaths.serverRuntimeStatePath;
266 checkedStatePaths.push(statePath);
267 const state = yield* readPersistedServerRuntimeState(statePath);
268 if (Option.isNone(state)) {
269 continue;
270 }
271 // The pid check guards against a dead server's state file whose port
272 // was since reused by a different server: pairing would then mint a
273 // token in the old database while the QR code points at the new server.
274 if (!isProcessAlive(state.value.pid)) {
275 continue;
276 }
277 const probed = yield* probeEnvironmentDescriptor(state.value.origin);
278 if (probed._tag !== "descriptor") {
279 continue;
280 }
281 return {
282 baseDir,
283 variant,
284 state: state.value,
285 descriptor: probed.descriptor,
286 } satisfies DiscoveredPairTarget;
287 }
288 }
289 return yield* new NoRunningServerError({ checkedStatePaths });
290});
291
292/**
293 * Server config pointed at the discovered server's state directory, so the
294 * minted token lands in the database the running server reads from. Built by
295 * hand rather than through `resolveServerConfig` to keep the dev-vs-userdata
296 * choice pinned to where the runtime state was actually found, independent of
297 * ambient environment variables.
298 */
299const makePairServerConfig = Effect.fn(function* (input: {
300 readonly target: DiscoveredPairTarget;
301 readonly logLevel: ServerConfig.ServerConfig["Service"]["logLevel"];
302}) {
303 const { baseDir, variant, state } = input.target;
304 // The state-dir variant does not imply dev-ness: a worktree dev server uses
305 // an explicit home and therefore lands in `userdata`. The recorded devUrl is
306 // what actually marks a dev server.
307 const devUrl = state.devUrl !== undefined ? new URL(state.devUrl) : undefined;
308 const derivedPaths = yield* ServerConfig.deriveServerPaths(
309 baseDir,
310 variant === "dev" ? DEV_VARIANT_PLACEHOLDER_URL : undefined,
311 {},
312 );
313 return ServerConfig.make({
314 logLevel: input.logLevel,
315 traceMinLevel: "Info",
316 traceTimingEnabled: false,
317 traceBatchWindowMs: 1_000,
318 traceMaxBytes: 10 * 1024 * 1024,
319 traceMaxFiles: 10,
320 otlpTracesUrl: undefined,
321 otlpMetricsUrl: undefined,
322 otlpLogsUrl: undefined,
323 otlpTracesExport: DEFAULT_SIGNAL_EXPORT,
324 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,
325 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,
326 otelEnvironment: OtelEnvironment.none,
327 mode: "web",
328 port: state.port,
329 host: state.host,
330 cwd: process.cwd(),
331 baseDir,
332 ...derivedPaths,
333 staticDir: undefined,
334 devUrl,
335 devAllowedOrigins: [],
336 noBrowser: true,
337 startupPresentation: "headless",
338 desktopBootstrapToken: undefined,
339 desktopTelemetryFd: undefined,
340 desktopBrowserFd: undefined,
341 desktopBrowserControlFd: undefined,
342 desktopTelemetryControlFd: undefined,
343 resourceMonitorPath: undefined,
344 autoBootstrapProjectFromCwd: false,
345 logWebSocketEvents: false,
346 tailscaleServeEnabled: false,
347 tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT,
348 });
349});
350
351const awaitEnvironmentDescriptor = Effect.fn(function* (baseUrl: string) {
352 let last: EnvironmentProbeResult = { _tag: "unreachable" };
353 for (let attempt = 0; attempt < TAILSCALE_PROBE_ATTEMPTS; attempt += 1) {
354 last = yield* probeEnvironmentDescriptor(baseUrl);
355 if (last._tag === "descriptor") {
356 return last;
357 }
358 yield* Effect.sleep(TAILSCALE_PROBE_RETRY_DELAY);
359 }
360 return last;
361});
362
363const resolveTailscalePairingBase = Effect.fn("pair.resolveTailscalePairingBase")(
364 function* (input: { readonly target: DiscoveredPairTarget; readonly servePort: number }) {
365 const notes: Array<string> = [];
366 const status = yield* readTailscaleStatus.pipe(
367 Effect.mapError((cause) => new TailscaleUnavailableError({ cause })),
368 );
369 if (status.magicDnsName === null) {
370 return yield* new MagicDnsNameMissingError();
371 }
372 const baseUrl = buildTailscaleHttpsBaseUrl({
373 magicDnsName: status.magicDnsName,
374 servePort: input.servePort,
375 });
376
377 // Only an unreachable port, or a mapping already fronting this exact
378 // environment, is safe to (re)configure. Any other responder — T3 or not
379 // — must not have its mapping silently replaced.
380 const existing = yield* probeEnvironmentDescriptor(baseUrl);
381 if (existing._tag === "descriptor") {
382 if (existing.descriptor.environmentId !== input.target.descriptor.environmentId) {
383 return yield* new ServesOtherEnvironmentError({ servePort: input.servePort });
384 }
385 // Matching environment id proves the mapping reaches this server, but
386 // not through which port: for a dev server it may front the backend
387 // (whose /.well-known also answers) while /pair only renders through
388 // the web origin. Reuse as-is for regular servers; fall through and
389 // repoint our own mapping at the web port for dev servers.
390 if (input.target.state.devUrl === undefined) {
391 return { baseUrl, notes };
392 }
393 }
394 if (existing._tag === "not-a-t3-server") {
395 return yield* new ServePortOccupiedError({ servePort: input.servePort });
396 }
397
398 const localTarget = resolveTailscaleLocalTarget(input.target.state);
399 if (isDevServerNotProxiableError(localTarget)) {
400 return yield* localTarget;
401 }
402 yield* ensureTailscaleServe({
403 localPort: localTarget.localPort,
404 servePort: input.servePort,
405 ...(localTarget.localHost !== undefined ? { localHost: localTarget.localHost } : {}),
406 }).pipe(
407 Effect.mapError(
408 (cause) => new TailscaleServeFailedError({ servePort: input.servePort, cause }),
409 ),
410 );
411 notes.push(
412 `Tailscale Serve now maps ${baseUrl} to this server and persists across restarts. Remove it with \`tailscale serve --https=${String(input.servePort)} off\`.`,
413 );
414
415 const probed = yield* awaitEnvironmentDescriptor(baseUrl);
416 if (probed._tag === "descriptor") {
417 if (probed.descriptor.environmentId !== input.target.descriptor.environmentId) {
418 return yield* new ServesOtherEnvironmentError({ servePort: input.servePort });
419 }
420 } else {
421 notes.push(
422 "The HTTPS endpoint has not answered yet. First use can take a moment while Tailscale provisions certificates.",
423 );
424 }
425 return { baseUrl, notes };
426 },
427);
428
429const mintPairingLink = Effect.fn("pair.mintPairingLink")(function* (input: {
430 readonly config: ServerConfig.ServerConfig["Service"];
431 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
432 readonly ttl: Option.Option<Duration.Duration>;
433 readonly label: Option.Option<string>;
434}) {
435 return yield* Effect.gen(function* () {
436 const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
437 return yield* environmentAuth.createPairingLink({
438 scopes: input.scopes,
439 subject: "one-time-token",
440 label: Option.getOrElse(input.label, () => "t3 pair"),
441 ...(Option.isSome(input.ttl) ? { ttl: input.ttl.value } : {}),
442 });
443 }).pipe(
444 Effect.provide(
445 EnvironmentAuth.layerRuntime.pipe(
446 Layer.provide(ServerConfig.layer(input.config)),
447 Layer.provide(Layer.succeed(References.MinimumLogLevel, input.config.logLevel)),
448 ),
449 ),
450 );
451});
452
453const ttlFlag = Flag.String("ttl").pipe(
454 Flag.withSchema(DurationFromString),
455 Flag.withDescription(
456 "Token TTL, for example `5m`, `1h`, or `15 minutes`. Defaults to 5 minutes.",
457 ),
458 Flag.optional,
459);
460
461const labelFlag = Flag.String("label").pipe(
462 Flag.withDescription("Optional label shown in the server's connections list."),
463 Flag.optional,
464);
465
466const tailscaleFlag = Flag.Boolean("tailscale").pipe(
467 Flag.withDescription(
468 "Publish the server over Tailscale Serve HTTPS and pair through the tailnet URL.",
469 ),
470 Flag.withDefault(false),
471);
472
473const tailscaleServePortFlag = Flag.Int("tailscale-serve-port").pipe(
474 Flag.withSchema(PortSchema),
475 Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale is enabled."),
476 Flag.withDefault(DEFAULT_TAILSCALE_SERVE_PORT),
477);
478
479export const pairCommand = Command.make("pair", {
480 baseDir: baseDirFlag,
481 scopes: authScopesFlag(AuthStandardClientScopes),
482 ttl: ttlFlag,
483 label: labelFlag,
484 tailscale: tailscaleFlag,
485 tailscaleServePort: tailscaleServePortFlag,
486}).pipe(
487 Command.withDescription(
488 "Mint a pairing token for a running T3 Code server and print it as a QR code.",
489 ),
490 Command.withHandler((flags) =>
491 Effect.gen(function* () {
492 const cliLogLevel = yield* GlobalFlag.LogLevel;
493 // Default to Warn so storage/migration chatter cannot bury the QR code;
494 // an explicit --log-level still wins.
495 const logLevel = Option.getOrElse(cliLogLevel, () => "Warn" as const);
496
497 const target = yield* discoverPairTarget(Option.getOrUndefined(flags.baseDir));
498
499 const notes: Array<string> = [];
500 let pairingBaseUrl: string;
501 if (flags.tailscale) {
502 const resolved = yield* resolveTailscalePairingBase({
503 target,
504 servePort: flags.tailscaleServePort,
505 });
506 pairingBaseUrl = resolved.baseUrl;
507 notes.push(...resolved.notes);
508 } else {
509 pairingBaseUrl = resolveDirectPairingBaseUrl(target.state);
510 if (isLoopbackHost(new URL(pairingBaseUrl).hostname)) {
511 notes.push(
512 "This URL is only reachable from this machine. Re-run with --tailscale, or restart the server with a reachable --host.",
513 );
514 }
515 if (target.variant === "dev" && target.state.devUrl === undefined) {
516 notes.push(
517 "This dev server did not record its web URL; restart it so pairing can go through the web origin.",
518 );
519 }
520 }
521
522 const config = yield* makePairServerConfig({ target, logLevel });
523 const issued = yield* mintPairingLink({
524 config,
525 scopes: flags.scopes,
526 ttl: flags.ttl,
527 label: flags.label,
528 });
529 const pairingUrl = buildPairingUrl(pairingBaseUrl, issued.credential);
530
531 yield* Console.log(
532 formatPairOutput({
533 serverLabel: target.descriptor.label,
534 origin: target.state.origin,
535 pairingUrl,
536 token: issued.credential,
537 expiresAt: issued.expiresAt,
538 notes,
539 }),
540 );
541 }).pipe(Effect.provide(FetchHttpClient.layer)),
542 ),
543);