- Pass one-shot startup config over inherited fd instead of env - Wire server bootstrap parsing and desktop IPC for websocket URL - Add tests for bootstrap envelope precedence and timeout handling
Unified · Split
| @@ -6,21 +6,24 @@ Use this when you want to open T3 Code from another device (phone, tablet, anoth · 5 unmodified lines | ||
| 6 | 6 | |
| 7 | 7 | The T3 Code CLI accepts the following configuration options, available either as CLI flags or environment variables: |
| 8 | 8 | |
| 9 | −| CLI flag | Env var | Notes | | |
| 10 | −| ----------------------- | --------------------- | ---------------------------------- | | |
| 11 | −| `--mode <web\|desktop>` | `T3CODE_MODE` | Runtime mode. | | |
| 12 | −| `--port <number>` | `T3CODE_PORT` | HTTP/WebSocket port. | | |
| 13 | −| `--host <address>` | `T3CODE_HOST` | Bind interface/address. | | |
| 14 | −| `--base-dir <path>` | `T3CODE_HOME` | Base directory. | | |
| 15 | −| `--dev-url <url>` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. | | |
| 16 | −| `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. | | |
| 17 | −| `--auth-token <token>` | `T3CODE_AUTH_TOKEN` | WebSocket auth token. | | |
| 9 | +| CLI flag | Env var | Notes | | |
| 10 | +| ----------------------- | --------------------- | ------------------------------------------------------------------------------------ | | |
| 11 | +| `--mode <web\|desktop>` | `T3CODE_MODE` | Runtime mode. | | |
| 12 | +| `--port <number>` | `T3CODE_PORT` | HTTP/WebSocket port. | | |
| 13 | +| `--host <address>` | `T3CODE_HOST` | Bind interface/address. | | |
| 14 | +| `--base-dir <path>` | `T3CODE_HOME` | Base directory. | | |
| 15 | +| `--dev-url <url>` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. | | |
| 16 | +| `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. | | |
| 17 | +| `--auth-token <token>` | `T3CODE_AUTH_TOKEN` | WebSocket auth token. Use this for standard CLI and remote-server flows. | | |
| 18 | +| `--bootstrap-fd <fd>` | `T3CODE_BOOTSTRAP_FD` | Read a one-shot bootstrap envelope from an inherited file descriptor during startup. | | |
| 18 | 19 | |
| 19 | 20 | > TIP: Use the `--help` flag to see all available options and their descriptions. |
| 20 | 21 | |
| 21 | 22 | ## Security First |
| 22 | 23 | |
| 23 | 24 | - Always set `--auth-token` before exposing the server outside localhost. |
| 25 | + - When you control the process launcher, prefer sending the auth token in a JSON envelope via `--bootstrap-fd <fd>`. | |
| 26 | + With `--bootstrap-fd <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. | |
| 24 | 27 | - Treat the token like a password. |
| 25 | 28 | - Prefer binding to trusted interfaces (LAN IP or Tailnet IP) instead of opening all interfaces unless needed. |
| 26 | 29 | |
| @@ -56,6 +56,7 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state"; · 55 unmodified lines | ||
| 56 | 56 | const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; |
| 57 | 57 | const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; |
| 58 | 58 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; |
| 59 | +const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; | |
| 59 | 60 | const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); |
| 60 | 61 | const STATE_DIR = Path.join(BASE_DIR, "userdata"); |
| 61 | 62 | const DESKTOP_SCHEME = "t3"; |
| @@ -918,17 +919,6 @@ function configureAutoUpdater(): void { · 856 unmodified lines | ||
| 918 | 919 | }, AUTO_UPDATE_POLL_INTERVAL_MS); |
| 919 | 920 | updatePollTimer.unref(); |
| 920 | 921 | } |
| 921 | −function backendEnv(): NodeJS.ProcessEnv { | |
| 922 | − return { | |
| 923 | − ...process.env, | |
| 924 | − T3CODE_MODE: "desktop", | |
| 925 | − T3CODE_NO_BROWSER: "1", | |
| 926 | − T3CODE_PORT: String(backendPort), | |
| 927 | − T3CODE_HOME: BASE_DIR, | |
| 928 | − T3CODE_AUTH_TOKEN: backendAuthToken, | |
| 929 | − }; | |
| 930 | −} | |
| 931 | − | |
| 932 | 922 | function scheduleBackendRestart(reason: string): void { |
| 933 | 923 | if (isQuitting || restartTimer) return; |
| 934 | 924 | |
| @@ -952,16 +942,35 @@ function startBackend(): void { · 17 unmodified lines | ||
| 952 | 942 | } |
| 953 | 943 | |
| 954 | 944 | const captureBackendLogs = app.isPackaged && backendLogSink !== null; |
| 955 | − const child = ChildProcess.spawn(process.execPath, [backendEntry], { | |
| 945 | + const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], { | |
| 956 | 946 | cwd: resolveBackendCwd(), |
| 957 | 947 | // In Electron main, process.execPath points to the Electron binary. |
| 958 | 948 | // Run the child in Node mode so this backend process does not become a GUI app instance. |
| 959 | 949 | env: { |
| 960 | − ...backendEnv(), | |
| 950 | + ...process.env, | |
| 961 | 951 | ELECTRON_RUN_AS_NODE: "1", |
| 962 | 952 | }, |
| 963 | − stdio: captureBackendLogs ? ["ignore", "pipe", "pipe"] : "inherit", | |
| 953 | + stdio: captureBackendLogs | |
| 954 | + ? ["ignore", "pipe", "pipe", "pipe"] | |
| 955 | + : ["ignore", "inherit", "inherit", "pipe"], | |
| 964 | 956 | }); |
| 957 | + const bootstrapStream = child.stdio[3]; | |
| 958 | + if (bootstrapStream && "write" in bootstrapStream) { | |
| 959 | + bootstrapStream.write( | |
| 960 | + `${JSON.stringify({ | |
| 961 | + mode: "desktop", | |
| 962 | + noBrowser: true, | |
| 963 | + port: backendPort, | |
| 964 | + t3Home: BASE_DIR, | |
| 965 | + authToken: backendAuthToken, | |
| 966 | + })}\n`, | |
| 967 | + ); | |
| 968 | + bootstrapStream.end(); | |
| 969 | + } else { | |
| 970 | + child.kill("SIGTERM"); | |
| 971 | + scheduleBackendRestart("missing desktop bootstrap pipe"); | |
| 972 | + return; | |
| 973 | + } | |
| 965 | 974 | backendProcess = child; |
| 966 | 975 | let backendSessionClosed = false; |
| 967 | 976 | const closeBackendSession = (details: string) => { |
| @@ -1072,6 +1081,11 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> { · 104 unmodified lines | ||
| 1072 | 1081 | } |
| 1073 | 1082 | |
| 1074 | 1083 | function registerIpcHandlers(): void { |
| 1084 | + ipcMain.removeAllListeners(GET_WS_URL_CHANNEL); | |
| 1085 | + ipcMain.on(GET_WS_URL_CHANNEL, (event) => { | |
| 1086 | + event.returnValue = backendWsUrl; | |
| 1087 | + }); | |
| 1088 | + | |
| 1075 | 1089 | ipcMain.removeHandler(PICK_FOLDER_CHANNEL); |
| 1076 | 1090 | ipcMain.handle(PICK_FOLDER_CHANNEL, async () => { |
| 1077 | 1091 | const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; |
| @@ -1320,9 +1334,9 @@ async function bootstrap(): Promise<void> { · 242 unmodified lines | ||
| 1320 | 1334 | ); |
| 1321 | 1335 | writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`); |
| 1322 | 1336 | backendAuthToken = Crypto.randomBytes(24).toString("hex"); |
| 1323 | − backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`; | |
| 1324 | − process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl; | |
| 1325 | − writeDesktopLogHeader(`bootstrap resolved websocket url=${backendWsUrl}`); | |
| 1337 | + const baseUrl = `ws://127.0.0.1:${backendPort}`; | |
| 1338 | + backendWsUrl = `${baseUrl}/?token=${encodeURIComponent(backendAuthToken)}`; | |
| 1339 | + writeDesktopLogHeader(`bootstrap resolved websocket endpoint baseUrl=${baseUrl}`); | |
| 1326 | 1340 | |
| 1327 | 1341 | registerIpcHandlers(); |
| 1328 | 1342 | writeDesktopLogHeader("bootstrap ipc handlers registered"); |
| @@ -11,10 +11,13 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state"; · 10 unmodified lines | ||
| 11 | 11 | const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; |
| 12 | 12 | const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; |
| 13 | 13 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; |
| 14 | −const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; | |
| 14 | +const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; | |
| 15 | 15 | |
| 16 | 16 | contextBridge.exposeInMainWorld("desktopBridge", { |
| 17 | − getWsUrl: () => wsUrl, | |
| 17 | + getWsUrl: () => { | |
| 18 | + const result = ipcRenderer.sendSync(GET_WS_URL_CHANNEL); | |
| 19 | + return typeof result === "string" ? result : null; | |
| 20 | + }, | |
| 18 | 21 | pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), |
| 19 | 22 | confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), |
| 20 | 23 | setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), |
| @@ -0,0 +1,88 @@ | ||
| 1 | +import * as NFS from "node:fs"; | |
| 2 | +import * as path from "node:path"; | |
| 3 | +import { execFileSync, spawn } from "node:child_process"; | |
| 4 | +import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 5 | +import { it } from "@effect/vitest"; | |
| 6 | +import { FileSystem, Schema } from "effect"; | |
| 7 | +import * as Duration from "effect/Duration"; | |
| 8 | +import * as Effect from "effect/Effect"; | |
| 9 | +import * as Fiber from "effect/Fiber"; | |
| 10 | +import { TestClock } from "effect/testing"; | |
| 11 | + | |
| 12 | +import { readBootstrapEnvelope } from "./bootstrap"; | |
| 13 | +import { assertNone, assertSome } from "@effect/vitest/utils"; | |
| 14 | + | |
| 15 | +const TestEnvelopeSchema = Schema.Struct({ mode: Schema.String }); | |
| 16 | + | |
| 17 | +it.layer(NodeServices.layer)("readBootstrapEnvelope", (it) => { | |
| 18 | + it.effect("reads a bootstrap envelope from a provided fd", () => | |
| 19 | + Effect.gen(function* () { | |
| 20 | + const fs = yield* FileSystem.FileSystem; | |
| 21 | + const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); | |
| 22 | + | |
| 23 | + yield* fs.writeFileString( | |
| 24 | + filePath, | |
| 25 | + `${yield* Schema.encodeEffect(Schema.fromJsonString(TestEnvelopeSchema))({ | |
| 26 | + mode: "desktop", | |
| 27 | + })}\n`, | |
| 28 | + ); | |
| 29 | + | |
| 30 | + const fd = yield* Effect.acquireRelease( | |
| 31 | + Effect.sync(() => NFS.openSync(filePath, "r")), | |
| 32 | + (fd) => Effect.sync(() => NFS.closeSync(fd)), | |
| 33 | + ); | |
| 34 | + | |
| 35 | + const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); | |
| 36 | + assertSome(payload, { | |
| 37 | + mode: "desktop", | |
| 38 | + }); | |
| 39 | + }), | |
| 40 | + ); | |
| 41 | + | |
| 42 | + it.effect("returns none when the fd is unavailable", () => | |
| 43 | + Effect.gen(function* () { | |
| 44 | + const fd = NFS.openSync("/dev/null", "r"); | |
| 45 | + NFS.closeSync(fd); | |
| 46 | + | |
| 47 | + const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); | |
| 48 | + assertNone(payload); | |
| 49 | + }), | |
| 50 | + ); | |
| 51 | + | |
| 52 | + it.effect("returns none when the bootstrap read times out before any value arrives", () => | |
| 53 | + Effect.gen(function* () { | |
| 54 | + const fs = yield* FileSystem.FileSystem; | |
| 55 | + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bootstrap-" }); | |
| 56 | + const fifoPath = path.join(tempDir, "bootstrap.pipe"); | |
| 57 | + | |
| 58 | + yield* Effect.sync(() => execFileSync("mkfifo", [fifoPath])); | |
| 59 | + | |
| 60 | + const _writer = yield* Effect.acquireRelease( | |
| 61 | + Effect.sync(() => | |
| 62 | + spawn("sh", ["-c", 'exec 3>"$1"; sleep 60', "sh", fifoPath], { | |
| 63 | + stdio: ["ignore", "ignore", "ignore"], | |
| 64 | + }), | |
| 65 | + ), | |
| 66 | + (writer) => | |
| 67 | + Effect.sync(() => { | |
| 68 | + writer.kill("SIGKILL"); | |
| 69 | + }), | |
| 70 | + ); | |
| 71 | + | |
| 72 | + const fd = yield* Effect.acquireRelease( | |
| 73 | + Effect.sync(() => NFS.openSync(fifoPath, "r")), | |
| 74 | + (fd) => Effect.sync(() => NFS.closeSync(fd)), | |
| 75 | + ); | |
| 76 | + | |
| 77 | + const fiber = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { | |
| 78 | + timeoutMs: 100, | |
| 79 | + }).pipe(Effect.forkScoped); | |
| 80 | + | |
| 81 | + yield* Effect.yieldNow; | |
| 82 | + yield* TestClock.adjust(Duration.millis(100)); | |
| 83 | + | |
| 84 | + const payload = yield* Fiber.join(fiber); | |
| 85 | + assertNone(payload); | |
| 86 | + }).pipe(Effect.provide(TestClock.layer())), | |
| 87 | + ); | |
| 88 | +}); | |
| @@ -0,0 +1,121 @@ | ||
| 1 | +import * as NFS from "node:fs"; | |
| 2 | +import * as readline from "node:readline"; | |
| 3 | + | |
| 4 | +import { Data, Effect, Option, Result, Schema } from "effect"; | |
| 5 | +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | |
| 6 | + | |
| 7 | +class BootstrapError extends Data.TaggedError("BootstrapError")<{ | |
| 8 | + readonly message: string; | |
| 9 | + readonly cause?: unknown; | |
| 10 | +}> {} | |
| 11 | + | |
| 12 | +export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function* < | |
| 13 | + S extends Schema.Codec<unknown, unknown, never, never>, | |
| 14 | +>( | |
| 15 | + schema: S, | |
| 16 | + fd: number, | |
| 17 | + options?: { | |
| 18 | + timeoutMs?: number; | |
| 19 | + }, | |
| 20 | +): Effect.fn.Return<Option.Option<Schema.Schema.Type<S>>, BootstrapError> { | |
| 21 | + const fdReady = yield* Effect.try({ | |
| 22 | + try: () => NFS.fstatSync(fd), | |
| 23 | + catch: (error) => | |
| 24 | + new BootstrapError({ | |
| 25 | + message: "Failed to stat bootstrap fd.", | |
| 26 | + cause: error, | |
| 27 | + }), | |
| 28 | + }).pipe( | |
| 29 | + Effect.as(true), | |
| 30 | + Effect.catchIf( | |
| 31 | + (error) => isUnavailableBootstrapFdError(error.cause), | |
| 32 | + () => Effect.succeed(false), | |
| 33 | + ), | |
| 34 | + ); | |
| 35 | + if (!fdReady) return Option.none(); | |
| 36 | + | |
| 37 | + const streamFd = yield* Effect.try({ | |
| 38 | + try: () => NFS.openSync(resolveFdPath(fd), "r"), | |
| 39 | + catch: (error) => | |
| 40 | + new BootstrapError({ | |
| 41 | + message: "Failed to duplicate bootstrap fd.", | |
| 42 | + cause: error, | |
| 43 | + }), | |
| 44 | + }); | |
| 45 | + | |
| 46 | + const stream = NFS.createReadStream("", { | |
| 47 | + fd: streamFd, | |
| 48 | + encoding: "utf8", | |
| 49 | + autoClose: true, | |
| 50 | + }); | |
| 51 | + | |
| 52 | + const timeoutMs = options?.timeoutMs ?? 1000; | |
| 53 | + | |
| 54 | + return yield* Effect.callback<Option.Option<Schema.Schema.Type<S>>, BootstrapError>((resume) => { | |
| 55 | + const input = readline.createInterface({ | |
| 56 | + input: stream, | |
| 57 | + crlfDelay: Infinity, | |
| 58 | + }); | |
| 59 | + | |
| 60 | + const cleanup = () => { | |
| 61 | + stream.removeListener("error", handleError); | |
| 62 | + input.removeListener("line", handleLine); | |
| 63 | + input.removeListener("close", handleClose); | |
| 64 | + input.close(); | |
| 65 | + }; | |
| 66 | + | |
| 67 | + const handleError = (error: Error) => { | |
| 68 | + if (isUnavailableBootstrapFdError(error)) { | |
| 69 | + resume(Effect.succeed(Option.none<Schema.Schema.Type<S>>())); | |
| 70 | + return; | |
| 71 | + } | |
| 72 | + resume( | |
| 73 | + Effect.fail( | |
| 74 | + new BootstrapError({ | |
| 75 | + message: "Failed to read bootstrap envelope.", | |
| 76 | + cause: error, | |
| 77 | + }), | |
| 78 | + ), | |
| 79 | + ); | |
| 80 | + }; | |
| 81 | + | |
| 82 | + const handleLine = (line: string) => { | |
| 83 | + const parsed = decodeJsonResult(schema)(line); | |
| 84 | + if (Result.isSuccess(parsed)) { | |
| 85 | + resume(Effect.succeed(Option.some(parsed.success as Schema.Schema.Type<S>))); | |
| 86 | + return; | |
| 87 | + } | |
| 88 | + resume( | |
| 89 | + Effect.fail( | |
| 90 | + new BootstrapError({ | |
| 91 | + message: "Failed to decode bootstrap envelope.", | |
| 92 | + cause: parsed.failure, | |
| 93 | + }), | |
| 94 | + ), | |
| 95 | + ); | |
| 96 | + }; | |
| 97 | + | |
| 98 | + const handleClose = () => { | |
| 99 | + resume(Effect.succeed(Option.none<Schema.Schema.Type<S>>())); | |
| 100 | + }; | |
| 101 | + | |
| 102 | + stream.once("error", handleError); | |
| 103 | + input.once("line", handleLine); | |
| 104 | + input.once("close", handleClose); | |
| 105 | + | |
| 106 | + return Effect.sync(cleanup); | |
| 107 | + }).pipe(Effect.timeoutOption(timeoutMs), Effect.map(Option.flatten)); | |
| 108 | +}); | |
| 109 | + | |
| 110 | +function isUnavailableBootstrapFdError(error: unknown): boolean { | |
| 111 | + return ( | |
| 112 | + error instanceof Error && "code" in error && (error.code === "EBADF" || error.code === "ENOENT") | |
| 113 | + ); | |
| 114 | +} | |
| 115 | + | |
| 116 | +function resolveFdPath(fd: number): string { | |
| 117 | + if (process.platform === "linux") { | |
| 118 | + return `/proc/self/fd/${fd}`; | |
| 119 | + } | |
| 120 | + return `/dev/fd/${fd}`; | |
| 121 | +} | |
| @@ -4,6 +4,7 @@ import { assert, it, vi } from "@effect/vitest"; · 3 unmodified lines | ||
| 4 | 4 | import type { OrchestrationReadModel } from "@t3tools/contracts"; |
| 5 | 5 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 6 | 6 | import * as Effect from "effect/Effect"; |
| 7 | +import * as FileSystem from "effect/FileSystem"; | |
| 7 | 8 | import * as Layer from "effect/Layer"; |
| 8 | 9 | import * as Command from "effect/unstable/cli/Command"; |
| 9 | 10 | import { FetchHttpClient } from "effect/unstable/http"; |
| @@ -151,6 +152,99 @@ it.layer(testLayer)("server CLI command", (it) => { · 141 unmodified lines | ||
| 151 | 152 | }), |
| 152 | 153 | ); |
| 153 | 154 | |
| 155 | + const openBootstrapFd = Effect.fn(function* (payload: Record<string, unknown>) { | |
| 156 | + const fs = yield* FileSystem.FileSystem; | |
| 157 | + const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); | |
| 158 | + yield* fs.writeFileString(filePath, `${JSON.stringify(payload)}\n`); | |
| 159 | + const { fd } = yield* fs.open(filePath, { flag: "r" }); | |
| 160 | + return fd; | |
| 161 | + }); | |
| 162 | + | |
| 163 | + it.effect("recognizes bootstrap fd from environment config", () => | |
| 164 | + Effect.gen(function* () { | |
| 165 | + const fd = yield* openBootstrapFd({ authToken: "bootstrap-token" }); | |
| 166 | + | |
| 167 | + yield* runCli([], { | |
| 168 | + T3CODE_MODE: "web", | |
| 169 | + T3CODE_BOOTSTRAP_FD: String(fd), | |
| 170 | + T3CODE_AUTH_TOKEN: "env-token", | |
| 171 | + T3CODE_NO_BROWSER: "true", | |
| 172 | + }); | |
| 173 | + | |
| 174 | + assert.equal(start.mock.calls.length, 1); | |
| 175 | + assert.equal(resolvedConfig?.mode, "web"); | |
| 176 | + assert.equal(resolvedConfig?.authToken, "env-token"); | |
| 177 | + }), | |
| 178 | + ); | |
| 179 | + | |
| 180 | + it.effect("uses bootstrap envelope values as fallbacks when CLI and env are absent", () => | |
| 181 | + Effect.gen(function* () { | |
| 182 | + const fd = yield* openBootstrapFd({ | |
| 183 | + mode: "desktop", | |
| 184 | + port: 4888, | |
| 185 | + host: "127.0.0.2", | |
| 186 | + t3Home: "/tmp/t3-bootstrap-home", | |
| 187 | + devUrl: "http://127.0.0.1:5173", | |
| 188 | + noBrowser: true, | |
| 189 | + authToken: "bootstrap-token", | |
| 190 | + autoBootstrapProjectFromCwd: false, | |
| 191 | + logWebSocketEvents: true, | |
| 192 | + }); | |
| 193 | + | |
| 194 | + yield* runCli([], { | |
| 195 | + T3CODE_BOOTSTRAP_FD: String(fd), | |
| 196 | + }); | |
| 197 | + | |
| 198 | + assert.equal(start.mock.calls.length, 1); | |
| 199 | + assert.equal(resolvedConfig?.mode, "desktop"); | |
| 200 | + assert.equal(resolvedConfig?.port, 4888); | |
| 201 | + assert.equal(resolvedConfig?.host, "127.0.0.2"); | |
| 202 | + assert.equal(resolvedConfig?.baseDir, "/tmp/t3-bootstrap-home"); | |
| 203 | + assert.equal(resolvedConfig?.stateDir, "/tmp/t3-bootstrap-home/dev"); | |
| 204 | + assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/"); | |
| 205 | + assert.equal(resolvedConfig?.noBrowser, true); | |
| 206 | + assert.equal(resolvedConfig?.authToken, "bootstrap-token"); | |
| 207 | + assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, false); | |
| 208 | + assert.equal(resolvedConfig?.logWebSocketEvents, true); | |
| 209 | + }), | |
| 210 | + ); | |
| 211 | + | |
| 212 | + it.effect("applies CLI then env precedence over bootstrap envelope values", () => | |
| 213 | + Effect.gen(function* () { | |
| 214 | + const fd = yield* openBootstrapFd({ | |
| 215 | + mode: "desktop", | |
| 216 | + port: 4888, | |
| 217 | + host: "127.0.0.2", | |
| 218 | + t3Home: "/tmp/t3-bootstrap-home", | |
| 219 | + devUrl: "http://127.0.0.1:5173", | |
| 220 | + noBrowser: false, | |
| 221 | + authToken: "bootstrap-token", | |
| 222 | + autoBootstrapProjectFromCwd: false, | |
| 223 | + logWebSocketEvents: false, | |
| 224 | + }); | |
| 225 | + | |
| 226 | + yield* runCli(["--port", "4999", "--host", "0.0.0.0", "--auth-token", "cli-token"], { | |
| 227 | + T3CODE_MODE: "web", | |
| 228 | + T3CODE_BOOTSTRAP_FD: String(fd), | |
| 229 | + T3CODE_HOME: "/tmp/t3-env-home", | |
| 230 | + T3CODE_NO_BROWSER: "true", | |
| 231 | + T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true", | |
| 232 | + T3CODE_LOG_WS_EVENTS: "true", | |
| 233 | + }); | |
| 234 | + | |
| 235 | + assert.equal(start.mock.calls.length, 1); | |
| 236 | + assert.equal(resolvedConfig?.mode, "web"); | |
| 237 | + assert.equal(resolvedConfig?.port, 4999); | |
| 238 | + assert.equal(resolvedConfig?.host, "0.0.0.0"); | |
| 239 | + assert.equal(resolvedConfig?.baseDir, "/tmp/t3-env-home"); | |
| 240 | + assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/"); | |
| 241 | + assert.equal(resolvedConfig?.noBrowser, true); | |
| 242 | + assert.equal(resolvedConfig?.authToken, "cli-token"); | |
| 243 | + assert.equal(resolvedConfig?.autoBootstrapProjectFromCwd, true); | |
| 244 | + assert.equal(resolvedConfig?.logWebSocketEvents, true); | |
| 245 | + }), | |
| 246 | + ); | |
| 247 | + | |
| 154 | 248 | it.effect("prefers --mode over T3CODE_MODE", () => |
| 155 | 249 | Effect.gen(function* () { |
| 156 | 250 | findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(4666)); |
| @@ -27,12 +27,27 @@ import { Server } from "./wsServer"; · 26 unmodified lines | ||
| 27 | 27 | import { ServerLoggerLive } from "./serverLogger"; |
| 28 | 28 | import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; |
| 29 | 29 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; |
| 30 | +import { readBootstrapEnvelope } from "./bootstrap"; | |
| 30 | 31 | |
| 31 | 32 | export class StartupError extends Data.TaggedError("StartupError")<{ |
| 32 | 33 | readonly message: string; |
| 33 | 34 | readonly cause?: unknown; |
| 34 | 35 | }> {} |
| 35 | 36 | |
| 37 | +const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); | |
| 38 | + | |
| 39 | +const BootstrapEnvelopeSchema = Schema.Struct({ | |
| 40 | + mode: Schema.optional(Schema.String), | |
| 41 | + port: Schema.optional(PortSchema), | |
| 42 | + host: Schema.optional(Schema.String), | |
| 43 | + t3Home: Schema.optional(Schema.String), | |
| 44 | + devUrl: Schema.optional(Schema.URLFromString), | |
| 45 | + noBrowser: Schema.optional(Schema.Boolean), | |
| 46 | + authToken: Schema.optional(Schema.String), | |
| 47 | + autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), | |
| 48 | + logWebSocketEvents: Schema.optional(Schema.Boolean), | |
| 49 | +}); | |
| 50 | + | |
| 36 | 51 | interface CliInput { |
| 37 | 52 | readonly mode: Option.Option<RuntimeMode>; |
| 38 | 53 | readonly port: Option.Option<number>; |
| @@ -41,6 +56,7 @@ interface CliInput { · 2 unmodified lines | ||
| 41 | 56 | readonly devUrl: Option.Option<URL>; |
| 42 | 57 | readonly noBrowser: Option.Option<boolean>; |
| 43 | 58 | readonly authToken: Option.Option<string>; |
| 59 | + readonly bootstrapFd: Option.Option<number>; | |
| 44 | 60 | readonly autoBootstrapProjectFromCwd: Option.Option<boolean>; |
| 45 | 61 | readonly logWebSocketEvents: Option.Option<boolean>; |
| 46 | 62 | } |
| @@ -91,12 +107,8 @@ export class CliConfig extends ServiceMap.Service<CliConfig, CliConfigShape>()( · 44 unmodified lines | ||
| 91 | 107 | const CliEnvConfig = Config.all({ |
| 92 | 108 | mode: Config.string("T3CODE_MODE").pipe( |
| 93 | 109 | Config.option, |
| 94 | − Config.map( | |
| 95 | − Option.match<RuntimeMode, string>({ | |
| 96 | − onNone: () => "web", | |
| 97 | − onSome: (value) => (value === "desktop" ? "desktop" : "web"), | |
| 98 | − }), | |
| 99 | − ), | |
| 110 | + Config.map(Option.map((value) => (value === "desktop" ? "desktop" : "web"))), | |
| 111 | + Config.map(Option.getOrUndefined), | |
| 100 | 112 | ), |
| 101 | 113 | port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), |
| 102 | 114 | host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), |
| @@ -110,6 +122,10 @@ const CliEnvConfig = Config.all({ · 7 unmodified lines | ||
| 110 | 122 | Config.option, |
| 111 | 123 | Config.map(Option.getOrUndefined), |
| 112 | 124 | ), |
| 125 | + bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe( | |
| 126 | + Config.option, | |
| 127 | + Config.map(Option.getOrUndefined), | |
| 128 | + ), | |
| 113 | 129 | autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe( |
| 114 | 130 | Config.option, |
| 115 | 131 | Config.map(Option.getOrUndefined), |
| @@ -123,6 +139,14 @@ const CliEnvConfig = Config.all({ · 7 unmodified lines | ||
| 123 | 139 | const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) => |
| 124 | 140 | Option.getOrElse(Option.filter(flag, Boolean), () => envValue); |
| 125 | 141 | |
| 142 | +const resolveOptionPrecedence = <Value>( | |
| 143 | + ...values: ReadonlyArray<Option.Option<Value>> | |
| 144 | +): Option.Option<Value> => Option.firstSomeOf(values); | |
| 145 | + | |
| 146 | +const isValidPort = (value: number): boolean => value >= 1 && value <= 65_535; | |
| 147 | +const isRuntimeMode = (value: string): value is RuntimeMode => | |
| 148 | + value === "web" || value === "desktop"; | |
| 149 | + | |
| 126 | 150 | const ServerConfigLive = (input: CliInput) => |
| 127 | 151 | Layer.effect( |
| 128 | 152 | ServerConfig, |
| @@ -136,39 +160,115 @@ const ServerConfigLive = (input: CliInput) => · 7 unmodified lines | ||
| 136 | 160 | ), |
| 137 | 161 | ); |
| 138 | 162 | |
| 139 | − const mode = Option.getOrElse(input.mode, () => env.mode); | |
| 140 | − | |
| 141 | − const port = yield* Option.match(input.port, { | |
| 142 | − onSome: (value) => Effect.succeed(value), | |
| 143 | − onNone: () => { | |
| 144 | − if (env.port) { | |
| 145 | − return Effect.succeed(env.port); | |
| 146 | − } | |
| 147 | − if (mode === "desktop") { | |
| 148 | − return Effect.succeed(DEFAULT_PORT); | |
| 149 | − } | |
| 150 | − return findAvailablePort(DEFAULT_PORT); | |
| 163 | + const bootstrapFd = Option.getOrUndefined(input.bootstrapFd) ?? env.bootstrapFd; | |
| 164 | + const bootstrapEnvelope = | |
| 165 | + bootstrapFd !== undefined | |
| 166 | + ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd) | |
| 167 | + : Option.none(); | |
| 168 | + | |
| 169 | + const mode: RuntimeMode = Option.getOrElse( | |
| 170 | + resolveOptionPrecedence( | |
| 171 | + input.mode, | |
| 172 | + Option.fromUndefinedOr(env.mode), | |
| 173 | + Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 174 | + Option.filter(Option.fromUndefinedOr(bootstrap.mode), isRuntimeMode), | |
| 175 | + ), | |
| 176 | + ), | |
| 177 | + () => "web", | |
| 178 | + ); | |
| 179 | + const port = yield* Option.match( | |
| 180 | + resolveOptionPrecedence( | |
| 181 | + input.port, | |
| 182 | + Option.fromUndefinedOr(env.port), | |
| 183 | + Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 184 | + Option.filter(Option.fromUndefinedOr(bootstrap.port), isValidPort), | |
| 185 | + ), | |
| 186 | + ), | |
| 187 | + { | |
| 188 | + onSome: (value) => Effect.succeed(value), | |
| 189 | + onNone: () => { | |
| 190 | + if (mode === "desktop") { | |
| 191 | + return Effect.succeed(DEFAULT_PORT); | |
| 192 | + } | |
| 193 | + return findAvailablePort(DEFAULT_PORT); | |
| 194 | + }, | |
| 151 | 195 | }, |
| 152 | − }); | |
| 196 | + ); | |
| 153 | 197 | |
| 154 | − const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl); | |
| 155 | − const baseDir = yield* resolveBaseDir(Option.getOrUndefined(input.t3Home) ?? env.t3Home); | |
| 198 | + const devUrl = Option.getOrElse( | |
| 199 | + resolveOptionPrecedence( | |
| 200 | + input.devUrl, | |
| 201 | + Option.fromUndefinedOr(env.devUrl), | |
| 202 | + Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 203 | + Option.fromUndefinedOr(bootstrap.devUrl), | |
| 204 | + ), | |
| 205 | + ), | |
| 206 | + () => undefined, | |
| 207 | + ); | |
| 208 | + const baseDir = yield* resolveBaseDir( | |
| 209 | + Option.getOrUndefined( | |
| 210 | + resolveOptionPrecedence( | |
| 211 | + input.t3Home, | |
| 212 | + Option.fromUndefinedOr(env.t3Home), | |
| 213 | + Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 214 | + Option.fromUndefinedOr(bootstrap.t3Home), | |
| 215 | + ), | |
| 216 | + ), | |
| 217 | + ), | |
| 218 | + ); | |
| 156 | 219 | const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); |
| 157 | − const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop"); | |
| 158 | − const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; | |
| 220 | + const noBrowser = resolveBooleanFlag( | |
| 221 | + input.noBrowser, | |
| 222 | + Option.getOrElse( | |
| 223 | + resolveOptionPrecedence( | |
| 224 | + Option.fromUndefinedOr(env.noBrowser), | |
| 225 | + Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 226 | + Option.fromUndefinedOr(bootstrap.noBrowser), | |
| 227 | + ), | |
| 228 | + ), | |
| 229 | + () => mode === "desktop", | |
| 230 | + ), | |
| 231 | + ); | |
| 232 | + const authToken = resolveOptionPrecedence( | |
| 233 | + input.authToken, | |
| 234 | + Option.fromUndefinedOr(env.authToken), | |
| 235 | + Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 236 | + Option.fromUndefinedOr(bootstrap.authToken), | |
| 237 | + ), | |
| 238 | + ); | |
| 159 | 239 | const autoBootstrapProjectFromCwd = resolveBooleanFlag( |
| 160 | 240 | input.autoBootstrapProjectFromCwd, |
| 161 | − env.autoBootstrapProjectFromCwd ?? mode === "web", | |
| 241 | + Option.getOrElse( | |
| 242 | + resolveOptionPrecedence( | |
| 243 | + Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd), | |
| 244 | + Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 245 | + Option.fromUndefinedOr(bootstrap.autoBootstrapProjectFromCwd), | |
| 246 | + ), | |
| 247 | + ), | |
| 248 | + () => mode === "web", | |
| 249 | + ), | |
| 162 | 250 | ); |
| 163 | 251 | const logWebSocketEvents = resolveBooleanFlag( |
| 164 | 252 | input.logWebSocketEvents, |
| 165 | − env.logWebSocketEvents ?? Boolean(devUrl), | |
| 253 | + Option.getOrElse( | |
| 254 | + resolveOptionPrecedence( | |
| 255 | + Option.fromUndefinedOr(env.logWebSocketEvents), | |
| 256 | + Option.flatMap(bootstrapEnvelope, (bootstrap) => | |
| 257 | + Option.fromUndefinedOr(bootstrap.logWebSocketEvents), | |
| 258 | + ), | |
| 259 | + ), | |
| 260 | + () => Boolean(devUrl), | |
| 261 | + ), | |
| 166 | 262 | ); |
| 167 | 263 | const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; |
| 168 | − const host = | |
| 169 | − Option.getOrUndefined(input.host) ?? | |
| 170 | − env.host ?? | |
| 171 | − (mode === "desktop" ? "127.0.0.1" : undefined); | |
| 264 | + const host = Option.getOrElse( | |
| 265 | + resolveOptionPrecedence( | |
| 266 | + input.host, | |
| 267 | + Option.fromUndefinedOr(env.host), | |
| 268 | + Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.host)), | |
| 269 | + ), | |
| 270 | + () => (mode === "desktop" ? "127.0.0.1" : undefined), | |
| 271 | + ); | |
| 172 | 272 | |
| 173 | 273 | const config: ServerConfigShape = { |
| 174 | 274 | mode, |
| @@ -180,7 +280,7 @@ const ServerConfigLive = (input: CliInput) => · 5 unmodified lines | ||
| 180 | 280 | staticDir, |
| 181 | 281 | devUrl, |
| 182 | 282 | noBrowser, |
| 183 | − authToken, | |
| 283 | + authToken: Option.getOrUndefined(authToken), | |
| 184 | 284 | autoBootstrapProjectFromCwd, |
| 185 | 285 | logWebSocketEvents, |
| 186 | 286 | } satisfies ServerConfigShape; |
| @@ -287,7 +387,7 @@ const modeFlag = Flag.choice("mode", ["web", "desktop"]).pipe( · 100 unmodified lines | ||
| 287 | 387 | Flag.optional, |
| 288 | 388 | ); |
| 289 | 389 | const portFlag = Flag.integer("port").pipe( |
| 290 | − Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), | |
| 390 | + Flag.withSchema(PortSchema), | |
| 291 | 391 | Flag.withDescription("Port for the HTTP/WebSocket server."), |
| 292 | 392 | Flag.optional, |
| 293 | 393 | ); |
| @@ -313,6 +413,11 @@ const authTokenFlag = Flag.string("auth-token").pipe( · 19 unmodified lines | ||
| 313 | 413 | Flag.withAlias("token"), |
| 314 | 414 | Flag.optional, |
| 315 | 415 | ); |
| 416 | +const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe( | |
| 417 | + Flag.withSchema(Schema.Int), | |
| 418 | + Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."), | |
| 419 | + Flag.optional, | |
| 420 | +); | |
| 316 | 421 | const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe( |
| 317 | 422 | Flag.withDescription( |
| 318 | 423 | "Create a project for the current working directory on startup when missing.", |
| @@ -335,6 +440,7 @@ export const t3Cli = Command.make("t3", { · 16 unmodified lines | ||
| 335 | 440 | devUrl: devUrlFlag, |
| 336 | 441 | noBrowser: noBrowserFlag, |
| 337 | 442 | authToken: authTokenFlag, |
| 443 | + bootstrapFd: bootstrapFdFlag, | |
| 338 | 444 | autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, |
| 339 | 445 | logWebSocketEvents: logWebSocketEventsFlag, |
| 340 | 446 | }).pipe( |