diff --git a/apps/desktop/package.json b/apps/desktop/package.json index be33c20302e32a707e2d6a59b7032cfd0230979c..07d1e84fe457cb3046ec46808835d44736775427 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -14,15 +14,16 @@ "smoke-test": "node scripts/smoke-test.mjs" }, "dependencies": { + "@t3tools/shared": "workspace:*", "electron": "40.6.0" }, "devDependencies": { "@t3tools/contracts": "workspace:*", - "@types/node": "^24.10.13", + "@types/node": "catalog:", "electronmon": "^2.0.2", - "tsdown": "^0.20.3", - "typescript": "^5.7.3", - "vitest": "^4.0.0", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", "wait-on": "^8.0.2" }, "productName": "T3 Code (Alpha)" diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index a85fba3219551070a5370b0499c1d6b82245372f..d7b91daa2365277a4d6bf0c73975137978beb15f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,14 +1,15 @@ import { spawn, type ChildProcess } from "node:child_process"; import { randomBytes } from "node:crypto"; import fs from "node:fs"; -import net from "node:net"; import os from "node:os"; import path from "node:path"; import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, protocol, shell } from "electron"; import type { MenuItemConstructorOptions } from "electron"; +import { Effect } from "effect"; import type { ContextMenuItem } from "@t3tools/contracts"; +import { NetService } from "@t3tools/shared/Net"; import { showDesktopConfirmDialog } from "./confirmDialog"; import { fixPath } from "./fixPath"; @@ -347,24 +348,6 @@ function configureAppIdentity(): void { } } -async function reserveLoopbackPort(): Promise { - return new Promise((resolve, reject) => { - const probe = net.createServer(); - probe.listen(0, "127.0.0.1", () => { - const address = probe.address(); - const port = typeof address === "object" && address !== null ? address.port : 0; - probe.close(() => { - if (port > 0) { - resolve(port); - return; - } - reject(new Error("Failed to reserve backend port")); - }); - }); - probe.on("error", reject); - }); -} - function backendEnv(): NodeJS.ProcessEnv { return { ...process.env, @@ -621,7 +604,12 @@ function createWindow(): BrowserWindow { configureAppIdentity(); async function bootstrap(): Promise { - backendPort = await reserveLoopbackPort(); + backendPort = await Effect.service(NetService).pipe( + Effect.flatMap((net) => net.reserveLoopbackPort()), + Effect.flatMap((port) => Effect.succeed(port)), + Effect.provide(NetService.layer), + Effect.runPromise, + ); backendAuthToken = randomBytes(24).toString("hex"); backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`; process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl; diff --git a/apps/server/package.json b/apps/server/package.json index b5e38bc6d71a85ec2022ddc0d64708ad3de0faeb..b904d656ce37231fd66a34900551131bb47c5df5 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -26,17 +26,17 @@ "ws": "^8.18.0" }, "devDependencies": { - "@effect/language-service": "0.75.1", + "@effect/language-service": "catalog:", "@effect/vitest": "catalog:", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/web": "workspace:*", - "@types/bun": "^1.3.9", - "@types/node": "^24.10.13", + "@types/bun": "catalog:", + "@types/node": "catalog:", "@types/ws": "^8.5.13", - "tsdown": "^0.20.3", - "typescript": "^5.7.3", - "vitest": "^4.0.0" + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" }, "engines": { "node": "^22.13 || ^23.4 || >=24.10" diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 7a1cb76831114ef783d564880f6d1ce3acbdd9c9..ccbcea469d6feaf6d63876f91175f9ab6d9b6874 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -1,13 +1,12 @@ /** - * ServerConfig and NetService - Runtime configuration services. + * ServerConfig - Runtime configuration services. * * Defines process-level server configuration and networking helpers used by * startup and runtime layers. * * @module ServerConfig */ -import * as Net from "node:net"; -import { Data, Effect, FileSystem, Layer, Path, ServiceMap } from "effect"; +import { Effect, FileSystem, Layer, Path, ServiceMap } from "effect"; export const DEFAULT_PORT = 3773; @@ -60,68 +59,6 @@ export class ServerConfig extends ServiceMap.Service {} - -/** - * NetServiceShape - Networking helper operations used during startup. - */ -export interface NetServiceShape { - /** - * Resolve an available listening port, preferring the provided port first. - * - * Falls back to an ephemeral OS-assigned port when the preferred port is in use. - */ - readonly findAvailablePort: (preferred: number) => Effect.Effect; -} - -/** - * NetService - Service tag for startup networking helpers. - */ -export class NetService extends ServiceMap.Service()( - "t3/config/NetService", -) { - static readonly layer = Layer.succeed(NetService, { - findAvailablePort: (preferred) => - Effect.callback((resume) => { - let fallbackServer: Net.Server | null = null; - const server = Net.createServer(); - server.listen(preferred, () => { - server.close(() => resume(Effect.succeed(preferred))); - }); - server.on("error", () => { - const fallback = Net.createServer(); - fallback.listen(0, () => { - const addr = fallback.address(); - const port = typeof addr === "object" && addr !== null ? addr.port : 0; - fallback.close(() => { - resume( - port > 0 - ? Effect.succeed(port) - : Effect.fail(new NetError({ message: "Could not find an available port." })), - ); - }); - }); - fallback.on("error", (cause) => { - resume( - Effect.fail(new NetError({ message: "Failed to find an available port", cause })), - ); - }); - fallbackServer = fallback; - }); - - return Effect.sync(() => { - server.close(); - fallbackServer?.close(); - }); - }), - }); -} - export const resolveStaticDir = Effect.fn(function* () { const { join, resolve } = yield* Path.Path; const { exists } = yield* FileSystem.FileSystem; diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 2f3ca764bae0e0a7bde85b5e97f38bd699f131ac..6eb26602cab91bb1954d6f8b933ef4342877c0ca 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -8,7 +8,7 @@ import { OpenLive } from "./open"; import { Command } from "effect/unstable/cli"; import { version } from "../package.json" with { type: "json" }; import { ServerLive } from "./wsServer"; -import { NetService } from "./config"; +import { NetService } from "@t3tools/shared/Net"; const RuntimeLayer = Layer.empty.pipe( Layer.provideMerge(CliConfig.layer), diff --git a/apps/server/src/main.test.ts b/apps/server/src/main.test.ts index be2b810394d337aafa87b7340d3404ed68a90508..e0d794aee6567ae66176d37f4e3b510e89f08516 100644 --- a/apps/server/src/main.test.ts +++ b/apps/server/src/main.test.ts @@ -6,9 +6,10 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Command from "effect/unstable/cli/Command"; import { beforeEach } from "vitest"; +import { NetService } from "@t3tools/shared/Net"; import { CliConfig, t3Cli, type CliConfigShape } from "./main"; -import { NetService, ServerConfig, type ServerConfigShape } from "./config"; +import { ServerConfig, type ServerConfigShape } from "./config"; import { Open, type OpenShape } from "./open"; import { Server, type ServerShape } from "./wsServer"; @@ -33,6 +34,9 @@ const testLayer = Layer.mergeAll( resolveStaticDir: Effect.undefined, } satisfies CliConfigShape), Layer.succeed(NetService, { + canListenOnHost: () => Effect.succeed(true), + isPortAvailableOnLoopback: () => Effect.succeed(true), + reserveLoopbackPort: () => Effect.succeed(0), findAvailablePort, }), Layer.succeed(Server, { diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 55ea71c54bfd4e519bce3861f4204b9422a33b17..a715840eb5246d09f9f522ef2acc4f919882fce1 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -8,10 +8,10 @@ */ import { Config, Data, Effect, FileSystem, Layer, Option, Path, Schema, ServiceMap } from "effect"; import { Command, Flag } from "effect/unstable/cli"; +import { NetService } from "@t3tools/shared/Net"; import { DEFAULT_PORT, - NetService, resolveStaticDir, ServerConfig, type RuntimeMode, diff --git a/apps/web/package.json b/apps/web/package.json index e3fb83fd619aac8b64baf6137639d31de5c97961..17d0930559d748db9fadc53eb4d07b34ac5bc386 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -43,7 +43,7 @@ "@vitejs/plugin-react": "^5.1.4", "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", "tailwindcss": "^4.0.0", - "typescript": "^5.7.3", + "typescript": "catalog:", "vite": "^8.0.0-beta.12" } } diff --git a/bun.lock b/bun.lock index 3325ce0b512d42c0591267b7b4c46e550ee5c0c4..1360795ace2674b9cef0ee3e3944da2a79ea8df6 100644 --- a/bun.lock +++ b/bun.lock @@ -5,29 +5,26 @@ "": { "name": "@t3tools/monorepo", "devDependencies": { - "@effect/platform-node": "catalog:", - "@types/node": "^24.10.13", - "effect": "catalog:", + "@types/node": "catalog:", "oxfmt": "^0.35.0", "oxlint": "^1.50.0", "turbo": "^2.3.3", - "typescript": "^5.7.3", - "vitest": "^4.0.0", }, }, "apps/desktop": { "name": "@t3tools/desktop", "version": "0.0.0-alpha.12", "dependencies": { + "@t3tools/shared": "workspace:*", "electron": "40.6.0", }, "devDependencies": { "@t3tools/contracts": "workspace:*", - "@types/node": "^24.10.13", + "@types/node": "catalog:", "electronmon": "^2.0.2", - "tsdown": "^0.20.3", - "typescript": "^5.7.3", - "vitest": "^4.0.0", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", "wait-on": "^8.0.2", }, }, @@ -47,17 +44,17 @@ "ws": "^8.18.0", }, "devDependencies": { - "@effect/language-service": "0.75.1", + "@effect/language-service": "catalog:", "@effect/vitest": "catalog:", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/web": "workspace:*", - "@types/bun": "^1.3.9", - "@types/node": "^24.10.13", + "@types/bun": "catalog:", + "@types/node": "catalog:", "@types/ws": "^8.5.13", - "tsdown": "^0.20.3", - "typescript": "^5.7.3", - "vitest": "^4.0.0", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", }, }, "apps/web": { @@ -95,7 +92,7 @@ "@vitejs/plugin-react": "^5.1.4", "babel-plugin-react-compiler": "^19.0.0-beta-e552027-20250112", "tailwindcss": "^4.0.0", - "typescript": "^5.7.3", + "typescript": "catalog:", "vite": "^8.0.0-beta.12", }, }, @@ -106,10 +103,10 @@ "effect": "catalog:", }, "devDependencies": { - "@effect/language-service": "0.75.1", + "@effect/language-service": "catalog:", "@effect/vitest": "catalog:", - "tsdown": "^0.20.3", - "typescript": "^5.7.3", + "tsdown": "catalog:", + "typescript": "catalog:", }, }, "packages/shared": { @@ -119,10 +116,26 @@ "effect": "catalog:", }, "devDependencies": { - "@effect/language-service": "0.75.1", + "@effect/language-service": "catalog:", "@effect/vitest": "catalog:", - "typescript": "^5.7.3", - "vitest": "^4.0.0", + "@types/node": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", + }, + }, + "scripts": { + "name": "@t3tools/scripts", + "dependencies": { + "@t3tools/contracts": "workspace:*", + "@t3tools/shared": "workspace:*", + "effect": "catalog:", + }, + "devDependencies": { + "@effect/language-service": "catalog:", + "@effect/vitest": "catalog:", + "@types/bun": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:", }, }, }, @@ -131,7 +144,12 @@ "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b", "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b", "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@8881a9b", + "@types/bun": "^1.3.9", + "@types/node": "^24.10.13", "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@8881a9b", + "tsdown": "^0.20.3", + "typescript": "^5.7.3", + "vitest": "^4.0.0", }, "packages": { "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], @@ -552,6 +570,8 @@ "@t3tools/desktop": ["@t3tools/desktop@workspace:apps/desktop"], + "@t3tools/scripts": ["@t3tools/scripts@workspace:scripts"], + "@t3tools/shared": ["@t3tools/shared@workspace:packages/shared"], "@t3tools/web": ["@t3tools/web@workspace:apps/web"], diff --git a/package.json b/package.json index e33709867bc1220feb5cd80be8fce3f9432ee95e..036d9d10471119e8469cfb89fb7d1a39b15ba9bb 100644 --- a/package.json +++ b/package.json @@ -4,22 +4,28 @@ "workspaces": { "packages": [ "apps/*", - "packages/*" + "packages/*", + "scripts" ], "catalog": { "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@8881a9b", "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b", "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b", "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@8881a9b", - "@effect/language-service": "0.75.1" + "@effect/language-service": "0.75.1", + "@types/bun": "^1.3.9", + "@types/node": "^24.10.13", + "tsdown": "^0.20.3", + "typescript": "^5.7.3", + "vitest": "^4.0.0" } }, "type": "module", "scripts": { - "dev": "bun run scripts/dev-runner.mjs dev", - "dev:server": "bun run scripts/dev-runner.mjs dev:server", - "dev:web": "bun run scripts/dev-runner.mjs dev:web", - "dev:desktop": "bun run scripts/dev-runner.mjs dev:desktop", + "dev": "node scripts/dev-runner.ts dev", + "dev:server": "node scripts/dev-runner.ts dev:server", + "dev:web": "node scripts/dev-runner.ts dev:web", + "dev:desktop": "node scripts/dev-runner.ts dev:desktop", "start": "turbo run start --filter=t3", "start:desktop": "turbo run start --filter=@t3tools/desktop", "build": "turbo run build", @@ -40,14 +46,10 @@ "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" }, "devDependencies": { - "@effect/platform-node": "catalog:", - "@types/node": "^24.10.13", - "effect": "catalog:", + "@types/node": "catalog:", "oxfmt": "^0.35.0", "oxlint": "^1.50.0", - "turbo": "^2.3.3", - "typescript": "^5.7.3", - "vitest": "^4.0.0" + "turbo": "^2.3.3" }, "engines": { "bun": "^1.3.9", diff --git a/packages/contracts/package.json b/packages/contracts/package.json index aff1083454be39bf1ee7d4bb778342315a4e03f5..f135f4b333130aa8ead863310b4f0b3258e9f3c3 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -27,9 +27,9 @@ "effect": "catalog:" }, "devDependencies": { - "@effect/language-service": "0.75.1", + "@effect/language-service": "catalog:", "@effect/vitest": "catalog:", - "tsdown": "^0.20.3", - "typescript": "^5.7.3" + "tsdown": "catalog:", + "typescript": "catalog:" } } diff --git a/packages/shared/package.json b/packages/shared/package.json index b3c56640ff228b2f786f35ac8d06d4e3f43ae0ef..88c03898c86ba479e5a2078f69baa804e18f3e11 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -7,20 +7,25 @@ "./git": { "types": "./src/git.ts", "import": "./src/git.ts" + }, + "./Net": { + "types": "./src/Net.ts", + "import": "./src/Net.ts" } }, "scripts": { "prepare": "effect-language-service patch", "typecheck": "tsc --noEmit", - "test": "vitest run --passWithNoTests" + "test": "vitest run" }, "dependencies": { "effect": "catalog:" }, "devDependencies": { - "@effect/language-service": "0.75.1", + "@effect/language-service": "catalog:", "@effect/vitest": "catalog:", - "typescript": "^5.7.3", - "vitest": "^4.0.0" + "@types/node": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/shared/src/Net.test.ts b/packages/shared/src/Net.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..137a9416fd1524fac4bf80738ea9ce21111dca51 --- /dev/null +++ b/packages/shared/src/Net.test.ts @@ -0,0 +1,98 @@ +import * as Net from "node:net"; + +import { assert, describe, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { NetError, NetService } from "./Net"; + +const closeServer = (server: Net.Server) => + Effect.sync(() => { + try { + server.close(); + } catch { + // Ignore cleanup failures in tests. + } + }); + +const getPort = (server: Net.Server): number => { + const address = server.address(); + return typeof address === "object" && address !== null ? address.port : 0; +}; + +const openServer = (host?: string): Effect.Effect => + Effect.callback((resume) => { + const server = Net.createServer(); + let settled = false; + + const settle = (effect: Effect.Effect) => { + if (settled) return; + settled = true; + resume(effect); + }; + + server.once("error", (cause) => { + settle(Effect.fail(new NetError({ message: "Failed to open test server", cause }))); + }); + + if (host) { + server.listen(0, host, () => settle(Effect.succeed(server))); + } else { + server.listen(0, () => settle(Effect.succeed(server))); + } + + return closeServer(server); + }); + +it.layer(NetService.layer)("NetService", (it) => { + describe("Net helpers", () => { + it.effect("reserveLoopbackPort returns a positive loopback port", () => + Effect.gen(function* () { + const net = yield* NetService; + const port = yield* net.reserveLoopbackPort(); + + assert.ok(port > 0); + }), + ); + + it.effect("isPortAvailableOnLoopback reports false for an occupied port", () => + Effect.acquireUseRelease( + openServer("127.0.0.1"), + (server) => + Effect.gen(function* () { + const net = yield* NetService; + const port = getPort(server); + + const available = yield* net.isPortAvailableOnLoopback(port); + assert.equal(available, false); + }), + closeServer, + ), + ); + + it.effect("findAvailablePort returns preferred when it is free", () => + Effect.gen(function* () { + const net = yield* NetService; + const preferred = yield* net.reserveLoopbackPort(); + + const resolved = yield* net.findAvailablePort(preferred); + assert.equal(resolved, preferred); + }), + ); + + it.effect("findAvailablePort falls back when preferred is occupied", () => + Effect.acquireUseRelease( + openServer(), + (server) => + Effect.gen(function* () { + const net = yield* NetService; + const preferred = getPort(server); + + const resolved = yield* net.findAvailablePort(preferred); + assert.ok(resolved > 0); + assert.notEqual(resolved, preferred); + }), + closeServer, + ), + ); + }); +}); diff --git a/packages/shared/src/Net.ts b/packages/shared/src/Net.ts new file mode 100644 index 0000000000000000000000000000000000000000..e5ac354f924754b738452767806cd0eb1de0fffb --- /dev/null +++ b/packages/shared/src/Net.ts @@ -0,0 +1,180 @@ +import * as Net from "node:net"; + +import { Data, Effect, Layer, ServiceMap } from "effect"; + +export class NetError extends Data.TaggedError("NetError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +function isErrnoExceptionWithCode(cause: unknown): cause is { + readonly code: string; +} { + return ( + typeof cause === "object" && + cause !== null && + "code" in cause && + typeof (cause as { readonly code: unknown }).code === "string" + ); +} + +const closeServer = (server: Net.Server) => { + try { + server.close(); + } catch { + // Ignore close failures during cleanup. + } +}; + +const tryReservePort = (port: number): Effect.Effect => + Effect.callback((resume) => { + const server = Net.createServer(); + let settled = false; + + const settle = (effect: Effect.Effect) => { + if (settled) return; + settled = true; + resume(effect); + }; + + server.unref(); + + server.once("error", (cause) => { + settle(Effect.fail(new NetError({ message: "Could not find an available port.", cause }))); + }); + + server.listen(port, () => { + const address = server.address(); + const resolved = typeof address === "object" && address !== null ? address.port : 0; + server.close(() => { + if (resolved > 0) { + settle(Effect.succeed(resolved)); + return; + } + settle(Effect.fail(new NetError({ message: "Could not find an available port." }))); + }); + }); + + return Effect.sync(() => { + closeServer(server); + }); + }); + +export interface NetServiceShape { + /** + * Returns true when a TCP server can bind to {host, port}. + */ + readonly canListenOnHost: (port: number, host: string) => Effect.Effect; + + /** + * Checks loopback availability on both IPv4 and IPv6 localhost addresses. + */ + readonly isPortAvailableOnLoopback: (port: number) => Effect.Effect; + + /** + * Reserve an ephemeral loopback port and release it immediately. + */ + readonly reserveLoopbackPort: (host?: string) => Effect.Effect; + + /** + * Resolve an available listening port, preferring the provided port first. + */ + readonly findAvailablePort: (preferred: number) => Effect.Effect; +} + +/** + * NetService - Service tag for startup networking helpers. + */ +export class NetService extends ServiceMap.Service()( + "@t3tools/shared/Net/NetService", +) { + static readonly layer = Layer.sync(NetService, () => { + /** + * Returns true when a TCP server can bind to {host, port}. + * `EADDRNOTAVAIL` is treated as available so IPv6-absent hosts don't fail + * loopback availability checks. + */ + const canListenOnHost = (port: number, host: string): Effect.Effect => + Effect.callback((resume) => { + const server = Net.createServer(); + let settled = false; + + const settle = (value: boolean) => { + if (settled) return; + settled = true; + resume(Effect.succeed(value)); + }; + + server.unref(); + + server.once("error", (cause) => { + if (isErrnoExceptionWithCode(cause) && cause.code === "EADDRNOTAVAIL") { + settle(true); + return; + } + settle(false); + }); + + server.once("listening", () => { + server.close(() => { + settle(true); + }); + }); + + server.listen({ host, port }); + + return Effect.sync(() => { + closeServer(server); + }); + }); + + /** + * Reserve an ephemeral loopback port and release it immediately. + * Returns the reserved port number. + */ + const reserveLoopbackPort = (host = "127.0.0.1"): Effect.Effect => + Effect.callback((resume) => { + const probe = Net.createServer(); + let settled = false; + + const settle = (effect: Effect.Effect) => { + if (settled) return; + settled = true; + resume(effect); + }; + + probe.once("error", (cause) => { + settle(Effect.fail(new NetError({ message: "Failed to reserve loopback port", cause }))); + }); + + probe.listen(0, host, () => { + const address = probe.address(); + const port = typeof address === "object" && address !== null ? address.port : 0; + probe.close(() => { + if (port > 0) { + settle(Effect.succeed(port)); + return; + } + settle(Effect.fail(new NetError({ message: "Failed to reserve loopback port" }))); + }); + }); + + return Effect.sync(() => { + closeServer(probe); + }); + }); + + return { + canListenOnHost, + isPortAvailableOnLoopback: (port) => + Effect.zipWith( + canListenOnHost(port, "127.0.0.1"), + canListenOnHost(port, "::1"), + (ipv4, ipv6) => ipv4 && ipv6, + ), + reserveLoopbackPort, + findAvailablePort: (preferred) => + Effect.catch(tryReservePort(preferred), () => tryReservePort(0)), + } satisfies NetServiceShape; + }); +} diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 813ad968984f46ebb4e9add3c63a0bef62a01d38..1c73cbf5a0832a4a3bad9969ac71eb49ae7e68cf 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -10,7 +10,7 @@ import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { Config, Data, Effect, FileSystem, Logger, Option, Path, Schema } from "effect"; +import { Config, Data, Effect, FileSystem, Layer, Logger, Option, Path, Schema } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -23,6 +23,7 @@ const RepoRoot = Effect.service(Path.Path).pipe( const IconSource = Effect.zipWith(RepoRoot, Effect.service(Path.Path), (repoRoot, path) => path.join(repoRoot, "assets/macos-icon-1024.png"), ); +const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString); interface PlatformConfig { readonly cliFlag: "--mac" | "--linux" | "--win"; @@ -290,9 +291,9 @@ function validateBundledClientAssets(clientDir: string) { const path = yield* Path.Path; const indexPath = path.join(clientDir, "index.html"); const indexHtml = yield* fs.readFileString(indexPath); - const refs = [...indexHtml.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)].map( - (match) => match[1], - ); + const refs = [...indexHtml.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)] + .map((match) => match[1]) + .filter((value): value is string => value !== undefined); const missing: string[] = []; for (const ref of refs) { @@ -505,10 +506,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( }, }; - yield* fs.writeFileString( - path.join(stageAppDir, "package.json"), - `${JSON.stringify(stagePackageJson, null, 2)}\n`, - ); + const stagePackageJsonString = yield* encodeJsonString(stagePackageJson); + yield* fs.writeFileString(path.join(stageAppDir, "package.json"), `${stagePackageJsonString}\n`); yield* Effect.log("[desktop-artifact] Installing staged production dependencies..."); yield* runCommand( @@ -626,9 +625,10 @@ const buildDesktopArtifactCli = Command.make("build-desktop-artifact", { Command.withHandler((input) => Effect.flatMap(resolveBuildOptions(input), buildDesktopArtifact)), ); +const cliRuntimeLayer = Layer.mergeAll(Logger.layer([Logger.consolePretty()]), NodeServices.layer); + Command.run(buildDesktopArtifactCli, { version: "0.0.0" }).pipe( Effect.scoped, - Effect.provide(Logger.layer([Logger.consolePretty()])), - Effect.provide(NodeServices.layer), + Effect.provide(cliRuntimeLayer), NodeRuntime.runMain, ); diff --git a/scripts/dev-runner.mjs b/scripts/dev-runner.mjs deleted file mode 100644 index 8861c1b8378efc2b59f6745f084f437414d58422..0000000000000000000000000000000000000000 --- a/scripts/dev-runner.mjs +++ /dev/null @@ -1,425 +0,0 @@ -import { spawn } from "node:child_process"; -import net from "node:net"; -import { homedir } from "node:os"; -import path from "node:path"; -import { pathToFileURL } from "node:url"; - -const BASE_SERVER_PORT = 3773; -const BASE_WEB_PORT = 5733; -const MAX_HASH_OFFSET = 3000; -const MAX_PORT = 65535; -export const DEFAULT_DEV_STATE_DIR = path.join(homedir(), ".t3", "dev"); -const MODE_ARGS = { - dev: [ - "run", - "dev", - "--ui=tui", - "--filter=@t3tools/contracts", - "--filter=@t3tools/web", - "--filter=t3", - "--parallel", - ], - "dev:server": ["run", "dev", "--filter=t3"], - "dev:web": ["run", "dev", "--filter=@t3tools/web"], - "dev:desktop": ["run", "dev", "--filter=@t3tools/desktop", "--filter=@t3tools/web", "--parallel"], -}; -const FORWARDED_ENV_FLAGS = { - "state-dir": { envName: "T3CODE_STATE_DIR", expectsValue: true }, - "auth-token": { envName: "T3CODE_AUTH_TOKEN", expectsValue: true }, - token: { envName: "T3CODE_AUTH_TOKEN", expectsValue: true }, - "no-browser": { envName: "T3CODE_NO_BROWSER", expectsValue: false }, - "auto-bootstrap-project-from-cwd": { - envName: "T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD", - expectsValue: false, - }, - "log-websocket-events": { envName: "T3CODE_LOG_WS_EVENTS", expectsValue: false }, - "log-ws-events": { envName: "T3CODE_LOG_WS_EVENTS", expectsValue: false }, - host: { envName: "T3CODE_HOST", expectsValue: true }, - port: { envName: "T3CODE_PORT", expectsValue: true }, - "dev-url": { envName: "VITE_DEV_SERVER_URL", expectsValue: true }, -}; - -function parseInteger(value, envName) { - const parsed = Number(value); - if (!Number.isInteger(parsed)) { - throw new Error(`Invalid ${envName}: ${value}`); - } - return parsed; -} - -function hashSeed(seed) { - let hash = 0x811c9dc5; - for (let i = 0; i < seed.length; i += 1) { - hash ^= seed.charCodeAt(i); - hash = Math.imul(hash, 0x01000193); - } - return hash >>> 0; -} - -export function resolveOffset(env = process.env) { - 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}`); - } - return { offset: parsed, source: `T3CODE_PORT_OFFSET=${explicitOffset}` }; - } - - const seed = env.T3CODE_DEV_INSTANCE?.trim(); - if (!seed) { - return { offset: 0, source: "default ports" }; - } - - if (/^\d+$/.test(seed)) { - return { offset: Number(seed), source: `numeric T3CODE_DEV_INSTANCE=${seed}` }; - } - - const offset = (hashSeed(seed) % MAX_HASH_OFFSET) + 1; - return { offset, source: `hashed T3CODE_DEV_INSTANCE=${seed}` }; -} - -function parseLongFlag(raw) { - if (!raw.startsWith("--")) return null; - const normalized = raw.slice(2); - const equalsIndex = normalized.indexOf("="); - if (equalsIndex === -1) { - return { name: normalized, value: undefined }; - } - return { - name: normalized.slice(0, equalsIndex), - value: normalized.slice(equalsIndex + 1), - }; -} - -function toBooleanEnvValue(value) { - if (value === undefined) return "1"; - const normalized = value.trim().toLowerCase(); - if (normalized === "false" || normalized === "0" || normalized === "no") return "0"; - return "1"; -} - -export function parseDevRunnerArgs(rawArgs) { - const args = rawArgs[0] === "--" ? rawArgs.slice(1) : rawArgs.slice(); - const envOverrides = {}; - const turboArgs = []; - let isDryRun = false; - - for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - if (arg === "--dry-run") { - isDryRun = true; - continue; - } - - if (arg === "--") { - turboArgs.push(...args.slice(index + 1)); - break; - } - - const parsedFlag = parseLongFlag(arg); - if (!parsedFlag) { - turboArgs.push(arg); - continue; - } - - const definition = FORWARDED_ENV_FLAGS[parsedFlag.name]; - if (!definition) { - turboArgs.push(arg); - continue; - } - - if (definition.expectsValue) { - let value = parsedFlag.value; - if (value === undefined) { - const next = args[index + 1]; - if (next === undefined || next === "--" || next.startsWith("-")) { - throw new Error(`Missing value for --${parsedFlag.name}`); - } - value = next; - index += 1; - } - envOverrides[definition.envName] = value; - continue; - } - - envOverrides[definition.envName] = toBooleanEnvValue(parsedFlag.value); - } - - return { - isDryRun, - envOverrides, - turboArgs, - }; -} - -function resolveStateDir(env, envOverrides) { - const overrideValue = envOverrides?.T3CODE_STATE_DIR?.trim(); - if (overrideValue) { - // 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 DEFAULT_DEV_STATE_DIR; -} - -export function createDevRunnerEnv({ mode, env, offset, envOverrides }) { - const serverPort = BASE_SERVER_PORT + offset; - const webPort = BASE_WEB_PORT + offset; - - const output = { - ...env, - 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, - }; - - output.T3CODE_STATE_DIR = resolveStateDir(env, envOverrides); - - const parsedServerPort = Number(output.T3CODE_PORT); - if (!Number.isInteger(parsedServerPort) || parsedServerPort < 1 || parsedServerPort > 65535) { - throw new Error( - `Invalid T3CODE_PORT override: '${String(output.T3CODE_PORT)}'. Expected an integer between 1 and 65535.`, - ); - } - output.VITE_WS_URL = `ws://localhost:${parsedServerPort}`; - - if (mode === "dev" || mode === "dev:server" || mode === "dev:web") { - // Running server/web in browser mode should not inherit desktop launcher state. - 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; - } - delete output.T3CODE_DESKTOP_WS_URL; - } - - if (mode === "dev" && !output.T3CODE_LOG_WS_EVENTS) { - output.T3CODE_LOG_WS_EVENTS = "1"; - } - - return output; -} - -function portPairForOffset(offset) { - return { - serverPort: BASE_SERVER_PORT + offset, - webPort: BASE_WEB_PORT + offset, - }; -} - -async function canListenOnHost(port, host) { - return await new Promise((resolve) => { - const server = net.createServer(); - server.unref(); - server.once("error", (error) => { - const code = error?.code; - if (code === "EADDRNOTAVAIL") { - // Host family unavailable on this machine (for example, no IPv6 loopback). - resolve(true); - return; - } - resolve(false); - }); - server.once("listening", () => { - server.close(() => { - resolve(true); - }); - }); - server.listen({ host, port }); - }); -} - -async function isPortAvailable(port) { - // Vite/dev traffic uses localhost loopback. Verify both IPv4 and IPv6 loopback - // so we don't pick a port that fails later on one address family. - const [ipv4, ipv6] = await Promise.all([ - canListenOnHost(port, "127.0.0.1"), - canListenOnHost(port, "::1"), - ]); - return ipv4 && ipv6; -} - -const defaultCheckPortAvailability = isPortAvailable; - -export async function findFirstAvailableOffset({ - startOffset, - requireServerPort, - requireWebPort, - checkPortAvailability = defaultCheckPortAvailability, -}) { - 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) || - (!requireServerPort && !requireWebPort && (serverPortOutOfRange || webPortOutOfRange)) - ) { - break; - } - - const checks = []; - if (requireServerPort) { - checks.push(checkPortAvailability(serverPort)); - } - if (requireWebPort) { - checks.push(checkPortAvailability(webPort)); - } - - if (checks.length === 0) { - return candidate; - } - - const availability = await Promise.all(checks); - if (availability.every(Boolean)) { - return candidate; - } - } - - throw new Error( - `No available dev ports found from offset ${startOffset}. Tried server=${BASE_SERVER_PORT}+n web=${BASE_WEB_PORT}+n up to port ${MAX_PORT}.`, - ); -} - -export async function resolveModePortOffsets({ - mode, - startOffset, - envOverrides, - checkPortAvailability, -}) { - const hasExplicitServerPort = typeof envOverrides.T3CODE_PORT === "string"; - const hasExplicitDevUrl = typeof envOverrides.VITE_DEV_SERVER_URL === "string"; - - if (mode === "dev:web") { - if (hasExplicitDevUrl) { - return { serverOffset: startOffset, webOffset: startOffset }; - } - const webOffset = await findFirstAvailableOffset({ - startOffset, - requireServerPort: false, - requireWebPort: true, - checkPortAvailability, - }); - return { serverOffset: startOffset, webOffset }; - } - - if (mode === "dev:server") { - if (hasExplicitServerPort) { - return { serverOffset: startOffset, webOffset: startOffset }; - } - const serverOffset = await findFirstAvailableOffset({ - startOffset, - requireServerPort: true, - requireWebPort: false, - checkPortAvailability, - }); - return { serverOffset, webOffset: serverOffset }; - } - - const sharedOffset = await findFirstAvailableOffset({ - startOffset, - requireServerPort: !hasExplicitServerPort, - requireWebPort: !hasExplicitDevUrl, - checkPortAvailability, - }); - return { serverOffset: sharedOffset, webOffset: sharedOffset }; -} - -export async function runDevRunner(argv = process.argv) { - const mode = argv[2]; - const parsedArgs = parseDevRunnerArgs(argv.slice(3)); - if (!mode || !(mode in MODE_ARGS)) { - const supportedModes = Object.keys(MODE_ARGS).join(", "); - throw new Error(`Usage: bun scripts/dev-runner.mjs . Supported modes: ${supportedModes}`); - } - - const { offset, source } = resolveOffset(); - const { serverOffset, webOffset } = await resolveModePortOffsets({ - mode, - startOffset: offset, - envOverrides: parsedArgs.envOverrides, - }); - - const mergedEnvOverrides = { ...parsedArgs.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 = createDevRunnerEnv({ - mode, - env: process.env, - offset: serverOffset, - envOverrides: mergedEnvOverrides, - }); - - 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 - ) { - throw new Error( - `Invalid computed dev ports: server='${String(env.T3CODE_PORT)}' web='${String(env.PORT)}'.`, - ); - } - - const selectionSuffix = - serverOffset !== offset || webOffset !== offset - ? ` selectedOffset(server=${serverOffset},web=${webOffset})` - : ""; - - console.info( - `[dev-runner] mode=${mode} source=${source}${selectionSuffix} serverPort=${parsedServerPort} webPort=${parsedWebPort} stateDir=${env.T3CODE_STATE_DIR}`, - ); - - if (parsedArgs.isDryRun) { - return; - } - - const command = process.platform === "win32" ? "turbo.cmd" : "turbo"; - const child = spawn(command, [...MODE_ARGS[mode], ...parsedArgs.turboArgs], { - stdio: "inherit", - env, - }); - - child.on("exit", (code, signal) => { - if (signal) { - process.kill(process.pid, signal); - return; - } - process.exit(code ?? 0); - }); - - child.on("error", (error) => { - console.error("[dev-runner] failed to start turbo", error); - process.exit(1); - }); -} - -const entrypointPath = process.argv[1] ? pathToFileURL(process.argv[1]).href : ""; - -if (import.meta.url === entrypointPath) { - runDevRunner().catch((error) => { - console.error("[dev-runner]", error); - process.exit(1); - }); -} diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index eafa3c8c8713ab1ab96c8e08ba2e7f779435b09b..3491c37e0e3f739fa2fa690dcbf42ceba9628628 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -1,223 +1,225 @@ -import { describe, expect, it } from "vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { Effect } from "effect"; import { DEFAULT_DEV_STATE_DIR, createDevRunnerEnv, findFirstAvailableOffset, - parseDevRunnerArgs, resolveModePortOffsets, -} from "./dev-runner.mjs"; - -describe("parseDevRunnerArgs", () => { - it("maps supported server flags to env overrides and keeps other flags for turbo", () => { - const parsed = parseDevRunnerArgs([ - "--", - "--state-dir", - "~/alt-state", - "--token=abc123", - "--no-browser=false", - "--dry-run", - "--continue", - ]); - - expect(parsed.isDryRun).toBe(true); - expect(parsed.envOverrides).toEqual({ - T3CODE_STATE_DIR: "~/alt-state", - T3CODE_AUTH_TOKEN: "abc123", - T3CODE_NO_BROWSER: "0", - }); - expect(parsed.turboArgs).toEqual(["--continue"]); - }); - - it("throws when a value flag is missing its value", () => { - expect(() => parseDevRunnerArgs(["--state-dir"])).toThrow("Missing value for --state-dir"); - }); - - it("throws when a value flag is followed by another flag token", () => { - expect(() => parseDevRunnerArgs(["--state-dir", "--continue"])).toThrow( - "Missing value for --state-dir", - ); - }); -}); +} 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; + + assert.equal(env.T3CODE_STATE_DIR, defaultStateDir); + }), + ); + + 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: {}, + }); + + assert.equal(env.T3CODE_STATE_DIR, "/tmp/existing-state"); + }), + ); + + 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" }, + }); + + assert.equal(env.T3CODE_STATE_DIR, "/tmp/override-state"); + }), + ); + + 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: " " }, + }); + + assert.equal(env.T3CODE_STATE_DIR, "/tmp/existing-state"); + }), + ); -describe("createDevRunnerEnv", () => { - it("defaults state dir to ~/.t3/dev when not provided", () => { - const env = createDevRunnerEnv({ - mode: "dev", - env: {}, - offset: 0, - envOverrides: {}, - }); - - expect(env.T3CODE_STATE_DIR).toBe(DEFAULT_DEV_STATE_DIR); - }); - - it("uses existing env state dir when --state-dir is not provided", () => { - const env = createDevRunnerEnv({ - mode: "dev:server", - env: { T3CODE_STATE_DIR: "/tmp/existing-state" }, - offset: 0, - envOverrides: {}, - }); - - expect(env.T3CODE_STATE_DIR).toBe("/tmp/existing-state"); - }); - - it("lets --state-dir override existing env state dir", () => { - const env = createDevRunnerEnv({ - mode: "dev:server", - env: { T3CODE_STATE_DIR: "/tmp/existing-state" }, - offset: 0, - envOverrides: { T3CODE_STATE_DIR: "/tmp/override-state" }, - }); - - expect(env.T3CODE_STATE_DIR).toBe("/tmp/override-state"); - }); - - it("treats whitespace-only --state-dir as missing and falls back to env/default", () => { - const env = createDevRunnerEnv({ - mode: "dev:server", - env: { T3CODE_STATE_DIR: "/tmp/existing-state" }, - offset: 0, - envOverrides: { T3CODE_STATE_DIR: " " }, - }); - - expect(env.T3CODE_STATE_DIR).toBe("/tmp/existing-state"); - }); - - it("recomputes websocket url when port is overridden", () => { - const env = createDevRunnerEnv({ - mode: "dev", - env: {}, - offset: 0, - envOverrides: { T3CODE_PORT: "4222" }, - }); - - expect(env.T3CODE_PORT).toBe("4222"); - expect(env.VITE_WS_URL).toBe("ws://localhost:4222"); - }); - - it("keeps explicitly forwarded web-mode flags", () => { - const env = createDevRunnerEnv({ - mode: "dev", - env: { - T3CODE_AUTH_TOKEN: "desktop-token", - }, - offset: 0, - envOverrides: { - T3CODE_NO_BROWSER: "1", - T3CODE_AUTH_TOKEN: "cli-token", - }, - }); - - expect(env.T3CODE_NO_BROWSER).toBe("1"); - expect(env.T3CODE_AUTH_TOKEN).toBe("cli-token"); - }); - - it("fails fast for invalid port overrides", () => { - expect(() => - createDevRunnerEnv({ + it.effect("recomputes websocket url when port is overridden", () => + Effect.gen(function* () { + const env = yield* createDevRunnerEnv({ mode: "dev", env: {}, offset: 0, - envOverrides: { T3CODE_PORT: "not-a-port" }, - }), - ).toThrow("Invalid T3CODE_PORT override"); - }); + envOverrides: { T3CODE_PORT: "4222" }, + }); + + assert.equal(env.T3CODE_PORT, "4222"); + assert.equal(env.VITE_WS_URL, "ws://localhost:4222"); + }), + ); + + 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", + }, + }); + + assert.equal(env.T3CODE_NO_BROWSER, "1"); + assert.equal(env.T3CODE_AUTH_TOKEN, "cli-token"); + }), + ); + + it.effect("fails fast for invalid port overrides", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + createDevRunnerEnv({ + mode: "dev", + env: {}, + offset: 0, + envOverrides: { T3CODE_PORT: "not-a-port" }, + }), + ); + + assert.ok(error.message.includes("Invalid T3CODE_PORT override")); + }), + ); }); -describe("findFirstAvailableOffset", () => { - it("returns the starting offset when required ports are available", async () => { - const offset = await findFirstAvailableOffset({ - startOffset: 0, - requireServerPort: true, - requireWebPort: true, - checkPortAvailability: async () => true, - }); - - expect(offset).toBe(0); - }); - - it("advances until all required ports are available", async () => { - const taken = new Set([3773, 5733, 3774, 5734]); - const offset = await findFirstAvailableOffset({ - startOffset: 0, - requireServerPort: true, - requireWebPort: true, - checkPortAvailability: async (port) => !taken.has(port), - }); - - expect(offset).toBe(2); - }); - - it("allows offsets where only non-required ports exceed max", async () => { - const offset = await findFirstAvailableOffset({ - startOffset: 59_803, - requireServerPort: true, - requireWebPort: false, - checkPortAvailability: async () => true, - }); - - expect(offset).toBe(59_803); - }); +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); + }), + ); }); -describe("resolveModePortOffsets", () => { - it("uses a shared fallback offset for dev mode", async () => { - const taken = new Set([3773, 5733]); - const offsets = await resolveModePortOffsets({ - mode: "dev", - startOffset: 0, - envOverrides: {}, - checkPortAvailability: async (port) => !taken.has(port), - }); - - expect(offsets).toEqual({ serverOffset: 1, webOffset: 1 }); - }); - - it("keeps server offset stable for dev:web and only shifts web offset", async () => { - const taken = new Set([5733]); - const offsets = await resolveModePortOffsets({ - mode: "dev:web", - startOffset: 0, - envOverrides: {}, - checkPortAvailability: async (port) => !taken.has(port), - }); - - expect(offsets).toEqual({ serverOffset: 0, webOffset: 1 }); - }); - - it("shifts only server offset for dev:server", async () => { - const taken = new Set([3773]); - const offsets = await resolveModePortOffsets({ - mode: "dev:server", - startOffset: 0, - envOverrides: {}, - checkPortAvailability: async (port) => !taken.has(port), - }); - - expect(offsets).toEqual({ serverOffset: 1, webOffset: 1 }); - }); - - it("respects explicit dev-url override for dev:web", async () => { - const offsets = await resolveModePortOffsets({ - mode: "dev:web", - startOffset: 0, - envOverrides: { VITE_DEV_SERVER_URL: "http://localhost:9999" }, - checkPortAvailability: async () => false, - }); - - expect(offsets).toEqual({ serverOffset: 0, webOffset: 0 }); - }); - - it("respects explicit server port override for dev:server", async () => { - const offsets = await resolveModePortOffsets({ - mode: "dev:server", - startOffset: 0, - envOverrides: { T3CODE_PORT: "4888" }, - checkPortAvailability: async () => false, - }); - - expect(offsets).toEqual({ serverOffset: 0, webOffset: 0 }); - }); +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 }); + }), + ); }); diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b8a396f319b51c3f13b4b1496b1b6f31c5c2447 --- /dev/null +++ b/scripts/dev-runner.ts @@ -0,0 +1,527 @@ +#!/usr/bin/env node + +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 { Argument, Command, Flag } from "effect/unstable/cli"; +import { ChildProcess } from "effect/unstable/process"; + +const BASE_SERVER_PORT = 3773; +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"); +}); + +const MODE_ARGS = { + dev: [ + "run", + "dev", + "--ui=tui", + "--filter=@t3tools/contracts", + "--filter=@t3tools/web", + "--filter=t3", + "--parallel", + ], + "dev:server": ["run", "dev", "--filter=t3"], + "dev:web": ["run", "dev", "--filter=@t3tools/web"], + "dev:desktop": ["run", "dev", "--filter=@t3tools/desktop", "--filter=@t3tools/web", "--parallel"], +} 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; + +class DevRunnerError extends Data.TaggedError("DevRunnerError")<{ + readonly message: string; + 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; +} + +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; +} + +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}`); + } + return { offset: parsed, source: `T3CODE_PORT_OFFSET=${explicitOffset}` }; + } + + const seed = env.T3CODE_DEV_INSTANCE?.trim(); + if (!seed) { + return { offset: 0, source: "default ports" }; + } + + if (/^\d+$/.test(seed)) { + return { offset: Number(seed), source: `numeric T3CODE_DEV_INSTANCE=${seed}` }; + } + + const offset = (hashSeed(seed) % 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 { + return Effect.gen(function* () { + const path = yield* Path.Path; + const overrideValue = envOverrides?.T3CODE_STATE_DIR?.trim(); + if (overrideValue) { + // 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 yield* DEFAULT_DEV_STATE_DIR; + }); +} + +interface CreateDevRunnerEnvInput { + readonly mode: DevMode; + readonly env: NodeJS.ProcessEnv; + readonly offset: number; + readonly envOverrides: EnvOverrides; +} + +export function createDevRunnerEnv({ + mode, + env, + offset, + envOverrides, +}: CreateDevRunnerEnvInput): Effect.Effect { + return Effect.gen(function* () { + const serverPort = BASE_SERVER_PORT + offset; + const webPort = BASE_WEB_PORT + offset; + + const output: NodeJS.ProcessEnv = { + ...env, + 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, + }; + + output.T3CODE_STATE_DIR = yield* resolveStateDir(env, envOverrides); + + 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 (mode === "dev" || mode === "dev:server" || mode === "dev:web") { + // Running server/web in browser mode should not inherit desktop launcher state. + 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; + } + delete output.T3CODE_DESKTOP_WS_URL; + } + + if (mode === "dev" && !output.T3CODE_LOG_WS_EVENTS) { + output.T3CODE_LOG_WS_EVENTS = "1"; + } + + return output; + }); +} + +function portPairForOffset(offset: number): { readonly serverPort: number; readonly webPort: number } { + return { + serverPort: BASE_SERVER_PORT + offset, + webPort: BASE_WEB_PORT + offset, + }; +} + +const defaultCheckPortAvailability: PortAvailabilityCheck = (port) => + Effect.gen(function* () { + const net = yield* NetService; + return yield* net.isPortAvailableOnLoopback(port); + }); + +interface FindFirstAvailableOffsetInput { + readonly startOffset: number; + readonly requireServerPort: boolean; + readonly requireWebPort: boolean; + readonly checkPortAvailability?: PortAvailabilityCheck; +} + +export function findFirstAvailableOffset({ + startOffset, + requireServerPort, + requireWebPort, + checkPortAvailability, +}: FindFirstAvailableOffsetInput): Effect.Effect { + return Effect.gen(function* () { + 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) || + (!requireServerPort && !requireWebPort && (serverPortOutOfRange || webPortOutOfRange)) + ) { + break; + } + + const checks: Array> = []; + if (requireServerPort) { + checks.push(checkPort(serverPort)); + } + if (requireWebPort) { + checks.push(checkPort(webPort)); + } + + if (checks.length === 0) { + return candidate; + } + + const availability = yield* Effect.all(checks); + if (availability.every(Boolean)) { + return candidate; + } + } + + return yield* new DevRunnerError({ + message: `No available dev ports found from offset ${startOffset}. Tried server=${BASE_SERVER_PORT}+n web=${BASE_WEB_PORT}+n up to port ${MAX_PORT}.`, + }); + }); +} + +interface ResolveModePortOffsetsInput { + readonly mode: DevMode; + readonly startOffset: number; + readonly envOverrides: EnvOverrides; + readonly checkPortAvailability?: PortAvailabilityCheck; +} + +export function resolveModePortOffsets({ + mode, + startOffset, + envOverrides, + checkPortAvailability, +}: ResolveModePortOffsetsInput): Effect.Effect< + { readonly serverOffset: number; readonly webOffset: number }, + DevRunnerError, + 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"; + + if (mode === "dev:web") { + if (hasExplicitDevUrl) { + return { serverOffset: startOffset, webOffset: startOffset }; + } + const webOffset = yield* findFirstAvailableOffset({ + startOffset, + requireServerPort: false, + requireWebPort: true, + checkPortAvailability: checkPort, + }); + return { serverOffset: startOffset, webOffset }; + } + + if (mode === "dev:server") { + if (hasExplicitServerPort) { + return { serverOffset: startOffset, webOffset: startOffset }; + } + const serverOffset = yield* findFirstAvailableOffset({ + startOffset, + requireServerPort: true, + requireWebPort: false, + checkPortAvailability: checkPort, + }); + return { serverOffset, webOffset: serverOffset }; + } + + const sharedOffset = yield* findFirstAvailableOffset({ + startOffset, + requireServerPort: !hasExplicitServerPort, + 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 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 { offset, source } = yield* Effect.try({ + try: () => resolveOffset(), + catch: (cause) => + new DevRunnerError({ + message: cause instanceof Error ? cause.message : String(cause), + cause, + }), + }); + + const { serverOffset, webOffset } = yield* resolveModePortOffsets({ + mode: input.mode, + startOffset: offset, + envOverrides, + }); + + 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, + }); + + 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)}`, + ); + + if (input.dryRun) { + return; + } + + const command = process.platform === "win32" ? "turbo.cmd" : "turbo"; + const child = yield* ChildProcess.make(command, [...MODE_ARGS[input.mode], ...turboArgs], { + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + env, + extendEnv: false, + }); + + const exitCode = Number(yield* child.exitCode); + if (exitCode !== 0) { + return yield* new DevRunnerError({ + message: `turbo exited with code ${exitCode}`, + }); + } + }).pipe( + Effect.mapError((cause) => + cause instanceof DevRunnerError + ? cause + : new DevRunnerError({ + message: cause instanceof Error ? cause.message : "dev-runner failed", + cause, + }), + ), + ); +} + +const devRunnerCli = Command.make("dev-runner", { + mode: Argument.choice("mode", DEV_RUNNER_MODES).pipe( + Argument.withDescription("Development mode to run."), + ), + stateDir: Flag.string("state-dir").pipe( + Flag.withDescription("State directory path (forwards to T3CODE_STATE_DIR)."), + Flag.optional, + ), + authToken: Flag.string("auth-token").pipe( + Flag.withDescription("Auth token (forwards to T3CODE_AUTH_TOKEN)."), + Flag.withAlias("token"), + Flag.optional, + ), + noBrowser: Flag.string("no-browser").pipe( + Flag.withDescription("Browser auto-open toggle. Accepts true/false/1/0/no."), + Flag.optional, + ), + autoBootstrapProjectFromCwd: Flag.string("auto-bootstrap-project-from-cwd").pipe( + Flag.withDescription("Auto-bootstrap toggle. Accepts true/false/1/0/no."), + Flag.optional, + ), + logWebSocketEvents: Flag.string("log-websocket-events").pipe( + Flag.withDescription("WebSocket event logging toggle. Accepts true/false/1/0/no."), + Flag.withAlias("log-ws-events"), + Flag.optional, + ), + host: Flag.string("host").pipe( + Flag.withDescription("Server host/interface override (forwards to T3CODE_HOST)."), + Flag.optional, + ), + port: Flag.string("port").pipe( + Flag.withDescription("Server port override (forwards to T3CODE_PORT)."), + Flag.optional, + ), + devUrl: Flag.string("dev-url").pipe( + Flag.withDescription("Web dev URL override (forwards to VITE_DEV_SERVER_URL)."), + Flag.optional, + ), + dryRun: Flag.boolean("dry-run").pipe( + Flag.withDescription("Resolve mode/ports/env and print, but do not spawn turbo."), + Flag.withDefault(false), + ), + turboArgs: Argument.string("turbo-arg").pipe( + Argument.withDescription("Additional turbo args (pass after `--`)."), + Argument.variadic(), + ), +}).pipe( + Command.withDescription("Run monorepo development modes with deterministic port/env wiring."), + 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( + Logger.layer([Logger.consolePretty()]), + NodeServices.layer, + NetService.layer, +); + +const runtimeProgram = runDevRunner().pipe(Effect.scoped, Effect.provide(runtimeLayer)); + +if (import.meta.main) { + NodeRuntime.runMain(runtimeProgram); +} diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 0000000000000000000000000000000000000000..f92e17f371e0efa7e1e76b21c7c5f8addccd4f4f --- /dev/null +++ b/scripts/package.json @@ -0,0 +1,22 @@ +{ + "name": "@t3tools/scripts", + "private": true, + "type": "module", + "scripts": { + "prepare": "effect-language-service patch", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@t3tools/contracts": "workspace:*", + "@t3tools/shared": "workspace:*", + "effect": "catalog:" + }, + "devDependencies": { + "@effect/language-service": "catalog:", + "@effect/vitest": "catalog:", + "@types/bun": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..e9ed7c8ae53f9ac9c86399f9422fede8fb693f52 --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "types": ["node", "bun"], + "lib": ["ES2023", "esnext.disposable"], + "noEmit": true, + "allowImportingTsExtensions": true, + "plugins": [ + { + "name": "@effect/language-service" + } + ] + }, + "include": ["**/*.ts"] +}