authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-03 12:42:07-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-03 18:22:29-08:00
loge6ac39de75de4abc5bf13a06a3e5c618a9759c51
treee3e676c031c220bb4c84b92e05a563b5a93f2926
parentb76a0a6c913f680ad5be15bd50b890249aeae24b

Migrate dev-runner to typed Effect CLI config and env fallbacks

- Replace string-based env override plumbing with typed config/flag parsing - Split server/web offset resolution from explicit port/dev-url overrides - Refactor and expand dev-runner tests around offset/env behavior - Add `vitest` as a contracts dev dependency for test execution

4 files changed, 396 insertions(+), 425 deletions(-)

bun.lock+1
......@@ -107,6 +107,7 @@
107107 "@effect/vitest": "catalog:",
108108 "tsdown": "catalog:",
109109 "typescript": "catalog:",
110 "vitest": "catalog:",
110111 },
111112 },
112113 "packages/shared": {
packages/contracts/package.json+2-1
......@@ -30,6 +30,7 @@
3030 "@effect/language-service": "catalog:",
3131 "@effect/vitest": "catalog:",
3232 "tsdown": "catalog:",
33 "typescript": "catalog:"
33 "typescript": "catalog:",
34 "vitest": "catalog:"
3435 }
3536}
scripts/dev-runner.test.ts+204-212
......@@ -1,5 +1,5 @@
11import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
2import { assert, describe, it } from "@effect/vitest";
33import { Effect } from "effect";
44
55import {
......@@ -7,219 +7,211 @@ import {
77 createDevRunnerEnv,
88 findFirstAvailableOffset,
99 resolveModePortOffsets,
10 resolveOffset,
1011} from "./dev-runner.ts";
1112
1213it.layer(NodeServices.layer)("dev-runner", (it) => {
13 it.effect("defaults state dir to ~/.t3/dev when not provided", () =>
14 Effect.gen(function* () {
15 const env = yield* createDevRunnerEnv({
16 mode: "dev",
17 env: {},
18 offset: 0,
19 envOverrides: {},
20 });
21 const defaultStateDir = yield* DEFAULT_DEV_STATE_DIR;
22
23 assert.equal(env.T3CODE_STATE_DIR, defaultStateDir);
24 }),
25 );
26
27 it.effect("uses existing env state dir when --state-dir is not provided", () =>
28 Effect.gen(function* () {
29 const env = yield* createDevRunnerEnv({
30 mode: "dev:server",
31 env: { T3CODE_STATE_DIR: "/tmp/existing-state" },
32 offset: 0,
33 envOverrides: {},
34 });
35
36 assert.equal(env.T3CODE_STATE_DIR, "/tmp/existing-state");
37 }),
38 );
39
40 it.effect("lets --state-dir override existing env state dir", () =>
41 Effect.gen(function* () {
42 const env = yield* createDevRunnerEnv({
43 mode: "dev:server",
44 env: { T3CODE_STATE_DIR: "/tmp/existing-state" },
45 offset: 0,
46 envOverrides: { T3CODE_STATE_DIR: "/tmp/override-state" },
47 });
48
49 assert.equal(env.T3CODE_STATE_DIR, "/tmp/override-state");
50 }),
51 );
52
53 it.effect("treats whitespace-only --state-dir as missing and falls back to env/default", () =>
54 Effect.gen(function* () {
55 const env = yield* createDevRunnerEnv({
56 mode: "dev:server",
57 env: { T3CODE_STATE_DIR: "/tmp/existing-state" },
58 offset: 0,
59 envOverrides: { T3CODE_STATE_DIR: " " },
60 });
61
62 assert.equal(env.T3CODE_STATE_DIR, "/tmp/existing-state");
63 }),
64 );
65
66 it.effect("recomputes websocket url when port is overridden", () =>
67 Effect.gen(function* () {
68 const env = yield* createDevRunnerEnv({
69 mode: "dev",
70 env: {},
71 offset: 0,
72 envOverrides: { T3CODE_PORT: "4222" },
73 });
74
75 assert.equal(env.T3CODE_PORT, "4222");
76 assert.equal(env.VITE_WS_URL, "ws://localhost:4222");
77 }),
78 );
79
80 it.effect("keeps explicitly forwarded web-mode flags", () =>
81 Effect.gen(function* () {
82 const env = yield* createDevRunnerEnv({
83 mode: "dev",
84 env: {
85 T3CODE_AUTH_TOKEN: "desktop-token",
86 },
87 offset: 0,
88 envOverrides: {
89 T3CODE_NO_BROWSER: "1",
90 T3CODE_AUTH_TOKEN: "cli-token",
91 },
92 });
93
94 assert.equal(env.T3CODE_NO_BROWSER, "1");
95 assert.equal(env.T3CODE_AUTH_TOKEN, "cli-token");
96 }),
97 );
98
99 it.effect("fails fast for invalid port overrides", () =>
100 Effect.gen(function* () {
101 const error = yield* Effect.flip(
102 createDevRunnerEnv({
14 describe("resolveOffset", () => {
15 it.effect("uses explicit T3CODE_PORT_OFFSET when provided", () =>
16 Effect.sync(() => {
17 const result = resolveOffset({ portOffset: 12, devInstance: undefined });
18 assert.deepStrictEqual(result, {
19 offset: 12,
20 source: "T3CODE_PORT_OFFSET=12",
21 });
22 }),
23 );
24
25 it.effect("hashes non-numeric instance values", () =>
26 Effect.sync(() => {
27 const result = resolveOffset({ portOffset: undefined, devInstance: "feature-branch" });
28 assert.ok(result.offset >= 1);
29 assert.ok(result.offset <= 3000);
30 }),
31 );
32
33 it.effect("throws for negative port offset", () =>
34 Effect.gen(function* () {
35 const error = yield* Effect.flip(
36 Effect.try({
37 try: () => resolveOffset({ portOffset: -1, devInstance: undefined }),
38 catch: (cause) => String(cause),
39 }),
40 );
41
42 assert.ok(error.includes("Invalid T3CODE_PORT_OFFSET"));
43 }),
44 );
45 });
46
47 describe("createDevRunnerEnv", () => {
48 it.effect("defaults state dir to ~/.t3/dev when not provided", () =>
49 Effect.gen(function* () {
50 const [env, defaultStateDir] = yield* Effect.all([
51 createDevRunnerEnv({
52 mode: "dev",
53 baseEnv: {},
54 serverOffset: 0,
55 webOffset: 0,
56 stateDir: undefined,
57 authToken: undefined,
58 noBrowser: undefined,
59 autoBootstrapProjectFromCwd: undefined,
60 logWebSocketEvents: undefined,
61 host: undefined,
62 port: undefined,
63 devUrl: undefined,
64 }),
65 DEFAULT_DEV_STATE_DIR,
66 ]);
67
68 assert.equal(env.T3CODE_STATE_DIR, defaultStateDir);
69 }),
70 );
71
72 it.effect("supports explicit typed overrides", () =>
73 Effect.gen(function* () {
74 const env = yield* createDevRunnerEnv({
75 mode: "dev:server",
76 baseEnv: {},
77 serverOffset: 0,
78 webOffset: 0,
79 stateDir: "/tmp/override-state",
80 authToken: "secret",
81 noBrowser: true,
82 autoBootstrapProjectFromCwd: false,
83 logWebSocketEvents: true,
84 host: "0.0.0.0",
85 port: 4222,
86 devUrl: new URL("http://localhost:7331"),
87 });
88
89 assert.equal(env.T3CODE_STATE_DIR, "/tmp/override-state");
90 assert.equal(env.T3CODE_PORT, "4222");
91 assert.equal(env.VITE_WS_URL, "ws://localhost:4222");
92 assert.equal(env.T3CODE_NO_BROWSER, "1");
93 assert.equal(env.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD, "0");
94 assert.equal(env.T3CODE_LOG_WS_EVENTS, "1");
95 assert.equal(env.T3CODE_HOST, "0.0.0.0");
96 assert.equal(env.VITE_DEV_SERVER_URL, "http://localhost:7331/");
97 }),
98 );
99 });
100
101 describe("findFirstAvailableOffset", () => {
102 it.effect("returns the starting offset when required ports are available", () =>
103 Effect.gen(function* () {
104 const offset = yield* findFirstAvailableOffset({
105 startOffset: 0,
106 requireServerPort: true,
107 requireWebPort: true,
108 checkPortAvailability: () => Effect.succeed(true),
109 });
110
111 assert.equal(offset, 0);
112 }),
113 );
114
115 it.effect("advances until all required ports are available", () =>
116 Effect.gen(function* () {
117 const taken = new Set([3773, 5733, 3774, 5734]);
118 const offset = yield* findFirstAvailableOffset({
119 startOffset: 0,
120 requireServerPort: true,
121 requireWebPort: true,
122 checkPortAvailability: (port) => Effect.succeed(!taken.has(port)),
123 });
124
125 assert.equal(offset, 2);
126 }),
127 );
128
129 it.effect("allows offsets where only non-required ports exceed max", () =>
130 Effect.gen(function* () {
131 const offset = yield* findFirstAvailableOffset({
132 startOffset: 59_803,
133 requireServerPort: true,
134 requireWebPort: false,
135 checkPortAvailability: () => Effect.succeed(true),
136 });
137
138 assert.equal(offset, 59_803);
139 }),
140 );
141 });
142
143 describe("resolveModePortOffsets", () => {
144 it.effect("uses a shared fallback offset for dev mode", () =>
145 Effect.gen(function* () {
146 const taken = new Set([3773, 5733]);
147 const offsets = yield* resolveModePortOffsets({
103148 mode: "dev",
104 env: {},
105 offset: 0,
106 envOverrides: { T3CODE_PORT: "not-a-port" },
107 }),
108 );
109
110 assert.ok(error.message.includes("Invalid T3CODE_PORT override"));
111 }),
112 );
113});
114
115it.layer(NodeServices.layer)("findFirstAvailableOffset", (it) => {
116 it.effect("returns the starting offset when required ports are available", () =>
117 Effect.gen(function* () {
118 const offset = yield* findFirstAvailableOffset({
119 startOffset: 0,
120 requireServerPort: true,
121 requireWebPort: true,
122 checkPortAvailability: () => Effect.succeed(true),
123 });
124
125 assert.equal(offset, 0);
126 }),
127 );
128
129 it.effect("advances until all required ports are available", () =>
130 Effect.gen(function* () {
131 const taken = new Set([3773, 5733, 3774, 5734]);
132 const offset = yield* findFirstAvailableOffset({
133 startOffset: 0,
134 requireServerPort: true,
135 requireWebPort: true,
136 checkPortAvailability: (port) => Effect.succeed(!taken.has(port)),
137 });
138
139 assert.equal(offset, 2);
140 }),
141 );
142
143 it.effect("allows offsets where only non-required ports exceed max", () =>
144 Effect.gen(function* () {
145 const offset = yield* findFirstAvailableOffset({
146 startOffset: 59_803,
147 requireServerPort: true,
148 requireWebPort: false,
149 checkPortAvailability: () => Effect.succeed(true),
150 });
151
152 assert.equal(offset, 59_803);
153 }),
154 );
155});
156
157it.layer(NodeServices.layer)("resolveModePortOffsets", (it) => {
158 it.effect("uses a shared fallback offset for dev mode", () =>
159 Effect.gen(function* () {
160 const taken = new Set([3773, 5733]);
161 const offsets = yield* resolveModePortOffsets({
162 mode: "dev",
163 startOffset: 0,
164 envOverrides: {},
165 checkPortAvailability: (port) => Effect.succeed(!taken.has(port)),
166 });
167
168 assert.deepStrictEqual(offsets, { serverOffset: 1, webOffset: 1 });
169 }),
170 );
171
172 it.effect("keeps server offset stable for dev:web and only shifts web offset", () =>
173 Effect.gen(function* () {
174 const taken = new Set([5733]);
175 const offsets = yield* resolveModePortOffsets({
176 mode: "dev:web",
177 startOffset: 0,
178 envOverrides: {},
179 checkPortAvailability: (port) => Effect.succeed(!taken.has(port)),
180 });
181
182 assert.deepStrictEqual(offsets, { serverOffset: 0, webOffset: 1 });
183 }),
184 );
185
186 it.effect("shifts only server offset for dev:server", () =>
187 Effect.gen(function* () {
188 const taken = new Set([3773]);
189 const offsets = yield* resolveModePortOffsets({
190 mode: "dev:server",
191 startOffset: 0,
192 envOverrides: {},
193 checkPortAvailability: (port) => Effect.succeed(!taken.has(port)),
194 });
195
196 assert.deepStrictEqual(offsets, { serverOffset: 1, webOffset: 1 });
197 }),
198 );
199
200 it.effect("respects explicit dev-url override for dev:web", () =>
201 Effect.gen(function* () {
202 const offsets = yield* resolveModePortOffsets({
203 mode: "dev:web",
204 startOffset: 0,
205 envOverrides: { VITE_DEV_SERVER_URL: "http://localhost:9999" },
206 checkPortAvailability: () => Effect.succeed(false),
207 });
208
209 assert.deepStrictEqual(offsets, { serverOffset: 0, webOffset: 0 });
210 }),
211 );
212
213 it.effect("respects explicit server port override for dev:server", () =>
214 Effect.gen(function* () {
215 const offsets = yield* resolveModePortOffsets({
216 mode: "dev:server",
217 startOffset: 0,
218 envOverrides: { T3CODE_PORT: "4888" },
219 checkPortAvailability: () => Effect.succeed(false),
220 });
221
222 assert.deepStrictEqual(offsets, { serverOffset: 0, webOffset: 0 });
223 }),
224 );
149 startOffset: 0,
150 hasExplicitServerPort: false,
151 hasExplicitDevUrl: false,
152 checkPortAvailability: (port) => Effect.succeed(!taken.has(port)),
153 });
154
155 assert.deepStrictEqual(offsets, { serverOffset: 1, webOffset: 1 });
156 }),
157 );
158
159 it.effect("keeps server offset stable for dev:web and only shifts web offset", () =>
160 Effect.gen(function* () {
161 const taken = new Set([5733]);
162 const offsets = yield* resolveModePortOffsets({
163 mode: "dev:web",
164 startOffset: 0,
165 hasExplicitServerPort: false,
166 hasExplicitDevUrl: false,
167 checkPortAvailability: (port) => Effect.succeed(!taken.has(port)),
168 });
169
170 assert.deepStrictEqual(offsets, { serverOffset: 0, webOffset: 1 });
171 }),
172 );
173
174 it.effect("shifts only server offset for dev:server", () =>
175 Effect.gen(function* () {
176 const taken = new Set([3773]);
177 const offsets = yield* resolveModePortOffsets({
178 mode: "dev:server",
179 startOffset: 0,
180 hasExplicitServerPort: false,
181 hasExplicitDevUrl: false,
182 checkPortAvailability: (port) => Effect.succeed(!taken.has(port)),
183 });
184
185 assert.deepStrictEqual(offsets, { serverOffset: 1, webOffset: 1 });
186 }),
187 );
188
189 it.effect("respects explicit dev-url override for dev:web", () =>
190 Effect.gen(function* () {
191 const offsets = yield* resolveModePortOffsets({
192 mode: "dev:web",
193 startOffset: 0,
194 hasExplicitServerPort: false,
195 hasExplicitDevUrl: true,
196 checkPortAvailability: () => Effect.succeed(false),
197 });
198
199 assert.deepStrictEqual(offsets, { serverOffset: 0, webOffset: 0 });
200 }),
201 );
202
203 it.effect("respects explicit server port override for dev:server", () =>
204 Effect.gen(function* () {
205 const offsets = yield* resolveModePortOffsets({
206 mode: "dev:server",
207 startOffset: 0,
208 hasExplicitServerPort: true,
209 hasExplicitDevUrl: false,
210 checkPortAvailability: () => Effect.succeed(false),
211 });
212
213 assert.deepStrictEqual(offsets, { serverOffset: 0, webOffset: 0 });
214 }),
215 );
216 });
225217});
scripts/dev-runner.ts+189-212
......@@ -5,7 +5,7 @@ import { homedir } from "node:os";
55import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
66import * as NodeServices from "@effect/platform-node/NodeServices";
77import { NetService } from "@t3tools/shared/Net";
8import { Data, Effect, Layer, Logger, Option, Path } from "effect";
8import { Config, Data, Effect, Hash, Layer, Logger, Option, Path, Schema } from "effect";
99import { Argument, Command, Flag } from "effect/unstable/cli";
1010import { ChildProcess } from "effect/unstable/process";
1111
......@@ -14,11 +14,9 @@ const BASE_WEB_PORT = 5733;
1414const MAX_HASH_OFFSET = 3000;
1515const MAX_PORT = 65535;
1616
17export const DEFAULT_DEV_STATE_DIR: Effect.Effect<string, never, Path.Path> = Effect.gen(
18 function* () {
19 const path = yield* Path.Path;
20 return path.join(homedir(), ".t3", "dev");
21});
17export const DEFAULT_DEV_STATE_DIR = Effect.map(Effect.service(Path.Path), (path) =>
18 path.join(homedir(), ".t3", "dev"),
19);
2220
2321const MODE_ARGS = {
2422 dev: [
......@@ -36,7 +34,6 @@ const MODE_ARGS = {
3634} as const satisfies Record<string, ReadonlyArray<string>>;
3735
3836type DevMode = keyof typeof MODE_ARGS;
39type EnvOverrides = Record<string, string>;
4037type PortAvailabilityCheck<R = never> = (port: number) => Effect.Effect<boolean, never, R>;
4138
4239const DEV_RUNNER_MODES = Object.keys(MODE_ARGS) as Array<DevMode>;
......@@ -46,37 +43,52 @@ class DevRunnerError extends Data.TaggedError("DevRunnerError")<{
4643 readonly cause?: unknown;
4744}> {}
4845
49function parseInteger(value: string, envName: string): number {
50 const parsed = Number(value);
51 if (!Number.isInteger(parsed)) {
52 throw new Error(`Invalid ${envName}: ${value}`);
53 }
54 return parsed;
55}
46const optionalStringConfig = (name: string): Config.Config<string | undefined> =>
47 Config.string(name).pipe(
48 Config.option,
49 Config.map((value) => Option.getOrUndefined(value)),
50 );
51const optionalBooleanConfig = (name: string): Config.Config<boolean | undefined> =>
52 Config.boolean(name).pipe(
53 Config.option,
54 Config.map((value) => Option.getOrUndefined(value)),
55 );
56const optionalPortConfig = (name: string): Config.Config<number | undefined> =>
57 Config.port(name).pipe(
58 Config.option,
59 Config.map((value) => Option.getOrUndefined(value)),
60 );
61const optionalIntegerConfig = (name: string): Config.Config<number | undefined> =>
62 Config.int(name).pipe(
63 Config.option,
64 Config.map((value) => Option.getOrUndefined(value)),
65 );
66const optionalUrlConfig = (name: string): Config.Config<URL | undefined> =>
67 Config.url(name).pipe(
68 Config.option,
69 Config.map((value) => Option.getOrUndefined(value)),
70 );
5671
57function hashSeed(seed: string): number {
58 let hash = 0x811c9dc5;
59 for (let i = 0; i < seed.length; i += 1) {
60 hash ^= seed.charCodeAt(i);
61 hash = Math.imul(hash, 0x01000193);
62 }
63 return hash >>> 0;
64}
72const OffsetConfig = Config.all({
73 portOffset: optionalIntegerConfig("T3CODE_PORT_OFFSET"),
74 devInstance: optionalStringConfig("T3CODE_DEV_INSTANCE"),
75});
6576
66export function resolveOffset(env: NodeJS.ProcessEnv = process.env): {
67 readonly offset: number;
68 readonly source: string;
69} {
70 const explicitOffset = env.T3CODE_PORT_OFFSET?.trim();
71 if (explicitOffset) {
72 const parsed = parseInteger(explicitOffset, "T3CODE_PORT_OFFSET");
73 if (parsed < 0) {
74 throw new Error(`Invalid T3CODE_PORT_OFFSET: ${explicitOffset}`);
77export function resolveOffset(config: {
78 readonly portOffset: number | undefined;
79 readonly devInstance: string | undefined;
80}): { readonly offset: number; readonly source: string } {
81 if (config.portOffset !== undefined) {
82 if (config.portOffset < 0) {
83 throw new Error(`Invalid T3CODE_PORT_OFFSET: ${config.portOffset}`);
7584 }
76 return { offset: parsed, source: `T3CODE_PORT_OFFSET=${explicitOffset}` };
85 return {
86 offset: config.portOffset,
87 source: `T3CODE_PORT_OFFSET=${config.portOffset}`,
88 };
7789 }
7890
79 const seed = env.T3CODE_DEV_INSTANCE?.trim();
91 const seed = config.devInstance?.trim();
8092 if (!seed) {
8193 return { offset: 0, source: "default ports" };
8294 }
......@@ -85,32 +97,18 @@ export function resolveOffset(env: NodeJS.ProcessEnv = process.env): {
8597 return { offset: Number(seed), source: `numeric T3CODE_DEV_INSTANCE=${seed}` };
8698 }
8799
88 const offset = (hashSeed(seed) % MAX_HASH_OFFSET) + 1;
100 const offset = ((Hash.string(seed) >>> 0) % MAX_HASH_OFFSET) + 1;
89101 return { offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` };
90102}
91103
92function toBooleanEnvValue(value: string | undefined): string {
93 if (value === undefined) return "1";
94 const normalized = value.trim().toLowerCase();
95 if (normalized === "false" || normalized === "0" || normalized === "no") return "0";
96 return "1";
97}
98
99function resolveStateDir(
100 env: NodeJS.ProcessEnv,
101 envOverrides: EnvOverrides,
102): Effect.Effect<string, never, Path.Path> {
104function resolveStateDir(stateDir: string | undefined): Effect.Effect<string, never, Path.Path> {
103105 return Effect.gen(function* () {
104106 const path = yield* Path.Path;
105 const overrideValue = envOverrides?.T3CODE_STATE_DIR?.trim();
106 if (overrideValue) {
107 // Resolve relative paths against cwd (monorepo root) before turbo changes directories.
108 return path.resolve(overrideValue);
109 }
107 const configured = stateDir?.trim();
110108
111 const envValue = env?.T3CODE_STATE_DIR?.trim();
112 if (envValue) {
113 return path.resolve(envValue);
109 if (configured) {
110 // Resolve relative paths against cwd (monorepo root) before turbo changes directories.
111 return path.resolve(configured);
114112 }
115113
116114 return yield* DEFAULT_DEV_STATE_DIR;
......@@ -119,72 +117,97 @@ function resolveStateDir(
119117
120118interface CreateDevRunnerEnvInput {
121119 readonly mode: DevMode;
122 readonly env: NodeJS.ProcessEnv;
123 readonly offset: number;
124 readonly envOverrides: EnvOverrides;
120 readonly baseEnv: NodeJS.ProcessEnv;
121 readonly serverOffset: number;
122 readonly webOffset: number;
123 readonly stateDir: string | undefined;
124 readonly authToken: string | undefined;
125 readonly noBrowser: boolean | undefined;
126 readonly autoBootstrapProjectFromCwd: boolean | undefined;
127 readonly logWebSocketEvents: boolean | undefined;
128 readonly host: string | undefined;
129 readonly port: number | undefined;
130 readonly devUrl: URL | undefined;
125131}
126132
127133export function createDevRunnerEnv({
128134 mode,
129 env,
130 offset,
131 envOverrides,
132}: CreateDevRunnerEnvInput): Effect.Effect<NodeJS.ProcessEnv, DevRunnerError, Path.Path> {
135 baseEnv,
136 serverOffset,
137 webOffset,
138 stateDir,
139 authToken,
140 noBrowser,
141 autoBootstrapProjectFromCwd,
142 logWebSocketEvents,
143 host,
144 port,
145 devUrl,
146}: CreateDevRunnerEnvInput): Effect.Effect<NodeJS.ProcessEnv, never, Path.Path> {
133147 return Effect.gen(function* () {
134 const serverPort = BASE_SERVER_PORT + offset;
135 const webPort = BASE_WEB_PORT + offset;
148 const serverPort = port ?? BASE_SERVER_PORT + serverOffset;
149 const webPort = BASE_WEB_PORT + webOffset;
150 const resolvedStateDir = yield* resolveStateDir(stateDir);
136151
137152 const output: NodeJS.ProcessEnv = {
138 ...env,
153 ...baseEnv,
139154 T3CODE_PORT: String(serverPort),
140155 PORT: String(webPort),
141156 ELECTRON_RENDERER_PORT: String(webPort),
142157 VITE_WS_URL: `ws://localhost:${serverPort}`,
143 VITE_DEV_SERVER_URL: `http://localhost:${webPort}`,
144 ...envOverrides,
158 VITE_DEV_SERVER_URL: devUrl?.toString() ?? `http://localhost:${webPort}`,
159 T3CODE_STATE_DIR: resolvedStateDir,
145160 };
146161
147 output.T3CODE_STATE_DIR = yield* resolveStateDir(env, envOverrides);
148
149 const parsedServerPort = yield* Effect.try({
150 try: () => {
151 const parsed = Number(output.T3CODE_PORT);
152 if (!Number.isInteger(parsed) || parsed < 1 || parsed > MAX_PORT) {
153 throw new Error(
154 `Invalid T3CODE_PORT override: '${String(output.T3CODE_PORT)}'. Expected an integer between 1 and 65535.`,
155 );
156 }
157 return parsed;
158 },
159 catch: (cause) =>
160 new DevRunnerError({
161 message: cause instanceof Error ? cause.message : String(cause),
162 cause,
163 }),
164 });
165 output.VITE_WS_URL = `ws://localhost:${parsedServerPort}`;
162 if (host !== undefined) {
163 output.T3CODE_HOST = host;
164 }
165
166 if (authToken !== undefined) {
167 output.T3CODE_AUTH_TOKEN = authToken;
168 } else {
169 delete output.T3CODE_AUTH_TOKEN;
170 }
171
172 if (noBrowser !== undefined) {
173 output.T3CODE_NO_BROWSER = noBrowser ? "1" : "0";
174 } else {
175 delete output.T3CODE_NO_BROWSER;
176 }
177
178 if (autoBootstrapProjectFromCwd !== undefined) {
179 output.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD = autoBootstrapProjectFromCwd ? "1" : "0";
180 } else {
181 delete output.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD;
182 }
183
184 if (logWebSocketEvents !== undefined) {
185 output.T3CODE_LOG_WS_EVENTS = logWebSocketEvents ? "1" : "0";
186 } else {
187 delete output.T3CODE_LOG_WS_EVENTS;
188 }
166189
167 if (mode === "dev" || mode === "dev:server" || mode === "dev:web") {
168 // Running server/web in browser mode should not inherit desktop launcher state.
190 if (mode === "dev") {
169191 output.T3CODE_MODE = "web";
170 if (!("T3CODE_NO_BROWSER" in envOverrides)) {
171 delete output.T3CODE_NO_BROWSER;
172 }
173 if (!("T3CODE_AUTH_TOKEN" in envOverrides)) {
174 delete output.T3CODE_AUTH_TOKEN;
192 if (logWebSocketEvents === undefined) {
193 output.T3CODE_LOG_WS_EVENTS = "1";
175194 }
176195 delete output.T3CODE_DESKTOP_WS_URL;
177196 }
178197
179 if (mode === "dev" && !output.T3CODE_LOG_WS_EVENTS) {
180 output.T3CODE_LOG_WS_EVENTS = "1";
198 if (mode === "dev:server" || mode === "dev:web") {
199 output.T3CODE_MODE = "web";
200 delete output.T3CODE_DESKTOP_WS_URL;
181201 }
182202
183203 return output;
184204 });
185205}
186206
187function portPairForOffset(offset: number): { readonly serverPort: number; readonly webPort: number } {
207function portPairForOffset(offset: number): {
208 readonly serverPort: number;
209 readonly webPort: number;
210} {
188211 return {
189212 serverPort: BASE_SERVER_PORT + offset,
190213 webPort: BASE_WEB_PORT + offset,
......@@ -211,11 +234,14 @@ export function findFirstAvailableOffset<R = NetService>({
211234 checkPortAvailability,
212235}: FindFirstAvailableOffsetInput<R>): Effect.Effect<number, DevRunnerError, R> {
213236 return Effect.gen(function* () {
214 const checkPort = (checkPortAvailability ?? defaultCheckPortAvailability) as PortAvailabilityCheck<R>;
237 const checkPort = (checkPortAvailability ??
238 defaultCheckPortAvailability) as PortAvailabilityCheck<R>;
239
215240 for (let candidate = startOffset; ; candidate += 1) {
216241 const { serverPort, webPort } = portPairForOffset(candidate);
217242 const serverPortOutOfRange = serverPort > MAX_PORT;
218243 const webPortOutOfRange = webPort > MAX_PORT;
244
219245 if (
220246 (requireServerPort && serverPortOutOfRange) ||
221247 (requireWebPort && webPortOutOfRange) ||
......@@ -251,14 +277,16 @@ export function findFirstAvailableOffset<R = NetService>({
251277interface ResolveModePortOffsetsInput<R = NetService> {
252278 readonly mode: DevMode;
253279 readonly startOffset: number;
254 readonly envOverrides: EnvOverrides;
280 readonly hasExplicitServerPort: boolean;
281 readonly hasExplicitDevUrl: boolean;
255282 readonly checkPortAvailability?: PortAvailabilityCheck<R>;
256283}
257284
258285export function resolveModePortOffsets<R = NetService>({
259286 mode,
260287 startOffset,
261 envOverrides,
288 hasExplicitServerPort,
289 hasExplicitDevUrl,
262290 checkPortAvailability,
263291}: ResolveModePortOffsetsInput<R>): Effect.Effect<
264292 { readonly serverOffset: number; readonly webOffset: number },
......@@ -266,14 +294,14 @@ export function resolveModePortOffsets<R = NetService>({
266294 R
267295> {
268296 return Effect.gen(function* () {
269 const checkPort = (checkPortAvailability ?? defaultCheckPortAvailability) as PortAvailabilityCheck<R>;
270 const hasExplicitServerPort = typeof envOverrides.T3CODE_PORT === "string";
271 const hasExplicitDevUrl = typeof envOverrides.VITE_DEV_SERVER_URL === "string";
297 const checkPort = (checkPortAvailability ??
298 defaultCheckPortAvailability) as PortAvailabilityCheck<R>;
272299
273300 if (mode === "dev:web") {
274301 if (hasExplicitDevUrl) {
275302 return { serverOffset: startOffset, webOffset: startOffset };
276303 }
304
277305 const webOffset = yield* findFirstAvailableOffset({
278306 startOffset,
279307 requireServerPort: false,
......@@ -287,6 +315,7 @@ export function resolveModePortOffsets<R = NetService>({
287315 if (hasExplicitServerPort) {
288316 return { serverOffset: startOffset, webOffset: startOffset };
289317 }
318
290319 const serverOffset = yield* findFirstAvailableOffset({
291320 startOffset,
292321 requireServerPort: true,
......@@ -302,71 +331,39 @@ export function resolveModePortOffsets<R = NetService>({
302331 requireWebPort: !hasExplicitDevUrl,
303332 checkPortAvailability: checkPort,
304333 });
334
305335 return { serverOffset: sharedOffset, webOffset: sharedOffset };
306336 });
307337}
308338
309const optionValue = <A>(option: Option.Option<A>): A | undefined =>
310 Option.isSome(option) ? option.value : undefined;
311
312339interface DevRunnerCliInput {
313340 readonly mode: DevMode;
314 readonly stateDir: Option.Option<string>;
315 readonly authToken: Option.Option<string>;
316 readonly noBrowser: Option.Option<string>;
317 readonly autoBootstrapProjectFromCwd: Option.Option<string>;
318 readonly logWebSocketEvents: Option.Option<string>;
319 readonly host: Option.Option<string>;
320 readonly port: Option.Option<string>;
321 readonly devUrl: Option.Option<string>;
341 readonly stateDir: string | undefined;
342 readonly authToken: string | undefined;
343 readonly noBrowser: boolean | undefined;
344 readonly autoBootstrapProjectFromCwd: boolean | undefined;
345 readonly logWebSocketEvents: boolean | undefined;
346 readonly host: string | undefined;
347 readonly port: number | undefined;
348 readonly devUrl: URL | undefined;
322349 readonly dryRun: boolean;
323350 readonly turboArgs: ReadonlyArray<string>;
324351}
325352
326function envOverridesFromCliInput(input: DevRunnerCliInput): EnvOverrides {
327 const envOverrides: EnvOverrides = {};
328
329 const stateDir = optionValue(input.stateDir);
330 if (stateDir !== undefined) envOverrides.T3CODE_STATE_DIR = stateDir;
331
332 const authToken = optionValue(input.authToken);
333 if (authToken !== undefined) envOverrides.T3CODE_AUTH_TOKEN = authToken;
334
335 const host = optionValue(input.host);
336 if (host !== undefined) envOverrides.T3CODE_HOST = host;
337
338 const port = optionValue(input.port);
339 if (port !== undefined) envOverrides.T3CODE_PORT = port;
340
341 const devUrl = optionValue(input.devUrl);
342 if (devUrl !== undefined) envOverrides.VITE_DEV_SERVER_URL = devUrl;
343
344 const noBrowser = optionValue(input.noBrowser);
345 if (noBrowser !== undefined) {
346 envOverrides.T3CODE_NO_BROWSER = toBooleanEnvValue(noBrowser);
347 }
348
349 const autoBootstrapProjectFromCwd = optionValue(input.autoBootstrapProjectFromCwd);
350 if (autoBootstrapProjectFromCwd !== undefined) {
351 envOverrides.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD = toBooleanEnvValue(
352 autoBootstrapProjectFromCwd,
353 );
354 }
355
356 const logWebSocketEvents = optionValue(input.logWebSocketEvents);
357 if (logWebSocketEvents !== undefined) {
358 envOverrides.T3CODE_LOG_WS_EVENTS = toBooleanEnvValue(logWebSocketEvents);
359 }
360
361 return envOverrides;
362}
363
364353export function runDevRunnerWithInput(input: DevRunnerCliInput) {
365354 return Effect.gen(function* () {
366 const envOverrides = envOverridesFromCliInput(input);
367 const turboArgs = Array.from(input.turboArgs);
355 const { portOffset, devInstance } = yield* OffsetConfig.asEffect().pipe(
356 Effect.mapError(
357 (cause) =>
358 new DevRunnerError({
359 message: "Failed to read T3CODE_PORT_OFFSET/T3CODE_DEV_INSTANCE configuration.",
360 cause,
361 }),
362 ),
363 );
364
368365 const { offset, source } = yield* Effect.try({
369 try: () => resolveOffset(),
366 try: () => resolveOffset({ portOffset, devInstance }),
370367 catch: (cause) =>
371368 new DevRunnerError({
372369 message: cause instanceof Error ? cause.message : String(cause),
......@@ -377,46 +374,32 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
377374 const { serverOffset, webOffset } = yield* resolveModePortOffsets({
378375 mode: input.mode,
379376 startOffset: offset,
380 envOverrides,
377 hasExplicitServerPort: input.port !== undefined,
378 hasExplicitDevUrl: input.devUrl !== undefined,
381379 });
382380
383 const mergedEnvOverrides = { ...envOverrides };
384 if (!("VITE_DEV_SERVER_URL" in mergedEnvOverrides)) {
385 const { webPort } = portPairForOffset(webOffset);
386 mergedEnvOverrides.PORT = String(webPort);
387 mergedEnvOverrides.ELECTRON_RENDERER_PORT = String(webPort);
388 mergedEnvOverrides.VITE_DEV_SERVER_URL = `http://localhost:${webPort}`;
389 }
390
391381 const env = yield* createDevRunnerEnv({
392382 mode: input.mode,
393 env: process.env,
394 offset: serverOffset,
395 envOverrides: mergedEnvOverrides,
383 baseEnv: process.env,
384 serverOffset,
385 webOffset,
386 stateDir: input.stateDir,
387 authToken: input.authToken,
388 noBrowser: input.noBrowser,
389 autoBootstrapProjectFromCwd: input.autoBootstrapProjectFromCwd,
390 logWebSocketEvents: input.logWebSocketEvents,
391 host: input.host,
392 port: input.port,
393 devUrl: input.devUrl,
396394 });
397395
398 const parsedServerPort = Number(env.T3CODE_PORT);
399 const parsedWebPort = Number(env.PORT);
400 if (
401 !Number.isInteger(parsedServerPort) ||
402 parsedServerPort < 1 ||
403 parsedServerPort > MAX_PORT ||
404 !Number.isInteger(parsedWebPort) ||
405 parsedWebPort < 1 ||
406 parsedWebPort > MAX_PORT
407 ) {
408 return yield* new DevRunnerError({
409 message: `Invalid computed dev ports: server='${String(env.T3CODE_PORT)}' web='${String(env.PORT)}'.`,
410 });
411 }
412
413396 const selectionSuffix =
414397 serverOffset !== offset || webOffset !== offset
415398 ? ` selectedOffset(server=${serverOffset},web=${webOffset})`
416399 : "";
417400
418401 yield* Effect.logInfo(
419 `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${parsedServerPort} webPort=${parsedWebPort} stateDir=${String(env.T3CODE_STATE_DIR)}`,
402 `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} stateDir=${String(env.T3CODE_STATE_DIR)}`,
420403 );
421404
422405 if (input.dryRun) {
......@@ -424,7 +407,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
424407 }
425408
426409 const command = process.platform === "win32" ? "turbo.cmd" : "turbo";
427 const child = yield* ChildProcess.make(command, [...MODE_ARGS[input.mode], ...turboArgs], {
410 const child = yield* ChildProcess.make(command, [...MODE_ARGS[input.mode], ...input.turboArgs], {
428411 stdin: "inherit",
429412 stdout: "inherit",
430413 stderr: "inherit",
......@@ -432,7 +415,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) {
432415 extendEnv: false,
433416 });
434417
435 const exitCode = Number(yield* child.exitCode);
418 const exitCode = yield* child.exitCode;
436419 if (exitCode !== 0) {
437420 return yield* new DevRunnerError({
438421 message: `turbo exited with code ${exitCode}`,
......@@ -456,37 +439,41 @@ const devRunnerCli = Command.make("dev-runner", {
456439 ),
457440 stateDir: Flag.string("state-dir").pipe(
458441 Flag.withDescription("State directory path (forwards to T3CODE_STATE_DIR)."),
459 Flag.optional,
442 Flag.withFallbackConfig(optionalStringConfig("T3CODE_STATE_DIR")),
460443 ),
461444 authToken: Flag.string("auth-token").pipe(
462445 Flag.withDescription("Auth token (forwards to T3CODE_AUTH_TOKEN)."),
463446 Flag.withAlias("token"),
464 Flag.optional,
447 Flag.withFallbackConfig(optionalStringConfig("T3CODE_AUTH_TOKEN")),
465448 ),
466 noBrowser: Flag.string("no-browser").pipe(
467 Flag.withDescription("Browser auto-open toggle. Accepts true/false/1/0/no."),
468 Flag.optional,
449 noBrowser: Flag.boolean("no-browser").pipe(
450 Flag.withDescription("Browser auto-open toggle (equivalent to T3CODE_NO_BROWSER)."),
451 Flag.withFallbackConfig(optionalBooleanConfig("T3CODE_NO_BROWSER")),
469452 ),
470 autoBootstrapProjectFromCwd: Flag.string("auto-bootstrap-project-from-cwd").pipe(
471 Flag.withDescription("Auto-bootstrap toggle. Accepts true/false/1/0/no."),
472 Flag.optional,
453 autoBootstrapProjectFromCwd: Flag.boolean("auto-bootstrap-project-from-cwd").pipe(
454 Flag.withDescription(
455 "Auto-bootstrap toggle (equivalent to T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD).",
456 ),
457 Flag.withFallbackConfig(optionalBooleanConfig("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD")),
473458 ),
474 logWebSocketEvents: Flag.string("log-websocket-events").pipe(
475 Flag.withDescription("WebSocket event logging toggle. Accepts true/false/1/0/no."),
459 logWebSocketEvents: Flag.boolean("log-websocket-events").pipe(
460 Flag.withDescription("WebSocket event logging toggle (equivalent to T3CODE_LOG_WS_EVENTS)."),
476461 Flag.withAlias("log-ws-events"),
477 Flag.optional,
462 Flag.withFallbackConfig(optionalBooleanConfig("T3CODE_LOG_WS_EVENTS")),
478463 ),
479464 host: Flag.string("host").pipe(
480465 Flag.withDescription("Server host/interface override (forwards to T3CODE_HOST)."),
481 Flag.optional,
466 Flag.withFallbackConfig(optionalStringConfig("T3CODE_HOST")),
482467 ),
483 port: Flag.string("port").pipe(
468 port: Flag.integer("port").pipe(
469 Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))),
484470 Flag.withDescription("Server port override (forwards to T3CODE_PORT)."),
485 Flag.optional,
471 Flag.withFallbackConfig(optionalPortConfig("T3CODE_PORT")),
486472 ),
487473 devUrl: Flag.string("dev-url").pipe(
474 Flag.withSchema(Schema.URLFromString),
488475 Flag.withDescription("Web dev URL override (forwards to VITE_DEV_SERVER_URL)."),
489 Flag.optional,
476 Flag.withFallbackConfig(optionalUrlConfig("VITE_DEV_SERVER_URL")),
490477 ),
491478 dryRun: Flag.boolean("dry-run").pipe(
492479 Flag.withDescription("Resolve mode/ports/env and print, but do not spawn turbo."),
......@@ -501,26 +488,16 @@ const devRunnerCli = Command.make("dev-runner", {
501488 Command.withHandler((input) => runDevRunnerWithInput(input)),
502489);
503490
504export function runDevRunner(argv = process.argv) {
505 return Command.runWith(devRunnerCli, { version: "0.0.0" })(argv.slice(2)).pipe(
506 Effect.mapError((cause) =>
507 cause instanceof DevRunnerError
508 ? cause
509 : new DevRunnerError({
510 message: cause instanceof Error ? cause.message : "dev-runner argument parsing failed",
511 cause,
512 }),
513 ),
514 );
515}
516
517const runtimeLayer = Layer.mergeAll(
491const cliRuntimeLayer = Layer.mergeAll(
518492 Logger.layer([Logger.consolePretty()]),
519493 NodeServices.layer,
520494 NetService.layer,
521495);
522496
523const runtimeProgram = runDevRunner().pipe(Effect.scoped, Effect.provide(runtimeLayer));
497const runtimeProgram = Command.run(devRunnerCli, { version: "0.0.0" }).pipe(
498 Effect.scoped,
499 Effect.provide(cliRuntimeLayer),
500);
524501
525502if (import.meta.main) {
526503 NodeRuntime.runMain(runtimeProgram);