diff --git a/apps/server/src/cli.test.ts b/apps/server/src/bin.test.ts similarity index 99% rename from apps/server/src/cli.test.ts rename to apps/server/src/bin.test.ts index 3740d1f868e..fa4572cc909 100644 --- a/apps/server/src/cli.test.ts +++ b/apps/server/src/bin.test.ts @@ -15,7 +15,7 @@ import * as CliError from "effect/unstable/cli/CliError"; import * as TestConsole from "effect/testing/TestConsole"; import { Command } from "effect/unstable/cli"; -import { cli } from "./cli.ts"; +import { cli } from "./bin.ts"; import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; @@ -148,7 +148,7 @@ const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Ef ); }); -it.layer(NodeServices.layer)("cli log-level parsing", (it) => { +it.layer(NodeServices.layer)("bin cli parsing", (it) => { it.effect("accepts the built-in lowercase log-level flag values", () => runCliWithRuntime(["--log-level", "debug", "--version"]), ); diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 874898d86e9..f106d25e5eb 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -5,13 +5,24 @@ import * as Layer from "effect/Layer"; import { Command } from "effect/unstable/cli"; import { NetService } from "@t3tools/shared/Net"; -import { cli } from "./cli.ts"; import packageJson from "../package.json" with { type: "json" }; +import { authCommand } from "./cli/auth.ts"; +import { sharedServerCommandFlags } from "./cli/config.ts"; +import { projectCommand } from "./cli/project.ts"; +import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); -Command.run(cli, { version: packageJson.version }).pipe( - Effect.scoped, - Effect.provide(CliRuntimeLayer), - NodeRuntime.runMain, +export const cli = Command.make("t3", { ...sharedServerCommandFlags }).pipe( + Command.withDescription("Run the T3 Code server."), + Command.withHandler((flags) => runServerCommand(flags)), + Command.withSubcommands([startCommand, serveCommand, authCommand, projectCommand]), ); + +if (import.meta.main) { + Command.run(cli, { version: packageJson.version }).pipe( + Effect.scoped, + Effect.provide(CliRuntimeLayer), + NodeRuntime.runMain, + ); +} diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts deleted file mode 100644 index bcf8bee861c..00000000000 --- a/apps/server/src/cli.ts +++ /dev/null @@ -1,1180 +0,0 @@ -import { NetService } from "@t3tools/shared/Net"; -import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; -import { - AuthSessionId, - CommandId, - OrchestrationReadModel, - ProjectId, - type ClientOrchestrationCommand, -} from "@t3tools/contracts"; -import { - Config, - Console, - Duration, - Effect, - Exit, - FileSystem, - Layer, - LogLevel, - Option, - Path, - References, - Schema, - SchemaIssue, - SchemaTransformation, -} from "effect"; -import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; -import { - FetchHttpClient, - HttpClient, - HttpClientRequest, - HttpClientResponse, -} from "effect/unstable/http"; - -import { - DEFAULT_PORT, - deriveServerPaths, - ensureServerDirectories, - resolveStaticDir, - ServerConfig, - RuntimeMode, - type ServerConfigShape, - type StartupPresentation, -} from "./config.ts"; -import { readBootstrapEnvelope } from "./bootstrap.ts"; -import { expandHomePath, resolveBaseDir } from "./os-jank.ts"; -import { runServer } from "./server.ts"; -import { AuthControlPlaneRuntimeLive } from "./auth/Layers/AuthControlPlane.ts"; -import { - formatIssuedPairingCredential, - formatIssuedSession, - formatPairingCredentialList, - formatSessionList, -} from "./cliAuthFormat.ts"; -import { AuthControlPlane } from "./auth/Services/AuthControlPlane.ts"; -import type { AuthControlPlaneShape } from "./auth/Services/AuthControlPlane.ts"; -import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts"; -import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts"; -import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; -import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; -import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts"; -import { getAutoBootstrapDefaultModelSelection } from "./serverRuntimeStartup.ts"; -import { - clearPersistedServerRuntimeState, - readPersistedServerRuntimeState, -} from "./serverRuntimeState.ts"; -import { WorkspacePaths } from "./workspace/Services/WorkspacePaths.ts"; -import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; - -const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); - -const BootstrapEnvelopeSchema = Schema.Struct({ - mode: Schema.optional(RuntimeMode), - port: Schema.optional(PortSchema), - host: Schema.optional(Schema.String), - t3Home: Schema.optional(Schema.String), - devUrl: Schema.optional(Schema.URLFromString), - noBrowser: Schema.optional(Schema.Boolean), - desktopBootstrapToken: Schema.optional(Schema.String), - autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), - logWebSocketEvents: Schema.optional(Schema.Boolean), - tailscaleServeEnabled: Schema.optional(Schema.Boolean), - tailscaleServePort: Schema.optional(PortSchema), - otlpTracesUrl: Schema.optional(Schema.String), - otlpMetricsUrl: Schema.optional(Schema.String), -}); - -const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe( - Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), - Flag.optional, -); -const portFlag = Flag.integer("port").pipe( - Flag.withSchema(PortSchema), - Flag.withDescription("Port for the HTTP/WebSocket server."), - Flag.optional, -); -const hostFlag = Flag.string("host").pipe( - Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."), - Flag.optional, -); -const baseDirFlag = Flag.string("base-dir").pipe( - Flag.withDescription("Base directory path (equivalent to T3CODE_HOME)."), - Flag.optional, -); -const devUrlFlag = Flag.string("dev-url").pipe( - Flag.withSchema(Schema.URLFromString), - Flag.withDescription("Dev web URL to proxy/redirect to (equivalent to VITE_DEV_SERVER_URL)."), - Flag.optional, -); -const noBrowserFlag = Flag.boolean("no-browser").pipe( - Flag.withDescription("Disable automatic browser opening."), - Flag.optional, -); -const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe( - Flag.withSchema(Schema.Int), - Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."), - Flag.optional, -); -const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe( - Flag.withDescription( - "Create a project for the current working directory on startup when missing.", - ), - Flag.optional, -); -const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe( - Flag.withDescription( - "Emit server-side logs for outbound WebSocket push traffic (equivalent to T3CODE_LOG_WS_EVENTS).", - ), - Flag.withAlias("log-ws-events"), - Flag.optional, -); -const tailscaleServeFlag = Flag.boolean("tailscale-serve").pipe( - Flag.withDescription( - "Configure Tailscale Serve to expose this backend over HTTPS on the Tailnet.", - ), - Flag.optional, -); -const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe( - Flag.withSchema(PortSchema), - Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."), - Flag.optional, -); - -const EnvServerConfig = Config.all({ - logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")), - traceMinLevel: Config.logLevel("T3CODE_TRACE_MIN_LEVEL").pipe(Config.withDefault("Info")), - traceTimingEnabled: Config.boolean("T3CODE_TRACE_TIMING_ENABLED").pipe(Config.withDefault(true)), - traceFile: Config.string("T3CODE_TRACE_FILE").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), - traceMaxBytes: Config.int("T3CODE_TRACE_MAX_BYTES").pipe(Config.withDefault(10 * 1024 * 1024)), - traceMaxFiles: Config.int("T3CODE_TRACE_MAX_FILES").pipe(Config.withDefault(10)), - traceBatchWindowMs: Config.int("T3CODE_TRACE_BATCH_WINDOW_MS").pipe(Config.withDefault(200)), - otlpTracesUrl: Config.string("T3CODE_OTLP_TRACES_URL").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), - otlpMetricsUrl: Config.string("T3CODE_OTLP_METRICS_URL").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), - otlpExportIntervalMs: Config.int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( - Config.withDefault(10_000), - ), - otlpServiceName: Config.string("T3CODE_OTLP_SERVICE_NAME").pipe(Config.withDefault("t3-server")), - mode: Config.schema(RuntimeMode, "T3CODE_MODE").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), - port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), - host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), - t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)), - devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)), - noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), - bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), - autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), - logWebSocketEvents: Config.boolean("T3CODE_LOG_WS_EVENTS").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), - tailscaleServeEnabled: Config.boolean("T3CODE_TAILSCALE_SERVE").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), - tailscaleServePort: Config.port("T3CODE_TAILSCALE_SERVE_PORT").pipe( - Config.option, - Config.map(Option.getOrUndefined), - ), -}); - -interface CliServerFlags { - readonly mode: Option.Option; - readonly port: Option.Option; - readonly host: Option.Option; - readonly baseDir: Option.Option; - readonly cwd: Option.Option; - readonly devUrl: Option.Option; - readonly noBrowser: Option.Option; - readonly bootstrapFd: Option.Option; - readonly autoBootstrapProjectFromCwd: Option.Option; - readonly logWebSocketEvents: Option.Option; - readonly tailscaleServeEnabled: Option.Option; - readonly tailscaleServePort: Option.Option; -} - -interface CliAuthLocationFlags { - readonly baseDir: Option.Option; - readonly devUrl?: Option.Option; -} - -const resolveOptionPrecedence = ( - ...values: ReadonlyArray> -): Option.Option => Option.firstSomeOf(values); - -const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: string) { - const fs = yield* FileSystem.FileSystem; - const exists = yield* fs.exists(settingsPath).pipe(Effect.orElseSucceed(() => false)); - if (!exists) { - return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; - } - - const raw = yield* fs.readFileString(settingsPath).pipe(Effect.orElseSucceed(() => "")); - return parsePersistedServerObservabilitySettings(raw); -}); - -export const resolveServerConfig = ( - flags: CliServerFlags, - cliLogLevel: Option.Option, - options?: { - readonly startupPresentation?: StartupPresentation; - readonly forceAutoBootstrapProjectFromCwd?: boolean; - }, -) => - Effect.gen(function* () { - const { findAvailablePort } = yield* NetService; - const path = yield* Path.Path; - const fs = yield* FileSystem.FileSystem; - const env = yield* EnvServerConfig; - const normalizedFlags = { - mode: flags.mode ?? Option.none(), - port: flags.port ?? Option.none(), - host: flags.host ?? Option.none(), - baseDir: flags.baseDir ?? Option.none(), - cwd: flags.cwd ?? Option.none(), - devUrl: flags.devUrl ?? Option.none(), - noBrowser: flags.noBrowser ?? Option.none(), - bootstrapFd: flags.bootstrapFd ?? Option.none(), - autoBootstrapProjectFromCwd: flags.autoBootstrapProjectFromCwd ?? Option.none(), - logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(), - tailscaleServeEnabled: flags.tailscaleServeEnabled ?? Option.none(), - tailscaleServePort: flags.tailscaleServePort ?? Option.none(), - } satisfies CliServerFlags; - const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd; - const bootstrapEnvelope = - bootstrapFd !== undefined - ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd) - : Option.none(); - const bootstrap = Option.getOrUndefined(bootstrapEnvelope); - - const mode: RuntimeMode = Option.getOrElse( - resolveOptionPrecedence( - normalizedFlags.mode, - Option.fromUndefinedOr(env.mode), - Option.fromUndefinedOr(bootstrap?.mode), - ), - () => "web", - ); - - const port = yield* Option.match( - resolveOptionPrecedence( - normalizedFlags.port, - Option.fromUndefinedOr(env.port), - Option.fromUndefinedOr(bootstrap?.port), - ), - { - onSome: (value) => Effect.succeed(value), - onNone: () => { - if (mode === "desktop") { - return Effect.succeed(DEFAULT_PORT); - } - return findAvailablePort(DEFAULT_PORT); - }, - }, - ); - const devUrl = Option.getOrElse( - resolveOptionPrecedence( - normalizedFlags.devUrl, - Option.fromUndefinedOr(env.devUrl), - Option.fromUndefinedOr(bootstrap?.devUrl), - ), - () => undefined, - ); - const baseDir = yield* resolveBaseDir( - Option.getOrUndefined( - resolveOptionPrecedence( - normalizedFlags.baseDir, - Option.fromUndefinedOr(env.t3Home), - Option.fromUndefinedOr(bootstrap?.t3Home), - ), - ), - ); - const rawCwd = Option.getOrElse(normalizedFlags.cwd, () => process.cwd()); - const cwd = path.resolve(yield* expandHomePath(rawCwd.trim())); - yield* fs.makeDirectory(cwd, { recursive: true }); - const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); - yield* ensureServerDirectories(derivedPaths); - const persistedObservabilitySettings = yield* loadPersistedObservabilitySettings( - derivedPaths.settingsPath, - ); - const serverTracePath = env.traceFile ?? derivedPaths.serverTracePath; - yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true }); - const startupPresentation = options?.startupPresentation ?? "browser"; - const isHeadlessStartup = startupPresentation === "headless"; - const noBrowser = Option.getOrElse( - resolveOptionPrecedence( - isHeadlessStartup ? Option.some(true) : Option.none(), - normalizedFlags.noBrowser, - Option.fromUndefinedOr(env.noBrowser), - Option.fromUndefinedOr(bootstrap?.noBrowser), - ), - () => mode === "desktop", - ); - const desktopBootstrapToken = bootstrap?.desktopBootstrapToken; - const autoBootstrapProjectFromCwd = Option.getOrElse( - resolveOptionPrecedence( - Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd), - isHeadlessStartup ? Option.some(false) : Option.none(), - normalizedFlags.autoBootstrapProjectFromCwd, - Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd), - Option.fromUndefinedOr(bootstrap?.autoBootstrapProjectFromCwd), - ), - () => mode === "web", - ); - const logWebSocketEvents = Option.getOrElse( - resolveOptionPrecedence( - normalizedFlags.logWebSocketEvents, - Option.fromUndefinedOr(env.logWebSocketEvents), - Option.fromUndefinedOr(bootstrap?.logWebSocketEvents), - ), - () => Boolean(devUrl), - ); - const tailscaleServeEnabled = Option.getOrElse( - resolveOptionPrecedence( - normalizedFlags.tailscaleServeEnabled, - Option.fromUndefinedOr(env.tailscaleServeEnabled), - Option.fromUndefinedOr(bootstrap?.tailscaleServeEnabled), - ), - () => false, - ); - const tailscaleServePort = Option.getOrElse( - resolveOptionPrecedence( - normalizedFlags.tailscaleServePort, - Option.fromUndefinedOr(env.tailscaleServePort), - Option.fromUndefinedOr(bootstrap?.tailscaleServePort), - ), - () => 443, - ); - const staticDir = devUrl ? undefined : yield* resolveStaticDir(); - const host = Option.getOrElse( - resolveOptionPrecedence( - normalizedFlags.host, - Option.fromUndefinedOr(env.host), - Option.fromUndefinedOr(bootstrap?.host), - ), - () => (mode === "desktop" ? "127.0.0.1" : undefined), - ); - const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel); - - const config: ServerConfigShape = { - logLevel, - traceMinLevel: env.traceMinLevel, - traceTimingEnabled: env.traceTimingEnabled, - traceBatchWindowMs: env.traceBatchWindowMs, - traceMaxBytes: env.traceMaxBytes, - traceMaxFiles: env.traceMaxFiles, - otlpTracesUrl: - env.otlpTracesUrl ?? - bootstrap?.otlpTracesUrl ?? - persistedObservabilitySettings.otlpTracesUrl, - otlpMetricsUrl: - env.otlpMetricsUrl ?? - bootstrap?.otlpMetricsUrl ?? - persistedObservabilitySettings.otlpMetricsUrl, - otlpExportIntervalMs: env.otlpExportIntervalMs, - otlpServiceName: env.otlpServiceName, - mode, - port, - cwd, - baseDir, - ...derivedPaths, - serverTracePath, - host, - staticDir, - devUrl, - noBrowser, - startupPresentation, - desktopBootstrapToken, - autoBootstrapProjectFromCwd, - logWebSocketEvents, - tailscaleServeEnabled, - tailscaleServePort, - }; - - return config; - }); - -const resolveCliAuthConfig = ( - flags: CliAuthLocationFlags, - cliLogLevel: Option.Option, -) => - resolveServerConfig( - { - mode: Option.none(), - port: Option.none(), - host: Option.none(), - baseDir: flags.baseDir, - cwd: Option.none(), - devUrl: flags.devUrl ?? Option.none(), - noBrowser: Option.none(), - bootstrapFd: Option.none(), - autoBootstrapProjectFromCwd: Option.none(), - logWebSocketEvents: Option.none(), - tailscaleServeEnabled: Option.none(), - tailscaleServePort: Option.none(), - }, - cliLogLevel, - ); - -const DurationShorthandPattern = /^(?\d+)(?ms|s|m|h|d|w)$/i; - -const parseDurationInput = (value: string): Duration.Duration | null => { - const trimmed = value.trim(); - if (trimmed.length === 0) return null; - - const shorthand = DurationShorthandPattern.exec(trimmed); - const normalizedInput = shorthand?.groups - ? (() => { - const amountText = shorthand.groups.value; - const unitText = shorthand.groups.unit; - if (typeof amountText !== "string" || typeof unitText !== "string") { - return null; - } - - const amount = Number.parseInt(amountText, 10); - if (!Number.isFinite(amount)) return null; - - switch (unitText.toLowerCase()) { - case "ms": - return `${amount} millis`; - case "s": - return `${amount} seconds`; - case "m": - return `${amount} minutes`; - case "h": - return `${amount} hours`; - case "d": - return `${amount} days`; - case "w": - return `${amount} weeks`; - default: - return null; - } - })() - : (trimmed as Duration.Input); - - if (normalizedInput === null) return null; - - const decoded = Duration.fromInput(normalizedInput as Duration.Input); - return Option.isSome(decoded) ? decoded.value : null; -}; - -const DurationFromString = Schema.String.pipe( - Schema.decodeTo( - Schema.Duration, - SchemaTransformation.transformOrFail({ - decode: (value) => { - const duration = parseDurationInput(value); - if (duration !== null) { - return Effect.succeed(duration); - } - return Effect.fail( - new SchemaIssue.InvalidValue(Option.some(value), { - message: "Invalid duration. Use values like 5m, 1h, 30d, or 15 minutes.", - }), - ); - }, - encode: (duration) => Effect.succeed(Duration.format(duration)), - }), - ), -); - -const runWithAuthControlPlane = ( - flags: CliAuthLocationFlags, - run: (authControlPlane: AuthControlPlaneShape) => Effect.Effect, - options?: { - readonly quietLogs?: boolean; - }, -) => - Effect.gen(function* () { - const logLevel = yield* GlobalFlag.LogLevel; - const config = yield* resolveCliAuthConfig(flags, logLevel); - const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel; - return yield* Effect.gen(function* () { - const authControlPlane = yield* AuthControlPlane; - return yield* run(authControlPlane); - }).pipe( - Effect.provide( - Layer.mergeAll(AuthControlPlaneRuntimeLive).pipe( - Layer.provide(Layer.succeed(ServerConfig, config)), - Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), - ), - ), - ); - }); - -type ProjectMutationTarget = { - readonly id: ProjectId; - readonly title: string; - readonly workspaceRoot: string; -}; - -type ProjectCommandExecutionMode = "live" | "offline"; -type ProjectCliDispatchCommand = Extract< - ClientOrchestrationCommand, - { type: "project.create" | "project.meta.update" | "project.delete" } ->; - -const ProjectCliRuntimeLive = Layer.mergeAll( - WorkspacePathsLive, - OrchestrationLayerLive.pipe( - Layer.provideMerge(RepositoryIdentityResolverLive), - Layer.provideMerge(SqlitePersistenceLayerLive), - ), -); - -const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1); -const OrchestrationHttpErrorResponse = Schema.Struct({ - error: Schema.String, -}); - -const withProjectCliSessionToken = ( - authControlPlane: AuthControlPlaneShape, - run: (token: string) => Effect.Effect, -) => - Effect.acquireUseRelease( - authControlPlane.issueSession({ - role: "owner", - label: "t3 project cli", - }), - (issued) => run(issued.token), - (issued) => authControlPlane.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })), - ); - -const withProjectCliLiveServerTimeout = (effect: Effect.Effect) => - effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT)); - -const runLiveServerRequest = ( - request: HttpClientRequest.HttpClientRequest, - handle: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect, -) => - Effect.gen(function* () { - const httpClient = yield* HttpClient.HttpClient; - const response = yield* httpClient.execute(request); - return yield* handle(response); - }).pipe(withProjectCliLiveServerTimeout); - -const decodeOrchestrationReadModelResponse = (response: HttpClientResponse.HttpClientResponse) => - HttpClientResponse.schemaBodyJson(OrchestrationReadModel)(response); - -const readErrorMessageFromResponse = (response: HttpClientResponse.HttpClientResponse) => - HttpClientResponse.schemaBodyJson(OrchestrationHttpErrorResponse)(response).pipe( - Effect.map((body) => body.error), - Effect.catch(() => Effect.succeed(null)), - Effect.map((body) => { - if (typeof body === "string" && body.trim().length > 0) { - return body; - } - return `Server request failed with status ${response.status}.`; - }), - ); - -const normalizeWorkspaceRootForProjectCommand = Effect.fn( - "normalizeWorkspaceRootForProjectCommand", -)(function* (workspaceRoot: string) { - const workspacePaths = yield* WorkspacePaths; - return yield* workspacePaths.normalizeWorkspaceRoot(workspaceRoot); -}); - -const resolveProjectTitle = Effect.fn("resolveProjectTitle")(function* ( - workspaceRoot: string, - explicitTitle?: string, -) { - if (explicitTitle !== undefined) { - const trimmed = explicitTitle.trim(); - if (trimmed.length > 0) { - return trimmed; - } - return yield* Effect.fail(new Error("Project title cannot be empty.")); - } - - const path = yield* Path.Path; - const basename = path.basename(workspaceRoot).trim(); - return basename.length > 0 ? basename : "project"; -}); - -const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (input: { - readonly snapshot: OrchestrationReadModel; - readonly identifier: string; -}) { - const trimmedIdentifier = input.identifier.trim(); - if (trimmedIdentifier.length === 0) { - return yield* Effect.fail(new Error("Project identifier cannot be empty.")); - } - - const activeProjects = input.snapshot.projects.filter((project) => project.deletedAt === null); - const exactIdMatch = activeProjects.find((project) => project.id === trimmedIdentifier); - if (exactIdMatch) { - return { - id: exactIdMatch.id, - title: exactIdMatch.title, - workspaceRoot: exactIdMatch.workspaceRoot, - } satisfies ProjectMutationTarget; - } - - const normalizedWorkspaceRootResult = yield* Effect.exit( - normalizeWorkspaceRootForProjectCommand(trimmedIdentifier), - ); - const normalizedWorkspaceRoot = Exit.isSuccess(normalizedWorkspaceRootResult) - ? normalizedWorkspaceRootResult.value - : null; - - const exactWorkspaceMatch = - normalizedWorkspaceRoot === null - ? undefined - : activeProjects.find((project) => project.workspaceRoot === normalizedWorkspaceRoot); - - const resolved = exactWorkspaceMatch; - if (!resolved) { - return yield* Effect.fail(new Error(`No active project found for '${trimmedIdentifier}'.`)); - } - - return { - id: resolved.id, - title: resolved.title, - workspaceRoot: resolved.workspaceRoot, - } satisfies ProjectMutationTarget; -}); - -const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) => - runLiveServerRequest( - HttpClientRequest.get(`${origin}/api/orchestration/snapshot`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.bearerToken(bearerToken), - ), - HttpClientResponse.matchStatus({ - "2xx": decodeOrchestrationReadModelResponse, - orElse: (response) => - readErrorMessageFromResponse(response).pipe( - Effect.flatMap((message) => Effect.fail(new Error(message))), - ), - }), - ); - -const dispatchLiveOrchestrationCommand = ( - origin: string, - bearerToken: string, - command: ProjectCliDispatchCommand, -) => - HttpClientRequest.post(`${origin}/api/orchestration/dispatch`).pipe( - HttpClientRequest.acceptJson, - HttpClientRequest.bearerToken(bearerToken), - HttpClientRequest.bodyJson(command), - Effect.flatMap((request) => - runLiveServerRequest( - request, - HttpClientResponse.matchStatus({ - "2xx": () => Effect.void, - orElse: (response) => - readErrorMessageFromResponse(response).pipe( - Effect.flatMap((message) => Effect.fail(new Error(message))), - ), - }), - ), - ), - ); - -const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () { - const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; - return yield* projectionSnapshotQuery.getSnapshot(); -}); - -const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecutionMode")( - function* (authControlPlane: AuthControlPlaneShape, config: ServerConfigShape) { - const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); - if (Option.isNone(runtimeState)) { - return Option.none<{ readonly origin: string }>(); - } - - const attempt = withProjectCliSessionToken(authControlPlane, (token) => - fetchLiveOrchestrationSnapshot(runtimeState.value.origin, token).pipe( - Effect.as({ - origin: runtimeState.value.origin, - }), - ), - ); - - const attempted = yield* Effect.exit(attempt); - if (Exit.isSuccess(attempted)) { - return Option.some(attempted.value); - } - - yield* clearPersistedServerRuntimeState(config.serverRuntimeStatePath); - return Option.none<{ readonly origin: string }>(); - }, -); - -const runProjectMutation = Effect.fn("runProjectMutation")(function* ( - flags: CliAuthLocationFlags, - run: (input: { - readonly snapshot: OrchestrationReadModel; - readonly dispatch: ( - command: ProjectCliDispatchCommand, - ) => Effect.Effect; - readonly mode: ProjectCommandExecutionMode; - }) => Effect.Effect< - string, - Error, - FileSystem.FileSystem | HttpClient.HttpClient | Path.Path | WorkspacePaths - >, -) { - const logLevel = yield* GlobalFlag.LogLevel; - const config = yield* resolveCliAuthConfig(flags, logLevel); - const minimumLogLevel = config.logLevel; - - return yield* Effect.gen(function* () { - const authControlPlane = yield* AuthControlPlane; - const liveMode = yield* tryResolveLiveProjectExecutionMode(authControlPlane, config); - - if (Option.isSome(liveMode)) { - return yield* withProjectCliSessionToken(authControlPlane, (token) => - Effect.gen(function* () { - const snapshot = yield* fetchLiveOrchestrationSnapshot(liveMode.value.origin, token); - const output = yield* run({ - snapshot, - dispatch: (command) => - dispatchLiveOrchestrationCommand(liveMode.value.origin, token, command), - mode: "live", - }); - yield* Console.log(output); - }), - ); - } - - const offlineRuntimeLayer = ProjectCliRuntimeLive.pipe( - Layer.provide(Layer.succeed(ServerConfig, config)), - Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), - ); - - return yield* Effect.gen(function* () { - const snapshot = yield* getOfflineSnapshot(); - const orchestrationEngine = yield* OrchestrationEngineService; - const output = yield* run({ - snapshot, - dispatch: (command) => orchestrationEngine.dispatch(command), - mode: "offline", - }); - yield* Console.log(output); - }).pipe(Effect.provide(offlineRuntimeLayer)); - }).pipe( - Effect.provide( - Layer.mergeAll(AuthControlPlaneRuntimeLive, WorkspacePathsLive).pipe( - Layer.provideMerge(FetchHttpClient.layer), - Layer.provide(Layer.succeed(ServerConfig, config)), - Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), - ), - ), - ); -}); - -const sharedServerLocationFlags = { - baseDir: baseDirFlag, - devUrl: devUrlFlag, -} as const; - -const projectLocationFlags = { - baseDir: baseDirFlag, -} as const; - -const sharedServerCommandFlags = { - mode: modeFlag, - port: portFlag, - host: hostFlag, - baseDir: baseDirFlag, - cwd: Argument.string("cwd").pipe( - Argument.withDescription( - "Working directory for provider sessions (defaults to the current directory).", - ), - Argument.optional, - ), - devUrl: devUrlFlag, - noBrowser: noBrowserFlag, - bootstrapFd: bootstrapFdFlag, - autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, - logWebSocketEvents: logWebSocketEventsFlag, - tailscaleServeEnabled: tailscaleServeFlag, - tailscaleServePort: tailscaleServePortFlag, -} as const; - -const authLocationFlags = sharedServerLocationFlags; - -const ttlFlag = Flag.string("ttl").pipe( - Flag.withSchema(DurationFromString), - Flag.withDescription("TTL, for example `5m`, `1h`, `30d`, or `15 minutes`."), - Flag.optional, -); - -const jsonFlag = Flag.boolean("json").pipe( - Flag.withDescription("Emit JSON instead of human-readable output."), - Flag.withDefault(false), -); - -const sessionRoleFlag = Flag.choice("role", ["owner", "client"]).pipe( - Flag.withDescription("Role for the issued bearer session."), - Flag.withDefault("owner"), -); - -const labelFlag = Flag.string("label").pipe( - Flag.withDescription("Optional human-readable label."), - Flag.optional, -); - -const subjectFlag = Flag.string("subject").pipe( - Flag.withDescription("Optional session subject."), - Flag.optional, -); - -const baseUrlFlag = Flag.string("base-url").pipe( - Flag.withDescription("Optional public base URL used to print a ready `/pair#token=...` link."), - Flag.optional, -); - -const tokenOnlyFlag = Flag.boolean("token-only").pipe( - Flag.withDescription("Print only the issued bearer token."), - Flag.withDefault(false), -); - -const pairingCreateCommand = Command.make("create", { - ...authLocationFlags, - ttl: ttlFlag, - label: labelFlag, - baseUrl: baseUrlFlag, - json: jsonFlag, -}).pipe( - Command.withDescription("Issue a new client pairing token."), - Command.withHandler((flags) => - runWithAuthControlPlane( - flags, - (authControlPlane) => - Effect.gen(function* () { - const issued = yield* authControlPlane.createPairingLink({ - role: "client", - subject: "one-time-token", - ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), - ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), - }); - const output = formatIssuedPairingCredential(issued, { - json: flags.json, - ...(Option.isSome(flags.baseUrl) ? { baseUrl: flags.baseUrl.value } : {}), - }); - yield* Console.log(output); - }), - { - quietLogs: flags.json, - }, - ), - ), -); - -const pairingListCommand = Command.make("list", { - ...authLocationFlags, - json: jsonFlag, -}).pipe( - Command.withDescription("List active client pairing tokens without revealing their secrets."), - Command.withHandler((flags) => - runWithAuthControlPlane( - flags, - (authControlPlane) => - Effect.gen(function* () { - const pairingLinks = yield* authControlPlane.listPairingLinks({ role: "client" }); - yield* Console.log(formatPairingCredentialList(pairingLinks, { json: flags.json })); - }), - { - quietLogs: flags.json, - }, - ), - ), -); - -const pairingRevokeCommand = Command.make("revoke", { - ...authLocationFlags, - id: Argument.string("id").pipe(Argument.withDescription("Pairing credential id to revoke.")), -}).pipe( - Command.withDescription("Revoke an active client pairing token."), - Command.withHandler((flags) => - runWithAuthControlPlane(flags, (authControlPlane) => - Effect.gen(function* () { - const revoked = yield* authControlPlane.revokePairingLink(flags.id); - yield* Console.log( - revoked - ? `Revoked pairing credential ${flags.id}.\n` - : `No active pairing credential found for ${flags.id}.\n`, - ); - }), - ), - ), -); - -const pairingCommand = Command.make("pairing").pipe( - Command.withDescription("Manage one-time client pairing tokens."), - Command.withSubcommands([pairingCreateCommand, pairingListCommand, pairingRevokeCommand]), -); - -const sessionIssueCommand = Command.make("issue", { - ...authLocationFlags, - ttl: ttlFlag, - role: sessionRoleFlag, - label: labelFlag, - subject: subjectFlag, - tokenOnly: tokenOnlyFlag, - json: jsonFlag, -}).pipe( - Command.withDescription("Issue a bearer session token for headless or remote clients."), - Command.withHandler((flags) => - runWithAuthControlPlane( - flags, - (authControlPlane) => - Effect.gen(function* () { - const issued = yield* authControlPlane.issueSession({ - role: flags.role, - ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), - ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), - ...(Option.isSome(flags.subject) ? { subject: flags.subject.value } : {}), - }); - yield* Console.log( - formatIssuedSession(issued, { - json: flags.json, - tokenOnly: flags.tokenOnly, - }), - ); - }), - { - quietLogs: flags.json || flags.tokenOnly, - }, - ), - ), -); - -const sessionListCommand = Command.make("list", { - ...authLocationFlags, - json: jsonFlag, -}).pipe( - Command.withDescription("List active sessions without revealing bearer tokens."), - Command.withHandler((flags) => - runWithAuthControlPlane( - flags, - (authControlPlane) => - Effect.gen(function* () { - const sessions = yield* authControlPlane.listSessions(); - yield* Console.log(formatSessionList(sessions, { json: flags.json })); - }), - { - quietLogs: flags.json, - }, - ), - ), -); - -const sessionRevokeCommand = Command.make("revoke", { - ...authLocationFlags, - sessionId: Argument.string("session-id").pipe( - Argument.withDescription("Session id to revoke."), - Argument.withSchema(AuthSessionId), - ), -}).pipe( - Command.withDescription("Revoke an active session."), - Command.withHandler((flags) => - runWithAuthControlPlane(flags, (authControlPlane) => - Effect.gen(function* () { - const revoked = yield* authControlPlane.revokeSession(flags.sessionId); - yield* Console.log( - revoked - ? `Revoked session ${flags.sessionId}.\n` - : `No active session found for ${flags.sessionId}.\n`, - ); - }), - ), - ), -); - -const sessionCommand = Command.make("session").pipe( - Command.withDescription("Manage bearer sessions."), - Command.withSubcommands([sessionIssueCommand, sessionListCommand, sessionRevokeCommand]), -); - -const authCommand = Command.make("auth").pipe( - Command.withDescription("Manage the local auth control plane for headless deployments."), - Command.withSubcommands([pairingCommand, sessionCommand]), -); - -const projectAddCommand = Command.make("add", { - ...projectLocationFlags, - workspaceRoot: Argument.string("path").pipe( - Argument.withDescription("Workspace root to add as a project."), - ), - title: Flag.string("title").pipe(Flag.withDescription("Optional project title."), Flag.optional), -}).pipe( - Command.withDescription("Add a project."), - Command.withHandler((flags) => - runProjectMutation( - flags, - Effect.fn("projectAddMutation")(function* ({ - snapshot, - dispatch, - }: { - readonly snapshot: OrchestrationReadModel; - readonly dispatch: ( - command: ProjectCliDispatchCommand, - ) => Effect.Effect; - }) { - const workspaceRoot = yield* normalizeWorkspaceRootForProjectCommand(flags.workspaceRoot); - const existingProject = snapshot.projects.find( - (project) => project.deletedAt === null && project.workspaceRoot === workspaceRoot, - ); - if (existingProject) { - return yield* Effect.fail( - new Error(`An active project already exists for '${workspaceRoot}'.`), - ); - } - - const title = yield* resolveProjectTitle(workspaceRoot, Option.getOrUndefined(flags.title)); - const projectId = ProjectId.make(crypto.randomUUID()); - yield* dispatch({ - type: "project.create", - commandId: CommandId.make(crypto.randomUUID()), - projectId, - title, - workspaceRoot, - defaultModelSelection: getAutoBootstrapDefaultModelSelection(), - createdAt: new Date().toISOString(), - }); - return `Added project ${projectId} (${title}) at ${workspaceRoot}.`; - }), - ), - ), -); - -const projectRemoveCommand = Command.make("remove", { - ...projectLocationFlags, - project: Argument.string("project").pipe( - Argument.withDescription("Project id or workspace root to remove."), - ), -}).pipe( - Command.withDescription("Remove a project."), - Command.withHandler((flags) => - runProjectMutation( - flags, - Effect.fn("projectRemoveMutation")(function* ({ - snapshot, - dispatch, - }: { - readonly snapshot: OrchestrationReadModel; - readonly dispatch: ( - command: ProjectCliDispatchCommand, - ) => Effect.Effect; - }) { - const project = yield* findActiveProjectTarget({ - snapshot, - identifier: flags.project, - }); - yield* dispatch({ - type: "project.delete", - commandId: CommandId.make(crypto.randomUUID()), - projectId: project.id, - }); - return `Removed project ${project.id} (${project.title}).`; - }), - ), - ), -); - -const projectRenameCommand = Command.make("rename", { - ...projectLocationFlags, - project: Argument.string("project").pipe( - Argument.withDescription("Project id or workspace root to rename."), - ), - title: Argument.string("title").pipe(Argument.withDescription("New project title.")), -}).pipe( - Command.withDescription("Rename a project."), - Command.withHandler((flags) => - runProjectMutation( - flags, - Effect.fn("projectRenameMutation")(function* ({ - snapshot, - dispatch, - }: { - readonly snapshot: OrchestrationReadModel; - readonly dispatch: ( - command: ProjectCliDispatchCommand, - ) => Effect.Effect; - }) { - const project = yield* findActiveProjectTarget({ - snapshot, - identifier: flags.project, - }); - const nextTitle = yield* resolveProjectTitle(project.workspaceRoot, flags.title); - if (nextTitle === project.title) { - return `Project ${project.id} is already named ${nextTitle}.`; - } - - yield* dispatch({ - type: "project.meta.update", - commandId: CommandId.make(crypto.randomUUID()), - projectId: project.id, - title: nextTitle, - }); - return `Renamed project ${project.id} to ${nextTitle}.`; - }), - ), - ), -); - -const projectCommand = Command.make("project").pipe( - Command.withDescription("Manage projects."), - Command.withSubcommands([projectAddCommand, projectRemoveCommand, projectRenameCommand]), -); - -const runServerCommand = ( - flags: CliServerFlags, - options?: { - readonly startupPresentation?: StartupPresentation; - readonly forceAutoBootstrapProjectFromCwd?: boolean; - }, -) => - Effect.gen(function* () { - const logLevel = yield* GlobalFlag.LogLevel; - const config = yield* resolveServerConfig(flags, logLevel, options); - return yield* runServer.pipe(Effect.provideService(ServerConfig, config)); - }); - -const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe( - Command.withDescription("Run the T3 Code server."), - Command.withHandler((flags) => runServerCommand(flags)), -); - -const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe( - Command.withDescription( - "Run the T3 Code server without opening a browser and print headless pairing details.", - ), - Command.withHandler((flags) => - runServerCommand(flags, { - startupPresentation: "headless", - forceAutoBootstrapProjectFromCwd: false, - }), - ), -); - -export const cli = Command.make("t3", { ...sharedServerCommandFlags }).pipe( - Command.withDescription("Run the T3 Code server."), - Command.withHandler((flags) => runServerCommand(flags)), - Command.withSubcommands([startCommand, serveCommand, authCommand, projectCommand]), -); diff --git a/apps/server/src/cli/auth.ts b/apps/server/src/cli/auth.ts new file mode 100644 index 00000000000..dabae1b0f88 --- /dev/null +++ b/apps/server/src/cli/auth.ts @@ -0,0 +1,243 @@ +import { AuthSessionId } from "@t3tools/contracts"; +import { Console, Effect, Layer, Option, References } from "effect"; +import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; + +import { AuthControlPlaneRuntimeLive } from "../auth/Layers/AuthControlPlane.ts"; +import { AuthControlPlane } from "../auth/Services/AuthControlPlane.ts"; +import type { AuthControlPlaneShape } from "../auth/Services/AuthControlPlane.ts"; +import { + formatIssuedPairingCredential, + formatIssuedSession, + formatPairingCredentialList, + formatSessionList, +} from "../cliAuthFormat.ts"; +import { ServerConfig } from "../config.ts"; +import { + authLocationFlags, + type CliAuthLocationFlags, + DurationFromString, + resolveCliAuthConfig, +} from "./config.ts"; + +const runWithAuthControlPlane = ( + flags: CliAuthLocationFlags, + run: (authControlPlane: AuthControlPlaneShape) => Effect.Effect, + options?: { + readonly quietLogs?: boolean; + }, +) => + Effect.gen(function* () { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig(flags, logLevel); + const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel; + return yield* Effect.gen(function* () { + const authControlPlane = yield* AuthControlPlane; + return yield* run(authControlPlane); + }).pipe( + Effect.provide( + Layer.mergeAll(AuthControlPlaneRuntimeLive).pipe( + Layer.provide(Layer.succeed(ServerConfig, config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), + ), + ), + ); + }); + +const ttlFlag = Flag.string("ttl").pipe( + Flag.withSchema(DurationFromString), + Flag.withDescription("TTL, for example `5m`, `1h`, `30d`, or `15 minutes`."), + Flag.optional, +); + +const jsonFlag = Flag.boolean("json").pipe( + Flag.withDescription("Emit JSON instead of human-readable output."), + Flag.withDefault(false), +); + +const sessionRoleFlag = Flag.choice("role", ["owner", "client"]).pipe( + Flag.withDescription("Role for the issued bearer session."), + Flag.withDefault("owner"), +); + +const labelFlag = Flag.string("label").pipe( + Flag.withDescription("Optional human-readable label."), + Flag.optional, +); + +const subjectFlag = Flag.string("subject").pipe( + Flag.withDescription("Optional session subject."), + Flag.optional, +); + +const baseUrlFlag = Flag.string("base-url").pipe( + Flag.withDescription("Optional public base URL used to print a ready `/pair#token=...` link."), + Flag.optional, +); + +const tokenOnlyFlag = Flag.boolean("token-only").pipe( + Flag.withDescription("Print only the issued bearer token."), + Flag.withDefault(false), +); + +const pairingCreateCommand = Command.make("create", { + ...authLocationFlags, + ttl: ttlFlag, + label: labelFlag, + baseUrl: baseUrlFlag, + json: jsonFlag, +}).pipe( + Command.withDescription("Issue a new client pairing token."), + Command.withHandler((flags) => + runWithAuthControlPlane( + flags, + (authControlPlane) => + Effect.gen(function* () { + const issued = yield* authControlPlane.createPairingLink({ + role: "client", + subject: "one-time-token", + ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), + ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), + }); + const output = formatIssuedPairingCredential(issued, { + json: flags.json, + ...(Option.isSome(flags.baseUrl) ? { baseUrl: flags.baseUrl.value } : {}), + }); + yield* Console.log(output); + }), + { + quietLogs: flags.json, + }, + ), + ), +); + +const pairingListCommand = Command.make("list", { + ...authLocationFlags, + json: jsonFlag, +}).pipe( + Command.withDescription("List active client pairing tokens without revealing their secrets."), + Command.withHandler((flags) => + runWithAuthControlPlane( + flags, + (authControlPlane) => + Effect.gen(function* () { + const pairingLinks = yield* authControlPlane.listPairingLinks({ role: "client" }); + yield* Console.log(formatPairingCredentialList(pairingLinks, { json: flags.json })); + }), + { + quietLogs: flags.json, + }, + ), + ), +); + +const pairingRevokeCommand = Command.make("revoke", { + ...authLocationFlags, + id: Argument.string("id").pipe(Argument.withDescription("Pairing credential id to revoke.")), +}).pipe( + Command.withDescription("Revoke an active client pairing token."), + Command.withHandler((flags) => + runWithAuthControlPlane(flags, (authControlPlane) => + Effect.gen(function* () { + const revoked = yield* authControlPlane.revokePairingLink(flags.id); + yield* Console.log( + revoked + ? `Revoked pairing credential ${flags.id}.\n` + : `No active pairing credential found for ${flags.id}.\n`, + ); + }), + ), + ), +); + +const pairingCommand = Command.make("pairing").pipe( + Command.withDescription("Manage one-time client pairing tokens."), + Command.withSubcommands([pairingCreateCommand, pairingListCommand, pairingRevokeCommand]), +); + +const sessionIssueCommand = Command.make("issue", { + ...authLocationFlags, + ttl: ttlFlag, + role: sessionRoleFlag, + label: labelFlag, + subject: subjectFlag, + tokenOnly: tokenOnlyFlag, + json: jsonFlag, +}).pipe( + Command.withDescription("Issue a bearer session token for headless or remote clients."), + Command.withHandler((flags) => + runWithAuthControlPlane( + flags, + (authControlPlane) => + Effect.gen(function* () { + const issued = yield* authControlPlane.issueSession({ + role: flags.role, + ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}), + ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), + ...(Option.isSome(flags.subject) ? { subject: flags.subject.value } : {}), + }); + yield* Console.log( + formatIssuedSession(issued, { + json: flags.json, + tokenOnly: flags.tokenOnly, + }), + ); + }), + { + quietLogs: flags.json || flags.tokenOnly, + }, + ), + ), +); + +const sessionListCommand = Command.make("list", { + ...authLocationFlags, + json: jsonFlag, +}).pipe( + Command.withDescription("List active sessions without revealing bearer tokens."), + Command.withHandler((flags) => + runWithAuthControlPlane( + flags, + (authControlPlane) => + Effect.gen(function* () { + const sessions = yield* authControlPlane.listSessions(); + yield* Console.log(formatSessionList(sessions, { json: flags.json })); + }), + { + quietLogs: flags.json, + }, + ), + ), +); + +const sessionRevokeCommand = Command.make("revoke", { + ...authLocationFlags, + sessionId: Argument.string("session-id").pipe( + Argument.withDescription("Session id to revoke."), + Argument.withSchema(AuthSessionId), + ), +}).pipe( + Command.withDescription("Revoke an active session."), + Command.withHandler((flags) => + runWithAuthControlPlane(flags, (authControlPlane) => + Effect.gen(function* () { + const revoked = yield* authControlPlane.revokeSession(flags.sessionId); + yield* Console.log( + revoked + ? `Revoked session ${flags.sessionId}.\n` + : `No active session found for ${flags.sessionId}.\n`, + ); + }), + ), + ), +); + +const sessionCommand = Command.make("session").pipe( + Command.withDescription("Manage bearer sessions."), + Command.withSubcommands([sessionIssueCommand, sessionListCommand, sessionRevokeCommand]), +); + +export const authCommand = Command.make("auth").pipe( + Command.withDescription("Manage the local auth control plane for headless deployments."), + Command.withSubcommands([pairingCommand, sessionCommand]), +); diff --git a/apps/server/src/cli-config.test.ts b/apps/server/src/cli/config.test.ts similarity index 99% rename from apps/server/src/cli-config.test.ts rename to apps/server/src/cli/config.test.ts index 2e13d33aabf..c1bd9f1a189 100644 --- a/apps/server/src/cli-config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -5,8 +5,8 @@ import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect" import { NetService } from "@t3tools/shared/Net"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { deriveServerPaths } from "./config.ts"; -import { resolveServerConfig } from "./cli.ts"; +import { deriveServerPaths } from "../config.ts"; +import { resolveServerConfig } from "./config.ts"; it.layer(NodeServices.layer)("cli config resolution", (it) => { const defaultObservabilityConfig = { diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts new file mode 100644 index 00000000000..baac5c708b6 --- /dev/null +++ b/apps/server/src/cli/config.ts @@ -0,0 +1,490 @@ +import { NetService } from "@t3tools/shared/Net"; +import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; +import { + Config, + Duration, + Effect, + FileSystem, + LogLevel, + Option, + Path, + Schema, + SchemaIssue, + SchemaTransformation, +} from "effect"; +import { Argument, Flag } from "effect/unstable/cli"; + +import { readBootstrapEnvelope } from "../bootstrap.ts"; +import { + DEFAULT_PORT, + deriveServerPaths, + ensureServerDirectories, + resolveStaticDir, + RuntimeMode, + type ServerConfigShape, + type StartupPresentation, +} from "../config.ts"; +import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; + +export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })); + +const BootstrapEnvelopeSchema = Schema.Struct({ + mode: Schema.optional(RuntimeMode), + port: Schema.optional(PortSchema), + host: Schema.optional(Schema.String), + t3Home: Schema.optional(Schema.String), + devUrl: Schema.optional(Schema.URLFromString), + noBrowser: Schema.optional(Schema.Boolean), + desktopBootstrapToken: Schema.optional(Schema.String), + autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean), + logWebSocketEvents: Schema.optional(Schema.Boolean), + tailscaleServeEnabled: Schema.optional(Schema.Boolean), + tailscaleServePort: Schema.optional(PortSchema), + otlpTracesUrl: Schema.optional(Schema.String), + otlpMetricsUrl: Schema.optional(Schema.String), +}); + +export const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe( + Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), + Flag.optional, +); +export const portFlag = Flag.integer("port").pipe( + Flag.withSchema(PortSchema), + Flag.withDescription("Port for the HTTP/WebSocket server."), + Flag.optional, +); +export const hostFlag = Flag.string("host").pipe( + Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."), + Flag.optional, +); +export const baseDirFlag = Flag.string("base-dir").pipe( + Flag.withDescription("Base directory path (equivalent to T3CODE_HOME)."), + Flag.optional, +); +export const devUrlFlag = Flag.string("dev-url").pipe( + Flag.withSchema(Schema.URLFromString), + Flag.withDescription("Dev web URL to proxy/redirect to (equivalent to VITE_DEV_SERVER_URL)."), + Flag.optional, +); +export const noBrowserFlag = Flag.boolean("no-browser").pipe( + Flag.withDescription("Disable automatic browser opening."), + Flag.optional, +); +export const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe( + Flag.withSchema(Schema.Int), + Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."), + Flag.optional, +); +export const autoBootstrapProjectFromCwdFlag = Flag.boolean("auto-bootstrap-project-from-cwd").pipe( + Flag.withDescription( + "Create a project for the current working directory on startup when missing.", + ), + Flag.optional, +); +export const logWebSocketEventsFlag = Flag.boolean("log-websocket-events").pipe( + Flag.withDescription( + "Emit server-side logs for outbound WebSocket push traffic (equivalent to T3CODE_LOG_WS_EVENTS).", + ), + Flag.withAlias("log-ws-events"), + Flag.optional, +); +export const tailscaleServeFlag = Flag.boolean("tailscale-serve").pipe( + Flag.withDescription( + "Configure Tailscale Serve to expose this backend over HTTPS on the Tailnet.", + ), + Flag.optional, +); +export const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe( + Flag.withSchema(PortSchema), + Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."), + Flag.optional, +); + +const EnvServerConfig = Config.all({ + logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")), + traceMinLevel: Config.logLevel("T3CODE_TRACE_MIN_LEVEL").pipe(Config.withDefault("Info")), + traceTimingEnabled: Config.boolean("T3CODE_TRACE_TIMING_ENABLED").pipe(Config.withDefault(true)), + traceFile: Config.string("T3CODE_TRACE_FILE").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + traceMaxBytes: Config.int("T3CODE_TRACE_MAX_BYTES").pipe(Config.withDefault(10 * 1024 * 1024)), + traceMaxFiles: Config.int("T3CODE_TRACE_MAX_FILES").pipe(Config.withDefault(10)), + traceBatchWindowMs: Config.int("T3CODE_TRACE_BATCH_WINDOW_MS").pipe(Config.withDefault(200)), + otlpTracesUrl: Config.string("T3CODE_OTLP_TRACES_URL").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + otlpMetricsUrl: Config.string("T3CODE_OTLP_METRICS_URL").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + otlpExportIntervalMs: Config.int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( + Config.withDefault(10_000), + ), + otlpServiceName: Config.string("T3CODE_OTLP_SERVICE_NAME").pipe(Config.withDefault("t3-server")), + mode: Config.schema(RuntimeMode, "T3CODE_MODE").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + port: Config.port("T3CODE_PORT").pipe(Config.option, Config.map(Option.getOrUndefined)), + host: Config.string("T3CODE_HOST").pipe(Config.option, Config.map(Option.getOrUndefined)), + t3Home: Config.string("T3CODE_HOME").pipe(Config.option, Config.map(Option.getOrUndefined)), + devUrl: Config.url("VITE_DEV_SERVER_URL").pipe(Config.option, Config.map(Option.getOrUndefined)), + noBrowser: Config.boolean("T3CODE_NO_BROWSER").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + autoBootstrapProjectFromCwd: Config.boolean("T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + logWebSocketEvents: Config.boolean("T3CODE_LOG_WS_EVENTS").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + tailscaleServeEnabled: Config.boolean("T3CODE_TAILSCALE_SERVE").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + tailscaleServePort: Config.port("T3CODE_TAILSCALE_SERVE_PORT").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), +}); + +export interface CliServerFlags { + readonly mode: Option.Option; + readonly port: Option.Option; + readonly host: Option.Option; + readonly baseDir: Option.Option; + readonly cwd: Option.Option; + readonly devUrl: Option.Option; + readonly noBrowser: Option.Option; + readonly bootstrapFd: Option.Option; + readonly autoBootstrapProjectFromCwd: Option.Option; + readonly logWebSocketEvents: Option.Option; + readonly tailscaleServeEnabled: Option.Option; + readonly tailscaleServePort: Option.Option; +} + +export interface CliAuthLocationFlags { + readonly baseDir: Option.Option; + readonly devUrl?: Option.Option; +} + +export const sharedServerLocationFlags = { + baseDir: baseDirFlag, + devUrl: devUrlFlag, +} as const; + +export const projectLocationFlags = { + baseDir: baseDirFlag, +} as const; + +export const sharedServerCommandFlags = { + mode: modeFlag, + port: portFlag, + host: hostFlag, + baseDir: baseDirFlag, + cwd: Argument.string("cwd").pipe( + Argument.withDescription( + "Working directory for provider sessions (defaults to the current directory).", + ), + Argument.optional, + ), + devUrl: devUrlFlag, + noBrowser: noBrowserFlag, + bootstrapFd: bootstrapFdFlag, + autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, + logWebSocketEvents: logWebSocketEventsFlag, + tailscaleServeEnabled: tailscaleServeFlag, + tailscaleServePort: tailscaleServePortFlag, +} as const; + +export const authLocationFlags = sharedServerLocationFlags; + +const resolveOptionPrecedence = ( + ...values: ReadonlyArray> +): Option.Option => Option.firstSomeOf(values); + +const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: string) { + const fs = yield* FileSystem.FileSystem; + const exists = yield* fs.exists(settingsPath).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return { otlpTracesUrl: undefined, otlpMetricsUrl: undefined }; + } + + const raw = yield* fs.readFileString(settingsPath).pipe(Effect.orElseSucceed(() => "")); + return parsePersistedServerObservabilitySettings(raw); +}); + +export const resolveServerConfig = ( + flags: CliServerFlags, + cliLogLevel: Option.Option, + options?: { + readonly startupPresentation?: StartupPresentation; + readonly forceAutoBootstrapProjectFromCwd?: boolean; + }, +) => + Effect.gen(function* () { + const { findAvailablePort } = yield* NetService; + const path = yield* Path.Path; + const fs = yield* FileSystem.FileSystem; + const env = yield* EnvServerConfig; + const normalizedFlags = { + mode: flags.mode ?? Option.none(), + port: flags.port ?? Option.none(), + host: flags.host ?? Option.none(), + baseDir: flags.baseDir ?? Option.none(), + cwd: flags.cwd ?? Option.none(), + devUrl: flags.devUrl ?? Option.none(), + noBrowser: flags.noBrowser ?? Option.none(), + bootstrapFd: flags.bootstrapFd ?? Option.none(), + autoBootstrapProjectFromCwd: flags.autoBootstrapProjectFromCwd ?? Option.none(), + logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(), + tailscaleServeEnabled: flags.tailscaleServeEnabled ?? Option.none(), + tailscaleServePort: flags.tailscaleServePort ?? Option.none(), + } satisfies CliServerFlags; + const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd; + const bootstrapEnvelope = + bootstrapFd !== undefined + ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd) + : Option.none(); + const bootstrap = Option.getOrUndefined(bootstrapEnvelope); + + const mode: RuntimeMode = Option.getOrElse( + resolveOptionPrecedence( + normalizedFlags.mode, + Option.fromUndefinedOr(env.mode), + Option.fromUndefinedOr(bootstrap?.mode), + ), + () => "web", + ); + + const port = yield* Option.match( + resolveOptionPrecedence( + normalizedFlags.port, + Option.fromUndefinedOr(env.port), + Option.fromUndefinedOr(bootstrap?.port), + ), + { + onSome: (value) => Effect.succeed(value), + onNone: () => { + if (mode === "desktop") { + return Effect.succeed(DEFAULT_PORT); + } + return findAvailablePort(DEFAULT_PORT); + }, + }, + ); + const devUrl = Option.getOrElse( + resolveOptionPrecedence( + normalizedFlags.devUrl, + Option.fromUndefinedOr(env.devUrl), + Option.fromUndefinedOr(bootstrap?.devUrl), + ), + () => undefined, + ); + const baseDir = yield* resolveBaseDir( + Option.getOrUndefined( + resolveOptionPrecedence( + normalizedFlags.baseDir, + Option.fromUndefinedOr(env.t3Home), + Option.fromUndefinedOr(bootstrap?.t3Home), + ), + ), + ); + const rawCwd = Option.getOrElse(normalizedFlags.cwd, () => process.cwd()); + const cwd = path.resolve(yield* expandHomePath(rawCwd.trim())); + yield* fs.makeDirectory(cwd, { recursive: true }); + const derivedPaths = yield* deriveServerPaths(baseDir, devUrl); + yield* ensureServerDirectories(derivedPaths); + const persistedObservabilitySettings = yield* loadPersistedObservabilitySettings( + derivedPaths.settingsPath, + ); + const serverTracePath = env.traceFile ?? derivedPaths.serverTracePath; + yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true }); + const startupPresentation = options?.startupPresentation ?? "browser"; + const isHeadlessStartup = startupPresentation === "headless"; + const noBrowser = Option.getOrElse( + resolveOptionPrecedence( + isHeadlessStartup ? Option.some(true) : Option.none(), + normalizedFlags.noBrowser, + Option.fromUndefinedOr(env.noBrowser), + Option.fromUndefinedOr(bootstrap?.noBrowser), + ), + () => mode === "desktop", + ); + const desktopBootstrapToken = bootstrap?.desktopBootstrapToken; + const autoBootstrapProjectFromCwd = Option.getOrElse( + resolveOptionPrecedence( + Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd), + isHeadlessStartup ? Option.some(false) : Option.none(), + normalizedFlags.autoBootstrapProjectFromCwd, + Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd), + Option.fromUndefinedOr(bootstrap?.autoBootstrapProjectFromCwd), + ), + () => mode === "web", + ); + const logWebSocketEvents = Option.getOrElse( + resolveOptionPrecedence( + normalizedFlags.logWebSocketEvents, + Option.fromUndefinedOr(env.logWebSocketEvents), + Option.fromUndefinedOr(bootstrap?.logWebSocketEvents), + ), + () => Boolean(devUrl), + ); + const tailscaleServeEnabled = Option.getOrElse( + resolveOptionPrecedence( + normalizedFlags.tailscaleServeEnabled, + Option.fromUndefinedOr(env.tailscaleServeEnabled), + Option.fromUndefinedOr(bootstrap?.tailscaleServeEnabled), + ), + () => false, + ); + const tailscaleServePort = Option.getOrElse( + resolveOptionPrecedence( + normalizedFlags.tailscaleServePort, + Option.fromUndefinedOr(env.tailscaleServePort), + Option.fromUndefinedOr(bootstrap?.tailscaleServePort), + ), + () => 443, + ); + const staticDir = devUrl ? undefined : yield* resolveStaticDir(); + const host = Option.getOrElse( + resolveOptionPrecedence( + normalizedFlags.host, + Option.fromUndefinedOr(env.host), + Option.fromUndefinedOr(bootstrap?.host), + ), + () => (mode === "desktop" ? "127.0.0.1" : undefined), + ); + const logLevel = Option.getOrElse(cliLogLevel, () => env.logLevel); + + const config: ServerConfigShape = { + logLevel, + traceMinLevel: env.traceMinLevel, + traceTimingEnabled: env.traceTimingEnabled, + traceBatchWindowMs: env.traceBatchWindowMs, + traceMaxBytes: env.traceMaxBytes, + traceMaxFiles: env.traceMaxFiles, + otlpTracesUrl: + env.otlpTracesUrl ?? + bootstrap?.otlpTracesUrl ?? + persistedObservabilitySettings.otlpTracesUrl, + otlpMetricsUrl: + env.otlpMetricsUrl ?? + bootstrap?.otlpMetricsUrl ?? + persistedObservabilitySettings.otlpMetricsUrl, + otlpExportIntervalMs: env.otlpExportIntervalMs, + otlpServiceName: env.otlpServiceName, + mode, + port, + cwd, + baseDir, + ...derivedPaths, + serverTracePath, + host, + staticDir, + devUrl, + noBrowser, + startupPresentation, + desktopBootstrapToken, + autoBootstrapProjectFromCwd, + logWebSocketEvents, + tailscaleServeEnabled, + tailscaleServePort, + }; + + return config; + }); + +export const resolveCliAuthConfig = ( + flags: CliAuthLocationFlags, + cliLogLevel: Option.Option, +) => + resolveServerConfig( + { + mode: Option.none(), + port: Option.none(), + host: Option.none(), + baseDir: flags.baseDir, + cwd: Option.none(), + devUrl: flags.devUrl ?? Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }, + cliLogLevel, + ); + +const DurationShorthandPattern = /^(?\d+)(?ms|s|m|h|d|w)$/i; + +const parseDurationInput = (value: string): Duration.Duration | null => { + const trimmed = value.trim(); + if (trimmed.length === 0) return null; + + const shorthand = DurationShorthandPattern.exec(trimmed); + const normalizedInput = shorthand?.groups + ? (() => { + const amountText = shorthand.groups.value; + const unitText = shorthand.groups.unit; + if (typeof amountText !== "string" || typeof unitText !== "string") { + return null; + } + + const amount = Number.parseInt(amountText, 10); + if (!Number.isFinite(amount)) return null; + + switch (unitText.toLowerCase()) { + case "ms": + return `${amount} millis`; + case "s": + return `${amount} seconds`; + case "m": + return `${amount} minutes`; + case "h": + return `${amount} hours`; + case "d": + return `${amount} days`; + case "w": + return `${amount} weeks`; + default: + return null; + } + })() + : (trimmed as Duration.Input); + + if (normalizedInput === null) return null; + + const decoded = Duration.fromInput(normalizedInput as Duration.Input); + return Option.isSome(decoded) ? decoded.value : null; +}; + +export const DurationFromString = Schema.String.pipe( + Schema.decodeTo( + Schema.Duration, + SchemaTransformation.transformOrFail({ + decode: (value) => { + const duration = parseDurationInput(value); + if (duration !== null) { + return Effect.succeed(duration); + } + return Effect.fail( + new SchemaIssue.InvalidValue(Option.some(value), { + message: "Invalid duration. Use values like 5m, 1h, 30d, or 15 minutes.", + }), + ); + }, + encode: (duration) => Effect.succeed(Duration.format(duration)), + }), + ), +); diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts new file mode 100644 index 00000000000..956bd386e38 --- /dev/null +++ b/apps/server/src/cli/project.ts @@ -0,0 +1,434 @@ +import { + CommandId, + OrchestrationReadModel, + ProjectId, + type ClientOrchestrationCommand, +} from "@t3tools/contracts"; +import { + Console, + Duration, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + References, + Schema, +} from "effect"; +import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; + +import { AuthControlPlaneRuntimeLive } from "../auth/Layers/AuthControlPlane.ts"; +import { AuthControlPlane } from "../auth/Services/AuthControlPlane.ts"; +import type { AuthControlPlaneShape } from "../auth/Services/AuthControlPlane.ts"; +import { ServerConfig, type ServerConfigShape } from "../config.ts"; +import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { OrchestrationLayerLive } from "../orchestration/runtimeLayer.ts"; +import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts"; +import { RepositoryIdentityResolverLive } from "../project/Layers/RepositoryIdentityResolver.ts"; +import { getAutoBootstrapDefaultModelSelection } from "../serverRuntimeStartup.ts"; +import { + clearPersistedServerRuntimeState, + readPersistedServerRuntimeState, +} from "../serverRuntimeState.ts"; +import { WorkspacePathsLive } from "../workspace/Layers/WorkspacePaths.ts"; +import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts"; +import { type CliAuthLocationFlags, projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; + +type ProjectMutationTarget = { + readonly id: ProjectId; + readonly title: string; + readonly workspaceRoot: string; +}; + +type ProjectCommandExecutionMode = "live" | "offline"; +type ProjectCliDispatchCommand = Extract< + ClientOrchestrationCommand, + { type: "project.create" | "project.meta.update" | "project.delete" } +>; + +const ProjectCliRuntimeLive = Layer.mergeAll( + WorkspacePathsLive, + OrchestrationLayerLive.pipe( + Layer.provideMerge(RepositoryIdentityResolverLive), + Layer.provideMerge(SqlitePersistenceLayerLive), + ), +); + +const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1); +const OrchestrationHttpErrorResponse = Schema.Struct({ + error: Schema.String, +}); + +const withProjectCliSessionToken = ( + authControlPlane: AuthControlPlaneShape, + run: (token: string) => Effect.Effect, +) => + Effect.acquireUseRelease( + authControlPlane.issueSession({ + role: "owner", + label: "t3 project cli", + }), + (issued) => run(issued.token), + (issued) => authControlPlane.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })), + ); + +const withProjectCliLiveServerTimeout = (effect: Effect.Effect) => + effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT)); + +const runLiveServerRequest = ( + request: HttpClientRequest.HttpClientRequest, + handle: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect, +) => + Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const response = yield* httpClient.execute(request); + return yield* handle(response); + }).pipe(withProjectCliLiveServerTimeout); + +const decodeOrchestrationReadModelResponse = (response: HttpClientResponse.HttpClientResponse) => + HttpClientResponse.schemaBodyJson(OrchestrationReadModel)(response); + +const readErrorMessageFromResponse = (response: HttpClientResponse.HttpClientResponse) => + HttpClientResponse.schemaBodyJson(OrchestrationHttpErrorResponse)(response).pipe( + Effect.map((body) => body.error), + Effect.catch(() => Effect.succeed(null)), + Effect.map((body) => { + if (typeof body === "string" && body.trim().length > 0) { + return body; + } + return `Server request failed with status ${response.status}.`; + }), + ); + +const normalizeWorkspaceRootForProjectCommand = Effect.fn( + "normalizeWorkspaceRootForProjectCommand", +)(function* (workspaceRoot: string) { + const workspacePaths = yield* WorkspacePaths; + return yield* workspacePaths.normalizeWorkspaceRoot(workspaceRoot); +}); + +const resolveProjectTitle = Effect.fn("resolveProjectTitle")(function* ( + workspaceRoot: string, + explicitTitle?: string, +) { + if (explicitTitle !== undefined) { + const trimmed = explicitTitle.trim(); + if (trimmed.length > 0) { + return trimmed; + } + return yield* Effect.fail(new Error("Project title cannot be empty.")); + } + + const path = yield* Path.Path; + const basename = path.basename(workspaceRoot).trim(); + return basename.length > 0 ? basename : "project"; +}); + +const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (input: { + readonly snapshot: OrchestrationReadModel; + readonly identifier: string; +}) { + const trimmedIdentifier = input.identifier.trim(); + if (trimmedIdentifier.length === 0) { + return yield* Effect.fail(new Error("Project identifier cannot be empty.")); + } + + const activeProjects = input.snapshot.projects.filter((project) => project.deletedAt === null); + const exactIdMatch = activeProjects.find((project) => project.id === trimmedIdentifier); + if (exactIdMatch) { + return { + id: exactIdMatch.id, + title: exactIdMatch.title, + workspaceRoot: exactIdMatch.workspaceRoot, + } satisfies ProjectMutationTarget; + } + + const normalizedWorkspaceRootResult = yield* Effect.exit( + normalizeWorkspaceRootForProjectCommand(trimmedIdentifier), + ); + const normalizedWorkspaceRoot = Exit.isSuccess(normalizedWorkspaceRootResult) + ? normalizedWorkspaceRootResult.value + : null; + + const exactWorkspaceMatch = + normalizedWorkspaceRoot === null + ? undefined + : activeProjects.find((project) => project.workspaceRoot === normalizedWorkspaceRoot); + + const resolved = exactWorkspaceMatch; + if (!resolved) { + return yield* Effect.fail(new Error(`No active project found for '${trimmedIdentifier}'.`)); + } + + return { + id: resolved.id, + title: resolved.title, + workspaceRoot: resolved.workspaceRoot, + } satisfies ProjectMutationTarget; +}); + +const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) => + runLiveServerRequest( + HttpClientRequest.get(`${origin}/api/orchestration/snapshot`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(bearerToken), + ), + HttpClientResponse.matchStatus({ + "2xx": decodeOrchestrationReadModelResponse, + orElse: (response) => + readErrorMessageFromResponse(response).pipe( + Effect.flatMap((message) => Effect.fail(new Error(message))), + ), + }), + ); + +const dispatchLiveOrchestrationCommand = ( + origin: string, + bearerToken: string, + command: ProjectCliDispatchCommand, +) => + HttpClientRequest.post(`${origin}/api/orchestration/dispatch`).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(bearerToken), + HttpClientRequest.bodyJson(command), + Effect.flatMap((request) => + runLiveServerRequest( + request, + HttpClientResponse.matchStatus({ + "2xx": () => Effect.void, + orElse: (response) => + readErrorMessageFromResponse(response).pipe( + Effect.flatMap((message) => Effect.fail(new Error(message))), + ), + }), + ), + ), + ); + +const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () { + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; + return yield* projectionSnapshotQuery.getSnapshot(); +}); + +const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecutionMode")( + function* (authControlPlane: AuthControlPlaneShape, config: ServerConfigShape) { + const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath); + if (Option.isNone(runtimeState)) { + return Option.none<{ readonly origin: string }>(); + } + + const attempt = withProjectCliSessionToken(authControlPlane, (token) => + fetchLiveOrchestrationSnapshot(runtimeState.value.origin, token).pipe( + Effect.as({ + origin: runtimeState.value.origin, + }), + ), + ); + + const attempted = yield* Effect.exit(attempt); + if (Exit.isSuccess(attempted)) { + return Option.some(attempted.value); + } + + yield* clearPersistedServerRuntimeState(config.serverRuntimeStatePath); + return Option.none<{ readonly origin: string }>(); + }, +); + +const runProjectMutation = Effect.fn("runProjectMutation")(function* ( + flags: CliAuthLocationFlags, + run: (input: { + readonly snapshot: OrchestrationReadModel; + readonly dispatch: ( + command: ProjectCliDispatchCommand, + ) => Effect.Effect; + readonly mode: ProjectCommandExecutionMode; + }) => Effect.Effect< + string, + Error, + FileSystem.FileSystem | HttpClient.HttpClient | Path.Path | WorkspacePaths + >, +) { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveCliAuthConfig(flags, logLevel); + const minimumLogLevel = config.logLevel; + + return yield* Effect.gen(function* () { + const authControlPlane = yield* AuthControlPlane; + const liveMode = yield* tryResolveLiveProjectExecutionMode(authControlPlane, config); + + if (Option.isSome(liveMode)) { + return yield* withProjectCliSessionToken(authControlPlane, (token) => + Effect.gen(function* () { + const snapshot = yield* fetchLiveOrchestrationSnapshot(liveMode.value.origin, token); + const output = yield* run({ + snapshot, + dispatch: (command) => + dispatchLiveOrchestrationCommand(liveMode.value.origin, token, command), + mode: "live", + }); + yield* Console.log(output); + }), + ); + } + + const offlineRuntimeLayer = ProjectCliRuntimeLive.pipe( + Layer.provide(Layer.succeed(ServerConfig, config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), + ); + + return yield* Effect.gen(function* () { + const snapshot = yield* getOfflineSnapshot(); + const orchestrationEngine = yield* OrchestrationEngineService; + const output = yield* run({ + snapshot, + dispatch: (command) => orchestrationEngine.dispatch(command), + mode: "offline", + }); + yield* Console.log(output); + }).pipe(Effect.provide(offlineRuntimeLayer)); + }).pipe( + Effect.provide( + Layer.mergeAll(AuthControlPlaneRuntimeLive, WorkspacePathsLive).pipe( + Layer.provideMerge(FetchHttpClient.layer), + Layer.provide(Layer.succeed(ServerConfig, config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)), + ), + ), + ); +}); + +const projectAddCommand = Command.make("add", { + ...projectLocationFlags, + workspaceRoot: Argument.string("path").pipe( + Argument.withDescription("Workspace root to add as a project."), + ), + title: Flag.string("title").pipe(Flag.withDescription("Optional project title."), Flag.optional), +}).pipe( + Command.withDescription("Add a project."), + Command.withHandler((flags) => + runProjectMutation( + flags, + Effect.fn("projectAddMutation")(function* ({ + snapshot, + dispatch, + }: { + readonly snapshot: OrchestrationReadModel; + readonly dispatch: ( + command: ProjectCliDispatchCommand, + ) => Effect.Effect; + }) { + const workspaceRoot = yield* normalizeWorkspaceRootForProjectCommand(flags.workspaceRoot); + const existingProject = snapshot.projects.find( + (project) => project.deletedAt === null && project.workspaceRoot === workspaceRoot, + ); + if (existingProject) { + return yield* Effect.fail( + new Error(`An active project already exists for '${workspaceRoot}'.`), + ); + } + + const title = yield* resolveProjectTitle(workspaceRoot, Option.getOrUndefined(flags.title)); + const projectId = ProjectId.make(crypto.randomUUID()); + yield* dispatch({ + type: "project.create", + commandId: CommandId.make(crypto.randomUUID()), + projectId, + title, + workspaceRoot, + defaultModelSelection: getAutoBootstrapDefaultModelSelection(), + createdAt: new Date().toISOString(), + }); + return `Added project ${projectId} (${title}) at ${workspaceRoot}.`; + }), + ), + ), +); + +const projectRemoveCommand = Command.make("remove", { + ...projectLocationFlags, + project: Argument.string("project").pipe( + Argument.withDescription("Project id or workspace root to remove."), + ), +}).pipe( + Command.withDescription("Remove a project."), + Command.withHandler((flags) => + runProjectMutation( + flags, + Effect.fn("projectRemoveMutation")(function* ({ + snapshot, + dispatch, + }: { + readonly snapshot: OrchestrationReadModel; + readonly dispatch: ( + command: ProjectCliDispatchCommand, + ) => Effect.Effect; + }) { + const project = yield* findActiveProjectTarget({ + snapshot, + identifier: flags.project, + }); + yield* dispatch({ + type: "project.delete", + commandId: CommandId.make(crypto.randomUUID()), + projectId: project.id, + }); + return `Removed project ${project.id} (${project.title}).`; + }), + ), + ), +); + +const projectRenameCommand = Command.make("rename", { + ...projectLocationFlags, + project: Argument.string("project").pipe( + Argument.withDescription("Project id or workspace root to rename."), + ), + title: Argument.string("title").pipe(Argument.withDescription("New project title.")), +}).pipe( + Command.withDescription("Rename a project."), + Command.withHandler((flags) => + runProjectMutation( + flags, + Effect.fn("projectRenameMutation")(function* ({ + snapshot, + dispatch, + }: { + readonly snapshot: OrchestrationReadModel; + readonly dispatch: ( + command: ProjectCliDispatchCommand, + ) => Effect.Effect; + }) { + const project = yield* findActiveProjectTarget({ + snapshot, + identifier: flags.project, + }); + const nextTitle = yield* resolveProjectTitle(project.workspaceRoot, flags.title); + if (nextTitle === project.title) { + return `Project ${project.id} is already named ${nextTitle}.`; + } + + yield* dispatch({ + type: "project.meta.update", + commandId: CommandId.make(crypto.randomUUID()), + projectId: project.id, + title: nextTitle, + }); + return `Renamed project ${project.id} to ${nextTitle}.`; + }), + ), + ), +); + +export const projectCommand = Command.make("project").pipe( + Command.withDescription("Manage projects."), + Command.withSubcommands([projectAddCommand, projectRemoveCommand, projectRenameCommand]), +); diff --git a/apps/server/src/cli/server.ts b/apps/server/src/cli/server.ts new file mode 100644 index 00000000000..e24a960650e --- /dev/null +++ b/apps/server/src/cli/server.ts @@ -0,0 +1,36 @@ +import { Effect } from "effect"; +import { Command, GlobalFlag } from "effect/unstable/cli"; + +import { ServerConfig, type StartupPresentation } from "../config.ts"; +import { runServer } from "../server.ts"; +import { type CliServerFlags, resolveServerConfig, sharedServerCommandFlags } from "./config.ts"; + +export const runServerCommand = ( + flags: CliServerFlags, + options?: { + readonly startupPresentation?: StartupPresentation; + readonly forceAutoBootstrapProjectFromCwd?: boolean; + }, +) => + Effect.gen(function* () { + const logLevel = yield* GlobalFlag.LogLevel; + const config = yield* resolveServerConfig(flags, logLevel, options); + return yield* runServer.pipe(Effect.provideService(ServerConfig, config)); + }); + +export const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe( + Command.withDescription("Run the T3 Code server."), + Command.withHandler((flags) => runServerCommand(flags)), +); + +export const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe( + Command.withDescription( + "Run the T3 Code server without opening a browser and print headless pairing details.", + ), + Command.withHandler((flags) => + runServerCommand(flags, { + startupPresentation: "headless", + forceAutoBootstrapProjectFromCwd: false, + }), + ), +);