authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-24 19:28:45-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
log448c48cd109bee06abecffddb3fc7130a50e3666
tree5289bbe4b091069e827a0949efeed18b401b19e6
parent470f282819079be867cdc1eaa52b0eeb632c14e7

remote support


4 files changed, 260 insertions(+), 38 deletions(-)

REMOTE.md created+65
...@@ -0,0 +1,65 @@
1# Remote Access Setup
2
3Use this when you want to open T3 Code from another device (phone, tablet, another laptop).
4
5## CLI ↔ Env option map
6
7The 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
29Remote access should use the built web app (not local Vite redirect mode).
30
31```bash
32bun run build
33TOKEN="$(openssl rand -hex 24)"
34bun run --cwd apps/server start -- --host 0.0.0.0 --port 3773 --auth-token "$TOKEN" --no-browser
35```
36
37Then open on your phone:
38
39`http://<your-machine-ip>:3773`
40
41Example:
42
43`http://192.168.1.42:3773`
44
45Notes:
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
53If you use Tailscale, you can bind directly to your Tailnet address.
54
55```bash
56TAILNET_IP="$(tailscale ip -4)"
57TOKEN="$(openssl rand -hex 24)"
58bun run --cwd apps/server start -- --host "$(tailscale ip -4)" --port 3773 --auth-token "$TOKEN" --no-browser
59```
60
61Open from any device in your tailnet:
62
63`http://<tailnet-ip>:3773`
64
65You 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";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
55
6import { CliConfig, makeCliCommand } from "./main";6import { CliConfig, t3Cli } from "./main";
7import { OpenLive } from "./open";7import { OpenLive } from "./open";
8import { Command } from "effect/unstable/cli";8import { Command } from "effect/unstable/cli";
9import { version } from "../package.json" with { type: "json" };9import { 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);
2020
21Command.run(makeCliCommand(), { version }).pipe(Effect.provide(RuntimeLayer), NodeRuntime.runMain);21Command.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";
7import * as Command from "effect/unstable/cli/Command";7import * as Command from "effect/unstable/cli/Command";
8import { beforeEach } from "vitest";8import { beforeEach } from "vitest";
99
10import { CliConfig, makeCliCommand, type CliConfigShape } from "./main";10import { CliConfig, t3Cli, type CliConfigShape } from "./main";
11import { NetService, ServerConfig, type ServerConfigShape } from "./config";11import { NetService, ServerConfig, type ServerConfigShape } from "./config";
12import { Open, type OpenShape } from "./open";12import { Open, type OpenShape } from "./open";
13import { Server, type ServerShape, type ServerOptions } from "./wsServer";13import { 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 );
5757
...@@ -74,33 +74,97 @@ beforeEach(() => {...@@ -74,33 +74,97 @@ beforeEach(() => {
74});74});
7575
76it.layer(testLayer)("server cli", (it) => {76it.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 ]);
8094
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 );
88107
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 });
96128
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 );
103140
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 );
129227
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}> {}
2222
23interface CliInput {23interface 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}
2732
28export interface CliConfigShape {33export 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 );
9096
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);
109127
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 );
131149
150const isWildcardHost = (host: string | undefined): boolean =>
151 host === "0.0.0.0" || host === "::" || host === "[::]";
152
153const formatHostForUrl = (host: string): string =>
154 host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
155
132const makeServerProgram = (input: CliInput) =>156const 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) =>
149173
150 yield* createServer();174 yield* createServer();
151175
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 });
160190
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)));
174204
175export 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,209const 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,213const portFlag = Flag.integer("port").pipe(
184 );214 Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))),
185215 Flag.withDescription("Port for the HTTP/WebSocket server."),
186 return Command.make("t3", {216 Flag.optional,
187 port: portFlag,217);
188 token: tokenFlag,218const 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 );222const stateDirFlag = Flag.string("state-dir").pipe(
193}223 Flag.withDescription("State directory path (equivalent to T3CODE_STATE_DIR)."),
224 Flag.optional,
225);
226const 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);
231const noBrowserFlag = Flag.boolean("no-browser").pipe(
232 Flag.withDescription("Disable automatic browser opening."),
233 Flag.optional,
234);
235const authTokenFlag = Flag.string("auth-token").pipe(
236 Flag.withDescription("Auth token required for WebSocket connections."),
237 Flag.withAlias("token"),
238 Flag.optional,
239);
240
241export 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);