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";
33import * as Effect from "effect/Effect";
44import * as Layer from "effect/Layer";
55
6import { CliConfig, makeCliCommand } from "./main";
6import { CliConfig, t3Cli } from "./main";
77import { OpenLive } from "./open";
88import { Command } from "effect/unstable/cli";
99import { version } from "../package.json" with { type: "json" };
......@@ -18,4 +18,4 @@ const RuntimeLayer = Layer.empty.pipe(
1818 Layer.provideMerge(NodeServices.layer),
1919);
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";
77import * as Command from "effect/unstable/cli/Command";
88import { beforeEach } from "vitest";
99
10import { CliConfig, makeCliCommand, type CliConfigShape } from "./main";
10import { CliConfig, t3Cli, type CliConfigShape } from "./main";
1111import { NetService, ServerConfig, type ServerConfigShape } from "./config";
1212import { Open, type OpenShape } from "./open";
1313import { Server, type ServerShape, type ServerOptions } from "./wsServer";
......@@ -51,7 +51,7 @@ const runCli = (
5151 args: ReadonlyArray<string>,
5252 env: Record<string, string> = { T3CODE_NO_BROWSER: "true" },
5353) =>
54 Command.runWith(makeCliCommand(), { version: "0.0.0-test" })(args).pipe(
54 Command.runWith(t3Cli, { version: "0.0.0-test" })(args).pipe(
5555 Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))),
5656 );
5757
......@@ -74,33 +74,97 @@ beforeEach(() => {
7474});
7575
7676it.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", () =>
7878 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
8195 assert.equal(createServer.mock.calls.length, 1);
96 assert.equal(resolvedConfig?.mode, "desktop");
8297 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");
84103 assert.equal(start.mock.calls.length, 1);
85104 assert.equal(stop.mock.calls.length, 1);
86105 }),
87106 );
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
89117 it.effect("uses env fallbacks when flags are not provided", () =>
90118 Effect.gen(function* () {
91119 yield* runCli([], {
92 T3CODE_NO_BROWSER: "true",
120 T3CODE_MODE: "desktop",
93121 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",
94126 T3CODE_AUTH_TOKEN: "env-token",
95127 });
96128
97129 assert.equal(createServer.mock.calls.length, 1);
130 assert.equal(resolvedConfig?.mode, "desktop");
98131 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);
99136 assert.equal(resolvedConfig?.authToken, "env-token");
100137 assert.equal(findAvailablePort.mock.calls.length, 0);
101138 }),
102139 );
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
104168 it.effect("uses dynamic port discovery in web mode when port is omitted", () =>
105169 Effect.gen(function* () {
106170 findAvailablePort.mockImplementation((_preferred: number) => Effect.succeed(5444));
......@@ -123,7 +187,41 @@ it.layer(testLayer)("server cli", (it) => {
123187 assert.equal(findAvailablePort.mock.calls.length, 0);
124188 assert.equal(createServer.mock.calls.length, 1);
125189 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);
126203 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);
127225 }),
128226 );
129227
apps/server/src/main.ts+89-30
......@@ -21,8 +21,13 @@ export class StartupError extends Data.TaggedError("StartupError")<{
2121}> {}
2222
2323interface CliInput {
24 readonly mode: Option.Option<RuntimeMode>;
2425 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>;
2631}
2732
2833export interface CliConfigShape {
......@@ -60,6 +65,7 @@ const CliEnvConfig = Config.all({
6065 ),
6166 ),
6267 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)),
6369 stateDir: Config.string("T3CODE_STATE_DIR").pipe(
6470 Config.option,
6571 Config.map(Option.getOrUndefined),
......@@ -88,27 +94,39 @@ const ServerConfigLive = (input: CliInput) =>
8894 ),
8995 );
9096
97 const mode = Option.getOrElse(input.mode, () => env.mode);
98
9199 const port = yield* Option.match(input.port, {
92100 onSome: (value) => Effect.succeed(value),
93101 onNone: () => {
94102 if (env.port) {
95103 return Effect.succeed(env.port);
96104 }
97 if (env.mode === "desktop") {
105 if (mode === "desktop") {
98106 return Effect.succeed(DEFAULT_PORT);
99107 }
100108 return findAvailablePort(DEFAULT_PORT);
101109 },
102110 });
103 const stateDir = yield* resolveStateDir(env.stateDir);
104 const devUrl = env.devUrl;
105 const noBrowser = env.noBrowser ?? env.mode === "desktop";
106 const authToken = Option.getOrUndefined(input.token) ?? env.authToken;
111 const stateDir = yield* resolveStateDir(
112 Option.getOrUndefined(input.stateDir) ?? env.stateDir,
113 );
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;
107122 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
110128 return {
111 mode: env.mode,
129 mode,
112130 port,
113131 cwd: cliConfig.cwd,
114132 host,
......@@ -129,6 +147,12 @@ const LayerLive = (input: CliInput) =>
129147 Layer.provideMerge(ServerConfigLive(input)),
130148 );
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
132156const makeServerProgram = (input: CliInput) =>
133157 Effect.gen(function* () {
134158 const cliConfig = yield* CliConfig;
......@@ -149,9 +173,15 @@ const makeServerProgram = (input: CliInput) =>
149173
150174 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;
153181 yield* Effect.logInfo("T3 Code running", {
154 url,
182 url: bindUrl,
183 localUrl,
184 bindHost: config.host ?? "default",
155185 cwd: config.cwd,
156186 mode: config.mode,
157187 stateDir: config.stateDir,
......@@ -159,7 +189,7 @@ const makeServerProgram = (input: CliInput) =>
159189 });
160190
161191 if (!config.noBrowser) {
162 const target = config.devUrl?.toString() ?? url;
192 const target = config.devUrl?.toString() ?? bindUrl;
163193 yield* openDeps.openBrowser(target).pipe(
164194 Effect.catch(() =>
165195 Effect.logInfo("browser auto-open unavailable", {
......@@ -172,22 +202,51 @@ const makeServerProgram = (input: CliInput) =>
172202 return yield* stopSignal;
173203 }).pipe(Effect.provide(LayerLive(input)));
174204
175export function makeCliCommand() {
176 const portFlag = Flag.integer("port").pipe(
177 Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))),
178 Flag.withDescription("Port for the HTTP/WebSocket server."),
179 Flag.optional,
180 );
181 const tokenFlag = Flag.string("token").pipe(
182 Flag.withDescription("Auth token required for WebSocket connections."),
183 Flag.optional,
184 );
185
186 return Command.make("t3", {
187 port: portFlag,
188 token: tokenFlag,
189 }).pipe(
190 Command.withDescription("Run the T3 Code server."),
191 Command.withHandler((input) => Effect.scoped(makeServerProgram(input))),
192 );
193}
205/**
206 * These flags mirrors the environment variables and the config shape.
207 */
208
209const modeFlag = Flag.choice("mode", ["web", "desktop"]).pipe(
210 Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."),
211 Flag.optional,
212);
213const portFlag = Flag.integer("port").pipe(
214 Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))),
215 Flag.withDescription("Port for the HTTP/WebSocket server."),
216 Flag.optional,
217);
218const hostFlag = Flag.string("host").pipe(
219 Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."),
220 Flag.optional,
221);
222const stateDirFlag = Flag.string("state-dir").pipe(
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);