authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-26 18:01:14-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 18:40:23-08:00
log0451b503a387692339c4e6633580d69009c21740
tree49214ad4a31ce856f61f7ec5099d87bbf7347502
parent5ec8c9f79d4a0676ad9107b6c328c2a388b2d825

Isolate dev state and forward server flags in dev runner

- default root dev commands to `T3CODE_STATE_DIR=~/.t3/dev` - add `--` flag forwarding in `scripts/dev-runner.mjs` for server env flags - resolve desktop state dir from `T3CODE_STATE_DIR` with new tests for desktop and dev runner

6 files changed, 285 insertions(+), 33 deletions(-)

README.md+3
......@@ -68,6 +68,9 @@ npx t3
6868- `bun run dev` — Starts contracts, server, and web in `turbo watch` mode.
6969- `bun run dev:server` — Starts just the WebSocket server (uses Bun TypeScript execution).
7070- `bun run dev:web` — Starts just the Vite dev server for the web app.
71- Dev commands default `T3CODE_STATE_DIR` to `~/.t3/dev` to keep dev state isolated from desktop/prod state.
72- Override server CLI-equivalent flags from root dev commands with `--`, for example:
73 `bun run dev -- --state-dir ~/.t3/another-dev-state`
7174- `bun run start` — Runs the production server (serves built web app as static files).
7275- `bun run build` — Builds contracts, web app, and server through Turbo.
7376- `bun run typecheck` — Strict TypeScript checks for all packages.
apps/desktop/src/main.ts+2-1
......@@ -18,6 +18,7 @@ import {
1818
1919import { showDesktopConfirmDialog } from "./confirmDialog";
2020import { fixPath } from "./fixPath";
21import { resolveDesktopStateDir } from "./stateDir";
2122
2223fixPath();
2324
......@@ -26,7 +27,7 @@ const CONFIRM_CHANNEL = "desktop:confirm";
2627const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
2728const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
2829const MENU_ACTION_CHANNEL = "desktop:menu-action";
29const STATE_DIR = path.join(os.homedir(), ".t3", "userdata");
30const STATE_DIR = resolveDesktopStateDir();
3031const DESKTOP_SCHEME = "t3";
3132const ROOT_DIR = path.resolve(__dirname, "../../..");
3233const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
apps/desktop/src/stateDir.test.ts created+23
......@@ -0,0 +1,23 @@
1import os from "node:os";
2import path from "node:path";
3
4import { describe, expect, it } from "vitest";
5
6import { DEFAULT_DESKTOP_STATE_DIR, resolveDesktopStateDir } from "./stateDir";
7
8describe("resolveDesktopStateDir", () => {
9 it("returns env override when provided", () => {
10 const resolved = resolveDesktopStateDir({
11 T3CODE_STATE_DIR: " /tmp/t3-dev-state ",
12 });
13
14 expect(resolved).toBe("/tmp/t3-dev-state");
15 });
16
17 it("falls back to default userdata dir when override is missing", () => {
18 const resolved = resolveDesktopStateDir({});
19
20 expect(resolved).toBe(DEFAULT_DESKTOP_STATE_DIR);
21 expect(resolved).toBe(path.join(os.homedir(), ".t3", "userdata"));
22 });
23});
apps/desktop/src/stateDir.ts created+12
......@@ -0,0 +1,12 @@
1import os from "node:os";
2import path from "node:path";
3
4export const DEFAULT_DESKTOP_STATE_DIR = path.join(os.homedir(), ".t3", "userdata");
5
6export function resolveDesktopStateDir(env: NodeJS.ProcessEnv = process.env): string {
7 const value = env.T3CODE_STATE_DIR?.trim();
8 if (value) {
9 return value;
10 }
11 return DEFAULT_DESKTOP_STATE_DIR;
12}
scripts/dev-runner.mjs+159-32
......@@ -1,8 +1,12 @@
11import { spawn } from "node:child_process";
2import { homedir } from "node:os";
3import path from "node:path";
4import { pathToFileURL } from "node:url";
25
36const BASE_SERVER_PORT = 3773;
47const BASE_WEB_PORT = 5173;
58const MAX_HASH_OFFSET = 3000;
9export const DEFAULT_DEV_STATE_DIR = path.join(homedir(), ".t3", "dev");
610const MODE_ARGS = {
711 dev: [
812 "run",
......@@ -17,6 +21,21 @@ const MODE_ARGS = {
1721 "dev:web": ["run", "dev", "--filter=@t3tools/web"],
1822 "dev:desktop": ["run", "dev", "--filter=@t3tools/desktop", "--filter=@t3tools/web", "--parallel"],
1923};
24const FORWARDED_ENV_FLAGS = {
25 "state-dir": { envName: "T3CODE_STATE_DIR", expectsValue: true },
26 "auth-token": { envName: "T3CODE_AUTH_TOKEN", expectsValue: true },
27 token: { envName: "T3CODE_AUTH_TOKEN", expectsValue: true },
28 "no-browser": { envName: "T3CODE_NO_BROWSER", expectsValue: false },
29 "auto-bootstrap-project-from-cwd": {
30 envName: "T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD",
31 expectsValue: false,
32 },
33 "log-websocket-events": { envName: "T3CODE_LOG_WS_EVENTS", expectsValue: false },
34 "log-ws-events": { envName: "T3CODE_LOG_WS_EVENTS", expectsValue: false },
35 host: { envName: "T3CODE_HOST", expectsValue: true },
36 port: { envName: "T3CODE_PORT", expectsValue: true },
37 "dev-url": { envName: "VITE_DEV_SERVER_URL", expectsValue: true },
38};
2039
2140function parseInteger(value, envName) {
2241 const parsed = Number(value);
......@@ -35,8 +54,8 @@ function hashSeed(seed) {
3554 return hash >>> 0;
3655}
3756
38function resolveOffset() {
39 const explicitOffset = process.env.T3CODE_PORT_OFFSET?.trim();
57export function resolveOffset(env = process.env) {
58 const explicitOffset = env.T3CODE_PORT_OFFSET?.trim();
4059 if (explicitOffset) {
4160 const parsed = parseInteger(explicitOffset, "T3CODE_PORT_OFFSET");
4261 if (parsed < 0) {
......@@ -45,7 +64,7 @@ function resolveOffset() {
4564 return { offset: parsed, source: `T3CODE_PORT_OFFSET=${explicitOffset}` };
4665 }
4766
48 const seed = process.env.T3CODE_DEV_INSTANCE?.trim();
67 const seed = env.T3CODE_DEV_INSTANCE?.trim();
4968 if (!seed) {
5069 return { offset: 0, source: "default ports" };
5170 }
......@@ -58,55 +77,159 @@ function resolveOffset() {
5877 return { offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` };
5978}
6079
61function main() {
62 const mode = process.argv[2];
63 const isDryRun = process.argv.includes("--dry-run");
64 if (!mode || !(mode in MODE_ARGS)) {
65 const supportedModes = Object.keys(MODE_ARGS).join(", ");
66 throw new Error(`Usage: bun scripts/dev-runner.mjs <mode>. Supported modes: ${supportedModes}`);
80function parseLongFlag(raw) {
81 if (!raw.startsWith("--")) return null;
82 const normalized = raw.slice(2);
83 const equalsIndex = normalized.indexOf("=");
84 if (equalsIndex === -1) {
85 return { name: normalized, value: undefined };
6786 }
87 return {
88 name: normalized.slice(0, equalsIndex),
89 value: normalized.slice(equalsIndex + 1),
90 };
91}
6892
69 const { offset, source } = resolveOffset();
70 const serverPort = BASE_SERVER_PORT + offset;
71 const webPort = BASE_WEB_PORT + offset;
93function toBooleanEnvValue(value) {
94 if (value === undefined) return "1";
95 const normalized = value.trim().toLowerCase();
96 if (normalized === "false" || normalized === "0" || normalized === "no") return "0";
97 return "1";
98}
7299
73 if (serverPort > 65535 || webPort > 65535) {
74 throw new Error(
75 `Port offset too large (${offset}). Computed ports: server=${serverPort}, web=${webPort}`,
76 );
100export function parseDevRunnerArgs(rawArgs) {
101 const args = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs.slice();
102 const envOverrides = {};
103 const turboArgs = [];
104 let isDryRun = false;
105
106 for (let index = 0; index < args.length; index += 1) {
107 const arg = args[index];
108 if (arg === "--dry-run") {
109 isDryRun = true;
110 continue;
111 }
112
113 if (arg === "--") {
114 turboArgs.push(...args.slice(index + 1));
115 break;
116 }
117
118 const parsedFlag = parseLongFlag(arg);
119 if (!parsedFlag) {
120 turboArgs.push(arg);
121 continue;
122 }
123
124 const definition = FORWARDED_ENV_FLAGS[parsedFlag.name];
125 if (!definition) {
126 turboArgs.push(arg);
127 continue;
128 }
129
130 if (definition.expectsValue) {
131 let value = parsedFlag.value;
132 if (value === undefined) {
133 const next = args[index + 1];
134 if (next === undefined) {
135 throw new Error(`Missing value for --${parsedFlag.name}`);
136 }
137 value = next;
138 index += 1;
139 }
140 envOverrides[definition.envName] = value;
141 continue;
142 }
143
144 envOverrides[definition.envName] = toBooleanEnvValue(parsedFlag.value);
77145 }
78146
79 const env = {
80 ...process.env,
147 return {
148 isDryRun,
149 envOverrides,
150 turboArgs,
151 };
152}
153
154function resolveStateDir(envOverrides) {
155 return envOverrides.T3CODE_STATE_DIR ?? DEFAULT_DEV_STATE_DIR;
156}
157
158export function createDevRunnerEnv({ mode, env, offset, envOverrides }) {
159 const serverPort = BASE_SERVER_PORT + offset;
160 const webPort = BASE_WEB_PORT + offset;
161
162 const output = {
163 ...env,
81164 T3CODE_PORT: String(serverPort),
82165 PORT: String(webPort),
83166 ELECTRON_RENDERER_PORT: String(webPort),
84167 VITE_WS_URL: `ws://localhost:${serverPort}`,
85168 VITE_DEV_SERVER_URL: `http://localhost:${webPort}`,
169 ...envOverrides,
86170 };
87171
172 output.T3CODE_STATE_DIR = resolveStateDir(envOverrides);
173
174 const parsedServerPort = Number(output.T3CODE_PORT);
175 if (Number.isInteger(parsedServerPort) && parsedServerPort > 0 && parsedServerPort <= 65535) {
176 output.VITE_WS_URL = `ws://localhost:${parsedServerPort}`;
177 }
178
88179 if (mode === "dev" || mode === "dev:server" || mode === "dev:web") {
89180 // Running server/web in browser mode should not inherit desktop launcher state.
90 env.T3CODE_MODE = "web";
91 delete env.T3CODE_NO_BROWSER;
92 delete env.T3CODE_AUTH_TOKEN;
93 delete env.T3CODE_DESKTOP_WS_URL;
181 output.T3CODE_MODE = "web";
182 if (!("T3CODE_NO_BROWSER" in envOverrides)) {
183 delete output.T3CODE_NO_BROWSER;
184 }
185 if (!("T3CODE_AUTH_TOKEN" in envOverrides)) {
186 delete output.T3CODE_AUTH_TOKEN;
187 }
188 delete output.T3CODE_DESKTOP_WS_URL;
189 }
190
191 if (mode === "dev" && !output.T3CODE_LOG_WS_EVENTS) {
192 output.T3CODE_LOG_WS_EVENTS = "1";
193 }
194
195 return output;
196}
197
198export function runDevRunner(argv = process.argv) {
199 const mode = argv[2];
200 const parsedArgs = parseDevRunnerArgs(argv.slice(3));
201 if (!mode || !(mode in MODE_ARGS)) {
202 const supportedModes = Object.keys(MODE_ARGS).join(", ");
203 throw new Error(`Usage: bun scripts/dev-runner.mjs <mode>. Supported modes: ${supportedModes}`);
94204 }
95205
96 if (mode === "dev" && !env.T3CODE_LOG_WS_EVENTS) {
97 env.T3CODE_LOG_WS_EVENTS = "1";
206 const { offset, source } = resolveOffset();
207 const serverPort = BASE_SERVER_PORT + offset;
208 const webPort = BASE_WEB_PORT + offset;
209
210 if (serverPort > 65535 || webPort > 65535) {
211 throw new Error(
212 `Port offset too large (${offset}). Computed ports: server=${serverPort}, web=${webPort}`,
213 );
98214 }
99215
216 const env = createDevRunnerEnv({
217 mode,
218 env: process.env,
219 offset,
220 envOverrides: parsedArgs.envOverrides,
221 });
222
100223 console.info(
101 `[dev-runner] mode=${mode} source=${source} serverPort=${serverPort} webPort=${webPort}`,
224 `[dev-runner] mode=${mode} source=${source} serverPort=${serverPort} webPort=${webPort} stateDir=${env.T3CODE_STATE_DIR}`,
102225 );
103226
104 if (isDryRun) {
227 if (parsedArgs.isDryRun) {
105228 return;
106229 }
107230
108231 const command = process.platform === "win32" ? "turbo.cmd" : "turbo";
109 const child = spawn(command, MODE_ARGS[mode], {
232 const child = spawn(command, [...MODE_ARGS[mode], ...parsedArgs.turboArgs], {
110233 stdio: "inherit",
111234 env,
112235 });
......@@ -125,9 +248,13 @@ function main() {
125248 });
126249}
127250
128try {
129 main();
130} catch (error) {
131 console.error("[dev-runner]", error);
132 process.exit(1);
251const entrypointPath = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
252
253if (import.meta.url === entrypointPath) {
254 try {
255 runDevRunner();
256 } catch (error) {
257 console.error("[dev-runner]", error);
258 process.exit(1);
259 }
133260}
scripts/dev-runner.test.ts created+86
......@@ -0,0 +1,86 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 DEFAULT_DEV_STATE_DIR,
5 createDevRunnerEnv,
6 parseDevRunnerArgs,
7} from "./dev-runner.mjs";
8
9describe("parseDevRunnerArgs", () => {
10 it("maps supported server flags to env overrides and keeps other flags for turbo", () => {
11 const parsed = parseDevRunnerArgs([
12 "--",
13 "--state-dir",
14 "~/alt-state",
15 "--token=abc123",
16 "--no-browser=false",
17 "--dry-run",
18 "--continue",
19 ]);
20
21 expect(parsed.isDryRun).toBe(true);
22 expect(parsed.envOverrides).toEqual({
23 T3CODE_STATE_DIR: "~/alt-state",
24 T3CODE_AUTH_TOKEN: "abc123",
25 T3CODE_NO_BROWSER: "0",
26 });
27 expect(parsed.turboArgs).toEqual(["--continue"]);
28 });
29
30 it("throws when a value flag is missing its value", () => {
31 expect(() => parseDevRunnerArgs(["--state-dir"])).toThrow("Missing value for --state-dir");
32 });
33});
34
35describe("createDevRunnerEnv", () => {
36 it("defaults state dir to ~/.t3/dev when not provided", () => {
37 const env = createDevRunnerEnv({
38 mode: "dev",
39 env: { T3CODE_STATE_DIR: "/tmp/prod-state" },
40 offset: 0,
41 envOverrides: {},
42 });
43
44 expect(env.T3CODE_STATE_DIR).toBe(DEFAULT_DEV_STATE_DIR);
45 });
46
47 it("lets --state-dir override existing env state dir", () => {
48 const env = createDevRunnerEnv({
49 mode: "dev:server",
50 env: { T3CODE_STATE_DIR: "/tmp/existing-state" },
51 offset: 0,
52 envOverrides: { T3CODE_STATE_DIR: "/tmp/override-state" },
53 });
54
55 expect(env.T3CODE_STATE_DIR).toBe("/tmp/override-state");
56 });
57
58 it("recomputes websocket url when port is overridden", () => {
59 const env = createDevRunnerEnv({
60 mode: "dev",
61 env: {},
62 offset: 0,
63 envOverrides: { T3CODE_PORT: "4222" },
64 });
65
66 expect(env.T3CODE_PORT).toBe("4222");
67 expect(env.VITE_WS_URL).toBe("ws://localhost:4222");
68 });
69
70 it("keeps explicitly forwarded web-mode flags", () => {
71 const env = createDevRunnerEnv({
72 mode: "dev",
73 env: {
74 T3CODE_AUTH_TOKEN: "desktop-token",
75 },
76 offset: 0,
77 envOverrides: {
78 T3CODE_NO_BROWSER: "1",
79 T3CODE_AUTH_TOKEN: "cli-token",
80 },
81 });
82
83 expect(env.T3CODE_NO_BROWSER).toBe("1");
84 expect(env.T3CODE_AUTH_TOKEN).toBe("cli-token");
85 });
86});