| 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 | 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 |
apps/desktop/src/main.ts+42-17| ... | ... | @@ -56,6 +56,7 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state"; |
| 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"; |
| ... | ... | @@ -113,6 +114,17 @@ function sanitizeLogValue(value: string): string { |
| 113 | 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 | 128 | function writeDesktopLogHeader(message: string): void { |
| 117 | 129 | if (!desktopLogSink) return; |
| 118 | 130 | desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`); |
| ... | ... | @@ -918,17 +930,6 @@ function configureAutoUpdater(): void { |
| 918 | 930 | }, AUTO_UPDATE_POLL_INTERVAL_MS); |
| 919 | 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 | 933 | function scheduleBackendRestart(reason: string): void { |
| 933 | 934 | if (isQuitting || restartTimer) return; |
| 934 | 935 | |
| ... | ... | @@ -952,16 +953,35 @@ function startBackend(): void { |
| 952 | 953 | } |
| 953 | 954 | |
| 954 | 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 | 957 | cwd: resolveBackendCwd(), |
| 957 | 958 | // In Electron main, process.execPath points to the Electron binary. |
| 958 | 959 | // Run the child in Node mode so this backend process does not become a GUI app instance. |
| 959 | 960 | env: { |
| 960 | ...backendEnv(), | |
| 961 | ...backendChildEnv(), | |
| 961 | 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 | 985 | backendProcess = child; |
| 966 | 986 | let backendSessionClosed = false; |
| 967 | 987 | const closeBackendSession = (details: string) => { |
| ... | ... | @@ -1072,6 +1092,11 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> { |
| 1072 | 1092 | } |
| 1073 | 1093 | |
| 1074 | 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 | 1100 | ipcMain.removeHandler(PICK_FOLDER_CHANNEL); |
| 1076 | 1101 | ipcMain.handle(PICK_FOLDER_CHANNEL, async () => { |
| 1077 | 1102 | const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; |
| ... | ... | @@ -1320,9 +1345,9 @@ async function bootstrap(): Promise<void> { |
| 1320 | 1345 | ); |
| 1321 | 1346 | writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`); |
| 1322 | 1347 | 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}`); | |
| 1348 | const baseUrl = `ws://127.0.0.1:${backendPort}`; | |
| 1349 | backendWsUrl = `${baseUrl}/?token=${encodeURIComponent(backendAuthToken)}`; | |
| 1350 | writeDesktopLogHeader(`bootstrap resolved websocket endpoint baseUrl=${baseUrl}`); | |
| 1326 | 1351 | |
| 1327 | 1352 | registerIpcHandlers(); |
| 1328 | 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 | 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), |
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 | 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) => { |
| 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)); |
apps/server/src/main.ts+137-31| ... | ... | @@ -27,12 +27,27 @@ import { Server } from "./wsServer"; |
| 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 { |
| 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>()( |
| 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({ |
| 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({ |
| 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) => |
| 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) => |
| 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( |
| 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( |
| 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", { |
| 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( |
scripts/dev-runner.test.ts+37| ... | ... | @@ -160,6 +160,43 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 160 | 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 | 202 | describe("findFirstAvailableOffset", () => { |
scripts/dev-runner.ts+22-7| ... | ... | @@ -147,30 +147,41 @@ export function createDevRunnerEnv({ |
| 147 | 147 | const serverPort = port ?? BASE_SERVER_PORT + serverOffset; |
| 148 | 148 | const webPort = BASE_WEB_PORT + webOffset; |
| 149 | 149 | const resolvedBaseDir = yield* resolveBaseDir(t3Home); |
| 150 | const isDesktopMode = mode === "dev:desktop"; | |
| 150 | 151 | |
| 151 | 152 | const output: NodeJS.ProcessEnv = { |
| 152 | 153 | ...baseEnv, |
| 153 | T3CODE_PORT: String(serverPort), | |
| 154 | 154 | PORT: String(webPort), |
| 155 | 155 | ELECTRON_RENDERER_PORT: String(webPort), |
| 156 | VITE_WS_URL: `ws://localhost:${serverPort}`, | |
| 157 | 156 | VITE_DEV_SERVER_URL: devUrl?.toString() ?? `http://localhost:${webPort}`, |
| 158 | 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 | 173 | output.T3CODE_HOST = host; |
| 163 | 174 | } |
| 164 | 175 | |
| 165 | if (authToken !== undefined) { | |
| 176 | if (!isDesktopMode && authToken !== undefined) { | |
| 166 | 177 | output.T3CODE_AUTH_TOKEN = authToken; |
| 167 | } else { | |
| 178 | } else if (!isDesktopMode) { | |
| 168 | 179 | delete output.T3CODE_AUTH_TOKEN; |
| 169 | 180 | } |
| 170 | 181 | |
| 171 | if (noBrowser !== undefined) { | |
| 182 | if (!isDesktopMode && noBrowser !== undefined) { | |
| 172 | 183 | output.T3CODE_NO_BROWSER = noBrowser ? "1" : "0"; |
| 173 | } else { | |
| 184 | } else if (!isDesktopMode) { | |
| 174 | 185 | delete output.T3CODE_NO_BROWSER; |
| 175 | 186 | } |
| 176 | 187 | |
| ... | ... | @@ -196,6 +207,10 @@ export function createDevRunnerEnv({ |
| 196 | 207 | delete output.T3CODE_DESKTOP_WS_URL; |
| 197 | 208 | } |
| 198 | 209 | |
| 210 | if (isDesktopMode) { | |
| 211 | delete output.T3CODE_DESKTOP_WS_URL; | |
| 212 | } | |
| 213 | ||
| 199 | 214 | return output; |
| 200 | 215 | }); |
| 201 | 216 | } |