| ... | @@ -1,17 +1,19 @@ | ... | @@ -1,17 +1,19 @@ |
| 1 | import fs from "node:fs"; | | |
| 2 | import net from "node:net"; | 1 | import net from "node:net"; |
| 3 | import os from "node:os"; | 2 | import os from "node:os"; |
| 4 | import path from "node:path"; | | |
| 5 | import { fileURLToPath } from "node:url"; | | |
| 6 | | 3 | |
| 7 | import * as Data from "effect/Data"; | 4 | import { |
| 8 | import * as Effect from "effect/Effect"; | 5 | ServiceMap, |
| 9 | import * as Layer from "effect/Layer"; | 6 | Data, |
| 10 | import * as Option from "effect/Option"; | 7 | Effect, |
| 11 | import * as Config from "effect/Config"; | 8 | Layer, |
| 12 | import { ServiceMap } from "effect"; | 9 | Option, |
| 13 | import * as Command from "effect/unstable/cli/Command"; | 10 | Config, |
| 14 | import * as Flag from "effect/unstable/cli/Flag"; | 11 | Path, |
| | 12 | Schema, |
| | 13 | FileSystem, |
| | 14 | PlatformError, |
| | 15 | } from "effect"; |
| | 16 | import { Command, Flag } from "effect/unstable/cli"; |
| 15 | | 17 | |
| 16 | import { fixPath } from "./fixPath"; | 18 | import { fixPath } from "./fixPath"; |
| 17 | import { Open } from "./open"; | 19 | import { Open } from "./open"; |
| ... | @@ -31,22 +33,11 @@ interface CliInput { | ... | @@ -31,22 +33,11 @@ interface CliInput { |
| 31 | readonly token: Option.Option<string>; | 33 | readonly token: Option.Option<string>; |
| 32 | } | 34 | } |
| 33 | | 35 | |
| 34 | interface ResolvedCliConfig { | | |
| 35 | readonly port: number; | | |
| 36 | readonly mode: RuntimeMode; | | |
| 37 | readonly cwd: string; | | |
| 38 | readonly stateDir: string; | | |
| 39 | readonly devUrl: URL | undefined; | | |
| 40 | readonly noBrowser: boolean; | | |
| 41 | readonly authToken: string | undefined; | | |
| 42 | readonly staticDir: string | undefined; | | |
| 43 | } | | |
| 44 | | | |
| 45 | export interface CliConfigShape { | 36 | export interface CliConfigShape { |
| 46 | readonly cwd: string; | 37 | readonly cwd: string; |
| 47 | readonly fixPath: Effect.Effect<unknown, never>; | 38 | readonly fixPath: Effect.Effect<unknown, never>; |
| 48 | readonly findAvailablePort: (preferred: number) => Effect.Effect<number, StartupError>; | 39 | readonly findAvailablePort: (preferred: number) => Effect.Effect<number, StartupError>; |
| 49 | readonly resolveStaticDir: () => string | undefined; | 40 | readonly resolveStaticDir: () => Effect.Effect<string | undefined, PlatformError.PlatformError>; |
| 50 | } | 41 | } |
| 51 | | 42 | |
| 52 | export class CliConfig extends ServiceMap.Service<CliConfig, CliConfigShape>()( | 43 | export class CliConfig extends ServiceMap.Service<CliConfig, CliConfigShape>()( |
| ... | @@ -79,31 +70,22 @@ const CliEnvConfig = Config.all({ | ... | @@ -79,31 +70,22 @@ const CliEnvConfig = Config.all({ |
| 79 | ), | 70 | ), |
| 80 | }); | 71 | }); |
| 81 | | 72 | |
| 82 | function assertValidPort(value: number, source: string): Effect.Effect<number, StartupError> { | 73 | const expandHomePath = Effect.fn(function* (input: string) { |
| 83 | if (Number.isInteger(value) && value >= 1 && value <= 65535) { | 74 | const { join } = yield* Path.Path; |
| 84 | return Effect.succeed(value); | | |
| 85 | } | | |
| 86 | return Effect.fail( | | |
| 87 | new StartupError({ | | |
| 88 | message: `Invalid ${source}: ${value}. Expected an integer between 1 and 65535.`, | | |
| 89 | }), | | |
| 90 | ); | | |
| 91 | } | | |
| 92 | | | |
| 93 | function expandHomePath(input: string): string { | | |
| 94 | if (input === "~") return os.homedir(); | 75 | if (input === "~") return os.homedir(); |
| 95 | if (input.startsWith("~/")) { | 76 | if (input.startsWith("~/")) { |
| 96 | return path.join(os.homedir(), input.slice(2)); | 77 | return join(os.homedir(), input.slice(2)); |
| 97 | } | 78 | } |
| 98 | return input; | 79 | return input; |
| 99 | } | 80 | }); |
| 100 | | 81 | |
| 101 | function resolveStateDir(raw: string | undefined): string { | 82 | const resolveStateDir = Effect.fn(function* (raw: string | undefined) { |
| | 83 | const { join, resolve } = yield* Path.Path; |
| 102 | if (!raw || raw.trim().length === 0) { | 84 | if (!raw || raw.trim().length === 0) { |
| 103 | return path.join(os.homedir(), ".t3", "userdata"); | 85 | return join(os.homedir(), ".t3", "userdata"); |
| 104 | } | 86 | } |
| 105 | return path.resolve(expandHomePath(raw.trim())); | 87 | return resolve(yield* expandHomePath(raw.trim())); |
| 106 | } | 88 | }); |
| 107 | | 89 | |
| 108 | async function findAvailablePort(preferred: number): Promise<number> { | 90 | async function findAvailablePort(preferred: number): Promise<number> { |
| 109 | return new Promise((resolve, reject) => { | 91 | return new Promise((resolve, reject) => { |
| ... | @@ -126,69 +108,57 @@ async function findAvailablePort(preferred: number): Promise<number> { | ... | @@ -126,69 +108,57 @@ async function findAvailablePort(preferred: number): Promise<number> { |
| 126 | }); | 108 | }); |
| 127 | } | 109 | } |
| 128 | | 110 | |
| 129 | function resolveStaticDir(): string | undefined { | 111 | const resolveStaticDir = Effect.fn(function* () { |
| 130 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); | 112 | const { join, resolve } = yield* Path.Path; |
| 131 | | 113 | const { stat } = yield* FileSystem.FileSystem; |
| 132 | const bundledClient = path.resolve(__dirname, "client"); | 114 | const bundledClient = resolve(join(import.meta.dirname, "client")); |
| 133 | try { | 115 | const bundledStat = yield* stat(join(bundledClient, "index.html")).pipe( |
| 134 | const stat = fs.statSync(path.join(bundledClient, "index.html")); | 116 | Effect.orElseSucceed(() => undefined), |
| 135 | if (stat.isFile()) return bundledClient; | 117 | ); |
| 136 | } catch { | 118 | if (bundledStat) return bundledClient; |
| 137 | // Not bundled — check monorepo layout. | | |
| 138 | } | | |
| 139 | | | |
| 140 | const monorepoClient = path.resolve(__dirname, "../../web/dist"); | | |
| 141 | try { | | |
| 142 | const stat = fs.statSync(path.join(monorepoClient, "index.html")); | | |
| 143 | if (stat.isFile()) return monorepoClient; | | |
| 144 | } catch { | | |
| 145 | // Not found — probably dev mode. | | |
| 146 | } | | |
| 147 | | 119 | |
| | 120 | const monorepoClient = resolve(join(import.meta.dirname, "../../web/dist")); |
| | 121 | const monorepoStat = yield* stat(join(monorepoClient, "index.html")).pipe( |
| | 122 | Effect.orElseSucceed(() => undefined), |
| | 123 | ); |
| | 124 | if (monorepoStat) return monorepoClient; |
| 148 | return undefined; | 125 | return undefined; |
| 149 | } | 126 | }); |
| 150 | | | |
| 151 | function resolveCliConfig( | | |
| 152 | input: CliInput, | | |
| 153 | ): Effect.Effect<ResolvedCliConfig, StartupError, CliConfig> { | | |
| 154 | return Effect.gen(function* () { | | |
| 155 | const cliConfig = yield* CliConfig; | | |
| 156 | const env = yield* CliEnvConfig.asEffect().pipe( | | |
| 157 | Effect.mapError( | | |
| 158 | (cause) => | | |
| 159 | new StartupError({ | | |
| 160 | message: "Failed to read environment configuration", | | |
| 161 | cause, | | |
| 162 | }), | | |
| 163 | ), | | |
| 164 | ); | | |
| 165 | const mode: RuntimeMode = env.mode === "desktop" ? "desktop" : "web"; | | |
| 166 | | 127 | |
| 167 | const cliPort = Option.getOrUndefined(input.port); | 128 | const resolveCliConfig = Effect.fn(function* (input: CliInput) { |
| 168 | const requestedPort = | 129 | const cliConfig = yield* CliConfig; |
| 169 | cliPort === undefined ? env.port : yield* assertValidPort(cliPort, "--port"); | 130 | const env = yield* CliEnvConfig.asEffect().pipe( |
| | 131 | Effect.mapError( |
| | 132 | (cause) => |
| | 133 | new StartupError({ |
| | 134 | message: "Failed to read environment configuration", |
| | 135 | cause, |
| | 136 | }), |
| | 137 | ), |
| | 138 | ); |
| | 139 | const mode: RuntimeMode = env.mode === "desktop" ? "desktop" : "web"; |
| 170 | | 140 | |
| 171 | const port = | 141 | const cliPort = Option.getOrUndefined(input.port); |
| 172 | requestedPort ?? | 142 | const port = |
| 173 | (mode === "desktop" ? DEFAULT_PORT : yield* cliConfig.findAvailablePort(DEFAULT_PORT)); | 143 | (cliPort === undefined ? env.port : cliPort) ?? |
| 174 | const stateDir = resolveStateDir(env.stateDir); | 144 | (mode === "desktop" ? DEFAULT_PORT : yield* cliConfig.findAvailablePort(DEFAULT_PORT)); |
| 175 | const devUrl = env.devUrl; | 145 | const stateDir = yield* resolveStateDir(env.stateDir); |
| 176 | const noBrowser = env.noBrowser ?? mode === "desktop"; | 146 | const devUrl = env.devUrl; |
| 177 | const authToken = Option.getOrUndefined(input.token) ?? env.authToken; | 147 | const noBrowser = env.noBrowser ?? mode === "desktop"; |
| 178 | const staticDir = devUrl ? undefined : cliConfig.resolveStaticDir(); | 148 | const authToken = Option.getOrUndefined(input.token) ?? env.authToken; |
| | 149 | const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir(); |
| 179 | | 150 | |
| 180 | return { | 151 | return { |
| 181 | mode, | 152 | mode, |
| 182 | port, | 153 | port, |
| 183 | stateDir, | 154 | stateDir, |
| 184 | devUrl, | 155 | devUrl, |
| 185 | noBrowser, | 156 | noBrowser, |
| 186 | authToken, | 157 | authToken, |
| 187 | staticDir, | 158 | staticDir, |
| 188 | cwd: cliConfig.cwd, | 159 | cwd: cliConfig.cwd, |
| 189 | }; | 160 | }; |
| 190 | }); | 161 | }); |
| 191 | } | | |
| 192 | | 162 | |
| 193 | const makeServerProgram = Effect.fn(function* (input: CliInput) { | 163 | const makeServerProgram = Effect.fn(function* (input: CliInput) { |
| 194 | const cliConfig = yield* CliConfig; | 164 | const cliConfig = yield* CliConfig; |
| ... | @@ -253,6 +223,7 @@ const makeServerProgram = Effect.fn(function* (input: CliInput) { | ... | @@ -253,6 +223,7 @@ const makeServerProgram = Effect.fn(function* (input: CliInput) { |
| 253 | | 223 | |
| 254 | export function makeCliCommand() { | 224 | export function makeCliCommand() { |
| 255 | const portFlag = Flag.integer("port").pipe( | 225 | const portFlag = Flag.integer("port").pipe( |
| | 226 | Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))), |
| 256 | Flag.withDescription("Port for the HTTP/WebSocket server."), | 227 | Flag.withDescription("Port for the HTTP/WebSocket server."), |
| 257 | Flag.optional, | 228 | Flag.optional, |
| 258 | ); | 229 | ); |
| ... | @@ -270,14 +241,25 @@ export function makeCliCommand() { | ... | @@ -270,14 +241,25 @@ export function makeCliCommand() { |
| 270 | ); | 241 | ); |
| 271 | } | 242 | } |
| 272 | | 243 | |
| 273 | export const CliConfigLive = Layer.succeed(CliConfig, { | 244 | export const CliConfigLive = Layer.effect( |
| 274 | cwd: process.cwd(), | 245 | CliConfig, |
| 275 | fixPath: Effect.sync(fixPath), | 246 | Effect.gen(function* () { |
| 276 | findAvailablePort: (preferred) => | 247 | const fileSystem = yield* FileSystem.FileSystem; |
| 277 | Effect.tryPromise({ | 248 | const path = yield* Path.Path; |
| 278 | try: () => findAvailablePort(preferred), | 249 | return { |
| 279 | catch: (cause) => | 250 | cwd: process.cwd(), |
| 280 | new StartupError({ message: "Failed to discover an available port", cause }), | 251 | fixPath: Effect.sync(fixPath), |
| 281 | }), | 252 | findAvailablePort: (preferred) => |
| 282 | resolveStaticDir, | 253 | Effect.tryPromise({ |
| 283 | } satisfies CliConfigShape); | 254 | try: () => findAvailablePort(preferred), |
| | 255 | catch: (cause) => |
| | 256 | new StartupError({ message: "Failed to discover an available port", cause }), |
| | 257 | }), |
| | 258 | resolveStaticDir: () => |
| | 259 | resolveStaticDir().pipe( |
| | 260 | Effect.provideService(FileSystem.FileSystem, fileSystem), |
| | 261 | Effect.provideService(Path.Path, path), |
| | 262 | ), |
| | 263 | } satisfies CliConfigShape; |
| | 264 | }), |
| | 265 | ); |