History

remote support

Julius Marminge committed 8 months ago

Commit 448c48c1 parent 470f2824 files · +260 −38Browse files

Unified · Split

4 files
  1. REMOTE.md +65 −0
  2. apps/server/src/index.ts +2 −2
  3. apps/server/src/main.test.ts +104 −6
  4. apps/server/src/main.ts +89 −30
REMOTE.md+65−0
@@ -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"; · 2 unmodified lines
33 import * as Effect from "effect/Effect";
44 import * as Layer from "effect/Layer";
55
6−import { CliConfig, makeCliCommand } from "./main";
6+import { CliConfig, t3Cli } from "./main";
77 import { OpenLive } from "./open";
88 import { Command } from "effect/unstable/cli";
99 import { version } from "../package.json" with { type: "json" };
@@ -18,4 +18,4 @@ const RuntimeLayer = Layer.empty.pipe( · 8 unmodified lines
1818 Layer.provideMerge(NodeServices.layer),
1919 );
2020
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"; · 6 unmodified lines
77 import * as Command from "effect/unstable/cli/Command";
88 import { beforeEach } from "vitest";
99
10−import { CliConfig, makeCliCommand, type CliConfigShape } from "./main";
10+import { CliConfig, t3Cli, type CliConfigShape } from "./main";
1111 import { NetService, ServerConfig, type ServerConfigShape } from "./config";
1212 import { Open, type OpenShape } from "./open";
1313 import { Server, type ServerShape, type ServerOptions } from "./wsServer";
@@ -51,7 +51,7 @@ const runCli = ( · 37 unmodified lines
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(() => { · 16 unmodified lines
7474 });
7575
7676 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", () =>
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) => { · 16 unmodified lines
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")<{ · 20 unmodified lines
2121 }> {}
2222
2323 interface 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
2833 export interface CliConfigShape {
@@ -60,6 +65,7 @@ const CliEnvConfig = Config.all({ · 31 unmodified lines
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) => · 22 unmodified lines
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) => · 14 unmodified lines
129147 Layer.provideMerge(ServerConfigLive(input)),
130148 );
131149
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+
132156 const makeServerProgram = (input: CliInput) =>
133157 Effect.gen(function* () {
134158 const cliConfig = yield* CliConfig;
@@ -149,9 +173,15 @@ const makeServerProgram = (input: CliInput) => · 14 unmodified lines
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) => · 1 unmodified line
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) => · 6 unmodified lines
172202 return yield* stopSignal;
173203 }).pipe(Effect.provide(LayerLive(input)));
174204
175−export 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+
209+const modeFlag = Flag.choice("mode", ["web", "desktop"]).pipe(
210+ Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."),
211+ Flag.optional,
212+);
213+const 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+);
218+const 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+);
222+const stateDirFlag = Flag.string("state-dir").pipe(
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+);