1import {
2 type AuthEnvironmentScope,
3 AuthStandardClientScopes,
4 ExecutionEnvironmentDescriptor,
5 ORCHESTRATION_PROTOCOL_VERSION,
6 PortSchema,
7} from "@t3tools/contracts";
8import { resolveWorktreeT3Home } from "@t3tools/shared/devHome";
9import * as HostProcess from "@t3tools/shared/HostProcess";
10import { formatHostForUrl, isWildcardHost } from "@t3tools/shared/hostClassification";
11import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
12import { buildPairingAppLink } from "@t3tools/shared/remote";
13import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
14import {
15 buildTailscaleHttpsBaseUrl,
16 DEFAULT_TAILSCALE_SERVE_PORT,
17 ensureTailscaleServe,
18 readTailscaleServeRootHolder,
19 readTailscaleStatus,
20} from "@t3tools/tailscale";
21import * as Config from "effect/Config";
22import * as Console from "effect/Console";
23import * as DateTime from "effect/DateTime";
24import * as Duration from "effect/Duration";
25import * as Effect from "effect/Effect";
26import * as Layer from "effect/Layer";
27import * as Option from "effect/Option";
28import * as References from "effect/References";
29import * as Schema from "effect/Schema";
30import { Command, Flag, GlobalFlag } from "effect/cli";
31import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
32
33import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
34import * as ServerConfig from "../config.ts";
35import * as ManagedLimits from "../policy/managedLimits.ts";
36import * as ManagedPolicy from "../policy/ManagedPolicy.ts";
37import { resolveBaseDir } from "../os-jank.ts";
38import {
39 type PersistedServerRuntimeState,
40 isProcessAlive,
41 readPersistedServerRuntimeState,
42} from "../serverRuntimeState.ts";
43import {
44 buildPairingUrl,
45 isLoopbackHost,
46 renderTerminalQrCode,
47 resolveHeadlessConnectionString,
48} from "../startupAccess.ts";
49import { authScopesFlag } from "./authScopes.ts";
50import { baseDirFlag, DurationFromString } from "./config.ts";
51import { hostShellWord, resolveHostCliCommand } from "./invocation.ts";
52
53const WELL_KNOWN_ENVIRONMENT_PATH = "/.well-known/t3/environment";
54const PAIR_PROBE_TIMEOUT = Duration.millis(2_500);
55// Tailscale's first HTTPS request may take seconds to provision a certificate.
56const TAILSCALE_PROBE_ATTEMPTS = 5;
57const TAILSCALE_PROBE_RETRY_DELAY = Duration.seconds(1);
58
59export type PairStateVariant = "userdata" | "dev";
60
61// Only devUrl's presence selects dev state; its value is unused.
62const DEV_VARIANT_PLACEHOLDER_URL = new URL("http://localhost");
63
64export class NoRunningServerError extends Schema.TaggedError<NoRunningServerError>()(
65 "NoRunningServerError",
66 {
67 checkedStatePaths: Schema.Array(Schema.String),
68 inLinkedWorktree: Schema.Boolean,
69 serveCommand: Schema.String,
70 connectCommand: Schema.String,
71 },
72) {
73 override get message(): string {
74 return [
75 "No running T3 Code server found.",
76 ...this.checkedStatePaths.map((statePath) => ` checked ${statePath}`),
77 ...(this.inLinkedWorktree
78 ? ["In a linked worktree, pass --base-dir to pair with a server outside it."]
79 : []),
80 `Start one with \`${this.serveCommand}\`, or connect this machine with T3 Connect: \`${this.connectCommand}\`.`,
81 ].join("\n");
82 }
83}
84
85export class IncompatibleServerProtocolError extends Schema.TaggedError<IncompatibleServerProtocolError>()(
86 "IncompatibleServerProtocolError",
87 {
88 serverVersion: Schema.String,
89 serverProtocolVersion: Schema.Number,
90 pairCommand: Schema.String,
91 },
92) {
93 override get message(): string {
94 return [
95 `The running T3 Code server (${this.serverVersion}) speaks orchestration protocol ${String(this.serverProtocolVersion)}, but this CLI speaks protocol ${String(ORCHESTRATION_PROTOCOL_VERSION)}.`,
96 `Pair with the matching CLI instead: \`${this.pairCommand}\`.`,
97 ].join("\n");
98 }
99}
100
101export class TailscaleUnavailableError extends Schema.TaggedError<TailscaleUnavailableError>()(
102 "TailscaleUnavailableError",
103 { cause: Schema.Defect() },
104) {
105 override get message(): string {
106 return "Could not talk to Tailscale. Is tailscaled running? Try `tailscale status`.";
107 }
108}
109
110export class MagicDnsNameMissingError extends Schema.TaggedError<MagicDnsNameMissingError>()(
111 "MagicDnsNameMissingError",
112 {},
113) {
114 override get message(): string {
115 return "This machine has no MagicDNS name. Run `tailscale up` and enable MagicDNS.";
116 }
117}
118
119export class TailscaleServeNotEnabledError extends Schema.TaggedError<TailscaleServeNotEnabledError>()(
120 "TailscaleServeNotEnabledError",
121 {},
122) {
123 override get message(): string {
124 return "Tailscale Serve is not enabled on your tailnet. Enable HTTPS certificates at https://login.tailscale.com/admin/dns, then run this command again.";
125 }
126}
127
128export class ServesOtherEnvironmentError extends Schema.TaggedError<ServesOtherEnvironmentError>()(
129 "ServesOtherEnvironmentError",
130 { servePort: Schema.Number },
131) {
132 override get message(): string {
133 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.`;
134 }
135}
136
137export class TailscaleServeFailedError extends Schema.TaggedError<TailscaleServeFailedError>()(
138 "TailscaleServeFailedError",
139 { servePort: Schema.Number, cause: Schema.Defect() },
140) {
141 override get message(): string {
142 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.`;
143 }
144}
145
146export class TailscaleServeTargetRefusedError extends Schema.TaggedError<TailscaleServeTargetRefusedError>()(
147 "TailscaleServeTargetRefusedError",
148 { localHost: Schema.String },
149) {
150 override get message(): string {
151 return `This Tailscale install can only proxy to localhost, and the server listens on ${this.localHost}. Restart it with \`--host 127.0.0.1\` or \`--host 0.0.0.0\`, then run this command again.`;
152 }
153}
154
155export class ServePortOccupiedError extends Schema.TaggedError<ServePortOccupiedError>()(
156 "ServePortOccupiedError",
157 { servePort: Schema.Number },
158) {
159 override get message(): string {
160 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.`;
161 }
162}
163
164const resolveDirectPairingBaseUrl = (state: PersistedServerRuntimeState): string =>
165 state.devUrl ??
166 resolveHeadlessConnectionString(state.host, state.port, {
167 privateNetworkOnly: state.privateNetworkOnly,
168 });
169
170export class DevServerNotProxiableError extends Schema.TaggedError<DevServerNotProxiableError>()(
171 "DevServerNotProxiableError",
172 { devUrl: Schema.String },
173) {
174 override get message(): string {
175 return `Tailscale Serve can only proxy plain-HTTP local targets, and this dev server runs at ${this.devUrl}. Pair without --tailscale instead.`;
176 }
177}
178
179const isDevServerNotProxiableError = Schema.is(DevServerNotProxiableError);
180
181/** Publish the single-origin web dev port, which proxies the backend. Serve requires HTTP targets. */
182export const resolveTailscaleLocalTarget = (
183 state: PersistedServerRuntimeState,
184): { readonly localPort: number; readonly localHost?: string } | DevServerNotProxiableError => {
185 if (state.devUrl !== undefined) {
186 const devUrl = new URL(state.devUrl);
187 if (devUrl.protocol !== "http:") {
188 return new DevServerNotProxiableError({ devUrl: state.devUrl });
189 }
190 const localPort = devUrl.port.length > 0 ? Number.parseInt(devUrl.port, 10) : 80;
191 return isLoopbackHost(devUrl.hostname)
192 ? { localPort }
193 : { localPort, localHost: devUrl.hostname };
194 }
195 if (state.host !== undefined && !isWildcardHost(state.host)) {
196 return { localPort: state.port, localHost: formatHostForUrl(state.host) };
197 }
198 return { localPort: state.port };
199};
200
201const formatPairOutput = (input: {
202 readonly serverLabel: string;
203 readonly origin: string;
204 readonly pairingUrl: string;
205 readonly token: string;
206 readonly expiresAt: DateTime.Utc;
207 readonly notes: ReadonlyArray<string>;
208}): string =>
209 [
210 `Pairing with ${input.serverLabel} (${input.origin}).`,
211 "",
212 renderTerminalQrCode(input.pairingUrl),
213 "",
214 `Pairing URL: ${input.pairingUrl}`,
215 `App link: ${buildPairingAppLink(input.pairingUrl)}`,
216 `Token: ${input.token}`,
217 `Expires: ${DateTime.formatIso(input.expiresAt)}`,
218 ...input.notes.flatMap((note) => ["", `Note: ${note}`]),
219 "",
220 ].join("\n");
221
222type EnvironmentProbeResult =
223 | { readonly _tag: "descriptor"; readonly descriptor: ExecutionEnvironmentDescriptor }
224 | { readonly _tag: "unreachable" }
225 | { readonly _tag: "not-a-t3-server" };
226
227const probeEnvironmentDescriptor: (
228 baseUrl: string,
229) => Effect.Effect<EnvironmentProbeResult, never, HttpClient.HttpClient> = Effect.fn(
230 "pair.probeEnvironmentDescriptor",
231)(
232 function* (baseUrl: string) {
233 const client = yield* HttpClient.HttpClient;
234 const request = HttpClientRequest.get(new URL(WELL_KNOWN_ENVIRONMENT_PATH, baseUrl).toString());
235 const response = yield* client.execute(request).pipe(
236 Effect.timeout(PAIR_PROBE_TIMEOUT),
237 Effect.mapError(() => ({ _tag: "unreachable" }) as const),
238 );
239 // Gateway errors identify stale proxy mappings that pairing may repair.
240 if (response.status === 502 || response.status === 503 || response.status === 504) {
241 return { _tag: "unreachable" } as const;
242 }
243 const descriptor = yield* HttpClientResponse.filterStatusOk(response).pipe(
244 Effect.flatMap(HttpClientResponse.schemaBodyJson(ExecutionEnvironmentDescriptor)),
245 Effect.mapError(() => ({ _tag: "not-a-t3-server" }) as const),
246 );
247 return { _tag: "descriptor", descriptor } as const;
248 },
249 Effect.catch((outcome) => Effect.succeed(outcome)),
250);
251
252interface DiscoveredPairTarget {
253 readonly baseDir: string;
254 readonly variant: PairStateVariant;
255 readonly state: PersistedServerRuntimeState;
256 readonly descriptor: ExecutionEnvironmentDescriptor;
257}
258
259const discoverPairTarget = Effect.fn("pair.discoverPairTarget")(function* (
260 explicitBaseDir: string | undefined,
261) {
262 const bases: Array<string> = [];
263 let inLinkedWorktree = false;
264 const hasExplicitBaseDir = explicitBaseDir !== undefined && explicitBaseDir.trim().length > 0;
265 if (hasExplicitBaseDir) {
266 bases.push(yield* resolveBaseDir(explicitBaseDir));
267 } else {
268 // Same precedence as dev-runner: inside a linked worktree only its own
269 // `.t3` counts. Minting opens the target database with this checkout's
270 // migrations, so falling back to the shared home could migrate the live
271 // install with unreleased schema.
272 const worktreeHome = yield* resolveWorktreeT3Home(yield* HostProcess.WorkingDirectory);
273 if (worktreeHome !== undefined) {
274 bases.push(worktreeHome);
275 inLinkedWorktree = true;
276 } else {
277 const envHome = yield* Config.String("T3CODE_HOME").pipe(Config.option);
278 bases.push(yield* resolveBaseDir(Option.getOrUndefined(envHome)));
279 }
280 }
281
282 const checkedStatePaths: Array<string> = [];
283 for (const baseDir of new Set(bases)) {
284 for (const variant of ["userdata", "dev"] as const) {
285 const derivedPaths = yield* ServerConfig.deriveServerPaths(
286 baseDir,
287 variant === "dev" ? DEV_VARIANT_PLACEHOLDER_URL : undefined,
288 {},
289 );
290 const statePath = derivedPaths.serverRuntimeStatePath;
291 checkedStatePaths.push(statePath);
292 const state = yield* readPersistedServerRuntimeState(statePath);
293 if (Option.isNone(state)) {
294 continue;
295 }
296 // A dead server's port may be reused; do not mint tokens in its old database for another server.
297 if (!isProcessAlive(state.value.pid)) {
298 continue;
299 }
300 const probed = yield* probeEnvironmentDescriptor(state.value.origin);
301 if (probed._tag !== "descriptor") {
302 continue;
303 }
304 return {
305 baseDir,
306 variant,
307 state: state.value,
308 descriptor: probed.descriptor,
309 } satisfies DiscoveredPairTarget;
310 }
311 }
312 const baseDirArgs = hasExplicitBaseDir ? ["--base-dir", explicitBaseDir] : [];
313 return yield* new NoRunningServerError({
314 checkedStatePaths,
315 inLinkedWorktree,
316 serveCommand: (yield* resolveHostCliCommand("serve", baseDirArgs)).command,
317 connectCommand: (yield* resolveHostCliCommand("connect", baseDirArgs)).command,
318 });
319});
320
321/** Use the discovered state directory, independent of ambient config variables. */
322const makePairServerConfig = Effect.fn(function* (input: {
323 readonly target: DiscoveredPairTarget;
324 readonly logLevel: ServerConfig.ServerConfig["Service"]["logLevel"];
325}) {
326 const { baseDir, variant, state } = input.target;
327 // Worktree dev servers can use `userdata`; only the recorded devUrl marks a dev server.
328 const devUrl = state.devUrl !== undefined ? new URL(state.devUrl) : undefined;
329 const derivedPaths = yield* ServerConfig.deriveServerPaths(
330 baseDir,
331 variant === "dev" ? DEV_VARIANT_PLACEHOLDER_URL : undefined,
332 {},
333 );
334 return ServerConfig.make({
335 logLevel: input.logLevel,
336 traceMinLevel: "Info",
337 traceTimingEnabled: false,
338 traceBatchWindowMs: 1_000,
339 traceMaxBytes: 10 * 1024 * 1024,
340 traceMaxFiles: 10,
341 otlpTracesUrl: undefined,
342 otlpMetricsUrl: undefined,
343 otlpLogsUrl: undefined,
344 otlpTracesExport: DEFAULT_SIGNAL_EXPORT,
345 otlpMetricsExport: DEFAULT_SIGNAL_EXPORT,
346 otlpLogsExport: DEFAULT_SIGNAL_EXPORT,
347 otelEnvironment: OtelEnvironment.none,
348 mode: "web",
349 port: state.port,
350 host: state.host,
351 cwd: process.cwd(),
352 baseDir,
353 ...derivedPaths,
354 staticDir: undefined,
355 devUrl,
356 devAllowedOrigins: [],
357 noBrowser: true,
358 startupPresentation: "headless",
359 desktopBootstrapToken: undefined,
360 desktopTelemetryFd: undefined,
361 desktopBrowserFd: undefined,
362 desktopBrowserControlFd: undefined,
363 desktopTelemetryControlFd: undefined,
364 resourceMonitorPath: undefined,
365 autoBootstrapProjectFromCwd: false,
366 logWebSocketEvents: false,
367 tailscaleServeEnabled: false,
368 tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT,
369 });
370});
371
372const awaitEnvironmentDescriptor = Effect.fn(function* (baseUrl: string) {
373 let last: EnvironmentProbeResult = { _tag: "unreachable" };
374 for (let attempt = 0; attempt < TAILSCALE_PROBE_ATTEMPTS; attempt += 1) {
375 last = yield* probeEnvironmentDescriptor(baseUrl);
376 if (last._tag === "descriptor") {
377 return last;
378 }
379 yield* Effect.sleep(TAILSCALE_PROBE_RETRY_DELAY);
380 }
381 return last;
382});
383
384const resolveTailscalePairingBase = Effect.fn("pair.resolveTailscalePairingBase")(
385 function* (input: { readonly target: DiscoveredPairTarget; readonly servePort: number }) {
386 const notes: Array<string> = [];
387 const status = yield* readTailscaleStatus.pipe(
388 Effect.mapError((cause) => new TailscaleUnavailableError({ cause })),
389 );
390 if (status.magicDnsName === null) {
391 return yield* new MagicDnsNameMissingError();
392 }
393 // Without the capability `tailscale serve` blocks on an enablement URL
394 // until it times out, which names neither the setting nor where to change it.
395 if (status.httpsServeEnabled === false) {
396 return yield* new TailscaleServeNotEnabledError();
397 }
398 const baseUrl = buildTailscaleHttpsBaseUrl({
399 magicDnsName: status.magicDnsName,
400 servePort: input.servePort,
401 });
402
403 // Only unreachable ports or this environment's mappings are safe to reconfigure.
404 const existing = yield* probeEnvironmentDescriptor(baseUrl);
405 if (existing._tag === "descriptor") {
406 if (existing.descriptor.environmentId !== input.target.descriptor.environmentId) {
407 return yield* new ServesOtherEnvironmentError({ servePort: input.servePort });
408 }
409 // A dev backend also answers /.well-known, but /pair needs the web origin.
410 if (input.target.state.devUrl === undefined) {
411 return { baseUrl, notes };
412 }
413 }
414 if (existing._tag === "not-a-t3-server") {
415 return yield* new ServePortOccupiedError({ servePort: input.servePort });
416 }
417
418 const localTarget = resolveTailscaleLocalTarget(input.target.state);
419 if (isDevServerNotProxiableError(localTarget)) {
420 return yield* localTarget;
421 }
422 // The probe above fails whenever this host cannot reach its own MagicDNS name, so the Serve
423 // config, not the probe, decides whether the port is free.
424 const holder = yield* readTailscaleServeRootHolder(input.servePort, {
425 host: (localTarget.localHost ?? "127.0.0.1").replace(/^\[|\]$/gu, ""),
426 port: localTarget.localPort,
427 }).pipe(Effect.mapError((cause) => new TailscaleUnavailableError({ cause })));
428 if (holder === "other") {
429 return yield* new ServePortOccupiedError({ servePort: input.servePort });
430 }
431 yield* ensureTailscaleServe({
432 localPort: localTarget.localPort,
433 servePort: input.servePort,
434 ...(localTarget.localHost !== undefined ? { localHost: localTarget.localHost } : {}),
435 }).pipe(
436 Effect.mapError((cause) =>
437 cause._tag === "TailscaleCommandExitError" &&
438 cause.stderrDiagnostic === "unsupported-target" &&
439 localTarget.localHost !== undefined
440 ? new TailscaleServeTargetRefusedError({ localHost: localTarget.localHost })
441 : new TailscaleServeFailedError({ servePort: input.servePort, cause }),
442 ),
443 );
444 notes.push(
445 `Tailscale Serve now maps ${baseUrl} to this server and persists across restarts. Remove it with \`tailscale serve --https=${String(input.servePort)} --set-path=/ off\`.`,
446 );
447
448 const probed = yield* awaitEnvironmentDescriptor(baseUrl);
449 if (probed._tag === "descriptor") {
450 if (probed.descriptor.environmentId !== input.target.descriptor.environmentId) {
451 return yield* new ServesOtherEnvironmentError({ servePort: input.servePort });
452 }
453 } else {
454 notes.push(
455 "The HTTPS endpoint has not answered yet. First use can take a moment while Tailscale provisions certificates.",
456 );
457 }
458 return { baseUrl, notes };
459 },
460);
461
462const mintPairingLink = Effect.fn("pair.mintPairingLink")(function* (input: {
463 readonly config: ServerConfig.ServerConfig["Service"];
464 readonly scopes: ReadonlyArray<AuthEnvironmentScope>;
465 readonly ttl: Option.Option<Duration.Duration>;
466 readonly label: Option.Option<string>;
467}) {
468 return yield* Effect.gen(function* () {
469 const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
470 return yield* environmentAuth.createPairingLink({
471 scopes: input.scopes,
472 subject: "one-time-token",
473 label: Option.getOrElse(input.label, () => "t3 pair"),
474 ...(Option.isSome(input.ttl) ? { ttl: input.ttl.value } : {}),
475 });
476 }).pipe(
477 Effect.provide(
478 EnvironmentAuth.layerRuntime.pipe(
479 Layer.provide(ServerConfig.layer(input.config)),
480 Layer.provide(Layer.succeed(References.MinimumLogLevel, input.config.logLevel)),
481 ),
482 ),
483 );
484});
485
486const ttlFlag = Flag.String("ttl").pipe(
487 Flag.withSchema(DurationFromString),
488 Flag.withDescription(
489 "Token TTL, for example `5m`, `1h`, or `15 minutes`. Defaults to 5 minutes.",
490 ),
491 Flag.optional,
492);
493
494const labelFlag = Flag.String("label").pipe(
495 Flag.withDescription("Optional label shown in the server's connections list."),
496 Flag.optional,
497);
498
499const tailscaleFlag = Flag.Boolean("tailscale").pipe(
500 Flag.withDescription(
501 "Publish the server over Tailscale Serve HTTPS and pair through the tailnet URL.",
502 ),
503 Flag.withDefault(false),
504);
505
506const tailscaleServePortFlag = Flag.Int("tailscale-serve-port").pipe(
507 Flag.withSchema(PortSchema),
508 Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale is enabled."),
509 Flag.withDefault(DEFAULT_TAILSCALE_SERVE_PORT),
510);
511
512export const pairCommand = Command.make("pair", {
513 baseDir: baseDirFlag,
514 scopes: authScopesFlag(AuthStandardClientScopes),
515 ttl: ttlFlag,
516 label: labelFlag,
517 tailscale: tailscaleFlag,
518 tailscaleServePort: tailscaleServePortFlag,
519}).pipe(
520 Command.withDescription(
521 "Mint a pairing token for a running T3 Code server and print it as a QR code.",
522 ),
523 Command.withHandler((flags) =>
524 Effect.gen(function* () {
525 const cliLogLevel = yield* GlobalFlag.LogLevel;
526 const logLevel = Option.getOrElse(cliLogLevel, () => "Warn" as const);
527
528 const explicitBaseDir = Option.getOrUndefined(flags.baseDir);
529 const target = yield* discoverPairTarget(explicitBaseDir);
530 // Each protocol keeps auth in its own database, so a token minted here
531 // for another protocol's server would never be accepted by it. Refuse
532 // before any local layer opens (and migrates) that database.
533 const serverProtocolVersion = target.descriptor.orchestrationProtocolVersion ?? 1;
534 if (serverProtocolVersion !== ORCHESTRATION_PROTOCOL_VERSION) {
535 const platform = yield* HostProcess.Platform;
536 const baseDirArgs =
537 explicitBaseDir !== undefined && explicitBaseDir.trim().length > 0
538 ? ` --base-dir ${hostShellWord(platform, explicitBaseDir)}`
539 : "";
540 return yield* new IncompatibleServerProtocolError({
541 serverVersion: target.descriptor.serverVersion,
542 serverProtocolVersion,
543 pairCommand: `npx t3@${target.descriptor.serverVersion} pair${baseDirArgs}`,
544 });
545 }
546
547 const notes: Array<string> = [];
548 let pairingBaseUrl: string;
549 if (flags.tailscale) {
550 // Serve stays on after this command exits, so refuse before turning it on.
551 const policy = yield* ManagedPolicy.ManagedPolicy.pipe(Effect.provide(ManagedPolicy.layer));
552 if (!ManagedLimits.allowsRemoteClients(policy)) {
553 return yield* ManagedLimits.refuseRemoteClients(
554 policy,
555 "so Tailscale Serve cannot be turned on",
556 );
557 }
558 const resolved = yield* resolveTailscalePairingBase({
559 target,
560 servePort: flags.tailscaleServePort,
561 });
562 pairingBaseUrl = resolved.baseUrl;
563 notes.push(...resolved.notes);
564 } else {
565 pairingBaseUrl = resolveDirectPairingBaseUrl(target.state);
566 if (isLoopbackHost(new URL(pairingBaseUrl).hostname)) {
567 notes.push(
568 "This URL is only reachable from this machine. To reach it from your other devices, run `t3 up`, or re-run with --tailscale.",
569 );
570 } else if (target.state.privateNetworkOnly) {
571 notes.push(
572 "This URL works only from this machine's local network. From elsewhere, re-run with --tailscale or use T3 Connect.",
573 );
574 }
575 if (target.variant === "dev" && target.state.devUrl === undefined) {
576 notes.push(
577 "This dev server did not record its web URL; restart it so pairing can go through the web origin.",
578 );
579 }
580 }
581
582 const config = yield* makePairServerConfig({ target, logLevel });
583 const issued = yield* mintPairingLink({
584 config,
585 scopes: flags.scopes,
586 ttl: flags.ttl,
587 label: flags.label,
588 });
589 const pairingUrl = buildPairingUrl(pairingBaseUrl, issued.credential);
590
591 yield* Console.log(
592 formatPairOutput({
593 serverLabel: target.descriptor.label,
594 origin: target.state.origin,
595 pairingUrl,
596 token: issued.credential,
597 expiresAt: issued.expiresAt,
598 notes,
599 }),
600 );
601 }).pipe(Effect.provide(FetchHttpClient.layer)),
602 ),
603);