diff --git a/REMOTE.md b/REMOTE.md index 5eed2f803e..9dc15ed1fe 100644 --- a/REMOTE.md +++ b/REMOTE.md @@ -6,21 +6,24 @@ Use this when you want to open T3 Code from another device (phone, tablet, anoth The T3 Code CLI accepts the following configuration options, available either as CLI flags or environment variables: -| CLI flag | Env var | Notes | -| ----------------------- | --------------------- | ---------------------------------- | -| `--mode ` | `T3CODE_MODE` | Runtime mode. | -| `--port ` | `T3CODE_PORT` | HTTP/WebSocket port. | -| `--host
` | `T3CODE_HOST` | Bind interface/address. | -| `--base-dir ` | `T3CODE_HOME` | Base directory. | -| `--dev-url ` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. | -| `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. | -| `--auth-token ` | `T3CODE_AUTH_TOKEN` | WebSocket auth token. | +| CLI flag | Env var | Notes | +| ----------------------- | --------------------- | ------------------------------------------------------------------------------------ | +| `--mode ` | `T3CODE_MODE` | Runtime mode. | +| `--port ` | `T3CODE_PORT` | HTTP/WebSocket port. | +| `--host
` | `T3CODE_HOST` | Bind interface/address. | +| `--base-dir ` | `T3CODE_HOME` | Base directory. | +| `--dev-url ` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. | +| `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. | +| `--auth-token ` | `T3CODE_AUTH_TOKEN` | WebSocket auth token. Use this for standard CLI and remote-server flows. | +| `--bootstrap-fd ` | `T3CODE_BOOTSTRAP_FD` | Read a one-shot bootstrap envelope from an inherited file descriptor during startup. | > TIP: Use the `--help` flag to see all available options and their descriptions. ## Security First - Always set `--auth-token` before exposing the server outside localhost. + - When you control the process launcher, prefer sending the auth token in a JSON envelope via `--bootstrap-fd `. + With `--bootstrap-fd `, the launcher starts the server first, then sends a one-shot JSON envelope over the inherited file descriptor. This allows the auth token to be delivered without putting it in process environment or command line arguments. - Treat the token like a password. - Prefer binding to trusted interfaces (LAN IP or Tailnet IP) instead of opening all interfaces unless needed. diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 062c79fa69..0bb3d20c4d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -56,6 +56,7 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state"; const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; +const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); const STATE_DIR = Path.join(BASE_DIR, "userdata"); const DESKTOP_SCHEME = "t3"; @@ -113,6 +114,17 @@ function sanitizeLogValue(value: string): string { return value.replace(/\s+/g, " ").trim(); } +function backendChildEnv(): NodeJS.ProcessEnv { + const env = { ...process.env }; + delete env.T3CODE_PORT; + delete env.T3CODE_AUTH_TOKEN; + delete env.T3CODE_MODE; + delete env.T3CODE_NO_BROWSER; + delete env.T3CODE_HOST; + delete env.T3CODE_DESKTOP_WS_URL; + return env; +} + function writeDesktopLogHeader(message: string): void { if (!desktopLogSink) return; desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`); @@ -918,17 +930,6 @@ function configureAutoUpdater(): void { }, AUTO_UPDATE_POLL_INTERVAL_MS); updatePollTimer.unref(); } -function backendEnv(): NodeJS.ProcessEnv { - return { - ...process.env, - T3CODE_MODE: "desktop", - T3CODE_NO_BROWSER: "1", - T3CODE_PORT: String(backendPort), - T3CODE_HOME: BASE_DIR, - T3CODE_AUTH_TOKEN: backendAuthToken, - }; -} - function scheduleBackendRestart(reason: string): void { if (isQuitting || restartTimer) return; @@ -952,16 +953,35 @@ function startBackend(): void { } const captureBackendLogs = app.isPackaged && backendLogSink !== null; - const child = ChildProcess.spawn(process.execPath, [backendEntry], { + const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], { cwd: resolveBackendCwd(), // In Electron main, process.execPath points to the Electron binary. // Run the child in Node mode so this backend process does not become a GUI app instance. env: { - ...backendEnv(), + ...backendChildEnv(), ELECTRON_RUN_AS_NODE: "1", }, - stdio: captureBackendLogs ? ["ignore", "pipe", "pipe"] : "inherit", + stdio: captureBackendLogs + ? ["ignore", "pipe", "pipe", "pipe"] + : ["ignore", "inherit", "inherit", "pipe"], }); + const bootstrapStream = child.stdio[3]; + if (bootstrapStream && "write" in bootstrapStream) { + bootstrapStream.write( + `${JSON.stringify({ + mode: "desktop", + noBrowser: true, + port: backendPort, + t3Home: BASE_DIR, + authToken: backendAuthToken, + })}\n`, + ); + bootstrapStream.end(); + } else { + child.kill("SIGTERM"); + scheduleBackendRestart("missing desktop bootstrap pipe"); + return; + } backendProcess = child; let backendSessionClosed = false; const closeBackendSession = (details: string) => { @@ -1072,6 +1092,11 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise { } function registerIpcHandlers(): void { + ipcMain.removeAllListeners(GET_WS_URL_CHANNEL); + ipcMain.on(GET_WS_URL_CHANNEL, (event) => { + event.returnValue = backendWsUrl; + }); + ipcMain.removeHandler(PICK_FOLDER_CHANNEL); ipcMain.handle(PICK_FOLDER_CHANNEL, async () => { const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; @@ -1320,9 +1345,9 @@ async function bootstrap(): Promise { ); writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`); backendAuthToken = Crypto.randomBytes(24).toString("hex"); - backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`; - process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl; - writeDesktopLogHeader(`bootstrap resolved websocket url=${backendWsUrl}`); + const baseUrl = `ws://127.0.0.1:${backendPort}`; + backendWsUrl = `${baseUrl}/?token=${encodeURIComponent(backendAuthToken)}`; + writeDesktopLogHeader(`bootstrap resolved websocket endpoint baseUrl=${baseUrl}`); registerIpcHandlers(); writeDesktopLogHeader("bootstrap ipc handlers registered"); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 1e1bb3bd8e..2fb7e3a1db 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -11,10 +11,13 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state"; const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; -const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; +const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; contextBridge.exposeInMainWorld("desktopBridge", { - getWsUrl: () => wsUrl, + getWsUrl: () => { + const result = ipcRenderer.sendSync(GET_WS_URL_CHANNEL); + return typeof result === "string" ? result : null; + }, pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), diff --git a/apps/server/src/bootstrap.test.ts b/apps/server/src/bootstrap.test.ts new file mode 100644 index 0000000000..804f2440a9 --- /dev/null +++ b/apps/server/src/bootstrap.test.ts @@ -0,0 +1,96 @@ +import * as NFS from "node:fs"; +import * as path from "node:path"; +import { execFileSync, spawn } from "node:child_process"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { FileSystem, Schema } from "effect"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import { TestClock } from "effect/testing"; + +import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap"; +import { assertNone, assertSome } from "@effect/vitest/utils"; + +const TestEnvelopeSchema = Schema.Struct({ mode: Schema.String }); + +it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { + it.effect("uses platform-specific fd paths", () => + Effect.sync(() => { + assert.equal(resolveFdPath(3, "linux"), "/proc/self/fd/3"); + assert.equal(resolveFdPath(3, "darwin"), "/dev/fd/3"); + assert.equal(resolveFdPath(3, "win32"), undefined); + }), + ); + + it.effect("reads a bootstrap envelope from a provided fd", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); + + yield* fs.writeFileString( + filePath, + `${yield* Schema.encodeEffect(Schema.fromJsonString(TestEnvelopeSchema))({ + mode: "desktop", + })}\n`, + ); + + const fd = yield* Effect.acquireRelease( + Effect.sync(() => NFS.openSync(filePath, "r")), + (fd) => Effect.sync(() => NFS.closeSync(fd)), + ); + + const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); + assertSome(payload, { + mode: "desktop", + }); + }), + ); + + it.effect("returns none when the fd is unavailable", () => + Effect.gen(function* () { + const fd = NFS.openSync("/dev/null", "r"); + NFS.closeSync(fd); + + const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); + assertNone(payload); + }), + ); + + it.effect("returns none when the bootstrap read times out before any value arrives", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bootstrap-" }); + const fifoPath = path.join(tempDir, "bootstrap.pipe"); + + yield* Effect.sync(() => execFileSync("mkfifo", [fifoPath])); + + const _writer = yield* Effect.acquireRelease( + Effect.sync(() => + spawn("sh", ["-c", 'exec 3>"$1"; sleep 60', "sh", fifoPath], { + stdio: ["ignore", "ignore", "ignore"], + }), + ), + (writer) => + Effect.sync(() => { + writer.kill("SIGKILL"); + }), + ); + + const fd = yield* Effect.acquireRelease( + Effect.sync(() => NFS.openSync(fifoPath, "r")), + (fd) => Effect.sync(() => NFS.closeSync(fd)), + ); + + const fiber = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { + timeoutMs: 100, + }).pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(100)); + + const payload = yield* Fiber.join(fiber); + assertNone(payload); + }).pipe(Effect.provide(TestClock.layer())), + ); +}); diff --git a/apps/server/src/bootstrap.ts b/apps/server/src/bootstrap.ts new file mode 100644 index 0000000000..b837ac6c18 --- /dev/null +++ b/apps/server/src/bootstrap.ts @@ -0,0 +1,145 @@ +import * as NFS from "node:fs"; +import * as Net from "node:net"; +import * as readline from "node:readline"; +import type { Readable } from "node:stream"; + +import { Data, Effect, Option, Predicate, Result, Schema } from "effect"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; + +class BootstrapError extends Data.TaggedError("BootstrapError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function* ( + schema: Schema.Codec, + fd: number, + options?: { + timeoutMs?: number; + }, +): Effect.fn.Return, BootstrapError> { + const fdReady = yield* isFdReady(fd); + if (!fdReady) return Option.none(); + + const stream = yield* makeBootstrapInputStream(fd); + + const timeoutMs = options?.timeoutMs ?? 1000; + + return yield* Effect.callback, BootstrapError>((resume) => { + const input = readline.createInterface({ + input: stream, + crlfDelay: Infinity, + }); + + const cleanup = () => { + stream.removeListener("error", handleError); + input.removeListener("line", handleLine); + input.removeListener("close", handleClose); + input.close(); + stream.destroy(); + }; + + const handleError = (error: Error) => { + if (isUnavailableBootstrapFdError(error)) { + resume(Effect.succeedNone); + return; + } + resume( + Effect.fail( + new BootstrapError({ + message: "Failed to read bootstrap envelope.", + cause: error, + }), + ), + ); + }; + + const handleLine = (line: string) => { + const parsed = decodeJsonResult(schema)(line); + if (Result.isSuccess(parsed)) { + resume(Effect.succeedSome(parsed.success)); + } else { + resume( + Effect.fail( + new BootstrapError({ + message: "Failed to decode bootstrap envelope.", + cause: parsed.failure, + }), + ), + ); + } + }; + + const handleClose = () => { + resume(Effect.succeedNone); + }; + + stream.once("error", handleError); + input.once("line", handleLine); + input.once("close", handleClose); + + return Effect.sync(cleanup); + }).pipe(Effect.timeoutOption(timeoutMs), Effect.map(Option.flatten)); +}); + +const isUnavailableBootstrapFdError = Predicate.compose( + Predicate.hasProperty("code"), + (_) => _.code === "EBADF" || _.code === "ENOENT", +); + +const isFdReady = (fd: number) => + Effect.try({ + try: () => NFS.fstatSync(fd), + catch: (error) => + new BootstrapError({ + message: "Failed to stat bootstrap fd.", + cause: error, + }), + }).pipe( + Effect.as(true), + Effect.catchIf( + (error) => isUnavailableBootstrapFdError(error.cause), + () => Effect.succeed(false), + ), + ); + +const makeBootstrapInputStream = (fd: number) => + Effect.try({ + try: () => { + const fdPath = resolveFdPath(fd); + if (fdPath === undefined) { + const stream = new Net.Socket({ + fd, + readable: true, + writable: false, + }); + stream.setEncoding("utf8"); + return stream; + } + + const streamFd = NFS.openSync(fdPath, "r"); + return NFS.createReadStream("", { + fd: streamFd, + encoding: "utf8", + autoClose: true, + }); + }, + catch: (error) => + new BootstrapError({ + message: "Failed to duplicate bootstrap fd.", + cause: error, + }), + }); + +export function resolveFdPath( + fd: number, + platform: NodeJS.Platform = process.platform, +): string | undefined { + if (platform === "linux") { + return `/proc/self/fd/${fd}`; + } + if (platform === "win32") { + return undefined; + } + return `/dev/fd/${fd}`; +} diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index b1e5da0c87..369a66c088 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -4,6 +4,7 @@ import { assert, it, vi } from "@effect/vitest"; import type { OrchestrationReadModel } from "@t3tools/contracts"; import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Command from "effect/unstable/cli/Command"; import { FetchHttpClient } from "effect/unstable/http"; @@ -151,6 +152,99 @@ it.layer(testLayer)("server CLI command", (it) => { }), ); + const openBootstrapFd = Effect.fn(function* (payload: Record) { + const fs = yield* FileSystem.FileSystem; + const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); + yield* fs.writeFileString(filePath, `${JSON.stringify(payload)}\n`); + const { fd } = yield* fs.open(filePath, { flag: "r" }); + return fd; + }); + + it.effect("recognizes bootstrap fd from environment config", () => + Effect.gen(function* () { + const fd = yield* openBootstrapFd({ authToken: "bootstrap-token" }); + + yield* runCli([], { + T3CODE_MODE: "web", + T3CODE_BOOTSTRAP_FD: String(fd), + T3CODE_AUTH_TOKEN: "env-token", + T3CODE_NO_BROWSER: "true", + }); + + assert.equal(start.mock.calls.length, 1); + assert.equal(resolvedConfig?.mode, "web"); + assert.equal(resolvedConfig?.authToken, "env-token"); + }), + ); + + it.effect("uses bootstrap envelope values as fallbacks when CLI and env are absent", () => + Effect.gen(function* () { + const fd = yield* openBootstrapFd({ + mode: "desktop", + port: 4888, + host: "127.0.0.2", + t3Home: "/tmp/t3-bootstrap-home", + devUrl: "http://127.0.0.1:5173", + noBrowser: true, + authToken: "bootstrap-token", + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: true, + }); + + yield* runCli([], { + T3CODE_BOOTSTRAP_FD: String(fd), + }); + + assert.equal(start.mock.calls.length, 1); + assert.equal(resolvedConfig?.mode, "desktop"); + assert.equal(resolvedConfig?.port, 4888); + assert.equal(resolvedConfig?.host, "127.0.0.2"); + assert.equal(resolvedConfig?.baseDir, "/tmp/t3-bootstrap-home"); + assert.equal(resolvedConfig?.stateDir, "/tmp/t3-bootstrap-home/dev"); + assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/"); + assert.equal(resolvedConfig?.noBrowser, true); + assert.equal(resolvedConfig?.authToken, "bootstrap-token"); + assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false); + assert.equal(resolvedConfig?.logWebSocketEvents, true); + }), + ); + + it.effect("applies CLI then env precedence over bootstrap envelope values", () => + Effect.gen(function* () { + const fd = yield* openBootstrapFd({ + mode: "desktop", + port: 4888, + host: "127.0.0.2", + t3Home: "/tmp/t3-bootstrap-home", + devUrl: "http://127.0.0.1:5173", + noBrowser: false, + authToken: "bootstrap-token", + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + }); + + yield* runCli(["--port", "4999", "--host", "0.0.0.0", "--auth-token", "cli-token"], { + T3CODE_MODE: "web", + T3CODE_BOOTSTRAP_FD: String(fd), + T3CODE_HOME: "/tmp/t3-env-home", + T3CODE_NO_BROWSER: "true", + T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true", + T3CODE_LOG_WS_EVENTS: "true", + }); + + assert.equal(start.mock.calls.length, 1); + assert.equal(resolvedConfig?.mode, "web"); + assert.equal(resolvedConfig?.port, 4999); + assert.equal(resolvedConfig?.host, "0.0.0.0"); + assert.equal(resolvedConfig?.baseDir, "/tmp/t3-env-home"); + assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/"); + assert.equal(resolvedConfig?.noBrowser, true); + assert.equal(resolvedConfig?.authToken, "cli-token"); + assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, true); + assert.equal(resolvedConfig?.logWebSocketEvents, true); + }), + ); + it.effect("prefers --mode over T3CODE_MODE", () => Effect.gen(function* () { findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(4666)); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 17bf7f32f7..5b21252884 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -27,12 +27,27 @@ import { Server } from "./wsServer"; import { ServerLoggerLive } from "./serverLogger"; import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; +import { readBootstrapEnvelope } from "./bootstrap"; export class StartupError extends Data.TaggedError("StartupError")<{ readonly message: string; readonly cause?: unknown; }> {} +const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); + +const BootstrapEnvelopeSchema = Schema.Struct({ + mode: Schema.optional(Schema.String), + port: Schema.optional(PortSchema), + host: Schema.optional(Schema.String), + t3Home: Schema.optional(Schema.String), + devUrl: Schema.optional(Schema.URLFromString), + noBrowser: Schema.optional(Schema.Boolean), + authToken: Schema.optional(Schema.String), + autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), + logWebSocketEvents: Schema.optional(Schema.Boolean), +}); + interface CliInput { readonly mode: Option.Option; readonly port: Option.Option; @@ -41,6 +56,7 @@ interface CliInput { readonly devUrl: Option.Option; readonly noBrowser: Option.Option; readonly authToken: Option.Option; + readonly bootstrapFd: Option.Option; readonly autoBootstrapProjectFromCwd: Option.Option; readonly logWebSocketEvents: Option.Option; } @@ -91,12 +107,8 @@ export class CliConfig extends ServiceMap.Service()( const CliEnvConfig = Config.all({ mode: Config.string("T3CODE_MODE").pipe( Config.option, - Config.map( - Option.match({ - onNone: () => "web", - onSome: (value) => (value === "desktop" ? "desktop" : "web"), - }), - ), + Config.map(Option.map((value) => (value === "desktop" ? "desktop" : "web"))), + Config.map(Option.getOrUndefined), ), port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), @@ -110,6 +122,10 @@ const CliEnvConfig = Config.all({ Config.option, Config.map(Option.getOrUndefined), ), + bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -123,6 +139,14 @@ const CliEnvConfig = Config.all({ const resolveBooleanFlag = (flag: Option.Option, envValue: boolean) => Option.getOrElse(Option.filter(flag, Boolean), () => envValue); +const resolveOptionPrecedence = ( + ...values: ReadonlyArray> +): Option.Option => Option.firstSomeOf(values); + +const isValidPort = (value: number): boolean => value >= 1 && value <= 65_535; +const isRuntimeMode = (value: string): value is RuntimeMode => + value === "web" || value === "desktop"; + const ServerConfigLive = (input: CliInput) => Layer.effect( ServerConfig, @@ -136,39 +160,115 @@ const ServerConfigLive = (input: CliInput) => ), ); - const mode = Option.getOrElse(input.mode, () => env.mode); - - const port = yield* Option.match(input.port, { - onSome: (value) => Effect.succeed(value), - onNone: () => { - if (env.port) { - return Effect.succeed(env.port); - } - if (mode === "desktop") { - return Effect.succeed(DEFAULT_PORT); - } - return findAvailablePort(DEFAULT_PORT); + const bootstrapFd = Option.getOrUndefined(input.bootstrapFd) ?? env.bootstrapFd; + const bootstrapEnvelope = + bootstrapFd !== undefined + ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd) + : Option.none(); + + const mode: RuntimeMode = Option.getOrElse( + resolveOptionPrecedence( + input.mode, + Option.fromUndefinedOr(env.mode), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.filter(Option.fromUndefinedOr(bootstrap.mode), isRuntimeMode), + ), + ), + () => "web", + ); + const port = yield* Option.match( + resolveOptionPrecedence( + input.port, + Option.fromUndefinedOr(env.port), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.filter(Option.fromUndefinedOr(bootstrap.port), isValidPort), + ), + ), + { + onSome: (value) => Effect.succeed(value), + onNone: () => { + if (mode === "desktop") { + return Effect.succeed(DEFAULT_PORT); + } + return findAvailablePort(DEFAULT_PORT); + }, }, - }); + ); - const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl); - const baseDir = yield* resolveBaseDir(Option.getOrUndefined(input.t3Home) ?? env.t3Home); + const devUrl = Option.getOrElse( + resolveOptionPrecedence( + input.devUrl, + Option.fromUndefinedOr(env.devUrl), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.devUrl), + ), + ), + () => undefined, + ); + const baseDir = yield* resolveBaseDir( + Option.getOrUndefined( + resolveOptionPrecedence( + input.t3Home, + Option.fromUndefinedOr(env.t3Home), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.t3Home), + ), + ), + ), + ); const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); - const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop"); - const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; + const noBrowser = resolveBooleanFlag( + input.noBrowser, + Option.getOrElse( + resolveOptionPrecedence( + Option.fromUndefinedOr(env.noBrowser), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.noBrowser), + ), + ), + () => mode === "desktop", + ), + ); + const authToken = resolveOptionPrecedence( + input.authToken, + Option.fromUndefinedOr(env.authToken), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.authToken), + ), + ); const autoBootstrapProjectFromCwd = resolveBooleanFlag( input.autoBootstrapProjectFromCwd, - env.autoBootstrapProjectFromCwd ?? mode === "web", + Option.getOrElse( + resolveOptionPrecedence( + Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.autoBootstrapProjectFromCwd), + ), + ), + () => mode === "web", + ), ); const logWebSocketEvents = resolveBooleanFlag( input.logWebSocketEvents, - env.logWebSocketEvents ?? Boolean(devUrl), + Option.getOrElse( + resolveOptionPrecedence( + Option.fromUndefinedOr(env.logWebSocketEvents), + Option.flatMap(bootstrapEnvelope, (bootstrap) => + Option.fromUndefinedOr(bootstrap.logWebSocketEvents), + ), + ), + () => Boolean(devUrl), + ), ); const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; - const host = - Option.getOrUndefined(input.host) ?? - env.host ?? - (mode === "desktop" ? "127.0.0.1" : undefined); + const host = Option.getOrElse( + resolveOptionPrecedence( + input.host, + Option.fromUndefinedOr(env.host), + Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.host)), + ), + () => (mode === "desktop" ? "127.0.0.1" : undefined), + ); const config: ServerConfigShape = { mode, @@ -180,7 +280,7 @@ const ServerConfigLive = (input: CliInput) => staticDir, devUrl, noBrowser, - authToken, + authToken: Option.getOrUndefined(authToken), autoBootstrapProjectFromCwd, logWebSocketEvents, } satisfies ServerConfigShape; @@ -287,7 +387,7 @@ const modeFlag = Flag.choice("mode", ["web", "desktop"]).pipe( Flag.optional, ); const portFlag = Flag.integer("port").pipe( - Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), + Flag.withSchema(PortSchema), Flag.withDescription("Port for the HTTP/WebSocket server."), Flag.optional, ); @@ -313,6 +413,11 @@ const authTokenFlag = Flag.string("auth-token").pipe( Flag.withAlias("token"), Flag.optional, ); +const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe( + Flag.withSchema(Schema.Int), + Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."), + Flag.optional, +); const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe( Flag.withDescription( "Create a project for the current working directory on startup when missing.", @@ -335,6 +440,7 @@ export const t3Cli = Command.make("t3", { devUrl: devUrlFlag, noBrowser: noBrowserFlag, authToken: authTokenFlag, + bootstrapFd: bootstrapFdFlag, autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, logWebSocketEvents: logWebSocketEventsFlag, }).pipe( diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index e76d187188..d3e19e55c2 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -160,6 +160,43 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { assert.equal(env.T3CODE_HOME, resolve("/tmp/my-t3")); }), ); + + it.effect("does not export backend bootstrap env for dev:desktop", () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode: "dev:desktop", + baseEnv: { + T3CODE_PORT: "3773", + T3CODE_AUTH_TOKEN: "stale-token", + T3CODE_MODE: "web", + T3CODE_NO_BROWSER: "0", + T3CODE_HOST: "0.0.0.0", + VITE_WS_URL: "ws://localhost:3773", + }, + serverOffset: 0, + webOffset: 0, + t3Home: "/tmp/my-t3", + authToken: "fresh-token", + noBrowser: true, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: "127.0.0.1", + port: 4222, + devUrl: undefined, + }); + + assert.equal(env.T3CODE_HOME, resolve("/tmp/my-t3")); + assert.equal(env.PORT, "5733"); + assert.equal(env.ELECTRON_RENDERER_PORT, "5733"); + assert.equal(env.VITE_DEV_SERVER_URL, "http://localhost:5733"); + assert.equal(env.T3CODE_PORT, undefined); + assert.equal(env.T3CODE_AUTH_TOKEN, undefined); + assert.equal(env.T3CODE_MODE, undefined); + assert.equal(env.T3CODE_NO_BROWSER, undefined); + assert.equal(env.T3CODE_HOST, undefined); + assert.equal(env.VITE_WS_URL, undefined); + }), + ); }); describe("findFirstAvailableOffset", () => { diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index da2b3ebe54..852f21ad01 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -147,30 +147,41 @@ export function createDevRunnerEnv({ const serverPort = port ?? BASE_SERVER_PORT + serverOffset; const webPort = BASE_WEB_PORT + webOffset; const resolvedBaseDir = yield* resolveBaseDir(t3Home); + const isDesktopMode = mode === "dev:desktop"; const output: NodeJS.ProcessEnv = { ...baseEnv, - T3CODE_PORT: String(serverPort), PORT: String(webPort), ELECTRON_RENDERER_PORT: String(webPort), - VITE_WS_URL: `ws://localhost:${serverPort}`, VITE_DEV_SERVER_URL: devUrl?.toString() ?? `http://localhost:${webPort}`, T3CODE_HOME: resolvedBaseDir, }; - if (host !== undefined) { + if (!isDesktopMode) { + output.T3CODE_PORT = String(serverPort); + output.VITE_WS_URL = `ws://localhost:${serverPort}`; + } else { + delete output.T3CODE_PORT; + delete output.VITE_WS_URL; + delete output.T3CODE_AUTH_TOKEN; + delete output.T3CODE_MODE; + delete output.T3CODE_NO_BROWSER; + delete output.T3CODE_HOST; + } + + if (!isDesktopMode && host !== undefined) { output.T3CODE_HOST = host; } - if (authToken !== undefined) { + if (!isDesktopMode && authToken !== undefined) { output.T3CODE_AUTH_TOKEN = authToken; - } else { + } else if (!isDesktopMode) { delete output.T3CODE_AUTH_TOKEN; } - if (noBrowser !== undefined) { + if (!isDesktopMode && noBrowser !== undefined) { output.T3CODE_NO_BROWSER = noBrowser ? "1" : "0"; - } else { + } else if (!isDesktopMode) { delete output.T3CODE_NO_BROWSER; } @@ -196,6 +207,10 @@ export function createDevRunnerEnv({ delete output.T3CODE_DESKTOP_WS_URL; } + if (isDesktopMode) { + delete output.T3CODE_DESKTOP_WS_URL; + } + return output; }); }