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...@@ -68,6 +68,9 @@ npx t3
68- `bun run dev` — Starts contracts, server, and web in `turbo watch` mode.68- `bun run dev` — Starts contracts, server, and web in `turbo watch` mode.
69- `bun run dev:server` — Starts just the WebSocket server (uses Bun TypeScript execution).69- `bun run dev:server` — Starts just the WebSocket server (uses Bun TypeScript execution).
70- `bun run dev:web` — Starts just the Vite dev server for the web app.70- `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`
71- `bun run start` — Runs the production server (serves built web app as static files).74- `bun run start` — Runs the production server (serves built web app as static files).
72- `bun run build` — Builds contracts, web app, and server through Turbo.75- `bun run build` — Builds contracts, web app, and server through Turbo.
73- `bun run typecheck` — Strict TypeScript checks for all packages.76- `bun run typecheck` — Strict TypeScript checks for all packages.
apps/desktop/src/main.ts+2-1
...@@ -18,6 +18,7 @@ import {...@@ -18,6 +18,7 @@ import {
1818
19import { showDesktopConfirmDialog } from "./confirmDialog";19import { showDesktopConfirmDialog } from "./confirmDialog";
20import { fixPath } from "./fixPath";20import { fixPath } from "./fixPath";
21import { resolveDesktopStateDir } from "./stateDir";
2122
22fixPath();23fixPath();
2324
...@@ -26,7 +27,7 @@ const CONFIRM_CHANNEL = "desktop:confirm";...@@ -26,7 +27,7 @@ const CONFIRM_CHANNEL = "desktop:confirm";
26const CONTEXT_MENU_CHANNEL = "desktop:context-menu";27const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
27const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";28const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
28const MENU_ACTION_CHANNEL = "desktop:menu-action";29const MENU_ACTION_CHANNEL = "desktop:menu-action";
29const STATE_DIR = path.join(os.homedir(), ".t3", "userdata");30const STATE_DIR = resolveDesktopStateDir();
30const DESKTOP_SCHEME = "t3";31const DESKTOP_SCHEME = "t3";
31const ROOT_DIR = path.resolve(__dirname, "../../..");32const ROOT_DIR = path.resolve(__dirname, "../../..");
32const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);33const 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 @@...@@ -1,8 +1,12 @@
1import { spawn } from "node:child_process";1import { spawn } from "node:child_process";
2import { homedir } from "node:os";
3import path from "node:path";
4import { pathToFileURL } from "node:url";
25
3const BASE_SERVER_PORT = 3773;6const BASE_SERVER_PORT = 3773;
4const BASE_WEB_PORT = 5173;7const BASE_WEB_PORT = 5173;
5const MAX_HASH_OFFSET = 3000;8const MAX_HASH_OFFSET = 3000;
9export const DEFAULT_DEV_STATE_DIR = path.join(homedir(), ".t3", "dev");
6const MODE_ARGS = {10const MODE_ARGS = {
7 dev: [11 dev: [
8 "run",12 "run",
...@@ -17,6 +21,21 @@ const MODE_ARGS = {...@@ -17,6 +21,21 @@ const MODE_ARGS = {
17 "dev:web": ["run", "dev", "--filter=@t3tools/web"],21 "dev:web": ["run", "dev", "--filter=@t3tools/web"],
18 "dev:desktop": ["run", "dev", "--filter=@t3tools/desktop", "--filter=@t3tools/web", "--parallel"],22 "dev:desktop": ["run", "dev", "--filter=@t3tools/desktop", "--filter=@t3tools/web", "--parallel"],
19};23};
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
21function parseInteger(value, envName) {40function parseInteger(value, envName) {
22 const parsed = Number(value);41 const parsed = Number(value);
...@@ -35,8 +54,8 @@ function hashSeed(seed) {...@@ -35,8 +54,8 @@ function hashSeed(seed) {
35 return hash >>> 0;54 return hash >>> 0;
36}55}
3756
38function resolveOffset() {57export function resolveOffset(env = process.env) {
39 const explicitOffset = process.env.T3CODE_PORT_OFFSET?.trim();58 const explicitOffset = env.T3CODE_PORT_OFFSET?.trim();
40 if (explicitOffset) {59 if (explicitOffset) {
41 const parsed = parseInteger(explicitOffset, "T3CODE_PORT_OFFSET");60 const parsed = parseInteger(explicitOffset, "T3CODE_PORT_OFFSET");
42 if (parsed < 0) {61 if (parsed < 0) {
...@@ -45,7 +64,7 @@ function resolveOffset() {...@@ -45,7 +64,7 @@ function resolveOffset() {
45 return { offset: parsed, source: `T3CODE_PORT_OFFSET=${explicitOffset}` };64 return { offset: parsed, source: `T3CODE_PORT_OFFSET=${explicitOffset}` };
46 }65 }
4766
48 const seed = process.env.T3CODE_DEV_INSTANCE?.trim();67 const seed = env.T3CODE_DEV_INSTANCE?.trim();
49 if (!seed) {68 if (!seed) {
50 return { offset: 0, source: "default ports" };69 return { offset: 0, source: "default ports" };
51 }70 }
...@@ -58,55 +77,159 @@ function resolveOffset() {...@@ -58,55 +77,159 @@ function resolveOffset() {
58 return { offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` };77 return { offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` };
59}78}
6079
61function main() {80function parseLongFlag(raw) {
62 const mode = process.argv[2];81 if (!raw.startsWith("--")) return null;
63 const isDryRun = process.argv.includes("--dry-run");82 const normalized = raw.slice(2);
64 if (!mode || !(mode in MODE_ARGS)) {83 const equalsIndex = normalized.indexOf("=");
65 const supportedModes = Object.keys(MODE_ARGS).join(", ");84 if (equalsIndex === -1) {
66 throw new Error(`Usage: bun scripts/dev-runner.mjs <mode>. Supported modes: ${supportedModes}`);85 return { name: normalized, value: undefined };
67 }86 }
87 return {
88 name: normalized.slice(0, equalsIndex),
89 value: normalized.slice(equalsIndex + 1),
90 };
91}
6892
69 const { offset, source } = resolveOffset();93function toBooleanEnvValue(value) {
70 const serverPort = BASE_SERVER_PORT + offset;94 if (value === undefined) return "1";
71 const webPort = BASE_WEB_PORT + offset;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) {100export function parseDevRunnerArgs(rawArgs) {
74 throw new Error(101 const args = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs.slice();
75 `Port offset too large (${offset}). Computed ports: server=${serverPort}, web=${webPort}`,102 const envOverrides = {};
76 );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);
77 }145 }
78146
79 const env = {147 return {
80 ...process.env,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,
81 T3CODE_PORT: String(serverPort),164 T3CODE_PORT: String(serverPort),
82 PORT: String(webPort),165 PORT: String(webPort),
83 ELECTRON_RENDERER_PORT: String(webPort),166 ELECTRON_RENDERER_PORT: String(webPort),
84 VITE_WS_URL: `ws://localhost:${serverPort}`,167 VITE_WS_URL: `ws://localhost:${serverPort}`,
85 VITE_DEV_SERVER_URL: `http://localhost:${webPort}`,168 VITE_DEV_SERVER_URL: `http://localhost:${webPort}`,
169 ...envOverrides,
86 };170 };
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
88 if (mode === "dev" || mode === "dev:server" || mode === "dev:web") {179 if (mode === "dev" || mode === "dev:server" || mode === "dev:web") {
89 // Running server/web in browser mode should not inherit desktop launcher state.180 // Running server/web in browser mode should not inherit desktop launcher state.
90 env.T3CODE_MODE = "web";181 output.T3CODE_MODE = "web";
91 delete env.T3CODE_NO_BROWSER;182 if (!("T3CODE_NO_BROWSER" in envOverrides)) {
92 delete env.T3CODE_AUTH_TOKEN;183 delete output.T3CODE_NO_BROWSER;
93 delete env.T3CODE_DESKTOP_WS_URL;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}`);
94 }204 }
95205
96 if (mode === "dev" && !env.T3CODE_LOG_WS_EVENTS) {206 const { offset, source } = resolveOffset();
97 env.T3CODE_LOG_WS_EVENTS = "1";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 );
98 }214 }
99215
216 const env = createDevRunnerEnv({
217 mode,
218 env: process.env,
219 offset,
220 envOverrides: parsedArgs.envOverrides,
221 });
222
100 console.info(223 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}`,
102 );225 );
103226
104 if (isDryRun) {227 if (parsedArgs.isDryRun) {
105 return;228 return;
106 }229 }
107230
108 const command = process.platform === "win32" ? "turbo.cmd" : "turbo";231 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], {
110 stdio: "inherit",233 stdio: "inherit",
111 env,234 env,
112 });235 });
...@@ -125,9 +248,13 @@ function main() {...@@ -125,9 +248,13 @@ function main() {
125 });248 });
126}249}
127250
128try {251const entrypointPath = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
129 main();252
130} catch (error) {253if (import.meta.url === entrypointPath) {
131 console.error("[dev-runner]", error);254 try {
132 process.exit(1);255 runDevRunner();
256 } catch (error) {
257 console.error("[dev-runner]", error);
258 process.exit(1);
259 }
133}260}
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});