| ... | @@ -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 | */ |
| 12 | import { |
| 13 | AuthStandardClientScopes, |
| 14 | ExecutionEnvironmentDescriptor, |
| 15 | PortSchema, |
| 16 | } from "@t3tools/contracts"; |
| 17 | import { resolveWorktreeT3Home } from "@t3tools/shared/devHome"; |
| 18 | import { |
| 19 | buildTailscaleHttpsBaseUrl, |
| 20 | DEFAULT_TAILSCALE_SERVE_PORT, |
| 21 | ensureTailscaleServe, |
| 22 | readTailscaleStatus, |
| 23 | } from "@t3tools/tailscale"; |
| 24 | import * as Config from "effect/Config"; |
| 25 | import * as Console from "effect/Console"; |
| 26 | import * as DateTime from "effect/DateTime"; |
| 27 | import * as Duration from "effect/Duration"; |
| 28 | import * as Effect from "effect/Effect"; |
| 29 | import * as Layer from "effect/Layer"; |
| 30 | import * as Option from "effect/Option"; |
| 31 | import * as References from "effect/References"; |
| 32 | import * as Schema from "effect/Schema"; |
| 33 | import { Command, Flag, GlobalFlag } from "effect/unstable/cli"; |
| 34 | import { |
| 35 | FetchHttpClient, |
| 36 | HttpClient, |
| 37 | HttpClientRequest, |
| 38 | HttpClientResponse, |
| 39 | } from "effect/unstable/http"; |
| 40 | |
| 41 | import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; |
| 42 | import * as ServerConfig from "../config.ts"; |
| 43 | import { resolveBaseDir } from "../os-jank.ts"; |
| 44 | import { |
| 45 | type PersistedServerRuntimeState, |
| 46 | readPersistedServerRuntimeState, |
| 47 | } from "../serverRuntimeState.ts"; |
| 48 | import { |
| 49 | buildPairingUrl, |
| 50 | formatHostForUrl, |
| 51 | isLoopbackHost, |
| 52 | isWildcardHost, |
| 53 | renderTerminalQrCode, |
| 54 | resolveHeadlessConnectionString, |
| 55 | } from "../startupAccess.ts"; |
| 56 | import { baseDirFlag, DurationFromString } from "./config.ts"; |
| 57 | |
| 58 | const WELL_KNOWN_ENVIRONMENT_PATH = "/.well-known/t3/environment"; |
| 59 | const 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. |
| 62 | const TAILSCALE_PROBE_ATTEMPTS = 5; |
| 63 | const TAILSCALE_PROBE_RETRY_DELAY = Duration.seconds(1); |
| 64 | |
| 65 | export 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. |
| 69 | const DEV_VARIANT_PLACEHOLDER_URL = new URL("http://localhost"); |
| 70 | |
| 71 | export 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. |
| 88 | export 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 | |
| 97 | export 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 | |
| 106 | export 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 | |
| 115 | export 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 | |
| 124 | export 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. */ |
| 134 | export const resolveDirectPairingBaseUrl = (state: PersistedServerRuntimeState): string => |
| 135 | state.devUrl ?? resolveHeadlessConnectionString(state.host, state.port); |
| 136 | |
| 137 | export 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 | |
| 146 | const 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 | */ |
| 154 | export 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 | |
| 175 | export 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 | */ |
| 200 | type EnvironmentProbeResult = |
| 201 | | { readonly _tag: "descriptor"; readonly descriptor: ExecutionEnvironmentDescriptor } |
| 202 | | { readonly _tag: "unreachable" } |
| 203 | | { readonly _tag: "not-a-t3-server" }; |
| 204 | |
| 205 | const 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. |
| 234 | const 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 | |
| 243 | interface DiscoveredPairTarget { |
| 244 | readonly baseDir: string; |
| 245 | readonly variant: PairStateVariant; |
| 246 | readonly state: PersistedServerRuntimeState; |
| 247 | readonly descriptor: ExecutionEnvironmentDescriptor; |
| 248 | } |
| 249 | |
| 250 | const 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 | */ |
| 310 | const 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 | |
| 357 | const 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 | |
| 369 | const 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 | |
| 435 | const 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 | |
| 458 | const 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 | |
| 466 | const labelFlag = Flag.string("label").pipe( |
| 467 | Flag.withDescription("Optional label shown in the server's connections list."), |
| 468 | Flag.optional, |
| 469 | ); |
| 470 | |
| 471 | const 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 | |
| 478 | const 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 | |
| 484 | export 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 | ); |