| author | |
| committer | |
| log | 448c48cd109bee06abecffddb3fc7130a50e3666 |
| tree | 5289bbe4b091069e827a0949efeed18b401b19e6 |
| parent | 470f282819079be867cdc1eaa52b0eeb632c14e7 |
4 files changed, 260 insertions(+), 38 deletions(-)
REMOTE.md created+65| ... | @@ -0,0 +1,65 @@ | ||
| 1 | # Remote Access Setup | ||
| 2 | |||
| 3 | Use this when you want to open T3 Code from another device (phone, tablet, another laptop). | ||
| 4 | |||
| 5 | ## CLI ↔ Env option map | ||
| 6 | |||
| 7 | The T3 Code CLI accepts the following configuration options, available either as CLI flags or environment variables: | ||
| 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 | | `--state-dir <path>` | `T3CODE_STATE_DIR` | State 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. | | ||
| 18 | |||
| 19 | > TIP: Use the `--help` flag to see all available options and their descriptions. | ||
| 20 | |||
| 21 | ## Security First | ||
| 22 | |||
| 23 | - Always set `--auth-token` before exposing the server outside localhost. | ||
| 24 | - Treat the token like a password. | ||
| 25 | - Prefer binding to trusted interfaces (LAN IP or Tailnet IP) instead of opening all interfaces unless needed. | ||
| 26 | |||
| 27 | ## 1) Build + run server for remote access | ||
| 28 | |||
| 29 | Remote access should use the built web app (not local Vite redirect mode). | ||
| 30 | |||
| 31 | ```bash | ||
| 32 | bun run build | ||
| 33 | TOKEN="$(openssl rand -hex 24)" | ||
| 34 | bun run --cwd apps/server start -- --host 0.0.0.0 --port 3773 --auth-token "$TOKEN" --no-browser | ||
| 35 | ``` | ||
| 36 | |||
| 37 | Then open on your phone: | ||
| 38 | |||
| 39 | `http://<your-machine-ip>:3773` | ||
| 40 | |||
| 41 | Example: | ||
| 42 | |||
| 43 | `http://192.168.1.42:3773` | ||
| 44 | |||
| 45 | Notes: | ||
| 46 | |||
| 47 | - `--host 0.0.0.0` listens on all IPv4 interfaces. | ||
| 48 | - `--no-browser` prevents local auto-open, which is usually better for headless/remote sessions. | ||
| 49 | - Ensure your OS firewall allows inbound TCP on the selected port. | ||
| 50 | |||
| 51 | ## 2) Tailnet / Tailscale access | ||
| 52 | |||
| 53 | If you use Tailscale, you can bind directly to your Tailnet address. | ||
| 54 | |||
| 55 | ```bash | ||
| 56 | TAILNET_IP="$(tailscale ip -4)" | ||
| 57 | TOKEN="$(openssl rand -hex 24)" | ||
| 58 | bun run --cwd apps/server start -- --host "$(tailscale ip -4)" --port 3773 --auth-token "$TOKEN" --no-browser | ||
| 59 | ``` | ||
| 60 | |||
| 61 | Open from any device in your tailnet: | ||
| 62 | |||
| 63 | `http://<tailnet-ip>:3773` | ||
| 64 | |||
| 65 | You can also bind `--host 0.0.0.0` and connect through the Tailnet IP, but binding directly to the Tailnet IP limits exposure. | ||
apps/server/src/index.ts+2-2| ... | @@ -3,7 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; | ... | @@ -3,7 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; | 4 | import * as Layer from "effect/Layer"; |
| 5 | 5 | ||
| 6 | import { CliConfig, makeCliCommand } from "./main"; | 6 | import { CliConfig, t3Cli } from "./main"; |
| 7 | import { OpenLive } from "./open"; | 7 | import { OpenLive } from "./open"; |
| 8 | import { Command } from "effect/unstable/cli"; | 8 | import { Command } from "effect/unstable/cli"; |
| 9 | import { version } from "../package.json" with { type: "json" }; | 9 | import { version } from "../package.json" with { type: "json" }; |
| ... | @@ -18,4 +18,4 @@ const RuntimeLayer = Layer.empty.pipe( | ... | @@ -18,4 +18,4 @@ const RuntimeLayer = Layer.empty.pipe( |
| 18 | Layer.provideMerge(NodeServices.layer), | 18 | Layer.provideMerge(NodeServices.layer), |
| 19 | ); | 19 | ); |
| 20 | 20 | ||
| 21 | Command.run(makeCliCommand(), { version }).pipe(Effect.provide(RuntimeLayer), NodeRuntime.runMain); | 21 | Command.run(t3Cli, { version }).pipe(Effect.provide(RuntimeLayer), NodeRuntime.runMain); |
apps/server/src/main.test.ts+104-6| ... | @@ -7,7 +7,7 @@ import * as Layer from "effect/Layer"; | ... | @@ -7,7 +7,7 @@ import * as Layer from "effect/Layer"; |
| 7 | import * as Command from "effect/unstable/cli/Command"; | 7 | import * as Command from "effect/unstable/cli/Command"; |
| 8 | import { beforeEach } from "vitest"; | 8 | import { beforeEach } from "vitest"; |
| 9 | 9 | ||
| 10 | import { CliConfig, makeCliCommand, type CliConfigShape } from "./main"; | 10 | import { CliConfig, t3Cli, type CliConfigShape } from "./main"; |
| 11 | import { NetService, ServerConfig, type ServerConfigShape } from "./config"; | 11 | import { NetService, ServerConfig, type ServerConfigShape } from "./config"; |
| 12 | import { Open, type OpenShape } from "./open"; | 12 | import { Open, type OpenShape } from "./open"; |
| 13 | import { Server, type ServerShape, type ServerOptions } from "./wsServer"; | 13 | import { Server, type ServerShape, type ServerOptions } from "./wsServer"; |
| ... | @@ -51,7 +51,7 @@ const runCli = ( | ... | @@ -51,7 +51,7 @@ const runCli = ( |
| 51 | args: ReadonlyArray<string>, | 51 | args: ReadonlyArray<string>, |
| 52 | env: Record<string, string> = { T3CODE_NO_BROWSER: "true" }, | 52 | env: Record<string, string> = { T3CODE_NO_BROWSER: "true" }, |
| 53 | ) => | 53 | ) => |
| 54 | Command.runWith(makeCliCommand(), { version: "0.0.0-test" })(args).pipe( | 54 | Command.runWith(t3Cli, { version: "0.0.0-test" })(args).pipe( |
| 55 | Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))), | 55 | Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))), |
| 56 | ); | 56 | ); |
| 57 | 57 | ||
| ... | @@ -74,33 +74,97 @@ beforeEach(() => { | ... | @@ -74,33 +74,97 @@ beforeEach(() => { |
| 74 | }); | 74 | }); |
| 75 | 75 | ||
| 76 | it.layer(testLayer)("server cli", (it) => { | 76 | it.layer(testLayer)("server cli", (it) => { |
| 77 | it.effect("parses --port and --token and wires scoped start/stop", () => | 77 | it.effect("parses all CLI flags and wires scoped start/stop", () => |
| 78 | Effect.gen(function* () { | 78 | Effect.gen(function* () { |
| 79 | yield* runCli(["--port", "4010", "--token", "secret"]); | 79 | yield* runCli([ |
| 80 | "--mode", | ||
| 81 | "desktop", | ||
| 82 | "--port", | ||
| 83 | "4010", | ||
| 84 | "--host", | ||
| 85 | "0.0.0.0", | ||
| 86 | "--state-dir", | ||
| 87 | "/tmp/t3-cli-state", | ||
| 88 | "--dev-url", | ||
| 89 | "http://127.0.0.1:5173", | ||
| 90 | "--no-browser", | ||
| 91 | "--auth-token", | ||
| 92 | "auth-secret", | ||
| 93 | ]); | ||
| 80 | 94 | ||
| 81 | assert.equal(createServer.mock.calls.length, 1); | 95 | assert.equal(createServer.mock.calls.length, 1); |
| 96 | assert.equal(resolvedConfig?.mode, "desktop"); | ||
| 82 | assert.equal(resolvedConfig?.port, 4010); | 97 | assert.equal(resolvedConfig?.port, 4010); |
| 83 | assert.equal(resolvedConfig?.authToken, "secret"); | 98 | assert.equal(resolvedConfig?.host, "0.0.0.0"); |
| 99 | assert.equal(resolvedConfig?.stateDir, "/tmp/t3-cli-state"); | ||
| 100 | assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/"); | ||
| 101 | assert.equal(resolvedConfig?.noBrowser, true); | ||
| 102 | assert.equal(resolvedConfig?.authToken, "auth-secret"); | ||
| 84 | assert.equal(start.mock.calls.length, 1); | 103 | assert.equal(start.mock.calls.length, 1); |
| 85 | assert.equal(stop.mock.calls.length, 1); | 104 | assert.equal(stop.mock.calls.length, 1); |
| 86 | }), | 105 | }), |
| 87 | ); | 106 | ); |
| 88 | 107 | ||
| 108 | it.effect("supports --token as an alias for --auth-token", () => | ||
| 109 | Effect.gen(function* () { | ||
| 110 | yield* runCli(["--token", "token-secret"]); | ||
| 111 | |||
| 112 | assert.equal(createServer.mock.calls.length, 1); | ||
| 113 | assert.equal(resolvedConfig?.authToken, "token-secret"); | ||
| 114 | }), | ||
| 115 | ); | ||
| 116 | |||
| 89 | it.effect("uses env fallbacks when flags are not provided", () => | 117 | it.effect("uses env fallbacks when flags are not provided", () => |
| 90 | Effect.gen(function* () { | 118 | Effect.gen(function* () { |
| 91 | yield* runCli([], { | 119 | yield* runCli([], { |
| 92 | T3CODE_NO_BROWSER: "true", | 120 | T3CODE_MODE: "desktop", |
| 93 | T3CODE_PORT: "4999", | 121 | T3CODE_PORT: "4999", |
| 122 | T3CODE_HOST: "100.88.10.4", | ||
| 123 | T3CODE_STATE_DIR: "/tmp/t3-env-state", | ||
| 124 | VITE_DEV_SERVER_URL: "http://localhost:5173", | ||
| 125 | T3CODE_NO_BROWSER: "true", | ||
| 94 | T3CODE_AUTH_TOKEN: "env-token", | 126 | T3CODE_AUTH_TOKEN: "env-token", |
| 95 | }); | 127 | }); |
| 96 | 128 | ||
| 97 | assert.equal(createServer.mock.calls.length, 1); | 129 | assert.equal(createServer.mock.calls.length, 1); |
| 130 | assert.equal(resolvedConfig?.mode, "desktop"); | ||
| 98 | assert.equal(resolvedConfig?.port, 4999); | 131 | assert.equal(resolvedConfig?.port, 4999); |
| 132 | assert.equal(resolvedConfig?.host, "100.88.10.4"); | ||
| 133 | assert.equal(resolvedConfig?.stateDir, "/tmp/t3-env-state"); | ||
| 134 | assert.equal(resolvedConfig?.devUrl?.toString(), "http://localhost:5173/"); | ||
| 135 | assert.equal(resolvedConfig?.noBrowser, true); | ||
| 99 | assert.equal(resolvedConfig?.authToken, "env-token"); | 136 | assert.equal(resolvedConfig?.authToken, "env-token"); |
| 100 | assert.equal(findAvailablePort.mock.calls.length, 0); | 137 | assert.equal(findAvailablePort.mock.calls.length, 0); |
| 101 | }), | 138 | }), |
| 102 | ); | 139 | ); |
| 103 | 140 | ||
| 141 | it.effect("prefers --mode over T3CODE_MODE", () => | ||
| 142 | Effect.gen(function* () { | ||
| 143 | findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(4666)); | ||
| 144 | yield* runCli(["--mode", "web"], { | ||
| 145 | T3CODE_MODE: "desktop", | ||
| 146 | T3CODE_NO_BROWSER: "true", | ||
| 147 | }); | ||
| 148 | |||
| 149 | assert.deepStrictEqual(findAvailablePort.mock.calls, [[3773]]); | ||
| 150 | assert.equal(createServer.mock.calls.length, 1); | ||
| 151 | assert.equal(resolvedConfig?.mode, "web"); | ||
| 152 | assert.equal(resolvedConfig?.port, 4666); | ||
| 153 | assert.equal(resolvedConfig?.host, undefined); | ||
| 154 | }), | ||
| 155 | ); | ||
| 156 | |||
| 157 | it.effect("prefers --no-browser over T3CODE_NO_BROWSER", () => | ||
| 158 | Effect.gen(function* () { | ||
| 159 | yield* runCli(["--no-browser"], { | ||
| 160 | T3CODE_NO_BROWSER: "false", | ||
| 161 | }); | ||
| 162 | |||
| 163 | assert.equal(createServer.mock.calls.length, 1); | ||
| 164 | assert.equal(resolvedConfig?.noBrowser, true); | ||
| 165 | }), | ||
| 166 | ); | ||
| 167 | |||
| 104 | it.effect("uses dynamic port discovery in web mode when port is omitted", () => | 168 | it.effect("uses dynamic port discovery in web mode when port is omitted", () => |
| 105 | Effect.gen(function* () { | 169 | Effect.gen(function* () { |
| 106 | findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(5444)); | 170 | findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(5444)); |
| ... | @@ -123,7 +187,41 @@ it.layer(testLayer)("server cli", (it) => { | ... | @@ -123,7 +187,41 @@ it.layer(testLayer)("server cli", (it) => { |
| 123 | assert.equal(findAvailablePort.mock.calls.length, 0); | 187 | assert.equal(findAvailablePort.mock.calls.length, 0); |
| 124 | assert.equal(createServer.mock.calls.length, 1); | 188 | assert.equal(createServer.mock.calls.length, 1); |
| 125 | assert.equal(resolvedConfig?.port, 3773); | 189 | assert.equal(resolvedConfig?.port, 3773); |
| 190 | assert.equal(resolvedConfig?.host, "127.0.0.1"); | ||
| 191 | assert.equal(resolvedConfig?.mode, "desktop"); | ||
| 192 | }), | ||
| 193 | ); | ||
| 194 | |||
| 195 | it.effect("allows overriding desktop host with --host", () => | ||
| 196 | Effect.gen(function* () { | ||
| 197 | yield* runCli(["--host", "0.0.0.0"], { | ||
| 198 | T3CODE_MODE: "desktop", | ||
| 199 | T3CODE_NO_BROWSER: "true", | ||
| 200 | }); | ||
| 201 | |||
| 202 | assert.equal(createServer.mock.calls.length, 1); | ||
| 126 | assert.equal(resolvedConfig?.mode, "desktop"); | 203 | assert.equal(resolvedConfig?.mode, "desktop"); |
| 204 | assert.equal(resolvedConfig?.host, "0.0.0.0"); | ||
| 205 | }), | ||
| 206 | ); | ||
| 207 | |||
| 208 | it.effect("does not start server for invalid --mode values", () => | ||
| 209 | Effect.gen(function* () { | ||
| 210 | yield* runCli(["--mode", "invalid"]); | ||
| 211 | |||
| 212 | assert.equal(createServer.mock.calls.length, 0); | ||
| 213 | assert.equal(start.mock.calls.length, 0); | ||
| 214 | assert.equal(stop.mock.calls.length, 0); | ||
| 215 | }), | ||
| 216 | ); | ||
| 217 | |||
| 218 | it.effect("does not start server for invalid --dev-url values", () => | ||
| 219 | Effect.gen(function* () { | ||
| 220 | yield* runCli(["--dev-url", "not-a-url"]).pipe(Effect.catch(() => Effect.void)); | ||
| 221 | |||
| 222 | assert.equal(createServer.mock.calls.length, 0); | ||
| 223 | assert.equal(start.mock.calls.length, 0); | ||
| 224 | assert.equal(stop.mock.calls.length, 0); | ||
| 127 | }), | 225 | }), |
| 128 | ); | 226 | ); |
| 129 | 227 |
apps/server/src/main.ts+89-30| ... | @@ -21,8 +21,13 @@ export class StartupError extends Data.TaggedError("StartupError")<{ | ... | @@ -21,8 +21,13 @@ export class StartupError extends Data.TaggedError("StartupError")<{ |
| 21 | }> {} | 21 | }> {} |
| 22 | 22 | ||
| 23 | interface CliInput { | 23 | interface CliInput { |
| 24 | readonly mode: Option.Option<RuntimeMode>; | ||
| 24 | readonly port: Option.Option<number>; | 25 | readonly port: Option.Option<number>; |
| 25 | readonly token: Option.Option<string>; | 26 | readonly host: Option.Option<string>; |
| 27 | readonly stateDir: Option.Option<string>; | ||
| 28 | readonly devUrl: Option.Option<URL>; | ||
| 29 | readonly noBrowser: Option.Option<boolean>; | ||
| 30 | readonly authToken: Option.Option<string>; | ||
| 26 | } | 31 | } |
| 27 | 32 | ||
| 28 | export interface CliConfigShape { | 33 | export interface CliConfigShape { |
| ... | @@ -60,6 +65,7 @@ const CliEnvConfig = Config.all({ | ... | @@ -60,6 +65,7 @@ const CliEnvConfig = Config.all({ |
| 60 | ), | 65 | ), |
| 61 | ), | 66 | ), |
| 62 | port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), | 67 | port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), |
| 68 | host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), | ||
| 63 | stateDir: Config.string("T3CODE_STATE_DIR").pipe( | 69 | stateDir: Config.string("T3CODE_STATE_DIR").pipe( |
| 64 | Config.option, | 70 | Config.option, |
| 65 | Config.map(Option.getOrUndefined), | 71 | Config.map(Option.getOrUndefined), |
| ... | @@ -88,27 +94,39 @@ const ServerConfigLive = (input: CliInput) => | ... | @@ -88,27 +94,39 @@ const ServerConfigLive = (input: CliInput) => |
| 88 | ), | 94 | ), |
| 89 | ); | 95 | ); |
| 90 | 96 | ||
| 97 | const mode = Option.getOrElse(input.mode, () => env.mode); | ||
| 98 | |||
| 91 | const port = yield* Option.match(input.port, { | 99 | const port = yield* Option.match(input.port, { |
| 92 | onSome: (value) => Effect.succeed(value), | 100 | onSome: (value) => Effect.succeed(value), |
| 93 | onNone: () => { | 101 | onNone: () => { |
| 94 | if (env.port) { | 102 | if (env.port) { |
| 95 | return Effect.succeed(env.port); | 103 | return Effect.succeed(env.port); |
| 96 | } | 104 | } |
| 97 | if (env.mode === "desktop") { | 105 | if (mode === "desktop") { |
| 98 | return Effect.succeed(DEFAULT_PORT); | 106 | return Effect.succeed(DEFAULT_PORT); |
| 99 | } | 107 | } |
| 100 | return findAvailablePort(DEFAULT_PORT); | 108 | return findAvailablePort(DEFAULT_PORT); |
| 101 | }, | 109 | }, |
| 102 | }); | 110 | }); |
| 103 | const stateDir = yield* resolveStateDir(env.stateDir); | 111 | const stateDir = yield* resolveStateDir( |
| 104 | const devUrl = env.devUrl; | 112 | Option.getOrUndefined(input.stateDir) ?? env.stateDir, |
| 105 | const noBrowser = env.noBrowser ?? env.mode === "desktop"; | 113 | ); |
| 106 | const authToken = Option.getOrUndefined(input.token) ?? env.authToken; | 114 | const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl); |
| 115 | const noBrowser = Option.match(input.noBrowser, { | ||
| 116 | // effect/cli boolean flags parse to `false` when absent; in that case | ||
| 117 | // we still want env/mode fallbacks to apply. | ||
| 118 | onSome: (value) => (value ? true : (env.noBrowser ?? mode === "desktop")), | ||
| 119 | onNone: () => env.noBrowser ?? mode === "desktop", | ||
| 120 | }); | ||
| 121 | const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; | ||
| 107 | const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; | 122 | const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; |
| 108 | const host = env.mode === "desktop" ? "127.0.0.1" : undefined; | 123 | const host = |
| 124 | Option.getOrUndefined(input.host) ?? | ||
| 125 | env.host ?? | ||
| 126 | (mode === "desktop" ? "127.0.0.1" : undefined); | ||
| 109 | 127 | ||
| 110 | return { | 128 | return { |
| 111 | mode: env.mode, | 129 | mode, |
| 112 | port, | 130 | port, |
| 113 | cwd: cliConfig.cwd, | 131 | cwd: cliConfig.cwd, |
| 114 | host, | 132 | host, |
| ... | @@ -129,6 +147,12 @@ const LayerLive = (input: CliInput) => | ... | @@ -129,6 +147,12 @@ const LayerLive = (input: CliInput) => |
| 129 | Layer.provideMerge(ServerConfigLive(input)), | 147 | Layer.provideMerge(ServerConfigLive(input)), |
| 130 | ); | 148 | ); |
| 131 | 149 | ||
| 150 | const isWildcardHost = (host: string | undefined): boolean => | ||
| 151 | host === "0.0.0.0" || host === "::" || host === "[::]"; | ||
| 152 | |||
| 153 | const formatHostForUrl = (host: string): string => | ||
| 154 | host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; | ||
| 155 | |||
| 132 | const makeServerProgram = (input: CliInput) => | 156 | const makeServerProgram = (input: CliInput) => |
| 133 | Effect.gen(function* () { | 157 | Effect.gen(function* () { |
| 134 | const cliConfig = yield* CliConfig; | 158 | const cliConfig = yield* CliConfig; |
| ... | @@ -149,9 +173,15 @@ const makeServerProgram = (input: CliInput) => | ... | @@ -149,9 +173,15 @@ const makeServerProgram = (input: CliInput) => |
| 149 | 173 | ||
| 150 | yield* createServer(); | 174 | yield* createServer(); |
| 151 | 175 | ||
| 152 | const url = `http://localhost:${config.port}`; | 176 | const localUrl = `http://localhost:${config.port}`; |
| 177 | const bindUrl = | ||
| 178 | config.host && !isWildcardHost(config.host) | ||
| 179 | ? `http://${formatHostForUrl(config.host)}:${config.port}` | ||
| 180 | : localUrl; | ||
| 153 | yield* Effect.logInfo("T3 Code running", { | 181 | yield* Effect.logInfo("T3 Code running", { |
| 154 | url, | 182 | url: bindUrl, |
| 183 | localUrl, | ||
| 184 | bindHost: config.host ?? "default", | ||
| 155 | cwd: config.cwd, | 185 | cwd: config.cwd, |
| 156 | mode: config.mode, | 186 | mode: config.mode, |
| 157 | stateDir: config.stateDir, | 187 | stateDir: config.stateDir, |
| ... | @@ -159,7 +189,7 @@ const makeServerProgram = (input: CliInput) => | ... | @@ -159,7 +189,7 @@ const makeServerProgram = (input: CliInput) => |
| 159 | }); | 189 | }); |
| 160 | 190 | ||
| 161 | if (!config.noBrowser) { | 191 | if (!config.noBrowser) { |
| 162 | const target = config.devUrl?.toString() ?? url; | 192 | const target = config.devUrl?.toString() ?? bindUrl; |
| 163 | yield* openDeps.openBrowser(target).pipe( | 193 | yield* openDeps.openBrowser(target).pipe( |
| 164 | Effect.catch(() => | 194 | Effect.catch(() => |
| 165 | Effect.logInfo("browser auto-open unavailable", { | 195 | Effect.logInfo("browser auto-open unavailable", { |
| ... | @@ -172,22 +202,51 @@ const makeServerProgram = (input: CliInput) => | ... | @@ -172,22 +202,51 @@ const makeServerProgram = (input: CliInput) => |
| 172 | return yield* stopSignal; | 202 | return yield* stopSignal; |
| 173 | }).pipe(Effect.provide(LayerLive(input))); | 203 | }).pipe(Effect.provide(LayerLive(input))); |
| 174 | 204 | ||
| 175 | export function makeCliCommand() { | 205 | /** |
| 176 | const portFlag = Flag.integer("port").pipe( | 206 | * These flags mirrors the environment variables and the config shape. |
| 177 | Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), | 207 | */ |
| 178 | Flag.withDescription("Port for the HTTP/WebSocket server."), | 208 | |
| 179 | Flag.optional, | 209 | const modeFlag = Flag.choice("mode", ["web", "desktop"]).pipe( |
| 180 | ); | 210 | Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), |
| 181 | const tokenFlag = Flag.string("token").pipe( | 211 | Flag.optional, |
| 182 | Flag.withDescription("Auth token required for WebSocket connections."), | 212 | ); |
| 183 | Flag.optional, | 213 | const portFlag = Flag.integer("port").pipe( |
| 184 | ); | 214 | Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), |
| 185 | 215 | Flag.withDescription("Port for the HTTP/WebSocket server."), | |
| 186 | return Command.make("t3", { | 216 | Flag.optional, |
| 187 | port: portFlag, | 217 | ); |
| 188 | token: tokenFlag, | 218 | const hostFlag = Flag.string("host").pipe( |
| 189 | }).pipe( | 219 | Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."), |
| 190 | Command.withDescription("Run the T3 Code server."), | 220 | Flag.optional, |
| 191 | Command.withHandler((input) => Effect.scoped(makeServerProgram(input))), | 221 | ); |
| 192 | ); | 222 | const stateDirFlag = Flag.string("state-dir").pipe( |
| 193 | } | 223 | Flag.withDescription("State directory path (equivalent to T3CODE_STATE_DIR)."), |
| 224 | Flag.optional, | ||
| 225 | ); | ||
| 226 | const devUrlFlag = Flag.string("dev-url").pipe( | ||
| 227 | Flag.withSchema(Schema.URLFromString), | ||
| 228 | Flag.withDescription("Dev web URL to proxy/redirect to (equivalent to VITE_DEV_SERVER_URL)."), | ||
| 229 | Flag.optional, | ||
| 230 | ); | ||
| 231 | const noBrowserFlag = Flag.boolean("no-browser").pipe( | ||
| 232 | Flag.withDescription("Disable automatic browser opening."), | ||
| 233 | Flag.optional, | ||
| 234 | ); | ||
| 235 | const authTokenFlag = Flag.string("auth-token").pipe( | ||
| 236 | Flag.withDescription("Auth token required for WebSocket connections."), | ||
| 237 | Flag.withAlias("token"), | ||
| 238 | Flag.optional, | ||
| 239 | ); | ||
| 240 | |||
| 241 | export const t3Cli = Command.make("t3", { | ||
| 242 | mode: modeFlag, | ||
| 243 | port: portFlag, | ||
| 244 | host: hostFlag, | ||
| 245 | stateDir: stateDirFlag, | ||
| 246 | devUrl: devUrlFlag, | ||
| 247 | noBrowser: noBrowserFlag, | ||
| 248 | authToken: authTokenFlag, | ||
| 249 | }).pipe( | ||
| 250 | Command.withDescription("Run the T3 Code server."), | ||
| 251 | Command.withHandler((input) => Effect.scoped(makeServerProgram(input))), | ||
| 252 | ); |