From e6ac39de75de4abc5bf13a06a3e5c618a9759c51 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 3 Mar 2026 12:42:07 -0800 Subject: [PATCH] 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 --- bun.lock | 1 + packages/contracts/package.json | 3 +- scripts/dev-runner.test.ts | 392 +++++++++++++++---------------- scripts/dev-runner.ts | 399 +++++++++++++++----------------- 4 files changed, 383 insertions(+), 412 deletions(-) diff --git a/bun.lock b/bun.lock index 1360795ace2674b9cef0ee3e3944da2a79ea8df6..31f3f5d73a8da9a31c7c19b834c2f75a8b4d4beb 100644 --- a/bun.lock +++ b/bun.lock @@ -107,6 +107,7 @@ "@effect/vitest": "catalog:", "tsdown": "catalog:", "typescript": "catalog:", + "vitest": "catalog:", }, }, "packages/shared": { diff --git a/packages/contracts/package.json b/packages/contracts/package.json index f135f4b333130aa8ead863310b4f0b3258e9f3c3..9cca34998698c3d51207e4f48eaed3bcb3664047 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -30,6 +30,7 @@ "@effect/language-service": "catalog:", "@effect/vitest": "catalog:", "tsdown": "catalog:", - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "catalog:" } } diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 3491c37e0e3f739fa2fa690dcbf42ceba9628628..ee8094970d1b7ffb8ed8f63890a49fa91d631484 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -1,5 +1,5 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; -import { assert, it } from "@effect/vitest"; +import { assert, describe, it } from "@effect/vitest"; import { Effect } from "effect"; import { @@ -7,219 +7,211 @@ import { createDevRunnerEnv, findFirstAvailableOffset, resolveModePortOffsets, + resolveOffset, } from "./dev-runner.ts"; it.layer(NodeServices.layer)("dev-runner", (it) => { - it.effect("defaults state dir to ~/.t3/dev when not provided", () => - Effect.gen(function* () { - const env = yield* createDevRunnerEnv({ - mode: "dev", - env: {}, - offset: 0, - envOverrides: {}, - }); - const defaultStateDir = yield* DEFAULT_DEV_STATE_DIR; + describe("resolveOffset", () => { + it.effect("uses explicit T3CODE_PORT_OFFSET when provided", () => + Effect.sync(() => { + const result = resolveOffset({ portOffset: 12, devInstance: undefined }); + assert.deepStrictEqual(result, { + offset: 12, + source: "T3CODE_PORT_OFFSET=12", + }); + }), + ); - assert.equal(env.T3CODE_STATE_DIR, defaultStateDir); - }), - ); + it.effect("hashes non-numeric instance values", () => + Effect.sync(() => { + const result = resolveOffset({ portOffset: undefined, devInstance: "feature-branch" }); + assert.ok(result.offset >= 1); + assert.ok(result.offset <= 3000); + }), + ); - it.effect("uses existing env state dir when --state-dir is not provided", () => - Effect.gen(function* () { - const env = yield* createDevRunnerEnv({ - mode: "dev:server", - env: { T3CODE_STATE_DIR: "/tmp/existing-state" }, - offset: 0, - envOverrides: {}, - }); + it.effect("throws for negative port offset", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + Effect.try({ + try: () => resolveOffset({ portOffset: -1, devInstance: undefined }), + catch: (cause) => String(cause), + }), + ); - assert.equal(env.T3CODE_STATE_DIR, "/tmp/existing-state"); - }), - ); + assert.ok(error.includes("Invalid T3CODE_PORT_OFFSET")); + }), + ); + }); - it.effect("lets --state-dir override existing env state dir", () => - Effect.gen(function* () { - const env = yield* createDevRunnerEnv({ - mode: "dev:server", - env: { T3CODE_STATE_DIR: "/tmp/existing-state" }, - offset: 0, - envOverrides: { T3CODE_STATE_DIR: "/tmp/override-state" }, - }); + describe("createDevRunnerEnv", () => { + it.effect("defaults state dir to ~/.t3/dev when not provided", () => + Effect.gen(function* () { + const [env, defaultStateDir] = yield* Effect.all([ + createDevRunnerEnv({ + mode: "dev", + baseEnv: {}, + serverOffset: 0, + webOffset: 0, + stateDir: undefined, + authToken: undefined, + noBrowser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + }), + DEFAULT_DEV_STATE_DIR, + ]); - assert.equal(env.T3CODE_STATE_DIR, "/tmp/override-state"); - }), - ); + assert.equal(env.T3CODE_STATE_DIR, defaultStateDir); + }), + ); - it.effect("treats whitespace-only --state-dir as missing and falls back to env/default", () => - Effect.gen(function* () { - const env = yield* createDevRunnerEnv({ - mode: "dev:server", - env: { T3CODE_STATE_DIR: "/tmp/existing-state" }, - offset: 0, - envOverrides: { T3CODE_STATE_DIR: " " }, - }); + it.effect("supports explicit typed overrides", () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ + mode: "dev:server", + baseEnv: {}, + serverOffset: 0, + webOffset: 0, + stateDir: "/tmp/override-state", + authToken: "secret", + noBrowser: true, + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: true, + host: "0.0.0.0", + port: 4222, + devUrl: new URL("http://localhost:7331"), + }); - assert.equal(env.T3CODE_STATE_DIR, "/tmp/existing-state"); - }), - ); + assert.equal(env.T3CODE_STATE_DIR, "/tmp/override-state"); + assert.equal(env.T3CODE_PORT, "4222"); + assert.equal(env.VITE_WS_URL, "ws://localhost:4222"); + assert.equal(env.T3CODE_NO_BROWSER, "1"); + assert.equal(env.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD, "0"); + assert.equal(env.T3CODE_LOG_WS_EVENTS, "1"); + assert.equal(env.T3CODE_HOST, "0.0.0.0"); + assert.equal(env.VITE_DEV_SERVER_URL, "http://localhost:7331/"); + }), + ); + }); - it.effect("recomputes websocket url when port is overridden", () => - Effect.gen(function* () { - const env = yield* createDevRunnerEnv({ - mode: "dev", - env: {}, - offset: 0, - envOverrides: { T3CODE_PORT: "4222" }, - }); + describe("findFirstAvailableOffset", () => { + it.effect("returns the starting offset when required ports are available", () => + Effect.gen(function* () { + const offset = yield* findFirstAvailableOffset({ + startOffset: 0, + requireServerPort: true, + requireWebPort: true, + checkPortAvailability: () => Effect.succeed(true), + }); - assert.equal(env.T3CODE_PORT, "4222"); - assert.equal(env.VITE_WS_URL, "ws://localhost:4222"); - }), - ); + assert.equal(offset, 0); + }), + ); - it.effect("keeps explicitly forwarded web-mode flags", () => - Effect.gen(function* () { - const env = yield* createDevRunnerEnv({ - mode: "dev", - env: { - T3CODE_AUTH_TOKEN: "desktop-token", - }, - offset: 0, - envOverrides: { - T3CODE_NO_BROWSER: "1", - T3CODE_AUTH_TOKEN: "cli-token", - }, - }); + it.effect("advances until all required ports are available", () => + Effect.gen(function* () { + const taken = new Set([3773, 5733, 3774, 5734]); + const offset = yield* findFirstAvailableOffset({ + startOffset: 0, + requireServerPort: true, + requireWebPort: true, + checkPortAvailability: (port) => Effect.succeed(!taken.has(port)), + }); - assert.equal(env.T3CODE_NO_BROWSER, "1"); - assert.equal(env.T3CODE_AUTH_TOKEN, "cli-token"); - }), - ); + assert.equal(offset, 2); + }), + ); - it.effect("fails fast for invalid port overrides", () => - Effect.gen(function* () { - const error = yield* Effect.flip( - createDevRunnerEnv({ + it.effect("allows offsets where only non-required ports exceed max", () => + Effect.gen(function* () { + const offset = yield* findFirstAvailableOffset({ + startOffset: 59_803, + requireServerPort: true, + requireWebPort: false, + checkPortAvailability: () => Effect.succeed(true), + }); + + assert.equal(offset, 59_803); + }), + ); + }); + + describe("resolveModePortOffsets", () => { + it.effect("uses a shared fallback offset for dev mode", () => + Effect.gen(function* () { + const taken = new Set([3773, 5733]); + const offsets = yield* resolveModePortOffsets({ mode: "dev", - env: {}, - offset: 0, - envOverrides: { T3CODE_PORT: "not-a-port" }, - }), - ); - - assert.ok(error.message.includes("Invalid T3CODE_PORT override")); - }), - ); -}); - -it.layer(NodeServices.layer)("findFirstAvailableOffset", (it) => { - it.effect("returns the starting offset when required ports are available", () => - Effect.gen(function* () { - const offset = yield* findFirstAvailableOffset({ - startOffset: 0, - requireServerPort: true, - requireWebPort: true, - checkPortAvailability: () => Effect.succeed(true), - }); - - assert.equal(offset, 0); - }), - ); - - it.effect("advances until all required ports are available", () => - Effect.gen(function* () { - const taken = new Set([3773, 5733, 3774, 5734]); - const offset = yield* findFirstAvailableOffset({ - startOffset: 0, - requireServerPort: true, - requireWebPort: true, - checkPortAvailability: (port) => Effect.succeed(!taken.has(port)), - }); - - assert.equal(offset, 2); - }), - ); - - it.effect("allows offsets where only non-required ports exceed max", () => - Effect.gen(function* () { - const offset = yield* findFirstAvailableOffset({ - startOffset: 59_803, - requireServerPort: true, - requireWebPort: false, - checkPortAvailability: () => Effect.succeed(true), - }); - - assert.equal(offset, 59_803); - }), - ); -}); - -it.layer(NodeServices.layer)("resolveModePortOffsets", (it) => { - it.effect("uses a shared fallback offset for dev mode", () => - Effect.gen(function* () { - const taken = new Set([3773, 5733]); - const offsets = yield* resolveModePortOffsets({ - mode: "dev", - startOffset: 0, - envOverrides: {}, - checkPortAvailability: (port) => Effect.succeed(!taken.has(port)), - }); - - assert.deepStrictEqual(offsets, { serverOffset: 1, webOffset: 1 }); - }), - ); - - it.effect("keeps server offset stable for dev:web and only shifts web offset", () => - Effect.gen(function* () { - const taken = new Set([5733]); - const offsets = yield* resolveModePortOffsets({ - mode: "dev:web", - startOffset: 0, - envOverrides: {}, - checkPortAvailability: (port) => Effect.succeed(!taken.has(port)), - }); - - assert.deepStrictEqual(offsets, { serverOffset: 0, webOffset: 1 }); - }), - ); - - it.effect("shifts only server offset for dev:server", () => - Effect.gen(function* () { - const taken = new Set([3773]); - const offsets = yield* resolveModePortOffsets({ - mode: "dev:server", - startOffset: 0, - envOverrides: {}, - checkPortAvailability: (port) => Effect.succeed(!taken.has(port)), - }); - - assert.deepStrictEqual(offsets, { serverOffset: 1, webOffset: 1 }); - }), - ); - - it.effect("respects explicit dev-url override for dev:web", () => - Effect.gen(function* () { - const offsets = yield* resolveModePortOffsets({ - mode: "dev:web", - startOffset: 0, - envOverrides: { VITE_DEV_SERVER_URL: "http://localhost:9999" }, - checkPortAvailability: () => Effect.succeed(false), - }); - - assert.deepStrictEqual(offsets, { serverOffset: 0, webOffset: 0 }); - }), - ); - - it.effect("respects explicit server port override for dev:server", () => - Effect.gen(function* () { - const offsets = yield* resolveModePortOffsets({ - mode: "dev:server", - startOffset: 0, - envOverrides: { T3CODE_PORT: "4888" }, - checkPortAvailability: () => Effect.succeed(false), - }); - - assert.deepStrictEqual(offsets, { serverOffset: 0, webOffset: 0 }); - }), - ); + startOffset: 0, + hasExplicitServerPort: false, + hasExplicitDevUrl: false, + checkPortAvailability: (port) => Effect.succeed(!taken.has(port)), + }); + + assert.deepStrictEqual(offsets, { serverOffset: 1, webOffset: 1 }); + }), + ); + + it.effect("keeps server offset stable for dev:web and only shifts web offset", () => + Effect.gen(function* () { + const taken = new Set([5733]); + const offsets = yield* resolveModePortOffsets({ + mode: "dev:web", + startOffset: 0, + hasExplicitServerPort: false, + hasExplicitDevUrl: false, + checkPortAvailability: (port) => Effect.succeed(!taken.has(port)), + }); + + assert.deepStrictEqual(offsets, { serverOffset: 0, webOffset: 1 }); + }), + ); + + it.effect("shifts only server offset for dev:server", () => + Effect.gen(function* () { + const taken = new Set([3773]); + const offsets = yield* resolveModePortOffsets({ + mode: "dev:server", + startOffset: 0, + hasExplicitServerPort: false, + hasExplicitDevUrl: false, + checkPortAvailability: (port) => Effect.succeed(!taken.has(port)), + }); + + assert.deepStrictEqual(offsets, { serverOffset: 1, webOffset: 1 }); + }), + ); + + it.effect("respects explicit dev-url override for dev:web", () => + Effect.gen(function* () { + const offsets = yield* resolveModePortOffsets({ + mode: "dev:web", + startOffset: 0, + hasExplicitServerPort: false, + hasExplicitDevUrl: true, + checkPortAvailability: () => Effect.succeed(false), + }); + + assert.deepStrictEqual(offsets, { serverOffset: 0, webOffset: 0 }); + }), + ); + + it.effect("respects explicit server port override for dev:server", () => + Effect.gen(function* () { + const offsets = yield* resolveModePortOffsets({ + mode: "dev:server", + startOffset: 0, + hasExplicitServerPort: true, + hasExplicitDevUrl: false, + checkPortAvailability: () => Effect.succeed(false), + }); + + assert.deepStrictEqual(offsets, { serverOffset: 0, webOffset: 0 }); + }), + ); + }); }); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 9b8a396f319b51c3f13b4b1496b1b6f31c5c2447..39c9999752effc45f9326dd2d15bc9f2d9b5e046 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -5,7 +5,7 @@ import { homedir } from "node:os"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { NetService } from "@t3tools/shared/Net"; -import { Data, Effect, Layer, Logger, Option, Path } from "effect"; +import { Config, Data, Effect, Hash, Layer, Logger, Option, Path, Schema } from "effect"; import { Argument, Command, Flag } from "effect/unstable/cli"; import { ChildProcess } from "effect/unstable/process"; @@ -14,11 +14,9 @@ const BASE_WEB_PORT = 5733; const MAX_HASH_OFFSET = 3000; const MAX_PORT = 65535; -export const DEFAULT_DEV_STATE_DIR: Effect.Effect = Effect.gen( - function* () { - const path = yield* Path.Path; - return path.join(homedir(), ".t3", "dev"); -}); +export const DEFAULT_DEV_STATE_DIR = Effect.map(Effect.service(Path.Path), (path) => + path.join(homedir(), ".t3", "dev"), +); const MODE_ARGS = { dev: [ @@ -36,7 +34,6 @@ const MODE_ARGS = { } as const satisfies Record>; type DevMode = keyof typeof MODE_ARGS; -type EnvOverrides = Record; type PortAvailabilityCheck = (port: number) => Effect.Effect; const DEV_RUNNER_MODES = Object.keys(MODE_ARGS) as Array; @@ -46,37 +43,52 @@ class DevRunnerError extends Data.TaggedError("DevRunnerError")<{ readonly cause?: unknown; }> {} -function parseInteger(value: string, envName: string): number { - const parsed = Number(value); - if (!Number.isInteger(parsed)) { - throw new Error(`Invalid ${envName}: ${value}`); - } - return parsed; -} +const optionalStringConfig = (name: string): Config.Config => + Config.string(name).pipe( + Config.option, + Config.map((value) => Option.getOrUndefined(value)), + ); +const optionalBooleanConfig = (name: string): Config.Config => + Config.boolean(name).pipe( + Config.option, + Config.map((value) => Option.getOrUndefined(value)), + ); +const optionalPortConfig = (name: string): Config.Config => + Config.port(name).pipe( + Config.option, + Config.map((value) => Option.getOrUndefined(value)), + ); +const optionalIntegerConfig = (name: string): Config.Config => + Config.int(name).pipe( + Config.option, + Config.map((value) => Option.getOrUndefined(value)), + ); +const optionalUrlConfig = (name: string): Config.Config => + Config.url(name).pipe( + Config.option, + Config.map((value) => Option.getOrUndefined(value)), + ); -function hashSeed(seed: string): number { - let hash = 0x811c9dc5; - for (let i = 0; i < seed.length; i += 1) { - hash ^= seed.charCodeAt(i); - hash = Math.imul(hash, 0x01000193); - } - return hash >>> 0; -} +const OffsetConfig = Config.all({ + portOffset: optionalIntegerConfig("T3CODE_PORT_OFFSET"), + devInstance: optionalStringConfig("T3CODE_DEV_INSTANCE"), +}); -export function resolveOffset(env: NodeJS.ProcessEnv = process.env): { - readonly offset: number; - readonly source: string; -} { - const explicitOffset = env.T3CODE_PORT_OFFSET?.trim(); - if (explicitOffset) { - const parsed = parseInteger(explicitOffset, "T3CODE_PORT_OFFSET"); - if (parsed < 0) { - throw new Error(`Invalid T3CODE_PORT_OFFSET: ${explicitOffset}`); +export function resolveOffset(config: { + readonly portOffset: number | undefined; + readonly devInstance: string | undefined; +}): { readonly offset: number; readonly source: string } { + if (config.portOffset !== undefined) { + if (config.portOffset < 0) { + throw new Error(`Invalid T3CODE_PORT_OFFSET: ${config.portOffset}`); } - return { offset: parsed, source: `T3CODE_PORT_OFFSET=${explicitOffset}` }; + return { + offset: config.portOffset, + source: `T3CODE_PORT_OFFSET=${config.portOffset}`, + }; } - const seed = env.T3CODE_DEV_INSTANCE?.trim(); + const seed = config.devInstance?.trim(); if (!seed) { return { offset: 0, source: "default ports" }; } @@ -85,32 +97,18 @@ export function resolveOffset(env: NodeJS.ProcessEnv = process.env): { return { offset: Number(seed), source: `numeric T3CODE_DEV_INSTANCE=${seed}` }; } - const offset = (hashSeed(seed) % MAX_HASH_OFFSET) + 1; + const offset = ((Hash.string(seed) >>> 0) % MAX_HASH_OFFSET) + 1; return { offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` }; } -function toBooleanEnvValue(value: string | undefined): string { - if (value === undefined) return "1"; - const normalized = value.trim().toLowerCase(); - if (normalized === "false" || normalized === "0" || normalized === "no") return "0"; - return "1"; -} - -function resolveStateDir( - env: NodeJS.ProcessEnv, - envOverrides: EnvOverrides, -): Effect.Effect { +function resolveStateDir(stateDir: string | undefined): Effect.Effect { return Effect.gen(function* () { const path = yield* Path.Path; - const overrideValue = envOverrides?.T3CODE_STATE_DIR?.trim(); - if (overrideValue) { + const configured = stateDir?.trim(); + + if (configured) { // Resolve relative paths against cwd (monorepo root) before turbo changes directories. - return path.resolve(overrideValue); - } - - const envValue = env?.T3CODE_STATE_DIR?.trim(); - if (envValue) { - return path.resolve(envValue); + return path.resolve(configured); } return yield* DEFAULT_DEV_STATE_DIR; @@ -119,72 +117,97 @@ function resolveStateDir( interface CreateDevRunnerEnvInput { readonly mode: DevMode; - readonly env: NodeJS.ProcessEnv; - readonly offset: number; - readonly envOverrides: EnvOverrides; + readonly baseEnv: NodeJS.ProcessEnv; + readonly serverOffset: number; + readonly webOffset: number; + readonly stateDir: string | undefined; + readonly authToken: string | undefined; + readonly noBrowser: boolean | undefined; + readonly autoBootstrapProjectFromCwd: boolean | undefined; + readonly logWebSocketEvents: boolean | undefined; + readonly host: string | undefined; + readonly port: number | undefined; + readonly devUrl: URL | undefined; } export function createDevRunnerEnv({ mode, - env, - offset, - envOverrides, -}: CreateDevRunnerEnvInput): Effect.Effect { + baseEnv, + serverOffset, + webOffset, + stateDir, + authToken, + noBrowser, + autoBootstrapProjectFromCwd, + logWebSocketEvents, + host, + port, + devUrl, +}: CreateDevRunnerEnvInput): Effect.Effect { return Effect.gen(function* () { - const serverPort = BASE_SERVER_PORT + offset; - const webPort = BASE_WEB_PORT + offset; + const serverPort = port ?? BASE_SERVER_PORT + serverOffset; + const webPort = BASE_WEB_PORT + webOffset; + const resolvedStateDir = yield* resolveStateDir(stateDir); const output: NodeJS.ProcessEnv = { - ...env, + ...baseEnv, T3CODE_PORT: String(serverPort), PORT: String(webPort), ELECTRON_RENDERER_PORT: String(webPort), VITE_WS_URL: `ws://localhost:${serverPort}`, - VITE_DEV_SERVER_URL: `http://localhost:${webPort}`, - ...envOverrides, + VITE_DEV_SERVER_URL: devUrl?.toString() ?? `http://localhost:${webPort}`, + T3CODE_STATE_DIR: resolvedStateDir, }; - output.T3CODE_STATE_DIR = yield* resolveStateDir(env, envOverrides); + if (host !== undefined) { + output.T3CODE_HOST = host; + } + + if (authToken !== undefined) { + output.T3CODE_AUTH_TOKEN = authToken; + } else { + delete output.T3CODE_AUTH_TOKEN; + } - const parsedServerPort = yield* Effect.try({ - try: () => { - const parsed = Number(output.T3CODE_PORT); - if (!Number.isInteger(parsed) || parsed < 1 || parsed > MAX_PORT) { - throw new Error( - `Invalid T3CODE_PORT override: '${String(output.T3CODE_PORT)}'. Expected an integer between 1 and 65535.`, - ); - } - return parsed; - }, - catch: (cause) => - new DevRunnerError({ - message: cause instanceof Error ? cause.message : String(cause), - cause, - }), - }); - output.VITE_WS_URL = `ws://localhost:${parsedServerPort}`; + if (noBrowser !== undefined) { + output.T3CODE_NO_BROWSER = noBrowser ? "1" : "0"; + } else { + delete output.T3CODE_NO_BROWSER; + } + + if (autoBootstrapProjectFromCwd !== undefined) { + output.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD = autoBootstrapProjectFromCwd ? "1" : "0"; + } else { + delete output.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD; + } - if (mode === "dev" || mode === "dev:server" || mode === "dev:web") { - // Running server/web in browser mode should not inherit desktop launcher state. + if (logWebSocketEvents !== undefined) { + output.T3CODE_LOG_WS_EVENTS = logWebSocketEvents ? "1" : "0"; + } else { + delete output.T3CODE_LOG_WS_EVENTS; + } + + if (mode === "dev") { output.T3CODE_MODE = "web"; - if (!("T3CODE_NO_BROWSER" in envOverrides)) { - delete output.T3CODE_NO_BROWSER; - } - if (!("T3CODE_AUTH_TOKEN" in envOverrides)) { - delete output.T3CODE_AUTH_TOKEN; + if (logWebSocketEvents === undefined) { + output.T3CODE_LOG_WS_EVENTS = "1"; } delete output.T3CODE_DESKTOP_WS_URL; } - if (mode === "dev" && !output.T3CODE_LOG_WS_EVENTS) { - output.T3CODE_LOG_WS_EVENTS = "1"; + if (mode === "dev:server" || mode === "dev:web") { + output.T3CODE_MODE = "web"; + delete output.T3CODE_DESKTOP_WS_URL; } return output; }); } -function portPairForOffset(offset: number): { readonly serverPort: number; readonly webPort: number } { +function portPairForOffset(offset: number): { + readonly serverPort: number; + readonly webPort: number; +} { return { serverPort: BASE_SERVER_PORT + offset, webPort: BASE_WEB_PORT + offset, @@ -211,11 +234,14 @@ export function findFirstAvailableOffset({ checkPortAvailability, }: FindFirstAvailableOffsetInput): Effect.Effect { return Effect.gen(function* () { - const checkPort = (checkPortAvailability ?? defaultCheckPortAvailability) as PortAvailabilityCheck; + const checkPort = (checkPortAvailability ?? + defaultCheckPortAvailability) as PortAvailabilityCheck; + for (let candidate = startOffset; ; candidate += 1) { const { serverPort, webPort } = portPairForOffset(candidate); const serverPortOutOfRange = serverPort > MAX_PORT; const webPortOutOfRange = webPort > MAX_PORT; + if ( (requireServerPort && serverPortOutOfRange) || (requireWebPort && webPortOutOfRange) || @@ -251,14 +277,16 @@ export function findFirstAvailableOffset({ interface ResolveModePortOffsetsInput { readonly mode: DevMode; readonly startOffset: number; - readonly envOverrides: EnvOverrides; + readonly hasExplicitServerPort: boolean; + readonly hasExplicitDevUrl: boolean; readonly checkPortAvailability?: PortAvailabilityCheck; } export function resolveModePortOffsets({ mode, startOffset, - envOverrides, + hasExplicitServerPort, + hasExplicitDevUrl, checkPortAvailability, }: ResolveModePortOffsetsInput): Effect.Effect< { readonly serverOffset: number; readonly webOffset: number }, @@ -266,14 +294,14 @@ export function resolveModePortOffsets({ R > { return Effect.gen(function* () { - const checkPort = (checkPortAvailability ?? defaultCheckPortAvailability) as PortAvailabilityCheck; - const hasExplicitServerPort = typeof envOverrides.T3CODE_PORT === "string"; - const hasExplicitDevUrl = typeof envOverrides.VITE_DEV_SERVER_URL === "string"; + const checkPort = (checkPortAvailability ?? + defaultCheckPortAvailability) as PortAvailabilityCheck; if (mode === "dev:web") { if (hasExplicitDevUrl) { return { serverOffset: startOffset, webOffset: startOffset }; } + const webOffset = yield* findFirstAvailableOffset({ startOffset, requireServerPort: false, @@ -287,6 +315,7 @@ export function resolveModePortOffsets({ if (hasExplicitServerPort) { return { serverOffset: startOffset, webOffset: startOffset }; } + const serverOffset = yield* findFirstAvailableOffset({ startOffset, requireServerPort: true, @@ -302,71 +331,39 @@ export function resolveModePortOffsets({ requireWebPort: !hasExplicitDevUrl, checkPortAvailability: checkPort, }); + return { serverOffset: sharedOffset, webOffset: sharedOffset }; }); } -const optionValue = (option: Option.Option): A | undefined => - Option.isSome(option) ? option.value : undefined; - interface DevRunnerCliInput { readonly mode: DevMode; - readonly stateDir: Option.Option; - readonly authToken: Option.Option; - readonly noBrowser: Option.Option; - readonly autoBootstrapProjectFromCwd: Option.Option; - readonly logWebSocketEvents: Option.Option; - readonly host: Option.Option; - readonly port: Option.Option; - readonly devUrl: Option.Option; + readonly stateDir: string | undefined; + readonly authToken: string | undefined; + readonly noBrowser: boolean | undefined; + readonly autoBootstrapProjectFromCwd: boolean | undefined; + readonly logWebSocketEvents: boolean | undefined; + readonly host: string | undefined; + readonly port: number | undefined; + readonly devUrl: URL | undefined; readonly dryRun: boolean; readonly turboArgs: ReadonlyArray; } -function envOverridesFromCliInput(input: DevRunnerCliInput): EnvOverrides { - const envOverrides: EnvOverrides = {}; - - const stateDir = optionValue(input.stateDir); - if (stateDir !== undefined) envOverrides.T3CODE_STATE_DIR = stateDir; - - const authToken = optionValue(input.authToken); - if (authToken !== undefined) envOverrides.T3CODE_AUTH_TOKEN = authToken; - - const host = optionValue(input.host); - if (host !== undefined) envOverrides.T3CODE_HOST = host; - - const port = optionValue(input.port); - if (port !== undefined) envOverrides.T3CODE_PORT = port; - - const devUrl = optionValue(input.devUrl); - if (devUrl !== undefined) envOverrides.VITE_DEV_SERVER_URL = devUrl; - - const noBrowser = optionValue(input.noBrowser); - if (noBrowser !== undefined) { - envOverrides.T3CODE_NO_BROWSER = toBooleanEnvValue(noBrowser); - } - - const autoBootstrapProjectFromCwd = optionValue(input.autoBootstrapProjectFromCwd); - if (autoBootstrapProjectFromCwd !== undefined) { - envOverrides.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD = toBooleanEnvValue( - autoBootstrapProjectFromCwd, - ); - } - - const logWebSocketEvents = optionValue(input.logWebSocketEvents); - if (logWebSocketEvents !== undefined) { - envOverrides.T3CODE_LOG_WS_EVENTS = toBooleanEnvValue(logWebSocketEvents); - } - - return envOverrides; -} - export function runDevRunnerWithInput(input: DevRunnerCliInput) { return Effect.gen(function* () { - const envOverrides = envOverridesFromCliInput(input); - const turboArgs = Array.from(input.turboArgs); + const { portOffset, devInstance } = yield* OffsetConfig.asEffect().pipe( + Effect.mapError( + (cause) => + new DevRunnerError({ + message: "Failed to read T3CODE_PORT_OFFSET/T3CODE_DEV_INSTANCE configuration.", + cause, + }), + ), + ); + const { offset, source } = yield* Effect.try({ - try: () => resolveOffset(), + try: () => resolveOffset({ portOffset, devInstance }), catch: (cause) => new DevRunnerError({ message: cause instanceof Error ? cause.message : String(cause), @@ -377,46 +374,32 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { const { serverOffset, webOffset } = yield* resolveModePortOffsets({ mode: input.mode, startOffset: offset, - envOverrides, + hasExplicitServerPort: input.port !== undefined, + hasExplicitDevUrl: input.devUrl !== undefined, }); - const mergedEnvOverrides = { ...envOverrides }; - if (!("VITE_DEV_SERVER_URL" in mergedEnvOverrides)) { - const { webPort } = portPairForOffset(webOffset); - mergedEnvOverrides.PORT = String(webPort); - mergedEnvOverrides.ELECTRON_RENDERER_PORT = String(webPort); - mergedEnvOverrides.VITE_DEV_SERVER_URL = `http://localhost:${webPort}`; - } - const env = yield* createDevRunnerEnv({ mode: input.mode, - env: process.env, - offset: serverOffset, - envOverrides: mergedEnvOverrides, + baseEnv: process.env, + serverOffset, + webOffset, + stateDir: input.stateDir, + authToken: input.authToken, + noBrowser: input.noBrowser, + autoBootstrapProjectFromCwd: input.autoBootstrapProjectFromCwd, + logWebSocketEvents: input.logWebSocketEvents, + host: input.host, + port: input.port, + devUrl: input.devUrl, }); - const parsedServerPort = Number(env.T3CODE_PORT); - const parsedWebPort = Number(env.PORT); - if ( - !Number.isInteger(parsedServerPort) || - parsedServerPort < 1 || - parsedServerPort > MAX_PORT || - !Number.isInteger(parsedWebPort) || - parsedWebPort < 1 || - parsedWebPort > MAX_PORT - ) { - return yield* new DevRunnerError({ - message: `Invalid computed dev ports: server='${String(env.T3CODE_PORT)}' web='${String(env.PORT)}'.`, - }); - } - const selectionSuffix = serverOffset !== offset || webOffset !== offset ? ` selectedOffset(server=${serverOffset},web=${webOffset})` : ""; yield* Effect.logInfo( - `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${parsedServerPort} webPort=${parsedWebPort} stateDir=${String(env.T3CODE_STATE_DIR)}`, + `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} stateDir=${String(env.T3CODE_STATE_DIR)}`, ); if (input.dryRun) { @@ -424,7 +407,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { } const command = process.platform === "win32" ? "turbo.cmd" : "turbo"; - const child = yield* ChildProcess.make(command, [...MODE_ARGS[input.mode], ...turboArgs], { + const child = yield* ChildProcess.make(command, [...MODE_ARGS[input.mode], ...input.turboArgs], { stdin: "inherit", stdout: "inherit", stderr: "inherit", @@ -432,7 +415,7 @@ export function runDevRunnerWithInput(input: DevRunnerCliInput) { extendEnv: false, }); - const exitCode = Number(yield* child.exitCode); + const exitCode = yield* child.exitCode; if (exitCode !== 0) { return yield* new DevRunnerError({ message: `turbo exited with code ${exitCode}`, @@ -456,37 +439,41 @@ const devRunnerCli = Command.make("dev-runner", { ), stateDir: Flag.string("state-dir").pipe( Flag.withDescription("State directory path (forwards to T3CODE_STATE_DIR)."), - Flag.optional, + Flag.withFallbackConfig(optionalStringConfig("T3CODE_STATE_DIR")), ), authToken: Flag.string("auth-token").pipe( Flag.withDescription("Auth token (forwards to T3CODE_AUTH_TOKEN)."), Flag.withAlias("token"), - Flag.optional, + Flag.withFallbackConfig(optionalStringConfig("T3CODE_AUTH_TOKEN")), ), - noBrowser: Flag.string("no-browser").pipe( - Flag.withDescription("Browser auto-open toggle. Accepts true/false/1/0/no."), - Flag.optional, + noBrowser: Flag.boolean("no-browser").pipe( + Flag.withDescription("Browser auto-open toggle (equivalent to T3CODE_NO_BROWSER)."), + Flag.withFallbackConfig(optionalBooleanConfig("T3CODE_NO_BROWSER")), ), - autoBootstrapProjectFromCwd: Flag.string("auto-bootstrap-project-from-cwd").pipe( - Flag.withDescription("Auto-bootstrap toggle. Accepts true/false/1/0/no."), - Flag.optional, + autoBootstrapProjectFromCwd: Flag.boolean("auto-bootstrap-project-from-cwd").pipe( + Flag.withDescription( + "Auto-bootstrap toggle (equivalent to T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD).", + ), + Flag.withFallbackConfig(optionalBooleanConfig("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD")), ), - logWebSocketEvents: Flag.string("log-websocket-events").pipe( - Flag.withDescription("WebSocket event logging toggle. Accepts true/false/1/0/no."), + logWebSocketEvents: Flag.boolean("log-websocket-events").pipe( + Flag.withDescription("WebSocket event logging toggle (equivalent to T3CODE_LOG_WS_EVENTS)."), Flag.withAlias("log-ws-events"), - Flag.optional, + Flag.withFallbackConfig(optionalBooleanConfig("T3CODE_LOG_WS_EVENTS")), ), host: Flag.string("host").pipe( Flag.withDescription("Server host/interface override (forwards to T3CODE_HOST)."), - Flag.optional, + Flag.withFallbackConfig(optionalStringConfig("T3CODE_HOST")), ), - port: Flag.string("port").pipe( + port: Flag.integer("port").pipe( + Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), Flag.withDescription("Server port override (forwards to T3CODE_PORT)."), - Flag.optional, + Flag.withFallbackConfig(optionalPortConfig("T3CODE_PORT")), ), devUrl: Flag.string("dev-url").pipe( + Flag.withSchema(Schema.URLFromString), Flag.withDescription("Web dev URL override (forwards to VITE_DEV_SERVER_URL)."), - Flag.optional, + Flag.withFallbackConfig(optionalUrlConfig("VITE_DEV_SERVER_URL")), ), dryRun: Flag.boolean("dry-run").pipe( Flag.withDescription("Resolve mode/ports/env and print, but do not spawn turbo."), @@ -501,26 +488,16 @@ const devRunnerCli = Command.make("dev-runner", { Command.withHandler((input) => runDevRunnerWithInput(input)), ); -export function runDevRunner(argv = process.argv) { - return Command.runWith(devRunnerCli, { version: "0.0.0" })(argv.slice(2)).pipe( - Effect.mapError((cause) => - cause instanceof DevRunnerError - ? cause - : new DevRunnerError({ - message: cause instanceof Error ? cause.message : "dev-runner argument parsing failed", - cause, - }), - ), - ); -} - -const runtimeLayer = Layer.mergeAll( +const cliRuntimeLayer = Layer.mergeAll( Logger.layer([Logger.consolePretty()]), NodeServices.layer, NetService.layer, ); -const runtimeProgram = runDevRunner().pipe(Effect.scoped, Effect.provide(runtimeLayer)); +const runtimeProgram = Command.run(devRunnerCli, { version: "0.0.0" }).pipe( + Effect.scoped, + Effect.provide(cliRuntimeLayer), +); if (import.meta.main) { NodeRuntime.runMain(runtimeProgram); -- 2.54.0