authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-07-31 03:18:55-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-31 03:18:55-07:00
log61514c1294ff79e2a9924551b2042fcc0d1c3a34
tree45902e3a467fb141d657d70c205a5637b1f73afb
parent894d6d68f555b97a070791590ed99695c09e4233
signature Signed by PGP key B5690EEEBB952194

feat(cli): `npx t3 pair` - generate QR code from a running server (#4955)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

6 files changed, 856 insertions(+), 1 deletions(-)

apps/server/src/bin.ts+2
...@@ -9,6 +9,7 @@ import * as NetService from "@t3tools/shared/Net";...@@ -9,6 +9,7 @@ import * as NetService from "@t3tools/shared/Net";
9import packageJson from "../package.json" with { type: "json" };9import packageJson from "../package.json" with { type: "json" };
10import { authCommand } from "./cli/auth.ts";10import { authCommand } from "./cli/auth.ts";
11import { connectCommand } from "./cli/connect.ts";11import { connectCommand } from "./cli/connect.ts";
12import { pairCommand } from "./cli/pair.ts";
12import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";13import { hasCloudPublicConfig } from "./cloud/publicConfig.ts";
13import { sharedServerCommandFlags } from "./cli/config.ts";14import { sharedServerCommandFlags } from "./cli/config.ts";
14import { projectCommand } from "./cli/project.ts";15import { projectCommand } from "./cli/project.ts";
...@@ -48,6 +49,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>...@@ -48,6 +49,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) =>
48 Command.withSubcommands([49 Command.withSubcommands([
49 startCommand,50 startCommand,
50 serveCommand,51 serveCommand,
52 pairCommand,
51 authCommand,53 authCommand,
52 projectCommand,54 projectCommand,
53 serviceCommand,55 serviceCommand,
apps/server/src/cli/pair.test.ts created+268
...@@ -0,0 +1,268 @@
1// @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises Node HTTP and filesystem boundaries.
2import * as NodeHttp from "node:http";
3import * as NodeFS from "node:fs";
4import * as NodeOS from "node:os";
5import * as NodePath from "node:path";
6
7import * as NodeServices from "@effect/platform-node/NodeServices";
8import * as NetService from "@t3tools/shared/Net";
9import { assert, describe, expect, it } from "@effect/vitest";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as TestConsole from "effect/testing/TestConsole";
13import { Command } from "effect/unstable/cli";
14
15import { cli } from "../bin.ts";
16import {
17 makePersistedServerRuntimeState,
18 persistServerRuntimeState,
19 type PersistedServerRuntimeState,
20} from "../serverRuntimeState.ts";
21import {
22 DevServerNotProxiableError,
23 resolveDirectPairingBaseUrl,
24 resolveTailscaleLocalTarget,
25} from "./pair.ts";
26
27const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
28
29const baseState = {
30 version: 1,
31 pid: 123,
32 port: 3_773,
33 origin: "http://127.0.0.1:3773",
34 startedAt: "2026-06-20T00:00:00.000Z",
35} as const satisfies PersistedServerRuntimeState;
36
37describe("pair base URL selection", () => {
38 it("pairs through the dev web origin when the server fronts a dev server", () => {
39 expect(resolveDirectPairingBaseUrl({ ...baseState, devUrl: "http://localhost:5733/" })).toBe(
40 "http://localhost:5733/",
41 );
42 });
43
44 it("pairs through the bound host when there is no dev server", () => {
45 expect(resolveDirectPairingBaseUrl({ ...baseState, host: "100.64.0.7" })).toBe(
46 "http://100.64.0.7:3773",
47 );
48 expect(resolveDirectPairingBaseUrl(baseState)).toBe("http://localhost:3773");
49 });
50});
51
52describe("pair tailscale local target", () => {
53 it("proxies the dev web port for dev servers", () => {
54 expect(resolveTailscaleLocalTarget({ ...baseState, devUrl: "http://localhost:5733/" })).toEqual(
55 { localPort: 5_733 },
56 );
57 // A dev server on a non-loopback interface must be proxied at that
58 // interface; tailscale serve defaults to 127.0.0.1 otherwise.
59 expect(
60 resolveTailscaleLocalTarget({ ...baseState, devUrl: "http://192.168.1.10:5733/" }),
61 ).toEqual({ localPort: 5_733, localHost: "192.168.1.10" });
62 // URL.hostname keeps IPv6 brackets, so the serve target stays valid.
63 expect(
64 resolveTailscaleLocalTarget({ ...baseState, devUrl: "http://[fd7a:115c::1]:5733/" }),
65 ).toEqual({ localPort: 5_733, localHost: "[fd7a:115c::1]" });
66 });
67
68 it("rejects HTTPS dev URLs, which tailscale serve cannot proxy", () => {
69 expect(
70 resolveTailscaleLocalTarget({ ...baseState, devUrl: "https://localhost:5733/" }),
71 ).toBeInstanceOf(DevServerNotProxiableError);
72 });
73
74 it("proxies the backend port directly otherwise", () => {
75 expect(resolveTailscaleLocalTarget(baseState)).toEqual({ localPort: 3_773 });
76 expect(resolveTailscaleLocalTarget({ ...baseState, host: "0.0.0.0" })).toEqual({
77 localPort: 3_773,
78 });
79 expect(resolveTailscaleLocalTarget({ ...baseState, host: "192.168.1.42" })).toEqual({
80 localPort: 3_773,
81 localHost: "192.168.1.42",
82 });
83 });
84});
85
86const runCli = (args: ReadonlyArray<string>) => Command.runWith(cli, { version: "0.0.0" })(args);
87
88const provideCliTestLayers = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
89 Effect.provide(effect, Layer.mergeAll(CliRuntimeLayer, TestConsole.layer));
90
91// Console output accumulates across CLI runs within a test, and each
92// Console.log call is one entry — so the latest command's output is the last
93// entry, even when it spans many lines.
94const captureStdout = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
95 provideCliTestLayers(
96 Effect.gen(function* () {
97 yield* effect;
98 return (
99 (yield* TestConsole.logLines).findLast(
100 (line): line is string => typeof line === "string",
101 ) ?? ""
102 );
103 }),
104 );
105
106const testDescriptor = {
107 environmentId: "pair-test-environment",
108 label: "pair-test",
109 platform: { os: "linux", arch: "x64" },
110 serverVersion: "0.0.1",
111 capabilities: { repositoryIdentity: true },
112};
113
114const withDescriptorServer = <A, E, R>(run: (origin: string) => Effect.Effect<A, E, R>) =>
115 Effect.acquireUseRelease(
116 Effect.callback<NodeHttp.Server>((resume) => {
117 const server = NodeHttp.createServer((request, response) => {
118 if (request.url === "/.well-known/t3/environment") {
119 response.writeHead(200, { "content-type": "application/json" });
120 response.end(JSON.stringify(testDescriptor));
121 return;
122 }
123 response.writeHead(404);
124 response.end();
125 });
126 server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server)));
127 }),
128 (server) => {
129 const address = server.address();
130 if (address === null || typeof address === "string") {
131 return Effect.die(new Error("Expected a TCP address"));
132 }
133 return run(`http://127.0.0.1:${String(address.port)}`);
134 },
135 (server) => Effect.sync(() => server.close()),
136 );
137
138describe("t3 pair", () => {
139 it.effect("mints a token and prints a QR pairing URL for a live server", () =>
140 withDescriptorServer((origin) =>
141 Effect.gen(function* () {
142 const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-test-"));
143 const port = Number(new URL(origin).port);
144 const statePath = NodePath.join(baseDir, "userdata", "server-runtime.json");
145 yield* persistServerRuntimeState({
146 path: statePath,
147 state: yield* makePersistedServerRuntimeState({
148 config: { host: "127.0.0.1", devUrl: undefined },
149 port,
150 }),
151 });
152
153 const output = yield* captureStdout(runCli(["pair", "--base-dir", baseDir]));
154
155 assert.include(output, `Pairing with pair-test (${origin})`);
156 assert.include(output, `Pairing URL: ${origin}/pair#token=`);
157 assert.isTrue(output.includes("█") || output.includes("▀") || output.includes("▄"));
158 // Loopback origins are not reachable from a phone; the output must say so.
159 assert.include(output, "only reachable from this machine");
160
161 const token = /#token=([A-Z2-9]+)/.exec(output)?.[1];
162 assert.isString(token);
163
164 // The token must be in the same store the running server reads.
165 const listed = yield* captureStdout(
166 runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]),
167 );
168 // @effect-diagnostics-next-line preferSchemaOverJson:off - CLI JSON output is decoded as a presentation DTO.
169 const credentials = JSON.parse(listed) as ReadonlyArray<{ readonly label?: string }>;
170 assert.equal(credentials.length, 1);
171 assert.equal(credentials[0]?.label, "t3 pair");
172 }),
173 ).pipe(Effect.provide(NodeServices.layer)),
174 );
175
176 it.effect("pairs through the recorded dev web URL for dev servers", () =>
177 withDescriptorServer((origin) =>
178 Effect.gen(function* () {
179 const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-dev-test-"));
180 const port = Number(new URL(origin).port);
181 const statePath = NodePath.join(baseDir, "dev", "server-runtime.json");
182 yield* persistServerRuntimeState({
183 path: statePath,
184 state: yield* makePersistedServerRuntimeState({
185 config: { host: undefined, devUrl: new URL("http://localhost:5733") },
186 port,
187 }),
188 });
189
190 const output = yield* captureStdout(runCli(["pair", "--base-dir", baseDir]));
191
192 assert.include(output, "Pairing URL: http://localhost:5733/pair#token=");
193 }),
194 ).pipe(Effect.provide(NodeServices.layer)),
195 );
196
197 it.effect("directs to t3 serve or t3 connect when no server is running", () =>
198 Effect.gen(function* () {
199 const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-none-test-"));
200
201 const error = yield* provideCliTestLayers(
202 runCli(["pair", "--base-dir", baseDir]).pipe(Effect.flip),
203 );
204
205 const rendered = String(
206 typeof error === "object" && error !== null && "cause" in error ? error.cause : error,
207 );
208 assert.include(rendered, "No running T3 Code server found.");
209 assert.include(rendered, "npx t3 serve");
210 assert.include(rendered, "npx t3 connect");
211 }).pipe(Effect.provide(NodeServices.layer)),
212 );
213
214 it.effect("ignores runtime state whose recorded pid is no longer alive", () =>
215 withDescriptorServer((origin) =>
216 Effect.gen(function* () {
217 const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-pid-test-"));
218 const statePath = NodePath.join(baseDir, "userdata", "server-runtime.json");
219 // The origin answers (another server reused the port), but the pid
220 // that wrote this state file is dead — pairing must not mint a token
221 // into the dead server's database.
222 const state = yield* makePersistedServerRuntimeState({
223 config: { host: "127.0.0.1", devUrl: undefined },
224 port: Number(new URL(origin).port),
225 });
226 yield* persistServerRuntimeState({
227 path: statePath,
228 // pid 2**22 + 1 exceeds any default Linux/macOS pid range.
229 state: { ...state, pid: 4_194_305 },
230 });
231
232 const error = yield* provideCliTestLayers(
233 runCli(["pair", "--base-dir", baseDir]).pipe(Effect.flip),
234 );
235
236 const rendered = String(
237 typeof error === "object" && error !== null && "cause" in error ? error.cause : error,
238 );
239 assert.include(rendered, "No running T3 Code server found.");
240 }),
241 ).pipe(Effect.provide(NodeServices.layer)),
242 );
243
244 it.effect("ignores stale runtime state pointing at a dead server", () =>
245 Effect.gen(function* () {
246 const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-stale-test-"));
247 const statePath = NodePath.join(baseDir, "userdata", "server-runtime.json");
248 // A port from the dynamic range with nothing listening: the probe fails
249 // fast with ECONNREFUSED and discovery moves on.
250 yield* persistServerRuntimeState({
251 path: statePath,
252 state: yield* makePersistedServerRuntimeState({
253 config: { host: "127.0.0.1", devUrl: undefined },
254 port: 1,
255 }),
256 });
257
258 const error = yield* provideCliTestLayers(
259 runCli(["pair", "--base-dir", baseDir]).pipe(Effect.flip),
260 );
261
262 const rendered = String(
263 typeof error === "object" && error !== null && "cause" in error ? error.cause : error,
264 );
265 assert.include(rendered, "No running T3 Code server found.");
266 }).pipe(Effect.provide(NodeServices.layer)),
267 );
268});
apps/server/src/cli/pair.ts created+542
...@@ -0,0 +1,542 @@
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 AuthStandardClientScopes,
14 ExecutionEnvironmentDescriptor,
15 PortSchema,
16} from "@t3tools/contracts";
17import { resolveWorktreeT3Home } from "@t3tools/shared/devHome";
18import {
19 buildTailscaleHttpsBaseUrl,
20 DEFAULT_TAILSCALE_SERVE_PORT,
21 ensureTailscaleServe,
22 readTailscaleStatus,
23} from "@t3tools/tailscale";
24import * as Config from "effect/Config";
25import * as Console from "effect/Console";
26import * as DateTime from "effect/DateTime";
27import * as Duration from "effect/Duration";
28import * as Effect from "effect/Effect";
29import * as Layer from "effect/Layer";
30import * as Option from "effect/Option";
31import * as References from "effect/References";
32import * as Schema from "effect/Schema";
33import { Command, Flag, GlobalFlag } from "effect/unstable/cli";
34import {
35 FetchHttpClient,
36 HttpClient,
37 HttpClientRequest,
38 HttpClientResponse,
39} from "effect/unstable/http";
40
41import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
42import * as ServerConfig from "../config.ts";
43import { resolveBaseDir } from "../os-jank.ts";
44import {
45 type PersistedServerRuntimeState,
46 readPersistedServerRuntimeState,
47} from "../serverRuntimeState.ts";
48import {
49 buildPairingUrl,
50 formatHostForUrl,
51 isLoopbackHost,
52 isWildcardHost,
53 renderTerminalQrCode,
54 resolveHeadlessConnectionString,
55} from "../startupAccess.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.TaggedErrorClass<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.TaggedErrorClass<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.TaggedErrorClass<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.TaggedErrorClass<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.TaggedErrorClass<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.TaggedErrorClass<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.TaggedErrorClass<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
175export const 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
232// signal 0 delivers nothing; it only reports whether the pid exists. EPERM
233// means it exists but belongs to another user, which still counts as alive.
234const isProcessAlive = (pid: number): boolean => {
235 try {
236 process.kill(pid, 0);
237 return true;
238 } catch (error) {
239 return error instanceof Error && "code" in error && error.code === "EPERM";
240 }
241};
242
243interface DiscoveredPairTarget {
244 readonly baseDir: string;
245 readonly variant: PairStateVariant;
246 readonly state: PersistedServerRuntimeState;
247 readonly descriptor: ExecutionEnvironmentDescriptor;
248}
249
250const discoverPairTarget = Effect.fn("pair.discoverPairTarget")(function* (
251 explicitBaseDir: string | undefined,
252) {
253 const bases: Array<string> = [];
254 if (explicitBaseDir !== undefined && explicitBaseDir.trim().length > 0) {
255 bases.push(yield* resolveBaseDir(explicitBaseDir));
256 } else {
257 // Same precedence as dev-runner: inside a linked worktree its own `.t3`
258 // outranks the shared home, so `t3 pair` in a worktree pairs with the dev
259 // server under test rather than the daily-driver install.
260 const worktreeHome = yield* resolveWorktreeT3Home(process.cwd());
261 if (worktreeHome !== undefined) {
262 bases.push(worktreeHome);
263 }
264 const envHome = yield* Config.string("T3CODE_HOME").pipe(Config.option);
265 bases.push(yield* resolveBaseDir(Option.getOrUndefined(envHome)));
266 }
267
268 const checkedStatePaths: Array<string> = [];
269 for (const baseDir of new Set(bases)) {
270 for (const variant of ["userdata", "dev"] as const) {
271 const derivedPaths = yield* ServerConfig.deriveServerPaths(
272 baseDir,
273 variant === "dev" ? DEV_VARIANT_PLACEHOLDER_URL : undefined,
274 {},
275 );
276 const statePath = derivedPaths.serverRuntimeStatePath;
277 checkedStatePaths.push(statePath);
278 const state = yield* readPersistedServerRuntimeState(statePath);
279 if (Option.isNone(state)) {
280 continue;
281 }
282 // The pid check guards against a dead server's state file whose port
283 // was since reused by a different server: pairing would then mint a
284 // token in the old database while the QR code points at the new server.
285 if (!isProcessAlive(state.value.pid)) {
286 continue;
287 }
288 const probed = yield* probeEnvironmentDescriptor(state.value.origin);
289 if (probed._tag !== "descriptor") {
290 continue;
291 }
292 return {
293 baseDir,
294 variant,
295 state: state.value,
296 descriptor: probed.descriptor,
297 } satisfies DiscoveredPairTarget;
298 }
299 }
300 return yield* new NoRunningServerError({ checkedStatePaths });
301});
302
303/**
304 * Server config pointed at the discovered server's state directory, so the
305 * minted token lands in the database the running server reads from. Built by
306 * hand rather than through `resolveServerConfig` to keep the dev-vs-userdata
307 * choice pinned to where the runtime state was actually found, independent of
308 * ambient environment variables.
309 */
310const makePairServerConfig = Effect.fn(function* (input: {
311 readonly target: DiscoveredPairTarget;
312 readonly logLevel: ServerConfig.ServerConfig["Service"]["logLevel"];
313}) {
314 const { baseDir, variant, state } = input.target;
315 // The state-dir variant does not imply dev-ness: a worktree dev server uses
316 // an explicit home and therefore lands in `userdata`. The recorded devUrl is
317 // what actually marks a dev server.
318 const devUrl = state.devUrl !== undefined ? new URL(state.devUrl) : undefined;
319 const derivedPaths = yield* ServerConfig.deriveServerPaths(
320 baseDir,
321 variant === "dev" ? DEV_VARIANT_PLACEHOLDER_URL : undefined,
322 {},
323 );
324 return ServerConfig.make({
325 logLevel: input.logLevel,
326 traceMinLevel: "Info",
327 traceTimingEnabled: false,
328 traceBatchWindowMs: 1_000,
329 traceMaxBytes: 10 * 1024 * 1024,
330 traceMaxFiles: 10,
331 otlpTracesUrl: undefined,
332 otlpMetricsUrl: undefined,
333 otlpExportIntervalMs: 10_000,
334 otlpServiceName: "t3-server",
335 mode: "web",
336 port: state.port,
337 host: state.host,
338 cwd: process.cwd(),
339 baseDir,
340 ...derivedPaths,
341 staticDir: undefined,
342 devUrl,
343 devAllowedOrigins: [],
344 noBrowser: true,
345 startupPresentation: "headless",
346 desktopBootstrapToken: undefined,
347 desktopTelemetryFd: undefined,
348 desktopTelemetryControlFd: undefined,
349 resourceMonitorPath: undefined,
350 autoBootstrapProjectFromCwd: false,
351 logWebSocketEvents: false,
352 tailscaleServeEnabled: false,
353 tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT,
354 });
355});
356
357const awaitEnvironmentDescriptor = Effect.fn(function* (baseUrl: string) {
358 let last: EnvironmentProbeResult = { _tag: "unreachable" };
359 for (let attempt = 0; attempt < TAILSCALE_PROBE_ATTEMPTS; attempt += 1) {
360 last = yield* probeEnvironmentDescriptor(baseUrl);
361 if (last._tag === "descriptor") {
362 return last;
363 }
364 yield* Effect.sleep(TAILSCALE_PROBE_RETRY_DELAY);
365 }
366 return last;
367});
368
369const resolveTailscalePairingBase = Effect.fn("pair.resolveTailscalePairingBase")(
370 function* (input: { readonly target: DiscoveredPairTarget; readonly servePort: number }) {
371 const notes: Array<string> = [];
372 const status = yield* readTailscaleStatus.pipe(
373 Effect.mapError((cause) => new TailscaleUnavailableError({ cause })),
374 );
375 if (status.magicDnsName === null) {
376 return yield* new MagicDnsNameMissingError();
377 }
378 const baseUrl = buildTailscaleHttpsBaseUrl({
379 magicDnsName: status.magicDnsName,
380 servePort: input.servePort,
381 });
382
383 // Only an unreachable port, or a mapping already fronting this exact
384 // environment, is safe to (re)configure. Any other responder — T3 or not
385 // — must not have its mapping silently replaced.
386 const existing = yield* probeEnvironmentDescriptor(baseUrl);
387 if (existing._tag === "descriptor") {
388 if (existing.descriptor.environmentId !== input.target.descriptor.environmentId) {
389 return yield* new ServesOtherEnvironmentError({ servePort: input.servePort });
390 }
391 // Matching environment id proves the mapping reaches this server, but
392 // not through which port: for a dev server it may front the backend
393 // (whose /.well-known also answers) while /pair only renders through
394 // the web origin. Reuse as-is for regular servers; fall through and
395 // repoint our own mapping at the web port for dev servers.
396 if (input.target.state.devUrl === undefined) {
397 return { baseUrl, notes };
398 }
399 }
400 if (existing._tag === "not-a-t3-server") {
401 return yield* new ServePortOccupiedError({ servePort: input.servePort });
402 }
403
404 const localTarget = resolveTailscaleLocalTarget(input.target.state);
405 if (isDevServerNotProxiableError(localTarget)) {
406 return yield* localTarget;
407 }
408 yield* ensureTailscaleServe({
409 localPort: localTarget.localPort,
410 servePort: input.servePort,
411 ...(localTarget.localHost !== undefined ? { localHost: localTarget.localHost } : {}),
412 }).pipe(
413 Effect.mapError(
414 (cause) => new TailscaleServeFailedError({ servePort: input.servePort, cause }),
415 ),
416 );
417 notes.push(
418 `Tailscale Serve now maps ${baseUrl} to this server and persists across restarts. Remove it with \`tailscale serve --https=${String(input.servePort)} off\`.`,
419 );
420
421 const probed = yield* awaitEnvironmentDescriptor(baseUrl);
422 if (probed._tag === "descriptor") {
423 if (probed.descriptor.environmentId !== input.target.descriptor.environmentId) {
424 return yield* new ServesOtherEnvironmentError({ servePort: input.servePort });
425 }
426 } else {
427 notes.push(
428 "The HTTPS endpoint has not answered yet. First use can take a moment while Tailscale provisions certificates.",
429 );
430 }
431 return { baseUrl, notes };
432 },
433);
434
435const mintPairingLink = Effect.fn("pair.mintPairingLink")(function* (input: {
436 readonly config: ServerConfig.ServerConfig["Service"];
437 readonly ttl: Option.Option<Duration.Duration>;
438 readonly label: Option.Option<string>;
439}) {
440 return yield* Effect.gen(function* () {
441 const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth;
442 return yield* environmentAuth.createPairingLink({
443 scopes: AuthStandardClientScopes,
444 subject: "one-time-token",
445 label: Option.getOrElse(input.label, () => "t3 pair"),
446 ...(Option.isSome(input.ttl) ? { ttl: input.ttl.value } : {}),
447 });
448 }).pipe(
449 Effect.provide(
450 EnvironmentAuth.runtimeLayer.pipe(
451 Layer.provide(ServerConfig.layer(input.config)),
452 Layer.provide(Layer.succeed(References.MinimumLogLevel, input.config.logLevel)),
453 ),
454 ),
455 );
456});
457
458const ttlFlag = Flag.string("ttl").pipe(
459 Flag.withSchema(DurationFromString),
460 Flag.withDescription(
461 "Token TTL, for example `5m`, `1h`, or `15 minutes`. Defaults to 5 minutes.",
462 ),
463 Flag.optional,
464);
465
466const labelFlag = Flag.string("label").pipe(
467 Flag.withDescription("Optional label shown in the server's connections list."),
468 Flag.optional,
469);
470
471const tailscaleFlag = Flag.boolean("tailscale").pipe(
472 Flag.withDescription(
473 "Publish the server over Tailscale Serve HTTPS and pair through the tailnet URL.",
474 ),
475 Flag.withDefault(false),
476);
477
478const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe(
479 Flag.withSchema(PortSchema),
480 Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale is enabled."),
481 Flag.withDefault(DEFAULT_TAILSCALE_SERVE_PORT),
482);
483
484export const pairCommand = Command.make("pair", {
485 baseDir: baseDirFlag,
486 ttl: ttlFlag,
487 label: labelFlag,
488 tailscale: tailscaleFlag,
489 tailscaleServePort: tailscaleServePortFlag,
490}).pipe(
491 Command.withDescription(
492 "Mint a pairing token for a running T3 Code server and print it as a QR code.",
493 ),
494 Command.withHandler((flags) =>
495 Effect.gen(function* () {
496 const cliLogLevel = yield* GlobalFlag.LogLevel;
497 // Default to Warn so storage/migration chatter cannot bury the QR code;
498 // an explicit --log-level still wins.
499 const logLevel = Option.getOrElse(cliLogLevel, () => "Warn" as const);
500
501 const target = yield* discoverPairTarget(Option.getOrUndefined(flags.baseDir));
502
503 const notes: Array<string> = [];
504 let pairingBaseUrl: string;
505 if (flags.tailscale) {
506 const resolved = yield* resolveTailscalePairingBase({
507 target,
508 servePort: flags.tailscaleServePort,
509 });
510 pairingBaseUrl = resolved.baseUrl;
511 notes.push(...resolved.notes);
512 } else {
513 pairingBaseUrl = resolveDirectPairingBaseUrl(target.state);
514 if (isLoopbackHost(new URL(pairingBaseUrl).hostname)) {
515 notes.push(
516 "This URL is only reachable from this machine. Re-run with --tailscale, or restart the server with a reachable --host.",
517 );
518 }
519 if (target.variant === "dev" && target.state.devUrl === undefined) {
520 notes.push(
521 "This dev server did not record its web URL; restart it so pairing can go through the web origin.",
522 );
523 }
524 }
525
526 const config = yield* makePairServerConfig({ target, logLevel });
527 const issued = yield* mintPairingLink({ config, ttl: flags.ttl, label: flags.label });
528 const pairingUrl = buildPairingUrl(pairingBaseUrl, issued.credential);
529
530 yield* Console.log(
531 formatPairOutput({
532 serverLabel: target.descriptor.label,
533 origin: target.state.origin,
534 pairingUrl,
535 token: issued.credential,
536 expiresAt: issued.expiresAt,
537 notes,
538 }),
539 );
540 }).pipe(Effect.provide(FetchHttpClient.layer)),
541 ),
542);
apps/server/src/serverRuntimeState.test.ts+19
...@@ -33,6 +33,7 @@ describe("serverRuntimeState", () => {...@@ -33,6 +33,7 @@ describe("serverRuntimeState", () => {
33 host: "127.0.0.1",33 host: "127.0.0.1",
34 port: 4_971,34 port: 4_971,
35 origin: "http://127.0.0.1:4971",35 origin: "http://127.0.0.1:4971",
36 devUrl: "http://localhost:5733/",
36 startedAt: "2026-06-20T00:00:00.000Z",37 startedAt: "2026-06-20T00:00:00.000Z",
37 };38 };
3839
...@@ -43,6 +44,24 @@ describe("serverRuntimeState", () => {...@@ -43,6 +44,24 @@ describe("serverRuntimeState", () => {
43 }).pipe(Effect.provide(NodeServices.layer)),44 }).pipe(Effect.provide(NodeServices.layer)),
44 );45 );
4546
47 it.effect("records the dev web URL when the server fronts a dev server", () =>
48 Effect.gen(function* () {
49 const state = yield* ServerRuntimeState.makePersistedServerRuntimeState({
50 config: { host: undefined, devUrl: new URL("http://localhost:5733") },
51 port: 13_773,
52 });
53
54 assert.equal(state.devUrl, "http://localhost:5733/");
55 assert.equal(state.origin, "http://127.0.0.1:13773");
56
57 const withoutDev = yield* ServerRuntimeState.makePersistedServerRuntimeState({
58 config: { host: undefined, devUrl: undefined },
59 port: 13_773,
60 });
61 assert.isFalse("devUrl" in withoutDev);
62 }),
63 );
64
46 it.effect("treats a missing runtime state file as absent", () =>65 it.effect("treats a missing runtime state file as absent", () =>
47 Effect.gen(function* () {66 Effect.gen(function* () {
48 const fileSystem = yield* FileSystem.FileSystem;67 const fileSystem = yield* FileSystem.FileSystem;
apps/server/src/serverRuntimeState.ts+5-1
...@@ -14,6 +14,9 @@ export const PersistedServerRuntimeState = Schema.Struct({...@@ -14,6 +14,9 @@ export const PersistedServerRuntimeState = Schema.Struct({
14 host: Schema.optional(Schema.String),14 host: Schema.optional(Schema.String),
15 port: Schema.Int,15 port: Schema.Int,
16 origin: Schema.String,16 origin: Schema.String,
17 // Present when the server fronts a dev web server (VITE_DEV_SERVER_URL).
18 // Dev is single-origin: browsers must pair through this URL, not `origin`.
19 devUrl: Schema.optional(Schema.String),
17 startedAt: Schema.String,20 startedAt: Schema.String,
18});21});
19export type PersistedServerRuntimeState = typeof PersistedServerRuntimeState.Type;22export type PersistedServerRuntimeState = typeof PersistedServerRuntimeState.Type;
...@@ -45,7 +48,7 @@ const runtimeOriginForConfig = (...@@ -45,7 +48,7 @@ const runtimeOriginForConfig = (
45};48};
4649
47export const makePersistedServerRuntimeState = (input: {50export const makePersistedServerRuntimeState = (input: {
48 readonly config: Pick<ServerConfig.ServerConfig["Service"], "host">;51 readonly config: Pick<ServerConfig.ServerConfig["Service"], "host" | "devUrl">;
49 readonly port: number;52 readonly port: number;
50}): Effect.Effect<PersistedServerRuntimeState> =>53}): Effect.Effect<PersistedServerRuntimeState> =>
51 Effect.map(DateTime.now, (now) => ({54 Effect.map(DateTime.now, (now) => ({
...@@ -54,6 +57,7 @@ export const makePersistedServerRuntimeState = (input: {...@@ -54,6 +57,7 @@ export const makePersistedServerRuntimeState = (input: {
54 ...(input.config.host ? { host: input.config.host } : {}),57 ...(input.config.host ? { host: input.config.host } : {}),
55 port: input.port,58 port: input.port,
56 origin: runtimeOriginForConfig(input.config, input.port),59 origin: runtimeOriginForConfig(input.config, input.port),
60 ...(input.config.devUrl ? { devUrl: input.config.devUrl.toString() } : {}),
57 startedAt: DateTime.formatIso(now),61 startedAt: DateTime.formatIso(now),
58 }));62 }));
5963
docs/user/remote-access.md+20
...@@ -2,6 +2,26 @@...@@ -2,6 +2,26 @@
22
3Use this when you want to connect to a T3 Code server from another device such as a phone, tablet, or separate desktop app.3Use this when you want to connect to a T3 Code server from another device such as a phone, tablet, or separate desktop app.
44
5## Quick Pairing for a Running Server
6
7If a server is already running on this machine, mint a fresh pairing token and QR code without restarting anything:
8
9```bash
10npx t3 pair
11```
12
13`t3 pair` finds the running server (the shared `~/.t3` install, or the current worktree's dev server when run inside one), issues a one-time pairing token, and prints the pairing URL as a QR code you can scan from your phone.
14
15If the server is only bound to loopback, the printed URL is not reachable from another device. Pair over your tailnet instead:
16
17```bash
18npx t3 pair --tailscale
19```
20
21This publishes the server over Tailscale Serve HTTPS (configuring the mapping if needed — it persists until you run `tailscale serve --https=443 off`) and pairs through the `https://machine.tailnet.ts.net/` URL. Use `--tailscale-serve-port` for a different HTTPS port, `--ttl` to change the token lifetime, and `--base-dir` to target a specific data directory.
22
23If no server is running, `t3 pair` says so and points you at `npx t3 serve` or `npx t3 connect`.
24
5## Recommended Setup25## Recommended Setup
626
7Use a trusted private network that meshes your devices together, such as a tailnet.27Use a trusted private network that meshes your devices together, such as a tailnet.