| author | |
| committer | |
| log | 499f1463d5fe80350b34a6d511bdd873dd3d564f |
| tree | 562265f7a1c397b37b025e5906970889f57fff72 |
| parent | a2ff50dbba0725a53dfdb76184a54a621645ddd5 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Julius Marminge <julius@macmini.local>10 files changed, 2140 insertions(+), 2106 deletions(-)
apps/server/src/bin.test.ts created+357| ... | @@ -0,0 +1,357 @@ | ||
| 1 | import * as NodeHttp from "node:http"; | ||
| 2 | import { mkdtempSync } from "node:fs"; | ||
| 3 | import { tmpdir } from "node:os"; | ||
| 4 | import { join } from "node:path"; | ||
| 5 | |||
| 6 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; | ||
| 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 8 | import { NetService } from "@t3tools/shared/Net"; | ||
| 9 | import { assert, it } from "@effect/vitest"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as Layer from "effect/Layer"; | ||
| 12 | import * as HttpRouter from "effect/unstable/http/HttpRouter"; | ||
| 13 | import * as HttpServer from "effect/unstable/http/HttpServer"; | ||
| 14 | import * as CliError from "effect/unstable/cli/CliError"; | ||
| 15 | import * as TestConsole from "effect/testing/TestConsole"; | ||
| 16 | import { Command } from "effect/unstable/cli"; | ||
| 17 | |||
| 18 | import { cli } from "./bin.ts"; | ||
| 19 | import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts"; | ||
| 20 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; | ||
| 21 | import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; | ||
| 22 | import { | ||
| 23 | orchestrationDispatchRouteLayer, | ||
| 24 | orchestrationSnapshotRouteLayer, | ||
| 25 | } from "./orchestration/http.ts"; | ||
| 26 | import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; | ||
| 27 | import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; | ||
| 28 | import { | ||
| 29 | makePersistedServerRuntimeState, | ||
| 30 | persistServerRuntimeState, | ||
| 31 | } from "./serverRuntimeState.ts"; | ||
| 32 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; | ||
| 33 | import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; | ||
| 34 | import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; | ||
| 35 | |||
| 36 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); | ||
| 37 | |||
| 38 | const runCli = (args: ReadonlyArray<string>) => Command.runWith(cli, { version: "0.0.0" })(args); | ||
| 39 | const runCliWithRuntime = (args: ReadonlyArray<string>) => | ||
| 40 | runCli(args).pipe(Effect.provide(CliRuntimeLayer)); | ||
| 41 | |||
| 42 | const captureStdout = <A, E, R>(effect: Effect.Effect<A, E, R>) => | ||
| 43 | Effect.gen(function* () { | ||
| 44 | const result = yield* effect; | ||
| 45 | const output = | ||
| 46 | (yield* TestConsole.logLines).findLast((line): line is string => typeof line === "string") ?? | ||
| 47 | ""; | ||
| 48 | return { result, output }; | ||
| 49 | }).pipe(Effect.provide(Layer.mergeAll(CliRuntimeLayer, TestConsole.layer))); | ||
| 50 | |||
| 51 | const makeCliTestServerConfig = (baseDir: string) => | ||
| 52 | Effect.gen(function* () { | ||
| 53 | const derivedPaths = yield* deriveServerPaths(baseDir, undefined); | ||
| 54 | return { | ||
| 55 | logLevel: "Info", | ||
| 56 | traceMinLevel: "Info", | ||
| 57 | traceTimingEnabled: true, | ||
| 58 | traceBatchWindowMs: 200, | ||
| 59 | traceMaxBytes: 10 * 1024 * 1024, | ||
| 60 | traceMaxFiles: 10, | ||
| 61 | otlpTracesUrl: undefined, | ||
| 62 | otlpMetricsUrl: undefined, | ||
| 63 | otlpExportIntervalMs: 10_000, | ||
| 64 | otlpServiceName: "t3-server", | ||
| 65 | mode: "web", | ||
| 66 | port: 0, | ||
| 67 | host: "127.0.0.1", | ||
| 68 | cwd: process.cwd(), | ||
| 69 | baseDir, | ||
| 70 | ...derivedPaths, | ||
| 71 | staticDir: undefined, | ||
| 72 | devUrl: undefined, | ||
| 73 | noBrowser: true, | ||
| 74 | startupPresentation: "browser", | ||
| 75 | desktopBootstrapToken: undefined, | ||
| 76 | autoBootstrapProjectFromCwd: false, | ||
| 77 | logWebSocketEvents: false, | ||
| 78 | tailscaleServeEnabled: false, | ||
| 79 | tailscaleServePort: 443, | ||
| 80 | } satisfies ServerConfigShape; | ||
| 81 | }); | ||
| 82 | |||
| 83 | const makeProjectPersistenceLayer = (config: ServerConfigShape) => | ||
| 84 | Layer.mergeAll( | ||
| 85 | OrchestrationLayerLive.pipe( | ||
| 86 | Layer.provideMerge(RepositoryIdentityResolverLive), | ||
| 87 | Layer.provideMerge(SqlitePersistenceLayerLive), | ||
| 88 | ), | ||
| 89 | WorkspacePathsLive, | ||
| 90 | ).pipe( | ||
| 91 | Layer.provideMerge(NodeServices.layer), | ||
| 92 | Layer.provide(Layer.succeed(ServerConfig, config)), | ||
| 93 | ); | ||
| 94 | |||
| 95 | const readPersistedSnapshot = (baseDir: string) => | ||
| 96 | Effect.gen(function* () { | ||
| 97 | const config = yield* makeCliTestServerConfig(baseDir); | ||
| 98 | return yield* Effect.gen(function* () { | ||
| 99 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; | ||
| 100 | return yield* projectionSnapshotQuery.getSnapshot(); | ||
| 101 | }).pipe(Effect.provide(makeProjectPersistenceLayer(config))); | ||
| 102 | }); | ||
| 103 | |||
| 104 | const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Effect<A, E, R>) => | ||
| 105 | Effect.gen(function* () { | ||
| 106 | const config = yield* makeCliTestServerConfig(baseDir); | ||
| 107 | const routesLayer = Layer.mergeAll( | ||
| 108 | orchestrationSnapshotRouteLayer, | ||
| 109 | orchestrationDispatchRouteLayer, | ||
| 110 | ); | ||
| 111 | const appLayer = HttpRouter.serve(routesLayer, { | ||
| 112 | disableListenLog: true, | ||
| 113 | disableLogger: true, | ||
| 114 | }).pipe( | ||
| 115 | Layer.provideMerge( | ||
| 116 | ServerAuthLive.pipe( | ||
| 117 | Layer.provideMerge(SqlitePersistenceLayerLive), | ||
| 118 | Layer.provide(ServerSecretStoreLive), | ||
| 119 | ), | ||
| 120 | ), | ||
| 121 | Layer.provideMerge(makeProjectPersistenceLayer(config)), | ||
| 122 | Layer.provideMerge( | ||
| 123 | NodeHttpServer.layer(NodeHttp.createServer, { | ||
| 124 | host: "127.0.0.1", | ||
| 125 | port: 0, | ||
| 126 | }), | ||
| 127 | ), | ||
| 128 | Layer.provideMerge(NodeServices.layer), | ||
| 129 | Layer.provide(Layer.succeed(ServerConfig, config)), | ||
| 130 | ); | ||
| 131 | |||
| 132 | return yield* Effect.scoped( | ||
| 133 | Effect.gen(function* () { | ||
| 134 | const server = yield* HttpServer.HttpServer; | ||
| 135 | const address = server.address; | ||
| 136 | if (typeof address === "string" || !("port" in address)) { | ||
| 137 | assert.fail(`Expected TCP address, got ${address}`); | ||
| 138 | } | ||
| 139 | yield* persistServerRuntimeState({ | ||
| 140 | path: config.serverRuntimeStatePath, | ||
| 141 | state: makePersistedServerRuntimeState({ | ||
| 142 | config, | ||
| 143 | port: address.port, | ||
| 144 | }), | ||
| 145 | }); | ||
| 146 | return yield* run(); | ||
| 147 | }).pipe(Effect.provide(Layer.mergeAll(appLayer, NodeServices.layer))), | ||
| 148 | ); | ||
| 149 | }); | ||
| 150 | |||
| 151 | it.layer(NodeServices.layer)("bin cli parsing", (it) => { | ||
| 152 | it.effect("accepts the built-in lowercase log-level flag values", () => | ||
| 153 | runCliWithRuntime(["--log-level", "debug", "--version"]), | ||
| 154 | ); | ||
| 155 | |||
| 156 | it.effect("accepts canonical --no-<flag> boolean negation", () => | ||
| 157 | runCliWithRuntime(["--no-log-websocket-events", "--version"]), | ||
| 158 | ); | ||
| 159 | |||
| 160 | it.effect("rejects invalid log-level casing before launching the server", () => | ||
| 161 | Effect.gen(function* () { | ||
| 162 | const error = yield* runCliWithRuntime(["--log-level", "Debug"]).pipe(Effect.flip); | ||
| 163 | |||
| 164 | if (!CliError.isCliError(error)) { | ||
| 165 | assert.fail(`Expected CliError, got ${String(error)}`); | ||
| 166 | } | ||
| 167 | if (error._tag !== "InvalidValue") { | ||
| 168 | assert.fail(`Expected InvalidValue, got ${error._tag}`); | ||
| 169 | } | ||
| 170 | assert.equal(error.option, "log-level"); | ||
| 171 | assert.equal(error.value, "Debug"); | ||
| 172 | }), | ||
| 173 | ); | ||
| 174 | |||
| 175 | it.effect("executes auth pairing subcommands and redacts secrets from list output", () => | ||
| 176 | Effect.gen(function* () { | ||
| 177 | const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-auth-pairing-test-")); | ||
| 178 | |||
| 179 | const createdOutput = yield* captureStdout( | ||
| 180 | runCli(["auth", "pairing", "create", "--base-dir", baseDir, "--json"]), | ||
| 181 | ); | ||
| 182 | const created = JSON.parse(createdOutput.output) as { | ||
| 183 | readonly id: string; | ||
| 184 | readonly credential: string; | ||
| 185 | }; | ||
| 186 | const listedOutput = yield* captureStdout( | ||
| 187 | runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]), | ||
| 188 | ); | ||
| 189 | const listed = JSON.parse(listedOutput.output) as ReadonlyArray<{ | ||
| 190 | readonly id: string; | ||
| 191 | readonly credential?: string; | ||
| 192 | }>; | ||
| 193 | |||
| 194 | assert.equal(typeof created.id, "string"); | ||
| 195 | assert.equal(typeof created.credential, "string"); | ||
| 196 | assert.equal(created.credential.length > 0, true); | ||
| 197 | assert.equal(listed.length, 1); | ||
| 198 | assert.equal(listed[0]?.id, created.id); | ||
| 199 | assert.equal("credential" in (listed[0] ?? {}), false); | ||
| 200 | }), | ||
| 201 | ); | ||
| 202 | |||
| 203 | it.effect("executes auth session subcommands and redacts secrets from list output", () => | ||
| 204 | Effect.gen(function* () { | ||
| 205 | const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-auth-session-test-")); | ||
| 206 | |||
| 207 | const issuedOutput = yield* captureStdout( | ||
| 208 | runCli(["auth", "session", "issue", "--base-dir", baseDir, "--json"]), | ||
| 209 | ); | ||
| 210 | const issued = JSON.parse(issuedOutput.output) as { | ||
| 211 | readonly sessionId: string; | ||
| 212 | readonly token: string; | ||
| 213 | readonly role: string; | ||
| 214 | }; | ||
| 215 | const listedOutput = yield* captureStdout( | ||
| 216 | runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"]), | ||
| 217 | ); | ||
| 218 | const listed = JSON.parse(listedOutput.output) as ReadonlyArray<{ | ||
| 219 | readonly sessionId: string; | ||
| 220 | readonly token?: string; | ||
| 221 | readonly role: string; | ||
| 222 | }>; | ||
| 223 | |||
| 224 | assert.equal(typeof issued.sessionId, "string"); | ||
| 225 | assert.equal(typeof issued.token, "string"); | ||
| 226 | assert.equal(issued.role, "owner"); | ||
| 227 | assert.equal(listed.length, 1); | ||
| 228 | assert.equal(listed[0]?.sessionId, issued.sessionId); | ||
| 229 | assert.equal(listed[0]?.role, "owner"); | ||
| 230 | assert.equal("token" in (listed[0] ?? {}), false); | ||
| 231 | }), | ||
| 232 | ); | ||
| 233 | |||
| 234 | it.effect("rejects invalid ttl values before running auth commands", () => | ||
| 235 | Effect.gen(function* () { | ||
| 236 | const error = yield* runCliWithRuntime(["auth", "pairing", "create", "--ttl", "soon"]).pipe( | ||
| 237 | Effect.flip, | ||
| 238 | ); | ||
| 239 | |||
| 240 | if (!CliError.isCliError(error)) { | ||
| 241 | assert.fail(`Expected CliError, got ${String(error)}`); | ||
| 242 | } | ||
| 243 | if (error._tag !== "ShowHelp") { | ||
| 244 | assert.fail(`Expected ShowHelp, got ${error._tag}`); | ||
| 245 | } | ||
| 246 | assert.deepEqual(error.commandPath, ["t3", "auth", "pairing", "create"]); | ||
| 247 | const ttlError = error.errors[0] as CliError.CliError | undefined; | ||
| 248 | if (!ttlError || ttlError._tag !== "InvalidValue") { | ||
| 249 | assert.fail(`Expected InvalidValue, got ${String(ttlError?._tag)}`); | ||
| 250 | } | ||
| 251 | assert.equal(ttlError.option, "ttl"); | ||
| 252 | assert.equal(ttlError.value, "soon"); | ||
| 253 | assert.isTrue(ttlError.message.includes("Invalid duration")); | ||
| 254 | assert.isTrue(ttlError.message.includes("5m, 1h, 30d, or 15 minutes")); | ||
| 255 | }), | ||
| 256 | ); | ||
| 257 | |||
| 258 | it.effect("adds, renames, and removes projects offline through the orchestration engine", () => | ||
| 259 | Effect.gen(function* () { | ||
| 260 | const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-projects-offline-test-")); | ||
| 261 | const workspaceRoot = mkdtempSync(join(tmpdir(), "t3-cli-projects-workspace-")); | ||
| 262 | |||
| 263 | yield* runCliWithRuntime([ | ||
| 264 | "project", | ||
| 265 | "add", | ||
| 266 | workspaceRoot, | ||
| 267 | "--title", | ||
| 268 | "Alpha", | ||
| 269 | "--base-dir", | ||
| 270 | baseDir, | ||
| 271 | ]); | ||
| 272 | const afterAdd = yield* readPersistedSnapshot(baseDir); | ||
| 273 | const addedProject = afterAdd.projects.find( | ||
| 274 | (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null, | ||
| 275 | ); | ||
| 276 | assert.isTrue(addedProject !== undefined); | ||
| 277 | assert.equal(addedProject?.title, "Alpha"); | ||
| 278 | |||
| 279 | yield* runCliWithRuntime(["project", "rename", workspaceRoot, "Beta", "--base-dir", baseDir]); | ||
| 280 | const afterRename = yield* readPersistedSnapshot(baseDir); | ||
| 281 | const renamedProject = afterRename.projects.find( | ||
| 282 | (project) => project.id === addedProject?.id, | ||
| 283 | ); | ||
| 284 | assert.equal(renamedProject?.title, "Beta"); | ||
| 285 | assert.equal(renamedProject?.deletedAt, null); | ||
| 286 | |||
| 287 | yield* runCliWithRuntime([ | ||
| 288 | "project", | ||
| 289 | "remove", | ||
| 290 | addedProject?.id ?? "", | ||
| 291 | "--base-dir", | ||
| 292 | baseDir, | ||
| 293 | ]); | ||
| 294 | const afterRemove = yield* readPersistedSnapshot(baseDir); | ||
| 295 | const removedProject = afterRemove.projects.find( | ||
| 296 | (project) => project.id === addedProject?.id, | ||
| 297 | ); | ||
| 298 | assert.isTrue((removedProject?.deletedAt ?? null) !== null); | ||
| 299 | }), | ||
| 300 | ); | ||
| 301 | |||
| 302 | it.effect("routes project commands through a running server when runtime state is present", () => | ||
| 303 | Effect.gen(function* () { | ||
| 304 | const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-projects-live-test-")); | ||
| 305 | const workspaceRoot = mkdtempSync(join(tmpdir(), "t3-cli-projects-live-workspace-")); | ||
| 306 | |||
| 307 | yield* withLiveProjectCliServer(baseDir, () => | ||
| 308 | Effect.gen(function* () { | ||
| 309 | yield* runCliWithRuntime([ | ||
| 310 | "project", | ||
| 311 | "add", | ||
| 312 | workspaceRoot, | ||
| 313 | "--title", | ||
| 314 | "Live Project", | ||
| 315 | "--base-dir", | ||
| 316 | baseDir, | ||
| 317 | ]); | ||
| 318 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; | ||
| 319 | const readModel = yield* projectionSnapshotQuery.getSnapshot(); | ||
| 320 | const addedProject = readModel.projects.find( | ||
| 321 | (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null, | ||
| 322 | ); | ||
| 323 | assert.isTrue(addedProject !== undefined); | ||
| 324 | assert.equal(addedProject?.title, "Live Project"); | ||
| 325 | }), | ||
| 326 | ); | ||
| 327 | }), | ||
| 328 | ); | ||
| 329 | |||
| 330 | it.effect("rejects dev-url on project commands", () => | ||
| 331 | Effect.gen(function* () { | ||
| 332 | const workspaceRoot = mkdtempSync( | ||
| 333 | join(tmpdir(), "t3-cli-projects-unknown-option-workspace-"), | ||
| 334 | ); | ||
| 335 | const error = yield* runCliWithRuntime([ | ||
| 336 | "project", | ||
| 337 | "add", | ||
| 338 | workspaceRoot, | ||
| 339 | "--dev-url", | ||
| 340 | "http://127.0.0.1:5173", | ||
| 341 | ]).pipe(Effect.flip); | ||
| 342 | |||
| 343 | if (!CliError.isCliError(error)) { | ||
| 344 | assert.fail(`Expected CliError, got ${String(error)}`); | ||
| 345 | } | ||
| 346 | if (error._tag !== "ShowHelp") { | ||
| 347 | assert.fail(`Expected ShowHelp, got ${error._tag}`); | ||
| 348 | } | ||
| 349 | assert.deepEqual(error.commandPath, ["t3", "project", "add"]); | ||
| 350 | const optionError = error.errors[0] as CliError.CliError | undefined; | ||
| 351 | if (!optionError || optionError._tag !== "UnrecognizedOption") { | ||
| 352 | assert.fail(`Expected UnrecognizedOption, got ${String(optionError?._tag)}`); | ||
| 353 | } | ||
| 354 | assert.equal(optionError.option, "--dev-url"); | ||
| 355 | }), | ||
| 356 | ); | ||
| 357 | }); | ||
apps/server/src/bin.ts+16-5| ... | @@ -5,13 +5,24 @@ import * as Layer from "effect/Layer"; | ... | @@ -5,13 +5,24 @@ import * as Layer from "effect/Layer"; |
| 5 | import { Command } from "effect/unstable/cli"; | 5 | import { Command } from "effect/unstable/cli"; |
| 6 | 6 | ||
| 7 | import { NetService } from "@t3tools/shared/Net"; | 7 | import { NetService } from "@t3tools/shared/Net"; |
| 8 | import { cli } from "./cli.ts"; | ||
| 9 | import packageJson from "../package.json" with { type: "json" }; | 8 | import packageJson from "../package.json" with { type: "json" }; |
| 9 | import { authCommand } from "./cli/auth.ts"; | ||
| 10 | import { sharedServerCommandFlags } from "./cli/config.ts"; | ||
| 11 | import { projectCommand } from "./cli/project.ts"; | ||
| 12 | import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; | ||
| 10 | 13 | ||
| 11 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); | 14 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); |
| 12 | 15 | ||
| 13 | Command.run(cli, { version: packageJson.version }).pipe( | 16 | export const cli = Command.make("t3", { ...sharedServerCommandFlags }).pipe( |
| 14 | Effect.scoped, | 17 | Command.withDescription("Run the T3 Code server."), |
| 15 | Effect.provide(CliRuntimeLayer), | 18 | Command.withHandler((flags) => runServerCommand(flags)), |
| 16 | NodeRuntime.runMain, | 19 | Command.withSubcommands([startCommand, serveCommand, authCommand, projectCommand]), |
| 17 | ); | 20 | ); |
| 21 | |||
| 22 | if (import.meta.main) { | ||
| 23 | Command.run(cli, { version: packageJson.version }).pipe( | ||
| 24 | Effect.scoped, | ||
| 25 | Effect.provide(CliRuntimeLayer), | ||
| 26 | NodeRuntime.runMain, | ||
| 27 | ); | ||
| 28 | } |
apps/server/src/cli-config.test.ts deleted-564| ... | @@ -1,564 +0,0 @@ | ||
| 1 | import os from "node:os"; | ||
| 2 | |||
| 3 | import { assert, expect, it } from "@effect/vitest"; | ||
| 4 | import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect"; | ||
| 5 | |||
| 6 | import { NetService } from "@t3tools/shared/Net"; | ||
| 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 8 | import { deriveServerPaths } from "./config.ts"; | ||
| 9 | import { resolveServerConfig } from "./cli.ts"; | ||
| 10 | |||
| 11 | it.layer(NodeServices.layer)("cli config resolution", (it) => { | ||
| 12 | const defaultObservabilityConfig = { | ||
| 13 | traceMinLevel: "Info", | ||
| 14 | traceTimingEnabled: true, | ||
| 15 | traceBatchWindowMs: 200, | ||
| 16 | traceMaxBytes: 10 * 1024 * 1024, | ||
| 17 | traceMaxFiles: 10, | ||
| 18 | otlpTracesUrl: undefined, | ||
| 19 | otlpMetricsUrl: undefined, | ||
| 20 | otlpExportIntervalMs: 10_000, | ||
| 21 | otlpServiceName: "t3-server", | ||
| 22 | } as const; | ||
| 23 | |||
| 24 | const openBootstrapFd = Effect.fn(function* (payload: Record<string, unknown>) { | ||
| 25 | const fs = yield* FileSystem.FileSystem; | ||
| 26 | const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); | ||
| 27 | yield* fs.writeFileString(filePath, `${JSON.stringify(payload)}\n`); | ||
| 28 | const { fd } = yield* fs.open(filePath, { flag: "r" }); | ||
| 29 | return fd; | ||
| 30 | }); | ||
| 31 | |||
| 32 | it.effect("falls back to effect/config values when flags are omitted", () => | ||
| 33 | Effect.gen(function* () { | ||
| 34 | const { join } = yield* Path.Path; | ||
| 35 | const baseDir = join(os.tmpdir(), "t3-cli-config-env-base"); | ||
| 36 | const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173")); | ||
| 37 | const resolved = yield* resolveServerConfig( | ||
| 38 | { | ||
| 39 | mode: Option.none(), | ||
| 40 | port: Option.none(), | ||
| 41 | host: Option.none(), | ||
| 42 | baseDir: Option.none(), | ||
| 43 | cwd: Option.none(), | ||
| 44 | devUrl: Option.none(), | ||
| 45 | noBrowser: Option.none(), | ||
| 46 | bootstrapFd: Option.none(), | ||
| 47 | autoBootstrapProjectFromCwd: Option.none(), | ||
| 48 | logWebSocketEvents: Option.none(), | ||
| 49 | tailscaleServeEnabled: Option.none(), | ||
| 50 | tailscaleServePort: Option.none(), | ||
| 51 | }, | ||
| 52 | Option.none(), | ||
| 53 | ).pipe( | ||
| 54 | Effect.provide( | ||
| 55 | Layer.mergeAll( | ||
| 56 | ConfigProvider.layer( | ||
| 57 | ConfigProvider.fromEnv({ | ||
| 58 | env: { | ||
| 59 | T3CODE_LOG_LEVEL: "Warn", | ||
| 60 | T3CODE_MODE: "desktop", | ||
| 61 | T3CODE_PORT: "4001", | ||
| 62 | T3CODE_HOST: "0.0.0.0", | ||
| 63 | T3CODE_HOME: baseDir, | ||
| 64 | VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", | ||
| 65 | T3CODE_NO_BROWSER: "true", | ||
| 66 | T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", | ||
| 67 | T3CODE_LOG_WS_EVENTS: "true", | ||
| 68 | }, | ||
| 69 | }), | ||
| 70 | ), | ||
| 71 | NetService.layer, | ||
| 72 | ), | ||
| 73 | ), | ||
| 74 | ); | ||
| 75 | |||
| 76 | expect(resolved).toEqual({ | ||
| 77 | logLevel: "Warn", | ||
| 78 | ...defaultObservabilityConfig, | ||
| 79 | mode: "desktop", | ||
| 80 | port: 4001, | ||
| 81 | cwd: process.cwd(), | ||
| 82 | baseDir, | ||
| 83 | ...derivedPaths, | ||
| 84 | host: "0.0.0.0", | ||
| 85 | staticDir: undefined, | ||
| 86 | devUrl: new URL("http://127.0.0.1:5173"), | ||
| 87 | noBrowser: true, | ||
| 88 | startupPresentation: "browser", | ||
| 89 | desktopBootstrapToken: undefined, | ||
| 90 | autoBootstrapProjectFromCwd: false, | ||
| 91 | logWebSocketEvents: true, | ||
| 92 | tailscaleServeEnabled: false, | ||
| 93 | tailscaleServePort: 443, | ||
| 94 | }); | ||
| 95 | }), | ||
| 96 | ); | ||
| 97 | |||
| 98 | it.effect("uses CLI flags when provided", () => | ||
| 99 | Effect.gen(function* () { | ||
| 100 | const { join } = yield* Path.Path; | ||
| 101 | const baseDir = join(os.tmpdir(), "t3-cli-config-flags-base"); | ||
| 102 | const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); | ||
| 103 | const resolved = yield* resolveServerConfig( | ||
| 104 | { | ||
| 105 | mode: Option.some("web"), | ||
| 106 | port: Option.some(8788), | ||
| 107 | host: Option.some("127.0.0.1"), | ||
| 108 | baseDir: Option.some(baseDir), | ||
| 109 | cwd: Option.none(), | ||
| 110 | devUrl: Option.some(new URL("http://127.0.0.1:4173")), | ||
| 111 | noBrowser: Option.some(true), | ||
| 112 | bootstrapFd: Option.none(), | ||
| 113 | autoBootstrapProjectFromCwd: Option.some(true), | ||
| 114 | logWebSocketEvents: Option.some(true), | ||
| 115 | tailscaleServeEnabled: Option.some(true), | ||
| 116 | tailscaleServePort: Option.some(8443), | ||
| 117 | }, | ||
| 118 | Option.some("Debug"), | ||
| 119 | ).pipe( | ||
| 120 | Effect.provide( | ||
| 121 | Layer.mergeAll( | ||
| 122 | ConfigProvider.layer( | ||
| 123 | ConfigProvider.fromEnv({ | ||
| 124 | env: { | ||
| 125 | T3CODE_LOG_LEVEL: "Warn", | ||
| 126 | T3CODE_MODE: "desktop", | ||
| 127 | T3CODE_PORT: "4001", | ||
| 128 | T3CODE_HOST: "0.0.0.0", | ||
| 129 | T3CODE_HOME: join(os.tmpdir(), "ignored-base"), | ||
| 130 | VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", | ||
| 131 | T3CODE_NO_BROWSER: "false", | ||
| 132 | T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", | ||
| 133 | T3CODE_LOG_WS_EVENTS: "false", | ||
| 134 | }, | ||
| 135 | }), | ||
| 136 | ), | ||
| 137 | NetService.layer, | ||
| 138 | ), | ||
| 139 | ), | ||
| 140 | ); | ||
| 141 | |||
| 142 | expect(resolved).toEqual({ | ||
| 143 | logLevel: "Debug", | ||
| 144 | ...defaultObservabilityConfig, | ||
| 145 | mode: "web", | ||
| 146 | port: 8788, | ||
| 147 | cwd: process.cwd(), | ||
| 148 | baseDir, | ||
| 149 | ...derivedPaths, | ||
| 150 | host: "127.0.0.1", | ||
| 151 | staticDir: undefined, | ||
| 152 | devUrl: new URL("http://127.0.0.1:4173"), | ||
| 153 | noBrowser: true, | ||
| 154 | startupPresentation: "browser", | ||
| 155 | desktopBootstrapToken: undefined, | ||
| 156 | autoBootstrapProjectFromCwd: true, | ||
| 157 | logWebSocketEvents: true, | ||
| 158 | tailscaleServeEnabled: true, | ||
| 159 | tailscaleServePort: 8443, | ||
| 160 | }); | ||
| 161 | }), | ||
| 162 | ); | ||
| 163 | |||
| 164 | it.effect("preserves explicit false CLI boolean flags over env and bootstrap values", () => | ||
| 165 | Effect.gen(function* () { | ||
| 166 | const { join } = yield* Path.Path; | ||
| 167 | const baseDir = join(os.tmpdir(), "t3-cli-config-false-flags"); | ||
| 168 | const fd = yield* openBootstrapFd({ | ||
| 169 | noBrowser: true, | ||
| 170 | autoBootstrapProjectFromCwd: true, | ||
| 171 | logWebSocketEvents: true, | ||
| 172 | tailscaleServeEnabled: false, | ||
| 173 | tailscaleServePort: 443, | ||
| 174 | }); | ||
| 175 | const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); | ||
| 176 | |||
| 177 | const resolved = yield* resolveServerConfig( | ||
| 178 | { | ||
| 179 | mode: Option.some("web"), | ||
| 180 | port: Option.some(8788), | ||
| 181 | host: Option.some("127.0.0.1"), | ||
| 182 | baseDir: Option.some(baseDir), | ||
| 183 | cwd: Option.none(), | ||
| 184 | devUrl: Option.some(new URL("http://127.0.0.1:4173")), | ||
| 185 | noBrowser: Option.some(false), | ||
| 186 | bootstrapFd: Option.none(), | ||
| 187 | autoBootstrapProjectFromCwd: Option.some(false), | ||
| 188 | logWebSocketEvents: Option.some(false), | ||
| 189 | tailscaleServeEnabled: Option.none(), | ||
| 190 | tailscaleServePort: Option.none(), | ||
| 191 | }, | ||
| 192 | Option.none(), | ||
| 193 | ).pipe( | ||
| 194 | Effect.provide( | ||
| 195 | Layer.mergeAll( | ||
| 196 | ConfigProvider.layer( | ||
| 197 | ConfigProvider.fromEnv({ | ||
| 198 | env: { | ||
| 199 | T3CODE_BOOTSTRAP_FD: String(fd), | ||
| 200 | T3CODE_NO_BROWSER: "true", | ||
| 201 | T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true", | ||
| 202 | T3CODE_LOG_WS_EVENTS: "true", | ||
| 203 | }, | ||
| 204 | }), | ||
| 205 | ), | ||
| 206 | NetService.layer, | ||
| 207 | ), | ||
| 208 | ), | ||
| 209 | ); | ||
| 210 | |||
| 211 | expect(resolved).toEqual({ | ||
| 212 | logLevel: "Info", | ||
| 213 | ...defaultObservabilityConfig, | ||
| 214 | mode: "web", | ||
| 215 | port: 8788, | ||
| 216 | cwd: process.cwd(), | ||
| 217 | baseDir, | ||
| 218 | ...derivedPaths, | ||
| 219 | host: "127.0.0.1", | ||
| 220 | staticDir: undefined, | ||
| 221 | devUrl: new URL("http://127.0.0.1:4173"), | ||
| 222 | noBrowser: false, | ||
| 223 | startupPresentation: "browser", | ||
| 224 | desktopBootstrapToken: undefined, | ||
| 225 | autoBootstrapProjectFromCwd: false, | ||
| 226 | logWebSocketEvents: false, | ||
| 227 | tailscaleServeEnabled: false, | ||
| 228 | tailscaleServePort: 443, | ||
| 229 | }); | ||
| 230 | }), | ||
| 231 | ); | ||
| 232 | |||
| 233 | it.effect("uses bootstrap envelope values as fallbacks when flags and env are absent", () => | ||
| 234 | Effect.gen(function* () { | ||
| 235 | const { join } = yield* Path.Path; | ||
| 236 | const baseDir = "/tmp/t3-bootstrap-home"; | ||
| 237 | const fd = yield* openBootstrapFd({ | ||
| 238 | mode: "desktop", | ||
| 239 | port: 4888, | ||
| 240 | host: "127.0.0.2", | ||
| 241 | t3Home: baseDir, | ||
| 242 | devUrl: "http://127.0.0.1:5173", | ||
| 243 | noBrowser: true, | ||
| 244 | autoBootstrapProjectFromCwd: false, | ||
| 245 | logWebSocketEvents: true, | ||
| 246 | tailscaleServeEnabled: false, | ||
| 247 | tailscaleServePort: 443, | ||
| 248 | otlpTracesUrl: "http://localhost:4318/v1/traces", | ||
| 249 | otlpMetricsUrl: "http://localhost:4318/v1/metrics", | ||
| 250 | }); | ||
| 251 | const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173")); | ||
| 252 | |||
| 253 | const resolved = yield* resolveServerConfig( | ||
| 254 | { | ||
| 255 | mode: Option.none(), | ||
| 256 | port: Option.none(), | ||
| 257 | host: Option.none(), | ||
| 258 | baseDir: Option.none(), | ||
| 259 | cwd: Option.none(), | ||
| 260 | devUrl: Option.none(), | ||
| 261 | noBrowser: Option.none(), | ||
| 262 | bootstrapFd: Option.none(), | ||
| 263 | autoBootstrapProjectFromCwd: Option.none(), | ||
| 264 | logWebSocketEvents: Option.none(), | ||
| 265 | tailscaleServeEnabled: Option.none(), | ||
| 266 | tailscaleServePort: Option.none(), | ||
| 267 | }, | ||
| 268 | Option.none(), | ||
| 269 | ).pipe( | ||
| 270 | Effect.provide( | ||
| 271 | Layer.mergeAll( | ||
| 272 | ConfigProvider.layer( | ||
| 273 | ConfigProvider.fromEnv({ | ||
| 274 | env: { | ||
| 275 | T3CODE_BOOTSTRAP_FD: String(fd), | ||
| 276 | }, | ||
| 277 | }), | ||
| 278 | ), | ||
| 279 | NetService.layer, | ||
| 280 | ), | ||
| 281 | ), | ||
| 282 | ); | ||
| 283 | |||
| 284 | expect(resolved).toEqual({ | ||
| 285 | logLevel: "Info", | ||
| 286 | ...defaultObservabilityConfig, | ||
| 287 | otlpTracesUrl: "http://localhost:4318/v1/traces", | ||
| 288 | otlpMetricsUrl: "http://localhost:4318/v1/metrics", | ||
| 289 | mode: "desktop", | ||
| 290 | port: 4888, | ||
| 291 | cwd: process.cwd(), | ||
| 292 | baseDir, | ||
| 293 | ...derivedPaths, | ||
| 294 | host: "127.0.0.2", | ||
| 295 | staticDir: undefined, | ||
| 296 | devUrl: new URL("http://127.0.0.1:5173"), | ||
| 297 | noBrowser: true, | ||
| 298 | startupPresentation: "browser", | ||
| 299 | desktopBootstrapToken: undefined, | ||
| 300 | autoBootstrapProjectFromCwd: false, | ||
| 301 | logWebSocketEvents: true, | ||
| 302 | tailscaleServeEnabled: false, | ||
| 303 | tailscaleServePort: 443, | ||
| 304 | }); | ||
| 305 | assert.equal(join(baseDir, "dev"), resolved.stateDir); | ||
| 306 | }), | ||
| 307 | ); | ||
| 308 | |||
| 309 | it.effect("creates derived runtime directories during config resolution", () => | ||
| 310 | Effect.gen(function* () { | ||
| 311 | const fs = yield* FileSystem.FileSystem; | ||
| 312 | const path = yield* Path.Path; | ||
| 313 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-dirs-" }); | ||
| 314 | const customCwd = path.join(baseDir, "nested", "project"); | ||
| 315 | |||
| 316 | const resolved = yield* resolveServerConfig( | ||
| 317 | { | ||
| 318 | mode: Option.some("desktop"), | ||
| 319 | port: Option.some(4888), | ||
| 320 | host: Option.none(), | ||
| 321 | baseDir: Option.some(baseDir), | ||
| 322 | cwd: Option.some(customCwd), | ||
| 323 | devUrl: Option.some(new URL("http://127.0.0.1:5173")), | ||
| 324 | noBrowser: Option.none(), | ||
| 325 | bootstrapFd: Option.none(), | ||
| 326 | autoBootstrapProjectFromCwd: Option.none(), | ||
| 327 | logWebSocketEvents: Option.none(), | ||
| 328 | tailscaleServeEnabled: Option.none(), | ||
| 329 | tailscaleServePort: Option.none(), | ||
| 330 | }, | ||
| 331 | Option.none(), | ||
| 332 | ).pipe( | ||
| 333 | Effect.provide( | ||
| 334 | Layer.mergeAll( | ||
| 335 | ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), | ||
| 336 | NetService.layer, | ||
| 337 | ), | ||
| 338 | ), | ||
| 339 | ); | ||
| 340 | |||
| 341 | for (const directory of [ | ||
| 342 | customCwd, | ||
| 343 | resolved.stateDir, | ||
| 344 | resolved.logsDir, | ||
| 345 | resolved.providerLogsDir, | ||
| 346 | resolved.terminalLogsDir, | ||
| 347 | resolved.attachmentsDir, | ||
| 348 | resolved.worktreesDir, | ||
| 349 | path.dirname(resolved.serverLogPath), | ||
| 350 | path.dirname(resolved.serverTracePath), | ||
| 351 | ]) { | ||
| 352 | expect(yield* fs.exists(directory)).toBe(true); | ||
| 353 | } | ||
| 354 | expect(resolved.cwd).toBe(path.resolve(customCwd)); | ||
| 355 | }), | ||
| 356 | ); | ||
| 357 | |||
| 358 | it.effect("applies flag then env precedence over bootstrap envelope values", () => | ||
| 359 | Effect.gen(function* () { | ||
| 360 | const { join } = yield* Path.Path; | ||
| 361 | const baseDir = join(os.tmpdir(), "t3-cli-config-env-wins"); | ||
| 362 | const fd = yield* openBootstrapFd({ | ||
| 363 | mode: "desktop", | ||
| 364 | port: 4888, | ||
| 365 | host: "127.0.0.2", | ||
| 366 | t3Home: "/tmp/t3-bootstrap-home", | ||
| 367 | devUrl: "http://127.0.0.1:5173", | ||
| 368 | noBrowser: false, | ||
| 369 | autoBootstrapProjectFromCwd: false, | ||
| 370 | logWebSocketEvents: false, | ||
| 371 | tailscaleServeEnabled: false, | ||
| 372 | tailscaleServePort: 443, | ||
| 373 | }); | ||
| 374 | const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); | ||
| 375 | |||
| 376 | const resolved = yield* resolveServerConfig( | ||
| 377 | { | ||
| 378 | mode: Option.none(), | ||
| 379 | port: Option.some(8788), | ||
| 380 | host: Option.some("127.0.0.1"), | ||
| 381 | baseDir: Option.none(), | ||
| 382 | cwd: Option.none(), | ||
| 383 | devUrl: Option.some(new URL("http://127.0.0.1:4173")), | ||
| 384 | noBrowser: Option.none(), | ||
| 385 | bootstrapFd: Option.none(), | ||
| 386 | autoBootstrapProjectFromCwd: Option.none(), | ||
| 387 | logWebSocketEvents: Option.none(), | ||
| 388 | tailscaleServeEnabled: Option.none(), | ||
| 389 | tailscaleServePort: Option.none(), | ||
| 390 | }, | ||
| 391 | Option.some("Debug"), | ||
| 392 | ).pipe( | ||
| 393 | Effect.provide( | ||
| 394 | Layer.mergeAll( | ||
| 395 | ConfigProvider.layer( | ||
| 396 | ConfigProvider.fromEnv({ | ||
| 397 | env: { | ||
| 398 | T3CODE_MODE: "web", | ||
| 399 | T3CODE_BOOTSTRAP_FD: String(fd), | ||
| 400 | T3CODE_HOME: baseDir, | ||
| 401 | T3CODE_NO_BROWSER: "true", | ||
| 402 | T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true", | ||
| 403 | T3CODE_LOG_WS_EVENTS: "true", | ||
| 404 | }, | ||
| 405 | }), | ||
| 406 | ), | ||
| 407 | NetService.layer, | ||
| 408 | ), | ||
| 409 | ), | ||
| 410 | ); | ||
| 411 | |||
| 412 | expect(resolved).toEqual({ | ||
| 413 | logLevel: "Debug", | ||
| 414 | ...defaultObservabilityConfig, | ||
| 415 | mode: "web", | ||
| 416 | port: 8788, | ||
| 417 | cwd: process.cwd(), | ||
| 418 | baseDir, | ||
| 419 | ...derivedPaths, | ||
| 420 | host: "127.0.0.1", | ||
| 421 | staticDir: undefined, | ||
| 422 | devUrl: new URL("http://127.0.0.1:4173"), | ||
| 423 | noBrowser: true, | ||
| 424 | startupPresentation: "browser", | ||
| 425 | desktopBootstrapToken: undefined, | ||
| 426 | autoBootstrapProjectFromCwd: true, | ||
| 427 | logWebSocketEvents: true, | ||
| 428 | tailscaleServeEnabled: false, | ||
| 429 | tailscaleServePort: 443, | ||
| 430 | }); | ||
| 431 | }), | ||
| 432 | ); | ||
| 433 | |||
| 434 | it.effect("falls back to persisted observability settings when env vars are absent", () => | ||
| 435 | Effect.gen(function* () { | ||
| 436 | const fs = yield* FileSystem.FileSystem; | ||
| 437 | const path = yield* Path.Path; | ||
| 438 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-settings-" }); | ||
| 439 | const derivedPaths = yield* deriveServerPaths(baseDir, undefined); | ||
| 440 | yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); | ||
| 441 | yield* fs.writeFileString( | ||
| 442 | derivedPaths.settingsPath, | ||
| 443 | `${JSON.stringify({ | ||
| 444 | observability: { | ||
| 445 | otlpTracesUrl: "http://localhost:4318/v1/traces", | ||
| 446 | otlpMetricsUrl: "http://localhost:4318/v1/metrics", | ||
| 447 | }, | ||
| 448 | })}\n`, | ||
| 449 | ); | ||
| 450 | |||
| 451 | const resolved = yield* resolveServerConfig( | ||
| 452 | { | ||
| 453 | mode: Option.some("desktop"), | ||
| 454 | port: Option.some(4888), | ||
| 455 | host: Option.none(), | ||
| 456 | baseDir: Option.some(baseDir), | ||
| 457 | cwd: Option.none(), | ||
| 458 | devUrl: Option.none(), | ||
| 459 | noBrowser: Option.none(), | ||
| 460 | bootstrapFd: Option.none(), | ||
| 461 | autoBootstrapProjectFromCwd: Option.none(), | ||
| 462 | logWebSocketEvents: Option.none(), | ||
| 463 | tailscaleServeEnabled: Option.none(), | ||
| 464 | tailscaleServePort: Option.none(), | ||
| 465 | }, | ||
| 466 | Option.none(), | ||
| 467 | ).pipe( | ||
| 468 | Effect.provide( | ||
| 469 | Layer.mergeAll( | ||
| 470 | ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), | ||
| 471 | NetService.layer, | ||
| 472 | ), | ||
| 473 | ), | ||
| 474 | ); | ||
| 475 | |||
| 476 | expect(resolved.otlpTracesUrl).toBe("http://localhost:4318/v1/traces"); | ||
| 477 | expect(resolved.otlpMetricsUrl).toBe("http://localhost:4318/v1/metrics"); | ||
| 478 | expect(resolved).toEqual({ | ||
| 479 | logLevel: "Info", | ||
| 480 | ...defaultObservabilityConfig, | ||
| 481 | otlpTracesUrl: "http://localhost:4318/v1/traces", | ||
| 482 | otlpMetricsUrl: "http://localhost:4318/v1/metrics", | ||
| 483 | mode: "desktop", | ||
| 484 | port: 4888, | ||
| 485 | cwd: process.cwd(), | ||
| 486 | baseDir, | ||
| 487 | ...derivedPaths, | ||
| 488 | host: "127.0.0.1", | ||
| 489 | staticDir: resolved.staticDir, | ||
| 490 | devUrl: undefined, | ||
| 491 | noBrowser: true, | ||
| 492 | startupPresentation: "browser", | ||
| 493 | desktopBootstrapToken: undefined, | ||
| 494 | autoBootstrapProjectFromCwd: false, | ||
| 495 | logWebSocketEvents: false, | ||
| 496 | tailscaleServeEnabled: false, | ||
| 497 | tailscaleServePort: 443, | ||
| 498 | }); | ||
| 499 | }), | ||
| 500 | ); | ||
| 501 | |||
| 502 | it.effect("forces noBrowser and disables auto-bootstrap for headless startup presentation", () => | ||
| 503 | Effect.gen(function* () { | ||
| 504 | const { join } = yield* Path.Path; | ||
| 505 | const baseDir = join(os.tmpdir(), "t3-cli-config-headless-base"); | ||
| 506 | const derivedPaths = yield* deriveServerPaths(baseDir, undefined); | ||
| 507 | |||
| 508 | const resolved = yield* resolveServerConfig( | ||
| 509 | { | ||
| 510 | mode: Option.some("web"), | ||
| 511 | port: Option.some(3773), | ||
| 512 | host: Option.none(), | ||
| 513 | baseDir: Option.some(baseDir), | ||
| 514 | cwd: Option.none(), | ||
| 515 | devUrl: Option.none(), | ||
| 516 | noBrowser: Option.none(), | ||
| 517 | bootstrapFd: Option.none(), | ||
| 518 | autoBootstrapProjectFromCwd: Option.none(), | ||
| 519 | logWebSocketEvents: Option.none(), | ||
| 520 | tailscaleServeEnabled: Option.none(), | ||
| 521 | tailscaleServePort: Option.none(), | ||
| 522 | }, | ||
| 523 | Option.none(), | ||
| 524 | { | ||
| 525 | startupPresentation: "headless", | ||
| 526 | }, | ||
| 527 | ).pipe( | ||
| 528 | Effect.provide( | ||
| 529 | Layer.mergeAll( | ||
| 530 | ConfigProvider.layer( | ||
| 531 | ConfigProvider.fromEnv({ | ||
| 532 | env: { | ||
| 533 | T3CODE_NO_BROWSER: "false", | ||
| 534 | T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true", | ||
| 535 | }, | ||
| 536 | }), | ||
| 537 | ), | ||
| 538 | NetService.layer, | ||
| 539 | ), | ||
| 540 | ), | ||
| 541 | ); | ||
| 542 | |||
| 543 | expect(resolved).toEqual({ | ||
| 544 | logLevel: "Info", | ||
| 545 | ...defaultObservabilityConfig, | ||
| 546 | mode: "web", | ||
| 547 | port: 3773, | ||
| 548 | cwd: process.cwd(), | ||
| 549 | baseDir, | ||
| 550 | ...derivedPaths, | ||
| 551 | host: undefined, | ||
| 552 | staticDir: resolved.staticDir, | ||
| 553 | devUrl: undefined, | ||
| 554 | noBrowser: true, | ||
| 555 | startupPresentation: "headless", | ||
| 556 | desktopBootstrapToken: undefined, | ||
| 557 | autoBootstrapProjectFromCwd: false, | ||
| 558 | logWebSocketEvents: false, | ||
| 559 | tailscaleServeEnabled: false, | ||
| 560 | tailscaleServePort: 443, | ||
| 561 | }); | ||
| 562 | }), | ||
| 563 | ); | ||
| 564 | }); | ||
apps/server/src/cli.test.ts deleted-357| ... | @@ -1,357 +0,0 @@ | ||
| 1 | import * as NodeHttp from "node:http"; | ||
| 2 | import { mkdtempSync } from "node:fs"; | ||
| 3 | import { tmpdir } from "node:os"; | ||
| 4 | import { join } from "node:path"; | ||
| 5 | |||
| 6 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; | ||
| 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 8 | import { NetService } from "@t3tools/shared/Net"; | ||
| 9 | import { assert, it } from "@effect/vitest"; | ||
| 10 | import * as Effect from "effect/Effect"; | ||
| 11 | import * as Layer from "effect/Layer"; | ||
| 12 | import * as HttpRouter from "effect/unstable/http/HttpRouter"; | ||
| 13 | import * as HttpServer from "effect/unstable/http/HttpServer"; | ||
| 14 | import * as CliError from "effect/unstable/cli/CliError"; | ||
| 15 | import * as TestConsole from "effect/testing/TestConsole"; | ||
| 16 | import { Command } from "effect/unstable/cli"; | ||
| 17 | |||
| 18 | import { cli } from "./cli.ts"; | ||
| 19 | import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts"; | ||
| 20 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; | ||
| 21 | import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; | ||
| 22 | import { | ||
| 23 | orchestrationDispatchRouteLayer, | ||
| 24 | orchestrationSnapshotRouteLayer, | ||
| 25 | } from "./orchestration/http.ts"; | ||
| 26 | import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; | ||
| 27 | import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; | ||
| 28 | import { | ||
| 29 | makePersistedServerRuntimeState, | ||
| 30 | persistServerRuntimeState, | ||
| 31 | } from "./serverRuntimeState.ts"; | ||
| 32 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; | ||
| 33 | import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; | ||
| 34 | import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; | ||
| 35 | |||
| 36 | const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); | ||
| 37 | |||
| 38 | const runCli = (args: ReadonlyArray<string>) => Command.runWith(cli, { version: "0.0.0" })(args); | ||
| 39 | const runCliWithRuntime = (args: ReadonlyArray<string>) => | ||
| 40 | runCli(args).pipe(Effect.provide(CliRuntimeLayer)); | ||
| 41 | |||
| 42 | const captureStdout = <A, E, R>(effect: Effect.Effect<A, E, R>) => | ||
| 43 | Effect.gen(function* () { | ||
| 44 | const result = yield* effect; | ||
| 45 | const output = | ||
| 46 | (yield* TestConsole.logLines).findLast((line): line is string => typeof line === "string") ?? | ||
| 47 | ""; | ||
| 48 | return { result, output }; | ||
| 49 | }).pipe(Effect.provide(Layer.mergeAll(CliRuntimeLayer, TestConsole.layer))); | ||
| 50 | |||
| 51 | const makeCliTestServerConfig = (baseDir: string) => | ||
| 52 | Effect.gen(function* () { | ||
| 53 | const derivedPaths = yield* deriveServerPaths(baseDir, undefined); | ||
| 54 | return { | ||
| 55 | logLevel: "Info", | ||
| 56 | traceMinLevel: "Info", | ||
| 57 | traceTimingEnabled: true, | ||
| 58 | traceBatchWindowMs: 200, | ||
| 59 | traceMaxBytes: 10 * 1024 * 1024, | ||
| 60 | traceMaxFiles: 10, | ||
| 61 | otlpTracesUrl: undefined, | ||
| 62 | otlpMetricsUrl: undefined, | ||
| 63 | otlpExportIntervalMs: 10_000, | ||
| 64 | otlpServiceName: "t3-server", | ||
| 65 | mode: "web", | ||
| 66 | port: 0, | ||
| 67 | host: "127.0.0.1", | ||
| 68 | cwd: process.cwd(), | ||
| 69 | baseDir, | ||
| 70 | ...derivedPaths, | ||
| 71 | staticDir: undefined, | ||
| 72 | devUrl: undefined, | ||
| 73 | noBrowser: true, | ||
| 74 | startupPresentation: "browser", | ||
| 75 | desktopBootstrapToken: undefined, | ||
| 76 | autoBootstrapProjectFromCwd: false, | ||
| 77 | logWebSocketEvents: false, | ||
| 78 | tailscaleServeEnabled: false, | ||
| 79 | tailscaleServePort: 443, | ||
| 80 | } satisfies ServerConfigShape; | ||
| 81 | }); | ||
| 82 | |||
| 83 | const makeProjectPersistenceLayer = (config: ServerConfigShape) => | ||
| 84 | Layer.mergeAll( | ||
| 85 | OrchestrationLayerLive.pipe( | ||
| 86 | Layer.provideMerge(RepositoryIdentityResolverLive), | ||
| 87 | Layer.provideMerge(SqlitePersistenceLayerLive), | ||
| 88 | ), | ||
| 89 | WorkspacePathsLive, | ||
| 90 | ).pipe( | ||
| 91 | Layer.provideMerge(NodeServices.layer), | ||
| 92 | Layer.provide(Layer.succeed(ServerConfig, config)), | ||
| 93 | ); | ||
| 94 | |||
| 95 | const readPersistedSnapshot = (baseDir: string) => | ||
| 96 | Effect.gen(function* () { | ||
| 97 | const config = yield* makeCliTestServerConfig(baseDir); | ||
| 98 | return yield* Effect.gen(function* () { | ||
| 99 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; | ||
| 100 | return yield* projectionSnapshotQuery.getSnapshot(); | ||
| 101 | }).pipe(Effect.provide(makeProjectPersistenceLayer(config))); | ||
| 102 | }); | ||
| 103 | |||
| 104 | const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Effect<A, E, R>) => | ||
| 105 | Effect.gen(function* () { | ||
| 106 | const config = yield* makeCliTestServerConfig(baseDir); | ||
| 107 | const routesLayer = Layer.mergeAll( | ||
| 108 | orchestrationSnapshotRouteLayer, | ||
| 109 | orchestrationDispatchRouteLayer, | ||
| 110 | ); | ||
| 111 | const appLayer = HttpRouter.serve(routesLayer, { | ||
| 112 | disableListenLog: true, | ||
| 113 | disableLogger: true, | ||
| 114 | }).pipe( | ||
| 115 | Layer.provideMerge( | ||
| 116 | ServerAuthLive.pipe( | ||
| 117 | Layer.provideMerge(SqlitePersistenceLayerLive), | ||
| 118 | Layer.provide(ServerSecretStoreLive), | ||
| 119 | ), | ||
| 120 | ), | ||
| 121 | Layer.provideMerge(makeProjectPersistenceLayer(config)), | ||
| 122 | Layer.provideMerge( | ||
| 123 | NodeHttpServer.layer(NodeHttp.createServer, { | ||
| 124 | host: "127.0.0.1", | ||
| 125 | port: 0, | ||
| 126 | }), | ||
| 127 | ), | ||
| 128 | Layer.provideMerge(NodeServices.layer), | ||
| 129 | Layer.provide(Layer.succeed(ServerConfig, config)), | ||
| 130 | ); | ||
| 131 | |||
| 132 | return yield* Effect.scoped( | ||
| 133 | Effect.gen(function* () { | ||
| 134 | const server = yield* HttpServer.HttpServer; | ||
| 135 | const address = server.address; | ||
| 136 | if (typeof address === "string" || !("port" in address)) { | ||
| 137 | assert.fail(`Expected TCP address, got ${address}`); | ||
| 138 | } | ||
| 139 | yield* persistServerRuntimeState({ | ||
| 140 | path: config.serverRuntimeStatePath, | ||
| 141 | state: makePersistedServerRuntimeState({ | ||
| 142 | config, | ||
| 143 | port: address.port, | ||
| 144 | }), | ||
| 145 | }); | ||
| 146 | return yield* run(); | ||
| 147 | }).pipe(Effect.provide(Layer.mergeAll(appLayer, NodeServices.layer))), | ||
| 148 | ); | ||
| 149 | }); | ||
| 150 | |||
| 151 | it.layer(NodeServices.layer)("cli log-level parsing", (it) => { | ||
| 152 | it.effect("accepts the built-in lowercase log-level flag values", () => | ||
| 153 | runCliWithRuntime(["--log-level", "debug", "--version"]), | ||
| 154 | ); | ||
| 155 | |||
| 156 | it.effect("accepts canonical --no-<flag> boolean negation", () => | ||
| 157 | runCliWithRuntime(["--no-log-websocket-events", "--version"]), | ||
| 158 | ); | ||
| 159 | |||
| 160 | it.effect("rejects invalid log-level casing before launching the server", () => | ||
| 161 | Effect.gen(function* () { | ||
| 162 | const error = yield* runCliWithRuntime(["--log-level", "Debug"]).pipe(Effect.flip); | ||
| 163 | |||
| 164 | if (!CliError.isCliError(error)) { | ||
| 165 | assert.fail(`Expected CliError, got ${String(error)}`); | ||
| 166 | } | ||
| 167 | if (error._tag !== "InvalidValue") { | ||
| 168 | assert.fail(`Expected InvalidValue, got ${error._tag}`); | ||
| 169 | } | ||
| 170 | assert.equal(error.option, "log-level"); | ||
| 171 | assert.equal(error.value, "Debug"); | ||
| 172 | }), | ||
| 173 | ); | ||
| 174 | |||
| 175 | it.effect("executes auth pairing subcommands and redacts secrets from list output", () => | ||
| 176 | Effect.gen(function* () { | ||
| 177 | const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-auth-pairing-test-")); | ||
| 178 | |||
| 179 | const createdOutput = yield* captureStdout( | ||
| 180 | runCli(["auth", "pairing", "create", "--base-dir", baseDir, "--json"]), | ||
| 181 | ); | ||
| 182 | const created = JSON.parse(createdOutput.output) as { | ||
| 183 | readonly id: string; | ||
| 184 | readonly credential: string; | ||
| 185 | }; | ||
| 186 | const listedOutput = yield* captureStdout( | ||
| 187 | runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]), | ||
| 188 | ); | ||
| 189 | const listed = JSON.parse(listedOutput.output) as ReadonlyArray<{ | ||
| 190 | readonly id: string; | ||
| 191 | readonly credential?: string; | ||
| 192 | }>; | ||
| 193 | |||
| 194 | assert.equal(typeof created.id, "string"); | ||
| 195 | assert.equal(typeof created.credential, "string"); | ||
| 196 | assert.equal(created.credential.length > 0, true); | ||
| 197 | assert.equal(listed.length, 1); | ||
| 198 | assert.equal(listed[0]?.id, created.id); | ||
| 199 | assert.equal("credential" in (listed[0] ?? {}), false); | ||
| 200 | }), | ||
| 201 | ); | ||
| 202 | |||
| 203 | it.effect("executes auth session subcommands and redacts secrets from list output", () => | ||
| 204 | Effect.gen(function* () { | ||
| 205 | const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-auth-session-test-")); | ||
| 206 | |||
| 207 | const issuedOutput = yield* captureStdout( | ||
| 208 | runCli(["auth", "session", "issue", "--base-dir", baseDir, "--json"]), | ||
| 209 | ); | ||
| 210 | const issued = JSON.parse(issuedOutput.output) as { | ||
| 211 | readonly sessionId: string; | ||
| 212 | readonly token: string; | ||
| 213 | readonly role: string; | ||
| 214 | }; | ||
| 215 | const listedOutput = yield* captureStdout( | ||
| 216 | runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"]), | ||
| 217 | ); | ||
| 218 | const listed = JSON.parse(listedOutput.output) as ReadonlyArray<{ | ||
| 219 | readonly sessionId: string; | ||
| 220 | readonly token?: string; | ||
| 221 | readonly role: string; | ||
| 222 | }>; | ||
| 223 | |||
| 224 | assert.equal(typeof issued.sessionId, "string"); | ||
| 225 | assert.equal(typeof issued.token, "string"); | ||
| 226 | assert.equal(issued.role, "owner"); | ||
| 227 | assert.equal(listed.length, 1); | ||
| 228 | assert.equal(listed[0]?.sessionId, issued.sessionId); | ||
| 229 | assert.equal(listed[0]?.role, "owner"); | ||
| 230 | assert.equal("token" in (listed[0] ?? {}), false); | ||
| 231 | }), | ||
| 232 | ); | ||
| 233 | |||
| 234 | it.effect("rejects invalid ttl values before running auth commands", () => | ||
| 235 | Effect.gen(function* () { | ||
| 236 | const error = yield* runCliWithRuntime(["auth", "pairing", "create", "--ttl", "soon"]).pipe( | ||
| 237 | Effect.flip, | ||
| 238 | ); | ||
| 239 | |||
| 240 | if (!CliError.isCliError(error)) { | ||
| 241 | assert.fail(`Expected CliError, got ${String(error)}`); | ||
| 242 | } | ||
| 243 | if (error._tag !== "ShowHelp") { | ||
| 244 | assert.fail(`Expected ShowHelp, got ${error._tag}`); | ||
| 245 | } | ||
| 246 | assert.deepEqual(error.commandPath, ["t3", "auth", "pairing", "create"]); | ||
| 247 | const ttlError = error.errors[0] as CliError.CliError | undefined; | ||
| 248 | if (!ttlError || ttlError._tag !== "InvalidValue") { | ||
| 249 | assert.fail(`Expected InvalidValue, got ${String(ttlError?._tag)}`); | ||
| 250 | } | ||
| 251 | assert.equal(ttlError.option, "ttl"); | ||
| 252 | assert.equal(ttlError.value, "soon"); | ||
| 253 | assert.isTrue(ttlError.message.includes("Invalid duration")); | ||
| 254 | assert.isTrue(ttlError.message.includes("5m, 1h, 30d, or 15 minutes")); | ||
| 255 | }), | ||
| 256 | ); | ||
| 257 | |||
| 258 | it.effect("adds, renames, and removes projects offline through the orchestration engine", () => | ||
| 259 | Effect.gen(function* () { | ||
| 260 | const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-projects-offline-test-")); | ||
| 261 | const workspaceRoot = mkdtempSync(join(tmpdir(), "t3-cli-projects-workspace-")); | ||
| 262 | |||
| 263 | yield* runCliWithRuntime([ | ||
| 264 | "project", | ||
| 265 | "add", | ||
| 266 | workspaceRoot, | ||
| 267 | "--title", | ||
| 268 | "Alpha", | ||
| 269 | "--base-dir", | ||
| 270 | baseDir, | ||
| 271 | ]); | ||
| 272 | const afterAdd = yield* readPersistedSnapshot(baseDir); | ||
| 273 | const addedProject = afterAdd.projects.find( | ||
| 274 | (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null, | ||
| 275 | ); | ||
| 276 | assert.isTrue(addedProject !== undefined); | ||
| 277 | assert.equal(addedProject?.title, "Alpha"); | ||
| 278 | |||
| 279 | yield* runCliWithRuntime(["project", "rename", workspaceRoot, "Beta", "--base-dir", baseDir]); | ||
| 280 | const afterRename = yield* readPersistedSnapshot(baseDir); | ||
| 281 | const renamedProject = afterRename.projects.find( | ||
| 282 | (project) => project.id === addedProject?.id, | ||
| 283 | ); | ||
| 284 | assert.equal(renamedProject?.title, "Beta"); | ||
| 285 | assert.equal(renamedProject?.deletedAt, null); | ||
| 286 | |||
| 287 | yield* runCliWithRuntime([ | ||
| 288 | "project", | ||
| 289 | "remove", | ||
| 290 | addedProject?.id ?? "", | ||
| 291 | "--base-dir", | ||
| 292 | baseDir, | ||
| 293 | ]); | ||
| 294 | const afterRemove = yield* readPersistedSnapshot(baseDir); | ||
| 295 | const removedProject = afterRemove.projects.find( | ||
| 296 | (project) => project.id === addedProject?.id, | ||
| 297 | ); | ||
| 298 | assert.isTrue((removedProject?.deletedAt ?? null) !== null); | ||
| 299 | }), | ||
| 300 | ); | ||
| 301 | |||
| 302 | it.effect("routes project commands through a running server when runtime state is present", () => | ||
| 303 | Effect.gen(function* () { | ||
| 304 | const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-projects-live-test-")); | ||
| 305 | const workspaceRoot = mkdtempSync(join(tmpdir(), "t3-cli-projects-live-workspace-")); | ||
| 306 | |||
| 307 | yield* withLiveProjectCliServer(baseDir, () => | ||
| 308 | Effect.gen(function* () { | ||
| 309 | yield* runCliWithRuntime([ | ||
| 310 | "project", | ||
| 311 | "add", | ||
| 312 | workspaceRoot, | ||
| 313 | "--title", | ||
| 314 | "Live Project", | ||
| 315 | "--base-dir", | ||
| 316 | baseDir, | ||
| 317 | ]); | ||
| 318 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; | ||
| 319 | const readModel = yield* projectionSnapshotQuery.getSnapshot(); | ||
| 320 | const addedProject = readModel.projects.find( | ||
| 321 | (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null, | ||
| 322 | ); | ||
| 323 | assert.isTrue(addedProject !== undefined); | ||
| 324 | assert.equal(addedProject?.title, "Live Project"); | ||
| 325 | }), | ||
| 326 | ); | ||
| 327 | }), | ||
| 328 | ); | ||
| 329 | |||
| 330 | it.effect("rejects dev-url on project commands", () => | ||
| 331 | Effect.gen(function* () { | ||
| 332 | const workspaceRoot = mkdtempSync( | ||
| 333 | join(tmpdir(), "t3-cli-projects-unknown-option-workspace-"), | ||
| 334 | ); | ||
| 335 | const error = yield* runCliWithRuntime([ | ||
| 336 | "project", | ||
| 337 | "add", | ||
| 338 | workspaceRoot, | ||
| 339 | "--dev-url", | ||
| 340 | "http://127.0.0.1:5173", | ||
| 341 | ]).pipe(Effect.flip); | ||
| 342 | |||
| 343 | if (!CliError.isCliError(error)) { | ||
| 344 | assert.fail(`Expected CliError, got ${String(error)}`); | ||
| 345 | } | ||
| 346 | if (error._tag !== "ShowHelp") { | ||
| 347 | assert.fail(`Expected ShowHelp, got ${error._tag}`); | ||
| 348 | } | ||
| 349 | assert.deepEqual(error.commandPath, ["t3", "project", "add"]); | ||
| 350 | const optionError = error.errors[0] as CliError.CliError | undefined; | ||
| 351 | if (!optionError || optionError._tag !== "UnrecognizedOption") { | ||
| 352 | assert.fail(`Expected UnrecognizedOption, got ${String(optionError?._tag)}`); | ||
| 353 | } | ||
| 354 | assert.equal(optionError.option, "--dev-url"); | ||
| 355 | }), | ||
| 356 | ); | ||
| 357 | }); | ||
apps/server/src/cli.ts deleted-1180| ... | @@ -1,1180 +0,0 @@ | ||
| 1 | import { NetService } from "@t3tools/shared/Net"; | ||
| 2 | import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; | ||
| 3 | import { | ||
| 4 | AuthSessionId, | ||
| 5 | CommandId, | ||
| 6 | OrchestrationReadModel, | ||
| 7 | ProjectId, | ||
| 8 | type ClientOrchestrationCommand, | ||
| 9 | } from "@t3tools/contracts"; | ||
| 10 | import { | ||
| 11 | Config, | ||
| 12 | Console, | ||
| 13 | Duration, | ||
| 14 | Effect, | ||
| 15 | Exit, | ||
| 16 | FileSystem, | ||
| 17 | Layer, | ||
| 18 | LogLevel, | ||
| 19 | Option, | ||
| 20 | Path, | ||
| 21 | References, | ||
| 22 | Schema, | ||
| 23 | SchemaIssue, | ||
| 24 | SchemaTransformation, | ||
| 25 | } from "effect"; | ||
| 26 | import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; | ||
| 27 | import { | ||
| 28 | FetchHttpClient, | ||
| 29 | HttpClient, | ||
| 30 | HttpClientRequest, | ||
| 31 | HttpClientResponse, | ||
| 32 | } from "effect/unstable/http"; | ||
| 33 | |||
| 34 | import { | ||
| 35 | DEFAULT_PORT, | ||
| 36 | deriveServerPaths, | ||
| 37 | ensureServerDirectories, | ||
| 38 | resolveStaticDir, | ||
| 39 | ServerConfig, | ||
| 40 | RuntimeMode, | ||
| 41 | type ServerConfigShape, | ||
| 42 | type StartupPresentation, | ||
| 43 | } from "./config.ts"; | ||
| 44 | import { readBootstrapEnvelope } from "./bootstrap.ts"; | ||
| 45 | import { expandHomePath, resolveBaseDir } from "./os-jank.ts"; | ||
| 46 | import { runServer } from "./server.ts"; | ||
| 47 | import { AuthControlPlaneRuntimeLive } from "./auth/Layers/AuthControlPlane.ts"; | ||
| 48 | import { | ||
| 49 | formatIssuedPairingCredential, | ||
| 50 | formatIssuedSession, | ||
| 51 | formatPairingCredentialList, | ||
| 52 | formatSessionList, | ||
| 53 | } from "./cliAuthFormat.ts"; | ||
| 54 | import { AuthControlPlane } from "./auth/Services/AuthControlPlane.ts"; | ||
| 55 | import type { AuthControlPlaneShape } from "./auth/Services/AuthControlPlane.ts"; | ||
| 56 | import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; | ||
| 57 | import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; | ||
| 58 | import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; | ||
| 59 | import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; | ||
| 60 | import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; | ||
| 61 | import { getAutoBootstrapDefaultModelSelection } from "./serverRuntimeStartup.ts"; | ||
| 62 | import { | ||
| 63 | clearPersistedServerRuntimeState, | ||
| 64 | readPersistedServerRuntimeState, | ||
| 65 | } from "./serverRuntimeState.ts"; | ||
| 66 | import { WorkspacePaths } from "./workspace/Services/WorkspacePaths.ts"; | ||
| 67 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; | ||
| 68 | |||
| 69 | const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); | ||
| 70 | |||
| 71 | const BootstrapEnvelopeSchema = Schema.Struct({ | ||
| 72 | mode: Schema.optional(RuntimeMode), | ||
| 73 | port: Schema.optional(PortSchema), | ||
| 74 | host: Schema.optional(Schema.String), | ||
| 75 | t3Home: Schema.optional(Schema.String), | ||
| 76 | devUrl: Schema.optional(Schema.URLFromString), | ||
| 77 | noBrowser: Schema.optional(Schema.Boolean), | ||
| 78 | desktopBootstrapToken: Schema.optional(Schema.String), | ||
| 79 | autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), | ||
| 80 | logWebSocketEvents: Schema.optional(Schema.Boolean), | ||
| 81 | tailscaleServeEnabled: Schema.optional(Schema.Boolean), | ||
| 82 | tailscaleServePort: Schema.optional(PortSchema), | ||
| 83 | otlpTracesUrl: Schema.optional(Schema.String), | ||
| 84 | otlpMetricsUrl: Schema.optional(Schema.String), | ||
| 85 | }); | ||
| 86 | |||
| 87 | const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe( | ||
| 88 | Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), | ||
| 89 | Flag.optional, | ||
| 90 | ); | ||
| 91 | const portFlag = Flag.integer("port").pipe( | ||
| 92 | Flag.withSchema(PortSchema), | ||
| 93 | Flag.withDescription("Port for the HTTP/WebSocket server."), | ||
| 94 | Flag.optional, | ||
| 95 | ); | ||
| 96 | const hostFlag = Flag.string("host").pipe( | ||
| 97 | Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."), | ||
| 98 | Flag.optional, | ||
| 99 | ); | ||
| 100 | const baseDirFlag = Flag.string("base-dir").pipe( | ||
| 101 | Flag.withDescription("Base directory path (equivalent to T3CODE_HOME)."), | ||
| 102 | Flag.optional, | ||
| 103 | ); | ||
| 104 | const devUrlFlag = Flag.string("dev-url").pipe( | ||
| 105 | Flag.withSchema(Schema.URLFromString), | ||
| 106 | Flag.withDescription("Dev web URL to proxy/redirect to (equivalent to VITE_DEV_SERVER_URL)."), | ||
| 107 | Flag.optional, | ||
| 108 | ); | ||
| 109 | const noBrowserFlag = Flag.boolean("no-browser").pipe( | ||
| 110 | Flag.withDescription("Disable automatic browser opening."), | ||
| 111 | Flag.optional, | ||
| 112 | ); | ||
| 113 | const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe( | ||
| 114 | Flag.withSchema(Schema.Int), | ||
| 115 | Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."), | ||
| 116 | Flag.optional, | ||
| 117 | ); | ||
| 118 | const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe( | ||
| 119 | Flag.withDescription( | ||
| 120 | "Create a project for the current working directory on startup when missing.", | ||
| 121 | ), | ||
| 122 | Flag.optional, | ||
| 123 | ); | ||
| 124 | const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe( | ||
| 125 | Flag.withDescription( | ||
| 126 | "Emit server-side logs for outbound WebSocket push traffic (equivalent to T3CODE_LOG_WS_EVENTS).", | ||
| 127 | ), | ||
| 128 | Flag.withAlias("log-ws-events"), | ||
| 129 | Flag.optional, | ||
| 130 | ); | ||
| 131 | const tailscaleServeFlag = Flag.boolean("tailscale-serve").pipe( | ||
| 132 | Flag.withDescription( | ||
| 133 | "Configure Tailscale Serve to expose this backend over HTTPS on the Tailnet.", | ||
| 134 | ), | ||
| 135 | Flag.optional, | ||
| 136 | ); | ||
| 137 | const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe( | ||
| 138 | Flag.withSchema(PortSchema), | ||
| 139 | Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."), | ||
| 140 | Flag.optional, | ||
| 141 | ); | ||
| 142 | |||
| 143 | const EnvServerConfig = Config.all({ | ||
| 144 | logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")), | ||
| 145 | traceMinLevel: Config.logLevel("T3CODE_TRACE_MIN_LEVEL").pipe(Config.withDefault("Info")), | ||
| 146 | traceTimingEnabled: Config.boolean("T3CODE_TRACE_TIMING_ENABLED").pipe(Config.withDefault(true)), | ||
| 147 | traceFile: Config.string("T3CODE_TRACE_FILE").pipe( | ||
| 148 | Config.option, | ||
| 149 | Config.map(Option.getOrUndefined), | ||
| 150 | ), | ||
| 151 | traceMaxBytes: Config.int("T3CODE_TRACE_MAX_BYTES").pipe(Config.withDefault(10 * 1024 * 1024)), | ||
| 152 | traceMaxFiles: Config.int("T3CODE_TRACE_MAX_FILES").pipe(Config.withDefault(10)), | ||
| 153 | traceBatchWindowMs: Config.int("T3CODE_TRACE_BATCH_WINDOW_MS").pipe(Config.withDefault(200)), | ||
| 154 | otlpTracesUrl: Config.string("T3CODE_OTLP_TRACES_URL").pipe( | ||
| 155 | Config.option, | ||
| 156 | Config.map(Option.getOrUndefined), | ||
| 157 | ), | ||
| 158 | otlpMetricsUrl: Config.string("T3CODE_OTLP_METRICS_URL").pipe( | ||
| 159 | Config.option, | ||
| 160 | Config.map(Option.getOrUndefined), | ||
| 161 | ), | ||
| 162 | otlpExportIntervalMs: Config.int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( | ||
| 163 | Config.withDefault(10_000), | ||
| 164 | ), | ||
| 165 | otlpServiceName: Config.string("T3CODE_OTLP_SERVICE_NAME").pipe(Config.withDefault("t3-server")), | ||
| 166 | mode: Config.schema(RuntimeMode, "T3CODE_MODE").pipe( | ||
| 167 | Config.option, | ||
| 168 | Config.map(Option.getOrUndefined), | ||
| 169 | ), | ||
| 170 | port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), | ||
| 171 | host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), | ||
| 172 | t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)), | ||
| 173 | devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)), | ||
| 174 | noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe( | ||
| 175 | Config.option, | ||
| 176 | Config.map(Option.getOrUndefined), | ||
| 177 | ), | ||
| 178 | bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe( | ||
| 179 | Config.option, | ||
| 180 | Config.map(Option.getOrUndefined), | ||
| 181 | ), | ||
| 182 | autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe( | ||
| 183 | Config.option, | ||
| 184 | Config.map(Option.getOrUndefined), | ||
| 185 | ), | ||
| 186 | logWebSocketEvents: Config.boolean("T3CODE_LOG_WS_EVENTS").pipe( | ||
| 187 | Config.option, | ||
| 188 | Config.map(Option.getOrUndefined), | ||
| 189 | ), | ||
| 190 | tailscaleServeEnabled: Config.boolean("T3CODE_TAILSCALE_SERVE").pipe( | ||
| 191 | Config.option, | ||
| 192 | Config.map(Option.getOrUndefined), | ||
| 193 | ), | ||
| 194 | tailscaleServePort: Config.port("T3CODE_TAILSCALE_SERVE_PORT").pipe( | ||
| 195 | Config.option, | ||
| 196 | Config.map(Option.getOrUndefined), | ||
| 197 | ), | ||
| 198 | }); | ||
| 199 | |||
| 200 | interface CliServerFlags { | ||
| 201 | readonly mode: Option.Option<RuntimeMode>; | ||
| 202 | readonly port: Option.Option<number>; | ||
| 203 | readonly host: Option.Option<string>; | ||
| 204 | readonly baseDir: Option.Option<string>; | ||
| 205 | readonly cwd: Option.Option<string>; | ||
| 206 | readonly devUrl: Option.Option<URL>; | ||
| 207 | readonly noBrowser: Option.Option<boolean>; | ||
| 208 | readonly bootstrapFd: Option.Option<number>; | ||
| 209 | readonly autoBootstrapProjectFromCwd: Option.Option<boolean>; | ||
| 210 | readonly logWebSocketEvents: Option.Option<boolean>; | ||
| 211 | readonly tailscaleServeEnabled: Option.Option<boolean>; | ||
| 212 | readonly tailscaleServePort: Option.Option<number>; | ||
| 213 | } | ||
| 214 | |||
| 215 | interface CliAuthLocationFlags { | ||
| 216 | readonly baseDir: Option.Option<string>; | ||
| 217 | readonly devUrl?: Option.Option<URL>; | ||
| 218 | } | ||
| 219 | |||
| 220 | const resolveOptionPrecedence = <Value>( | ||
| 221 | ...values: ReadonlyArray<Option.Option<Value>> | ||
| 222 | ): Option.Option<Value> => Option.firstSomeOf(values); | ||
| 223 | |||
| 224 | const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: string) { | ||
| 225 | const fs = yield* FileSystem.FileSystem; | ||
| 226 | const exists = yield* fs.exists(settingsPath).pipe(Effect.orElseSucceed(() => false)); | ||
| 227 | if (!exists) { | ||
| 228 | return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; | ||
| 229 | } | ||
| 230 | |||
| 231 | const raw = yield* fs.readFileString(settingsPath).pipe(Effect.orElseSucceed(() => "")); | ||
| 232 | return parsePersistedServerObservabilitySettings(raw); | ||
| 233 | }); | ||
| 234 | |||
| 235 | export const resolveServerConfig = ( | ||
| 236 | flags: CliServerFlags, | ||
| 237 | cliLogLevel: Option.Option<LogLevel.LogLevel>, | ||
| 238 | options?: { | ||
| 239 | readonly startupPresentation?: StartupPresentation; | ||
| 240 | readonly forceAutoBootstrapProjectFromCwd?: boolean; | ||
| 241 | }, | ||
| 242 | ) => | ||
| 243 | Effect.gen(function* () { | ||
| 244 | const { findAvailablePort } = yield* NetService; | ||
| 245 | const path = yield* Path.Path; | ||
| 246 | const fs = yield* FileSystem.FileSystem; | ||
| 247 | const env = yield* EnvServerConfig; | ||
| 248 | const normalizedFlags = { | ||
| 249 | mode: flags.mode ?? Option.none(), | ||
| 250 | port: flags.port ?? Option.none(), | ||
| 251 | host: flags.host ?? Option.none(), | ||
| 252 | baseDir: flags.baseDir ?? Option.none(), | ||
| 253 | cwd: flags.cwd ?? Option.none(), | ||
| 254 | devUrl: flags.devUrl ?? Option.none(), | ||
| 255 | noBrowser: flags.noBrowser ?? Option.none(), | ||
| 256 | bootstrapFd: flags.bootstrapFd ?? Option.none(), | ||
| 257 | autoBootstrapProjectFromCwd: flags.autoBootstrapProjectFromCwd ?? Option.none(), | ||
| 258 | logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(), | ||
| 259 | tailscaleServeEnabled: flags.tailscaleServeEnabled ?? Option.none(), | ||
| 260 | tailscaleServePort: flags.tailscaleServePort ?? Option.none(), | ||
| 261 | } satisfies CliServerFlags; | ||
| 262 | const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd; | ||
| 263 | const bootstrapEnvelope = | ||
| 264 | bootstrapFd !== undefined | ||
| 265 | ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd) | ||
| 266 | : Option.none(); | ||
| 267 | const bootstrap = Option.getOrUndefined(bootstrapEnvelope); | ||
| 268 | |||
| 269 | const mode: RuntimeMode = Option.getOrElse( | ||
| 270 | resolveOptionPrecedence( | ||
| 271 | normalizedFlags.mode, | ||
| 272 | Option.fromUndefinedOr(env.mode), | ||
| 273 | Option.fromUndefinedOr(bootstrap?.mode), | ||
| 274 | ), | ||
| 275 | () => "web", | ||
| 276 | ); | ||
| 277 | |||
| 278 | const port = yield* Option.match( | ||
| 279 | resolveOptionPrecedence( | ||
| 280 | normalizedFlags.port, | ||
| 281 | Option.fromUndefinedOr(env.port), | ||
| 282 | Option.fromUndefinedOr(bootstrap?.port), | ||
| 283 | ), | ||
| 284 | { | ||
| 285 | onSome: (value) => Effect.succeed(value), | ||
| 286 | onNone: () => { | ||
| 287 | if (mode === "desktop") { | ||
| 288 | return Effect.succeed(DEFAULT_PORT); | ||
| 289 | } | ||
| 290 | return findAvailablePort(DEFAULT_PORT); | ||
| 291 | }, | ||
| 292 | }, | ||
| 293 | ); | ||
| 294 | const devUrl = Option.getOrElse( | ||
| 295 | resolveOptionPrecedence( | ||
| 296 | normalizedFlags.devUrl, | ||
| 297 | Option.fromUndefinedOr(env.devUrl), | ||
| 298 | Option.fromUndefinedOr(bootstrap?.devUrl), | ||
| 299 | ), | ||
| 300 | () => undefined, | ||
| 301 | ); | ||
| 302 | const baseDir = yield* resolveBaseDir( | ||
| 303 | Option.getOrUndefined( | ||
| 304 | resolveOptionPrecedence( | ||
| 305 | normalizedFlags.baseDir, | ||
| 306 | Option.fromUndefinedOr(env.t3Home), | ||
| 307 | Option.fromUndefinedOr(bootstrap?.t3Home), | ||
| 308 | ), | ||
| 309 | ), | ||
| 310 | ); | ||
| 311 | const rawCwd = Option.getOrElse(normalizedFlags.cwd, () => process.cwd()); | ||
| 312 | const cwd = path.resolve(yield* expandHomePath(rawCwd.trim())); | ||
| 313 | yield* fs.makeDirectory(cwd, { recursive: true }); | ||
| 314 | const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); | ||
| 315 | yield* ensureServerDirectories(derivedPaths); | ||
| 316 | const persistedObservabilitySettings = yield* loadPersistedObservabilitySettings( | ||
| 317 | derivedPaths.settingsPath, | ||
| 318 | ); | ||
| 319 | const serverTracePath = env.traceFile ?? derivedPaths.serverTracePath; | ||
| 320 | yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true }); | ||
| 321 | const startupPresentation = options?.startupPresentation ?? "browser"; | ||
| 322 | const isHeadlessStartup = startupPresentation === "headless"; | ||
| 323 | const noBrowser = Option.getOrElse( | ||
| 324 | resolveOptionPrecedence( | ||
| 325 | isHeadlessStartup ? Option.some(true) : Option.none(), | ||
| 326 | normalizedFlags.noBrowser, | ||
| 327 | Option.fromUndefinedOr(env.noBrowser), | ||
| 328 | Option.fromUndefinedOr(bootstrap?.noBrowser), | ||
| 329 | ), | ||
| 330 | () => mode === "desktop", | ||
| 331 | ); | ||
| 332 | const desktopBootstrapToken = bootstrap?.desktopBootstrapToken; | ||
| 333 | const autoBootstrapProjectFromCwd = Option.getOrElse( | ||
| 334 | resolveOptionPrecedence( | ||
| 335 | Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd), | ||
| 336 | isHeadlessStartup ? Option.some(false) : Option.none(), | ||
| 337 | normalizedFlags.autoBootstrapProjectFromCwd, | ||
| 338 | Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd), | ||
| 339 | Option.fromUndefinedOr(bootstrap?.autoBootstrapProjectFromCwd), | ||
| 340 | ), | ||
| 341 | () => mode === "web", | ||
| 342 | ); | ||
| 343 | const logWebSocketEvents = Option.getOrElse( | ||
| 344 | resolveOptionPrecedence( | ||
| 345 | normalizedFlags.logWebSocketEvents, | ||
| 346 | Option.fromUndefinedOr(env.logWebSocketEvents), | ||
| 347 | Option.fromUndefinedOr(bootstrap?.logWebSocketEvents), | ||
| 348 | ), | ||
| 349 | () => Boolean(devUrl), | ||
| 350 | ); | ||
| 351 | const tailscaleServeEnabled = Option.getOrElse( | ||
| 352 | resolveOptionPrecedence( | ||
| 353 | normalizedFlags.tailscaleServeEnabled, | ||
| 354 | Option.fromUndefinedOr(env.tailscaleServeEnabled), | ||
| 355 | Option.fromUndefinedOr(bootstrap?.tailscaleServeEnabled), | ||
| 356 | ), | ||
| 357 | () => false, | ||
| 358 | ); | ||
| 359 | const tailscaleServePort = Option.getOrElse( | ||
| 360 | resolveOptionPrecedence( | ||
| 361 | normalizedFlags.tailscaleServePort, | ||
| 362 | Option.fromUndefinedOr(env.tailscaleServePort), | ||
| 363 | Option.fromUndefinedOr(bootstrap?.tailscaleServePort), | ||
| 364 | ), | ||
| 365 | () => 443, | ||
| 366 | ); | ||
| 367 | const staticDir = devUrl ? undefined : yield* resolveStaticDir(); | ||
| 368 | const host = Option.getOrElse( | ||
| 369 | resolveOptionPrecedence( | ||
| 370 | normalizedFlags.host, | ||
| 371 | Option.fromUndefinedOr(env.host), | ||
| 372 | Option.fromUndefinedOr(bootstrap?.host), | ||
| 373 | ), | ||
| 374 | () => (mode === "desktop" ? "127.0.0.1" : undefined), | ||
| 375 | ); | ||
| 376 | const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel); | ||
| 377 | |||
| 378 | const config: ServerConfigShape = { | ||
| 379 | logLevel, | ||
| 380 | traceMinLevel: env.traceMinLevel, | ||
| 381 | traceTimingEnabled: env.traceTimingEnabled, | ||
| 382 | traceBatchWindowMs: env.traceBatchWindowMs, | ||
| 383 | traceMaxBytes: env.traceMaxBytes, | ||
| 384 | traceMaxFiles: env.traceMaxFiles, | ||
| 385 | otlpTracesUrl: | ||
| 386 | env.otlpTracesUrl ?? | ||
| 387 | bootstrap?.otlpTracesUrl ?? | ||
| 388 | persistedObservabilitySettings.otlpTracesUrl, | ||
| 389 | otlpMetricsUrl: | ||
| 390 | env.otlpMetricsUrl ?? | ||
| 391 | bootstrap?.otlpMetricsUrl ?? | ||
| 392 | persistedObservabilitySettings.otlpMetricsUrl, | ||
| 393 | otlpExportIntervalMs: env.otlpExportIntervalMs, | ||
| 394 | otlpServiceName: env.otlpServiceName, | ||
| 395 | mode, | ||
| 396 | port, | ||
| 397 | cwd, | ||
| 398 | baseDir, | ||
| 399 | ...derivedPaths, | ||
| 400 | serverTracePath, | ||
| 401 | host, | ||
| 402 | staticDir, | ||
| 403 | devUrl, | ||
| 404 | noBrowser, | ||
| 405 | startupPresentation, | ||
| 406 | desktopBootstrapToken, | ||
| 407 | autoBootstrapProjectFromCwd, | ||
| 408 | logWebSocketEvents, | ||
| 409 | tailscaleServeEnabled, | ||
| 410 | tailscaleServePort, | ||
| 411 | }; | ||
| 412 | |||
| 413 | return config; | ||
| 414 | }); | ||
| 415 | |||
| 416 | const resolveCliAuthConfig = ( | ||
| 417 | flags: CliAuthLocationFlags, | ||
| 418 | cliLogLevel: Option.Option<LogLevel.LogLevel>, | ||
| 419 | ) => | ||
| 420 | resolveServerConfig( | ||
| 421 | { | ||
| 422 | mode: Option.none(), | ||
| 423 | port: Option.none(), | ||
| 424 | host: Option.none(), | ||
| 425 | baseDir: flags.baseDir, | ||
| 426 | cwd: Option.none(), | ||
| 427 | devUrl: flags.devUrl ?? Option.none(), | ||
| 428 | noBrowser: Option.none(), | ||
| 429 | bootstrapFd: Option.none(), | ||
| 430 | autoBootstrapProjectFromCwd: Option.none(), | ||
| 431 | logWebSocketEvents: Option.none(), | ||
| 432 | tailscaleServeEnabled: Option.none(), | ||
| 433 | tailscaleServePort: Option.none(), | ||
| 434 | }, | ||
| 435 | cliLogLevel, | ||
| 436 | ); | ||
| 437 | |||
| 438 | const DurationShorthandPattern = /^(?<value>\d+)(?<unit>ms|s|m|h|d|w)$/i; | ||
| 439 | |||
| 440 | const parseDurationInput = (value: string): Duration.Duration | null => { | ||
| 441 | const trimmed = value.trim(); | ||
| 442 | if (trimmed.length === 0) return null; | ||
| 443 | |||
| 444 | const shorthand = DurationShorthandPattern.exec(trimmed); | ||
| 445 | const normalizedInput = shorthand?.groups | ||
| 446 | ? (() => { | ||
| 447 | const amountText = shorthand.groups.value; | ||
| 448 | const unitText = shorthand.groups.unit; | ||
| 449 | if (typeof amountText !== "string" || typeof unitText !== "string") { | ||
| 450 | return null; | ||
| 451 | } | ||
| 452 | |||
| 453 | const amount = Number.parseInt(amountText, 10); | ||
| 454 | if (!Number.isFinite(amount)) return null; | ||
| 455 | |||
| 456 | switch (unitText.toLowerCase()) { | ||
| 457 | case "ms": | ||
| 458 | return `${amount} millis`; | ||
| 459 | case "s": | ||
| 460 | return `${amount} seconds`; | ||
| 461 | case "m": | ||
| 462 | return `${amount} minutes`; | ||
| 463 | case "h": | ||
| 464 | return `${amount} hours`; | ||
| 465 | case "d": | ||
| 466 | return `${amount} days`; | ||
| 467 | case "w": | ||
| 468 | return `${amount} weeks`; | ||
| 469 | default: | ||
| 470 | return null; | ||
| 471 | } | ||
| 472 | })() | ||
| 473 | : (trimmed as Duration.Input); | ||
| 474 | |||
| 475 | if (normalizedInput === null) return null; | ||
| 476 | |||
| 477 | const decoded = Duration.fromInput(normalizedInput as Duration.Input); | ||
| 478 | return Option.isSome(decoded) ? decoded.value : null; | ||
| 479 | }; | ||
| 480 | |||
| 481 | const DurationFromString = Schema.String.pipe( | ||
| 482 | Schema.decodeTo( | ||
| 483 | Schema.Duration, | ||
| 484 | SchemaTransformation.transformOrFail({ | ||
| 485 | decode: (value) => { | ||
| 486 | const duration = parseDurationInput(value); | ||
| 487 | if (duration !== null) { | ||
| 488 | return Effect.succeed(duration); | ||
| 489 | } | ||
| 490 | return Effect.fail( | ||
| 491 | new SchemaIssue.InvalidValue(Option.some(value), { | ||
| 492 | message: "Invalid duration. Use values like 5m, 1h, 30d, or 15 minutes.", | ||
| 493 | }), | ||
| 494 | ); | ||
| 495 | }, | ||
| 496 | encode: (duration) => Effect.succeed(Duration.format(duration)), | ||
| 497 | }), | ||
| 498 | ), | ||
| 499 | ); | ||
| 500 | |||
| 501 | const runWithAuthControlPlane = <A, E>( | ||
| 502 | flags: CliAuthLocationFlags, | ||
| 503 | run: (authControlPlane: AuthControlPlaneShape) => Effect.Effect<A, E>, | ||
| 504 | options?: { | ||
| 505 | readonly quietLogs?: boolean; | ||
| 506 | }, | ||
| 507 | ) => | ||
| 508 | Effect.gen(function* () { | ||
| 509 | const logLevel = yield* GlobalFlag.LogLevel; | ||
| 510 | const config = yield* resolveCliAuthConfig(flags, logLevel); | ||
| 511 | const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel; | ||
| 512 | return yield* Effect.gen(function* () { | ||
| 513 | const authControlPlane = yield* AuthControlPlane; | ||
| 514 | return yield* run(authControlPlane); | ||
| 515 | }).pipe( | ||
| 516 | Effect.provide( | ||
| 517 | Layer.mergeAll(AuthControlPlaneRuntimeLive).pipe( | ||
| 518 | Layer.provide(Layer.succeed(ServerConfig, config)), | ||
| 519 | Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), | ||
| 520 | ), | ||
| 521 | ), | ||
| 522 | ); | ||
| 523 | }); | ||
| 524 | |||
| 525 | type ProjectMutationTarget = { | ||
| 526 | readonly id: ProjectId; | ||
| 527 | readonly title: string; | ||
| 528 | readonly workspaceRoot: string; | ||
| 529 | }; | ||
| 530 | |||
| 531 | type ProjectCommandExecutionMode = "live" | "offline"; | ||
| 532 | type ProjectCliDispatchCommand = Extract< | ||
| 533 | ClientOrchestrationCommand, | ||
| 534 | { type: "project.create" | "project.meta.update" | "project.delete" } | ||
| 535 | >; | ||
| 536 | |||
| 537 | const ProjectCliRuntimeLive = Layer.mergeAll( | ||
| 538 | WorkspacePathsLive, | ||
| 539 | OrchestrationLayerLive.pipe( | ||
| 540 | Layer.provideMerge(RepositoryIdentityResolverLive), | ||
| 541 | Layer.provideMerge(SqlitePersistenceLayerLive), | ||
| 542 | ), | ||
| 543 | ); | ||
| 544 | |||
| 545 | const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1); | ||
| 546 | const OrchestrationHttpErrorResponse = Schema.Struct({ | ||
| 547 | error: Schema.String, | ||
| 548 | }); | ||
| 549 | |||
| 550 | const withProjectCliSessionToken = <A, E, R>( | ||
| 551 | authControlPlane: AuthControlPlaneShape, | ||
| 552 | run: (token: string) => Effect.Effect<A, E, R>, | ||
| 553 | ) => | ||
| 554 | Effect.acquireUseRelease( | ||
| 555 | authControlPlane.issueSession({ | ||
| 556 | role: "owner", | ||
| 557 | label: "t3 project cli", | ||
| 558 | }), | ||
| 559 | (issued) => run(issued.token), | ||
| 560 | (issued) => authControlPlane.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })), | ||
| 561 | ); | ||
| 562 | |||
| 563 | const withProjectCliLiveServerTimeout = <A, E, R>(effect: Effect.Effect<A, E, R>) => | ||
| 564 | effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT)); | ||
| 565 | |||
| 566 | const runLiveServerRequest = <A, E extends Error, R>( | ||
| 567 | request: HttpClientRequest.HttpClientRequest, | ||
| 568 | handle: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect<A, E, R>, | ||
| 569 | ) => | ||
| 570 | Effect.gen(function* () { | ||
| 571 | const httpClient = yield* HttpClient.HttpClient; | ||
| 572 | const response = yield* httpClient.execute(request); | ||
| 573 | return yield* handle(response); | ||
| 574 | }).pipe(withProjectCliLiveServerTimeout); | ||
| 575 | |||
| 576 | const decodeOrchestrationReadModelResponse = (response: HttpClientResponse.HttpClientResponse) => | ||
| 577 | HttpClientResponse.schemaBodyJson(OrchestrationReadModel)(response); | ||
| 578 | |||
| 579 | const readErrorMessageFromResponse = (response: HttpClientResponse.HttpClientResponse) => | ||
| 580 | HttpClientResponse.schemaBodyJson(OrchestrationHttpErrorResponse)(response).pipe( | ||
| 581 | Effect.map((body) => body.error), | ||
| 582 | Effect.catch(() => Effect.succeed(null)), | ||
| 583 | Effect.map((body) => { | ||
| 584 | if (typeof body === "string" && body.trim().length > 0) { | ||
| 585 | return body; | ||
| 586 | } | ||
| 587 | return `Server request failed with status ${response.status}.`; | ||
| 588 | }), | ||
| 589 | ); | ||
| 590 | |||
| 591 | const normalizeWorkspaceRootForProjectCommand = Effect.fn( | ||
| 592 | "normalizeWorkspaceRootForProjectCommand", | ||
| 593 | )(function* (workspaceRoot: string) { | ||
| 594 | const workspacePaths = yield* WorkspacePaths; | ||
| 595 | return yield* workspacePaths.normalizeWorkspaceRoot(workspaceRoot); | ||
| 596 | }); | ||
| 597 | |||
| 598 | const resolveProjectTitle = Effect.fn("resolveProjectTitle")(function* ( | ||
| 599 | workspaceRoot: string, | ||
| 600 | explicitTitle?: string, | ||
| 601 | ) { | ||
| 602 | if (explicitTitle !== undefined) { | ||
| 603 | const trimmed = explicitTitle.trim(); | ||
| 604 | if (trimmed.length > 0) { | ||
| 605 | return trimmed; | ||
| 606 | } | ||
| 607 | return yield* Effect.fail(new Error("Project title cannot be empty.")); | ||
| 608 | } | ||
| 609 | |||
| 610 | const path = yield* Path.Path; | ||
| 611 | const basename = path.basename(workspaceRoot).trim(); | ||
| 612 | return basename.length > 0 ? basename : "project"; | ||
| 613 | }); | ||
| 614 | |||
| 615 | const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (input: { | ||
| 616 | readonly snapshot: OrchestrationReadModel; | ||
| 617 | readonly identifier: string; | ||
| 618 | }) { | ||
| 619 | const trimmedIdentifier = input.identifier.trim(); | ||
| 620 | if (trimmedIdentifier.length === 0) { | ||
| 621 | return yield* Effect.fail(new Error("Project identifier cannot be empty.")); | ||
| 622 | } | ||
| 623 | |||
| 624 | const activeProjects = input.snapshot.projects.filter((project) => project.deletedAt === null); | ||
| 625 | const exactIdMatch = activeProjects.find((project) => project.id === trimmedIdentifier); | ||
| 626 | if (exactIdMatch) { | ||
| 627 | return { | ||
| 628 | id: exactIdMatch.id, | ||
| 629 | title: exactIdMatch.title, | ||
| 630 | workspaceRoot: exactIdMatch.workspaceRoot, | ||
| 631 | } satisfies ProjectMutationTarget; | ||
| 632 | } | ||
| 633 | |||
| 634 | const normalizedWorkspaceRootResult = yield* Effect.exit( | ||
| 635 | normalizeWorkspaceRootForProjectCommand(trimmedIdentifier), | ||
| 636 | ); | ||
| 637 | const normalizedWorkspaceRoot = Exit.isSuccess(normalizedWorkspaceRootResult) | ||
| 638 | ? normalizedWorkspaceRootResult.value | ||
| 639 | : null; | ||
| 640 | |||
| 641 | const exactWorkspaceMatch = | ||
| 642 | normalizedWorkspaceRoot === null | ||
| 643 | ? undefined | ||
| 644 | : activeProjects.find((project) => project.workspaceRoot === normalizedWorkspaceRoot); | ||
| 645 | |||
| 646 | const resolved = exactWorkspaceMatch; | ||
| 647 | if (!resolved) { | ||
| 648 | return yield* Effect.fail(new Error(`No active project found for '${trimmedIdentifier}'.`)); | ||
| 649 | } | ||
| 650 | |||
| 651 | return { | ||
| 652 | id: resolved.id, | ||
| 653 | title: resolved.title, | ||
| 654 | workspaceRoot: resolved.workspaceRoot, | ||
| 655 | } satisfies ProjectMutationTarget; | ||
| 656 | }); | ||
| 657 | |||
| 658 | const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) => | ||
| 659 | runLiveServerRequest( | ||
| 660 | HttpClientRequest.get(`${origin}/api/orchestration/snapshot`).pipe( | ||
| 661 | HttpClientRequest.acceptJson, | ||
| 662 | HttpClientRequest.bearerToken(bearerToken), | ||
| 663 | ), | ||
| 664 | HttpClientResponse.matchStatus({ | ||
| 665 | "2xx": decodeOrchestrationReadModelResponse, | ||
| 666 | orElse: (response) => | ||
| 667 | readErrorMessageFromResponse(response).pipe( | ||
| 668 | Effect.flatMap((message) => Effect.fail(new Error(message))), | ||
| 669 | ), | ||
| 670 | }), | ||
| 671 | ); | ||
| 672 | |||
| 673 | const dispatchLiveOrchestrationCommand = ( | ||
| 674 | origin: string, | ||
| 675 | bearerToken: string, | ||
| 676 | command: ProjectCliDispatchCommand, | ||
| 677 | ) => | ||
| 678 | HttpClientRequest.post(`${origin}/api/orchestration/dispatch`).pipe( | ||
| 679 | HttpClientRequest.acceptJson, | ||
| 680 | HttpClientRequest.bearerToken(bearerToken), | ||
| 681 | HttpClientRequest.bodyJson(command), | ||
| 682 | Effect.flatMap((request) => | ||
| 683 | runLiveServerRequest( | ||
| 684 | request, | ||
| 685 | HttpClientResponse.matchStatus({ | ||
| 686 | "2xx": () => Effect.void, | ||
| 687 | orElse: (response) => | ||
| 688 | readErrorMessageFromResponse(response).pipe( | ||
| 689 | Effect.flatMap((message) => Effect.fail(new Error(message))), | ||
| 690 | ), | ||
| 691 | }), | ||
| 692 | ), | ||
| 693 | ), | ||
| 694 | ); | ||
| 695 | |||
| 696 | const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () { | ||
| 697 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; | ||
| 698 | return yield* projectionSnapshotQuery.getSnapshot(); | ||
| 699 | }); | ||
| 700 | |||
| 701 | const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecutionMode")( | ||
| 702 | function* (authControlPlane: AuthControlPlaneShape, config: ServerConfigShape) { | ||
| 703 | const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); | ||
| 704 | if (Option.isNone(runtimeState)) { | ||
| 705 | return Option.none<{ readonly origin: string }>(); | ||
| 706 | } | ||
| 707 | |||
| 708 | const attempt = withProjectCliSessionToken(authControlPlane, (token) => | ||
| 709 | fetchLiveOrchestrationSnapshot(runtimeState.value.origin, token).pipe( | ||
| 710 | Effect.as({ | ||
| 711 | origin: runtimeState.value.origin, | ||
| 712 | }), | ||
| 713 | ), | ||
| 714 | ); | ||
| 715 | |||
| 716 | const attempted = yield* Effect.exit(attempt); | ||
| 717 | if (Exit.isSuccess(attempted)) { | ||
| 718 | return Option.some(attempted.value); | ||
| 719 | } | ||
| 720 | |||
| 721 | yield* clearPersistedServerRuntimeState(config.serverRuntimeStatePath); | ||
| 722 | return Option.none<{ readonly origin: string }>(); | ||
| 723 | }, | ||
| 724 | ); | ||
| 725 | |||
| 726 | const runProjectMutation = Effect.fn("runProjectMutation")(function* ( | ||
| 727 | flags: CliAuthLocationFlags, | ||
| 728 | run: (input: { | ||
| 729 | readonly snapshot: OrchestrationReadModel; | ||
| 730 | readonly dispatch: ( | ||
| 731 | command: ProjectCliDispatchCommand, | ||
| 732 | ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>; | ||
| 733 | readonly mode: ProjectCommandExecutionMode; | ||
| 734 | }) => Effect.Effect< | ||
| 735 | string, | ||
| 736 | Error, | ||
| 737 | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path | WorkspacePaths | ||
| 738 | >, | ||
| 739 | ) { | ||
| 740 | const logLevel = yield* GlobalFlag.LogLevel; | ||
| 741 | const config = yield* resolveCliAuthConfig(flags, logLevel); | ||
| 742 | const minimumLogLevel = config.logLevel; | ||
| 743 | |||
| 744 | return yield* Effect.gen(function* () { | ||
| 745 | const authControlPlane = yield* AuthControlPlane; | ||
| 746 | const liveMode = yield* tryResolveLiveProjectExecutionMode(authControlPlane, config); | ||
| 747 | |||
| 748 | if (Option.isSome(liveMode)) { | ||
| 749 | return yield* withProjectCliSessionToken(authControlPlane, (token) => | ||
| 750 | Effect.gen(function* () { | ||
| 751 | const snapshot = yield* fetchLiveOrchestrationSnapshot(liveMode.value.origin, token); | ||
| 752 | const output = yield* run({ | ||
| 753 | snapshot, | ||
| 754 | dispatch: (command) => | ||
| 755 | dispatchLiveOrchestrationCommand(liveMode.value.origin, token, command), | ||
| 756 | mode: "live", | ||
| 757 | }); | ||
| 758 | yield* Console.log(output); | ||
| 759 | }), | ||
| 760 | ); | ||
| 761 | } | ||
| 762 | |||
| 763 | const offlineRuntimeLayer = ProjectCliRuntimeLive.pipe( | ||
| 764 | Layer.provide(Layer.succeed(ServerConfig, config)), | ||
| 765 | Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), | ||
| 766 | ); | ||
| 767 | |||
| 768 | return yield* Effect.gen(function* () { | ||
| 769 | const snapshot = yield* getOfflineSnapshot(); | ||
| 770 | const orchestrationEngine = yield* OrchestrationEngineService; | ||
| 771 | const output = yield* run({ | ||
| 772 | snapshot, | ||
| 773 | dispatch: (command) => orchestrationEngine.dispatch(command), | ||
| 774 | mode: "offline", | ||
| 775 | }); | ||
| 776 | yield* Console.log(output); | ||
| 777 | }).pipe(Effect.provide(offlineRuntimeLayer)); | ||
| 778 | }).pipe( | ||
| 779 | Effect.provide( | ||
| 780 | Layer.mergeAll(AuthControlPlaneRuntimeLive, WorkspacePathsLive).pipe( | ||
| 781 | Layer.provideMerge(FetchHttpClient.layer), | ||
| 782 | Layer.provide(Layer.succeed(ServerConfig, config)), | ||
| 783 | Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), | ||
| 784 | ), | ||
| 785 | ), | ||
| 786 | ); | ||
| 787 | }); | ||
| 788 | |||
| 789 | const sharedServerLocationFlags = { | ||
| 790 | baseDir: baseDirFlag, | ||
| 791 | devUrl: devUrlFlag, | ||
| 792 | } as const; | ||
| 793 | |||
| 794 | const projectLocationFlags = { | ||
| 795 | baseDir: baseDirFlag, | ||
| 796 | } as const; | ||
| 797 | |||
| 798 | const sharedServerCommandFlags = { | ||
| 799 | mode: modeFlag, | ||
| 800 | port: portFlag, | ||
| 801 | host: hostFlag, | ||
| 802 | baseDir: baseDirFlag, | ||
| 803 | cwd: Argument.string("cwd").pipe( | ||
| 804 | Argument.withDescription( | ||
| 805 | "Working directory for provider sessions (defaults to the current directory).", | ||
| 806 | ), | ||
| 807 | Argument.optional, | ||
| 808 | ), | ||
| 809 | devUrl: devUrlFlag, | ||
| 810 | noBrowser: noBrowserFlag, | ||
| 811 | bootstrapFd: bootstrapFdFlag, | ||
| 812 | autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, | ||
| 813 | logWebSocketEvents: logWebSocketEventsFlag, | ||
| 814 | tailscaleServeEnabled: tailscaleServeFlag, | ||
| 815 | tailscaleServePort: tailscaleServePortFlag, | ||
| 816 | } as const; | ||
| 817 | |||
| 818 | const authLocationFlags = sharedServerLocationFlags; | ||
| 819 | |||
| 820 | const ttlFlag = Flag.string("ttl").pipe( | ||
| 821 | Flag.withSchema(DurationFromString), | ||
| 822 | Flag.withDescription("TTL, for example `5m`, `1h`, `30d`, or `15 minutes`."), | ||
| 823 | Flag.optional, | ||
| 824 | ); | ||
| 825 | |||
| 826 | const jsonFlag = Flag.boolean("json").pipe( | ||
| 827 | Flag.withDescription("Emit JSON instead of human-readable output."), | ||
| 828 | Flag.withDefault(false), | ||
| 829 | ); | ||
| 830 | |||
| 831 | const sessionRoleFlag = Flag.choice("role", ["owner", "client"]).pipe( | ||
| 832 | Flag.withDescription("Role for the issued bearer session."), | ||
| 833 | Flag.withDefault("owner"), | ||
| 834 | ); | ||
| 835 | |||
| 836 | const labelFlag = Flag.string("label").pipe( | ||
| 837 | Flag.withDescription("Optional human-readable label."), | ||
| 838 | Flag.optional, | ||
| 839 | ); | ||
| 840 | |||
| 841 | const subjectFlag = Flag.string("subject").pipe( | ||
| 842 | Flag.withDescription("Optional session subject."), | ||
| 843 | Flag.optional, | ||
| 844 | ); | ||
| 845 | |||
| 846 | const baseUrlFlag = Flag.string("base-url").pipe( | ||
| 847 | Flag.withDescription("Optional public base URL used to print a ready `/pair#token=...` link."), | ||
| 848 | Flag.optional, | ||
| 849 | ); | ||
| 850 | |||
| 851 | const tokenOnlyFlag = Flag.boolean("token-only").pipe( | ||
| 852 | Flag.withDescription("Print only the issued bearer token."), | ||
| 853 | Flag.withDefault(false), | ||
| 854 | ); | ||
| 855 | |||
| 856 | const pairingCreateCommand = Command.make("create", { | ||
| 857 | ...authLocationFlags, | ||
| 858 | ttl: ttlFlag, | ||
| 859 | label: labelFlag, | ||
| 860 | baseUrl: baseUrlFlag, | ||
| 861 | json: jsonFlag, | ||
| 862 | }).pipe( | ||
| 863 | Command.withDescription("Issue a new client pairing token."), | ||
| 864 | Command.withHandler((flags) => | ||
| 865 | runWithAuthControlPlane( | ||
| 866 | flags, | ||
| 867 | (authControlPlane) => | ||
| 868 | Effect.gen(function* () { | ||
| 869 | const issued = yield* authControlPlane.createPairingLink({ | ||
| 870 | role: "client", | ||
| 871 | subject: "one-time-token", | ||
| 872 | ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), | ||
| 873 | ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), | ||
| 874 | }); | ||
| 875 | const output = formatIssuedPairingCredential(issued, { | ||
| 876 | json: flags.json, | ||
| 877 | ...(Option.isSome(flags.baseUrl) ? { baseUrl: flags.baseUrl.value } : {}), | ||
| 878 | }); | ||
| 879 | yield* Console.log(output); | ||
| 880 | }), | ||
| 881 | { | ||
| 882 | quietLogs: flags.json, | ||
| 883 | }, | ||
| 884 | ), | ||
| 885 | ), | ||
| 886 | ); | ||
| 887 | |||
| 888 | const pairingListCommand = Command.make("list", { | ||
| 889 | ...authLocationFlags, | ||
| 890 | json: jsonFlag, | ||
| 891 | }).pipe( | ||
| 892 | Command.withDescription("List active client pairing tokens without revealing their secrets."), | ||
| 893 | Command.withHandler((flags) => | ||
| 894 | runWithAuthControlPlane( | ||
| 895 | flags, | ||
| 896 | (authControlPlane) => | ||
| 897 | Effect.gen(function* () { | ||
| 898 | const pairingLinks = yield* authControlPlane.listPairingLinks({ role: "client" }); | ||
| 899 | yield* Console.log(formatPairingCredentialList(pairingLinks, { json: flags.json })); | ||
| 900 | }), | ||
| 901 | { | ||
| 902 | quietLogs: flags.json, | ||
| 903 | }, | ||
| 904 | ), | ||
| 905 | ), | ||
| 906 | ); | ||
| 907 | |||
| 908 | const pairingRevokeCommand = Command.make("revoke", { | ||
| 909 | ...authLocationFlags, | ||
| 910 | id: Argument.string("id").pipe(Argument.withDescription("Pairing credential id to revoke.")), | ||
| 911 | }).pipe( | ||
| 912 | Command.withDescription("Revoke an active client pairing token."), | ||
| 913 | Command.withHandler((flags) => | ||
| 914 | runWithAuthControlPlane(flags, (authControlPlane) => | ||
| 915 | Effect.gen(function* () { | ||
| 916 | const revoked = yield* authControlPlane.revokePairingLink(flags.id); | ||
| 917 | yield* Console.log( | ||
| 918 | revoked | ||
| 919 | ? `Revoked pairing credential ${flags.id}.\n` | ||
| 920 | : `No active pairing credential found for ${flags.id}.\n`, | ||
| 921 | ); | ||
| 922 | }), | ||
| 923 | ), | ||
| 924 | ), | ||
| 925 | ); | ||
| 926 | |||
| 927 | const pairingCommand = Command.make("pairing").pipe( | ||
| 928 | Command.withDescription("Manage one-time client pairing tokens."), | ||
| 929 | Command.withSubcommands([pairingCreateCommand, pairingListCommand, pairingRevokeCommand]), | ||
| 930 | ); | ||
| 931 | |||
| 932 | const sessionIssueCommand = Command.make("issue", { | ||
| 933 | ...authLocationFlags, | ||
| 934 | ttl: ttlFlag, | ||
| 935 | role: sessionRoleFlag, | ||
| 936 | label: labelFlag, | ||
| 937 | subject: subjectFlag, | ||
| 938 | tokenOnly: tokenOnlyFlag, | ||
| 939 | json: jsonFlag, | ||
| 940 | }).pipe( | ||
| 941 | Command.withDescription("Issue a bearer session token for headless or remote clients."), | ||
| 942 | Command.withHandler((flags) => | ||
| 943 | runWithAuthControlPlane( | ||
| 944 | flags, | ||
| 945 | (authControlPlane) => | ||
| 946 | Effect.gen(function* () { | ||
| 947 | const issued = yield* authControlPlane.issueSession({ | ||
| 948 | role: flags.role, | ||
| 949 | ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), | ||
| 950 | ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), | ||
| 951 | ...(Option.isSome(flags.subject) ? { subject: flags.subject.value } : {}), | ||
| 952 | }); | ||
| 953 | yield* Console.log( | ||
| 954 | formatIssuedSession(issued, { | ||
| 955 | json: flags.json, | ||
| 956 | tokenOnly: flags.tokenOnly, | ||
| 957 | }), | ||
| 958 | ); | ||
| 959 | }), | ||
| 960 | { | ||
| 961 | quietLogs: flags.json || flags.tokenOnly, | ||
| 962 | }, | ||
| 963 | ), | ||
| 964 | ), | ||
| 965 | ); | ||
| 966 | |||
| 967 | const sessionListCommand = Command.make("list", { | ||
| 968 | ...authLocationFlags, | ||
| 969 | json: jsonFlag, | ||
| 970 | }).pipe( | ||
| 971 | Command.withDescription("List active sessions without revealing bearer tokens."), | ||
| 972 | Command.withHandler((flags) => | ||
| 973 | runWithAuthControlPlane( | ||
| 974 | flags, | ||
| 975 | (authControlPlane) => | ||
| 976 | Effect.gen(function* () { | ||
| 977 | const sessions = yield* authControlPlane.listSessions(); | ||
| 978 | yield* Console.log(formatSessionList(sessions, { json: flags.json })); | ||
| 979 | }), | ||
| 980 | { | ||
| 981 | quietLogs: flags.json, | ||
| 982 | }, | ||
| 983 | ), | ||
| 984 | ), | ||
| 985 | ); | ||
| 986 | |||
| 987 | const sessionRevokeCommand = Command.make("revoke", { | ||
| 988 | ...authLocationFlags, | ||
| 989 | sessionId: Argument.string("session-id").pipe( | ||
| 990 | Argument.withDescription("Session id to revoke."), | ||
| 991 | Argument.withSchema(AuthSessionId), | ||
| 992 | ), | ||
| 993 | }).pipe( | ||
| 994 | Command.withDescription("Revoke an active session."), | ||
| 995 | Command.withHandler((flags) => | ||
| 996 | runWithAuthControlPlane(flags, (authControlPlane) => | ||
| 997 | Effect.gen(function* () { | ||
| 998 | const revoked = yield* authControlPlane.revokeSession(flags.sessionId); | ||
| 999 | yield* Console.log( | ||
| 1000 | revoked | ||
| 1001 | ? `Revoked session ${flags.sessionId}.\n` | ||
| 1002 | : `No active session found for ${flags.sessionId}.\n`, | ||
| 1003 | ); | ||
| 1004 | }), | ||
| 1005 | ), | ||
| 1006 | ), | ||
| 1007 | ); | ||
| 1008 | |||
| 1009 | const sessionCommand = Command.make("session").pipe( | ||
| 1010 | Command.withDescription("Manage bearer sessions."), | ||
| 1011 | Command.withSubcommands([sessionIssueCommand, sessionListCommand, sessionRevokeCommand]), | ||
| 1012 | ); | ||
| 1013 | |||
| 1014 | const authCommand = Command.make("auth").pipe( | ||
| 1015 | Command.withDescription("Manage the local auth control plane for headless deployments."), | ||
| 1016 | Command.withSubcommands([pairingCommand, sessionCommand]), | ||
| 1017 | ); | ||
| 1018 | |||
| 1019 | const projectAddCommand = Command.make("add", { | ||
| 1020 | ...projectLocationFlags, | ||
| 1021 | workspaceRoot: Argument.string("path").pipe( | ||
| 1022 | Argument.withDescription("Workspace root to add as a project."), | ||
| 1023 | ), | ||
| 1024 | title: Flag.string("title").pipe(Flag.withDescription("Optional project title."), Flag.optional), | ||
| 1025 | }).pipe( | ||
| 1026 | Command.withDescription("Add a project."), | ||
| 1027 | Command.withHandler((flags) => | ||
| 1028 | runProjectMutation( | ||
| 1029 | flags, | ||
| 1030 | Effect.fn("projectAddMutation")(function* ({ | ||
| 1031 | snapshot, | ||
| 1032 | dispatch, | ||
| 1033 | }: { | ||
| 1034 | readonly snapshot: OrchestrationReadModel; | ||
| 1035 | readonly dispatch: ( | ||
| 1036 | command: ProjectCliDispatchCommand, | ||
| 1037 | ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>; | ||
| 1038 | }) { | ||
| 1039 | const workspaceRoot = yield* normalizeWorkspaceRootForProjectCommand(flags.workspaceRoot); | ||
| 1040 | const existingProject = snapshot.projects.find( | ||
| 1041 | (project) => project.deletedAt === null && project.workspaceRoot === workspaceRoot, | ||
| 1042 | ); | ||
| 1043 | if (existingProject) { | ||
| 1044 | return yield* Effect.fail( | ||
| 1045 | new Error(`An active project already exists for '${workspaceRoot}'.`), | ||
| 1046 | ); | ||
| 1047 | } | ||
| 1048 | |||
| 1049 | const title = yield* resolveProjectTitle(workspaceRoot, Option.getOrUndefined(flags.title)); | ||
| 1050 | const projectId = ProjectId.make(crypto.randomUUID()); | ||
| 1051 | yield* dispatch({ | ||
| 1052 | type: "project.create", | ||
| 1053 | commandId: CommandId.make(crypto.randomUUID()), | ||
| 1054 | projectId, | ||
| 1055 | title, | ||
| 1056 | workspaceRoot, | ||
| 1057 | defaultModelSelection: getAutoBootstrapDefaultModelSelection(), | ||
| 1058 | createdAt: new Date().toISOString(), | ||
| 1059 | }); | ||
| 1060 | return `Added project ${projectId} (${title}) at ${workspaceRoot}.`; | ||
| 1061 | }), | ||
| 1062 | ), | ||
| 1063 | ), | ||
| 1064 | ); | ||
| 1065 | |||
| 1066 | const projectRemoveCommand = Command.make("remove", { | ||
| 1067 | ...projectLocationFlags, | ||
| 1068 | project: Argument.string("project").pipe( | ||
| 1069 | Argument.withDescription("Project id or workspace root to remove."), | ||
| 1070 | ), | ||
| 1071 | }).pipe( | ||
| 1072 | Command.withDescription("Remove a project."), | ||
| 1073 | Command.withHandler((flags) => | ||
| 1074 | runProjectMutation( | ||
| 1075 | flags, | ||
| 1076 | Effect.fn("projectRemoveMutation")(function* ({ | ||
| 1077 | snapshot, | ||
| 1078 | dispatch, | ||
| 1079 | }: { | ||
| 1080 | readonly snapshot: OrchestrationReadModel; | ||
| 1081 | readonly dispatch: ( | ||
| 1082 | command: ProjectCliDispatchCommand, | ||
| 1083 | ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>; | ||
| 1084 | }) { | ||
| 1085 | const project = yield* findActiveProjectTarget({ | ||
| 1086 | snapshot, | ||
| 1087 | identifier: flags.project, | ||
| 1088 | }); | ||
| 1089 | yield* dispatch({ | ||
| 1090 | type: "project.delete", | ||
| 1091 | commandId: CommandId.make(crypto.randomUUID()), | ||
| 1092 | projectId: project.id, | ||
| 1093 | }); | ||
| 1094 | return `Removed project ${project.id} (${project.title}).`; | ||
| 1095 | }), | ||
| 1096 | ), | ||
| 1097 | ), | ||
| 1098 | ); | ||
| 1099 | |||
| 1100 | const projectRenameCommand = Command.make("rename", { | ||
| 1101 | ...projectLocationFlags, | ||
| 1102 | project: Argument.string("project").pipe( | ||
| 1103 | Argument.withDescription("Project id or workspace root to rename."), | ||
| 1104 | ), | ||
| 1105 | title: Argument.string("title").pipe(Argument.withDescription("New project title.")), | ||
| 1106 | }).pipe( | ||
| 1107 | Command.withDescription("Rename a project."), | ||
| 1108 | Command.withHandler((flags) => | ||
| 1109 | runProjectMutation( | ||
| 1110 | flags, | ||
| 1111 | Effect.fn("projectRenameMutation")(function* ({ | ||
| 1112 | snapshot, | ||
| 1113 | dispatch, | ||
| 1114 | }: { | ||
| 1115 | readonly snapshot: OrchestrationReadModel; | ||
| 1116 | readonly dispatch: ( | ||
| 1117 | command: ProjectCliDispatchCommand, | ||
| 1118 | ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>; | ||
| 1119 | }) { | ||
| 1120 | const project = yield* findActiveProjectTarget({ | ||
| 1121 | snapshot, | ||
| 1122 | identifier: flags.project, | ||
| 1123 | }); | ||
| 1124 | const nextTitle = yield* resolveProjectTitle(project.workspaceRoot, flags.title); | ||
| 1125 | if (nextTitle === project.title) { | ||
| 1126 | return `Project ${project.id} is already named ${nextTitle}.`; | ||
| 1127 | } | ||
| 1128 | |||
| 1129 | yield* dispatch({ | ||
| 1130 | type: "project.meta.update", | ||
| 1131 | commandId: CommandId.make(crypto.randomUUID()), | ||
| 1132 | projectId: project.id, | ||
| 1133 | title: nextTitle, | ||
| 1134 | }); | ||
| 1135 | return `Renamed project ${project.id} to ${nextTitle}.`; | ||
| 1136 | }), | ||
| 1137 | ), | ||
| 1138 | ), | ||
| 1139 | ); | ||
| 1140 | |||
| 1141 | const projectCommand = Command.make("project").pipe( | ||
| 1142 | Command.withDescription("Manage projects."), | ||
| 1143 | Command.withSubcommands([projectAddCommand, projectRemoveCommand, projectRenameCommand]), | ||
| 1144 | ); | ||
| 1145 | |||
| 1146 | const runServerCommand = ( | ||
| 1147 | flags: CliServerFlags, | ||
| 1148 | options?: { | ||
| 1149 | readonly startupPresentation?: StartupPresentation; | ||
| 1150 | readonly forceAutoBootstrapProjectFromCwd?: boolean; | ||
| 1151 | }, | ||
| 1152 | ) => | ||
| 1153 | Effect.gen(function* () { | ||
| 1154 | const logLevel = yield* GlobalFlag.LogLevel; | ||
| 1155 | const config = yield* resolveServerConfig(flags, logLevel, options); | ||
| 1156 | return yield* runServer.pipe(Effect.provideService(ServerConfig, config)); | ||
| 1157 | }); | ||
| 1158 | |||
| 1159 | const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe( | ||
| 1160 | Command.withDescription("Run the T3 Code server."), | ||
| 1161 | Command.withHandler((flags) => runServerCommand(flags)), | ||
| 1162 | ); | ||
| 1163 | |||
| 1164 | const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe( | ||
| 1165 | Command.withDescription( | ||
| 1166 | "Run the T3 Code server without opening a browser and print headless pairing details.", | ||
| 1167 | ), | ||
| 1168 | Command.withHandler((flags) => | ||
| 1169 | runServerCommand(flags, { | ||
| 1170 | startupPresentation: "headless", | ||
| 1171 | forceAutoBootstrapProjectFromCwd: false, | ||
| 1172 | }), | ||
| 1173 | ), | ||
| 1174 | ); | ||
| 1175 | |||
| 1176 | export const cli = Command.make("t3", { ...sharedServerCommandFlags }).pipe( | ||
| 1177 | Command.withDescription("Run the T3 Code server."), | ||
| 1178 | Command.withHandler((flags) => runServerCommand(flags)), | ||
| 1179 | Command.withSubcommands([startCommand, serveCommand, authCommand, projectCommand]), | ||
| 1180 | ); | ||
apps/server/src/cli/auth.ts created+243| ... | @@ -0,0 +1,243 @@ | ||
| 1 | import { AuthSessionId } from "@t3tools/contracts"; | ||
| 2 | import { Console, Effect, Layer, Option, References } from "effect"; | ||
| 3 | import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; | ||
| 4 | |||
| 5 | import { AuthControlPlaneRuntimeLive } from "../auth/Layers/AuthControlPlane.ts"; | ||
| 6 | import { AuthControlPlane } from "../auth/Services/AuthControlPlane.ts"; | ||
| 7 | import type { AuthControlPlaneShape } from "../auth/Services/AuthControlPlane.ts"; | ||
| 8 | import { | ||
| 9 | formatIssuedPairingCredential, | ||
| 10 | formatIssuedSession, | ||
| 11 | formatPairingCredentialList, | ||
| 12 | formatSessionList, | ||
| 13 | } from "../cliAuthFormat.ts"; | ||
| 14 | import { ServerConfig } from "../config.ts"; | ||
| 15 | import { | ||
| 16 | authLocationFlags, | ||
| 17 | type CliAuthLocationFlags, | ||
| 18 | DurationFromString, | ||
| 19 | resolveCliAuthConfig, | ||
| 20 | } from "./config.ts"; | ||
| 21 | |||
| 22 | const runWithAuthControlPlane = <A, E>( | ||
| 23 | flags: CliAuthLocationFlags, | ||
| 24 | run: (authControlPlane: AuthControlPlaneShape) => Effect.Effect<A, E>, | ||
| 25 | options?: { | ||
| 26 | readonly quietLogs?: boolean; | ||
| 27 | }, | ||
| 28 | ) => | ||
| 29 | Effect.gen(function* () { | ||
| 30 | const logLevel = yield* GlobalFlag.LogLevel; | ||
| 31 | const config = yield* resolveCliAuthConfig(flags, logLevel); | ||
| 32 | const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel; | ||
| 33 | return yield* Effect.gen(function* () { | ||
| 34 | const authControlPlane = yield* AuthControlPlane; | ||
| 35 | return yield* run(authControlPlane); | ||
| 36 | }).pipe( | ||
| 37 | Effect.provide( | ||
| 38 | Layer.mergeAll(AuthControlPlaneRuntimeLive).pipe( | ||
| 39 | Layer.provide(Layer.succeed(ServerConfig, config)), | ||
| 40 | Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), | ||
| 41 | ), | ||
| 42 | ), | ||
| 43 | ); | ||
| 44 | }); | ||
| 45 | |||
| 46 | const ttlFlag = Flag.string("ttl").pipe( | ||
| 47 | Flag.withSchema(DurationFromString), | ||
| 48 | Flag.withDescription("TTL, for example `5m`, `1h`, `30d`, or `15 minutes`."), | ||
| 49 | Flag.optional, | ||
| 50 | ); | ||
| 51 | |||
| 52 | const jsonFlag = Flag.boolean("json").pipe( | ||
| 53 | Flag.withDescription("Emit JSON instead of human-readable output."), | ||
| 54 | Flag.withDefault(false), | ||
| 55 | ); | ||
| 56 | |||
| 57 | const sessionRoleFlag = Flag.choice("role", ["owner", "client"]).pipe( | ||
| 58 | Flag.withDescription("Role for the issued bearer session."), | ||
| 59 | Flag.withDefault("owner"), | ||
| 60 | ); | ||
| 61 | |||
| 62 | const labelFlag = Flag.string("label").pipe( | ||
| 63 | Flag.withDescription("Optional human-readable label."), | ||
| 64 | Flag.optional, | ||
| 65 | ); | ||
| 66 | |||
| 67 | const subjectFlag = Flag.string("subject").pipe( | ||
| 68 | Flag.withDescription("Optional session subject."), | ||
| 69 | Flag.optional, | ||
| 70 | ); | ||
| 71 | |||
| 72 | const baseUrlFlag = Flag.string("base-url").pipe( | ||
| 73 | Flag.withDescription("Optional public base URL used to print a ready `/pair#token=...` link."), | ||
| 74 | Flag.optional, | ||
| 75 | ); | ||
| 76 | |||
| 77 | const tokenOnlyFlag = Flag.boolean("token-only").pipe( | ||
| 78 | Flag.withDescription("Print only the issued bearer token."), | ||
| 79 | Flag.withDefault(false), | ||
| 80 | ); | ||
| 81 | |||
| 82 | const pairingCreateCommand = Command.make("create", { | ||
| 83 | ...authLocationFlags, | ||
| 84 | ttl: ttlFlag, | ||
| 85 | label: labelFlag, | ||
| 86 | baseUrl: baseUrlFlag, | ||
| 87 | json: jsonFlag, | ||
| 88 | }).pipe( | ||
| 89 | Command.withDescription("Issue a new client pairing token."), | ||
| 90 | Command.withHandler((flags) => | ||
| 91 | runWithAuthControlPlane( | ||
| 92 | flags, | ||
| 93 | (authControlPlane) => | ||
| 94 | Effect.gen(function* () { | ||
| 95 | const issued = yield* authControlPlane.createPairingLink({ | ||
| 96 | role: "client", | ||
| 97 | subject: "one-time-token", | ||
| 98 | ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), | ||
| 99 | ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), | ||
| 100 | }); | ||
| 101 | const output = formatIssuedPairingCredential(issued, { | ||
| 102 | json: flags.json, | ||
| 103 | ...(Option.isSome(flags.baseUrl) ? { baseUrl: flags.baseUrl.value } : {}), | ||
| 104 | }); | ||
| 105 | yield* Console.log(output); | ||
| 106 | }), | ||
| 107 | { | ||
| 108 | quietLogs: flags.json, | ||
| 109 | }, | ||
| 110 | ), | ||
| 111 | ), | ||
| 112 | ); | ||
| 113 | |||
| 114 | const pairingListCommand = Command.make("list", { | ||
| 115 | ...authLocationFlags, | ||
| 116 | json: jsonFlag, | ||
| 117 | }).pipe( | ||
| 118 | Command.withDescription("List active client pairing tokens without revealing their secrets."), | ||
| 119 | Command.withHandler((flags) => | ||
| 120 | runWithAuthControlPlane( | ||
| 121 | flags, | ||
| 122 | (authControlPlane) => | ||
| 123 | Effect.gen(function* () { | ||
| 124 | const pairingLinks = yield* authControlPlane.listPairingLinks({ role: "client" }); | ||
| 125 | yield* Console.log(formatPairingCredentialList(pairingLinks, { json: flags.json })); | ||
| 126 | }), | ||
| 127 | { | ||
| 128 | quietLogs: flags.json, | ||
| 129 | }, | ||
| 130 | ), | ||
| 131 | ), | ||
| 132 | ); | ||
| 133 | |||
| 134 | const pairingRevokeCommand = Command.make("revoke", { | ||
| 135 | ...authLocationFlags, | ||
| 136 | id: Argument.string("id").pipe(Argument.withDescription("Pairing credential id to revoke.")), | ||
| 137 | }).pipe( | ||
| 138 | Command.withDescription("Revoke an active client pairing token."), | ||
| 139 | Command.withHandler((flags) => | ||
| 140 | runWithAuthControlPlane(flags, (authControlPlane) => | ||
| 141 | Effect.gen(function* () { | ||
| 142 | const revoked = yield* authControlPlane.revokePairingLink(flags.id); | ||
| 143 | yield* Console.log( | ||
| 144 | revoked | ||
| 145 | ? `Revoked pairing credential ${flags.id}.\n` | ||
| 146 | : `No active pairing credential found for ${flags.id}.\n`, | ||
| 147 | ); | ||
| 148 | }), | ||
| 149 | ), | ||
| 150 | ), | ||
| 151 | ); | ||
| 152 | |||
| 153 | const pairingCommand = Command.make("pairing").pipe( | ||
| 154 | Command.withDescription("Manage one-time client pairing tokens."), | ||
| 155 | Command.withSubcommands([pairingCreateCommand, pairingListCommand, pairingRevokeCommand]), | ||
| 156 | ); | ||
| 157 | |||
| 158 | const sessionIssueCommand = Command.make("issue", { | ||
| 159 | ...authLocationFlags, | ||
| 160 | ttl: ttlFlag, | ||
| 161 | role: sessionRoleFlag, | ||
| 162 | label: labelFlag, | ||
| 163 | subject: subjectFlag, | ||
| 164 | tokenOnly: tokenOnlyFlag, | ||
| 165 | json: jsonFlag, | ||
| 166 | }).pipe( | ||
| 167 | Command.withDescription("Issue a bearer session token for headless or remote clients."), | ||
| 168 | Command.withHandler((flags) => | ||
| 169 | runWithAuthControlPlane( | ||
| 170 | flags, | ||
| 171 | (authControlPlane) => | ||
| 172 | Effect.gen(function* () { | ||
| 173 | const issued = yield* authControlPlane.issueSession({ | ||
| 174 | role: flags.role, | ||
| 175 | ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), | ||
| 176 | ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), | ||
| 177 | ...(Option.isSome(flags.subject) ? { subject: flags.subject.value } : {}), | ||
| 178 | }); | ||
| 179 | yield* Console.log( | ||
| 180 | formatIssuedSession(issued, { | ||
| 181 | json: flags.json, | ||
| 182 | tokenOnly: flags.tokenOnly, | ||
| 183 | }), | ||
| 184 | ); | ||
| 185 | }), | ||
| 186 | { | ||
| 187 | quietLogs: flags.json || flags.tokenOnly, | ||
| 188 | }, | ||
| 189 | ), | ||
| 190 | ), | ||
| 191 | ); | ||
| 192 | |||
| 193 | const sessionListCommand = Command.make("list", { | ||
| 194 | ...authLocationFlags, | ||
| 195 | json: jsonFlag, | ||
| 196 | }).pipe( | ||
| 197 | Command.withDescription("List active sessions without revealing bearer tokens."), | ||
| 198 | Command.withHandler((flags) => | ||
| 199 | runWithAuthControlPlane( | ||
| 200 | flags, | ||
| 201 | (authControlPlane) => | ||
| 202 | Effect.gen(function* () { | ||
| 203 | const sessions = yield* authControlPlane.listSessions(); | ||
| 204 | yield* Console.log(formatSessionList(sessions, { json: flags.json })); | ||
| 205 | }), | ||
| 206 | { | ||
| 207 | quietLogs: flags.json, | ||
| 208 | }, | ||
| 209 | ), | ||
| 210 | ), | ||
| 211 | ); | ||
| 212 | |||
| 213 | const sessionRevokeCommand = Command.make("revoke", { | ||
| 214 | ...authLocationFlags, | ||
| 215 | sessionId: Argument.string("session-id").pipe( | ||
| 216 | Argument.withDescription("Session id to revoke."), | ||
| 217 | Argument.withSchema(AuthSessionId), | ||
| 218 | ), | ||
| 219 | }).pipe( | ||
| 220 | Command.withDescription("Revoke an active session."), | ||
| 221 | Command.withHandler((flags) => | ||
| 222 | runWithAuthControlPlane(flags, (authControlPlane) => | ||
| 223 | Effect.gen(function* () { | ||
| 224 | const revoked = yield* authControlPlane.revokeSession(flags.sessionId); | ||
| 225 | yield* Console.log( | ||
| 226 | revoked | ||
| 227 | ? `Revoked session ${flags.sessionId}.\n` | ||
| 228 | : `No active session found for ${flags.sessionId}.\n`, | ||
| 229 | ); | ||
| 230 | }), | ||
| 231 | ), | ||
| 232 | ), | ||
| 233 | ); | ||
| 234 | |||
| 235 | const sessionCommand = Command.make("session").pipe( | ||
| 236 | Command.withDescription("Manage bearer sessions."), | ||
| 237 | Command.withSubcommands([sessionIssueCommand, sessionListCommand, sessionRevokeCommand]), | ||
| 238 | ); | ||
| 239 | |||
| 240 | export const authCommand = Command.make("auth").pipe( | ||
| 241 | Command.withDescription("Manage the local auth control plane for headless deployments."), | ||
| 242 | Command.withSubcommands([pairingCommand, sessionCommand]), | ||
| 243 | ); | ||
apps/server/src/cli/config.test.ts created+564| ... | @@ -0,0 +1,564 @@ | ||
| 1 | import os from "node:os"; | ||
| 2 | |||
| 3 | import { assert, expect, it } from "@effect/vitest"; | ||
| 4 | import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect"; | ||
| 5 | |||
| 6 | import { NetService } from "@t3tools/shared/Net"; | ||
| 7 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 8 | import { deriveServerPaths } from "../config.ts"; | ||
| 9 | import { resolveServerConfig } from "./config.ts"; | ||
| 10 | |||
| 11 | it.layer(NodeServices.layer)("cli config resolution", (it) => { | ||
| 12 | const defaultObservabilityConfig = { | ||
| 13 | traceMinLevel: "Info", | ||
| 14 | traceTimingEnabled: true, | ||
| 15 | traceBatchWindowMs: 200, | ||
| 16 | traceMaxBytes: 10 * 1024 * 1024, | ||
| 17 | traceMaxFiles: 10, | ||
| 18 | otlpTracesUrl: undefined, | ||
| 19 | otlpMetricsUrl: undefined, | ||
| 20 | otlpExportIntervalMs: 10_000, | ||
| 21 | otlpServiceName: "t3-server", | ||
| 22 | } as const; | ||
| 23 | |||
| 24 | const openBootstrapFd = Effect.fn(function* (payload: Record<string, unknown>) { | ||
| 25 | const fs = yield* FileSystem.FileSystem; | ||
| 26 | const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); | ||
| 27 | yield* fs.writeFileString(filePath, `${JSON.stringify(payload)}\n`); | ||
| 28 | const { fd } = yield* fs.open(filePath, { flag: "r" }); | ||
| 29 | return fd; | ||
| 30 | }); | ||
| 31 | |||
| 32 | it.effect("falls back to effect/config values when flags are omitted", () => | ||
| 33 | Effect.gen(function* () { | ||
| 34 | const { join } = yield* Path.Path; | ||
| 35 | const baseDir = join(os.tmpdir(), "t3-cli-config-env-base"); | ||
| 36 | const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173")); | ||
| 37 | const resolved = yield* resolveServerConfig( | ||
| 38 | { | ||
| 39 | mode: Option.none(), | ||
| 40 | port: Option.none(), | ||
| 41 | host: Option.none(), | ||
| 42 | baseDir: Option.none(), | ||
| 43 | cwd: Option.none(), | ||
| 44 | devUrl: Option.none(), | ||
| 45 | noBrowser: Option.none(), | ||
| 46 | bootstrapFd: Option.none(), | ||
| 47 | autoBootstrapProjectFromCwd: Option.none(), | ||
| 48 | logWebSocketEvents: Option.none(), | ||
| 49 | tailscaleServeEnabled: Option.none(), | ||
| 50 | tailscaleServePort: Option.none(), | ||
| 51 | }, | ||
| 52 | Option.none(), | ||
| 53 | ).pipe( | ||
| 54 | Effect.provide( | ||
| 55 | Layer.mergeAll( | ||
| 56 | ConfigProvider.layer( | ||
| 57 | ConfigProvider.fromEnv({ | ||
| 58 | env: { | ||
| 59 | T3CODE_LOG_LEVEL: "Warn", | ||
| 60 | T3CODE_MODE: "desktop", | ||
| 61 | T3CODE_PORT: "4001", | ||
| 62 | T3CODE_HOST: "0.0.0.0", | ||
| 63 | T3CODE_HOME: baseDir, | ||
| 64 | VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", | ||
| 65 | T3CODE_NO_BROWSER: "true", | ||
| 66 | T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", | ||
| 67 | T3CODE_LOG_WS_EVENTS: "true", | ||
| 68 | }, | ||
| 69 | }), | ||
| 70 | ), | ||
| 71 | NetService.layer, | ||
| 72 | ), | ||
| 73 | ), | ||
| 74 | ); | ||
| 75 | |||
| 76 | expect(resolved).toEqual({ | ||
| 77 | logLevel: "Warn", | ||
| 78 | ...defaultObservabilityConfig, | ||
| 79 | mode: "desktop", | ||
| 80 | port: 4001, | ||
| 81 | cwd: process.cwd(), | ||
| 82 | baseDir, | ||
| 83 | ...derivedPaths, | ||
| 84 | host: "0.0.0.0", | ||
| 85 | staticDir: undefined, | ||
| 86 | devUrl: new URL("http://127.0.0.1:5173"), | ||
| 87 | noBrowser: true, | ||
| 88 | startupPresentation: "browser", | ||
| 89 | desktopBootstrapToken: undefined, | ||
| 90 | autoBootstrapProjectFromCwd: false, | ||
| 91 | logWebSocketEvents: true, | ||
| 92 | tailscaleServeEnabled: false, | ||
| 93 | tailscaleServePort: 443, | ||
| 94 | }); | ||
| 95 | }), | ||
| 96 | ); | ||
| 97 | |||
| 98 | it.effect("uses CLI flags when provided", () => | ||
| 99 | Effect.gen(function* () { | ||
| 100 | const { join } = yield* Path.Path; | ||
| 101 | const baseDir = join(os.tmpdir(), "t3-cli-config-flags-base"); | ||
| 102 | const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); | ||
| 103 | const resolved = yield* resolveServerConfig( | ||
| 104 | { | ||
| 105 | mode: Option.some("web"), | ||
| 106 | port: Option.some(8788), | ||
| 107 | host: Option.some("127.0.0.1"), | ||
| 108 | baseDir: Option.some(baseDir), | ||
| 109 | cwd: Option.none(), | ||
| 110 | devUrl: Option.some(new URL("http://127.0.0.1:4173")), | ||
| 111 | noBrowser: Option.some(true), | ||
| 112 | bootstrapFd: Option.none(), | ||
| 113 | autoBootstrapProjectFromCwd: Option.some(true), | ||
| 114 | logWebSocketEvents: Option.some(true), | ||
| 115 | tailscaleServeEnabled: Option.some(true), | ||
| 116 | tailscaleServePort: Option.some(8443), | ||
| 117 | }, | ||
| 118 | Option.some("Debug"), | ||
| 119 | ).pipe( | ||
| 120 | Effect.provide( | ||
| 121 | Layer.mergeAll( | ||
| 122 | ConfigProvider.layer( | ||
| 123 | ConfigProvider.fromEnv({ | ||
| 124 | env: { | ||
| 125 | T3CODE_LOG_LEVEL: "Warn", | ||
| 126 | T3CODE_MODE: "desktop", | ||
| 127 | T3CODE_PORT: "4001", | ||
| 128 | T3CODE_HOST: "0.0.0.0", | ||
| 129 | T3CODE_HOME: join(os.tmpdir(), "ignored-base"), | ||
| 130 | VITE_DEV_SERVER_URL: "http://127.0.0.1:5173", | ||
| 131 | T3CODE_NO_BROWSER: "false", | ||
| 132 | T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", | ||
| 133 | T3CODE_LOG_WS_EVENTS: "false", | ||
| 134 | }, | ||
| 135 | }), | ||
| 136 | ), | ||
| 137 | NetService.layer, | ||
| 138 | ), | ||
| 139 | ), | ||
| 140 | ); | ||
| 141 | |||
| 142 | expect(resolved).toEqual({ | ||
| 143 | logLevel: "Debug", | ||
| 144 | ...defaultObservabilityConfig, | ||
| 145 | mode: "web", | ||
| 146 | port: 8788, | ||
| 147 | cwd: process.cwd(), | ||
| 148 | baseDir, | ||
| 149 | ...derivedPaths, | ||
| 150 | host: "127.0.0.1", | ||
| 151 | staticDir: undefined, | ||
| 152 | devUrl: new URL("http://127.0.0.1:4173"), | ||
| 153 | noBrowser: true, | ||
| 154 | startupPresentation: "browser", | ||
| 155 | desktopBootstrapToken: undefined, | ||
| 156 | autoBootstrapProjectFromCwd: true, | ||
| 157 | logWebSocketEvents: true, | ||
| 158 | tailscaleServeEnabled: true, | ||
| 159 | tailscaleServePort: 8443, | ||
| 160 | }); | ||
| 161 | }), | ||
| 162 | ); | ||
| 163 | |||
| 164 | it.effect("preserves explicit false CLI boolean flags over env and bootstrap values", () => | ||
| 165 | Effect.gen(function* () { | ||
| 166 | const { join } = yield* Path.Path; | ||
| 167 | const baseDir = join(os.tmpdir(), "t3-cli-config-false-flags"); | ||
| 168 | const fd = yield* openBootstrapFd({ | ||
| 169 | noBrowser: true, | ||
| 170 | autoBootstrapProjectFromCwd: true, | ||
| 171 | logWebSocketEvents: true, | ||
| 172 | tailscaleServeEnabled: false, | ||
| 173 | tailscaleServePort: 443, | ||
| 174 | }); | ||
| 175 | const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); | ||
| 176 | |||
| 177 | const resolved = yield* resolveServerConfig( | ||
| 178 | { | ||
| 179 | mode: Option.some("web"), | ||
| 180 | port: Option.some(8788), | ||
| 181 | host: Option.some("127.0.0.1"), | ||
| 182 | baseDir: Option.some(baseDir), | ||
| 183 | cwd: Option.none(), | ||
| 184 | devUrl: Option.some(new URL("http://127.0.0.1:4173")), | ||
| 185 | noBrowser: Option.some(false), | ||
| 186 | bootstrapFd: Option.none(), | ||
| 187 | autoBootstrapProjectFromCwd: Option.some(false), | ||
| 188 | logWebSocketEvents: Option.some(false), | ||
| 189 | tailscaleServeEnabled: Option.none(), | ||
| 190 | tailscaleServePort: Option.none(), | ||
| 191 | }, | ||
| 192 | Option.none(), | ||
| 193 | ).pipe( | ||
| 194 | Effect.provide( | ||
| 195 | Layer.mergeAll( | ||
| 196 | ConfigProvider.layer( | ||
| 197 | ConfigProvider.fromEnv({ | ||
| 198 | env: { | ||
| 199 | T3CODE_BOOTSTRAP_FD: String(fd), | ||
| 200 | T3CODE_NO_BROWSER: "true", | ||
| 201 | T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true", | ||
| 202 | T3CODE_LOG_WS_EVENTS: "true", | ||
| 203 | }, | ||
| 204 | }), | ||
| 205 | ), | ||
| 206 | NetService.layer, | ||
| 207 | ), | ||
| 208 | ), | ||
| 209 | ); | ||
| 210 | |||
| 211 | expect(resolved).toEqual({ | ||
| 212 | logLevel: "Info", | ||
| 213 | ...defaultObservabilityConfig, | ||
| 214 | mode: "web", | ||
| 215 | port: 8788, | ||
| 216 | cwd: process.cwd(), | ||
| 217 | baseDir, | ||
| 218 | ...derivedPaths, | ||
| 219 | host: "127.0.0.1", | ||
| 220 | staticDir: undefined, | ||
| 221 | devUrl: new URL("http://127.0.0.1:4173"), | ||
| 222 | noBrowser: false, | ||
| 223 | startupPresentation: "browser", | ||
| 224 | desktopBootstrapToken: undefined, | ||
| 225 | autoBootstrapProjectFromCwd: false, | ||
| 226 | logWebSocketEvents: false, | ||
| 227 | tailscaleServeEnabled: false, | ||
| 228 | tailscaleServePort: 443, | ||
| 229 | }); | ||
| 230 | }), | ||
| 231 | ); | ||
| 232 | |||
| 233 | it.effect("uses bootstrap envelope values as fallbacks when flags and env are absent", () => | ||
| 234 | Effect.gen(function* () { | ||
| 235 | const { join } = yield* Path.Path; | ||
| 236 | const baseDir = "/tmp/t3-bootstrap-home"; | ||
| 237 | const fd = yield* openBootstrapFd({ | ||
| 238 | mode: "desktop", | ||
| 239 | port: 4888, | ||
| 240 | host: "127.0.0.2", | ||
| 241 | t3Home: baseDir, | ||
| 242 | devUrl: "http://127.0.0.1:5173", | ||
| 243 | noBrowser: true, | ||
| 244 | autoBootstrapProjectFromCwd: false, | ||
| 245 | logWebSocketEvents: true, | ||
| 246 | tailscaleServeEnabled: false, | ||
| 247 | tailscaleServePort: 443, | ||
| 248 | otlpTracesUrl: "http://localhost:4318/v1/traces", | ||
| 249 | otlpMetricsUrl: "http://localhost:4318/v1/metrics", | ||
| 250 | }); | ||
| 251 | const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:5173")); | ||
| 252 | |||
| 253 | const resolved = yield* resolveServerConfig( | ||
| 254 | { | ||
| 255 | mode: Option.none(), | ||
| 256 | port: Option.none(), | ||
| 257 | host: Option.none(), | ||
| 258 | baseDir: Option.none(), | ||
| 259 | cwd: Option.none(), | ||
| 260 | devUrl: Option.none(), | ||
| 261 | noBrowser: Option.none(), | ||
| 262 | bootstrapFd: Option.none(), | ||
| 263 | autoBootstrapProjectFromCwd: Option.none(), | ||
| 264 | logWebSocketEvents: Option.none(), | ||
| 265 | tailscaleServeEnabled: Option.none(), | ||
| 266 | tailscaleServePort: Option.none(), | ||
| 267 | }, | ||
| 268 | Option.none(), | ||
| 269 | ).pipe( | ||
| 270 | Effect.provide( | ||
| 271 | Layer.mergeAll( | ||
| 272 | ConfigProvider.layer( | ||
| 273 | ConfigProvider.fromEnv({ | ||
| 274 | env: { | ||
| 275 | T3CODE_BOOTSTRAP_FD: String(fd), | ||
| 276 | }, | ||
| 277 | }), | ||
| 278 | ), | ||
| 279 | NetService.layer, | ||
| 280 | ), | ||
| 281 | ), | ||
| 282 | ); | ||
| 283 | |||
| 284 | expect(resolved).toEqual({ | ||
| 285 | logLevel: "Info", | ||
| 286 | ...defaultObservabilityConfig, | ||
| 287 | otlpTracesUrl: "http://localhost:4318/v1/traces", | ||
| 288 | otlpMetricsUrl: "http://localhost:4318/v1/metrics", | ||
| 289 | mode: "desktop", | ||
| 290 | port: 4888, | ||
| 291 | cwd: process.cwd(), | ||
| 292 | baseDir, | ||
| 293 | ...derivedPaths, | ||
| 294 | host: "127.0.0.2", | ||
| 295 | staticDir: undefined, | ||
| 296 | devUrl: new URL("http://127.0.0.1:5173"), | ||
| 297 | noBrowser: true, | ||
| 298 | startupPresentation: "browser", | ||
| 299 | desktopBootstrapToken: undefined, | ||
| 300 | autoBootstrapProjectFromCwd: false, | ||
| 301 | logWebSocketEvents: true, | ||
| 302 | tailscaleServeEnabled: false, | ||
| 303 | tailscaleServePort: 443, | ||
| 304 | }); | ||
| 305 | assert.equal(join(baseDir, "dev"), resolved.stateDir); | ||
| 306 | }), | ||
| 307 | ); | ||
| 308 | |||
| 309 | it.effect("creates derived runtime directories during config resolution", () => | ||
| 310 | Effect.gen(function* () { | ||
| 311 | const fs = yield* FileSystem.FileSystem; | ||
| 312 | const path = yield* Path.Path; | ||
| 313 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-dirs-" }); | ||
| 314 | const customCwd = path.join(baseDir, "nested", "project"); | ||
| 315 | |||
| 316 | const resolved = yield* resolveServerConfig( | ||
| 317 | { | ||
| 318 | mode: Option.some("desktop"), | ||
| 319 | port: Option.some(4888), | ||
| 320 | host: Option.none(), | ||
| 321 | baseDir: Option.some(baseDir), | ||
| 322 | cwd: Option.some(customCwd), | ||
| 323 | devUrl: Option.some(new URL("http://127.0.0.1:5173")), | ||
| 324 | noBrowser: Option.none(), | ||
| 325 | bootstrapFd: Option.none(), | ||
| 326 | autoBootstrapProjectFromCwd: Option.none(), | ||
| 327 | logWebSocketEvents: Option.none(), | ||
| 328 | tailscaleServeEnabled: Option.none(), | ||
| 329 | tailscaleServePort: Option.none(), | ||
| 330 | }, | ||
| 331 | Option.none(), | ||
| 332 | ).pipe( | ||
| 333 | Effect.provide( | ||
| 334 | Layer.mergeAll( | ||
| 335 | ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), | ||
| 336 | NetService.layer, | ||
| 337 | ), | ||
| 338 | ), | ||
| 339 | ); | ||
| 340 | |||
| 341 | for (const directory of [ | ||
| 342 | customCwd, | ||
| 343 | resolved.stateDir, | ||
| 344 | resolved.logsDir, | ||
| 345 | resolved.providerLogsDir, | ||
| 346 | resolved.terminalLogsDir, | ||
| 347 | resolved.attachmentsDir, | ||
| 348 | resolved.worktreesDir, | ||
| 349 | path.dirname(resolved.serverLogPath), | ||
| 350 | path.dirname(resolved.serverTracePath), | ||
| 351 | ]) { | ||
| 352 | expect(yield* fs.exists(directory)).toBe(true); | ||
| 353 | } | ||
| 354 | expect(resolved.cwd).toBe(path.resolve(customCwd)); | ||
| 355 | }), | ||
| 356 | ); | ||
| 357 | |||
| 358 | it.effect("applies flag then env precedence over bootstrap envelope values", () => | ||
| 359 | Effect.gen(function* () { | ||
| 360 | const { join } = yield* Path.Path; | ||
| 361 | const baseDir = join(os.tmpdir(), "t3-cli-config-env-wins"); | ||
| 362 | const fd = yield* openBootstrapFd({ | ||
| 363 | mode: "desktop", | ||
| 364 | port: 4888, | ||
| 365 | host: "127.0.0.2", | ||
| 366 | t3Home: "/tmp/t3-bootstrap-home", | ||
| 367 | devUrl: "http://127.0.0.1:5173", | ||
| 368 | noBrowser: false, | ||
| 369 | autoBootstrapProjectFromCwd: false, | ||
| 370 | logWebSocketEvents: false, | ||
| 371 | tailscaleServeEnabled: false, | ||
| 372 | tailscaleServePort: 443, | ||
| 373 | }); | ||
| 374 | const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173")); | ||
| 375 | |||
| 376 | const resolved = yield* resolveServerConfig( | ||
| 377 | { | ||
| 378 | mode: Option.none(), | ||
| 379 | port: Option.some(8788), | ||
| 380 | host: Option.some("127.0.0.1"), | ||
| 381 | baseDir: Option.none(), | ||
| 382 | cwd: Option.none(), | ||
| 383 | devUrl: Option.some(new URL("http://127.0.0.1:4173")), | ||
| 384 | noBrowser: Option.none(), | ||
| 385 | bootstrapFd: Option.none(), | ||
| 386 | autoBootstrapProjectFromCwd: Option.none(), | ||
| 387 | logWebSocketEvents: Option.none(), | ||
| 388 | tailscaleServeEnabled: Option.none(), | ||
| 389 | tailscaleServePort: Option.none(), | ||
| 390 | }, | ||
| 391 | Option.some("Debug"), | ||
| 392 | ).pipe( | ||
| 393 | Effect.provide( | ||
| 394 | Layer.mergeAll( | ||
| 395 | ConfigProvider.layer( | ||
| 396 | ConfigProvider.fromEnv({ | ||
| 397 | env: { | ||
| 398 | T3CODE_MODE: "web", | ||
| 399 | T3CODE_BOOTSTRAP_FD: String(fd), | ||
| 400 | T3CODE_HOME: baseDir, | ||
| 401 | T3CODE_NO_BROWSER: "true", | ||
| 402 | T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true", | ||
| 403 | T3CODE_LOG_WS_EVENTS: "true", | ||
| 404 | }, | ||
| 405 | }), | ||
| 406 | ), | ||
| 407 | NetService.layer, | ||
| 408 | ), | ||
| 409 | ), | ||
| 410 | ); | ||
| 411 | |||
| 412 | expect(resolved).toEqual({ | ||
| 413 | logLevel: "Debug", | ||
| 414 | ...defaultObservabilityConfig, | ||
| 415 | mode: "web", | ||
| 416 | port: 8788, | ||
| 417 | cwd: process.cwd(), | ||
| 418 | baseDir, | ||
| 419 | ...derivedPaths, | ||
| 420 | host: "127.0.0.1", | ||
| 421 | staticDir: undefined, | ||
| 422 | devUrl: new URL("http://127.0.0.1:4173"), | ||
| 423 | noBrowser: true, | ||
| 424 | startupPresentation: "browser", | ||
| 425 | desktopBootstrapToken: undefined, | ||
| 426 | autoBootstrapProjectFromCwd: true, | ||
| 427 | logWebSocketEvents: true, | ||
| 428 | tailscaleServeEnabled: false, | ||
| 429 | tailscaleServePort: 443, | ||
| 430 | }); | ||
| 431 | }), | ||
| 432 | ); | ||
| 433 | |||
| 434 | it.effect("falls back to persisted observability settings when env vars are absent", () => | ||
| 435 | Effect.gen(function* () { | ||
| 436 | const fs = yield* FileSystem.FileSystem; | ||
| 437 | const path = yield* Path.Path; | ||
| 438 | const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-config-settings-" }); | ||
| 439 | const derivedPaths = yield* deriveServerPaths(baseDir, undefined); | ||
| 440 | yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); | ||
| 441 | yield* fs.writeFileString( | ||
| 442 | derivedPaths.settingsPath, | ||
| 443 | `${JSON.stringify({ | ||
| 444 | observability: { | ||
| 445 | otlpTracesUrl: "http://localhost:4318/v1/traces", | ||
| 446 | otlpMetricsUrl: "http://localhost:4318/v1/metrics", | ||
| 447 | }, | ||
| 448 | })}\n`, | ||
| 449 | ); | ||
| 450 | |||
| 451 | const resolved = yield* resolveServerConfig( | ||
| 452 | { | ||
| 453 | mode: Option.some("desktop"), | ||
| 454 | port: Option.some(4888), | ||
| 455 | host: Option.none(), | ||
| 456 | baseDir: Option.some(baseDir), | ||
| 457 | cwd: Option.none(), | ||
| 458 | devUrl: Option.none(), | ||
| 459 | noBrowser: Option.none(), | ||
| 460 | bootstrapFd: Option.none(), | ||
| 461 | autoBootstrapProjectFromCwd: Option.none(), | ||
| 462 | logWebSocketEvents: Option.none(), | ||
| 463 | tailscaleServeEnabled: Option.none(), | ||
| 464 | tailscaleServePort: Option.none(), | ||
| 465 | }, | ||
| 466 | Option.none(), | ||
| 467 | ).pipe( | ||
| 468 | Effect.provide( | ||
| 469 | Layer.mergeAll( | ||
| 470 | ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), | ||
| 471 | NetService.layer, | ||
| 472 | ), | ||
| 473 | ), | ||
| 474 | ); | ||
| 475 | |||
| 476 | expect(resolved.otlpTracesUrl).toBe("http://localhost:4318/v1/traces"); | ||
| 477 | expect(resolved.otlpMetricsUrl).toBe("http://localhost:4318/v1/metrics"); | ||
| 478 | expect(resolved).toEqual({ | ||
| 479 | logLevel: "Info", | ||
| 480 | ...defaultObservabilityConfig, | ||
| 481 | otlpTracesUrl: "http://localhost:4318/v1/traces", | ||
| 482 | otlpMetricsUrl: "http://localhost:4318/v1/metrics", | ||
| 483 | mode: "desktop", | ||
| 484 | port: 4888, | ||
| 485 | cwd: process.cwd(), | ||
| 486 | baseDir, | ||
| 487 | ...derivedPaths, | ||
| 488 | host: "127.0.0.1", | ||
| 489 | staticDir: resolved.staticDir, | ||
| 490 | devUrl: undefined, | ||
| 491 | noBrowser: true, | ||
| 492 | startupPresentation: "browser", | ||
| 493 | desktopBootstrapToken: undefined, | ||
| 494 | autoBootstrapProjectFromCwd: false, | ||
| 495 | logWebSocketEvents: false, | ||
| 496 | tailscaleServeEnabled: false, | ||
| 497 | tailscaleServePort: 443, | ||
| 498 | }); | ||
| 499 | }), | ||
| 500 | ); | ||
| 501 | |||
| 502 | it.effect("forces noBrowser and disables auto-bootstrap for headless startup presentation", () => | ||
| 503 | Effect.gen(function* () { | ||
| 504 | const { join } = yield* Path.Path; | ||
| 505 | const baseDir = join(os.tmpdir(), "t3-cli-config-headless-base"); | ||
| 506 | const derivedPaths = yield* deriveServerPaths(baseDir, undefined); | ||
| 507 | |||
| 508 | const resolved = yield* resolveServerConfig( | ||
| 509 | { | ||
| 510 | mode: Option.some("web"), | ||
| 511 | port: Option.some(3773), | ||
| 512 | host: Option.none(), | ||
| 513 | baseDir: Option.some(baseDir), | ||
| 514 | cwd: Option.none(), | ||
| 515 | devUrl: Option.none(), | ||
| 516 | noBrowser: Option.none(), | ||
| 517 | bootstrapFd: Option.none(), | ||
| 518 | autoBootstrapProjectFromCwd: Option.none(), | ||
| 519 | logWebSocketEvents: Option.none(), | ||
| 520 | tailscaleServeEnabled: Option.none(), | ||
| 521 | tailscaleServePort: Option.none(), | ||
| 522 | }, | ||
| 523 | Option.none(), | ||
| 524 | { | ||
| 525 | startupPresentation: "headless", | ||
| 526 | }, | ||
| 527 | ).pipe( | ||
| 528 | Effect.provide( | ||
| 529 | Layer.mergeAll( | ||
| 530 | ConfigProvider.layer( | ||
| 531 | ConfigProvider.fromEnv({ | ||
| 532 | env: { | ||
| 533 | T3CODE_NO_BROWSER: "false", | ||
| 534 | T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true", | ||
| 535 | }, | ||
| 536 | }), | ||
| 537 | ), | ||
| 538 | NetService.layer, | ||
| 539 | ), | ||
| 540 | ), | ||
| 541 | ); | ||
| 542 | |||
| 543 | expect(resolved).toEqual({ | ||
| 544 | logLevel: "Info", | ||
| 545 | ...defaultObservabilityConfig, | ||
| 546 | mode: "web", | ||
| 547 | port: 3773, | ||
| 548 | cwd: process.cwd(), | ||
| 549 | baseDir, | ||
| 550 | ...derivedPaths, | ||
| 551 | host: undefined, | ||
| 552 | staticDir: resolved.staticDir, | ||
| 553 | devUrl: undefined, | ||
| 554 | noBrowser: true, | ||
| 555 | startupPresentation: "headless", | ||
| 556 | desktopBootstrapToken: undefined, | ||
| 557 | autoBootstrapProjectFromCwd: false, | ||
| 558 | logWebSocketEvents: false, | ||
| 559 | tailscaleServeEnabled: false, | ||
| 560 | tailscaleServePort: 443, | ||
| 561 | }); | ||
| 562 | }), | ||
| 563 | ); | ||
| 564 | }); | ||
apps/server/src/cli/config.ts created+490| ... | @@ -0,0 +1,490 @@ | ||
| 1 | import { NetService } from "@t3tools/shared/Net"; | ||
| 2 | import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; | ||
| 3 | import { | ||
| 4 | Config, | ||
| 5 | Duration, | ||
| 6 | Effect, | ||
| 7 | FileSystem, | ||
| 8 | LogLevel, | ||
| 9 | Option, | ||
| 10 | Path, | ||
| 11 | Schema, | ||
| 12 | SchemaIssue, | ||
| 13 | SchemaTransformation, | ||
| 14 | } from "effect"; | ||
| 15 | import { Argument, Flag } from "effect/unstable/cli"; | ||
| 16 | |||
| 17 | import { readBootstrapEnvelope } from "../bootstrap.ts"; | ||
| 18 | import { | ||
| 19 | DEFAULT_PORT, | ||
| 20 | deriveServerPaths, | ||
| 21 | ensureServerDirectories, | ||
| 22 | resolveStaticDir, | ||
| 23 | RuntimeMode, | ||
| 24 | type ServerConfigShape, | ||
| 25 | type StartupPresentation, | ||
| 26 | } from "../config.ts"; | ||
| 27 | import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; | ||
| 28 | |||
| 29 | export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); | ||
| 30 | |||
| 31 | const BootstrapEnvelopeSchema = Schema.Struct({ | ||
| 32 | mode: Schema.optional(RuntimeMode), | ||
| 33 | port: Schema.optional(PortSchema), | ||
| 34 | host: Schema.optional(Schema.String), | ||
| 35 | t3Home: Schema.optional(Schema.String), | ||
| 36 | devUrl: Schema.optional(Schema.URLFromString), | ||
| 37 | noBrowser: Schema.optional(Schema.Boolean), | ||
| 38 | desktopBootstrapToken: Schema.optional(Schema.String), | ||
| 39 | autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), | ||
| 40 | logWebSocketEvents: Schema.optional(Schema.Boolean), | ||
| 41 | tailscaleServeEnabled: Schema.optional(Schema.Boolean), | ||
| 42 | tailscaleServePort: Schema.optional(PortSchema), | ||
| 43 | otlpTracesUrl: Schema.optional(Schema.String), | ||
| 44 | otlpMetricsUrl: Schema.optional(Schema.String), | ||
| 45 | }); | ||
| 46 | |||
| 47 | export const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe( | ||
| 48 | Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), | ||
| 49 | Flag.optional, | ||
| 50 | ); | ||
| 51 | export const portFlag = Flag.integer("port").pipe( | ||
| 52 | Flag.withSchema(PortSchema), | ||
| 53 | Flag.withDescription("Port for the HTTP/WebSocket server."), | ||
| 54 | Flag.optional, | ||
| 55 | ); | ||
| 56 | export const hostFlag = Flag.string("host").pipe( | ||
| 57 | Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."), | ||
| 58 | Flag.optional, | ||
| 59 | ); | ||
| 60 | export const baseDirFlag = Flag.string("base-dir").pipe( | ||
| 61 | Flag.withDescription("Base directory path (equivalent to T3CODE_HOME)."), | ||
| 62 | Flag.optional, | ||
| 63 | ); | ||
| 64 | export const devUrlFlag = Flag.string("dev-url").pipe( | ||
| 65 | Flag.withSchema(Schema.URLFromString), | ||
| 66 | Flag.withDescription("Dev web URL to proxy/redirect to (equivalent to VITE_DEV_SERVER_URL)."), | ||
| 67 | Flag.optional, | ||
| 68 | ); | ||
| 69 | export const noBrowserFlag = Flag.boolean("no-browser").pipe( | ||
| 70 | Flag.withDescription("Disable automatic browser opening."), | ||
| 71 | Flag.optional, | ||
| 72 | ); | ||
| 73 | export const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe( | ||
| 74 | Flag.withSchema(Schema.Int), | ||
| 75 | Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."), | ||
| 76 | Flag.optional, | ||
| 77 | ); | ||
| 78 | export const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe( | ||
| 79 | Flag.withDescription( | ||
| 80 | "Create a project for the current working directory on startup when missing.", | ||
| 81 | ), | ||
| 82 | Flag.optional, | ||
| 83 | ); | ||
| 84 | export const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe( | ||
| 85 | Flag.withDescription( | ||
| 86 | "Emit server-side logs for outbound WebSocket push traffic (equivalent to T3CODE_LOG_WS_EVENTS).", | ||
| 87 | ), | ||
| 88 | Flag.withAlias("log-ws-events"), | ||
| 89 | Flag.optional, | ||
| 90 | ); | ||
| 91 | export const tailscaleServeFlag = Flag.boolean("tailscale-serve").pipe( | ||
| 92 | Flag.withDescription( | ||
| 93 | "Configure Tailscale Serve to expose this backend over HTTPS on the Tailnet.", | ||
| 94 | ), | ||
| 95 | Flag.optional, | ||
| 96 | ); | ||
| 97 | export const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe( | ||
| 98 | Flag.withSchema(PortSchema), | ||
| 99 | Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."), | ||
| 100 | Flag.optional, | ||
| 101 | ); | ||
| 102 | |||
| 103 | const EnvServerConfig = Config.all({ | ||
| 104 | logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")), | ||
| 105 | traceMinLevel: Config.logLevel("T3CODE_TRACE_MIN_LEVEL").pipe(Config.withDefault("Info")), | ||
| 106 | traceTimingEnabled: Config.boolean("T3CODE_TRACE_TIMING_ENABLED").pipe(Config.withDefault(true)), | ||
| 107 | traceFile: Config.string("T3CODE_TRACE_FILE").pipe( | ||
| 108 | Config.option, | ||
| 109 | Config.map(Option.getOrUndefined), | ||
| 110 | ), | ||
| 111 | traceMaxBytes: Config.int("T3CODE_TRACE_MAX_BYTES").pipe(Config.withDefault(10 * 1024 * 1024)), | ||
| 112 | traceMaxFiles: Config.int("T3CODE_TRACE_MAX_FILES").pipe(Config.withDefault(10)), | ||
| 113 | traceBatchWindowMs: Config.int("T3CODE_TRACE_BATCH_WINDOW_MS").pipe(Config.withDefault(200)), | ||
| 114 | otlpTracesUrl: Config.string("T3CODE_OTLP_TRACES_URL").pipe( | ||
| 115 | Config.option, | ||
| 116 | Config.map(Option.getOrUndefined), | ||
| 117 | ), | ||
| 118 | otlpMetricsUrl: Config.string("T3CODE_OTLP_METRICS_URL").pipe( | ||
| 119 | Config.option, | ||
| 120 | Config.map(Option.getOrUndefined), | ||
| 121 | ), | ||
| 122 | otlpExportIntervalMs: Config.int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( | ||
| 123 | Config.withDefault(10_000), | ||
| 124 | ), | ||
| 125 | otlpServiceName: Config.string("T3CODE_OTLP_SERVICE_NAME").pipe(Config.withDefault("t3-server")), | ||
| 126 | mode: Config.schema(RuntimeMode, "T3CODE_MODE").pipe( | ||
| 127 | Config.option, | ||
| 128 | Config.map(Option.getOrUndefined), | ||
| 129 | ), | ||
| 130 | port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), | ||
| 131 | host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), | ||
| 132 | t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)), | ||
| 133 | devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)), | ||
| 134 | noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe( | ||
| 135 | Config.option, | ||
| 136 | Config.map(Option.getOrUndefined), | ||
| 137 | ), | ||
| 138 | bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe( | ||
| 139 | Config.option, | ||
| 140 | Config.map(Option.getOrUndefined), | ||
| 141 | ), | ||
| 142 | autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe( | ||
| 143 | Config.option, | ||
| 144 | Config.map(Option.getOrUndefined), | ||
| 145 | ), | ||
| 146 | logWebSocketEvents: Config.boolean("T3CODE_LOG_WS_EVENTS").pipe( | ||
| 147 | Config.option, | ||
| 148 | Config.map(Option.getOrUndefined), | ||
| 149 | ), | ||
| 150 | tailscaleServeEnabled: Config.boolean("T3CODE_TAILSCALE_SERVE").pipe( | ||
| 151 | Config.option, | ||
| 152 | Config.map(Option.getOrUndefined), | ||
| 153 | ), | ||
| 154 | tailscaleServePort: Config.port("T3CODE_TAILSCALE_SERVE_PORT").pipe( | ||
| 155 | Config.option, | ||
| 156 | Config.map(Option.getOrUndefined), | ||
| 157 | ), | ||
| 158 | }); | ||
| 159 | |||
| 160 | export interface CliServerFlags { | ||
| 161 | readonly mode: Option.Option<RuntimeMode>; | ||
| 162 | readonly port: Option.Option<number>; | ||
| 163 | readonly host: Option.Option<string>; | ||
| 164 | readonly baseDir: Option.Option<string>; | ||
| 165 | readonly cwd: Option.Option<string>; | ||
| 166 | readonly devUrl: Option.Option<URL>; | ||
| 167 | readonly noBrowser: Option.Option<boolean>; | ||
| 168 | readonly bootstrapFd: Option.Option<number>; | ||
| 169 | readonly autoBootstrapProjectFromCwd: Option.Option<boolean>; | ||
| 170 | readonly logWebSocketEvents: Option.Option<boolean>; | ||
| 171 | readonly tailscaleServeEnabled: Option.Option<boolean>; | ||
| 172 | readonly tailscaleServePort: Option.Option<number>; | ||
| 173 | } | ||
| 174 | |||
| 175 | export interface CliAuthLocationFlags { | ||
| 176 | readonly baseDir: Option.Option<string>; | ||
| 177 | readonly devUrl?: Option.Option<URL>; | ||
| 178 | } | ||
| 179 | |||
| 180 | export const sharedServerLocationFlags = { | ||
| 181 | baseDir: baseDirFlag, | ||
| 182 | devUrl: devUrlFlag, | ||
| 183 | } as const; | ||
| 184 | |||
| 185 | export const projectLocationFlags = { | ||
| 186 | baseDir: baseDirFlag, | ||
| 187 | } as const; | ||
| 188 | |||
| 189 | export const sharedServerCommandFlags = { | ||
| 190 | mode: modeFlag, | ||
| 191 | port: portFlag, | ||
| 192 | host: hostFlag, | ||
| 193 | baseDir: baseDirFlag, | ||
| 194 | cwd: Argument.string("cwd").pipe( | ||
| 195 | Argument.withDescription( | ||
| 196 | "Working directory for provider sessions (defaults to the current directory).", | ||
| 197 | ), | ||
| 198 | Argument.optional, | ||
| 199 | ), | ||
| 200 | devUrl: devUrlFlag, | ||
| 201 | noBrowser: noBrowserFlag, | ||
| 202 | bootstrapFd: bootstrapFdFlag, | ||
| 203 | autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, | ||
| 204 | logWebSocketEvents: logWebSocketEventsFlag, | ||
| 205 | tailscaleServeEnabled: tailscaleServeFlag, | ||
| 206 | tailscaleServePort: tailscaleServePortFlag, | ||
| 207 | } as const; | ||
| 208 | |||
| 209 | export const authLocationFlags = sharedServerLocationFlags; | ||
| 210 | |||
| 211 | const resolveOptionPrecedence = <Value>( | ||
| 212 | ...values: ReadonlyArray<Option.Option<Value>> | ||
| 213 | ): Option.Option<Value> => Option.firstSomeOf(values); | ||
| 214 | |||
| 215 | const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: string) { | ||
| 216 | const fs = yield* FileSystem.FileSystem; | ||
| 217 | const exists = yield* fs.exists(settingsPath).pipe(Effect.orElseSucceed(() => false)); | ||
| 218 | if (!exists) { | ||
| 219 | return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; | ||
| 220 | } | ||
| 221 | |||
| 222 | const raw = yield* fs.readFileString(settingsPath).pipe(Effect.orElseSucceed(() => "")); | ||
| 223 | return parsePersistedServerObservabilitySettings(raw); | ||
| 224 | }); | ||
| 225 | |||
| 226 | export const resolveServerConfig = ( | ||
| 227 | flags: CliServerFlags, | ||
| 228 | cliLogLevel: Option.Option<LogLevel.LogLevel>, | ||
| 229 | options?: { | ||
| 230 | readonly startupPresentation?: StartupPresentation; | ||
| 231 | readonly forceAutoBootstrapProjectFromCwd?: boolean; | ||
| 232 | }, | ||
| 233 | ) => | ||
| 234 | Effect.gen(function* () { | ||
| 235 | const { findAvailablePort } = yield* NetService; | ||
| 236 | const path = yield* Path.Path; | ||
| 237 | const fs = yield* FileSystem.FileSystem; | ||
| 238 | const env = yield* EnvServerConfig; | ||
| 239 | const normalizedFlags = { | ||
| 240 | mode: flags.mode ?? Option.none(), | ||
| 241 | port: flags.port ?? Option.none(), | ||
| 242 | host: flags.host ?? Option.none(), | ||
| 243 | baseDir: flags.baseDir ?? Option.none(), | ||
| 244 | cwd: flags.cwd ?? Option.none(), | ||
| 245 | devUrl: flags.devUrl ?? Option.none(), | ||
| 246 | noBrowser: flags.noBrowser ?? Option.none(), | ||
| 247 | bootstrapFd: flags.bootstrapFd ?? Option.none(), | ||
| 248 | autoBootstrapProjectFromCwd: flags.autoBootstrapProjectFromCwd ?? Option.none(), | ||
| 249 | logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(), | ||
| 250 | tailscaleServeEnabled: flags.tailscaleServeEnabled ?? Option.none(), | ||
| 251 | tailscaleServePort: flags.tailscaleServePort ?? Option.none(), | ||
| 252 | } satisfies CliServerFlags; | ||
| 253 | const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd; | ||
| 254 | const bootstrapEnvelope = | ||
| 255 | bootstrapFd !== undefined | ||
| 256 | ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd) | ||
| 257 | : Option.none(); | ||
| 258 | const bootstrap = Option.getOrUndefined(bootstrapEnvelope); | ||
| 259 | |||
| 260 | const mode: RuntimeMode = Option.getOrElse( | ||
| 261 | resolveOptionPrecedence( | ||
| 262 | normalizedFlags.mode, | ||
| 263 | Option.fromUndefinedOr(env.mode), | ||
| 264 | Option.fromUndefinedOr(bootstrap?.mode), | ||
| 265 | ), | ||
| 266 | () => "web", | ||
| 267 | ); | ||
| 268 | |||
| 269 | const port = yield* Option.match( | ||
| 270 | resolveOptionPrecedence( | ||
| 271 | normalizedFlags.port, | ||
| 272 | Option.fromUndefinedOr(env.port), | ||
| 273 | Option.fromUndefinedOr(bootstrap?.port), | ||
| 274 | ), | ||
| 275 | { | ||
| 276 | onSome: (value) => Effect.succeed(value), | ||
| 277 | onNone: () => { | ||
| 278 | if (mode === "desktop") { | ||
| 279 | return Effect.succeed(DEFAULT_PORT); | ||
| 280 | } | ||
| 281 | return findAvailablePort(DEFAULT_PORT); | ||
| 282 | }, | ||
| 283 | }, | ||
| 284 | ); | ||
| 285 | const devUrl = Option.getOrElse( | ||
| 286 | resolveOptionPrecedence( | ||
| 287 | normalizedFlags.devUrl, | ||
| 288 | Option.fromUndefinedOr(env.devUrl), | ||
| 289 | Option.fromUndefinedOr(bootstrap?.devUrl), | ||
| 290 | ), | ||
| 291 | () => undefined, | ||
| 292 | ); | ||
| 293 | const baseDir = yield* resolveBaseDir( | ||
| 294 | Option.getOrUndefined( | ||
| 295 | resolveOptionPrecedence( | ||
| 296 | normalizedFlags.baseDir, | ||
| 297 | Option.fromUndefinedOr(env.t3Home), | ||
| 298 | Option.fromUndefinedOr(bootstrap?.t3Home), | ||
| 299 | ), | ||
| 300 | ), | ||
| 301 | ); | ||
| 302 | const rawCwd = Option.getOrElse(normalizedFlags.cwd, () => process.cwd()); | ||
| 303 | const cwd = path.resolve(yield* expandHomePath(rawCwd.trim())); | ||
| 304 | yield* fs.makeDirectory(cwd, { recursive: true }); | ||
| 305 | const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); | ||
| 306 | yield* ensureServerDirectories(derivedPaths); | ||
| 307 | const persistedObservabilitySettings = yield* loadPersistedObservabilitySettings( | ||
| 308 | derivedPaths.settingsPath, | ||
| 309 | ); | ||
| 310 | const serverTracePath = env.traceFile ?? derivedPaths.serverTracePath; | ||
| 311 | yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true }); | ||
| 312 | const startupPresentation = options?.startupPresentation ?? "browser"; | ||
| 313 | const isHeadlessStartup = startupPresentation === "headless"; | ||
| 314 | const noBrowser = Option.getOrElse( | ||
| 315 | resolveOptionPrecedence( | ||
| 316 | isHeadlessStartup ? Option.some(true) : Option.none(), | ||
| 317 | normalizedFlags.noBrowser, | ||
| 318 | Option.fromUndefinedOr(env.noBrowser), | ||
| 319 | Option.fromUndefinedOr(bootstrap?.noBrowser), | ||
| 320 | ), | ||
| 321 | () => mode === "desktop", | ||
| 322 | ); | ||
| 323 | const desktopBootstrapToken = bootstrap?.desktopBootstrapToken; | ||
| 324 | const autoBootstrapProjectFromCwd = Option.getOrElse( | ||
| 325 | resolveOptionPrecedence( | ||
| 326 | Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd), | ||
| 327 | isHeadlessStartup ? Option.some(false) : Option.none(), | ||
| 328 | normalizedFlags.autoBootstrapProjectFromCwd, | ||
| 329 | Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd), | ||
| 330 | Option.fromUndefinedOr(bootstrap?.autoBootstrapProjectFromCwd), | ||
| 331 | ), | ||
| 332 | () => mode === "web", | ||
| 333 | ); | ||
| 334 | const logWebSocketEvents = Option.getOrElse( | ||
| 335 | resolveOptionPrecedence( | ||
| 336 | normalizedFlags.logWebSocketEvents, | ||
| 337 | Option.fromUndefinedOr(env.logWebSocketEvents), | ||
| 338 | Option.fromUndefinedOr(bootstrap?.logWebSocketEvents), | ||
| 339 | ), | ||
| 340 | () => Boolean(devUrl), | ||
| 341 | ); | ||
| 342 | const tailscaleServeEnabled = Option.getOrElse( | ||
| 343 | resolveOptionPrecedence( | ||
| 344 | normalizedFlags.tailscaleServeEnabled, | ||
| 345 | Option.fromUndefinedOr(env.tailscaleServeEnabled), | ||
| 346 | Option.fromUndefinedOr(bootstrap?.tailscaleServeEnabled), | ||
| 347 | ), | ||
| 348 | () => false, | ||
| 349 | ); | ||
| 350 | const tailscaleServePort = Option.getOrElse( | ||
| 351 | resolveOptionPrecedence( | ||
| 352 | normalizedFlags.tailscaleServePort, | ||
| 353 | Option.fromUndefinedOr(env.tailscaleServePort), | ||
| 354 | Option.fromUndefinedOr(bootstrap?.tailscaleServePort), | ||
| 355 | ), | ||
| 356 | () => 443, | ||
| 357 | ); | ||
| 358 | const staticDir = devUrl ? undefined : yield* resolveStaticDir(); | ||
| 359 | const host = Option.getOrElse( | ||
| 360 | resolveOptionPrecedence( | ||
| 361 | normalizedFlags.host, | ||
| 362 | Option.fromUndefinedOr(env.host), | ||
| 363 | Option.fromUndefinedOr(bootstrap?.host), | ||
| 364 | ), | ||
| 365 | () => (mode === "desktop" ? "127.0.0.1" : undefined), | ||
| 366 | ); | ||
| 367 | const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel); | ||
| 368 | |||
| 369 | const config: ServerConfigShape = { | ||
| 370 | logLevel, | ||
| 371 | traceMinLevel: env.traceMinLevel, | ||
| 372 | traceTimingEnabled: env.traceTimingEnabled, | ||
| 373 | traceBatchWindowMs: env.traceBatchWindowMs, | ||
| 374 | traceMaxBytes: env.traceMaxBytes, | ||
| 375 | traceMaxFiles: env.traceMaxFiles, | ||
| 376 | otlpTracesUrl: | ||
| 377 | env.otlpTracesUrl ?? | ||
| 378 | bootstrap?.otlpTracesUrl ?? | ||
| 379 | persistedObservabilitySettings.otlpTracesUrl, | ||
| 380 | otlpMetricsUrl: | ||
| 381 | env.otlpMetricsUrl ?? | ||
| 382 | bootstrap?.otlpMetricsUrl ?? | ||
| 383 | persistedObservabilitySettings.otlpMetricsUrl, | ||
| 384 | otlpExportIntervalMs: env.otlpExportIntervalMs, | ||
| 385 | otlpServiceName: env.otlpServiceName, | ||
| 386 | mode, | ||
| 387 | port, | ||
| 388 | cwd, | ||
| 389 | baseDir, | ||
| 390 | ...derivedPaths, | ||
| 391 | serverTracePath, | ||
| 392 | host, | ||
| 393 | staticDir, | ||
| 394 | devUrl, | ||
| 395 | noBrowser, | ||
| 396 | startupPresentation, | ||
| 397 | desktopBootstrapToken, | ||
| 398 | autoBootstrapProjectFromCwd, | ||
| 399 | logWebSocketEvents, | ||
| 400 | tailscaleServeEnabled, | ||
| 401 | tailscaleServePort, | ||
| 402 | }; | ||
| 403 | |||
| 404 | return config; | ||
| 405 | }); | ||
| 406 | |||
| 407 | export const resolveCliAuthConfig = ( | ||
| 408 | flags: CliAuthLocationFlags, | ||
| 409 | cliLogLevel: Option.Option<LogLevel.LogLevel>, | ||
| 410 | ) => | ||
| 411 | resolveServerConfig( | ||
| 412 | { | ||
| 413 | mode: Option.none(), | ||
| 414 | port: Option.none(), | ||
| 415 | host: Option.none(), | ||
| 416 | baseDir: flags.baseDir, | ||
| 417 | cwd: Option.none(), | ||
| 418 | devUrl: flags.devUrl ?? Option.none(), | ||
| 419 | noBrowser: Option.none(), | ||
| 420 | bootstrapFd: Option.none(), | ||
| 421 | autoBootstrapProjectFromCwd: Option.none(), | ||
| 422 | logWebSocketEvents: Option.none(), | ||
| 423 | tailscaleServeEnabled: Option.none(), | ||
| 424 | tailscaleServePort: Option.none(), | ||
| 425 | }, | ||
| 426 | cliLogLevel, | ||
| 427 | ); | ||
| 428 | |||
| 429 | const DurationShorthandPattern = /^(?<value>\d+)(?<unit>ms|s|m|h|d|w)$/i; | ||
| 430 | |||
| 431 | const parseDurationInput = (value: string): Duration.Duration | null => { | ||
| 432 | const trimmed = value.trim(); | ||
| 433 | if (trimmed.length === 0) return null; | ||
| 434 | |||
| 435 | const shorthand = DurationShorthandPattern.exec(trimmed); | ||
| 436 | const normalizedInput = shorthand?.groups | ||
| 437 | ? (() => { | ||
| 438 | const amountText = shorthand.groups.value; | ||
| 439 | const unitText = shorthand.groups.unit; | ||
| 440 | if (typeof amountText !== "string" || typeof unitText !== "string") { | ||
| 441 | return null; | ||
| 442 | } | ||
| 443 | |||
| 444 | const amount = Number.parseInt(amountText, 10); | ||
| 445 | if (!Number.isFinite(amount)) return null; | ||
| 446 | |||
| 447 | switch (unitText.toLowerCase()) { | ||
| 448 | case "ms": | ||
| 449 | return `${amount} millis`; | ||
| 450 | case "s": | ||
| 451 | return `${amount} seconds`; | ||
| 452 | case "m": | ||
| 453 | return `${amount} minutes`; | ||
| 454 | case "h": | ||
| 455 | return `${amount} hours`; | ||
| 456 | case "d": | ||
| 457 | return `${amount} days`; | ||
| 458 | case "w": | ||
| 459 | return `${amount} weeks`; | ||
| 460 | default: | ||
| 461 | return null; | ||
| 462 | } | ||
| 463 | })() | ||
| 464 | : (trimmed as Duration.Input); | ||
| 465 | |||
| 466 | if (normalizedInput === null) return null; | ||
| 467 | |||
| 468 | const decoded = Duration.fromInput(normalizedInput as Duration.Input); | ||
| 469 | return Option.isSome(decoded) ? decoded.value : null; | ||
| 470 | }; | ||
| 471 | |||
| 472 | export const DurationFromString = Schema.String.pipe( | ||
| 473 | Schema.decodeTo( | ||
| 474 | Schema.Duration, | ||
| 475 | SchemaTransformation.transformOrFail({ | ||
| 476 | decode: (value) => { | ||
| 477 | const duration = parseDurationInput(value); | ||
| 478 | if (duration !== null) { | ||
| 479 | return Effect.succeed(duration); | ||
| 480 | } | ||
| 481 | return Effect.fail( | ||
| 482 | new SchemaIssue.InvalidValue(Option.some(value), { | ||
| 483 | message: "Invalid duration. Use values like 5m, 1h, 30d, or 15 minutes.", | ||
| 484 | }), | ||
| 485 | ); | ||
| 486 | }, | ||
| 487 | encode: (duration) => Effect.succeed(Duration.format(duration)), | ||
| 488 | }), | ||
| 489 | ), | ||
| 490 | ); | ||
apps/server/src/cli/project.ts created+434| ... | @@ -0,0 +1,434 @@ | ||
| 1 | import { | ||
| 2 | CommandId, | ||
| 3 | OrchestrationReadModel, | ||
| 4 | ProjectId, | ||
| 5 | type ClientOrchestrationCommand, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { | ||
| 8 | Console, | ||
| 9 | Duration, | ||
| 10 | Effect, | ||
| 11 | Exit, | ||
| 12 | FileSystem, | ||
| 13 | Layer, | ||
| 14 | Option, | ||
| 15 | Path, | ||
| 16 | References, | ||
| 17 | Schema, | ||
| 18 | } from "effect"; | ||
| 19 | import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; | ||
| 20 | import { | ||
| 21 | FetchHttpClient, | ||
| 22 | HttpClient, | ||
| 23 | HttpClientRequest, | ||
| 24 | HttpClientResponse, | ||
| 25 | } from "effect/unstable/http"; | ||
| 26 | |||
| 27 | import { AuthControlPlaneRuntimeLive } from "../auth/Layers/AuthControlPlane.ts"; | ||
| 28 | import { AuthControlPlane } from "../auth/Services/AuthControlPlane.ts"; | ||
| 29 | import type { AuthControlPlaneShape } from "../auth/Services/AuthControlPlane.ts"; | ||
| 30 | import { ServerConfig, type ServerConfigShape } from "../config.ts"; | ||
| 31 | import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; | ||
| 32 | import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; | ||
| 33 | import { OrchestrationLayerLive } from "../orchestration/runtimeLayer.ts"; | ||
| 34 | import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts"; | ||
| 35 | import { RepositoryIdentityResolverLive } from "../project/Layers/RepositoryIdentityResolver.ts"; | ||
| 36 | import { getAutoBootstrapDefaultModelSelection } from "../serverRuntimeStartup.ts"; | ||
| 37 | import { | ||
| 38 | clearPersistedServerRuntimeState, | ||
| 39 | readPersistedServerRuntimeState, | ||
| 40 | } from "../serverRuntimeState.ts"; | ||
| 41 | import { WorkspacePathsLive } from "../workspace/Layers/WorkspacePaths.ts"; | ||
| 42 | import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts"; | ||
| 43 | import { type CliAuthLocationFlags, projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; | ||
| 44 | |||
| 45 | type ProjectMutationTarget = { | ||
| 46 | readonly id: ProjectId; | ||
| 47 | readonly title: string; | ||
| 48 | readonly workspaceRoot: string; | ||
| 49 | }; | ||
| 50 | |||
| 51 | type ProjectCommandExecutionMode = "live" | "offline"; | ||
| 52 | type ProjectCliDispatchCommand = Extract< | ||
| 53 | ClientOrchestrationCommand, | ||
| 54 | { type: "project.create" | "project.meta.update" | "project.delete" } | ||
| 55 | >; | ||
| 56 | |||
| 57 | const ProjectCliRuntimeLive = Layer.mergeAll( | ||
| 58 | WorkspacePathsLive, | ||
| 59 | OrchestrationLayerLive.pipe( | ||
| 60 | Layer.provideMerge(RepositoryIdentityResolverLive), | ||
| 61 | Layer.provideMerge(SqlitePersistenceLayerLive), | ||
| 62 | ), | ||
| 63 | ); | ||
| 64 | |||
| 65 | const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1); | ||
| 66 | const OrchestrationHttpErrorResponse = Schema.Struct({ | ||
| 67 | error: Schema.String, | ||
| 68 | }); | ||
| 69 | |||
| 70 | const withProjectCliSessionToken = <A, E, R>( | ||
| 71 | authControlPlane: AuthControlPlaneShape, | ||
| 72 | run: (token: string) => Effect.Effect<A, E, R>, | ||
| 73 | ) => | ||
| 74 | Effect.acquireUseRelease( | ||
| 75 | authControlPlane.issueSession({ | ||
| 76 | role: "owner", | ||
| 77 | label: "t3 project cli", | ||
| 78 | }), | ||
| 79 | (issued) => run(issued.token), | ||
| 80 | (issued) => authControlPlane.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })), | ||
| 81 | ); | ||
| 82 | |||
| 83 | const withProjectCliLiveServerTimeout = <A, E, R>(effect: Effect.Effect<A, E, R>) => | ||
| 84 | effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT)); | ||
| 85 | |||
| 86 | const runLiveServerRequest = <A, E extends Error, R>( | ||
| 87 | request: HttpClientRequest.HttpClientRequest, | ||
| 88 | handle: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect<A, E, R>, | ||
| 89 | ) => | ||
| 90 | Effect.gen(function* () { | ||
| 91 | const httpClient = yield* HttpClient.HttpClient; | ||
| 92 | const response = yield* httpClient.execute(request); | ||
| 93 | return yield* handle(response); | ||
| 94 | }).pipe(withProjectCliLiveServerTimeout); | ||
| 95 | |||
| 96 | const decodeOrchestrationReadModelResponse = (response: HttpClientResponse.HttpClientResponse) => | ||
| 97 | HttpClientResponse.schemaBodyJson(OrchestrationReadModel)(response); | ||
| 98 | |||
| 99 | const readErrorMessageFromResponse = (response: HttpClientResponse.HttpClientResponse) => | ||
| 100 | HttpClientResponse.schemaBodyJson(OrchestrationHttpErrorResponse)(response).pipe( | ||
| 101 | Effect.map((body) => body.error), | ||
| 102 | Effect.catch(() => Effect.succeed(null)), | ||
| 103 | Effect.map((body) => { | ||
| 104 | if (typeof body === "string" && body.trim().length > 0) { | ||
| 105 | return body; | ||
| 106 | } | ||
| 107 | return `Server request failed with status ${response.status}.`; | ||
| 108 | }), | ||
| 109 | ); | ||
| 110 | |||
| 111 | const normalizeWorkspaceRootForProjectCommand = Effect.fn( | ||
| 112 | "normalizeWorkspaceRootForProjectCommand", | ||
| 113 | )(function* (workspaceRoot: string) { | ||
| 114 | const workspacePaths = yield* WorkspacePaths; | ||
| 115 | return yield* workspacePaths.normalizeWorkspaceRoot(workspaceRoot); | ||
| 116 | }); | ||
| 117 | |||
| 118 | const resolveProjectTitle = Effect.fn("resolveProjectTitle")(function* ( | ||
| 119 | workspaceRoot: string, | ||
| 120 | explicitTitle?: string, | ||
| 121 | ) { | ||
| 122 | if (explicitTitle !== undefined) { | ||
| 123 | const trimmed = explicitTitle.trim(); | ||
| 124 | if (trimmed.length > 0) { | ||
| 125 | return trimmed; | ||
| 126 | } | ||
| 127 | return yield* Effect.fail(new Error("Project title cannot be empty.")); | ||
| 128 | } | ||
| 129 | |||
| 130 | const path = yield* Path.Path; | ||
| 131 | const basename = path.basename(workspaceRoot).trim(); | ||
| 132 | return basename.length > 0 ? basename : "project"; | ||
| 133 | }); | ||
| 134 | |||
| 135 | const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (input: { | ||
| 136 | readonly snapshot: OrchestrationReadModel; | ||
| 137 | readonly identifier: string; | ||
| 138 | }) { | ||
| 139 | const trimmedIdentifier = input.identifier.trim(); | ||
| 140 | if (trimmedIdentifier.length === 0) { | ||
| 141 | return yield* Effect.fail(new Error("Project identifier cannot be empty.")); | ||
| 142 | } | ||
| 143 | |||
| 144 | const activeProjects = input.snapshot.projects.filter((project) => project.deletedAt === null); | ||
| 145 | const exactIdMatch = activeProjects.find((project) => project.id === trimmedIdentifier); | ||
| 146 | if (exactIdMatch) { | ||
| 147 | return { | ||
| 148 | id: exactIdMatch.id, | ||
| 149 | title: exactIdMatch.title, | ||
| 150 | workspaceRoot: exactIdMatch.workspaceRoot, | ||
| 151 | } satisfies ProjectMutationTarget; | ||
| 152 | } | ||
| 153 | |||
| 154 | const normalizedWorkspaceRootResult = yield* Effect.exit( | ||
| 155 | normalizeWorkspaceRootForProjectCommand(trimmedIdentifier), | ||
| 156 | ); | ||
| 157 | const normalizedWorkspaceRoot = Exit.isSuccess(normalizedWorkspaceRootResult) | ||
| 158 | ? normalizedWorkspaceRootResult.value | ||
| 159 | : null; | ||
| 160 | |||
| 161 | const exactWorkspaceMatch = | ||
| 162 | normalizedWorkspaceRoot === null | ||
| 163 | ? undefined | ||
| 164 | : activeProjects.find((project) => project.workspaceRoot === normalizedWorkspaceRoot); | ||
| 165 | |||
| 166 | const resolved = exactWorkspaceMatch; | ||
| 167 | if (!resolved) { | ||
| 168 | return yield* Effect.fail(new Error(`No active project found for '${trimmedIdentifier}'.`)); | ||
| 169 | } | ||
| 170 | |||
| 171 | return { | ||
| 172 | id: resolved.id, | ||
| 173 | title: resolved.title, | ||
| 174 | workspaceRoot: resolved.workspaceRoot, | ||
| 175 | } satisfies ProjectMutationTarget; | ||
| 176 | }); | ||
| 177 | |||
| 178 | const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) => | ||
| 179 | runLiveServerRequest( | ||
| 180 | HttpClientRequest.get(`${origin}/api/orchestration/snapshot`).pipe( | ||
| 181 | HttpClientRequest.acceptJson, | ||
| 182 | HttpClientRequest.bearerToken(bearerToken), | ||
| 183 | ), | ||
| 184 | HttpClientResponse.matchStatus({ | ||
| 185 | "2xx": decodeOrchestrationReadModelResponse, | ||
| 186 | orElse: (response) => | ||
| 187 | readErrorMessageFromResponse(response).pipe( | ||
| 188 | Effect.flatMap((message) => Effect.fail(new Error(message))), | ||
| 189 | ), | ||
| 190 | }), | ||
| 191 | ); | ||
| 192 | |||
| 193 | const dispatchLiveOrchestrationCommand = ( | ||
| 194 | origin: string, | ||
| 195 | bearerToken: string, | ||
| 196 | command: ProjectCliDispatchCommand, | ||
| 197 | ) => | ||
| 198 | HttpClientRequest.post(`${origin}/api/orchestration/dispatch`).pipe( | ||
| 199 | HttpClientRequest.acceptJson, | ||
| 200 | HttpClientRequest.bearerToken(bearerToken), | ||
| 201 | HttpClientRequest.bodyJson(command), | ||
| 202 | Effect.flatMap((request) => | ||
| 203 | runLiveServerRequest( | ||
| 204 | request, | ||
| 205 | HttpClientResponse.matchStatus({ | ||
| 206 | "2xx": () => Effect.void, | ||
| 207 | orElse: (response) => | ||
| 208 | readErrorMessageFromResponse(response).pipe( | ||
| 209 | Effect.flatMap((message) => Effect.fail(new Error(message))), | ||
| 210 | ), | ||
| 211 | }), | ||
| 212 | ), | ||
| 213 | ), | ||
| 214 | ); | ||
| 215 | |||
| 216 | const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () { | ||
| 217 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; | ||
| 218 | return yield* projectionSnapshotQuery.getSnapshot(); | ||
| 219 | }); | ||
| 220 | |||
| 221 | const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecutionMode")( | ||
| 222 | function* (authControlPlane: AuthControlPlaneShape, config: ServerConfigShape) { | ||
| 223 | const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); | ||
| 224 | if (Option.isNone(runtimeState)) { | ||
| 225 | return Option.none<{ readonly origin: string }>(); | ||
| 226 | } | ||
| 227 | |||
| 228 | const attempt = withProjectCliSessionToken(authControlPlane, (token) => | ||
| 229 | fetchLiveOrchestrationSnapshot(runtimeState.value.origin, token).pipe( | ||
| 230 | Effect.as({ | ||
| 231 | origin: runtimeState.value.origin, | ||
| 232 | }), | ||
| 233 | ), | ||
| 234 | ); | ||
| 235 | |||
| 236 | const attempted = yield* Effect.exit(attempt); | ||
| 237 | if (Exit.isSuccess(attempted)) { | ||
| 238 | return Option.some(attempted.value); | ||
| 239 | } | ||
| 240 | |||
| 241 | yield* clearPersistedServerRuntimeState(config.serverRuntimeStatePath); | ||
| 242 | return Option.none<{ readonly origin: string }>(); | ||
| 243 | }, | ||
| 244 | ); | ||
| 245 | |||
| 246 | const runProjectMutation = Effect.fn("runProjectMutation")(function* ( | ||
| 247 | flags: CliAuthLocationFlags, | ||
| 248 | run: (input: { | ||
| 249 | readonly snapshot: OrchestrationReadModel; | ||
| 250 | readonly dispatch: ( | ||
| 251 | command: ProjectCliDispatchCommand, | ||
| 252 | ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>; | ||
| 253 | readonly mode: ProjectCommandExecutionMode; | ||
| 254 | }) => Effect.Effect< | ||
| 255 | string, | ||
| 256 | Error, | ||
| 257 | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path | WorkspacePaths | ||
| 258 | >, | ||
| 259 | ) { | ||
| 260 | const logLevel = yield* GlobalFlag.LogLevel; | ||
| 261 | const config = yield* resolveCliAuthConfig(flags, logLevel); | ||
| 262 | const minimumLogLevel = config.logLevel; | ||
| 263 | |||
| 264 | return yield* Effect.gen(function* () { | ||
| 265 | const authControlPlane = yield* AuthControlPlane; | ||
| 266 | const liveMode = yield* tryResolveLiveProjectExecutionMode(authControlPlane, config); | ||
| 267 | |||
| 268 | if (Option.isSome(liveMode)) { | ||
| 269 | return yield* withProjectCliSessionToken(authControlPlane, (token) => | ||
| 270 | Effect.gen(function* () { | ||
| 271 | const snapshot = yield* fetchLiveOrchestrationSnapshot(liveMode.value.origin, token); | ||
| 272 | const output = yield* run({ | ||
| 273 | snapshot, | ||
| 274 | dispatch: (command) => | ||
| 275 | dispatchLiveOrchestrationCommand(liveMode.value.origin, token, command), | ||
| 276 | mode: "live", | ||
| 277 | }); | ||
| 278 | yield* Console.log(output); | ||
| 279 | }), | ||
| 280 | ); | ||
| 281 | } | ||
| 282 | |||
| 283 | const offlineRuntimeLayer = ProjectCliRuntimeLive.pipe( | ||
| 284 | Layer.provide(Layer.succeed(ServerConfig, config)), | ||
| 285 | Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), | ||
| 286 | ); | ||
| 287 | |||
| 288 | return yield* Effect.gen(function* () { | ||
| 289 | const snapshot = yield* getOfflineSnapshot(); | ||
| 290 | const orchestrationEngine = yield* OrchestrationEngineService; | ||
| 291 | const output = yield* run({ | ||
| 292 | snapshot, | ||
| 293 | dispatch: (command) => orchestrationEngine.dispatch(command), | ||
| 294 | mode: "offline", | ||
| 295 | }); | ||
| 296 | yield* Console.log(output); | ||
| 297 | }).pipe(Effect.provide(offlineRuntimeLayer)); | ||
| 298 | }).pipe( | ||
| 299 | Effect.provide( | ||
| 300 | Layer.mergeAll(AuthControlPlaneRuntimeLive, WorkspacePathsLive).pipe( | ||
| 301 | Layer.provideMerge(FetchHttpClient.layer), | ||
| 302 | Layer.provide(Layer.succeed(ServerConfig, config)), | ||
| 303 | Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), | ||
| 304 | ), | ||
| 305 | ), | ||
| 306 | ); | ||
| 307 | }); | ||
| 308 | |||
| 309 | const projectAddCommand = Command.make("add", { | ||
| 310 | ...projectLocationFlags, | ||
| 311 | workspaceRoot: Argument.string("path").pipe( | ||
| 312 | Argument.withDescription("Workspace root to add as a project."), | ||
| 313 | ), | ||
| 314 | title: Flag.string("title").pipe(Flag.withDescription("Optional project title."), Flag.optional), | ||
| 315 | }).pipe( | ||
| 316 | Command.withDescription("Add a project."), | ||
| 317 | Command.withHandler((flags) => | ||
| 318 | runProjectMutation( | ||
| 319 | flags, | ||
| 320 | Effect.fn("projectAddMutation")(function* ({ | ||
| 321 | snapshot, | ||
| 322 | dispatch, | ||
| 323 | }: { | ||
| 324 | readonly snapshot: OrchestrationReadModel; | ||
| 325 | readonly dispatch: ( | ||
| 326 | command: ProjectCliDispatchCommand, | ||
| 327 | ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>; | ||
| 328 | }) { | ||
| 329 | const workspaceRoot = yield* normalizeWorkspaceRootForProjectCommand(flags.workspaceRoot); | ||
| 330 | const existingProject = snapshot.projects.find( | ||
| 331 | (project) => project.deletedAt === null && project.workspaceRoot === workspaceRoot, | ||
| 332 | ); | ||
| 333 | if (existingProject) { | ||
| 334 | return yield* Effect.fail( | ||
| 335 | new Error(`An active project already exists for '${workspaceRoot}'.`), | ||
| 336 | ); | ||
| 337 | } | ||
| 338 | |||
| 339 | const title = yield* resolveProjectTitle(workspaceRoot, Option.getOrUndefined(flags.title)); | ||
| 340 | const projectId = ProjectId.make(crypto.randomUUID()); | ||
| 341 | yield* dispatch({ | ||
| 342 | type: "project.create", | ||
| 343 | commandId: CommandId.make(crypto.randomUUID()), | ||
| 344 | projectId, | ||
| 345 | title, | ||
| 346 | workspaceRoot, | ||
| 347 | defaultModelSelection: getAutoBootstrapDefaultModelSelection(), | ||
| 348 | createdAt: new Date().toISOString(), | ||
| 349 | }); | ||
| 350 | return `Added project ${projectId} (${title}) at ${workspaceRoot}.`; | ||
| 351 | }), | ||
| 352 | ), | ||
| 353 | ), | ||
| 354 | ); | ||
| 355 | |||
| 356 | const projectRemoveCommand = Command.make("remove", { | ||
| 357 | ...projectLocationFlags, | ||
| 358 | project: Argument.string("project").pipe( | ||
| 359 | Argument.withDescription("Project id or workspace root to remove."), | ||
| 360 | ), | ||
| 361 | }).pipe( | ||
| 362 | Command.withDescription("Remove a project."), | ||
| 363 | Command.withHandler((flags) => | ||
| 364 | runProjectMutation( | ||
| 365 | flags, | ||
| 366 | Effect.fn("projectRemoveMutation")(function* ({ | ||
| 367 | snapshot, | ||
| 368 | dispatch, | ||
| 369 | }: { | ||
| 370 | readonly snapshot: OrchestrationReadModel; | ||
| 371 | readonly dispatch: ( | ||
| 372 | command: ProjectCliDispatchCommand, | ||
| 373 | ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>; | ||
| 374 | }) { | ||
| 375 | const project = yield* findActiveProjectTarget({ | ||
| 376 | snapshot, | ||
| 377 | identifier: flags.project, | ||
| 378 | }); | ||
| 379 | yield* dispatch({ | ||
| 380 | type: "project.delete", | ||
| 381 | commandId: CommandId.make(crypto.randomUUID()), | ||
| 382 | projectId: project.id, | ||
| 383 | }); | ||
| 384 | return `Removed project ${project.id} (${project.title}).`; | ||
| 385 | }), | ||
| 386 | ), | ||
| 387 | ), | ||
| 388 | ); | ||
| 389 | |||
| 390 | const projectRenameCommand = Command.make("rename", { | ||
| 391 | ...projectLocationFlags, | ||
| 392 | project: Argument.string("project").pipe( | ||
| 393 | Argument.withDescription("Project id or workspace root to rename."), | ||
| 394 | ), | ||
| 395 | title: Argument.string("title").pipe(Argument.withDescription("New project title.")), | ||
| 396 | }).pipe( | ||
| 397 | Command.withDescription("Rename a project."), | ||
| 398 | Command.withHandler((flags) => | ||
| 399 | runProjectMutation( | ||
| 400 | flags, | ||
| 401 | Effect.fn("projectRenameMutation")(function* ({ | ||
| 402 | snapshot, | ||
| 403 | dispatch, | ||
| 404 | }: { | ||
| 405 | readonly snapshot: OrchestrationReadModel; | ||
| 406 | readonly dispatch: ( | ||
| 407 | command: ProjectCliDispatchCommand, | ||
| 408 | ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>; | ||
| 409 | }) { | ||
| 410 | const project = yield* findActiveProjectTarget({ | ||
| 411 | snapshot, | ||
| 412 | identifier: flags.project, | ||
| 413 | }); | ||
| 414 | const nextTitle = yield* resolveProjectTitle(project.workspaceRoot, flags.title); | ||
| 415 | if (nextTitle === project.title) { | ||
| 416 | return `Project ${project.id} is already named ${nextTitle}.`; | ||
| 417 | } | ||
| 418 | |||
| 419 | yield* dispatch({ | ||
| 420 | type: "project.meta.update", | ||
| 421 | commandId: CommandId.make(crypto.randomUUID()), | ||
| 422 | projectId: project.id, | ||
| 423 | title: nextTitle, | ||
| 424 | }); | ||
| 425 | return `Renamed project ${project.id} to ${nextTitle}.`; | ||
| 426 | }), | ||
| 427 | ), | ||
| 428 | ), | ||
| 429 | ); | ||
| 430 | |||
| 431 | export const projectCommand = Command.make("project").pipe( | ||
| 432 | Command.withDescription("Manage projects."), | ||
| 433 | Command.withSubcommands([projectAddCommand, projectRemoveCommand, projectRenameCommand]), | ||
| 434 | ); | ||
apps/server/src/cli/server.ts created+36| ... | @@ -0,0 +1,36 @@ | ||
| 1 | import { Effect } from "effect"; | ||
| 2 | import { Command, GlobalFlag } from "effect/unstable/cli"; | ||
| 3 | |||
| 4 | import { ServerConfig, type StartupPresentation } from "../config.ts"; | ||
| 5 | import { runServer } from "../server.ts"; | ||
| 6 | import { type CliServerFlags, resolveServerConfig, sharedServerCommandFlags } from "./config.ts"; | ||
| 7 | |||
| 8 | export const runServerCommand = ( | ||
| 9 | flags: CliServerFlags, | ||
| 10 | options?: { | ||
| 11 | readonly startupPresentation?: StartupPresentation; | ||
| 12 | readonly forceAutoBootstrapProjectFromCwd?: boolean; | ||
| 13 | }, | ||
| 14 | ) => | ||
| 15 | Effect.gen(function* () { | ||
| 16 | const logLevel = yield* GlobalFlag.LogLevel; | ||
| 17 | const config = yield* resolveServerConfig(flags, logLevel, options); | ||
| 18 | return yield* runServer.pipe(Effect.provideService(ServerConfig, config)); | ||
| 19 | }); | ||
| 20 | |||
| 21 | export const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe( | ||
| 22 | Command.withDescription("Run the T3 Code server."), | ||
| 23 | Command.withHandler((flags) => runServerCommand(flags)), | ||
| 24 | ); | ||
| 25 | |||
| 26 | export const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe( | ||
| 27 | Command.withDescription( | ||
| 28 | "Run the T3 Code server without opening a browser and print headless pairing details.", | ||
| 29 | ), | ||
| 30 | Command.withHandler((flags) => | ||
| 31 | runServerCommand(flags, { | ||
| 32 | startupPresentation: "headless", | ||
| 33 | forceAutoBootstrapProjectFromCwd: false, | ||
| 34 | }), | ||
| 35 | ), | ||
| 36 | ); | ||