Skip to content

Commit c9531d0

Browse files
skulidropekclaude
andcommitted
feat(app): add api-client HTTP module
- HTTP client for the unified REST API via DOCKER_GIT_API_URL env var - Typed ProjectCreateRequest and ProjectApplyRequest interfaces (no unknown/Record<string,unknown>) - O(n) trailing slash removal without backtracking regex (sonarjs/slow-regex safe) - ProjectDetailsSchema extends ProjectSummarySchema.fields (no code duplication) - EFFECT: Effect<T, ApiClientError, HttpClient.HttpClient> per request Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 9dda672 commit c9531d0

1 file changed

Lines changed: 272 additions & 0 deletions

File tree

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
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

Comments
 (0)