From 448c48cd109bee06abecffddb3fc7130a50e3666 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 24 Feb 2026 19:28:45 -0800 Subject: [PATCH] remote support --- REMOTE.md | 65 +++++++++++++++++++ apps/server/src/index.ts | 4 +- apps/server/src/main.test.ts | 110 ++++++++++++++++++++++++++++++-- apps/server/src/main.ts | 117 ++++++++++++++++++++++++++--------- 4 files changed, 259 insertions(+), 37 deletions(-) create mode 100644 REMOTE.md diff --git a/REMOTE.md b/REMOTE.md new file mode 100644 index 0000000000000000000000000000000000000000..8bbd481deae86f5db84bcb0d5b8999724eb1ce44 --- /dev/null +++ b/REMOTE.md @@ -0,0 +1,65 @@ +# Remote Access Setup + +Use this when you want to open T3 Code from another device (phone, tablet, another laptop). + +## CLI ↔ Env option map + +The T3 Code CLI accepts the following configuration options, available either as CLI flags or environment variables: + +| CLI flag | Env var | Notes | +| ----------------------- | --------------------- | ---------------------------------- | +| `--mode ` | `T3CODE_MODE` | Runtime mode. | +| `--port ` | `T3CODE_PORT` | HTTP/WebSocket port. | +| `--host
` | `T3CODE_HOST` | Bind interface/address. | +| `--state-dir ` | `T3CODE_STATE_DIR` | State directory. | +| `--dev-url ` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. | +| `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. | +| `--auth-token ` | `T3CODE_AUTH_TOKEN` | WebSocket auth token. | + +> TIP: Use the `--help` flag to see all available options and their descriptions. + +## Security First + +- Always set `--auth-token` before exposing the server outside localhost. +- Treat the token like a password. +- Prefer binding to trusted interfaces (LAN IP or Tailnet IP) instead of opening all interfaces unless needed. + +## 1) Build + run server for remote access + +Remote access should use the built web app (not local Vite redirect mode). + +```bash +bun run build +TOKEN="$(openssl rand -hex 24)" +bun run --cwd apps/server start -- --host 0.0.0.0 --port 3773 --auth-token "$TOKEN" --no-browser +``` + +Then open on your phone: + +`http://:3773` + +Example: + +`http://192.168.1.42:3773` + +Notes: + +- `--host 0.0.0.0` listens on all IPv4 interfaces. +- `--no-browser` prevents local auto-open, which is usually better for headless/remote sessions. +- Ensure your OS firewall allows inbound TCP on the selected port. + +## 2) Tailnet / Tailscale access + +If you use Tailscale, you can bind directly to your Tailnet address. + +```bash +TAILNET_IP="$(tailscale ip -4)" +TOKEN="$(openssl rand -hex 24)" +bun run --cwd apps/server start -- --host "$(tailscale ip -4)" --port 3773 --auth-token "$TOKEN" --no-browser +``` + +Open from any device in your tailnet: + +`http://:3773` + +You can also bind `--host 0.0.0.0` and connect through the Tailnet IP, but binding directly to the Tailnet IP limits exposure. diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 711bedff37e0d962e0e82909e6cb3ee75e1d9c0e..2f3ca764bae0e0a7bde85b5e97f38bd699f131ac 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -3,7 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { CliConfig, makeCliCommand } from "./main"; +import { CliConfig, t3Cli } from "./main"; import { OpenLive } from "./open"; import { Command } from "effect/unstable/cli"; import { version } from "../package.json" with { type: "json" }; @@ -18,4 +18,4 @@ const RuntimeLayer = Layer.empty.pipe( Layer.provideMerge(NodeServices.layer), ); -Command.run(makeCliCommand(), { version }).pipe(Effect.provide(RuntimeLayer), NodeRuntime.runMain); +Command.run(t3Cli, { version }).pipe(Effect.provide(RuntimeLayer), NodeRuntime.runMain); diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index 836c97ea7d2d20a21c3d28470dbec2736d66e033..e14141fb7dead5f5e6cb9baddbc1e24e7e9ee7c5 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -7,7 +7,7 @@ import * as Layer from "effect/Layer"; import * as Command from "effect/unstable/cli/Command"; import { beforeEach } from "vitest"; -import { CliConfig, makeCliCommand, type CliConfigShape } from "./main"; +import { CliConfig, t3Cli, type CliConfigShape } from "./main"; import { NetService, ServerConfig, type ServerConfigShape } from "./config"; import { Open, type OpenShape } from "./open"; import { Server, type ServerShape, type ServerOptions } from "./wsServer"; @@ -51,7 +51,7 @@ const runCli = ( args: ReadonlyArray, env: Record = { T3CODE_NO_BROWSER: "true" }, ) => - Command.runWith(makeCliCommand(), { version: "0.0.0-test" })(args).pipe( + Command.runWith(t3Cli, { version: "0.0.0-test" })(args).pipe( Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))), ); @@ -74,33 +74,97 @@ beforeEach(() => { }); it.layer(testLayer)("server cli", (it) => { - it.effect("parses --port and --token and wires scoped start/stop", () => + it.effect("parses all CLI flags and wires scoped start/stop", () => Effect.gen(function* () { - yield* runCli(["--port", "4010", "--token", "secret"]); + yield* runCli([ + "--mode", + "desktop", + "--port", + "4010", + "--host", + "0.0.0.0", + "--state-dir", + "/tmp/t3-cli-state", + "--dev-url", + "http://127.0.0.1:5173", + "--no-browser", + "--auth-token", + "auth-secret", + ]); assert.equal(createServer.mock.calls.length, 1); + assert.equal(resolvedConfig?.mode, "desktop"); assert.equal(resolvedConfig?.port, 4010); - assert.equal(resolvedConfig?.authToken, "secret"); + assert.equal(resolvedConfig?.host, "0.0.0.0"); + assert.equal(resolvedConfig?.stateDir, "/tmp/t3-cli-state"); + assert.equal(resolvedConfig?.devUrl?.toString(), "http://127.0.0.1:5173/"); + assert.equal(resolvedConfig?.noBrowser, true); + assert.equal(resolvedConfig?.authToken, "auth-secret"); assert.equal(start.mock.calls.length, 1); assert.equal(stop.mock.calls.length, 1); }), ); + it.effect("supports --token as an alias for --auth-token", () => + Effect.gen(function* () { + yield* runCli(["--token", "token-secret"]); + + assert.equal(createServer.mock.calls.length, 1); + assert.equal(resolvedConfig?.authToken, "token-secret"); + }), + ); + it.effect("uses env fallbacks when flags are not provided", () => Effect.gen(function* () { yield* runCli([], { - T3CODE_NO_BROWSER: "true", + T3CODE_MODE: "desktop", T3CODE_PORT: "4999", + T3CODE_HOST: "100.88.10.4", + T3CODE_STATE_DIR: "/tmp/t3-env-state", + VITE_DEV_SERVER_URL: "http://localhost:5173", + T3CODE_NO_BROWSER: "true", T3CODE_AUTH_TOKEN: "env-token", }); assert.equal(createServer.mock.calls.length, 1); + assert.equal(resolvedConfig?.mode, "desktop"); assert.equal(resolvedConfig?.port, 4999); + assert.equal(resolvedConfig?.host, "100.88.10.4"); + assert.equal(resolvedConfig?.stateDir, "/tmp/t3-env-state"); + assert.equal(resolvedConfig?.devUrl?.toString(), "http://localhost:5173/"); + assert.equal(resolvedConfig?.noBrowser, true); assert.equal(resolvedConfig?.authToken, "env-token"); assert.equal(findAvailablePort.mock.calls.length, 0); }), ); + it.effect("prefers --mode over T3CODE_MODE", () => + Effect.gen(function* () { + findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(4666)); + yield* runCli(["--mode", "web"], { + T3CODE_MODE: "desktop", + T3CODE_NO_BROWSER: "true", + }); + + assert.deepStrictEqual(findAvailablePort.mock.calls, [[3773]]); + assert.equal(createServer.mock.calls.length, 1); + assert.equal(resolvedConfig?.mode, "web"); + assert.equal(resolvedConfig?.port, 4666); + assert.equal(resolvedConfig?.host, undefined); + }), + ); + + it.effect("prefers --no-browser over T3CODE_NO_BROWSER", () => + Effect.gen(function* () { + yield* runCli(["--no-browser"], { + T3CODE_NO_BROWSER: "false", + }); + + assert.equal(createServer.mock.calls.length, 1); + assert.equal(resolvedConfig?.noBrowser, true); + }), + ); + it.effect("uses dynamic port discovery in web mode when port is omitted", () => Effect.gen(function* () { findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(5444)); @@ -123,10 +187,44 @@ it.layer(testLayer)("server cli", (it) => { assert.equal(findAvailablePort.mock.calls.length, 0); assert.equal(createServer.mock.calls.length, 1); assert.equal(resolvedConfig?.port, 3773); + assert.equal(resolvedConfig?.host, "127.0.0.1"); assert.equal(resolvedConfig?.mode, "desktop"); }), ); + it.effect("allows overriding desktop host with --host", () => + Effect.gen(function* () { + yield* runCli(["--host", "0.0.0.0"], { + T3CODE_MODE: "desktop", + T3CODE_NO_BROWSER: "true", + }); + + assert.equal(createServer.mock.calls.length, 1); + assert.equal(resolvedConfig?.mode, "desktop"); + assert.equal(resolvedConfig?.host, "0.0.0.0"); + }), + ); + + it.effect("does not start server for invalid --mode values", () => + Effect.gen(function* () { + yield* runCli(["--mode", "invalid"]); + + assert.equal(createServer.mock.calls.length, 0); + assert.equal(start.mock.calls.length, 0); + assert.equal(stop.mock.calls.length, 0); + }), + ); + + it.effect("does not start server for invalid --dev-url values", () => + Effect.gen(function* () { + yield* runCli(["--dev-url", "not-a-url"]).pipe(Effect.catch(() => Effect.void)); + + assert.equal(createServer.mock.calls.length, 0); + assert.equal(start.mock.calls.length, 0); + assert.equal(stop.mock.calls.length, 0); + }), + ); + it.effect("does not start server for out-of-range --port values", () => Effect.gen(function* () { yield* runCli(["--port", "70000"]); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 661ccae1919f8819ef2bc9a4454c2c7ce6d0fbc3..4d0691ce05bee8b8358f6dcb9f4ee2c3d6f8502f 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -21,8 +21,13 @@ export class StartupError extends Data.TaggedError("StartupError")<{ }> {} interface CliInput { + readonly mode: Option.Option; readonly port: Option.Option; - readonly token: Option.Option; + readonly host: Option.Option; + readonly stateDir: Option.Option; + readonly devUrl: Option.Option; + readonly noBrowser: Option.Option; + readonly authToken: Option.Option; } export interface CliConfigShape { @@ -60,6 +65,7 @@ const CliEnvConfig = Config.all({ ), ), port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), + host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), stateDir: Config.string("T3CODE_STATE_DIR").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -88,27 +94,39 @@ const ServerConfigLive = (input: CliInput) => ), ); + const mode = Option.getOrElse(input.mode, () => env.mode); + const port = yield* Option.match(input.port, { onSome: (value) => Effect.succeed(value), onNone: () => { if (env.port) { return Effect.succeed(env.port); } - if (env.mode === "desktop") { + if (mode === "desktop") { return Effect.succeed(DEFAULT_PORT); } return findAvailablePort(DEFAULT_PORT); }, }); - const stateDir = yield* resolveStateDir(env.stateDir); - const devUrl = env.devUrl; - const noBrowser = env.noBrowser ?? env.mode === "desktop"; - const authToken = Option.getOrUndefined(input.token) ?? env.authToken; + const stateDir = yield* resolveStateDir( + Option.getOrUndefined(input.stateDir) ?? env.stateDir, + ); + const devUrl = Option.getOrElse(input.devUrl, () => env.devUrl); + const noBrowser = Option.match(input.noBrowser, { + // effect/cli boolean flags parse to `false` when absent; in that case + // we still want env/mode fallbacks to apply. + onSome: (value) => (value ? true : (env.noBrowser ?? mode === "desktop")), + onNone: () => env.noBrowser ?? mode === "desktop", + }); + const authToken = Option.getOrUndefined(input.authToken) ?? env.authToken; const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; - const host = env.mode === "desktop" ? "127.0.0.1" : undefined; + const host = + Option.getOrUndefined(input.host) ?? + env.host ?? + (mode === "desktop" ? "127.0.0.1" : undefined); return { - mode: env.mode, + mode, port, cwd: cliConfig.cwd, host, @@ -129,6 +147,12 @@ const LayerLive = (input: CliInput) => Layer.provideMerge(ServerConfigLive(input)), ); +const isWildcardHost = (host: string | undefined): boolean => + host === "0.0.0.0" || host === "::" || host === "[::]"; + +const formatHostForUrl = (host: string): string => + host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; + const makeServerProgram = (input: CliInput) => Effect.gen(function* () { const cliConfig = yield* CliConfig; @@ -149,9 +173,15 @@ const makeServerProgram = (input: CliInput) => yield* createServer(); - const url = `http://localhost:${config.port}`; + const localUrl = `http://localhost:${config.port}`; + const bindUrl = + config.host && !isWildcardHost(config.host) + ? `http://${formatHostForUrl(config.host)}:${config.port}` + : localUrl; yield* Effect.logInfo("T3 Code running", { - url, + url: bindUrl, + localUrl, + bindHost: config.host ?? "default", cwd: config.cwd, mode: config.mode, stateDir: config.stateDir, @@ -159,7 +189,7 @@ const makeServerProgram = (input: CliInput) => }); if (!config.noBrowser) { - const target = config.devUrl?.toString() ?? url; + const target = config.devUrl?.toString() ?? bindUrl; yield* openDeps.openBrowser(target).pipe( Effect.catch(() => Effect.logInfo("browser auto-open unavailable", { @@ -172,22 +202,51 @@ const makeServerProgram = (input: CliInput) => return yield* stopSignal; }).pipe(Effect.provide(LayerLive(input))); -export function makeCliCommand() { - const portFlag = Flag.integer("port").pipe( - Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), - Flag.withDescription("Port for the HTTP/WebSocket server."), - Flag.optional, - ); - const tokenFlag = Flag.string("token").pipe( - Flag.withDescription("Auth token required for WebSocket connections."), - Flag.optional, - ); +/** + * These flags mirrors the environment variables and the config shape. + */ - return Command.make("t3", { - port: portFlag, - token: tokenFlag, - }).pipe( - Command.withDescription("Run the T3 Code server."), - Command.withHandler((input) => Effect.scoped(makeServerProgram(input))), - ); -} +const modeFlag = Flag.choice("mode", ["web", "desktop"]).pipe( + Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), + Flag.optional, +); +const portFlag = Flag.integer("port").pipe( + Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), + Flag.withDescription("Port for the HTTP/WebSocket server."), + Flag.optional, +); +const hostFlag = Flag.string("host").pipe( + Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."), + Flag.optional, +); +const stateDirFlag = Flag.string("state-dir").pipe( + Flag.withDescription("State directory path (equivalent to T3CODE_STATE_DIR)."), + Flag.optional, +); +const devUrlFlag = Flag.string("dev-url").pipe( + Flag.withSchema(Schema.URLFromString), + Flag.withDescription("Dev web URL to proxy/redirect to (equivalent to VITE_DEV_SERVER_URL)."), + Flag.optional, +); +const noBrowserFlag = Flag.boolean("no-browser").pipe( + Flag.withDescription("Disable automatic browser opening."), + Flag.optional, +); +const authTokenFlag = Flag.string("auth-token").pipe( + Flag.withDescription("Auth token required for WebSocket connections."), + Flag.withAlias("token"), + Flag.optional, +); + +export const t3Cli = Command.make("t3", { + mode: modeFlag, + port: portFlag, + host: hostFlag, + stateDir: stateDirFlag, + devUrl: devUrlFlag, + noBrowser: noBrowserFlag, + authToken: authTokenFlag, +}).pipe( + Command.withDescription("Run the T3 Code server."), + Command.withHandler((input) => Effect.scoped(makeServerProgram(input))), +); -- 2.54.0