| author | |
| committer | |
| log | b3bca04caf8449c4d10da1c70cd1f3a0c9070280 |
| tree | 26f2fd22c4d604c75828908111a8e2f784705249 |
| parent | bf71e0bc5eb0af9494a5969302f27f3d95b694c5 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>9 files changed, 590 insertions(+), 66 deletions(-)
REMOTE.md+12-9| ... | @@ -6,21 +6,24 @@ Use this when you want to open T3 Code from another device (phone, tablet, anoth | ... | @@ -6,21 +6,24 @@ Use this when you want to open T3 Code from another device (phone, tablet, anoth |
| 6 | 6 | ||
| 7 | The T3 Code CLI accepts the following configuration options, available either as CLI flags or environment variables: | 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 | | 9 | | CLI flag | Env var | Notes | |
| 10 | | ----------------------- | --------------------- | ---------------------------------- | | 10 | | ----------------------- | --------------------- | ------------------------------------------------------------------------------------ | |
| 11 | | `--mode <web\|desktop>` | `T3CODE_MODE` | Runtime mode. | | 11 | | `--mode <web\|desktop>` | `T3CODE_MODE` | Runtime mode. | |
| 12 | | `--port <number>` | `T3CODE_PORT` | HTTP/WebSocket port. | | 12 | | `--port <number>` | `T3CODE_PORT` | HTTP/WebSocket port. | |
| 13 | | `--host <address>` | `T3CODE_HOST` | Bind interface/address. | | 13 | | `--host <address>` | `T3CODE_HOST` | Bind interface/address. | |
| 14 | | `--base-dir <path>` | `T3CODE_HOME` | Base directory. | | 14 | | `--base-dir <path>` | `T3CODE_HOME` | Base directory. | |
| 15 | | `--dev-url <url>` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. | | 15 | | `--dev-url <url>` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. | |
| 16 | | `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. | | 16 | | `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. | |
| 17 | | `--auth-token <token>` | `T3CODE_AUTH_TOKEN` | WebSocket auth token. | | 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 | > TIP: Use the `--help` flag to see all available options and their descriptions. | 20 | > TIP: Use the `--help` flag to see all available options and their descriptions. |
| 20 | 21 | ||
| 21 | ## Security First | 22 | ## Security First |
| 22 | 23 | ||
| 23 | - Always set `--auth-token` before exposing the server outside localhost. | 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 | - Treat the token like a password. | 27 | - Treat the token like a password. |
| 25 | - Prefer binding to trusted interfaces (LAN IP or Tailnet IP) instead of opening all interfaces unless needed. | 28 | - Prefer binding to trusted interfaces (LAN IP or Tailnet IP) instead of opening all interfaces unless needed. |
| 26 | 29 |
apps/desktop/src/main.ts+42-17| ... | @@ -56,6 +56,7 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state"; | ... | @@ -56,6 +56,7 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state"; |
| 56 | const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; | 56 | const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; |
| 57 | const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; | 57 | const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; |
| 58 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; | 58 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; |
| 59 | const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; | ||
| 59 | const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); | 60 | const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); |
| 60 | const STATE_DIR = Path.join(BASE_DIR, "userdata"); | 61 | const STATE_DIR = Path.join(BASE_DIR, "userdata"); |
| 61 | const DESKTOP_SCHEME = "t3"; | 62 | const DESKTOP_SCHEME = "t3"; |
| ... | @@ -113,6 +114,17 @@ function sanitizeLogValue(value: string): string { | ... | @@ -113,6 +114,17 @@ function sanitizeLogValue(value: string): string { |
| 113 | return value.replace(/\s+/g, " ").trim(); | 114 | return value.replace(/\s+/g, " ").trim(); |
| 114 | } | 115 | } |
| 115 | 116 | ||
| 117 | function backendChildEnv(): NodeJS.ProcessEnv { | ||
| 118 | const env = { ...process.env }; | ||
| 119 | delete env.T3CODE_PORT; | ||
| 120 | delete env.T3CODE_AUTH_TOKEN; | ||
| 121 | delete env.T3CODE_MODE; | ||
| 122 | delete env.T3CODE_NO_BROWSER; | ||
| 123 | delete env.T3CODE_HOST; | ||
| 124 | delete env.T3CODE_DESKTOP_WS_URL; | ||
| 125 | return env; | ||
| 126 | } | ||
| 127 | |||
| 116 | function writeDesktopLogHeader(message: string): void { | 128 | function writeDesktopLogHeader(message: string): void { |
| 117 | if (!desktopLogSink) return; | 129 | if (!desktopLogSink) return; |
| 118 | desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`); | 130 | desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`); |
| ... | @@ -918,17 +930,6 @@ function configureAutoUpdater(): void { | ... | @@ -918,17 +930,6 @@ function configureAutoUpdater(): void { |
| 918 | }, AUTO_UPDATE_POLL_INTERVAL_MS); | 930 | }, AUTO_UPDATE_POLL_INTERVAL_MS); |
| 919 | updatePollTimer.unref(); | 931 | updatePollTimer.unref(); |
| 920 | } | 932 | } |
| 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 | function scheduleBackendRestart(reason: string): void { | 933 | function scheduleBackendRestart(reason: string): void { |
| 933 | if (isQuitting || restartTimer) return; | 934 | if (isQuitting || restartTimer) return; |
| 934 | 935 | ||
| ... | @@ -952,16 +953,35 @@ function startBackend(): void { | ... | @@ -952,16 +953,35 @@ function startBackend(): void { |
| 952 | } | 953 | } |
| 953 | 954 | ||
| 954 | const captureBackendLogs = app.isPackaged && backendLogSink !== null; | 955 | const captureBackendLogs = app.isPackaged && backendLogSink !== null; |
| 955 | const child = ChildProcess.spawn(process.execPath, [backendEntry], { | 956 | const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], { |
| 956 | cwd: resolveBackendCwd(), | 957 | cwd: resolveBackendCwd(), |
| 957 | // In Electron main, process.execPath points to the Electron binary. | 958 | // In Electron main, process.execPath points to the Electron binary. |
| 958 | // Run the child in Node mode so this backend process does not become a GUI app instance. | 959 | // Run the child in Node mode so this backend process does not become a GUI app instance. |
| 959 | env: { | 960 | env: { |
| 960 | ...backendEnv(), | 961 | ...backendChildEnv(), |
| 961 | ELECTRON_RUN_AS_NODE: "1", | 962 | ELECTRON_RUN_AS_NODE: "1", |
| 962 | }, | 963 | }, |
| 963 | stdio: captureBackendLogs ? ["ignore", "pipe", "pipe"] : "inherit", | 964 | stdio: captureBackendLogs |
| 965 | ? ["ignore", "pipe", "pipe", "pipe"] | ||
| 966 | : ["ignore", "inherit", "inherit", "pipe"], | ||
| 964 | }); | 967 | }); |
| 968 | const bootstrapStream = child.stdio[3]; | ||
| 969 | if (bootstrapStream && "write" in bootstrapStream) { | ||
| 970 | bootstrapStream.write( | ||
| 971 | `${JSON.stringify({ | ||
| 972 | mode: "desktop", | ||
| 973 | noBrowser: true, | ||
| 974 | port: backendPort, | ||
| 975 | t3Home: BASE_DIR, | ||
| 976 | authToken: backendAuthToken, | ||
| 977 | })}\n`, | ||
| 978 | ); | ||
| 979 | bootstrapStream.end(); | ||
| 980 | } else { | ||
| 981 | child.kill("SIGTERM"); | ||
| 982 | scheduleBackendRestart("missing desktop bootstrap pipe"); | ||
| 983 | return; | ||
| 984 | } | ||
| 965 | backendProcess = child; | 985 | backendProcess = child; |
| 966 | let backendSessionClosed = false; | 986 | let backendSessionClosed = false; |
| 967 | const closeBackendSession = (details: string) => { | 987 | const closeBackendSession = (details: string) => { |
| ... | @@ -1072,6 +1092,11 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> { | ... | @@ -1072,6 +1092,11 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> { |
| 1072 | } | 1092 | } |
| 1073 | 1093 | ||
| 1074 | function registerIpcHandlers(): void { | 1094 | function registerIpcHandlers(): void { |
| 1095 | ipcMain.removeAllListeners(GET_WS_URL_CHANNEL); | ||
| 1096 | ipcMain.on(GET_WS_URL_CHANNEL, (event) => { | ||
| 1097 | event.returnValue = backendWsUrl; | ||
| 1098 | }); | ||
| 1099 | |||
| 1075 | ipcMain.removeHandler(PICK_FOLDER_CHANNEL); | 1100 | ipcMain.removeHandler(PICK_FOLDER_CHANNEL); |
| 1076 | ipcMain.handle(PICK_FOLDER_CHANNEL, async () => { | 1101 | ipcMain.handle(PICK_FOLDER_CHANNEL, async () => { |
| 1077 | const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; | 1102 | const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; |
| ... | @@ -1320,9 +1345,9 @@ async function bootstrap(): Promise<void> { | ... | @@ -1320,9 +1345,9 @@ async function bootstrap(): Promise<void> { |
| 1320 | ); | 1345 | ); |
| 1321 | writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`); | 1346 | writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`); |
| 1322 | backendAuthToken = Crypto.randomBytes(24).toString("hex"); | 1347 | backendAuthToken = Crypto.randomBytes(24).toString("hex"); |
| 1323 | backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`; | 1348 | const baseUrl = `ws://127.0.0.1:${backendPort}`; |
| 1324 | process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl; | 1349 | backendWsUrl = `${baseUrl}/?token=${encodeURIComponent(backendAuthToken)}`; |
| 1325 | writeDesktopLogHeader(`bootstrap resolved websocket url=${backendWsUrl}`); | 1350 | writeDesktopLogHeader(`bootstrap resolved websocket endpoint baseUrl=${baseUrl}`); |
| 1326 | 1351 | ||
| 1327 | registerIpcHandlers(); | 1352 | registerIpcHandlers(); |
| 1328 | writeDesktopLogHeader("bootstrap ipc handlers registered"); | 1353 | writeDesktopLogHeader("bootstrap ipc handlers registered"); |
apps/desktop/src/preload.ts+5-2| ... | @@ -11,10 +11,13 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state"; | ... | @@ -11,10 +11,13 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state"; |
| 11 | const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; | 11 | const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; |
| 12 | const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; | 12 | const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; |
| 13 | const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; | 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 | contextBridge.exposeInMainWorld("desktopBridge", { | 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 | pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), | 21 | pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), |
| 19 | confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), | 22 | confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), |
| 20 | setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), | 23 | setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), |
apps/server/src/bootstrap.test.ts created+96| ... | @@ -0,0 +1,96 @@ | ||
| 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 { assert, 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, resolveFdPath } 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("uses platform-specific fd paths", () => | ||
| 19 | Effect.sync(() => { | ||
| 20 | assert.equal(resolveFdPath(3, "linux"), "/proc/self/fd/3"); | ||
| 21 | assert.equal(resolveFdPath(3, "darwin"), "/dev/fd/3"); | ||
| 22 | assert.equal(resolveFdPath(3, "win32"), undefined); | ||
| 23 | }), | ||
| 24 | ); | ||
| 25 | |||
| 26 | it.effect("reads a bootstrap envelope from a provided fd", () => | ||
| 27 | Effect.gen(function* () { | ||
| 28 | const fs = yield* FileSystem.FileSystem; | ||
| 29 | const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); | ||
| 30 | |||
| 31 | yield* fs.writeFileString( | ||
| 32 | filePath, | ||
| 33 | `${yield* Schema.encodeEffect(Schema.fromJsonString(TestEnvelopeSchema))({ | ||
| 34 | mode: "desktop", | ||
| 35 | })}\n`, | ||
| 36 | ); | ||
| 37 | |||
| 38 | const fd = yield* Effect.acquireRelease( | ||
| 39 | Effect.sync(() => NFS.openSync(filePath, "r")), | ||
| 40 | (fd) => Effect.sync(() => NFS.closeSync(fd)), | ||
| 41 | ); | ||
| 42 | |||
| 43 | const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); | ||
| 44 | assertSome(payload, { | ||
| 45 | mode: "desktop", | ||
| 46 | }); | ||
| 47 | }), | ||
| 48 | ); | ||
| 49 | |||
| 50 | it.effect("returns none when the fd is unavailable", () => | ||
| 51 | Effect.gen(function* () { | ||
| 52 | const fd = NFS.openSync("/dev/null", "r"); | ||
| 53 | NFS.closeSync(fd); | ||
| 54 | |||
| 55 | const payload = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { timeoutMs: 100 }); | ||
| 56 | assertNone(payload); | ||
| 57 | }), | ||
| 58 | ); | ||
| 59 | |||
| 60 | it.effect("returns none when the bootstrap read times out before any value arrives", () => | ||
| 61 | Effect.gen(function* () { | ||
| 62 | const fs = yield* FileSystem.FileSystem; | ||
| 63 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-bootstrap-" }); | ||
| 64 | const fifoPath = path.join(tempDir, "bootstrap.pipe"); | ||
| 65 | |||
| 66 | yield* Effect.sync(() => execFileSync("mkfifo", [fifoPath])); | ||
| 67 | |||
| 68 | const _writer = yield* Effect.acquireRelease( | ||
| 69 | Effect.sync(() => | ||
| 70 | spawn("sh", ["-c", 'exec 3>"$1"; sleep 60', "sh", fifoPath], { | ||
| 71 | stdio: ["ignore", "ignore", "ignore"], | ||
| 72 | }), | ||
| 73 | ), | ||
| 74 | (writer) => | ||
| 75 | Effect.sync(() => { | ||
| 76 | writer.kill("SIGKILL"); | ||
| 77 | }), | ||
| 78 | ); | ||
| 79 | |||
| 80 | const fd = yield* Effect.acquireRelease( | ||
| 81 | Effect.sync(() => NFS.openSync(fifoPath, "r")), | ||
| 82 | (fd) => Effect.sync(() => NFS.closeSync(fd)), | ||
| 83 | ); | ||
| 84 | |||
| 85 | const fiber = yield* readBootstrapEnvelope(TestEnvelopeSchema, fd, { | ||
| 86 | timeoutMs: 100, | ||
| 87 | }).pipe(Effect.forkScoped); | ||
| 88 | |||
| 89 | yield* Effect.yieldNow; | ||
| 90 | yield* TestClock.adjust(Duration.millis(100)); | ||
| 91 | |||
| 92 | const payload = yield* Fiber.join(fiber); | ||
| 93 | assertNone(payload); | ||
| 94 | }).pipe(Effect.provide(TestClock.layer())), | ||
| 95 | ); | ||
| 96 | }); | ||
apps/server/src/bootstrap.ts created+145| ... | @@ -0,0 +1,145 @@ | ||
| 1 | import * as NFS from "node:fs"; | ||
| 2 | import * as Net from "node:net"; | ||
| 3 | import * as readline from "node:readline"; | ||
| 4 | import type { Readable } from "node:stream"; | ||
| 5 | |||
| 6 | import { Data, Effect, Option, Predicate, Result, Schema } from "effect"; | ||
| 7 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | ||
| 8 | |||
| 9 | class BootstrapError extends Data.TaggedError("BootstrapError")<{ | ||
| 10 | readonly message: string; | ||
| 11 | readonly cause?: unknown; | ||
| 12 | }> {} | ||
| 13 | |||
| 14 | export const readBootstrapEnvelope = Effect.fn("readBootstrapEnvelope")(function* <A, I>( | ||
| 15 | schema: Schema.Codec<A, I>, | ||
| 16 | fd: number, | ||
| 17 | options?: { | ||
| 18 | timeoutMs?: number; | ||
| 19 | }, | ||
| 20 | ): Effect.fn.Return<Option.Option<A>, BootstrapError> { | ||
| 21 | const fdReady = yield* isFdReady(fd); | ||
| 22 | if (!fdReady) return Option.none(); | ||
| 23 | |||
| 24 | const stream = yield* makeBootstrapInputStream(fd); | ||
| 25 | |||
| 26 | const timeoutMs = options?.timeoutMs ?? 1000; | ||
| 27 | |||
| 28 | return yield* Effect.callback<Option.Option<A>, BootstrapError>((resume) => { | ||
| 29 | const input = readline.createInterface({ | ||
| 30 | input: stream, | ||
| 31 | crlfDelay: Infinity, | ||
| 32 | }); | ||
| 33 | |||
| 34 | const cleanup = () => { | ||
| 35 | stream.removeListener("error", handleError); | ||
| 36 | input.removeListener("line", handleLine); | ||
| 37 | input.removeListener("close", handleClose); | ||
| 38 | input.close(); | ||
| 39 | stream.destroy(); | ||
| 40 | }; | ||
| 41 | |||
| 42 | const handleError = (error: Error) => { | ||
| 43 | if (isUnavailableBootstrapFdError(error)) { | ||
| 44 | resume(Effect.succeedNone); | ||
| 45 | return; | ||
| 46 | } | ||
| 47 | resume( | ||
| 48 | Effect.fail( | ||
| 49 | new BootstrapError({ | ||
| 50 | message: "Failed to read bootstrap envelope.", | ||
| 51 | cause: error, | ||
| 52 | }), | ||
| 53 | ), | ||
| 54 | ); | ||
| 55 | }; | ||
| 56 | |||
| 57 | const handleLine = (line: string) => { | ||
| 58 | const parsed = decodeJsonResult(schema)(line); | ||
| 59 | if (Result.isSuccess(parsed)) { | ||
| 60 | resume(Effect.succeedSome(parsed.success)); | ||
| 61 | } else { | ||
| 62 | resume( | ||
| 63 | Effect.fail( | ||
| 64 | new BootstrapError({ | ||
| 65 | message: "Failed to decode bootstrap envelope.", | ||
| 66 | cause: parsed.failure, | ||
| 67 | }), | ||
| 68 | ), | ||
| 69 | ); | ||
| 70 | } | ||
| 71 | }; | ||
| 72 | |||
| 73 | const handleClose = () => { | ||
| 74 | resume(Effect.succeedNone); | ||
| 75 | }; | ||
| 76 | |||
| 77 | stream.once("error", handleError); | ||
| 78 | input.once("line", handleLine); | ||
| 79 | input.once("close", handleClose); | ||
| 80 | |||
| 81 | return Effect.sync(cleanup); | ||
| 82 | }).pipe(Effect.timeoutOption(timeoutMs), Effect.map(Option.flatten)); | ||
| 83 | }); | ||
| 84 | |||
| 85 | const isUnavailableBootstrapFdError = Predicate.compose( | ||
| 86 | Predicate.hasProperty("code"), | ||
| 87 | (_) => _.code === "EBADF" || _.code === "ENOENT", | ||
| 88 | ); | ||
| 89 | |||
| 90 | const isFdReady = (fd: number) => | ||
| 91 | Effect.try({ | ||
| 92 | try: () => NFS.fstatSync(fd), | ||
| 93 | catch: (error) => | ||
| 94 | new BootstrapError({ | ||
| 95 | message: "Failed to stat bootstrap fd.", | ||
| 96 | cause: error, | ||
| 97 | }), | ||
| 98 | }).pipe( | ||
| 99 | Effect.as(true), | ||
| 100 | Effect.catchIf( | ||
| 101 | (error) => isUnavailableBootstrapFdError(error.cause), | ||
| 102 | () => Effect.succeed(false), | ||
| 103 | ), | ||
| 104 | ); | ||
| 105 | |||
| 106 | const makeBootstrapInputStream = (fd: number) => | ||
| 107 | Effect.try<Readable, BootstrapError>({ | ||
| 108 | try: () => { | ||
| 109 | const fdPath = resolveFdPath(fd); | ||
| 110 | if (fdPath === undefined) { | ||
| 111 | const stream = new Net.Socket({ | ||
| 112 | fd, | ||
| 113 | readable: true, | ||
| 114 | writable: false, | ||
| 115 | }); | ||
| 116 | stream.setEncoding("utf8"); | ||
| 117 | return stream; | ||
| 118 | } | ||
| 119 | |||
| 120 | const streamFd = NFS.openSync(fdPath, "r"); | ||
| 121 | return NFS.createReadStream("", { | ||
| 122 | fd: streamFd, | ||
| 123 | encoding: "utf8", | ||
| 124 | autoClose: true, | ||
| 125 | }); | ||
| 126 | }, | ||
| 127 | catch: (error) => | ||
| 128 | new BootstrapError({ | ||
| 129 | message: "Failed to duplicate bootstrap fd.", | ||
| 130 | cause: error, | ||
| 131 | }), | ||
| 132 | }); | ||
| 133 | |||
| 134 | export function resolveFdPath( | ||
| 135 | fd: number, | ||
| 136 | platform: NodeJS.Platform = process.platform, | ||
| 137 | ): string | undefined { | ||
| 138 | if (platform === "linux") { | ||
| 139 | return `/proc/self/fd/${fd}`; | ||
| 140 | } | ||
| 141 | if (platform === "win32") { | ||
| 142 | return undefined; | ||
| 143 | } | ||
| 144 | return `/dev/fd/${fd}`; | ||
| 145 | } | ||
apps/server/src/main.test.ts+94| ... | @@ -4,6 +4,7 @@ import { assert, it, vi } from "@effect/vitest"; | ... | @@ -4,6 +4,7 @@ import { assert, it, vi } from "@effect/vitest"; |
| 4 | import type { OrchestrationReadModel } from "@t3tools/contracts"; | 4 | import type { OrchestrationReadModel } from "@t3tools/contracts"; |
| 5 | import * as ConfigProvider from "effect/ConfigProvider"; | 5 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 6 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as FileSystem from "effect/FileSystem"; | ||
| 7 | import * as Layer from "effect/Layer"; | 8 | import * as Layer from "effect/Layer"; |
| 8 | import * as Command from "effect/unstable/cli/Command"; | 9 | import * as Command from "effect/unstable/cli/Command"; |
| 9 | import { FetchHttpClient } from "effect/unstable/http"; | 10 | import { FetchHttpClient } from "effect/unstable/http"; |
| ... | @@ -151,6 +152,99 @@ it.layer(testLayer)("server CLI command", (it) => { | ... | @@ -151,6 +152,99 @@ it.layer(testLayer)("server CLI command", (it) => { |
| 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 | it.effect("prefers --mode over T3CODE_MODE", () => | 248 | it.effect("prefers --mode over T3CODE_MODE", () => |
| 155 | Effect.gen(function* () { | 249 | Effect.gen(function* () { |
| 156 | findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(4666)); | 250 | findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(4666)); |
apps/server/src/main.ts+137-31| ... | @@ -27,12 +27,27 @@ import { Server } from "./wsServer"; | ... | @@ -27,12 +27,27 @@ import { Server } from "./wsServer"; |
| 27 | import { ServerLoggerLive } from "./serverLogger"; | 27 | import { ServerLoggerLive } from "./serverLogger"; |
| 28 | import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; | 28 | import { AnalyticsServiceLayerLive } from "./telemetry/Layers/AnalyticsService"; |
| 29 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; | 29 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; |
| 30 | import { readBootstrapEnvelope } from "./bootstrap"; | ||
| 30 | 31 | ||
| 31 | export class StartupError extends Data.TaggedError("StartupError")<{ | 32 | export class StartupError extends Data.TaggedError("StartupError")<{ |
| 32 | readonly message: string; | 33 | readonly message: string; |
| 33 | readonly cause?: unknown; | 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 | interface CliInput { | 51 | interface CliInput { |
| 37 | readonly mode: Option.Option<RuntimeMode>; | 52 | readonly mode: Option.Option<RuntimeMode>; |
| 38 | readonly port: Option.Option<number>; | 53 | readonly port: Option.Option<number>; |
| ... | @@ -41,6 +56,7 @@ interface CliInput { | ... | @@ -41,6 +56,7 @@ interface CliInput { |
| 41 | readonly devUrl: Option.Option<URL>; | 56 | readonly devUrl: Option.Option<URL>; |
| 42 | readonly noBrowser: Option.Option<boolean>; | 57 | readonly noBrowser: Option.Option<boolean>; |
| 43 | readonly authToken: Option.Option<string>; | 58 | readonly authToken: Option.Option<string>; |
| 59 | readonly bootstrapFd: Option.Option<number>; | ||
| 44 | readonly autoBootstrapProjectFromCwd: Option.Option<boolean>; | 60 | readonly autoBootstrapProjectFromCwd: Option.Option<boolean>; |
| 45 | readonly logWebSocketEvents: Option.Option<boolean>; | 61 | readonly logWebSocketEvents: Option.Option<boolean>; |
| 46 | } | 62 | } |
| ... | @@ -91,12 +107,8 @@ export class CliConfig extends ServiceMap.Service<CliConfig, CliConfigShape>()( | ... | @@ -91,12 +107,8 @@ export class CliConfig extends ServiceMap.Service<CliConfig, CliConfigShape>()( |
| 91 | const CliEnvConfig = Config.all({ | 107 | const CliEnvConfig = Config.all({ |
| 92 | mode: Config.string("T3CODE_MODE").pipe( | 108 | mode: Config.string("T3CODE_MODE").pipe( |
| 93 | Config.option, | 109 | Config.option, |
| 94 | Config.map( | 110 | Config.map(Option.map((value) => (value === "desktop" ? "desktop" : "web"))), |
| 95 | Option.match<RuntimeMode, string>({ | 111 | Config.map(Option.getOrUndefined), |
| 96 | onNone: () => "web", | ||
| 97 | onSome: (value) => (value === "desktop" ? "desktop" : "web"), | ||
| 98 | }), | ||
| 99 | ), | ||
| 100 | ), | 112 | ), |
| 101 | port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), | 113 | port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), |
| 102 | host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), | 114 | host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), |
| ... | @@ -110,6 +122,10 @@ const CliEnvConfig = Config.all({ | ... | @@ -110,6 +122,10 @@ const CliEnvConfig = Config.all({ |
| 110 | Config.option, | 122 | Config.option, |
| 111 | Config.map(Option.getOrUndefined), | 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 | autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe( | 129 | autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe( |
| 114 | Config.option, | 130 | Config.option, |
| 115 | Config.map(Option.getOrUndefined), | 131 | Config.map(Option.getOrUndefined), |
| ... | @@ -123,6 +139,14 @@ const CliEnvConfig = Config.all({ | ... | @@ -123,6 +139,14 @@ const CliEnvConfig = Config.all({ |
| 123 | const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) => | 139 | const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) => |
| 124 | Option.getOrElse(Option.filter(flag, Boolean), () => envValue); | 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 | const ServerConfigLive = (input: CliInput) => | 150 | const ServerConfigLive = (input: CliInput) => |
| 127 | Layer.effect( | 151 | Layer.effect( |
| 128 | ServerConfig, | 152 | ServerConfig, |
| ... | @@ -136,39 +160,115 @@ const ServerConfigLive = (input: CliInput) => | ... | @@ -136,39 +160,115 @@ const ServerConfigLive = (input: CliInput) => |
| 136 | ), | 160 | ), |
| 137 | ); | 161 | ); |
| 138 | 162 | ||
| 139 | const mode = Option.getOrElse(input.mode, () => env.mode); | 163 | const bootstrapFd = Option.getOrUndefined(input.bootstrapFd) ?? env.bootstrapFd; |
| 140 | 164 | const bootstrapEnvelope = | |
| 141 | const port = yield* Option.match(input.port, { | 165 | bootstrapFd !== undefined |
| 142 | onSome: (value) => Effect.succeed(value), | 166 | ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd) |
| 143 | onNone: () => { | 167 | : Option.none(); |
| 144 | if (env.port) { | 168 | |
| 145 | return Effect.succeed(env.port); | 169 | const mode: RuntimeMode = Option.getOrElse( |
| 146 | } | 170 | resolveOptionPrecedence( |
| 147 | if (mode === "desktop") { | 171 | input.mode, |
| 148 | return Effect.succeed(DEFAULT_PORT); | 172 | Option.fromUndefinedOr(env.mode), |
| 149 | } | 173 | Option.flatMap(bootstrapEnvelope, (bootstrap) => |
| 150 | return findAvailablePort(DEFAULT_PORT); | 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); | 198 | const devUrl = Option.getOrElse( |
| 155 | const baseDir = yield* resolveBaseDir(Option.getOrUndefined(input.t3Home) ?? env.t3Home); | 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 | const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); | 219 | const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); |
| 157 | const noBrowser = resolveBooleanFlag(input.noBrowser, env.noBrowser ?? mode === "desktop"); | 220 | const noBrowser = resolveBooleanFlag( |
| 158 | const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; | 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 | const autoBootstrapProjectFromCwd = resolveBooleanFlag( | 239 | const autoBootstrapProjectFromCwd = resolveBooleanFlag( |
| 160 | input.autoBootstrapProjectFromCwd, | 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 | const logWebSocketEvents = resolveBooleanFlag( | 251 | const logWebSocketEvents = resolveBooleanFlag( |
| 164 | input.logWebSocketEvents, | 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 | const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; | 263 | const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; |
| 168 | const host = | 264 | const host = Option.getOrElse( |
| 169 | Option.getOrUndefined(input.host) ?? | 265 | resolveOptionPrecedence( |
| 170 | env.host ?? | 266 | input.host, |
| 171 | (mode === "desktop" ? "127.0.0.1" : undefined); | 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 | const config: ServerConfigShape = { | 273 | const config: ServerConfigShape = { |
| 174 | mode, | 274 | mode, |
| ... | @@ -180,7 +280,7 @@ const ServerConfigLive = (input: CliInput) => | ... | @@ -180,7 +280,7 @@ const ServerConfigLive = (input: CliInput) => |
| 180 | staticDir, | 280 | staticDir, |
| 181 | devUrl, | 281 | devUrl, |
| 182 | noBrowser, | 282 | noBrowser, |
| 183 | authToken, | 283 | authToken: Option.getOrUndefined(authToken), |
| 184 | autoBootstrapProjectFromCwd, | 284 | autoBootstrapProjectFromCwd, |
| 185 | logWebSocketEvents, | 285 | logWebSocketEvents, |
| 186 | } satisfies ServerConfigShape; | 286 | } satisfies ServerConfigShape; |
| ... | @@ -287,7 +387,7 @@ const modeFlag = Flag.choice("mode", ["web", "desktop"]).pipe( | ... | @@ -287,7 +387,7 @@ const modeFlag = Flag.choice("mode", ["web", "desktop"]).pipe( |
| 287 | Flag.optional, | 387 | Flag.optional, |
| 288 | ); | 388 | ); |
| 289 | const portFlag = Flag.integer("port").pipe( | 389 | const portFlag = Flag.integer("port").pipe( |
| 290 | Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), | 390 | Flag.withSchema(PortSchema), |
| 291 | Flag.withDescription("Port for the HTTP/WebSocket server."), | 391 | Flag.withDescription("Port for the HTTP/WebSocket server."), |
| 292 | Flag.optional, | 392 | Flag.optional, |
| 293 | ); | 393 | ); |
| ... | @@ -313,6 +413,11 @@ const authTokenFlag = Flag.string("auth-token").pipe( | ... | @@ -313,6 +413,11 @@ const authTokenFlag = Flag.string("auth-token").pipe( |
| 313 | Flag.withAlias("token"), | 413 | Flag.withAlias("token"), |
| 314 | Flag.optional, | 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 | const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe( | 421 | const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe( |
| 317 | Flag.withDescription( | 422 | Flag.withDescription( |
| 318 | "Create a project for the current working directory on startup when missing.", | 423 | "Create a project for the current working directory on startup when missing.", |
| ... | @@ -335,6 +440,7 @@ export const t3Cli = Command.make("t3", { | ... | @@ -335,6 +440,7 @@ export const t3Cli = Command.make("t3", { |
| 335 | devUrl: devUrlFlag, | 440 | devUrl: devUrlFlag, |
| 336 | noBrowser: noBrowserFlag, | 441 | noBrowser: noBrowserFlag, |
| 337 | authToken: authTokenFlag, | 442 | authToken: authTokenFlag, |
| 443 | bootstrapFd: bootstrapFdFlag, | ||
| 338 | autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, | 444 | autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, |
| 339 | logWebSocketEvents: logWebSocketEventsFlag, | 445 | logWebSocketEvents: logWebSocketEventsFlag, |
| 340 | }).pipe( | 446 | }).pipe( |
scripts/dev-runner.test.ts+37| ... | @@ -160,6 +160,43 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { | ... | @@ -160,6 +160,43 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 160 | assert.equal(env.T3CODE_HOME, resolve("/tmp/my-t3")); | 160 | assert.equal(env.T3CODE_HOME, resolve("/tmp/my-t3")); |
| 161 | }), | 161 | }), |
| 162 | ); | 162 | ); |
| 163 | |||
| 164 | it.effect("does not export backend bootstrap env for dev:desktop", () => | ||
| 165 | Effect.gen(function* () { | ||
| 166 | const env = yield* createDevRunnerEnv({ | ||
| 167 | mode: "dev:desktop", | ||
| 168 | baseEnv: { | ||
| 169 | T3CODE_PORT: "3773", | ||
| 170 | T3CODE_AUTH_TOKEN: "stale-token", | ||
| 171 | T3CODE_MODE: "web", | ||
| 172 | T3CODE_NO_BROWSER: "0", | ||
| 173 | T3CODE_HOST: "0.0.0.0", | ||
| 174 | VITE_WS_URL: "ws://localhost:3773", | ||
| 175 | }, | ||
| 176 | serverOffset: 0, | ||
| 177 | webOffset: 0, | ||
| 178 | t3Home: "/tmp/my-t3", | ||
| 179 | authToken: "fresh-token", | ||
| 180 | noBrowser: true, | ||
| 181 | autoBootstrapProjectFromCwd: undefined, | ||
| 182 | logWebSocketEvents: undefined, | ||
| 183 | host: "127.0.0.1", | ||
| 184 | port: 4222, | ||
| 185 | devUrl: undefined, | ||
| 186 | }); | ||
| 187 | |||
| 188 | assert.equal(env.T3CODE_HOME, resolve("/tmp/my-t3")); | ||
| 189 | assert.equal(env.PORT, "5733"); | ||
| 190 | assert.equal(env.ELECTRON_RENDERER_PORT, "5733"); | ||
| 191 | assert.equal(env.VITE_DEV_SERVER_URL, "http://localhost:5733"); | ||
| 192 | assert.equal(env.T3CODE_PORT, undefined); | ||
| 193 | assert.equal(env.T3CODE_AUTH_TOKEN, undefined); | ||
| 194 | assert.equal(env.T3CODE_MODE, undefined); | ||
| 195 | assert.equal(env.T3CODE_NO_BROWSER, undefined); | ||
| 196 | assert.equal(env.T3CODE_HOST, undefined); | ||
| 197 | assert.equal(env.VITE_WS_URL, undefined); | ||
| 198 | }), | ||
| 199 | ); | ||
| 163 | }); | 200 | }); |
| 164 | 201 | ||
| 165 | describe("findFirstAvailableOffset", () => { | 202 | describe("findFirstAvailableOffset", () => { |
scripts/dev-runner.ts+22-7| ... | @@ -147,30 +147,41 @@ export function createDevRunnerEnv({ | ... | @@ -147,30 +147,41 @@ export function createDevRunnerEnv({ |
| 147 | const serverPort = port ?? BASE_SERVER_PORT + serverOffset; | 147 | const serverPort = port ?? BASE_SERVER_PORT + serverOffset; |
| 148 | const webPort = BASE_WEB_PORT + webOffset; | 148 | const webPort = BASE_WEB_PORT + webOffset; |
| 149 | const resolvedBaseDir = yield* resolveBaseDir(t3Home); | 149 | const resolvedBaseDir = yield* resolveBaseDir(t3Home); |
| 150 | const isDesktopMode = mode === "dev:desktop"; | ||
| 150 | 151 | ||
| 151 | const output: NodeJS.ProcessEnv = { | 152 | const output: NodeJS.ProcessEnv = { |
| 152 | ...baseEnv, | 153 | ...baseEnv, |
| 153 | T3CODE_PORT: String(serverPort), | ||
| 154 | PORT: String(webPort), | 154 | PORT: String(webPort), |
| 155 | ELECTRON_RENDERER_PORT: String(webPort), | 155 | ELECTRON_RENDERER_PORT: String(webPort), |
| 156 | VITE_WS_URL: `ws://localhost:${serverPort}`, | ||
| 157 | VITE_DEV_SERVER_URL: devUrl?.toString() ?? `http://localhost:${webPort}`, | 156 | VITE_DEV_SERVER_URL: devUrl?.toString() ?? `http://localhost:${webPort}`, |
| 158 | T3CODE_HOME: resolvedBaseDir, | 157 | T3CODE_HOME: resolvedBaseDir, |
| 159 | }; | 158 | }; |
| 160 | 159 | ||
| 161 | if (host !== undefined) { | 160 | if (!isDesktopMode) { |
| 161 | output.T3CODE_PORT = String(serverPort); | ||
| 162 | output.VITE_WS_URL = `ws://localhost:${serverPort}`; | ||
| 163 | } else { | ||
| 164 | delete output.T3CODE_PORT; | ||
| 165 | delete output.VITE_WS_URL; | ||
| 166 | delete output.T3CODE_AUTH_TOKEN; | ||
| 167 | delete output.T3CODE_MODE; | ||
| 168 | delete output.T3CODE_NO_BROWSER; | ||
| 169 | delete output.T3CODE_HOST; | ||
| 170 | } | ||
| 171 | |||
| 172 | if (!isDesktopMode && host !== undefined) { | ||
| 162 | output.T3CODE_HOST = host; | 173 | output.T3CODE_HOST = host; |
| 163 | } | 174 | } |
| 164 | 175 | ||
| 165 | if (authToken !== undefined) { | 176 | if (!isDesktopMode && authToken !== undefined) { |
| 166 | output.T3CODE_AUTH_TOKEN = authToken; | 177 | output.T3CODE_AUTH_TOKEN = authToken; |
| 167 | } else { | 178 | } else if (!isDesktopMode) { |
| 168 | delete output.T3CODE_AUTH_TOKEN; | 179 | delete output.T3CODE_AUTH_TOKEN; |
| 169 | } | 180 | } |
| 170 | 181 | ||
| 171 | if (noBrowser !== undefined) { | 182 | if (!isDesktopMode && noBrowser !== undefined) { |
| 172 | output.T3CODE_NO_BROWSER = noBrowser ? "1" : "0"; | 183 | output.T3CODE_NO_BROWSER = noBrowser ? "1" : "0"; |
| 173 | } else { | 184 | } else if (!isDesktopMode) { |
| 174 | delete output.T3CODE_NO_BROWSER; | 185 | delete output.T3CODE_NO_BROWSER; |
| 175 | } | 186 | } |
| 176 | 187 | ||
| ... | @@ -196,6 +207,10 @@ export function createDevRunnerEnv({ | ... | @@ -196,6 +207,10 @@ export function createDevRunnerEnv({ |
| 196 | delete output.T3CODE_DESKTOP_WS_URL; | 207 | delete output.T3CODE_DESKTOP_WS_URL; |
| 197 | } | 208 | } |
| 198 | 209 | ||
| 210 | if (isDesktopMode) { | ||
| 211 | delete output.T3CODE_DESKTOP_WS_URL; | ||
| 212 | } | ||
| 213 | |||
| 199 | return output; | 214 | return output; |
| 200 | }); | 215 | }); |
| 201 | } | 216 | } |