532 lines · 20.1 KB · TypeScript
RawDownload
/** * `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,  isProcessAlive,  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>()(  "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>()(  "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>()(  "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>()(  "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>()(  "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 <local-url>\` by hand to see why.`;  }} export class ServePortOccupiedError extends Schema.TaggedErrorClass<ServePortOccupiedError>()(  "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>()(  "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>;}): 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<EnvironmentProbeResult, never, HttpClient.HttpClient> =>  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))); 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<string> = [];  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<string> = [];  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<string> = [];    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<Duration.Duration>;  readonly label: Option.Option<string>;}) {  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<string> = [];      let pairingBaseUrl: string;      if (flags.tailscale) {        const resolved = yield* resolveTailscalePairingBase({          target,          servePort: flags.tailscaleServePort,        });        pairingBaseUrl = resolved.baseUrl;

Lines 1–500 of 532.