diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 82398748cf2..ab60749e389 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -9,6 +9,7 @@ import * as NetService from "@t3tools/shared/Net"; import packageJson from "../package.json" with { type: "json" }; import { authCommand } from "./cli/auth.ts"; import { connectCommand } from "./cli/connect.ts"; +import { pairCommand } from "./cli/pair.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { sharedServerCommandFlags } from "./cli/config.ts"; import { projectCommand } from "./cli/project.ts"; @@ -48,6 +49,7 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => Command.withSubcommands([ startCommand, serveCommand, + pairCommand, authCommand, projectCommand, serviceCommand, diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts new file mode 100644 index 00000000000..c4f321a5a61 --- /dev/null +++ b/apps/server/src/cli/pair.test.ts @@ -0,0 +1,268 @@ +// @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises Node HTTP and filesystem boundaries. +import * as NodeHttp from "node:http"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NetService from "@t3tools/shared/Net"; +import { assert, describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as TestConsole from "effect/testing/TestConsole"; +import { Command } from "effect/unstable/cli"; + +import { cli } from "../bin.ts"; +import { + makePersistedServerRuntimeState, + persistServerRuntimeState, + type PersistedServerRuntimeState, +} from "../serverRuntimeState.ts"; +import { + DevServerNotProxiableError, + resolveDirectPairingBaseUrl, + resolveTailscaleLocalTarget, +} from "./pair.ts"; + +const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); + +const baseState = { + version: 1, + pid: 123, + port: 3_773, + origin: "http://127.0.0.1:3773", + startedAt: "2026-06-20T00:00:00.000Z", +} as const satisfies PersistedServerRuntimeState; + +describe("pair base URL selection", () => { + it("pairs through the dev web origin when the server fronts a dev server", () => { + expect(resolveDirectPairingBaseUrl({ ...baseState, devUrl: "http://localhost:5733/" })).toBe( + "http://localhost:5733/", + ); + }); + + it("pairs through the bound host when there is no dev server", () => { + expect(resolveDirectPairingBaseUrl({ ...baseState, host: "100.64.0.7" })).toBe( + "http://100.64.0.7:3773", + ); + expect(resolveDirectPairingBaseUrl(baseState)).toBe("http://localhost:3773"); + }); +}); + +describe("pair tailscale local target", () => { + it("proxies the dev web port for dev servers", () => { + expect(resolveTailscaleLocalTarget({ ...baseState, devUrl: "http://localhost:5733/" })).toEqual( + { localPort: 5_733 }, + ); + // A dev server on a non-loopback interface must be proxied at that + // interface; tailscale serve defaults to 127.0.0.1 otherwise. + expect( + resolveTailscaleLocalTarget({ ...baseState, devUrl: "http://192.168.1.10:5733/" }), + ).toEqual({ localPort: 5_733, localHost: "192.168.1.10" }); + // URL.hostname keeps IPv6 brackets, so the serve target stays valid. + expect( + resolveTailscaleLocalTarget({ ...baseState, devUrl: "http://[fd7a:115c::1]:5733/" }), + ).toEqual({ localPort: 5_733, localHost: "[fd7a:115c::1]" }); + }); + + it("rejects HTTPS dev URLs, which tailscale serve cannot proxy", () => { + expect( + resolveTailscaleLocalTarget({ ...baseState, devUrl: "https://localhost:5733/" }), + ).toBeInstanceOf(DevServerNotProxiableError); + }); + + it("proxies the backend port directly otherwise", () => { + expect(resolveTailscaleLocalTarget(baseState)).toEqual({ localPort: 3_773 }); + expect(resolveTailscaleLocalTarget({ ...baseState, host: "0.0.0.0" })).toEqual({ + localPort: 3_773, + }); + expect(resolveTailscaleLocalTarget({ ...baseState, host: "192.168.1.42" })).toEqual({ + localPort: 3_773, + localHost: "192.168.1.42", + }); + }); +}); + +const runCli = (args: ReadonlyArray) => Command.runWith(cli, { version: "0.0.0" })(args); + +const provideCliTestLayers = (effect: Effect.Effect) => + Effect.provide(effect, Layer.mergeAll(CliRuntimeLayer, TestConsole.layer)); + +// Console output accumulates across CLI runs within a test, and each +// Console.log call is one entry — so the latest command's output is the last +// entry, even when it spans many lines. +const captureStdout = (effect: Effect.Effect) => + provideCliTestLayers( + Effect.gen(function* () { + yield* effect; + return ( + (yield* TestConsole.logLines).findLast( + (line): line is string => typeof line === "string", + ) ?? "" + ); + }), + ); + +const testDescriptor = { + environmentId: "pair-test-environment", + label: "pair-test", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.1", + capabilities: { repositoryIdentity: true }, +}; + +const withDescriptorServer = (run: (origin: string) => Effect.Effect) => + Effect.acquireUseRelease( + Effect.callback((resume) => { + const server = NodeHttp.createServer((request, response) => { + if (request.url === "/.well-known/t3/environment") { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify(testDescriptor)); + return; + } + response.writeHead(404); + response.end(); + }); + server.listen(0, "127.0.0.1", () => resume(Effect.succeed(server))); + }), + (server) => { + const address = server.address(); + if (address === null || typeof address === "string") { + return Effect.die(new Error("Expected a TCP address")); + } + return run(`http://127.0.0.1:${String(address.port)}`); + }, + (server) => Effect.sync(() => server.close()), + ); + +describe("t3 pair", () => { + it.effect("mints a token and prints a QR pairing URL for a live server", () => + withDescriptorServer((origin) => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-test-")); + const port = Number(new URL(origin).port); + const statePath = NodePath.join(baseDir, "userdata", "server-runtime.json"); + yield* persistServerRuntimeState({ + path: statePath, + state: yield* makePersistedServerRuntimeState({ + config: { host: "127.0.0.1", devUrl: undefined }, + port, + }), + }); + + const output = yield* captureStdout(runCli(["pair", "--base-dir", baseDir])); + + assert.include(output, `Pairing with pair-test (${origin})`); + assert.include(output, `Pairing URL: ${origin}/pair#token=`); + assert.isTrue(output.includes("█") || output.includes("▀") || output.includes("▄")); + // Loopback origins are not reachable from a phone; the output must say so. + assert.include(output, "only reachable from this machine"); + + const token = /#token=([A-Z2-9]+)/.exec(output)?.[1]; + assert.isString(token); + + // The token must be in the same store the running server reads. + const listed = yield* captureStdout( + runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]), + ); + // @effect-diagnostics-next-line preferSchemaOverJson:off - CLI JSON output is decoded as a presentation DTO. + const credentials = JSON.parse(listed) as ReadonlyArray<{ readonly label?: string }>; + assert.equal(credentials.length, 1); + assert.equal(credentials[0]?.label, "t3 pair"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("pairs through the recorded dev web URL for dev servers", () => + withDescriptorServer((origin) => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-dev-test-")); + const port = Number(new URL(origin).port); + const statePath = NodePath.join(baseDir, "dev", "server-runtime.json"); + yield* persistServerRuntimeState({ + path: statePath, + state: yield* makePersistedServerRuntimeState({ + config: { host: undefined, devUrl: new URL("http://localhost:5733") }, + port, + }), + }); + + const output = yield* captureStdout(runCli(["pair", "--base-dir", baseDir])); + + assert.include(output, "Pairing URL: http://localhost:5733/pair#token="); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("directs to t3 serve or t3 connect when no server is running", () => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-none-test-")); + + const error = yield* provideCliTestLayers( + runCli(["pair", "--base-dir", baseDir]).pipe(Effect.flip), + ); + + const rendered = String( + typeof error === "object" && error !== null && "cause" in error ? error.cause : error, + ); + assert.include(rendered, "No running T3 Code server found."); + assert.include(rendered, "npx t3 serve"); + assert.include(rendered, "npx t3 connect"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("ignores runtime state whose recorded pid is no longer alive", () => + withDescriptorServer((origin) => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-pid-test-")); + const statePath = NodePath.join(baseDir, "userdata", "server-runtime.json"); + // The origin answers (another server reused the port), but the pid + // that wrote this state file is dead — pairing must not mint a token + // into the dead server's database. + const state = yield* makePersistedServerRuntimeState({ + config: { host: "127.0.0.1", devUrl: undefined }, + port: Number(new URL(origin).port), + }); + yield* persistServerRuntimeState({ + path: statePath, + // pid 2**22 + 1 exceeds any default Linux/macOS pid range. + state: { ...state, pid: 4_194_305 }, + }); + + const error = yield* provideCliTestLayers( + runCli(["pair", "--base-dir", baseDir]).pipe(Effect.flip), + ); + + const rendered = String( + typeof error === "object" && error !== null && "cause" in error ? error.cause : error, + ); + assert.include(rendered, "No running T3 Code server found."); + }), + ).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("ignores stale runtime state pointing at a dead server", () => + Effect.gen(function* () { + const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-stale-test-")); + const statePath = NodePath.join(baseDir, "userdata", "server-runtime.json"); + // A port from the dynamic range with nothing listening: the probe fails + // fast with ECONNREFUSED and discovery moves on. + yield* persistServerRuntimeState({ + path: statePath, + state: yield* makePersistedServerRuntimeState({ + config: { host: "127.0.0.1", devUrl: undefined }, + port: 1, + }), + }); + + const error = yield* provideCliTestLayers( + runCli(["pair", "--base-dir", baseDir]).pipe(Effect.flip), + ); + + const rendered = String( + typeof error === "object" && error !== null && "cause" in error ? error.cause : error, + ); + assert.include(rendered, "No running T3 Code server found."); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts new file mode 100644 index 00000000000..38fa3be8bb5 --- /dev/null +++ b/apps/server/src/cli/pair.ts @@ -0,0 +1,542 @@ +/** + * `t3 pair` - mint a pairing token for an already-running server and print it + * as a QR code, without restarting anything. + * + * Discovery reads the `server-runtime.json` a live server persists next to its + * database, then confirms the process is actually answering by fetching its + * public environment descriptor. Inside a linked git worktree the worktree's + * own `.t3` is checked first (matching dev-runner precedence); otherwise the + * shared T3 home. `--tailscale` publishes the server over Tailscale Serve + * HTTPS and pairs through the tailnet URL instead. + */ +import { + AuthStandardClientScopes, + ExecutionEnvironmentDescriptor, + PortSchema, +} from "@t3tools/contracts"; +import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; +import { + buildTailscaleHttpsBaseUrl, + DEFAULT_TAILSCALE_SERVE_PORT, + ensureTailscaleServe, + readTailscaleStatus, +} from "@t3tools/tailscale"; +import * as Config from "effect/Config"; +import * as Console from "effect/Console"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as References from "effect/References"; +import * as Schema from "effect/Schema"; +import { Command, Flag, GlobalFlag } from "effect/unstable/cli"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerConfig from "../config.ts"; +import { resolveBaseDir } from "../os-jank.ts"; +import { + type PersistedServerRuntimeState, + readPersistedServerRuntimeState, +} from "../serverRuntimeState.ts"; +import { + buildPairingUrl, + formatHostForUrl, + isLoopbackHost, + isWildcardHost, + renderTerminalQrCode, + resolveHeadlessConnectionString, +} from "../startupAccess.ts"; +import { baseDirFlag, DurationFromString } from "./config.ts"; + +const WELL_KNOWN_ENVIRONMENT_PATH = "/.well-known/t3/environment"; +const PAIR_PROBE_TIMEOUT = Duration.millis(2_500); +// Tailscale provisions an HTTPS certificate on the first request to a fresh +// serve mapping, which can take a few seconds. +const TAILSCALE_PROBE_ATTEMPTS = 5; +const TAILSCALE_PROBE_RETRY_DELAY = Duration.seconds(1); + +export type PairStateVariant = "userdata" | "dev"; + +// deriveServerPaths only checks devUrl for undefined-ness when picking the +// dev-vs-userdata state directory; the value itself is not used. +const DEV_VARIANT_PLACEHOLDER_URL = new URL("http://localhost"); + +export class NoRunningServerError extends Schema.TaggedErrorClass()( + "NoRunningServerError", + { + checkedStatePaths: Schema.Array(Schema.String), + }, +) { + override get message(): string { + return [ + "No running T3 Code server found.", + ...this.checkedStatePaths.map((statePath) => ` checked ${statePath}`), + "Start one with `npx t3 serve`, or connect this machine with T3 Connect: `npx t3 connect`.", + ].join("\n"); + } +} + +// Each tailscale failure gets its own class (same reasoning as +// scripts/lib/dev-share.ts): distinct caller-visible message, distinct remedy. +export class TailscaleUnavailableError extends Schema.TaggedErrorClass()( + "TailscaleUnavailableError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not talk to Tailscale. Is tailscaled running? Try `tailscale status`."; + } +} + +export class MagicDnsNameMissingError extends Schema.TaggedErrorClass()( + "MagicDnsNameMissingError", + {}, +) { + override get message(): string { + return "This machine has no MagicDNS name. Run `tailscale up` and enable MagicDNS."; + } +} + +export class ServesOtherEnvironmentError extends Schema.TaggedErrorClass()( + "ServesOtherEnvironmentError", + { servePort: Schema.Number }, +) { + override get message(): string { + 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.`; + } +} + +export class TailscaleServeFailedError extends Schema.TaggedErrorClass()( + "TailscaleServeFailedError", + { servePort: Schema.Number, cause: Schema.Defect() }, +) { + override get message(): string { + return `tailscale serve failed for HTTPS port ${String(this.servePort)}. Run \`tailscale serve --https=${String(this.servePort)} --bg \` by hand to see why.`; + } +} + +export class ServePortOccupiedError extends Schema.TaggedErrorClass()( + "ServePortOccupiedError", + { servePort: Schema.Number }, +) { + override get message(): string { + 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.`; + } +} + +/** The URL a browser or phone should pair through, absent Tailscale. */ +export const resolveDirectPairingBaseUrl = (state: PersistedServerRuntimeState): string => + state.devUrl ?? resolveHeadlessConnectionString(state.host, state.port); + +export class DevServerNotProxiableError extends Schema.TaggedErrorClass()( + "DevServerNotProxiableError", + { devUrl: Schema.String }, +) { + override get message(): string { + return `Tailscale Serve can only proxy plain-HTTP local targets, and this dev server runs at ${this.devUrl}. Pair without --tailscale instead.`; + } +} + +const isDevServerNotProxiableError = Schema.is(DevServerNotProxiableError); + +/** + * The local endpoint Tailscale Serve should proxy to. Dev servers are + * single-origin, so the web dev server's port is the one to publish; the + * backend rides along behind Vite's proxy. Serve targets are always plain + * HTTP, so an HTTPS dev URL cannot be proxied and is rejected. + */ +export const resolveTailscaleLocalTarget = ( + state: PersistedServerRuntimeState, +): { readonly localPort: number; readonly localHost?: string } | DevServerNotProxiableError => { + if (state.devUrl !== undefined) { + const devUrl = new URL(state.devUrl); + if (devUrl.protocol !== "http:") { + return new DevServerNotProxiableError({ devUrl: state.devUrl }); + } + const localPort = devUrl.port.length > 0 ? Number.parseInt(devUrl.port, 10) : 80; + return isLoopbackHost(devUrl.hostname) + ? { localPort } + : { localPort, localHost: devUrl.hostname }; + } + // A server bound to one specific interface does not answer on loopback, so + // the proxy has to target that interface directly. + if (state.host !== undefined && !isWildcardHost(state.host) && !isLoopbackHost(state.host)) { + return { localPort: state.port, localHost: formatHostForUrl(state.host) }; + } + return { localPort: state.port }; +}; + +export const formatPairOutput = (input: { + readonly serverLabel: string; + readonly origin: string; + readonly pairingUrl: string; + readonly token: string; + readonly expiresAt: DateTime.Utc; + readonly notes: ReadonlyArray; +}): string => + [ + `Pairing with ${input.serverLabel} (${input.origin}).`, + "", + renderTerminalQrCode(input.pairingUrl), + "", + `Pairing URL: ${input.pairingUrl}`, + `Token: ${input.token}`, + `Expires: ${DateTime.formatIso(input.expiresAt)}`, + ...input.notes.flatMap((note) => ["", `Note: ${note}`]), + "", + ].join("\n"); + +/** + * Three outcomes, because they drive different decisions: a T3 descriptor + * (pair with it), nothing answering (safe to configure Tailscale Serve), or + * something answering that is not a T3 server (do NOT overwrite its mapping). + */ +type EnvironmentProbeResult = + | { readonly _tag: "descriptor"; readonly descriptor: ExecutionEnvironmentDescriptor } + | { readonly _tag: "unreachable" } + | { readonly _tag: "not-a-t3-server" }; + +const probeEnvironmentDescriptor = ( + baseUrl: string, +): Effect.Effect => + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const request = HttpClientRequest.get(new URL(WELL_KNOWN_ENVIRONMENT_PATH, baseUrl).toString()); + const response = yield* client.execute(request).pipe( + Effect.timeout(PAIR_PROBE_TIMEOUT), + // Transport failure or timeout: nothing (reachable) is listening there. + Effect.mapError(() => ({ _tag: "unreachable" }) as const), + ); + // Bad-gateway family means a proxy (Tailscale Serve) answered for a + // backend that is gone — a stale mapping, not a live occupant. Treating + // it as unreachable lets `t3 pair --tailscale` repair its own mapping + // after the server's port changed. + if (response.status === 502 || response.status === 503 || response.status === 504) { + return { _tag: "unreachable" } as const; + } + // Anything else that answered HTTP but not with a valid descriptor is + // some other service. + const descriptor = yield* HttpClientResponse.filterStatusOk(response).pipe( + Effect.flatMap(HttpClientResponse.schemaBodyJson(ExecutionEnvironmentDescriptor)), + Effect.mapError(() => ({ _tag: "not-a-t3-server" }) as const), + ); + return { _tag: "descriptor", descriptor } as const; + }).pipe(Effect.catch((outcome) => Effect.succeed(outcome))); + +// signal 0 delivers nothing; it only reports whether the pid exists. EPERM +// means it exists but belongs to another user, which still counts as alive. +const isProcessAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error instanceof Error && "code" in error && error.code === "EPERM"; + } +}; + +interface DiscoveredPairTarget { + readonly baseDir: string; + readonly variant: PairStateVariant; + readonly state: PersistedServerRuntimeState; + readonly descriptor: ExecutionEnvironmentDescriptor; +} + +const discoverPairTarget = Effect.fn("pair.discoverPairTarget")(function* ( + explicitBaseDir: string | undefined, +) { + const bases: Array = []; + if (explicitBaseDir !== undefined && explicitBaseDir.trim().length > 0) { + bases.push(yield* resolveBaseDir(explicitBaseDir)); + } else { + // Same precedence as dev-runner: inside a linked worktree its own `.t3` + // outranks the shared home, so `t3 pair` in a worktree pairs with the dev + // server under test rather than the daily-driver install. + const worktreeHome = yield* resolveWorktreeT3Home(process.cwd()); + if (worktreeHome !== undefined) { + bases.push(worktreeHome); + } + const envHome = yield* Config.string("T3CODE_HOME").pipe(Config.option); + bases.push(yield* resolveBaseDir(Option.getOrUndefined(envHome))); + } + + const checkedStatePaths: Array = []; + for (const baseDir of new Set(bases)) { + for (const variant of ["userdata", "dev"] as const) { + const derivedPaths = yield* ServerConfig.deriveServerPaths( + baseDir, + variant === "dev" ? DEV_VARIANT_PLACEHOLDER_URL : undefined, + {}, + ); + const statePath = derivedPaths.serverRuntimeStatePath; + checkedStatePaths.push(statePath); + const state = yield* readPersistedServerRuntimeState(statePath); + if (Option.isNone(state)) { + continue; + } + // The pid check guards against a dead server's state file whose port + // was since reused by a different server: pairing would then mint a + // token in the old database while the QR code points at the new server. + if (!isProcessAlive(state.value.pid)) { + continue; + } + const probed = yield* probeEnvironmentDescriptor(state.value.origin); + if (probed._tag !== "descriptor") { + continue; + } + return { + baseDir, + variant, + state: state.value, + descriptor: probed.descriptor, + } satisfies DiscoveredPairTarget; + } + } + return yield* new NoRunningServerError({ checkedStatePaths }); +}); + +/** + * Server config pointed at the discovered server's state directory, so the + * minted token lands in the database the running server reads from. Built by + * hand rather than through `resolveServerConfig` to keep the dev-vs-userdata + * choice pinned to where the runtime state was actually found, independent of + * ambient environment variables. + */ +const makePairServerConfig = Effect.fn(function* (input: { + readonly target: DiscoveredPairTarget; + readonly logLevel: ServerConfig.ServerConfig["Service"]["logLevel"]; +}) { + const { baseDir, variant, state } = input.target; + // The state-dir variant does not imply dev-ness: a worktree dev server uses + // an explicit home and therefore lands in `userdata`. The recorded devUrl is + // what actually marks a dev server. + const devUrl = state.devUrl !== undefined ? new URL(state.devUrl) : undefined; + const derivedPaths = yield* ServerConfig.deriveServerPaths( + baseDir, + variant === "dev" ? DEV_VARIANT_PLACEHOLDER_URL : undefined, + {}, + ); + return ServerConfig.make({ + logLevel: input.logLevel, + traceMinLevel: "Info", + traceTimingEnabled: false, + traceBatchWindowMs: 1_000, + traceMaxBytes: 10 * 1024 * 1024, + traceMaxFiles: 10, + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + otlpExportIntervalMs: 10_000, + otlpServiceName: "t3-server", + mode: "web", + port: state.port, + host: state.host, + cwd: process.cwd(), + baseDir, + ...derivedPaths, + staticDir: undefined, + devUrl, + devAllowedOrigins: [], + noBrowser: true, + startupPresentation: "headless", + desktopBootstrapToken: undefined, + desktopTelemetryFd: undefined, + desktopTelemetryControlFd: undefined, + resourceMonitorPath: undefined, + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, + }); +}); + +const awaitEnvironmentDescriptor = Effect.fn(function* (baseUrl: string) { + let last: EnvironmentProbeResult = { _tag: "unreachable" }; + for (let attempt = 0; attempt < TAILSCALE_PROBE_ATTEMPTS; attempt += 1) { + last = yield* probeEnvironmentDescriptor(baseUrl); + if (last._tag === "descriptor") { + return last; + } + yield* Effect.sleep(TAILSCALE_PROBE_RETRY_DELAY); + } + return last; +}); + +const resolveTailscalePairingBase = Effect.fn("pair.resolveTailscalePairingBase")( + function* (input: { readonly target: DiscoveredPairTarget; readonly servePort: number }) { + const notes: Array = []; + const status = yield* readTailscaleStatus.pipe( + Effect.mapError((cause) => new TailscaleUnavailableError({ cause })), + ); + if (status.magicDnsName === null) { + return yield* new MagicDnsNameMissingError(); + } + const baseUrl = buildTailscaleHttpsBaseUrl({ + magicDnsName: status.magicDnsName, + servePort: input.servePort, + }); + + // Only an unreachable port, or a mapping already fronting this exact + // environment, is safe to (re)configure. Any other responder — T3 or not + // — must not have its mapping silently replaced. + const existing = yield* probeEnvironmentDescriptor(baseUrl); + if (existing._tag === "descriptor") { + if (existing.descriptor.environmentId !== input.target.descriptor.environmentId) { + return yield* new ServesOtherEnvironmentError({ servePort: input.servePort }); + } + // Matching environment id proves the mapping reaches this server, but + // not through which port: for a dev server it may front the backend + // (whose /.well-known also answers) while /pair only renders through + // the web origin. Reuse as-is for regular servers; fall through and + // repoint our own mapping at the web port for dev servers. + if (input.target.state.devUrl === undefined) { + return { baseUrl, notes }; + } + } + if (existing._tag === "not-a-t3-server") { + return yield* new ServePortOccupiedError({ servePort: input.servePort }); + } + + const localTarget = resolveTailscaleLocalTarget(input.target.state); + if (isDevServerNotProxiableError(localTarget)) { + return yield* localTarget; + } + yield* ensureTailscaleServe({ + localPort: localTarget.localPort, + servePort: input.servePort, + ...(localTarget.localHost !== undefined ? { localHost: localTarget.localHost } : {}), + }).pipe( + Effect.mapError( + (cause) => new TailscaleServeFailedError({ servePort: input.servePort, cause }), + ), + ); + notes.push( + `Tailscale Serve now maps ${baseUrl} to this server and persists across restarts. Remove it with \`tailscale serve --https=${String(input.servePort)} off\`.`, + ); + + const probed = yield* awaitEnvironmentDescriptor(baseUrl); + if (probed._tag === "descriptor") { + if (probed.descriptor.environmentId !== input.target.descriptor.environmentId) { + return yield* new ServesOtherEnvironmentError({ servePort: input.servePort }); + } + } else { + notes.push( + "The HTTPS endpoint has not answered yet. First use can take a moment while Tailscale provisions certificates.", + ); + } + return { baseUrl, notes }; + }, +); + +const mintPairingLink = Effect.fn("pair.mintPairingLink")(function* (input: { + readonly config: ServerConfig.ServerConfig["Service"]; + readonly ttl: Option.Option; + readonly label: Option.Option; +}) { + return yield* Effect.gen(function* () { + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + return yield* environmentAuth.createPairingLink({ + scopes: AuthStandardClientScopes, + subject: "one-time-token", + label: Option.getOrElse(input.label, () => "t3 pair"), + ...(Option.isSome(input.ttl) ? { ttl: input.ttl.value } : {}), + }); + }).pipe( + Effect.provide( + EnvironmentAuth.runtimeLayer.pipe( + Layer.provide(ServerConfig.layer(input.config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, input.config.logLevel)), + ), + ), + ); +}); + +const ttlFlag = Flag.string("ttl").pipe( + Flag.withSchema(DurationFromString), + Flag.withDescription( + "Token TTL, for example `5m`, `1h`, or `15 minutes`. Defaults to 5 minutes.", + ), + Flag.optional, +); + +const labelFlag = Flag.string("label").pipe( + Flag.withDescription("Optional label shown in the server's connections list."), + Flag.optional, +); + +const tailscaleFlag = Flag.boolean("tailscale").pipe( + Flag.withDescription( + "Publish the server over Tailscale Serve HTTPS and pair through the tailnet URL.", + ), + Flag.withDefault(false), +); + +const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe( + Flag.withSchema(PortSchema), + Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale is enabled."), + Flag.withDefault(DEFAULT_TAILSCALE_SERVE_PORT), +); + +export const pairCommand = Command.make("pair", { + baseDir: baseDirFlag, + ttl: ttlFlag, + label: labelFlag, + tailscale: tailscaleFlag, + tailscaleServePort: tailscaleServePortFlag, +}).pipe( + Command.withDescription( + "Mint a pairing token for a running T3 Code server and print it as a QR code.", + ), + Command.withHandler((flags) => + Effect.gen(function* () { + const cliLogLevel = yield* GlobalFlag.LogLevel; + // Default to Warn so storage/migration chatter cannot bury the QR code; + // an explicit --log-level still wins. + const logLevel = Option.getOrElse(cliLogLevel, () => "Warn" as const); + + const target = yield* discoverPairTarget(Option.getOrUndefined(flags.baseDir)); + + const notes: Array = []; + let pairingBaseUrl: string; + if (flags.tailscale) { + const resolved = yield* resolveTailscalePairingBase({ + target, + servePort: flags.tailscaleServePort, + }); + pairingBaseUrl = resolved.baseUrl; + notes.push(...resolved.notes); + } else { + pairingBaseUrl = resolveDirectPairingBaseUrl(target.state); + if (isLoopbackHost(new URL(pairingBaseUrl).hostname)) { + notes.push( + "This URL is only reachable from this machine. Re-run with --tailscale, or restart the server with a reachable --host.", + ); + } + if (target.variant === "dev" && target.state.devUrl === undefined) { + notes.push( + "This dev server did not record its web URL; restart it so pairing can go through the web origin.", + ); + } + } + + const config = yield* makePairServerConfig({ target, logLevel }); + const issued = yield* mintPairingLink({ config, ttl: flags.ttl, label: flags.label }); + const pairingUrl = buildPairingUrl(pairingBaseUrl, issued.credential); + + yield* Console.log( + formatPairOutput({ + serverLabel: target.descriptor.label, + origin: target.state.origin, + pairingUrl, + token: issued.credential, + expiresAt: issued.expiresAt, + notes, + }), + ); + }).pipe(Effect.provide(FetchHttpClient.layer)), + ), +); diff --git a/apps/server/src/serverRuntimeState.test.ts b/apps/server/src/serverRuntimeState.test.ts index 749fd3062e9..4c2375b29a7 100644 --- a/apps/server/src/serverRuntimeState.test.ts +++ b/apps/server/src/serverRuntimeState.test.ts @@ -33,6 +33,7 @@ describe("serverRuntimeState", () => { host: "127.0.0.1", port: 4_971, origin: "http://127.0.0.1:4971", + devUrl: "http://localhost:5733/", startedAt: "2026-06-20T00:00:00.000Z", }; @@ -43,6 +44,24 @@ describe("serverRuntimeState", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("records the dev web URL when the server fronts a dev server", () => + Effect.gen(function* () { + const state = yield* ServerRuntimeState.makePersistedServerRuntimeState({ + config: { host: undefined, devUrl: new URL("http://localhost:5733") }, + port: 13_773, + }); + + assert.equal(state.devUrl, "http://localhost:5733/"); + assert.equal(state.origin, "http://127.0.0.1:13773"); + + const withoutDev = yield* ServerRuntimeState.makePersistedServerRuntimeState({ + config: { host: undefined, devUrl: undefined }, + port: 13_773, + }); + assert.isFalse("devUrl" in withoutDev); + }), + ); + it.effect("treats a missing runtime state file as absent", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index 329b000369a..b32f3814547 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -14,6 +14,9 @@ export const PersistedServerRuntimeState = Schema.Struct({ host: Schema.optional(Schema.String), port: Schema.Int, origin: Schema.String, + // Present when the server fronts a dev web server (VITE_DEV_SERVER_URL). + // Dev is single-origin: browsers must pair through this URL, not `origin`. + devUrl: Schema.optional(Schema.String), startedAt: Schema.String, }); export type PersistedServerRuntimeState = typeof PersistedServerRuntimeState.Type; @@ -45,7 +48,7 @@ const runtimeOriginForConfig = ( }; export const makePersistedServerRuntimeState = (input: { - readonly config: Pick; + readonly config: Pick; readonly port: number; }): Effect.Effect => Effect.map(DateTime.now, (now) => ({ @@ -54,6 +57,7 @@ export const makePersistedServerRuntimeState = (input: { ...(input.config.host ? { host: input.config.host } : {}), port: input.port, origin: runtimeOriginForConfig(input.config, input.port), + ...(input.config.devUrl ? { devUrl: input.config.devUrl.toString() } : {}), startedAt: DateTime.formatIso(now), })); diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index d6ff00bee1f..7c8cc388ea1 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -2,6 +2,26 @@ Use this when you want to connect to a T3 Code server from another device such as a phone, tablet, or separate desktop app. +## Quick Pairing for a Running Server + +If a server is already running on this machine, mint a fresh pairing token and QR code without restarting anything: + +```bash +npx t3 pair +``` + +`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. + +If the server is only bound to loopback, the printed URL is not reachable from another device. Pair over your tailnet instead: + +```bash +npx t3 pair --tailscale +``` + +This 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. + +If no server is running, `t3 pair` says so and points you at `npx t3 serve` or `npx t3 connect`. + ## Recommended Setup Use a trusted private network that meshes your devices together, such as a tailnet.