diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 841962f0dc8e..2225a1f20b97 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -131,6 +131,14 @@ const EnvServerConfig = Config.all({ host: Config.String("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), t3Home: Config.String("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)), devUrl: Config.URL("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)), + connectDevShare: Config.String("T3CODE_DEV_SHARE").pipe( + Config.withDefault(""), + Config.map((value) => value === "connect"), + ), + connectAuthorizationHome: Config.String("T3CODE_CONNECT_AUTHORIZATION_HOME").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), devAllowedOrigins: Config.String("T3CODE_DEV_ALLOWED_ORIGINS").pipe( Config.withDefault(""), Config.map((value) => @@ -464,6 +472,10 @@ export const resolveServerConfig = ( devUrl, ...(devAuthToken === undefined ? {} : { devAuthToken }), devAllowedOrigins: env.devAllowedOrigins, + ...(env.connectDevShare ? { connectDevShare: true } : {}), + ...(env.connectAuthorizationHome + ? { connectAuthorizationHome: env.connectAuthorizationHome } + : {}), noBrowser, startupPresentation, desktopBootstrapToken, diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 83d384097ecc..d83a67327185 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -434,7 +434,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* (run: (origin: string) => Effect.Effect { + it.effect("refuses missing, invalid and unreachable connect endpoints before minting", () => + withDescriptorServer((origin) => + Effect.gen(function* () { + const baseDir = yield* Effect.acquireRelease( + Effect.sync(() => NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-connect-"))), + (path) => Effect.sync(() => NodeFS.rmSync(path, { recursive: true, force: true })), + ); + const stateDir = NodePath.join(baseDir, "userdata"); + yield* persistServerRuntimeState({ + path: NodePath.join(stateDir, "server-runtime.json"), + state: yield* makePersistedServerRuntimeState({ + config: { host: "127.0.0.1", devUrl: undefined }, + port: Number(new URL(origin).port), + }), + }); + const secretsDir = NodePath.join(stateDir, "secrets"); + NodeFS.mkdirSync(secretsDir, { recursive: true }); + for (const [endpoint, expectedMessage] of [ + [undefined, "No saved Connect endpoint"], + ["not a url", "saved Connect endpoint is invalid"], + [origin, "saved Connect endpoint is invalid"], + ["https://user:password@example.com", "saved Connect endpoint is invalid"], + ["https://example.com/path", "saved Connect endpoint is invalid"], + ["https://127.0.0.1:1", "not reaching this environment"], + ] as const) { + if (endpoint !== undefined) { + NodeFS.writeFileSync( + NodePath.join(secretsDir, `${CLOUD_ENDPOINT_HTTP_ORIGIN}.bin`), + endpoint, + ); + } + const error = yield* provideCliTestLayers( + runCli(["pair", "--base-dir", baseDir, "--connect"]).pipe(Effect.flip), + ); + const rendered = String( + typeof error === "object" && error !== null && "cause" in error ? error.cause : error, + ); + assert.include(rendered, expectedMessage); + assert.isFalse(NodeFS.existsSync(NodePath.join(stateDir, "state.sqlite"))); + } + }), + ).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("mints a token and prints a QR pairing URL for a live server", () => withDescriptorServer((origin) => Effect.gen(function* () { diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 63e7de96c232..c3125e7b684b 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -7,7 +7,8 @@ * 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. + * HTTPS and pairs through the tailnet URL instead. `--connect` uses the + * environment's existing managed Connect endpoint after checking it is live. */ import { AuthStandardClientScopes, @@ -36,6 +37,8 @@ import { Command, Flag, GlobalFlag } from "effect/cli"; import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { CLOUD_ENDPOINT_HTTP_ORIGIN } from "../cloud/config.ts"; import * as ServerConfig from "../config.ts"; import { resolveBaseDir } from "../os-jank.ts"; import { @@ -55,6 +58,7 @@ import { baseDirFlag, DurationFromString } from "./config.ts"; const WELL_KNOWN_ENVIRONMENT_PATH = "/.well-known/t3/environment"; const PAIR_PROBE_TIMEOUT = Duration.millis(2_500); +const decodeConnectOrigin = Schema.decodeUnknownOption(Schema.URLFromString); // 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; @@ -81,6 +85,23 @@ export class NoRunningServerError extends Schema.TaggedError()( + "ConnectPairingError", + { reason: Schema.Literals(["missing", "invalid", "unreachable", "conflict"]) }, +) { + override get message(): string { + return { + missing: + "No saved Connect endpoint for this environment. Run `t3 connect link`, or restart its `vp run dev --share` server.", + invalid: + "The saved Connect endpoint is invalid. Restart this environment to refresh its link.", + unreachable: + "The saved Connect endpoint is not reaching this environment. Restart its Connect share and retry once the public endpoint is ready.", + conflict: "Choose either --connect or --tailscale.", + }[this.reason]; + } +} + // 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.TaggedError()( @@ -128,7 +149,7 @@ export class ServePortOccupiedError extends Schema.TaggedError state.devUrl ?? resolveHeadlessConnectionString(state.host, state.port); @@ -227,6 +248,34 @@ const probeEnvironmentDescriptor = ( return { _tag: "descriptor", descriptor } as const; }).pipe(Effect.catch((outcome) => Effect.succeed(outcome))); +const resolveConnectPairingBaseUrl = Effect.fn("pair.resolveConnectPairingBaseUrl")(function* ( + environmentId: ExecutionEnvironmentDescriptor["environmentId"], +) { + const secrets = yield* ServerSecretStore.ServerSecretStore; + const stored = yield* secrets.get(CLOUD_ENDPOINT_HTTP_ORIGIN); + if (Option.isNone(stored)) { + return yield* new ConnectPairingError({ reason: "missing" }); + } + const parsed = decodeConnectOrigin(new TextDecoder().decode(stored.value)); + if ( + Option.isNone(parsed) || + parsed.value.protocol !== "https:" || + parsed.value.username !== "" || + parsed.value.password !== "" || + parsed.value.pathname !== "/" || + parsed.value.search !== "" || + parsed.value.hash !== "" + ) { + return yield* new ConnectPairingError({ reason: "invalid" }); + } + const baseUrl = parsed.value.origin; + const probe = yield* probeEnvironmentDescriptor(baseUrl); + if (probe._tag !== "descriptor" || probe.descriptor.environmentId !== environmentId) { + return yield* new ConnectPairingError({ reason: "unreachable" }); + } + return baseUrl; +}); + interface DiscoveredPairTarget { readonly baseDir: string; readonly variant: PairStateVariant; @@ -477,6 +526,10 @@ export const pairCommand = Command.make("pair", { baseDir: baseDirFlag, ttl: ttlFlag, label: labelFlag, + connect: Flag.Boolean("connect").pipe( + Flag.withDescription("Pair through this environment's existing T3 Connect endpoint."), + Flag.withDefault(false), + ), tailscale: tailscaleFlag, tailscaleServePort: tailscaleServePortFlag, }).pipe( @@ -485,16 +538,24 @@ export const pairCommand = Command.make("pair", { ), Command.withHandler((flags) => Effect.gen(function* () { + if (flags.connect && flags.tailscale) { + return yield* new ConnectPairingError({ reason: "conflict" }); + } 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 config = yield* makePairServerConfig({ target, logLevel }); const notes: Array = []; let pairingBaseUrl: string; - if (flags.tailscale) { + if (flags.connect) { + pairingBaseUrl = yield* resolveConnectPairingBaseUrl(target.descriptor.environmentId).pipe( + Effect.provide(ServerSecretStore.layer.pipe(Layer.provide(ServerConfig.layer(config)))), + ); + } else if (flags.tailscale) { const resolved = yield* resolveTailscalePairingBase({ target, servePort: flags.tailscaleServePort, @@ -515,7 +576,6 @@ export const pairCommand = Command.make("pair", { } } - const config = yield* makePairServerConfig({ target, logLevel }); const issued = yield* mintPairingLink({ config, ttl: flags.ttl, label: flags.label }); const pairingUrl = buildPairingUrl(pairingBaseUrl, issued.credential); diff --git a/apps/server/src/cloud/CliState.ts b/apps/server/src/cloud/CliState.ts index dc77609ccc92..82b8425a1be9 100644 --- a/apps/server/src/cloud/CliState.ts +++ b/apps/server/src/cloud/CliState.ts @@ -4,6 +4,7 @@ import * as Option from "effect/Option"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { CLOUD_ENDPOINT_CONFIRMED_ORIGIN, + CLOUD_ENDPOINT_HTTP_ORIGIN, CLOUD_ENDPOINT_RUNTIME_CONFIG, CLOUD_LINKED_USER_ID, CLOUD_MINT_PUBLIC_KEY, @@ -54,6 +55,7 @@ export const setCliDesiredCloudLink = Effect.fn("cloud.cli_state.set_desired")(f ); } else { yield* secrets.remove(CLOUD_CLI_DESIRED_LINK_SECRET); + yield* secrets.remove(CLOUD_ENDPOINT_HTTP_ORIGIN); } }); @@ -68,6 +70,7 @@ export const clearPersistedCloudLink = Effect.gen(function* () { secrets.remove(RELAY_ENVIRONMENT_CREDENTIAL_SECRET), secrets.remove(CLOUD_MINT_PUBLIC_KEY), secrets.remove(CLOUD_ENDPOINT_RUNTIME_CONFIG), + secrets.remove(CLOUD_ENDPOINT_HTTP_ORIGIN), secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN), secrets.remove(PUBLISH_AGENT_ACTIVITY_SECRET), ], diff --git a/apps/server/src/cloud/CliTokenManager.test.ts b/apps/server/src/cloud/CliTokenManager.test.ts index c0fb59026a76..ae7393b951e3 100644 --- a/apps/server/src/cloud/CliTokenManager.test.ts +++ b/apps/server/src/cloud/CliTokenManager.test.ts @@ -5,9 +5,11 @@ import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Base64Url from "effect/encoding/Base64Url"; +import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Terminal from "effect/Terminal"; @@ -15,6 +17,9 @@ import * as TestClock from "effect/testing/TestClock"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as ExternalLauncher from "../process/externalLauncher.ts"; import * as CliTokenManager from "./CliTokenManager.ts"; // pk_test_ @@ -323,3 +328,106 @@ it.layer(NodeServices.layer)("CliTokenManager.deviceAuthorizationLogin", (it) => }), ); }); + +it.layer(NodeServices.layer)("CliTokenManager.layerWithSharedAuthorization", (it) => { + it.effect.each([ + { + name: "prefers the stored home over the dev-share home", + stored: true, + devShare: true, + configured: true, + expected: "stored", + }, + { + name: "uses the stored home outside dev sharing", + stored: true, + devShare: false, + configured: true, + expected: "stored", + }, + { + name: "uses the configured home for dev sharing", + stored: false, + devShare: true, + configured: true, + expected: "configured", + }, + { + name: "ignores the configured home outside dev sharing", + stored: false, + devShare: false, + configured: true, + expected: "local", + }, + { + name: "uses local credentials without an authorization home", + stored: false, + devShare: true, + configured: false, + expected: "local", + }, + ])("$name", (scenario) => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-auth-priority-" }); + const storedHome = path.join(directory, "stored"); + const configuredHome = path.join(directory, "configured"); + const localConfig = ServerConfig.make({ + ...config, + secretsDir: path.join(directory, "local"), + connectDevShare: scenario.devShare, + ...(scenario.configured ? { connectAuthorizationHome: configuredHome } : {}), + }); + for (const [identity, secretsDir] of [ + ["stored", path.join(storedHome, "userdata", "secrets")], + ["configured", path.join(configuredHome, "userdata", "secrets")], + ["local", localConfig.secretsDir], + ] as const) { + yield* Effect.gen(function* () { + const manager = yield* CliTokenManager.CloudCliTokenManager; + yield* manager.store({ + accessToken: identity, + refreshToken: identity, + expiresAtEpochMs: Number.MAX_SAFE_INTEGER, + }); + }).pipe( + Effect.provide( + CliTokenManager.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provide(ServerConfig.layer({ ...localConfig, secretsDir })), + ), + ), + ); + } + yield* Effect.gen(function* () { + const secrets = yield* ServerSecretStore.ServerSecretStore; + if (scenario.stored) { + yield* secrets.set( + CliTokenManager.CLOUD_CLI_AUTHORIZATION_HOME_SECRET, + new TextEncoder().encode(storedHome), + ); + } + const token = yield* Effect.gen(function* () { + const manager = yield* CliTokenManager.CloudCliTokenManager; + return yield* manager.getExisting; + }).pipe(Effect.provide(CliTokenManager.layerWithSharedAuthorization)); + assert.isTrue(Option.isSome(token)); + assert.equal(Option.getOrThrow(token).accessToken, scenario.expected); + }).pipe( + Effect.provide( + ServerSecretStore.layer.pipe(Layer.provideMerge(ServerConfig.layer(localConfig))), + ), + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-cli-auth-config-" }), + ExternalLauncher.layer, + layerDeviceFlow({ requests: [], tokenReplies: [] }), + ), + ), + ), + ); +}); diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index 475f9aa9caee..2982cdcf7bc7 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -13,6 +13,7 @@ import * as Effect from "effect/Effect"; import * as Base64Url from "effect/encoding/Base64Url"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; @@ -28,6 +29,7 @@ import * as HttpServerResponse from "effect/http/HttpServerResponse"; import { buildConnectAuthorizeRequestUrl } from "@t3tools/shared/connectAuth"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; import * as ExternalLauncher from "../process/externalLauncher.ts"; import { cloudCliOAuthConfig, @@ -37,6 +39,7 @@ import { import { renderLoopbackAuthorizationCompleteHtml } from "./cliAuthHtml.ts"; const CLOUD_CLI_OAUTH_TOKEN_SECRET = "cloud-cli-oauth-token"; +export const CLOUD_CLI_AUTHORIZATION_HOME_SECRET = "cloud-cli-authorization-home"; const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10); const CLOUD_CLI_OAUTH_REFRESH_EARLY_MS = Duration.toMillis(Duration.minutes(5)); const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; @@ -583,3 +586,33 @@ export const make = Effect.gen(function* () { }); export const layer = Layer.effect(CloudCliTokenManager, make); + +// A dev environment retains its own link state while account commands use +// the same login that originally authorized its shared endpoint. +export const layerWithSharedAuthorization = Layer.unwrap( + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const localSecrets = yield* ServerSecretStore.ServerSecretStore; + const storedHome = yield* localSecrets.get(CLOUD_CLI_AUTHORIZATION_HOME_SECRET); + const authorizationHome = + (Option.isSome(storedHome) ? new TextDecoder().decode(storedHome.value).trim() : undefined) || + (config.connectDevShare ? config.connectAuthorizationHome : undefined); + const path = yield* Path.Path; + return layer.pipe( + Layer.provide( + Layer.fresh(ServerSecretStore.layer).pipe( + Layer.provide( + ServerConfig.layer( + ServerConfig.make({ + ...config, + secretsDir: authorizationHome + ? path.join(authorizationHome, "userdata", "secrets") + : config.secretsDir, + }), + ), + ), + ), + ), + ); + }), +); diff --git a/apps/server/src/cloud/CloudLink.lifecycle.test.ts b/apps/server/src/cloud/CloudLink.lifecycle.test.ts index dd2115692778..7a2ffa60fbe2 100644 --- a/apps/server/src/cloud/CloudLink.lifecycle.test.ts +++ b/apps/server/src/cloud/CloudLink.lifecycle.test.ts @@ -33,6 +33,7 @@ import * as CliTokenManager from "./CliTokenManager.ts"; import { RelayManagedEndpointRecoveryRegistrationRequest } from "@t3tools/contracts/relay"; import { CLOUD_ENDPOINT_CONFIRMED_ORIGIN, + CLOUD_ENDPOINT_HTTP_ORIGIN, CLOUD_ENDPOINT_RUNTIME_CONFIG, CLOUD_LINKED_USER_ID, decodeConfirmedOrigin, @@ -357,6 +358,25 @@ describe("releaseManagedTunnelOnShutdown", () => { [CLOUD_CLI_DESIRED_LINK_SECRET, "managed"], ] as const; + it.effect("clears the managed public HTTP origin when unlinking", () => { + const { store, values } = makeMemorySecretStore([ + ...managedLinkSecrets, + [CLOUD_ENDPOINT_HTTP_ORIGIN, "https://environment.example.test"], + ]); + const applyConfigCalls: Array = []; + const requests: Array = []; + return Effect.gen(function* () { + const link = yield* CloudLink.CloudLink; + expect(yield* link.unlink()).toMatchObject({ + ok: true, + endpointRuntimeStatus: { status: "disabled" }, + }); + expect(values.has(CLOUD_ENDPOINT_HTTP_ORIGIN)).toBe(false); + expect(values.has(CLOUD_CLI_DESIRED_LINK_SECRET)).toBe(false); + expect(applyConfigCalls).toEqual([null]); + }).pipe(provideReleaseHarness({ store, applyConfigCalls, requests })); + }); + it.effect("does not recreate a link that was unlinked while startup registration retried", () => { const { store, values } = makeMemorySecretStore(managedLinkSecrets); const applyConfigCalls: Array = []; diff --git a/apps/server/src/cloud/CloudLink.ts b/apps/server/src/cloud/CloudLink.ts index 57904596b5f1..b4ddfb900b59 100644 --- a/apps/server/src/cloud/CloudLink.ts +++ b/apps/server/src/cloud/CloudLink.ts @@ -83,6 +83,7 @@ import { import * as CliTokenManager from "./CliTokenManager.ts"; import { CLOUD_ENDPOINT_CONFIRMED_ORIGIN, + CLOUD_ENDPOINT_HTTP_ORIGIN, CLOUD_ENDPOINT_RUNTIME_CONFIG, CLOUD_LINKED_USER_ID, CLOUD_MINT_PUBLIC_KEY, @@ -817,6 +818,7 @@ const make = Effect.gen(function* () { } yield* endpointRuntime.applyConfig(null); yield* secrets.remove(CLOUD_ENDPOINT_CONFIRMED_ORIGIN); + yield* secrets.remove(CLOUD_ENDPOINT_HTTP_ORIGIN); yield* secrets.set(RELAY_URL_SECRET, stringToBytes(payload.relayUrl)); yield* secrets.set( @@ -944,6 +946,11 @@ const make = Effect.gen(function* () { confirmedOrigin: parsedOrigin.origin, }, ); + if (managedTunnelsEnabled) { + yield* secrets.set(CLOUD_ENDPOINT_HTTP_ORIGIN, stringToBytes(link.endpoint.httpBaseUrl)); + } else { + yield* secrets.remove(CLOUD_ENDPOINT_HTTP_ORIGIN); + } // Callers decide on managed tunnel recovery from the mode this link // actually used, not from a value read before the relay round trip. return mode; diff --git a/apps/server/src/cloud/config.ts b/apps/server/src/cloud/config.ts index 910f0820282f..54a46b40f78a 100644 --- a/apps/server/src/cloud/config.ts +++ b/apps/server/src/cloud/config.ts @@ -10,6 +10,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; export const CLOUD_MINT_PUBLIC_KEY = "cloud-mint-ed25519-public-key"; export const CLOUD_ENDPOINT_RUNTIME_CONFIG = "cloud-endpoint-runtime-config"; +export const CLOUD_ENDPOINT_HTTP_ORIGIN = "cloud-endpoint-http-origin"; export const CLOUD_ENDPOINT_CONFIRMED_ORIGIN = "cloud-endpoint-confirmed-origin"; export const CLOUD_LINKED_USER_ID = "cloud-linked-user-id"; export const RELAY_URL_SECRET = "cloud-relay-url"; diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 91a1775bdf22..2968b2a0df07 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -89,6 +89,8 @@ export class ServerConfig extends Context.Service< readonly baseDir: string; readonly staticDir: string | undefined; readonly devUrl: URL | undefined; + readonly connectDevShare?: boolean; + readonly connectAuthorizationHome?: string; readonly devAuthToken?: Redacted.Redacted | undefined; readonly devAllowedOrigins: ReadonlyArray; readonly noBrowser: boolean; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 1294e6bdcf32..58f29d5154ed 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -10,7 +10,11 @@ import * as NodeHttp from "node:http"; import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { EnvironmentHttpApi, type RepositoryIdentity } from "@t3tools/contracts"; +import { + EnvironmentHttpApi, + ExecutionEnvironmentDescriptor, + type RepositoryIdentity, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Duration from "effect/Duration"; import * as Deferred from "effect/Deferred"; @@ -18,8 +22,15 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; +import * as Schema from "effect/Schema"; import * as Schedule from "effect/Schedule"; -import { FetchHttpClient, HttpRouter, HttpServer } from "effect/http"; +import { + FetchHttpClient, + HttpClient, + HttpClientResponse, + HttpRouter, + HttpServer, +} from "effect/http"; import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; @@ -138,6 +149,7 @@ import { retryManagedTunnelRegistration, } from "./cloud/managedTunnelStartup.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; +import * as CloudConfig from "./cloud/config.ts"; import * as CloudCliState from "./cloud/CliState.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as DesktopAppUpdate from "./desktopUpdate/DesktopAppUpdate.ts"; @@ -618,7 +630,7 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe( Layer.provideMerge(ServerSecretStore.layer), Layer.provideMerge( Layer.mergeAll( - CloudCliTokenManager.layer.pipe( + CloudCliTokenManager.layerWithSharedAuthorization.pipe( Layer.provide(ServerSecretStore.layer), Layer.provide(ExternalLauncher.layer), ), @@ -692,9 +704,29 @@ const layerMakeRoutes = Layer.mergeAll( Layer.provide(ServerHttp.layerHttpCompression), ); +class ConnectDevShareError extends Schema.TaggedError()( + "ConnectDevShareError", + { reason: Schema.Literals(["configuration", "environment-mismatch"]) }, +) { + override get message(): string { + return this.reason === "configuration" + ? "Connect dev sharing requires T3 Connect public configuration and a local HTTP Vite origin. Copy .env.example to .env to configure a source build." + : "Connect endpoint belongs to a different environment."; + } +} + const layerMakeServer = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; + if ( + config.connectDevShare && + (!hasCloudPublicConfig || + !config.devUrl || + config.devUrl.protocol !== "http:" || + !["localhost", "127.0.0.1"].includes(config.devUrl.hostname)) + ) { + return yield* new ConnectDevShareError({ reason: "configuration" }); + } const activation = yield* Deferred.make(); const awaitActivation = Deferred.await(activation); const layerActivation = Layer.succeed(ServerActivation.ServerActivation, awaitActivation); @@ -831,10 +863,69 @@ const layerMakeServer = Layer.unwrap( if (!cleanupBeforeActivation) { yield* Effect.addFinalizer(() => releaseManagedTunnel); } + if (config.devUrl && !config.connectDevShare) return; const server = yield* HttpServer.HttpServer; const address = server.address; if (typeof address === "string" || !("port" in address)) return; - const localOrigin = `http://127.0.0.1:${address.port}`; + const localOrigin = + config.connectDevShare && config.devUrl + ? config.devUrl.origin + : `http://127.0.0.1:${address.port}`; + if (config.connectDevShare) { + yield* Effect.gen(function* () { + const secrets = yield* ServerSecretStore.ServerSecretStore; + if (config.connectAuthorizationHome) { + yield* secrets.set( + CloudCliTokenManager.CLOUD_CLI_AUTHORIZATION_HOME_SECRET, + new TextEncoder().encode(config.connectAuthorizationHome), + ); + } + const relayClient = yield* RelayClient.RelayClient; + yield* relayClient.install; + yield* cloudLink.reconcileDesiredLink(localOrigin).pipe( + Effect.retry({ + while: CloudLink.shouldRetryCloudLink, + schedule: Schedule.exponential("1 second").pipe( + Schedule.upTo({ duration: "10 minutes" }), + ), + }), + ); + const storedOrigin = yield* secrets.get(CloudConfig.CLOUD_ENDPOINT_HTTP_ORIGIN); + if (Option.isNone(storedOrigin)) + return yield* new ConnectDevShareError({ reason: "configuration" }); + const publicOrigin = new TextDecoder().decode(storedOrigin.value); + const environment = yield* ServerEnvironment.ServerEnvironment; + const expected = yield* environment.getDescriptor; + const client = yield* HttpClient.HttpClient; + yield* client.get(new URL("/.well-known/t3/environment", publicOrigin)).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap(HttpClientResponse.schemaBodyJson(ExecutionEnvironmentDescriptor)), + Effect.filterOrFail( + (actual) => actual.environmentId === expected.environmentId, + () => new ConnectDevShareError({ reason: "environment-mismatch" }), + ), + Effect.timeout("5 seconds"), + Effect.retry({ + schedule: Schedule.spaced("1 second"), + while: (error) => !Schema.is(ConnectDevShareError)(error), + }), + Effect.timeout("2 minutes"), + ); + const auth = yield* EnvironmentAuth.EnvironmentAuth; + const pairingUrl = yield* auth.issueStartupPairingUrl(publicOrigin); + yield* Effect.logInfo(`[dev-runner] pairingUrl: ${pairingUrl}`); + }).pipe( + Effect.tapError((error) => + Effect.logWarning( + Schema.is(CloudLink.CloudLinkAuthorizationMissingError)(error) || + Schema.is(ConnectDevShareError)(error) + ? `T3 Connect dev sharing failed. ${error.message}` + : "T3 Connect dev sharing failed. Check the Connect configuration and retry.", + { errorTag: error._tag }, + ), + ), + ); + } const endpointRuntime = yield* CloudManagedEndpointRuntime.CloudManagedEndpointRuntime; const recoveryLock = yield* Semaphore.make(1); let lastRecoveryAtMillis = 0; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 1d710f273cd0..5d305843b82f 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -9,7 +9,7 @@ import "vite-plus/test/config"; import { defineConfig, type Connect, type Plugin } from "vite-plus"; import pkg from "./package.json" with { type: "json" }; -import { DEV_PROXIED_PATH_PREFIXES } from "@t3tools/shared/devProxy"; +import { DEV_PROXIED_PATH_PREFIXES, isDevProxiedPath } from "@t3tools/shared/devProxy"; import { loadRepoEnv } from "../../scripts/lib/public-config"; import { thirdPartyLicensesPlugin } from "../../scripts/lib/third-party-licenses"; @@ -32,8 +32,14 @@ const explicitHost = process.env.HOST?.trim(); const host = explicitHost || "localhost"; const configuredWsUrl = isSingleOriginDev ? undefined : process.env.VITE_WS_URL?.trim(); const configuredHttpUrl = isSingleOriginDev ? undefined : process.env.VITE_HTTP_URL?.trim(); -const configuredRelayUrl = repoEnv.VITE_T3CODE_RELAY_URL?.trim() || ""; -const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || ""; +// Shared dev uses direct pairing; Clerk's hosted sign-in is not configured +// for each managed environment hostname. +const configuredRelayUrl = + process.env.T3CODE_DEV_SHARE === "connect" ? "" : repoEnv.VITE_T3CODE_RELAY_URL?.trim() || ""; +const configuredClerkPublishableKey = + process.env.T3CODE_DEV_SHARE === "connect" + ? "" + : repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || ""; const configuredClerkJwtTemplate = repoEnv.VITE_CLERK_JWT_TEMPLATE?.trim() || ""; const configuredClerkCliOAuthClientId = repoEnv.VITE_CLERK_CLI_OAUTH_CLIENT_ID?.trim() || ""; const configuredRelayTracingUrl = repoEnv.VITE_RELAY_OTLP_TRACES_URL?.trim() || ""; @@ -63,6 +69,11 @@ const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase(); // round trip per import level in unbundled dev); T3CODE_BUNDLED_DEV=0 opts out. const bundledDevEnv = process.env.T3CODE_BUNDLED_DEV?.trim().toLowerCase(); const bundledDev = bundledDevEnv === "1" || bundledDevEnv === "true"; +const connectDevShare = process.env.T3CODE_DEV_SHARE === "connect"; + +if (connectDevShare && (!bundledDev || !["localhost", "127.0.0.1"].includes(host))) { + throw new Error("Connect dev sharing requires bundled dev and a loopback listener."); +} const buildSourcemap: boolean | "hidden" = sourcemapEnv === "0" || sourcemapEnv === "false" @@ -143,6 +154,114 @@ function devCompressionPlugin(): Plugin { }; } +function connectDevSharePlugin(): Plugin { + const publicFiles = new Set([ + "/apple-touch-icon.png", + "/favicon-16x16.png", + "/favicon-32x32.png", + "/favicon.ico", + "/manifest.webmanifest", + ]); + return { + name: "t3code:connect-dev-share", + apply: "serve", + configureServer(server) { + // Bundled dev needs only these two inbound events. Vite's other custom + // handlers trust their payloads and must not receive public socket input. + server.ws.on("connection", (socket) => { + const listeners = socket.listeners("message"); + socket.removeAllListeners("message"); + let registered = false; + socket.on("message", (raw, isBinary) => { + if (isBinary || !Buffer.isBuffer(raw) || raw.length > 4096) return socket.terminate(); + let message: unknown; + try { + message = JSON.parse(raw.toString()); + } catch { + return socket.terminate(); + } + if ( + typeof message === "object" && + message !== null && + "type" in message && + message.type === "ping" + ) + return; + if ( + typeof message !== "object" || + message === null || + !("type" in message) || + message.type !== "custom" || + !("event" in message) || + !("data" in message) || + typeof message.data !== "object" || + message.data === null + ) + return socket.terminate(); + if ( + message.event === "vite:client-connected" && + !registered && + "clientId" in message.data && + typeof message.data.clientId === "string" && + message.data.clientId.length > 0 + ) + registered = true; + else if ( + message.event !== "vite:bundled-dev:reload-needed" || + !registered || + !("reason" in message.data) || + typeof message.data.reason !== "string" + ) + return socket.terminate(); + for (const listener of listeners) listener.call(socket, raw, isBinary); + }); + }); + server.middlewares.use((request, response, next) => { + const [pathname = "/", query] = (request.url ?? "/").split("?", 2); + if (isDevProxiedPath(pathname)) return next(); + response.setHeader("Cache-Control", "no-cache"); + response.setHeader("Cloudflare-CDN-Cache-Control", "no-store"); + const params = new URLSearchParams(query); + const safePath = + pathname.startsWith("/") && + !/[%#\\]/u.test(pathname) && + !pathname.includes("/.") && + !pathname.endsWith(".map") && + !/^\/(?:src|node_modules|@|__)/u.test(pathname) && + !["raw", "url", "import", "direct", "sourcemap"].some((key) => params.has(key)); + if (safePath && (request.method === "GET" || request.method === "HEAD")) { + // Only Vite's emitted files are public, never its source loader or + // compiler endpoints. HMR patches use the same in-memory file store. + const emitted = server.environments.client?.bundledDev?.memoryFiles.has( + pathname.slice(1), + ); + const assetQuery = [...params].every( + ([key, value]) => key === "t" && /^\d+$/u.test(value), + ); + if ((emitted || publicFiles.has(pathname)) && assetQuery) return next(); + if (pathname === "/index.html" || /^\/[a-zA-Z0-9_/-]*$/u.test(pathname)) { + // Every navigation renders this app, even if another HTML file + // exists in the workspace. Pairing queries stay in the browser. + request.url = "/index.html"; + return next(); + } + } + response.writeHead(404).end(); + }); + server.httpServer?.prependListener("upgrade", (request, socket) => { + const pathname = request.url?.split("?", 1)[0]; + const protocol = request.headers["sec-websocket-protocol"]; + if ( + !isDevProxiedPath(pathname ?? "/") && + !(pathname === "/" && (protocol === "vite-hmr" || protocol === "vite-ping")) + ) { + socket.destroy(); + } + }); + }, + }; +} + // Vite rejects requests whose Host header isn't localhost, which blocks sharing // a dev server over Tailscale/LAN. Tailnet names are safe to allow wholesale: // the DNS is controlled by tailscale, so they can't be rebound by an attacker. @@ -157,6 +276,7 @@ export default defineConfig(() => { return { assetsInclude: ["**/*.wasm"], plugins: [ + ...(connectDevShare ? [connectDevSharePlugin()] : []), devCompressionPlugin(), thirdPartyLicensesPlugin({ bundleName: "web", @@ -225,10 +345,14 @@ export default defineConfig(() => { bundledDev, }, server: { + // The environment server owns credentialed API CORS, including Electron origins. + ...(connectDevShare ? { cors: { preflightContinue: true } } : {}), host, port, strictPort: true, - allowedHosts, + // The guarded public surface is intentional; the managed hostname is + // allocated after Vite starts and backend routes retain their own auth. + allowedHosts: connectDevShare ? (true as const) : allowedHosts, // Transform the whole module graph at server start instead of on the // first request. Without this, a cold worktree discovers and transforms // modules one import-level at a time while the browser waits — which @@ -262,7 +386,7 @@ export default defineConfig(() => { // page origin, which is what makes HMR work over Tailscale/LAN instead of // failing an attempt against the wrong machine's localhost first. // (Vite 8 logs connection state via console.debug — enable "Verbose".) - ...(explicitHost + ...(explicitHost && !connectDevShare ? { hmr: { protocol: "ws", @@ -278,6 +402,24 @@ export default defineConfig(() => { devSourcemap: buildSourcemap !== false, }, build: { + // Compile split chunks at startup instead of exposing Vite's arbitrary + // module compilation endpoint. Clients still load routes on demand. + ...(connectDevShare + ? { + rolldownOptions: { + experimental: { devMode: { lazy: false } }, + output: { + codeSplitting: { + groups: [ + { name: "refresh", test: /react-refresh/, priority: 30 }, + { name: "icons", test: /[\\/]lucide-react[\\/]/, priority: 20 }, + { name: "shared", minShareCount: 2, includeDependenciesRecursively: true }, + ], + }, + }, + }, + } + : {}), outDir: "dist", emptyOutDir: true, manifest: true, diff --git a/docs/operations/development.md b/docs/operations/development.md index b7e1b257d0b2..9fa7920a3ea7 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -37,15 +37,24 @@ different preference when needed. ### Sharing and remote debugging -`vp run dev --share` publishes the web port over the machine's tailnet and prints a pairing URL -for that origin. Give the tester the complete URL, including its token. The dev runner removes -its mapping on exit. +`vp run dev --share` shares through T3 Connect. Copy `.env.example` to `.env` for a source build, +then run `node --env-file=.env apps/server/src/bin.ts connect login --base-dir ~/.t3` from the +repository root once. The runner reuses that account login while keeping each worktree's +environment and runtime state separate. Give the tester the complete printed pairing URL, +including its token; `node --env-file=.env apps/server/src/bin.ts pair --connect` creates another. + +Each worktree uses a normal Connect environment and counts toward the account's tunnel limit. +Shutdown releases its tunnel but keeps its registration and hostname for the next run. +Deregister an unused worktree from T3 Connect to remove it. A killed process can leave its +tunnel allocated until restart or deregistration. `--share-via tailscale` selects the existing +tailnet sharing path instead. Leave `VITE_HTTP_URL` and `VITE_WS_URL` unset. Vite proxies the backend through the browser's origin so the same build works over localhost and remote connections. Shared runs enable bundled dev to avoid a network round trip for each import level. -`T3CODE_BUNDLED_DEV=0` opts out when debugging bundler differences. Two reload traps matter +Connect sharing requires bundled dev; `T3CODE_BUNDLED_DEV=0` opts out for other runs when +debugging bundler differences. Two reload traps matter when changing this setup: - The web entry must dynamically import the app so React refresh initializes before application diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index c8363c181976..8f52a9e944a1 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -912,11 +912,15 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { // lives in runDevRunnerWithInput; the env builder must not consult the // ambient variable on its own, or it would silently outrank the worktree // default and land dev state on the user's real database. - it.effect("ignores an ambient T3CODE_HOME when no home is resolved", () => + it.effect("clears inherited home and sharing state when neither is requested", () => Effect.gen(function* () { const env = yield* createDevRunnerEnv({ mode: "dev", - baseEnv: { T3CODE_HOME: "/home/user/.t3" }, + baseEnv: { + T3CODE_HOME: "/home/user/.t3", + T3CODE_DEV_SHARE: "connect", + T3CODE_CONNECT_AUTHORIZATION_HOME: "/another/environment", + }, serverOffset: 0, webOffset: 0, t3Home: undefined, @@ -929,13 +933,15 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }); assert.equal(env.T3CODE_HOME, undefined); + assert.equal(env.T3CODE_DEV_SHARE, undefined); + assert.equal(env.T3CODE_CONNECT_AUTHORIZATION_HOME, undefined); }), ); // Sharing dev:desktop would publish a URL whose renderer dials the // visitor's own loopback, and would clobber the VITE_DEV_SERVER_URL that // Electron loads from. It must decline, not half-work. - it.effect("declines to share for dev:desktop and still starts the stack", () => { + it.effect("declines tailscale sharing for dev:desktop and still starts the stack", () => { let spawnCount = 0; const spawnerLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, @@ -951,6 +957,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { mode: "dev:desktop", port: undefined, share: true, + shareVia: "tailscale", }).pipe( Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), Effect.provideService(HostProcessPlatform, "linux"), @@ -1048,7 +1055,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { // per-module waterfall pays a tailnet round trip per import level. The // runner defaults bundled dev on for the spawned stack, but only // defaults: an explicit T3CODE_BUNDLED_DEV (even "0") must pass through. - describe("--share bundled dev default", () => { + describe("--share-via tailscale bundled dev default", () => { const shareSpawnedEnv = (input: { readonly ambientBundledDev: string | undefined }) => Effect.gen(function* () { let captured: Record | undefined; @@ -1095,6 +1102,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { mode: "dev", port: undefined, share: true, + shareVia: "tailscale", }).pipe( Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), Effect.provideService(HostProcessPlatform, "linux"), @@ -1153,7 +1161,54 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { ); }); - it.effect("spawns nothing when --dry-run is combined with --share", () => { + it.effect("shares through connect with stable account auth and isolated runtime state", () => { + const commands: string[] = []; + let captured: NodeJS.ProcessEnv | undefined; + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const spawned = command as { + readonly command: string; + readonly options?: { readonly env?: NodeJS.ProcessEnv }; + }; + commands.push(spawned.command); + captured = spawned.options?.env; + return Effect.succeed(mockProcess(0)); + }), + ); + + return Effect.gen(function* () { + for (const shareVia of [undefined, "connect"] as const) { + yield* runDevRunnerWithInput({ + ...devServerInput, + mode: "dev", + share: true, + ...(shareVia ? { shareVia } : {}), + }).pipe( + Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessEnvironment, { + T3CODE_HOME: "/another/runtime", + T3CODE_CONNECT_AUTHORIZATION_HOME: "/another/account", + T3CODE_BUNDLED_DEV: "0", + }), + ); + assert.equal(captured?.T3CODE_HOME, NodePath.resolve(devServerInput.t3Home)); + assert.equal( + captured?.T3CODE_CONNECT_AUTHORIZATION_HOME, + NodePath.join(NodeOS.homedir(), ".t3"), + ); + assert.equal(captured?.T3CODE_DEV_SHARE, "connect"); + assert.equal(captured?.T3CODE_BUNDLED_DEV, "1"); + assert.equal(captured?.T3CODE_SINGLE_ORIGIN_DEV, "1"); + assert.equal(captured?.VITE_HTTP_URL, undefined); + assert.equal(captured?.VITE_WS_URL, undefined); + } + assert.deepStrictEqual(commands, ["vp", "vp"]); + }); + }); + + it.effect("rejects unsupported connect configurations before spawning", () => { let spawnCount = 0; const spawnerLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, @@ -1164,16 +1219,64 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { ); return Effect.gen(function* () { - yield* runDevRunnerWithInput({ - ...devServerInput, - mode: "dev", - port: undefined, - dryRun: true, - share: true, - }).pipe( - Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), - Effect.provideService(HostProcessPlatform, "linux"), - ); + for (const invalid of [ + { mode: "dev:server" }, + { mode: "dev:web" }, + { mode: "dev:desktop" }, + { mode: "dev", devUrl: new URL("https://preview.example.com") }, + { mode: "dev", host: "0.0.0.0" }, + { mode: "dev", host: "::" }, + { mode: "dev", host: "::1" }, + ] as const) { + const error = yield* runDevRunnerWithInput({ + ...devServerInput, + ...invalid, + share: true, + }).pipe( + Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.flip, + ); + assert.equal(error._tag, "DevRunnerConnectShareUnsupportedError"); + if (error._tag === "DevRunnerConnectShareUnsupportedError") { + assert.equal(error.mode, invalid.mode); + assert.equal( + error.reason, + invalid.mode !== "dev" ? "mode" : "devUrl" in invalid ? "dev-url" : "host", + ); + assert.include(error.message, "dev mode"); + assert.include(error.message, "--dev-url"); + assert.include(error.message, "loopback host"); + } + } + assert.equal(spawnCount, 0); + }); + }); + + it.effect("spawns nothing when --dry-run is combined with either sharing transport", () => { + let spawnCount = 0; + const spawnerLayer = Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => { + spawnCount += 1; + return Effect.succeed(mockProcess(0)); + }), + ); + + return Effect.gen(function* () { + for (const shareVia of [undefined, "connect", "tailscale"] as const) { + yield* runDevRunnerWithInput({ + ...devServerInput, + mode: "dev", + port: undefined, + dryRun: true, + share: true, + ...(shareVia ? { shareVia } : {}), + }).pipe( + Effect.provide(Layer.mergeAll(emptyConfigLayer, netServiceLayer, spawnerLayer)), + Effect.provideService(HostProcessPlatform, "linux"), + ); + } assert.equal(spawnCount, 0); }); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index f026e2507e59..1282e025b75f 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -118,6 +118,15 @@ export class DevRunnerConfigurationError extends Schema.TaggedError()( + "DevRunnerConnectShareUnsupportedError", + { mode: Schema.String, reason: Schema.Literals(["mode", "dev-url", "host"]) }, +) { + override get message(): string { + return "T3 Connect sharing requires dev mode, no --dev-url override, and a loopback host (localhost or 127.0.0.1)."; + } +} + export class DevRunnerInvalidPortOffsetError extends Schema.TaggedError()( "DevRunnerInvalidPortOffsetError", { @@ -336,6 +345,8 @@ export function createDevRunnerEnv({ // (serviceLauncherClient.ts resolveStartup). delete output.T3_SERVICE_LAUNCHER_CONTEXT; delete output.T3_BOOT_SERVICE_UNIT; + delete output.T3CODE_DEV_SHARE; + delete output.T3CODE_CONNECT_AUTHORIZATION_HOME; if (!isDesktopMode) { output.T3CODE_PORT = String(serverPort); @@ -617,11 +628,13 @@ interface DevRunnerCliInput { readonly devUrl: URL | undefined; readonly dryRun: boolean; readonly share: boolean; + readonly shareVia?: "connect" | "tailscale"; readonly runArgs: ReadonlyArray; } export function runDevRunnerWithInput(input: DevRunnerCliInput) { return Effect.gen(function* () { + const host = input.host?.trim() || undefined; const { portOffset, devInstance } = yield* OffsetConfig.pipe( Effect.mapError( (cause) => @@ -639,10 +652,10 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { // front instead. (dev:server and dev:desktop don't proxy — untouched.) if ( (input.mode === "dev" || input.mode === "dev:web") && - input.host !== undefined && - !isProxiableBindHost(input.host) + host !== undefined && + !isProxiableBindHost(host) ) { - return yield* new DevRunnerHostNotProxiableError({ mode: input.mode, host: input.host }); + return yield* new DevRunnerHostNotProxiableError({ mode: input.mode, host }); } const worktreePath = yield* resolveGitWorktreePath(yield* HostProcessWorkingDirectory); @@ -660,7 +673,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { hasExplicitDevUrl: input.devUrl !== undefined, // A non-loopback bind host decides whether the backend can actually take // the port, so it has to be probed alongside loopback. - checkPortAvailability: makeDefaultCheckPortAvailability(input.host), + checkPortAvailability: makeDefaultCheckPortAvailability(host), }); const hostEnvironment = yield* HostProcessEnvironment; @@ -684,7 +697,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { browser: input.browser, autoBootstrapProjectFromCwd: input.autoBootstrapProjectFromCwd, logWebSocketEvents: input.logWebSocketEvents, - host: input.host, + host, port: input.port, devUrl: input.devUrl, }); @@ -694,6 +707,26 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { ? ` selectedOffset(server=${serverOffset},web=${webOffset})` : ""; const baseDir = env.T3CODE_HOME ?? (yield* DEFAULT_T3_HOME); + const connectShare = input.share && input.shareVia !== "tailscale"; + if ( + connectShare && + (input.mode !== "dev" || + input.devUrl !== undefined || + (host !== undefined && !["localhost", "127.0.0.1"].includes(host))) + ) { + return yield* new DevRunnerConnectShareUnsupportedError({ + mode: input.mode, + reason: input.mode !== "dev" ? "mode" : input.devUrl !== undefined ? "dev-url" : "host", + }); + } + if (connectShare) { + env.T3CODE_DEV_SHARE = "connect"; + env.T3CODE_CONNECT_AUTHORIZATION_HOME = yield* DEFAULT_T3_HOME; + env.T3CODE_BUNDLED_DEV = "1"; + yield* Effect.logInfo( + "[dev-runner] sharing through T3 Connect using this worktree's saved environment", + ); + } yield* Effect.logInfo( `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} baseDir=${baseDir}`, @@ -707,7 +740,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { } const sharedWebPort = BASE_WEB_PORT + webOffset; - if (input.share) { + if (input.share && !connectShare) { if (input.mode === "dev:server") { yield* Effect.logInfo("[dev-runner] --share has no effect for dev:server (no web server)."); } else if (input.mode === "dev:desktop") { @@ -893,10 +926,14 @@ const devRunnerCli = Command.make("dev-runner", { ), share: Flag.Boolean("share").pipe( Flag.withDescription( - "Publish the web dev server on this machine's tailnet over HTTPS (via `tailscale serve`) and print the pairing URL for it. Removed again on exit.", + "Share through T3 Connect and print a pairing URL. Reuses this worktree's saved environment; run `t3 connect login` once first.", ), Flag.withDefault(false), ), + shareVia: Flag.Literals("share-via", ["connect", "tailscale"]).pipe( + Flag.withDescription("Transport for --share (defaults to T3 Connect)."), + Flag.withDefault("connect"), + ), runArgs: Argument.String("run-arg").pipe( Argument.withDescription("Additional Vite+ run args (pass after `--`)."), Argument.variadic(),