|
| 1 | +import * as HttpBody from "@effect/platform/HttpBody" |
| 2 | +import * as HttpClient from "@effect/platform/HttpClient" |
| 3 | +import type * as HttpClientResponse from "@effect/platform/HttpClientResponse" |
| 4 | +import { Data, Effect } from "effect" |
| 5 | +import * as Schema from "effect/Schema" |
| 6 | + |
| 7 | +// CHANGE: HTTP client for the unified REST API |
| 8 | +// WHY: CLI becomes a thin HTTP frontend; all business logic runs in the API server |
| 9 | +// QUOTE(ТЗ): "CLI → DOCKER_GIT_API_URL → REST API" |
| 10 | +// PURITY: SHELL |
| 11 | +// EFFECT: Effect<T, ApiClientError, HttpClient.HttpClient> |
| 12 | +// INVARIANT: ∀ cmd ∈ CLICommands \ {Attach, Panes, Menu}: handler(cmd) = httpCall(apiEndpoint(cmd)) |
| 13 | +// COMPLEXITY: O(1) per request |
| 14 | + |
| 15 | +export class ApiClientError extends Data.TaggedError("ApiClientError")<{ |
| 16 | + readonly message: string |
| 17 | +}> {} |
| 18 | + |
| 19 | +// CHANGE: trim trailing slashes without backtracking regex |
| 20 | +// WHY: /\/+$/ is flagged as slow-regex by sonarjs; loop avoids super-linear backtracking |
| 21 | +// PURITY: CORE |
| 22 | +// COMPLEXITY: O(n) where n = number of trailing slashes (typically 0 or 1) |
| 23 | +const resolveApiBaseUrl = (): string => { |
| 24 | + const raw = process.env["DOCKER_GIT_API_URL"] ?? "http://localhost:3334" |
| 25 | + const trimmed = raw.trim() |
| 26 | + let end = trimmed.length |
| 27 | + while (end > 0 && trimmed[end - 1] === "/") { |
| 28 | + end-- |
| 29 | + } |
| 30 | + return trimmed.slice(0, end) |
| 31 | +} |
| 32 | + |
| 33 | +const handleResponse = <T>( |
| 34 | + response: HttpClientResponse.HttpClientResponse, |
| 35 | + schema: Schema.Schema<T> |
| 36 | +): Effect.Effect<T, ApiClientError> => |
| 37 | + Effect.gen(function*(_) { |
| 38 | + if (response.status >= 400) { |
| 39 | + const text = yield* _( |
| 40 | + response.text.pipe( |
| 41 | + Effect.mapError((e) => new ApiClientError({ message: String(e) })) |
| 42 | + ) |
| 43 | + ) |
| 44 | + return yield* _( |
| 45 | + Effect.fail(new ApiClientError({ message: `HTTP ${response.status}: ${text}` })) |
| 46 | + ) |
| 47 | + } |
| 48 | + const json = yield* _( |
| 49 | + response.json.pipe( |
| 50 | + Effect.mapError((e) => new ApiClientError({ message: String(e) })) |
| 51 | + ) |
| 52 | + ) |
| 53 | + return yield* _( |
| 54 | + Schema.decodeUnknown(schema)(json).pipe( |
| 55 | + Effect.mapError((e) => new ApiClientError({ message: `Response parse error: ${String(e)}` })) |
| 56 | + ) |
| 57 | + ) |
| 58 | + }) |
| 59 | + |
| 60 | +const apiPost = <T>( |
| 61 | + path: string, |
| 62 | + body: object, |
| 63 | + schema: Schema.Schema<T> |
| 64 | +): Effect.Effect<T, ApiClientError, HttpClient.HttpClient> => |
| 65 | + Effect.gen(function*(_) { |
| 66 | + const client = yield* _(HttpClient.HttpClient) |
| 67 | + const url = `${resolveApiBaseUrl()}${path}` |
| 68 | + const response = yield* _( |
| 69 | + client.post(url, { body: HttpBody.unsafeJson(body) }).pipe( |
| 70 | + Effect.mapError((e) => new ApiClientError({ message: String(e) })) |
| 71 | + ) |
| 72 | + ) |
| 73 | + return yield* _(handleResponse(response, schema)) |
| 74 | + }) |
| 75 | + |
| 76 | +const apiGet = <T>( |
| 77 | + path: string, |
| 78 | + schema: Schema.Schema<T> |
| 79 | +): Effect.Effect<T, ApiClientError, HttpClient.HttpClient> => |
| 80 | + Effect.gen(function*(_) { |
| 81 | + const client = yield* _(HttpClient.HttpClient) |
| 82 | + const url = `${resolveApiBaseUrl()}${path}` |
| 83 | + const response = yield* _( |
| 84 | + client.get(url).pipe( |
| 85 | + Effect.mapError((e) => new ApiClientError({ message: String(e) })) |
| 86 | + ) |
| 87 | + ) |
| 88 | + return yield* _(handleResponse(response, schema)) |
| 89 | + }) |
| 90 | + |
| 91 | +// ─── Response schemas ─────────────────────────────────────────────────────── |
| 92 | + |
| 93 | +const AuthStatusResponseSchema = Schema.Struct({ message: Schema.String }) |
| 94 | + |
| 95 | +const StatePathResponseSchema = Schema.Struct({ path: Schema.String }) |
| 96 | + |
| 97 | +const StateOutputResponseSchema = Schema.Struct({ output: Schema.String }) |
| 98 | + |
| 99 | +const SessionsOutputSchema = Schema.Struct({ output: Schema.String }) |
| 100 | + |
| 101 | +const OkResponseSchema = Schema.Struct({ ok: Schema.Boolean }) |
| 102 | + |
| 103 | +const ProjectSummarySchema = Schema.Struct({ |
| 104 | + id: Schema.String, |
| 105 | + displayName: Schema.String, |
| 106 | + repoUrl: Schema.String, |
| 107 | + repoRef: Schema.String, |
| 108 | + status: Schema.String, |
| 109 | + statusLabel: Schema.String |
| 110 | +}) |
| 111 | + |
| 112 | +const ProjectDetailsSchema = Schema.Struct({ |
| 113 | + ...ProjectSummarySchema.fields, |
| 114 | + containerName: Schema.String, |
| 115 | + serviceName: Schema.String, |
| 116 | + sshUser: Schema.String, |
| 117 | + sshPort: Schema.Number, |
| 118 | + targetDir: Schema.String, |
| 119 | + projectDir: Schema.String, |
| 120 | + sshCommand: Schema.String, |
| 121 | + envGlobalPath: Schema.String, |
| 122 | + envProjectPath: Schema.String, |
| 123 | + codexAuthPath: Schema.String, |
| 124 | + codexHome: Schema.String |
| 125 | +}) |
| 126 | + |
| 127 | +const ProjectsListResponseSchema = Schema.Struct({ |
| 128 | + projects: Schema.Array(ProjectSummarySchema) |
| 129 | +}) |
| 130 | + |
| 131 | +const ProjectCreatedResponseSchema = Schema.Struct({ |
| 132 | + project: ProjectDetailsSchema |
| 133 | +}) |
| 134 | + |
| 135 | +const ApplyResultSchema = Schema.Struct({ |
| 136 | + applied: Schema.Boolean, |
| 137 | + containerName: Schema.String |
| 138 | +}) |
| 139 | + |
| 140 | +// ─── Auth endpoints ────────────────────────────────────────────────────────── |
| 141 | + |
| 142 | +export const apiAuthGithubLogin = (req: { |
| 143 | + readonly label?: string | null |
| 144 | + readonly token?: string | null |
| 145 | + readonly scopes?: string | null |
| 146 | + readonly envGlobalPath: string |
| 147 | +}) => apiPost("/auth/github/login", req, AuthStatusResponseSchema) |
| 148 | + |
| 149 | +export const apiAuthGithubStatus = (req: { readonly envGlobalPath: string }) => |
| 150 | + apiPost("/auth/github/status", req, AuthStatusResponseSchema) |
| 151 | + |
| 152 | +export const apiAuthGithubLogout = (req: { readonly label?: string | null; readonly envGlobalPath: string }) => |
| 153 | + apiPost("/auth/github/logout", req, AuthStatusResponseSchema) |
| 154 | + |
| 155 | +export const apiAuthCodexLogin = (req: { readonly label?: string | null; readonly codexAuthPath: string }) => |
| 156 | + apiPost("/auth/codex/login", req, AuthStatusResponseSchema) |
| 157 | + |
| 158 | +export const apiAuthCodexStatus = (req: { readonly label?: string | null; readonly codexAuthPath: string }) => |
| 159 | + apiPost("/auth/codex/status", req, AuthStatusResponseSchema) |
| 160 | + |
| 161 | +export const apiAuthCodexLogout = (req: { readonly label?: string | null; readonly codexAuthPath: string }) => |
| 162 | + apiPost("/auth/codex/logout", req, AuthStatusResponseSchema) |
| 163 | + |
| 164 | +export const apiAuthClaudeLogin = (req: { readonly label?: string | null; readonly claudeAuthPath: string }) => |
| 165 | + apiPost("/auth/claude/login", req, AuthStatusResponseSchema) |
| 166 | + |
| 167 | +export const apiAuthClaudeStatus = (req: { readonly label?: string | null; readonly claudeAuthPath: string }) => |
| 168 | + apiPost("/auth/claude/status", req, AuthStatusResponseSchema) |
| 169 | + |
| 170 | +export const apiAuthClaudeLogout = (req: { readonly label?: string | null; readonly claudeAuthPath: string }) => |
| 171 | + apiPost("/auth/claude/logout", req, AuthStatusResponseSchema) |
| 172 | + |
| 173 | +// ─── State endpoints ───────────────────────────────────────────────────────── |
| 174 | + |
| 175 | +export const apiStatePath = () => apiGet("/state/path", StatePathResponseSchema) |
| 176 | + |
| 177 | +export const apiStateInit = (req: { readonly repoUrl: string; readonly repoRef?: string }) => |
| 178 | + apiPost("/state/init", req, StateOutputResponseSchema) |
| 179 | + |
| 180 | +export const apiStateStatus = () => apiGet("/state/status", StateOutputResponseSchema) |
| 181 | + |
| 182 | +export const apiStatePull = () => apiPost("/state/pull", {}, StateOutputResponseSchema) |
| 183 | + |
| 184 | +export const apiStatePush = () => apiPost("/state/push", {}, StateOutputResponseSchema) |
| 185 | + |
| 186 | +export const apiStateCommit = (req: { readonly message: string }) => |
| 187 | + apiPost("/state/commit", req, StateOutputResponseSchema) |
| 188 | + |
| 189 | +export const apiStateSync = (req: { readonly message?: string | null }) => |
| 190 | + apiPost("/state/sync", req, StateOutputResponseSchema) |
| 191 | + |
| 192 | +// ─── Scrap endpoints ────────────────────────────────────────────────────────── |
| 193 | + |
| 194 | +export const apiScrapExport = (req: { readonly projectDir: string; readonly archivePath?: string }) => |
| 195 | + apiPost("/scrap/export", req, SessionsOutputSchema) |
| 196 | + |
| 197 | +export const apiScrapImport = (req: { |
| 198 | + readonly projectDir: string |
| 199 | + readonly archivePath: string |
| 200 | + readonly wipe?: boolean |
| 201 | +}) => apiPost("/scrap/import", req, SessionsOutputSchema) |
| 202 | + |
| 203 | +// ─── MCP Playwright ─────────────────────────────────────────────────────────── |
| 204 | + |
| 205 | +export const apiMcpPlaywrightUp = (req: { readonly projectDir: string; readonly runUp?: boolean }) => |
| 206 | + apiPost("/mcp-playwright", req, SessionsOutputSchema) |
| 207 | + |
| 208 | +// ─── Sessions endpoints ─────────────────────────────────────────────────────── |
| 209 | + |
| 210 | +export const apiSessionsList = (req: { readonly projectDir: string; readonly includeDefault?: boolean }) => |
| 211 | + apiPost("/sessions/list", req, SessionsOutputSchema) |
| 212 | + |
| 213 | +export const apiSessionsKill = (req: { readonly projectDir: string; readonly pid: number }) => |
| 214 | + apiPost("/sessions/kill", req, SessionsOutputSchema) |
| 215 | + |
| 216 | +export const apiSessionsLogs = (req: { readonly projectDir: string; readonly pid: number; readonly lines?: number }) => |
| 217 | + apiPost("/sessions/logs", req, SessionsOutputSchema) |
| 218 | + |
| 219 | +// ─── Project create request ─────────────────────────────────────────────────── |
| 220 | + |
| 221 | +export type ProjectCreateRequest = { |
| 222 | + readonly repoUrl?: string | undefined |
| 223 | + readonly repoRef?: string | undefined |
| 224 | + readonly targetDir?: string | undefined |
| 225 | + readonly sshPort?: string | undefined |
| 226 | + readonly sshUser?: string | undefined |
| 227 | + readonly containerName?: string | undefined |
| 228 | + readonly serviceName?: string | undefined |
| 229 | + readonly volumeName?: string | undefined |
| 230 | + readonly authorizedKeysPath?: string | undefined |
| 231 | + readonly envGlobalPath?: string | undefined |
| 232 | + readonly envProjectPath?: string | undefined |
| 233 | + readonly codexAuthPath?: string | undefined |
| 234 | + readonly codexHome?: string | undefined |
| 235 | + readonly cpuLimit?: string | undefined |
| 236 | + readonly ramLimit?: string | undefined |
| 237 | + readonly dockerNetworkMode?: string | undefined |
| 238 | + readonly dockerSharedNetworkName?: string | undefined |
| 239 | + readonly enableMcpPlaywright?: boolean | undefined |
| 240 | + readonly outDir?: string | undefined |
| 241 | + readonly gitTokenLabel?: string | undefined |
| 242 | + readonly codexTokenLabel?: string | undefined |
| 243 | + readonly claudeTokenLabel?: string | undefined |
| 244 | + readonly agentAutoMode?: string | undefined |
| 245 | + readonly up?: boolean | undefined |
| 246 | + readonly openSsh?: boolean | undefined |
| 247 | + readonly force?: boolean | undefined |
| 248 | + readonly forceEnv?: boolean | undefined |
| 249 | +} |
| 250 | + |
| 251 | +// ─── Project apply request ──────────────────────────────────────────────────── |
| 252 | + |
| 253 | +export type ProjectApplyRequest = { |
| 254 | + readonly runUp?: boolean | undefined |
| 255 | + readonly gitTokenLabel?: string | undefined |
| 256 | + readonly codexTokenLabel?: string | undefined |
| 257 | + readonly claudeTokenLabel?: string | undefined |
| 258 | + readonly cpuLimit?: string | undefined |
| 259 | + readonly ramLimit?: string | undefined |
| 260 | + readonly enableMcpPlaywright?: boolean | undefined |
| 261 | +} |
| 262 | + |
| 263 | +// ─── Projects endpoints ─────────────────────────────────────────────────────── |
| 264 | + |
| 265 | +export const apiProjectsList = () => apiGet("/projects", ProjectsListResponseSchema) |
| 266 | + |
| 267 | +export const apiProjectCreate = (req: ProjectCreateRequest) => apiPost("/projects", req, ProjectCreatedResponseSchema) |
| 268 | + |
| 269 | +export const apiProjectsDownAll = () => apiPost("/projects/down-all", {}, OkResponseSchema) |
| 270 | + |
| 271 | +export const apiProjectApply = (projectId: string, req: ProjectApplyRequest) => |
| 272 | + apiPost(`/projects/${encodeURIComponent(projectId)}/apply`, req, ApplyResultSchema) |
0 commit comments