diff --git a/REMOTE.md b/REMOTE.md index 5eed2f803e2cb2d62f6210a3a638ff48bb731882..9dc15ed1fe973912c4a73c0fdfd277d282f61e02 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 062c79fa69f6c55b517a6a28d8732df8ef4c7f71..0bb3d20c4d3d47a1caa59bb628ef3fe2d3000fd7 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 1e1bb3bd8ebe2ec85a49e01c08e2591261d0c411..2fb7e3a1db9905366ddca2ff18a09c3f6b43094b 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 0000000000000000000000000000000000000000..804f2440a9186575dd72c7da21a619b0fecefeb1 --- /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 0000000000000000000000000000000000000000..b837ac6c18496cdb636caf3f221380130f82d248 --- /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 b1e5da0c875ec5e0d41ff014cd68f803f54e0cfc..369a66c08807aae76e969ad5c871e830870ea3ab 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 17bf7f32f7d366edc43b74d59277ac3a79ea467d..5b212528846e3bede0adc97af94cceec43316001 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 bootstrapFd = Option.getOrUndefined(input.bootstrapFd) ?? env.bootstrapFd; + const bootstrapEnvelope = + bootstrapFd !== undefined + ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd) + : Option.none(); - 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 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 e76d1871884152486005be481c9d664f817e7e6c..d3e19e55c23894df84ef110832478e8cb2d4935e 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 da2b3ebe54b3cb67505a71c2e037503ca2d72742..852f21ad01b156983d6985c532026b6bb5dd81ad 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; }); }