From 924b7fa460b52ef0c9c16276bb62da45bc57ef57 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:08:44 +0000 Subject: [PATCH 1/8] fix(shell): restart browser sidecar reliably via healthcheck + DinD isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add healthcheck to browser service (curl /json/version on port 9223) so Docker knows when CDP is actually ready instead of just when the container started - switch depends_on to condition: service_healthy so the main container waits for a healthy browser before starting — fixes the restart race condition (#137) - replace host docker.sock bind-mount in docker-compose.api.yml with a dedicated DinD service (docker:27-dind) and set DOCKER_HOST=tcp://dind:2375 in api, providing full Docker isolation without touching the host daemon Closes #137 Co-Authored-By: Claude Sonnet 4.6 --- docker-compose.api.yml | 17 ++++++++++++++++- .../lib/src/core/templates/docker-compose.ts | 4 ++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/docker-compose.api.yml b/docker-compose.api.yml index 4f68d18e..94e4020d 100644 --- a/docker-compose.api.yml +++ b/docker-compose.api.yml @@ -1,4 +1,14 @@ services: + dind: + image: docker:27-dind + container_name: docker-git-dind + privileged: true + environment: + DOCKER_TLS_CERTDIR: "" + volumes: + - docker-git-dind-storage:/var/lib/docker + restart: unless-stopped + api: build: context: . @@ -9,9 +19,14 @@ services: DOCKER_GIT_PROJECTS_ROOT: ${DOCKER_GIT_PROJECTS_ROOT:-/home/dev/.docker-git} DOCKER_GIT_FEDERATION_PUBLIC_ORIGIN: ${DOCKER_GIT_FEDERATION_PUBLIC_ORIGIN:-} DOCKER_GIT_FEDERATION_ACTOR: ${DOCKER_GIT_FEDERATION_ACTOR:-docker-git} + DOCKER_HOST: tcp://dind:2375 ports: - "${DOCKER_GIT_API_BIND_HOST:-127.0.0.1}:${DOCKER_GIT_API_PORT:-3334}:${DOCKER_GIT_API_PORT:-3334}" volumes: - - /var/run/docker.sock:/var/run/docker.sock - ${DOCKER_GIT_PROJECTS_ROOT_HOST:-/home/dev/.docker-git}:${DOCKER_GIT_PROJECTS_ROOT:-/home/dev/.docker-git} + depends_on: + - dind restart: unless-stopped + +volumes: + docker-git-dind-storage: diff --git a/packages/lib/src/core/templates/docker-compose.ts b/packages/lib/src/core/templates/docker-compose.ts index e8657cc2..180e7d35 100644 --- a/packages/lib/src/core/templates/docker-compose.ts +++ b/packages/lib/src/core/templates/docker-compose.ts @@ -78,13 +78,13 @@ const buildPlaywrightFragments = ( const browserCdpEndpoint = `http://${browserServiceName}:9223` return { - maybeDependsOn: ` depends_on:\n - ${browserServiceName}\n`, + maybeDependsOn: ` depends_on:\n ${browserServiceName}:\n condition: service_healthy\n`, maybePlaywrightEnv: ` MCP_PLAYWRIGHT_ENABLE: "1"\n MCP_PLAYWRIGHT_CDP_ENDPOINT: "${browserCdpEndpoint}"\n`, maybeBrowserService: `\n ${browserServiceName}:\n build:\n context: .\n dockerfile: ${browserDockerfile}\n container_name: ${browserContainerName}\n restart: unless-stopped\n${ renderResourceLimits(resourceLimits) - } environment:\n VNC_NOPW: "1"\n shm_size: "2gb"\n expose:\n - "9223"\n volumes:\n - ${browserVolumeName}:/data\n networks:\n - ${networkName}\n`, + } healthcheck:\n test: ["CMD", "curl", "-sf", "http://localhost:9223/json/version"]\n interval: 5s\n timeout: 3s\n retries: 10\n start_period: 15s\n environment:\n VNC_NOPW: "1"\n shm_size: "2gb"\n expose:\n - "9223"\n volumes:\n - ${browserVolumeName}:/data\n networks:\n - ${networkName}\n`, maybeBrowserVolume: ` ${browserVolumeName}:\n` } } From 9dda6727dcf77540eb3c4d215cd95b7c4d4e97ed Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Mon, 16 Mar 2026 07:23:18 +0000 Subject: [PATCH 2/8] feat(api): add auth/state/scrap/sessions/mcp-playwright endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add REST endpoints for all CLI commands: /auth/github, /auth/codex, /auth/claude, /state/*, /scrap/*, /sessions/*, /mcp-playwright, /projects/down-all, /projects/:id/apply - Add captureLogOutput utility to capture Effect.log output as response body - POST /projects/down-all placed before parametric /:projectId routes - INVARIANT: ∀ cmd ∈ CLICommands \ {Attach, Panes, Menu}: ∃ endpoint: API handles cmd Co-Authored-By: Claude Sonnet 4.6 --- packages/api/src/api/contracts.ts | 115 +++++++++ packages/api/src/api/schema.ts | 101 ++++++++ packages/api/src/http.ts | 248 +++++++++++++++++++- packages/api/src/services/auth.ts | 148 ++++++++++++ packages/api/src/services/capture-output.ts | 32 +++ packages/api/src/services/mcp-playwright.ts | 30 +++ packages/api/src/services/projects.ts | 43 +++- packages/api/src/services/scrap.ts | 47 ++++ packages/api/src/services/sessions.ts | 59 +++++ packages/api/src/services/state.ts | 77 ++++++ 10 files changed, 897 insertions(+), 3 deletions(-) create mode 100644 packages/api/src/services/auth.ts create mode 100644 packages/api/src/services/capture-output.ts create mode 100644 packages/api/src/services/mcp-playwright.ts create mode 100644 packages/api/src/services/scrap.ts create mode 100644 packages/api/src/services/sessions.ts create mode 100644 packages/api/src/services/state.ts diff --git a/packages/api/src/api/contracts.ts b/packages/api/src/api/contracts.ts index 9a3f0d60..c1d88fc9 100644 --- a/packages/api/src/api/contracts.ts +++ b/packages/api/src/api/contracts.ts @@ -227,3 +227,118 @@ export type ApiEvent = { readonly at: string readonly payload: unknown } + +// Auth +export type AuthStatusResponse = { readonly message: string } + +export type AuthGithubLoginRequest = { + readonly label?: string | null | undefined + readonly token?: string | null | undefined + readonly scopes?: string | null | undefined + readonly envGlobalPath: string +} + +export type AuthGithubStatusRequest = { + readonly envGlobalPath: string +} + +export type AuthGithubLogoutRequest = { + readonly label?: string | null | undefined + readonly envGlobalPath: string +} + +export type AuthCodexLoginRequest = { + readonly label?: string | null | undefined + readonly codexAuthPath: string +} + +export type AuthCodexStatusRequest = { + readonly label?: string | null | undefined + readonly codexAuthPath: string +} + +export type AuthCodexLogoutRequest = { + readonly label?: string | null | undefined + readonly codexAuthPath: string +} + +export type AuthClaudeLoginRequest = { + readonly label?: string | null | undefined + readonly claudeAuthPath: string +} + +export type AuthClaudeStatusRequest = { + readonly label?: string | null | undefined + readonly claudeAuthPath: string +} + +export type AuthClaudeLogoutRequest = { + readonly label?: string | null | undefined + readonly claudeAuthPath: string +} + +// State +export type StateInitRequest = { + readonly repoUrl: string + readonly repoRef?: string | undefined +} + +export type StateCommitRequest = { + readonly message: string +} + +export type StateSyncRequest = { + readonly message?: string | null | undefined +} + +export type StatePathResponse = { readonly path: string } + +export type StateOutputResponse = { readonly output: string } + +// Scrap +export type ScrapExportRequest = { + readonly projectDir: string + readonly archivePath?: string | undefined +} + +export type ScrapImportRequest = { + readonly projectDir: string + readonly archivePath: string + readonly wipe?: boolean | undefined +} + +// Sessions +export type SessionsListRequest = { + readonly projectDir: string + readonly includeDefault?: boolean | undefined +} + +export type SessionsKillRequest = { + readonly projectDir: string + readonly pid: number +} + +export type SessionsLogsRequest = { + readonly projectDir: string + readonly pid: number + readonly lines?: number | undefined +} + +export type SessionsOutput = { readonly output: string } + +// MCP Playwright +export type McpPlaywrightUpRequest = { + readonly projectDir: string + readonly runUp?: boolean | undefined +} + +// Apply (project config) +export type ApplyRequest = { + readonly runUp?: boolean | undefined + readonly gitTokenLabel?: string | undefined + readonly codexTokenLabel?: string | undefined + readonly claudeTokenLabel?: string | undefined + readonly cpuLimit?: string | undefined + readonly ramLimit?: string | undefined + readonly enableMcpPlaywright?: boolean | undefined +} diff --git a/packages/api/src/api/schema.ts b/packages/api/src/api/schema.ts index eadcd700..2b18f71d 100644 --- a/packages/api/src/api/schema.ts +++ b/packages/api/src/api/schema.ts @@ -86,3 +86,104 @@ export const AgentLogLineSchema = Schema.Struct({ export type CreateProjectRequestInput = Schema.Schema.Type export type CreateAgentRequestInput = Schema.Schema.Type export type CreateFollowRequestInput = Schema.Schema.Type + +export const AuthGithubLoginRequestSchema = Schema.Struct({ + label: Schema.optional(Schema.NullOr(Schema.String)), + token: Schema.optional(Schema.NullOr(Schema.String)), + scopes: Schema.optional(Schema.NullOr(Schema.String)), + envGlobalPath: Schema.String +}) + +export const AuthGithubStatusRequestSchema = Schema.Struct({ + envGlobalPath: Schema.String +}) + +export const AuthGithubLogoutRequestSchema = Schema.Struct({ + label: Schema.optional(Schema.NullOr(Schema.String)), + envGlobalPath: Schema.String +}) + +export const AuthCodexLoginRequestSchema = Schema.Struct({ + label: Schema.optional(Schema.NullOr(Schema.String)), + codexAuthPath: Schema.String +}) + +export const AuthCodexStatusRequestSchema = Schema.Struct({ + label: Schema.optional(Schema.NullOr(Schema.String)), + codexAuthPath: Schema.String +}) + +export const AuthCodexLogoutRequestSchema = Schema.Struct({ + label: Schema.optional(Schema.NullOr(Schema.String)), + codexAuthPath: Schema.String +}) + +export const AuthClaudeLoginRequestSchema = Schema.Struct({ + label: Schema.optional(Schema.NullOr(Schema.String)), + claudeAuthPath: Schema.String +}) + +export const AuthClaudeStatusRequestSchema = Schema.Struct({ + label: Schema.optional(Schema.NullOr(Schema.String)), + claudeAuthPath: Schema.String +}) + +export const AuthClaudeLogoutRequestSchema = Schema.Struct({ + label: Schema.optional(Schema.NullOr(Schema.String)), + claudeAuthPath: Schema.String +}) + +export const StateInitRequestSchema = Schema.Struct({ + repoUrl: Schema.String, + repoRef: OptionalString +}) + +export const StateCommitRequestSchema = Schema.Struct({ + message: Schema.String +}) + +export const StateSyncRequestSchema = Schema.Struct({ + message: Schema.optional(Schema.NullOr(Schema.String)) +}) + +export const ScrapExportRequestSchema = Schema.Struct({ + projectDir: Schema.String, + archivePath: OptionalString +}) + +export const ScrapImportRequestSchema = Schema.Struct({ + projectDir: Schema.String, + archivePath: Schema.String, + wipe: OptionalBoolean +}) + +export const SessionsListRequestSchema = Schema.Struct({ + projectDir: Schema.String, + includeDefault: OptionalBoolean +}) + +export const SessionsKillRequestSchema = Schema.Struct({ + projectDir: Schema.String, + pid: Schema.Number +}) + +export const SessionsLogsRequestSchema = Schema.Struct({ + projectDir: Schema.String, + pid: Schema.Number, + lines: Schema.optional(Schema.Number) +}) + +export const McpPlaywrightUpRequestSchema = Schema.Struct({ + projectDir: Schema.String, + runUp: OptionalBoolean +}) + +export const ApplyRequestSchema = Schema.Struct({ + runUp: OptionalBoolean, + gitTokenLabel: OptionalString, + codexTokenLabel: OptionalString, + claudeTokenLabel: OptionalString, + cpuLimit: OptionalString, + ramLimit: OptionalString, + enableMcpPlaywright: OptionalBoolean +}) diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index c92a4a6c..8c51ed97 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -37,6 +37,51 @@ import { recreateProject, upProject } from "./services/projects.js" +import { + runAuthClaudeLogin, + runAuthClaudeLogout, + runAuthClaudeStatus, + runAuthCodexLogin, + runAuthCodexLogout, + runAuthCodexStatus, + runAuthGithubLogin, + runAuthGithubLogout, + runAuthGithubStatus +} from "./services/auth.js" +import { runMcpPlaywrightUp } from "./services/mcp-playwright.js" +import { applyProject, downAllProjects } from "./services/projects.js" +import { runScrapExport, runScrapImport } from "./services/scrap.js" +import { runSessionsKill, runSessionsList, runSessionsLogs } from "./services/sessions.js" +import { + runStateCommit, + runStateInit, + runStatePath, + runStatePull, + runStatePush, + runStateStatus, + runStateSync +} from "./services/state.js" +import { + AuthClaudeLoginRequestSchema, + AuthClaudeLogoutRequestSchema, + AuthClaudeStatusRequestSchema, + AuthCodexLoginRequestSchema, + AuthCodexLogoutRequestSchema, + AuthCodexStatusRequestSchema, + AuthGithubLoginRequestSchema, + AuthGithubLogoutRequestSchema, + AuthGithubStatusRequestSchema, + ApplyRequestSchema, + McpPlaywrightUpRequestSchema, + ScrapExportRequestSchema, + ScrapImportRequestSchema, + SessionsKillRequestSchema, + SessionsListRequestSchema, + SessionsLogsRequestSchema, + StateCommitRequestSchema, + StateInitRequestSchema, + StateSyncRequestSchema +} from "./api/schema.js" const ProjectParamsSchema = Schema.Struct({ projectId: Schema.String @@ -396,7 +441,208 @@ export const makeRouter = () => { ) ) - return withAgents.pipe( + const withAuth = withAgents.pipe( + HttpRouter.post( + "/auth/github/login", + Effect.gen(function*(_) { + const req = yield* _(HttpServerRequest.schemaBodyJson(AuthGithubLoginRequestSchema)) + const result = yield* _(runAuthGithubLogin(req)) + return yield* _(jsonResponse(result, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.get( + "/auth/github/status", + Effect.gen(function*(_) { + const req = yield* _(HttpServerRequest.schemaBodyJson(AuthGithubStatusRequestSchema)) + const result = yield* _(runAuthGithubStatus(req)) + return yield* _(jsonResponse(result, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.post( + "/auth/github/logout", + Effect.gen(function*(_) { + const req = yield* _(HttpServerRequest.schemaBodyJson(AuthGithubLogoutRequestSchema)) + const result = yield* _(runAuthGithubLogout(req)) + return yield* _(jsonResponse(result, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.post( + "/auth/codex/login", + Effect.gen(function*(_) { + const req = yield* _(HttpServerRequest.schemaBodyJson(AuthCodexLoginRequestSchema)) + const result = yield* _(runAuthCodexLogin(req)) + return yield* _(jsonResponse(result, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.get( + "/auth/codex/status", + Effect.gen(function*(_) { + const req = yield* _(HttpServerRequest.schemaBodyJson(AuthCodexStatusRequestSchema)) + const result = yield* _(runAuthCodexStatus(req)) + return yield* _(jsonResponse(result, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.post( + "/auth/codex/logout", + Effect.gen(function*(_) { + const req = yield* _(HttpServerRequest.schemaBodyJson(AuthCodexLogoutRequestSchema)) + const result = yield* _(runAuthCodexLogout(req)) + return yield* _(jsonResponse(result, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.post( + "/auth/claude/login", + Effect.gen(function*(_) { + const req = yield* _(HttpServerRequest.schemaBodyJson(AuthClaudeLoginRequestSchema)) + const result = yield* _(runAuthClaudeLogin(req)) + return yield* _(jsonResponse(result, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.get( + "/auth/claude/status", + Effect.gen(function*(_) { + const req = yield* _(HttpServerRequest.schemaBodyJson(AuthClaudeStatusRequestSchema)) + const result = yield* _(runAuthClaudeStatus(req)) + return yield* _(jsonResponse(result, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.post( + "/auth/claude/logout", + Effect.gen(function*(_) { + const req = yield* _(HttpServerRequest.schemaBodyJson(AuthClaudeLogoutRequestSchema)) + const result = yield* _(runAuthClaudeLogout(req)) + return yield* _(jsonResponse(result, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ) + ) + + const withState = withAuth.pipe( + HttpRouter.get( + "/state/path", + runStatePath().pipe( + Effect.flatMap((result) => jsonResponse(result, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.post( + "/state/init", + Effect.gen(function*(_) { + const req = yield* _(HttpServerRequest.schemaBodyJson(StateInitRequestSchema)) + const result = yield* _(runStateInit(req)) + return yield* _(jsonResponse(result, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.get( + "/state/status", + runStateStatus().pipe( + Effect.flatMap((result) => jsonResponse(result, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.post( + "/state/pull", + runStatePull().pipe( + Effect.flatMap((result) => jsonResponse(result, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.post( + "/state/push", + runStatePush().pipe( + Effect.flatMap((result) => jsonResponse(result, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.post( + "/state/commit", + Effect.gen(function*(_) { + const req = yield* _(HttpServerRequest.schemaBodyJson(StateCommitRequestSchema)) + const result = yield* _(runStateCommit(req)) + return yield* _(jsonResponse(result, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.post( + "/state/sync", + Effect.gen(function*(_) { + const req = yield* _(HttpServerRequest.schemaBodyJson(StateSyncRequestSchema)) + const result = yield* _(runStateSync(req)) + return yield* _(jsonResponse(result, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ) + ) + + const withScrapAndSessions = withState.pipe( + HttpRouter.post( + "/scrap/export", + Effect.gen(function*(_) { + const req = yield* _(HttpServerRequest.schemaBodyJson(ScrapExportRequestSchema)) + const result = yield* _(runScrapExport(req)) + return yield* _(jsonResponse(result, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.post( + "/scrap/import", + Effect.gen(function*(_) { + const req = yield* _(HttpServerRequest.schemaBodyJson(ScrapImportRequestSchema)) + const result = yield* _(runScrapImport(req)) + return yield* _(jsonResponse(result, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.post( + "/mcp-playwright", + Effect.gen(function*(_) { + const req = yield* _(HttpServerRequest.schemaBodyJson(McpPlaywrightUpRequestSchema)) + const result = yield* _(runMcpPlaywrightUp(req)) + return yield* _(jsonResponse(result, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.post( + "/sessions/list", + Effect.gen(function*(_) { + const req = yield* _(HttpServerRequest.schemaBodyJson(SessionsListRequestSchema)) + const result = yield* _(runSessionsList(req)) + return yield* _(jsonResponse(result, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.post( + "/sessions/kill", + Effect.gen(function*(_) { + const req = yield* _(HttpServerRequest.schemaBodyJson(SessionsKillRequestSchema)) + const result = yield* _(runSessionsKill(req)) + return yield* _(jsonResponse(result, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ), + HttpRouter.post( + "/sessions/logs", + Effect.gen(function*(_) { + const req = yield* _(HttpServerRequest.schemaBodyJson(SessionsLogsRequestSchema)) + const result = yield* _(runSessionsLogs(req)) + return yield* _(jsonResponse(result, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ) + ) + + // NOTE: POST /projects/down-all MUST be before /:projectId routes + const withProjectExtensions = withScrapAndSessions.pipe( + HttpRouter.post( + "/projects/down-all", + downAllProjects().pipe( + Effect.flatMap(() => jsonResponse({ ok: true }, 200)), + Effect.catchAll(errorResponse) + ) + ), + HttpRouter.post( + "/projects/:projectId/apply", + Effect.gen(function*(_) { + const { projectId } = yield* _(projectParams) + const req = yield* _(HttpServerRequest.schemaBodyJson(ApplyRequestSchema)) + const result = yield* _(applyProject(projectId, req)) + return yield* _(jsonResponse(result, 200)) + }).pipe(Effect.catchAll(errorResponse)) + ) + ) + + return withProjectExtensions.pipe( HttpRouter.get( "/projects/:projectId/events", projectParams.pipe( diff --git a/packages/api/src/services/auth.ts b/packages/api/src/services/auth.ts new file mode 100644 index 00000000..fbe32ad9 --- /dev/null +++ b/packages/api/src/services/auth.ts @@ -0,0 +1,148 @@ +import { + authClaudeLogin, + authClaudeLogout, + authClaudeStatus, + authCodexLogin, + authCodexLogout, + authCodexStatus, + authGithubLogin, + authGithubLogout, + authGithubStatus +} from "@effect-template/lib/usecases/auth" +import { Effect } from "effect" + +import type { + AuthClaudeLoginRequest, + AuthClaudeLogoutRequest, + AuthClaudeStatusRequest, + AuthCodexLoginRequest, + AuthCodexLogoutRequest, + AuthCodexStatusRequest, + AuthGithubLoginRequest, + AuthGithubLogoutRequest, + AuthGithubStatusRequest +} from "../api/contracts.js" +import { ApiInternalError } from "../api/errors.js" +import { captureLogOutput } from "./capture-output.js" + +const toApiError = (cause: unknown): ApiInternalError => + new ApiInternalError({ + message: String(cause), + cause: cause instanceof Error ? cause : new Error(String(cause)) + }) + +// CHANGE: expose lib auth functions through REST API +// WHY: CLI becomes HTTP client; all auth operations run on API server +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: captured log messages form the response body +// COMPLEXITY: O(n) where n = env file size + +export const runAuthGithubLogin = (req: AuthGithubLoginRequest) => + captureLogOutput( + authGithubLogin({ + _tag: "AuthGithubLogin", + label: req.label ?? null, + token: req.token ?? null, + scopes: req.scopes ?? null, + envGlobalPath: req.envGlobalPath + }) + ).pipe( + Effect.map(({ output }) => ({ message: output.length > 0 ? output : "Done." })), + Effect.mapError(toApiError) + ) + +export const runAuthGithubStatus = (req: AuthGithubStatusRequest) => + captureLogOutput( + authGithubStatus({ + _tag: "AuthGithubStatus", + envGlobalPath: req.envGlobalPath + }) + ).pipe( + Effect.map(({ output }) => ({ message: output.length > 0 ? output : "(no status)" })), + Effect.mapError(toApiError) + ) + +export const runAuthGithubLogout = (req: AuthGithubLogoutRequest) => + captureLogOutput( + authGithubLogout({ + _tag: "AuthGithubLogout", + label: req.label ?? null, + envGlobalPath: req.envGlobalPath + }) + ).pipe( + Effect.map(({ output }) => ({ message: output.length > 0 ? output : "Done." })), + Effect.mapError(toApiError) + ) + +export const runAuthCodexLogin = (req: AuthCodexLoginRequest) => + captureLogOutput( + authCodexLogin({ + _tag: "AuthCodexLogin", + label: req.label ?? null, + codexAuthPath: req.codexAuthPath + }) + ).pipe( + Effect.map(({ output }) => ({ message: output.length > 0 ? output : "Done." })), + Effect.mapError(toApiError) + ) + +export const runAuthCodexStatus = (req: AuthCodexStatusRequest) => + captureLogOutput( + authCodexStatus({ + _tag: "AuthCodexStatus", + label: req.label ?? null, + codexAuthPath: req.codexAuthPath + }) + ).pipe( + Effect.map(({ output }) => ({ message: output.length > 0 ? output : "(no status)" })), + Effect.mapError(toApiError) + ) + +export const runAuthCodexLogout = (req: AuthCodexLogoutRequest) => + captureLogOutput( + authCodexLogout({ + _tag: "AuthCodexLogout", + label: req.label ?? null, + codexAuthPath: req.codexAuthPath + }) + ).pipe( + Effect.map(({ output }) => ({ message: output.length > 0 ? output : "Done." })), + Effect.mapError(toApiError) + ) + +export const runAuthClaudeLogin = (req: AuthClaudeLoginRequest) => + captureLogOutput( + authClaudeLogin({ + _tag: "AuthClaudeLogin", + label: req.label ?? null, + claudeAuthPath: req.claudeAuthPath + }) + ).pipe( + Effect.map(({ output }) => ({ message: output.length > 0 ? output : "Done." })), + Effect.mapError(toApiError) + ) + +export const runAuthClaudeStatus = (req: AuthClaudeStatusRequest) => + captureLogOutput( + authClaudeStatus({ + _tag: "AuthClaudeStatus", + label: req.label ?? null, + claudeAuthPath: req.claudeAuthPath + }) + ).pipe( + Effect.map(({ output }) => ({ message: output.length > 0 ? output : "(no status)" })), + Effect.mapError(toApiError) + ) + +export const runAuthClaudeLogout = (req: AuthClaudeLogoutRequest) => + captureLogOutput( + authClaudeLogout({ + _tag: "AuthClaudeLogout", + label: req.label ?? null, + claudeAuthPath: req.claudeAuthPath + }) + ).pipe( + Effect.map(({ output }) => ({ message: output.length > 0 ? output : "Done." })), + Effect.mapError(toApiError) + ) diff --git a/packages/api/src/services/capture-output.ts b/packages/api/src/services/capture-output.ts new file mode 100644 index 00000000..061b943f --- /dev/null +++ b/packages/api/src/services/capture-output.ts @@ -0,0 +1,32 @@ +import { Effect } from "effect" +import * as Logger from "effect/Logger" + +// CHANGE: capture Effect.log output so API can return it as JSON response +// WHY: lib functions communicate results via Effect.log; REST API needs string output +// PURITY: SHELL +// EFFECT: Effect<{ result: A; output: string }, E, R> +// INVARIANT: captured lines are joined with newline +// COMPLEXITY: O(n) where n = log lines +export const captureLogOutput = ( + effect: Effect.Effect +): Effect.Effect<{ result: A; output: string }, E, R> => { + const lines: string[] = [] + const captureLayer = Logger.replace( + Logger.defaultLogger, + Logger.make(({ message }) => { + const text = + typeof message === "string" + ? message + : Array.isArray(message) + ? message.map(String).join(" ") + : String(message) + if (text.trim().length > 0) { + lines.push(text) + } + }) + ) + return effect.pipe( + Effect.provide(captureLayer), + Effect.map((result) => ({ result, output: lines.join("\n") })) + ) +} diff --git a/packages/api/src/services/mcp-playwright.ts b/packages/api/src/services/mcp-playwright.ts new file mode 100644 index 00000000..a023b6d6 --- /dev/null +++ b/packages/api/src/services/mcp-playwright.ts @@ -0,0 +1,30 @@ +import { mcpPlaywrightUp } from "@effect-template/lib/usecases/mcp-playwright" +import { Effect } from "effect" + +import type { McpPlaywrightUpRequest } from "../api/contracts.js" +import { ApiInternalError } from "../api/errors.js" +import { captureLogOutput } from "./capture-output.js" + +const toApiError = (cause: unknown): ApiInternalError => + new ApiInternalError({ + message: String(cause), + cause: cause instanceof Error ? cause : new Error(String(cause)) + }) + +// CHANGE: expose lib mcpPlaywrightUp through REST API +// WHY: CLI becomes HTTP client; MCP Playwright setup runs on API server +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: captured log output forms the response body + +export const runMcpPlaywrightUp = (req: McpPlaywrightUpRequest) => + captureLogOutput( + mcpPlaywrightUp({ + _tag: "McpPlaywrightUp", + projectDir: req.projectDir, + runUp: req.runUp ?? false + }) + ).pipe( + Effect.map(({ output }) => ({ output: output.length > 0 ? output : "Done." })), + Effect.mapError(toApiError) + ) diff --git a/packages/api/src/services/projects.ts b/packages/api/src/services/projects.ts index c91f7e4c..5bd40e42 100644 --- a/packages/api/src/services/projects.ts +++ b/packages/api/src/services/projects.ts @@ -1,12 +1,13 @@ import { buildCreateCommand, createProject, formatParseError, listProjectItems, readProjectConfig } from "@effect-template/lib" import { runCommandCapture } from "@effect-template/lib/shell/command-runner" import { CommandFailedError } from "@effect-template/lib/shell/errors" -import { deleteDockerGitProject } from "@effect-template/lib/usecases/projects" +import { applyProjectConfig } from "@effect-template/lib/usecases/apply" +import { deleteDockerGitProject, downAllDockerGitProjects } from "@effect-template/lib/usecases/projects" import type { RawOptions } from "@effect-template/lib/core/command-options" import type { ProjectItem } from "@effect-template/lib/usecases/projects" import { Effect, Either } from "effect" -import type { CreateProjectRequest, ProjectDetails, ProjectStatus, ProjectSummary } from "../api/contracts.js" +import type { ApplyRequest, CreateProjectRequest, ProjectDetails, ProjectStatus, ProjectSummary } from "../api/contracts.js" import { ApiInternalError, ApiNotFoundError, ApiBadRequestError } from "../api/errors.js" import { emitProjectEvent } from "./events.js" @@ -339,3 +340,41 @@ export const readProjectLogs = ( }) export const resolveProjectById = findProjectById + +export const downAllProjects = () => + downAllDockerGitProjects.pipe( + Effect.mapError( + (cause) => + new ApiInternalError({ + message: String(cause), + cause: cause instanceof Error ? cause : new Error(String(cause)) + }) + ) + ) + +export const applyProject = (projectId: string, request: ApplyRequest) => + getProject(projectId).pipe( + Effect.flatMap((project) => + applyProjectConfig({ + _tag: "Apply", + projectDir: project.projectDir, + runUp: request.runUp ?? false, + gitTokenLabel: request.gitTokenLabel, + codexTokenLabel: request.codexTokenLabel, + claudeTokenLabel: request.claudeTokenLabel, + cpuLimit: request.cpuLimit, + ramLimit: request.ramLimit, + enableMcpPlaywright: request.enableMcpPlaywright + }) + ), + Effect.map((template) => ({ applied: true, containerName: template.containerName })), + Effect.mapError((e) => { + if (e instanceof ApiNotFoundError) { + return e + } + return new ApiInternalError({ + message: String(e), + cause: e instanceof Error ? e : new Error(String(e)) + }) + }) + ) diff --git a/packages/api/src/services/scrap.ts b/packages/api/src/services/scrap.ts new file mode 100644 index 00000000..bc844b0b --- /dev/null +++ b/packages/api/src/services/scrap.ts @@ -0,0 +1,47 @@ +import { exportScrap, importScrap } from "@effect-template/lib/usecases/scrap" +import { Effect } from "effect" + +import type { ScrapExportRequest, ScrapImportRequest } from "../api/contracts.js" +import { ApiInternalError } from "../api/errors.js" +import { captureLogOutput } from "./capture-output.js" + +const DEFAULT_ARCHIVE_PATH = ".orch/scrap/session" + +const toApiError = (cause: unknown): ApiInternalError => + new ApiInternalError({ + message: String(cause), + cause: cause instanceof Error ? cause : new Error(String(cause)) + }) + +// CHANGE: expose lib scrap functions through REST API +// WHY: CLI becomes HTTP client; scrap export/import runs on API server +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: captured log output forms the response body + +export const runScrapExport = (req: ScrapExportRequest) => + captureLogOutput( + exportScrap({ + _tag: "ScrapExport", + projectDir: req.projectDir, + archivePath: req.archivePath ?? DEFAULT_ARCHIVE_PATH, + mode: "session" + }) + ).pipe( + Effect.map(({ output }) => ({ output: output.length > 0 ? output : "Export complete." })), + Effect.mapError(toApiError) + ) + +export const runScrapImport = (req: ScrapImportRequest) => + captureLogOutput( + importScrap({ + _tag: "ScrapImport", + projectDir: req.projectDir, + archivePath: req.archivePath, + wipe: req.wipe ?? true, + mode: "session" + }) + ).pipe( + Effect.map(({ output }) => ({ output: output.length > 0 ? output : "Import complete." })), + Effect.mapError(toApiError) + ) diff --git a/packages/api/src/services/sessions.ts b/packages/api/src/services/sessions.ts new file mode 100644 index 00000000..9400d21e --- /dev/null +++ b/packages/api/src/services/sessions.ts @@ -0,0 +1,59 @@ +import { + killTerminalProcess, + listTerminalSessions, + tailTerminalLogs +} from "@effect-template/lib/usecases/terminal-sessions" +import { Effect } from "effect" + +import type { SessionsKillRequest, SessionsListRequest, SessionsLogsRequest } from "../api/contracts.js" +import { ApiInternalError } from "../api/errors.js" +import { captureLogOutput } from "./capture-output.js" + +const toApiError = (cause: unknown): ApiInternalError => + new ApiInternalError({ + message: String(cause), + cause: cause instanceof Error ? cause : new Error(String(cause)) + }) + +// CHANGE: expose lib terminal-sessions functions through REST API +// WHY: CLI becomes HTTP client; session management runs on API server +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: captured log output forms the response body + +export const runSessionsList = (req: SessionsListRequest) => + captureLogOutput( + listTerminalSessions({ + _tag: "SessionsList", + projectDir: req.projectDir, + includeDefault: req.includeDefault ?? false + }) + ).pipe( + Effect.map(({ output }) => ({ output: output.length > 0 ? output : "(no sessions)" })), + Effect.mapError(toApiError) + ) + +export const runSessionsKill = (req: SessionsKillRequest) => + captureLogOutput( + killTerminalProcess({ + _tag: "SessionsKill", + projectDir: req.projectDir, + pid: req.pid + }) + ).pipe( + Effect.map(({ output }) => ({ output: output.length > 0 ? output : "Done." })), + Effect.mapError(toApiError) + ) + +export const runSessionsLogs = (req: SessionsLogsRequest) => + captureLogOutput( + tailTerminalLogs({ + _tag: "SessionsLogs", + projectDir: req.projectDir, + pid: req.pid, + lines: req.lines ?? 200 + }) + ).pipe( + Effect.map(({ output }) => ({ output: output.length > 0 ? output : "(no output)" })), + Effect.mapError(toApiError) + ) diff --git a/packages/api/src/services/state.ts b/packages/api/src/services/state.ts new file mode 100644 index 00000000..86c8a990 --- /dev/null +++ b/packages/api/src/services/state.ts @@ -0,0 +1,77 @@ +import { + stateCommit, + stateInit, + statePath, + statePull, + statePush, + stateStatus, + stateSync +} from "@effect-template/lib/usecases/state-repo" +import { Effect } from "effect" + +import type { + StateCommitRequest, + StateInitRequest, + StateSyncRequest +} from "../api/contracts.js" +import { ApiInternalError } from "../api/errors.js" +import { captureLogOutput } from "./capture-output.js" + +const toApiError = (cause: unknown): ApiInternalError => + new ApiInternalError({ + message: String(cause), + cause: cause instanceof Error ? cause : new Error(String(cause)) + }) + +// CHANGE: expose lib state-repo functions through REST API +// WHY: CLI becomes HTTP client; all state operations run on API server +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: captured log messages form the response body + +export const runStatePath = () => + captureLogOutput(statePath).pipe( + Effect.map(({ output }) => ({ path: output.trim() })), + Effect.mapError(toApiError) + ) + +export const runStateInit = (req: StateInitRequest) => + captureLogOutput( + stateInit({ + repoUrl: req.repoUrl, + repoRef: req.repoRef ?? "main" + }) + ).pipe( + Effect.map(({ output }) => ({ output: output.length > 0 ? output : "Done." })), + Effect.mapError(toApiError) + ) + +export const runStateStatus = () => + captureLogOutput(stateStatus).pipe( + Effect.map(({ output }) => ({ output: output.length > 0 ? output : "(clean)" })), + Effect.mapError(toApiError) + ) + +export const runStatePull = () => + captureLogOutput(statePull).pipe( + Effect.map(({ output }) => ({ output: output.length > 0 ? output : "Done." })), + Effect.mapError(toApiError) + ) + +export const runStatePush = () => + captureLogOutput(statePush).pipe( + Effect.map(({ output }) => ({ output: output.length > 0 ? output : "Done." })), + Effect.mapError(toApiError) + ) + +export const runStateCommit = (req: StateCommitRequest) => + captureLogOutput(stateCommit(req.message)).pipe( + Effect.map(({ output }) => ({ output: output.length > 0 ? output : "Done." })), + Effect.mapError(toApiError) + ) + +export const runStateSync = (req: StateSyncRequest) => + captureLogOutput(stateSync(req.message ?? null)).pipe( + Effect.map(({ output }) => ({ output: output.length > 0 ? output : "Done." })), + Effect.mapError(toApiError) + ) From c9531d04e5166e9c6c6c092b0471f19b84b77ae8 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Mon, 16 Mar 2026 07:23:26 +0000 Subject: [PATCH 3/8] 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) - O(n) trailing slash removal without backtracking regex (sonarjs/slow-regex safe) - ProjectDetailsSchema extends ProjectSummarySchema.fields (no code duplication) - EFFECT: Effect per request Co-Authored-By: Claude Sonnet 4.6 --- packages/app/src/docker-git/api-client.ts | 272 ++++++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 packages/app/src/docker-git/api-client.ts diff --git a/packages/app/src/docker-git/api-client.ts b/packages/app/src/docker-git/api-client.ts new file mode 100644 index 00000000..400658b6 --- /dev/null +++ b/packages/app/src/docker-git/api-client.ts @@ -0,0 +1,272 @@ +import * as HttpBody from "@effect/platform/HttpBody" +import * as HttpClient from "@effect/platform/HttpClient" +import type * as HttpClientResponse from "@effect/platform/HttpClientResponse" +import { Data, Effect } from "effect" +import * as Schema from "effect/Schema" + +// CHANGE: HTTP client for the unified REST API +// WHY: CLI becomes a thin HTTP frontend; all business logic runs in the API server +// QUOTE(ТЗ): "CLI → DOCKER_GIT_API_URL → REST API" +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: ∀ cmd ∈ CLICommands \ {Attach, Panes, Menu}: handler(cmd) = httpCall(apiEndpoint(cmd)) +// COMPLEXITY: O(1) per request + +export class ApiClientError extends Data.TaggedError("ApiClientError")<{ + readonly message: string +}> {} + +// CHANGE: trim trailing slashes without backtracking regex +// WHY: /\/+$/ is flagged as slow-regex by sonarjs; loop avoids super-linear backtracking +// PURITY: CORE +// COMPLEXITY: O(n) where n = number of trailing slashes (typically 0 or 1) +const resolveApiBaseUrl = (): string => { + const raw = process.env["DOCKER_GIT_API_URL"] ?? "http://localhost:3334" + const trimmed = raw.trim() + let end = trimmed.length + while (end > 0 && trimmed[end - 1] === "/") { + end-- + } + return trimmed.slice(0, end) +} + +const handleResponse = ( + response: HttpClientResponse.HttpClientResponse, + schema: Schema.Schema +): Effect.Effect => + Effect.gen(function*(_) { + if (response.status >= 400) { + const text = yield* _( + response.text.pipe( + Effect.mapError((e) => new ApiClientError({ message: String(e) })) + ) + ) + return yield* _( + Effect.fail(new ApiClientError({ message: `HTTP ${response.status}: ${text}` })) + ) + } + const json = yield* _( + response.json.pipe( + Effect.mapError((e) => new ApiClientError({ message: String(e) })) + ) + ) + return yield* _( + Schema.decodeUnknown(schema)(json).pipe( + Effect.mapError((e) => new ApiClientError({ message: `Response parse error: ${String(e)}` })) + ) + ) + }) + +const apiPost = ( + path: string, + body: object, + schema: Schema.Schema +): Effect.Effect => + Effect.gen(function*(_) { + const client = yield* _(HttpClient.HttpClient) + const url = `${resolveApiBaseUrl()}${path}` + const response = yield* _( + client.post(url, { body: HttpBody.unsafeJson(body) }).pipe( + Effect.mapError((e) => new ApiClientError({ message: String(e) })) + ) + ) + return yield* _(handleResponse(response, schema)) + }) + +const apiGet = ( + path: string, + schema: Schema.Schema +): Effect.Effect => + Effect.gen(function*(_) { + const client = yield* _(HttpClient.HttpClient) + const url = `${resolveApiBaseUrl()}${path}` + const response = yield* _( + client.get(url).pipe( + Effect.mapError((e) => new ApiClientError({ message: String(e) })) + ) + ) + return yield* _(handleResponse(response, schema)) + }) + +// ─── Response schemas ─────────────────────────────────────────────────────── + +const AuthStatusResponseSchema = Schema.Struct({ message: Schema.String }) + +const StatePathResponseSchema = Schema.Struct({ path: Schema.String }) + +const StateOutputResponseSchema = Schema.Struct({ output: Schema.String }) + +const SessionsOutputSchema = Schema.Struct({ output: Schema.String }) + +const OkResponseSchema = Schema.Struct({ ok: Schema.Boolean }) + +const ProjectSummarySchema = Schema.Struct({ + id: Schema.String, + displayName: Schema.String, + repoUrl: Schema.String, + repoRef: Schema.String, + status: Schema.String, + statusLabel: Schema.String +}) + +const ProjectDetailsSchema = Schema.Struct({ + ...ProjectSummarySchema.fields, + containerName: Schema.String, + serviceName: Schema.String, + sshUser: Schema.String, + sshPort: Schema.Number, + targetDir: Schema.String, + projectDir: Schema.String, + sshCommand: Schema.String, + envGlobalPath: Schema.String, + envProjectPath: Schema.String, + codexAuthPath: Schema.String, + codexHome: Schema.String +}) + +const ProjectsListResponseSchema = Schema.Struct({ + projects: Schema.Array(ProjectSummarySchema) +}) + +const ProjectCreatedResponseSchema = Schema.Struct({ + project: ProjectDetailsSchema +}) + +const ApplyResultSchema = Schema.Struct({ + applied: Schema.Boolean, + containerName: Schema.String +}) + +// ─── Auth endpoints ────────────────────────────────────────────────────────── + +export const apiAuthGithubLogin = (req: { + readonly label?: string | null + readonly token?: string | null + readonly scopes?: string | null + readonly envGlobalPath: string +}) => apiPost("/auth/github/login", req, AuthStatusResponseSchema) + +export const apiAuthGithubStatus = (req: { readonly envGlobalPath: string }) => + apiPost("/auth/github/status", req, AuthStatusResponseSchema) + +export const apiAuthGithubLogout = (req: { readonly label?: string | null; readonly envGlobalPath: string }) => + apiPost("/auth/github/logout", req, AuthStatusResponseSchema) + +export const apiAuthCodexLogin = (req: { readonly label?: string | null; readonly codexAuthPath: string }) => + apiPost("/auth/codex/login", req, AuthStatusResponseSchema) + +export const apiAuthCodexStatus = (req: { readonly label?: string | null; readonly codexAuthPath: string }) => + apiPost("/auth/codex/status", req, AuthStatusResponseSchema) + +export const apiAuthCodexLogout = (req: { readonly label?: string | null; readonly codexAuthPath: string }) => + apiPost("/auth/codex/logout", req, AuthStatusResponseSchema) + +export const apiAuthClaudeLogin = (req: { readonly label?: string | null; readonly claudeAuthPath: string }) => + apiPost("/auth/claude/login", req, AuthStatusResponseSchema) + +export const apiAuthClaudeStatus = (req: { readonly label?: string | null; readonly claudeAuthPath: string }) => + apiPost("/auth/claude/status", req, AuthStatusResponseSchema) + +export const apiAuthClaudeLogout = (req: { readonly label?: string | null; readonly claudeAuthPath: string }) => + apiPost("/auth/claude/logout", req, AuthStatusResponseSchema) + +// ─── State endpoints ───────────────────────────────────────────────────────── + +export const apiStatePath = () => apiGet("/state/path", StatePathResponseSchema) + +export const apiStateInit = (req: { readonly repoUrl: string; readonly repoRef?: string }) => + apiPost("/state/init", req, StateOutputResponseSchema) + +export const apiStateStatus = () => apiGet("/state/status", StateOutputResponseSchema) + +export const apiStatePull = () => apiPost("/state/pull", {}, StateOutputResponseSchema) + +export const apiStatePush = () => apiPost("/state/push", {}, StateOutputResponseSchema) + +export const apiStateCommit = (req: { readonly message: string }) => + apiPost("/state/commit", req, StateOutputResponseSchema) + +export const apiStateSync = (req: { readonly message?: string | null }) => + apiPost("/state/sync", req, StateOutputResponseSchema) + +// ─── Scrap endpoints ────────────────────────────────────────────────────────── + +export const apiScrapExport = (req: { readonly projectDir: string; readonly archivePath?: string }) => + apiPost("/scrap/export", req, SessionsOutputSchema) + +export const apiScrapImport = (req: { + readonly projectDir: string + readonly archivePath: string + readonly wipe?: boolean +}) => apiPost("/scrap/import", req, SessionsOutputSchema) + +// ─── MCP Playwright ─────────────────────────────────────────────────────────── + +export const apiMcpPlaywrightUp = (req: { readonly projectDir: string; readonly runUp?: boolean }) => + apiPost("/mcp-playwright", req, SessionsOutputSchema) + +// ─── Sessions endpoints ─────────────────────────────────────────────────────── + +export const apiSessionsList = (req: { readonly projectDir: string; readonly includeDefault?: boolean }) => + apiPost("/sessions/list", req, SessionsOutputSchema) + +export const apiSessionsKill = (req: { readonly projectDir: string; readonly pid: number }) => + apiPost("/sessions/kill", req, SessionsOutputSchema) + +export const apiSessionsLogs = (req: { readonly projectDir: string; readonly pid: number; readonly lines?: number }) => + apiPost("/sessions/logs", req, SessionsOutputSchema) + +// ─── Project create request ─────────────────────────────────────────────────── + +export type ProjectCreateRequest = { + readonly repoUrl?: string | undefined + readonly repoRef?: string | undefined + readonly targetDir?: string | undefined + readonly sshPort?: string | undefined + readonly sshUser?: string | undefined + readonly containerName?: string | undefined + readonly serviceName?: string | undefined + readonly volumeName?: string | undefined + readonly authorizedKeysPath?: string | undefined + readonly envGlobalPath?: string | undefined + readonly envProjectPath?: string | undefined + readonly codexAuthPath?: string | undefined + readonly codexHome?: string | undefined + readonly cpuLimit?: string | undefined + readonly ramLimit?: string | undefined + readonly dockerNetworkMode?: string | undefined + readonly dockerSharedNetworkName?: string | undefined + readonly enableMcpPlaywright?: boolean | undefined + readonly outDir?: string | undefined + readonly gitTokenLabel?: string | undefined + readonly codexTokenLabel?: string | undefined + readonly claudeTokenLabel?: string | undefined + readonly agentAutoMode?: string | undefined + readonly up?: boolean | undefined + readonly openSsh?: boolean | undefined + readonly force?: boolean | undefined + readonly forceEnv?: boolean | undefined +} + +// ─── Project apply request ──────────────────────────────────────────────────── + +export type ProjectApplyRequest = { + readonly runUp?: boolean | undefined + readonly gitTokenLabel?: string | undefined + readonly codexTokenLabel?: string | undefined + readonly claudeTokenLabel?: string | undefined + readonly cpuLimit?: string | undefined + readonly ramLimit?: string | undefined + readonly enableMcpPlaywright?: boolean | undefined +} + +// ─── Projects endpoints ─────────────────────────────────────────────────────── + +export const apiProjectsList = () => apiGet("/projects", ProjectsListResponseSchema) + +export const apiProjectCreate = (req: ProjectCreateRequest) => apiPost("/projects", req, ProjectCreatedResponseSchema) + +export const apiProjectsDownAll = () => apiPost("/projects/down-all", {}, OkResponseSchema) + +export const apiProjectApply = (projectId: string, req: ProjectApplyRequest) => + apiPost(`/projects/${encodeURIComponent(projectId)}/apply`, req, ApplyResultSchema) From d91380ae88159e5dbde6a41e60c42064cad02b6d Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Mon, 16 Mar 2026 07:23:34 +0000 Subject: [PATCH 4/8] feat(app): rewrite CLI program to use unified REST API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CLI is now a thin HTTP client: all business logic delegated to REST API - Extract named handler functions (handleStateX, handleAuthX, etc.) to satisfy max-lines-per-function - Attach and Panes remain local (require tmux/terminal) - Create command: maps config fields → ProjectCreateRequest, conditionally calls attachTmux for openSsh - main.ts: provide FetchHttpClient.layer alongside NodeContext.layer for HttpClient requirement - INVARIANT: ∀ cmd ∈ CLICommands \ {Attach, Panes, Menu}: handler(cmd) = httpCall(apiEndpoint(cmd)) Co-Authored-By: Claude Sonnet 4.6 --- packages/app/src/docker-git/main.ts | 19 +- packages/app/src/docker-git/program.ts | 378 +++++++++++++++++++------ 2 files changed, 300 insertions(+), 97 deletions(-) diff --git a/packages/app/src/docker-git/main.ts b/packages/app/src/docker-git/main.ts index b82e82ea..6cb7113e 100644 --- a/packages/app/src/docker-git/main.ts +++ b/packages/app/src/docker-git/main.ts @@ -1,20 +1,19 @@ #!/usr/bin/env node import { NodeContext, NodeRuntime } from "@effect/platform-node" -import { Effect } from "effect" +import * as FetchHttpClient from "@effect/platform/FetchHttpClient" +import { Effect, Layer } from "effect" import { program } from "./program.js" -// CHANGE: run docker-git CLI through the Node runtime -// WHY: ensure platform services (FS, Path, Command) are available in app CLI -// QUOTE(ТЗ): "CLI (отображение, фронт) это app" -// REF: user-request-2026-01-28-cli-move -// SOURCE: n/a -// FORMAT THEOREM: forall env: runMain(program, env) -> exit +// CHANGE: run docker-git CLI through the Node runtime with FetchHttpClient for API calls +// WHY: FetchHttpClient.layer provides HttpClient.HttpClient service required by api-client.ts +// QUOTE(ТЗ): "CLI → DOCKER_GIT_API_URL → REST API" // PURITY: SHELL -// EFFECT: Effect -// INVARIANT: program runs with NodeContext.layer +// EFFECT: Effect +// INVARIANT: program runs with NodeContext.layer + FetchHttpClient.layer // COMPLEXITY: O(n) -const main = Effect.provide(program, NodeContext.layer) +const mainLayer = Layer.merge(NodeContext.layer, FetchHttpClient.layer) +const main = Effect.provide(program, mainLayer) NodeRuntime.runMain(main) diff --git a/packages/app/src/docker-git/program.ts b/packages/app/src/docker-git/program.ts index dd97d1bc..163aeb0c 100644 --- a/packages/app/src/docker-git/program.ts +++ b/packages/app/src/docker-git/program.ts @@ -1,41 +1,73 @@ -import type { Command, ParseError } from "@effect-template/lib/core/domain" -import { createProject } from "@effect-template/lib/usecases/actions" -import { applyProjectConfig } from "@effect-template/lib/usecases/apply" -import { - authClaudeLogin, - authClaudeLogout, - authClaudeStatus, - authCodexLogin, - authCodexLogout, - authCodexStatus, - authGithubLogin, - authGithubLogout, - authGithubStatus -} from "@effect-template/lib/usecases/auth" +import type { + ApplyCommand, + AttachCommand, + AuthClaudeLoginCommand, + AuthClaudeLogoutCommand, + AuthClaudeStatusCommand, + AuthCodexLoginCommand, + AuthCodexLogoutCommand, + AuthCodexStatusCommand, + AuthGithubLoginCommand, + AuthGithubLogoutCommand, + AuthGithubStatusCommand, + Command, + CreateCommand, + McpPlaywrightUpCommand, + PanesCommand, + ParseError, + ScrapExportCommand, + ScrapImportCommand, + SessionsKillCommand, + SessionsListCommand, + SessionsLogsCommand, + StateCommitCommand, + StateInitCommand, + StateSyncCommand +} from "@effect-template/lib/core/domain" import type { AppError } from "@effect-template/lib/usecases/errors" import { renderError } from "@effect-template/lib/usecases/errors" -import { mcpPlaywrightUp } from "@effect-template/lib/usecases/mcp-playwright" -import { downAllDockerGitProjects, listProjectStatus } from "@effect-template/lib/usecases/projects" -import { exportScrap, importScrap } from "@effect-template/lib/usecases/scrap" -import { - stateCommit, - stateInit, - statePath, - statePull, - statePush, - stateStatus, - stateSync -} from "@effect-template/lib/usecases/state-repo" -import { - killTerminalProcess, - listTerminalSessions, - tailTerminalLogs -} from "@effect-template/lib/usecases/terminal-sessions" import { Effect, Match, pipe } from "effect" + +import type { ApiClientError } from "./api-client.js" +import { + apiAuthClaudeLogin, + apiAuthClaudeLogout, + apiAuthClaudeStatus, + apiAuthCodexLogin, + apiAuthCodexLogout, + apiAuthCodexStatus, + apiAuthGithubLogin, + apiAuthGithubLogout, + apiAuthGithubStatus, + apiMcpPlaywrightUp, + apiProjectApply, + apiProjectCreate, + apiProjectsDownAll, + apiProjectsList, + apiScrapExport, + apiScrapImport, + apiSessionsKill, + apiSessionsList, + apiSessionsLogs, + apiStateCommit, + apiStateInit, + apiStatePath, + apiStatePull, + apiStatePush, + apiStateStatus, + apiStateSync +} from "./api-client.js" import { readCommand } from "./cli/read-command.js" +import { runMenu } from "./menu.js" import { attachTmux, listTmuxPanes } from "./tmux.js" -import { runMenu } from "./menu.js" +// CHANGE: rewrite CLI program to use unified REST API +// WHY: CLI becomes thin HTTP client; business logic lives in API server +// QUOTE(ТЗ): "CLI → DOCKER_GIT_API_URL → REST API → packages/lib → Docker daemon" +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: ∀ cmd ∈ CLICommands \ {Attach, Panes, Menu}: handler(cmd) = httpCall(apiEndpoint(cmd)) +// COMPLEXITY: O(1) per command const isParseError = (error: AppError): error is ParseError => error._tag === "UnknownCommand" || @@ -50,20 +82,22 @@ const setExitCode = (code: number) => process.exitCode = code }) -const logWarningAndExit = (error: AppError) => +const logErrorAndExit = (error: AppError) => pipe( - Effect.logWarning(renderError(error)), + Effect.logError(renderError(error)), Effect.tap(() => setExitCode(1)), Effect.asVoid ) -const logErrorAndExit = (error: AppError) => +const logApiError = (e: ApiClientError) => pipe( - Effect.logError(renderError(error)), + Effect.logError(`API error: ${e.message}`), Effect.tap(() => setExitCode(1)), Effect.asVoid ) +const logMsg = (msg: string) => msg.trim().length > 0 ? Effect.log(msg) : Effect.void + type NonBaseCommand = Exclude< Command, | { readonly _tag: "Help" } @@ -73,74 +107,244 @@ type NonBaseCommand = Exclude< | { readonly _tag: "Menu" } > +// ─── State handlers ────────────────────────────────────────────────────────── + +const handleStatePath = () => apiStatePath().pipe(Effect.flatMap(({ path }) => Effect.log(path))) + +const handleStateInit = (cmd: StateInitCommand) => + apiStateInit({ repoUrl: cmd.repoUrl, repoRef: cmd.repoRef }).pipe( + Effect.flatMap(({ output }) => logMsg(output)) + ) + +const handleStateStatus = () => apiStateStatus().pipe(Effect.flatMap(({ output }) => logMsg(output))) + +const handleStatePull = () => apiStatePull().pipe(Effect.flatMap(({ output }) => logMsg(output))) + +const handleStateCommit = (cmd: StateCommitCommand) => + apiStateCommit({ message: cmd.message }).pipe( + Effect.flatMap(({ output }) => logMsg(output)) + ) + +const handleStatePush = () => apiStatePush().pipe(Effect.flatMap(({ output }) => logMsg(output))) + +const handleStateSync = (cmd: StateSyncCommand) => + apiStateSync({ message: cmd.message ?? null }).pipe( + Effect.flatMap(({ output }) => logMsg(output)) + ) + +// ─── Auth handlers ─────────────────────────────────────────────────────────── + +const handleAuthGithubLogin = (cmd: AuthGithubLoginCommand) => + apiAuthGithubLogin({ + label: cmd.label, + token: cmd.token, + scopes: cmd.scopes, + envGlobalPath: cmd.envGlobalPath + }).pipe(Effect.flatMap(({ message }) => logMsg(message))) + +const handleAuthGithubStatus = (cmd: AuthGithubStatusCommand) => + apiAuthGithubStatus({ envGlobalPath: cmd.envGlobalPath }).pipe( + Effect.flatMap(({ message }) => logMsg(message)) + ) + +const handleAuthGithubLogout = (cmd: AuthGithubLogoutCommand) => + apiAuthGithubLogout({ label: cmd.label, envGlobalPath: cmd.envGlobalPath }).pipe( + Effect.flatMap(({ message }) => logMsg(message)) + ) + +const handleAuthCodexLogin = (cmd: AuthCodexLoginCommand) => + apiAuthCodexLogin({ label: cmd.label, codexAuthPath: cmd.codexAuthPath }).pipe( + Effect.flatMap(({ message }) => logMsg(message)) + ) + +const handleAuthCodexStatus = (cmd: AuthCodexStatusCommand) => + apiAuthCodexStatus({ label: cmd.label, codexAuthPath: cmd.codexAuthPath }).pipe( + Effect.flatMap(({ message }) => logMsg(message)) + ) + +const handleAuthCodexLogout = (cmd: AuthCodexLogoutCommand) => + apiAuthCodexLogout({ label: cmd.label, codexAuthPath: cmd.codexAuthPath }).pipe( + Effect.flatMap(({ message }) => logMsg(message)) + ) + +const handleAuthClaudeLogin = (cmd: AuthClaudeLoginCommand) => + apiAuthClaudeLogin({ label: cmd.label, claudeAuthPath: cmd.claudeAuthPath }).pipe( + Effect.flatMap(({ message }) => logMsg(message)) + ) + +const handleAuthClaudeStatus = (cmd: AuthClaudeStatusCommand) => + apiAuthClaudeStatus({ label: cmd.label, claudeAuthPath: cmd.claudeAuthPath }).pipe( + Effect.flatMap(({ message }) => logMsg(message)) + ) + +const handleAuthClaudeLogout = (cmd: AuthClaudeLogoutCommand) => + apiAuthClaudeLogout({ label: cmd.label, claudeAuthPath: cmd.claudeAuthPath }).pipe( + Effect.flatMap(({ message }) => logMsg(message)) + ) + +// ─── Sessions / Scrap / MCP / Apply handlers ───────────────────────────────── + +const handleAttach = (cmd: AttachCommand) => attachTmux(cmd) + +const handlePanes = (cmd: PanesCommand) => listTmuxPanes(cmd) + +const handleSessionsList = (cmd: SessionsListCommand) => + apiSessionsList({ projectDir: cmd.projectDir, includeDefault: cmd.includeDefault }).pipe( + Effect.flatMap(({ output }) => logMsg(output)) + ) + +const handleSessionsKill = (cmd: SessionsKillCommand) => + apiSessionsKill({ projectDir: cmd.projectDir, pid: cmd.pid }).pipe( + Effect.flatMap(({ output }) => logMsg(output)) + ) + +const handleApply = (cmd: ApplyCommand) => + apiProjectApply(cmd.projectDir, { + runUp: cmd.runUp, + gitTokenLabel: cmd.gitTokenLabel, + codexTokenLabel: cmd.codexTokenLabel, + claudeTokenLabel: cmd.claudeTokenLabel, + cpuLimit: cmd.cpuLimit, + ramLimit: cmd.ramLimit, + enableMcpPlaywright: cmd.enableMcpPlaywright + }).pipe(Effect.flatMap(({ containerName }) => Effect.log(`Applied: ${containerName}`))) + +const handleSessionsLogs = (cmd: SessionsLogsCommand) => + apiSessionsLogs({ projectDir: cmd.projectDir, pid: cmd.pid, lines: cmd.lines }).pipe( + Effect.flatMap(({ output }) => logMsg(output)) + ) + +const handleScrapExport = (cmd: ScrapExportCommand) => + apiScrapExport({ projectDir: cmd.projectDir, archivePath: cmd.archivePath }).pipe( + Effect.flatMap(({ output }) => logMsg(output)) + ) + +const handleScrapImport = (cmd: ScrapImportCommand) => + apiScrapImport({ projectDir: cmd.projectDir, archivePath: cmd.archivePath, wipe: cmd.wipe }).pipe( + Effect.flatMap(({ output }) => logMsg(output)) + ) + +const handleMcpPlaywrightUp = (cmd: McpPlaywrightUpCommand) => + apiMcpPlaywrightUp({ projectDir: cmd.projectDir, runUp: cmd.runUp }).pipe( + Effect.flatMap(({ output }) => logMsg(output)) + ) + +// ─── Non-base command dispatcher ───────────────────────────────────────────── + +// CHANGE: split into named handlers to satisfy max-lines-per-function +// WHY: each Match.when references a named function; dispatcher stays under 50 lines +// PURITY: SHELL +// INVARIANT: ∀ cmd ∈ NonBaseCommand: exactly one Match.when branch handles cmd +// COMPLEXITY: O(1) const handleNonBaseCommand = (command: NonBaseCommand) => Match.value(command) .pipe( - Match.when({ _tag: "StatePath" }, () => statePath), - Match.when({ _tag: "StateInit" }, (cmd) => stateInit(cmd)), - Match.when({ _tag: "StateStatus" }, () => stateStatus), - Match.when({ _tag: "StatePull" }, () => statePull), - Match.when({ _tag: "StateCommit" }, (cmd) => stateCommit(cmd.message)), - Match.when({ _tag: "StatePush" }, () => statePush), - Match.when({ _tag: "StateSync" }, (cmd) => stateSync(cmd.message)), - Match.when({ _tag: "AuthGithubLogin" }, (cmd) => authGithubLogin(cmd)), - Match.when({ _tag: "AuthGithubStatus" }, (cmd) => authGithubStatus(cmd)), - Match.when({ _tag: "AuthGithubLogout" }, (cmd) => authGithubLogout(cmd)), - Match.when({ _tag: "AuthCodexLogin" }, (cmd) => authCodexLogin(cmd)), - Match.when({ _tag: "AuthCodexStatus" }, (cmd) => authCodexStatus(cmd)), - Match.when({ _tag: "AuthCodexLogout" }, (cmd) => authCodexLogout(cmd)), - Match.when({ _tag: "AuthClaudeLogin" }, (cmd) => authClaudeLogin(cmd)), - Match.when({ _tag: "AuthClaudeStatus" }, (cmd) => authClaudeStatus(cmd)), - Match.when({ _tag: "AuthClaudeLogout" }, (cmd) => authClaudeLogout(cmd)), - Match.when({ _tag: "Attach" }, (cmd) => attachTmux(cmd)), - Match.when({ _tag: "Panes" }, (cmd) => listTmuxPanes(cmd)), - Match.when({ _tag: "SessionsList" }, (cmd) => listTerminalSessions(cmd)), - Match.when({ _tag: "SessionsKill" }, (cmd) => killTerminalProcess(cmd)) + Match.when({ _tag: "StatePath" }, handleStatePath), + Match.when({ _tag: "StateInit" }, handleStateInit), + Match.when({ _tag: "StateStatus" }, handleStateStatus), + Match.when({ _tag: "StatePull" }, handleStatePull), + Match.when({ _tag: "StateCommit" }, handleStateCommit), + Match.when({ _tag: "StatePush" }, handleStatePush), + Match.when({ _tag: "StateSync" }, handleStateSync), + Match.when({ _tag: "AuthGithubLogin" }, handleAuthGithubLogin), + Match.when({ _tag: "AuthGithubStatus" }, handleAuthGithubStatus), + Match.when({ _tag: "AuthGithubLogout" }, handleAuthGithubLogout), + Match.when({ _tag: "AuthCodexLogin" }, handleAuthCodexLogin), + Match.when({ _tag: "AuthCodexStatus" }, handleAuthCodexStatus), + Match.when({ _tag: "AuthCodexLogout" }, handleAuthCodexLogout), + Match.when({ _tag: "AuthClaudeLogin" }, handleAuthClaudeLogin), + Match.when({ _tag: "AuthClaudeStatus" }, handleAuthClaudeStatus), + Match.when({ _tag: "AuthClaudeLogout" }, handleAuthClaudeLogout), + Match.when({ _tag: "Attach" }, handleAttach), + Match.when({ _tag: "Panes" }, handlePanes), + Match.when({ _tag: "SessionsList" }, handleSessionsList), + Match.when({ _tag: "SessionsKill" }, handleSessionsKill) ) .pipe( - Match.when({ _tag: "Apply" }, (cmd) => applyProjectConfig(cmd)), - Match.when({ _tag: "SessionsLogs" }, (cmd) => tailTerminalLogs(cmd)), - Match.when({ _tag: "ScrapExport" }, (cmd) => exportScrap(cmd)), - Match.when({ _tag: "ScrapImport" }, (cmd) => importScrap(cmd)), - Match.when({ _tag: "McpPlaywrightUp" }, (cmd) => mcpPlaywrightUp(cmd)), + Match.when({ _tag: "Apply" }, handleApply), + Match.when({ _tag: "SessionsLogs" }, handleSessionsLogs), + Match.when({ _tag: "ScrapExport" }, handleScrapExport), + Match.when({ _tag: "ScrapImport" }, handleScrapImport), + Match.when({ _tag: "McpPlaywrightUp" }, handleMcpPlaywrightUp), Match.exhaustive ) -// CHANGE: compose CLI program with typed errors and shell effects -// WHY: keep a thin entry layer over pure parsing and template generation -// QUOTE(ТЗ): "CLI команду... создавать докер образы" -// REF: user-request-2026-01-07 -// SOURCE: n/a -// FORMAT THEOREM: forall cmd: handle(cmd) terminates with typed outcome +// ─── Create command handler ─────────────────────────────────────────────────── + +// CHANGE: extracted to named function to keep program lambda under 50 lines +// WHY: Create is the most complex command (30+ config fields + conditional SSH attach) // PURITY: SHELL -// EFFECT: Effect -// INVARIANT: help is printed without side effects beyond logs -// COMPLEXITY: O(n) where n = |files| +// INVARIANT: openSsh=true → attachTmux locally after project created on server +const handleCreateCmd = (create: CreateCommand) => + apiProjectCreate({ + repoUrl: create.config.repoUrl, + repoRef: create.config.repoRef, + targetDir: create.config.targetDir, + sshPort: String(create.config.sshPort), + sshUser: create.config.sshUser, + containerName: create.config.containerName, + serviceName: create.config.serviceName, + volumeName: create.config.volumeName, + authorizedKeysPath: create.config.authorizedKeysPath, + envGlobalPath: create.config.envGlobalPath, + envProjectPath: create.config.envProjectPath, + codexAuthPath: create.config.codexAuthPath, + codexHome: create.config.codexHome, + cpuLimit: create.config.cpuLimit, + ramLimit: create.config.ramLimit, + dockerNetworkMode: create.config.dockerNetworkMode, + dockerSharedNetworkName: create.config.dockerSharedNetworkName, + enableMcpPlaywright: create.config.enableMcpPlaywright, + outDir: create.outDir, + gitTokenLabel: create.config.gitTokenLabel, + codexTokenLabel: create.config.codexAuthLabel, + claudeTokenLabel: create.config.claudeAuthLabel, + agentAutoMode: create.config.agentMode, + up: create.runUp, + openSsh: false, + force: create.force, + forceEnv: create.forceEnv + }).pipe( + Effect.flatMap(({ project }) => { + if (create.openSsh) { + return attachTmux({ _tag: "Attach", projectDir: project.projectDir }) + } + return Effect.log(`Project created: ${project.displayName} (${project.sshCommand})`) + }) + ) + +// ─── Status command handler ─────────────────────────────────────────────────── + +const handleStatusCmd = () => + apiProjectsList().pipe( + Effect.flatMap(({ projects }) => { + if (projects.length === 0) { + return Effect.log("No projects found.") + } + const lines = projects.map( + (p) => ` ${p.displayName} [${p.statusLabel}] ${p.repoUrl}` + ) + return Effect.log(lines.join("\n")) + }) + ) + +// ─── Program entry point ────────────────────────────────────────────────────── + export const program = pipe( readCommand, Effect.flatMap((command: Command) => Match.value(command).pipe( Match.when({ _tag: "Help" }, ({ message }) => Effect.log(message)), - Match.when({ _tag: "Create" }, (create) => createProject(create)), - Match.when({ _tag: "Status" }, () => listProjectStatus), - Match.when({ _tag: "DownAll" }, () => downAllDockerGitProjects), - Match.when({ _tag: "Menu" }, () => runMenu), + Match.when({ _tag: "Create" }, handleCreateCmd), + Match.when({ _tag: "Status" }, handleStatusCmd), + Match.when({ _tag: "DownAll" }, () => + apiProjectsDownAll().pipe(Effect.flatMap(() => Effect.log("All projects stopped.")))), + Match.when({ _tag: "Menu" }, () => + runMenu), Match.orElse((cmd) => handleNonBaseCommand(cmd)) ) ), - Effect.catchTag("FileExistsError", (error) => - pipe( - Effect.logWarning(renderError(error)), - Effect.asVoid - )), - Effect.catchTag("DockerAccessError", logWarningAndExit), - Effect.catchTag("DockerCommandError", logWarningAndExit), - Effect.catchTag("AuthError", logWarningAndExit), - Effect.catchTag("AgentFailedError", logWarningAndExit), - Effect.catchTag("CommandFailedError", logWarningAndExit), - Effect.catchTag("ScrapArchiveNotFoundError", logErrorAndExit), - Effect.catchTag("ScrapTargetDirUnsupportedError", logErrorAndExit), - Effect.catchTag("ScrapWipeRefusedError", logErrorAndExit), + Effect.catchTag("ApiClientError", logApiError), Effect.matchEffect({ onFailure: (error) => isParseError(error) From c0cef75b1c3c799952bd6f52eb19fdb092cf1ce9 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Mon, 16 Mar 2026 07:43:23 +0000 Subject: [PATCH 5/8] fix(api): change auth status routes from GET to POST - /auth/github/status, /auth/codex/status, /auth/claude/status use POST - WHY: status requests carry a body (envGlobalPath, claudeAuthPath) - INVARIANT: all 3 auth status endpoints match CLI apiPost() calls Co-Authored-By: Claude Sonnet 4.6 --- packages/api/src/http.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index 8c51ed97..64108df5 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -450,7 +450,7 @@ export const makeRouter = () => { return yield* _(jsonResponse(result, 200)) }).pipe(Effect.catchAll(errorResponse)) ), - HttpRouter.get( + HttpRouter.post( "/auth/github/status", Effect.gen(function*(_) { const req = yield* _(HttpServerRequest.schemaBodyJson(AuthGithubStatusRequestSchema)) @@ -474,7 +474,7 @@ export const makeRouter = () => { return yield* _(jsonResponse(result, 200)) }).pipe(Effect.catchAll(errorResponse)) ), - HttpRouter.get( + HttpRouter.post( "/auth/codex/status", Effect.gen(function*(_) { const req = yield* _(HttpServerRequest.schemaBodyJson(AuthCodexStatusRequestSchema)) @@ -498,7 +498,7 @@ export const makeRouter = () => { return yield* _(jsonResponse(result, 200)) }).pipe(Effect.catchAll(errorResponse)) ), - HttpRouter.get( + HttpRouter.post( "/auth/claude/status", Effect.gen(function*(_) { const req = yield* _(HttpServerRequest.schemaBodyJson(AuthClaudeStatusRequestSchema)) From a7d99b67631c04f90d150afdfaf9297a7b9e53d7 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Mon, 16 Mar 2026 08:08:19 +0000 Subject: [PATCH 6/8] feat(ssh): enable password auth out of the box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PasswordAuthentication yes in sshd_config (was: no) - Default password = SSH username (dev:dev) set via chpasswd at build time - PubkeyAuthentication yes kept — authorized_keys still works if provided - WHY: users need exactly one command to connect, no key setup required - INVARIANT: sshCommand from REST API works immediately after clone/create Co-Authored-By: Claude Sonnet 4.6 --- Dockerfile | 7 +++++-- packages/lib/src/core/templates/dockerfile.ts | 9 ++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index c2227fab..cbe7149d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,14 +15,17 @@ RUN useradd -m -s /bin/bash dev # sshd runtime dir RUN mkdir -p /run/sshd -# Harden sshd: disable password auth and root login +# sshd: password auth enabled so users can connect without key setup RUN printf "%s\n" \ - "PasswordAuthentication no" \ + "PasswordAuthentication yes" \ "PermitRootLogin no" \ "PubkeyAuthentication yes" \ "AllowUsers dev" \ > /etc/ssh/sshd_config.d/dev.conf +# Default password = username (works out of the box; key auth still accepted if authorized_keys provided) +RUN echo "dev:dev" | chpasswd + # Workspace in dev home RUN mkdir -p /home/dev/app && chown -R dev:dev /home/dev diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index d39a2e06..15ba0696 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -205,16 +205,19 @@ RUN printf "%s\\n" "${config.sshUser} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/$ # sshd runtime dir RUN mkdir -p /run/sshd -# Harden sshd: disable password auth and root login +# sshd: password auth enabled so users can connect without key setup RUN printf "%s\\n" \ - "PasswordAuthentication no" \ + "PasswordAuthentication yes" \ "PermitRootLogin no" \ "PubkeyAuthentication yes" \ "X11Forwarding yes" \ "X11UseLocalhost yes" \ "PermitUserEnvironment yes" \ "AllowUsers ${config.sshUser}" \ - > /etc/ssh/sshd_config.d/${config.sshUser}.conf` + > /etc/ssh/sshd_config.d/${config.sshUser}.conf + +# Default password = username (works out of the box; key auth still accepted if authorized_keys provided) +RUN echo "${config.sshUser}:${config.sshUser}" | chpasswd` const renderDockerfileWorkspace = (config: TemplateConfig): string => `# Workspace path (supports root-level dirs like /repo) From ccb578e65db8d6a21fd8e3a4b8f96ced506da436 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Mon, 16 Mar 2026 08:17:46 +0000 Subject: [PATCH 7/8] feat(ssh): embed password in sshCommand via sshpass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - buildSshCommand: when no key → sshpass -p ssh ... - sshUser is also the default password (set via chpasswd at build time) - Result: one command from clone/create output connects immediately - Key auth path unchanged (ssh -i ...) - INVARIANT: sshCommand from REST API is always directly executable Co-Authored-By: Claude Sonnet 4.6 --- packages/lib/src/usecases/projects-core.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/lib/src/usecases/projects-core.ts b/packages/lib/src/usecases/projects-core.ts index ff422de4..8b1165b7 100644 --- a/packages/lib/src/usecases/projects-core.ts +++ b/packages/lib/src/usecases/projects-core.ts @@ -18,12 +18,16 @@ const sshOptions = "-tt -Y -o LogLevel=ERROR -o StrictHostKeyChecking=no -o User export type ProjectLoadError = PlatformError | ConfigNotFoundError | ConfigDecodeError +// CHANGE: use sshpass when no key provided so the command works without interaction +// WHY: password = sshUser (set via chpasswd at build time); sshpass embeds it in one command +// PURITY: CORE +// INVARIANT: sshKey !== null → key auth; sshKey === null → sshpass with default password export const buildSshCommand = ( config: TemplateConfig, sshKey: string | null ): string => sshKey === null - ? `ssh ${sshOptions} -p ${config.sshPort} ${config.sshUser}@localhost` + ? `sshpass -p ${config.sshUser} ssh ${sshOptions} -p ${config.sshPort} ${config.sshUser}@localhost` : `ssh -i ${sshKey} ${sshOptions} -p ${config.sshPort} ${config.sshUser}@localhost` export type ProjectSummary = { From 35fd9640bc1b62cd123f4cd0e6decbaede826891 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:46:36 +0000 Subject: [PATCH 8/8] sync --- packages/app/src/docker-git/api-client.ts | 11 +++- packages/app/src/docker-git/program.ts | 56 ++++++++++++++++-- packages/app/src/docker-git/tmux.ts | 47 +++++++++++++++ .../lib/src/core/templates/docker-compose.ts | 15 ++++- packages/lib/src/index.ts | 1 + packages/lib/src/shell/docker-env.ts | 13 ++++ .../src/usecases/actions/create-project.ts | 16 ++++- packages/lib/src/usecases/projects-core.ts | 21 +++++-- packages/lib/src/usecases/projects-ssh.ts | 59 +++++++++++++++---- .../usecases/create-project-open-ssh.test.ts | 4 ++ 10 files changed, 218 insertions(+), 25 deletions(-) create mode 100644 packages/lib/src/shell/docker-env.ts diff --git a/packages/app/src/docker-git/api-client.ts b/packages/app/src/docker-git/api-client.ts index 400658b6..aa462725 100644 --- a/packages/app/src/docker-git/api-client.ts +++ b/packages/app/src/docker-git/api-client.ts @@ -1,6 +1,7 @@ import * as HttpBody from "@effect/platform/HttpBody" import * as HttpClient from "@effect/platform/HttpClient" import type * as HttpClientResponse from "@effect/platform/HttpClientResponse" +import { isInsideDocker } from "@effect-template/lib" import { Data, Effect } from "effect" import * as Schema from "effect/Schema" @@ -16,12 +17,13 @@ export class ApiClientError extends Data.TaggedError("ApiClientError")<{ readonly message: string }> {} -// CHANGE: trim trailing slashes without backtracking regex -// WHY: /\/+$/ is flagged as slow-regex by sonarjs; loop avoids super-linear backtracking +// CHANGE: trim trailing slashes without backtracking regex; resolve DinD API host +// WHY: in DinD, localhost:3334 is on the outer host; use docker-git-api:3334 via Docker DNS // PURITY: CORE // COMPLEXITY: O(n) where n = number of trailing slashes (typically 0 or 1) const resolveApiBaseUrl = (): string => { - const raw = process.env["DOCKER_GIT_API_URL"] ?? "http://localhost:3334" + const defaultUrl = isInsideDocker() ? "http://docker-git-api:3334" : "http://localhost:3334" + const raw = process.env["DOCKER_GIT_API_URL"] ?? defaultUrl const trimmed = raw.trim() let end = trimmed.length while (end > 0 && trimmed[end - 1] === "/") { @@ -264,6 +266,9 @@ export type ProjectApplyRequest = { export const apiProjectsList = () => apiGet("/projects", ProjectsListResponseSchema) +export const apiProjectGet = (projectId: string) => + apiGet(`/projects/${encodeURIComponent(projectId)}`, Schema.Struct({ project: ProjectDetailsSchema })) + export const apiProjectCreate = (req: ProjectCreateRequest) => apiPost("/projects", req, ProjectCreatedResponseSchema) export const apiProjectsDownAll = () => apiPost("/projects/down-all", {}, OkResponseSchema) diff --git a/packages/app/src/docker-git/program.ts b/packages/app/src/docker-git/program.ts index 163aeb0c..78014ddf 100644 --- a/packages/app/src/docker-git/program.ts +++ b/packages/app/src/docker-git/program.ts @@ -24,11 +24,12 @@ import type { StateInitCommand, StateSyncCommand } from "@effect-template/lib/core/domain" +import { isInsideDocker } from "@effect-template/lib" import type { AppError } from "@effect-template/lib/usecases/errors" import { renderError } from "@effect-template/lib/usecases/errors" import { Effect, Match, pipe } from "effect" -import type { ApiClientError } from "./api-client.js" +import { ApiClientError } from "./api-client.js" import { apiAuthClaudeLogin, apiAuthClaudeLogout, @@ -42,6 +43,7 @@ import { apiMcpPlaywrightUp, apiProjectApply, apiProjectCreate, + apiProjectGet, apiProjectsDownAll, apiProjectsList, apiScrapExport, @@ -59,7 +61,7 @@ import { } from "./api-client.js" import { readCommand } from "./cli/read-command.js" import { runMenu } from "./menu.js" -import { attachTmux, listTmuxPanes } from "./tmux.js" +import { attachTmux, attachTmuxFromProject, listTmuxPanes } from "./tmux.js" // CHANGE: rewrite CLI program to use unified REST API // WHY: CLI becomes thin HTTP client; business logic lives in API server @@ -184,7 +186,46 @@ const handleAuthClaudeLogout = (cmd: AuthClaudeLogoutCommand) => // ─── Sessions / Scrap / MCP / Apply handlers ───────────────────────────────── -const handleAttach = (cmd: AttachCommand) => attachTmux(cmd) +// CHANGE: in DinD, fetch project details from API instead of reading local filesystem +// WHY: project config files live on the API host, not visible in the CLI container +// PURITY: SHELL +// INVARIANT: DinD → API path (list + match); local → filesystem path +// CHANGE: match CLI shorthand (e.g. ".docker-git/provercoderai/docker-git") to API project ID +// WHY: CLI resolves to relative path; API uses absolute path; match by suffix +// PURITY: CORE +// COMPLEXITY: O(n) where n = number of projects +const findProjectByShorthand = (shorthand: string) => + apiProjectsList().pipe( + Effect.flatMap(({ projects }) => { + const normalized = shorthand.replace(/^\.docker-git\//, "") + const match = projects.find( + (p) => + p.id === shorthand || + p.id.endsWith(`/${shorthand}`) || + p.id.endsWith(`/${normalized}`) || + p.displayName === normalized + ) + return match + ? apiProjectGet(match.id) + : Effect.fail(new ApiClientError({ message: `Project not found: ${shorthand}` })) + }) + ) + +const handleAttach = (cmd: AttachCommand) => + isInsideDocker() + ? findProjectByShorthand(cmd.projectDir).pipe( + Effect.flatMap(({ project }) => + attachTmuxFromProject({ + containerName: project.containerName, + sshUser: project.sshUser, + sshPort: project.sshPort, + repoUrl: project.repoUrl, + repoRef: project.repoRef, + sshCommand: project.sshCommand + }) + ) + ) + : attachTmux(cmd) const handlePanes = (cmd: PanesCommand) => listTmuxPanes(cmd) @@ -307,7 +348,14 @@ const handleCreateCmd = (create: CreateCommand) => }).pipe( Effect.flatMap(({ project }) => { if (create.openSsh) { - return attachTmux({ _tag: "Attach", projectDir: project.projectDir }) + return attachTmuxFromProject({ + containerName: project.containerName, + sshUser: project.sshUser, + sshPort: project.sshPort, + repoUrl: project.repoUrl, + repoRef: project.repoRef, + sshCommand: project.sshCommand + }) } return Effect.log(`Project created: ${project.displayName} (${project.sshCommand})`) }) diff --git a/packages/app/src/docker-git/tmux.ts b/packages/app/src/docker-git/tmux.ts index a2434fab..6cf824b2 100644 --- a/packages/app/src/docker-git/tmux.ts +++ b/packages/app/src/docker-git/tmux.ts @@ -290,3 +290,50 @@ export const attachTmux = ( yield* _(setupPanes(session, sshCommand, template.containerName)) yield* _(runTmux(["attach", "-t", session])) }) + +// CHANGE: attach tmux from API project details without local filesystem access +// WHY: in DinD, project files live on the API host; CLI cannot read them locally +// QUOTE(ТЗ): "он сам бы подключался к API и всё делал бы сам" +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: tmux session name is deterministic from repoUrl; no local file reads +// COMPLEXITY: O(1) +export type ProjectInfo = { + readonly containerName: string + readonly sshUser: string + readonly sshPort: number + readonly repoUrl: string + readonly repoRef: string + readonly sshCommand: string +} + +export const attachTmuxFromProject = ( + project: ProjectInfo +): Effect.Effect< + void, + CommandFailedError | PlatformError, + CommandExecutor.CommandExecutor +> => + Effect.gen(function*(_) { + const repoDisplayName = formatRepoDisplayName(project.repoUrl) + const refLabel = formatRepoRefLabel(project.repoRef) + const statusRight = + `SSH: ${project.sshUser}@localhost:${project.sshPort} | Repo: ${repoDisplayName} | Ref: ${refLabel} | Status: Running` + const session = `dg-${deriveRepoSlug(project.repoUrl)}` + const hasSessionCode = yield* _(runTmuxExitCode(["has-session", "-t", session])) + + if (hasSessionCode === 0) { + const existingLayout = yield* _(readLayoutVersion(session)) + if (existingLayout === layoutVersion) { + yield* _(runTmux(["attach", "-t", session])) + return + } + yield* _(Effect.logWarning(`tmux session ${session} uses an old layout; recreating.`)) + yield* _(runTmux(["kill-session", "-t", session])) + } + + yield* _(createLayout(session)) + yield* _(configureSession(session, repoDisplayName, statusRight)) + yield* _(setupPanes(session, project.sshCommand, project.containerName)) + yield* _(runTmux(["attach", "-t", session])) + }) diff --git a/packages/lib/src/core/templates/docker-compose.ts b/packages/lib/src/core/templates/docker-compose.ts index 180e7d35..cc429926 100644 --- a/packages/lib/src/core/templates/docker-compose.ts +++ b/packages/lib/src/core/templates/docker-compose.ts @@ -52,6 +52,19 @@ const renderProjectsRootHostMount = (projectsRoot: string): string => const renderSharedCodexHostMount = (projectsRoot: string): string => `\${DOCKER_GIT_PROJECTS_ROOT_HOST:-${projectsRoot}}/.orch/auth/codex` +// CHANGE: render authorized_keys mount with DOCKER_GIT_PROJECTS_ROOT_HOST override +// WHY: in Docker-in-Docker scenarios the relative path resolves on the outer host, not the container; +// without the host override Docker creates an empty directory instead of mounting the file +// PURITY: CORE +const renderAuthorizedKeysHostMount = (dockerGitPath: string, authorizedKeysPath: string): string => { + const prefix = `${dockerGitPath}/` + if (authorizedKeysPath.startsWith(prefix)) { + const suffix = authorizedKeysPath.slice(prefix.length) + return `\${DOCKER_GIT_PROJECTS_ROOT_HOST:-${dockerGitPath}}/${suffix}` + } + return authorizedKeysPath +} + const renderResourceLimits = (resourceLimits: ResolvedComposeResourceLimits | undefined): string => resourceLimits === undefined ? "" @@ -149,7 +162,7 @@ ${fragments.maybePlaywrightEnv}${fragments.maybeDependsOn} env_file: ${renderResourceLimits(resourceLimits)} volumes: - ${config.volumeName}:/home/${config.sshUser} - ${renderProjectsRootHostMount(config.dockerGitPath)}:/home/${config.sshUser}/.docker-git - - ${config.authorizedKeysPath}:/authorized_keys:ro + - ${renderAuthorizedKeysHostMount(config.dockerGitPath, config.authorizedKeysPath)}:/authorized_keys:ro - ${config.codexAuthPath}:${config.codexHome} - ${renderSharedCodexHostMount(config.dockerGitPath)}:${config.codexHome}-shared - /var/run/docker.sock:/var/run/docker.sock diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts index f4193a1b..a5b09424 100644 --- a/packages/lib/src/index.ts +++ b/packages/lib/src/index.ts @@ -6,6 +6,7 @@ export * from "./core/parse-errors.js" export * from "./core/templates.js" export * from "./shell/clone.js" export * from "./shell/config.js" +export * from "./shell/docker-env.js" export * from "./shell/docker.js" export * from "./shell/errors.js" export * from "./shell/files.js" diff --git a/packages/lib/src/shell/docker-env.ts b/packages/lib/src/shell/docker-env.ts new file mode 100644 index 00000000..db337506 --- /dev/null +++ b/packages/lib/src/shell/docker-env.ts @@ -0,0 +1,13 @@ +import * as fs from "node:fs" + +// CHANGE: detect Docker-in-Docker environment +// WHY: SSH host resolution and path mapping differ in DinD vs host environments +// PURITY: CORE +// INVARIANT: returns true when /.dockerenv exists (standard Docker indicator) +export const isInsideDocker = (): boolean => { + try { + return fs.existsSync("/.dockerenv") + } catch { + return false + } +} diff --git a/packages/lib/src/usecases/actions/create-project.ts b/packages/lib/src/usecases/actions/create-project.ts index dc61b555..6ab2f906 100644 --- a/packages/lib/src/usecases/actions/create-project.ts +++ b/packages/lib/src/usecases/actions/create-project.ts @@ -94,11 +94,23 @@ const formatStateSyncLabel = (repoUrl: string): string => { const isInteractiveTty = (): boolean => process.stdin.isTTY && process.stdout.isTTY +import { isInsideDocker } from "../../shell/docker-env.js" + +// CHANGE: detect DinD for SSH host resolution +// WHY: in DinD, localhost:mappedPort is on outer host; use containerName:22 via Docker DNS +// PURITY: CORE + +const resolveSshHost = (config: CreateCommand["config"]): { host: string; port: number } => + isInsideDocker() + ? { host: config.containerName, port: 22 } + : { host: "localhost", port: config.sshPort } + const buildSshArgs = ( config: CreateCommand["config"], sshKeyPath: string | null, remoteCommand?: string ): ReadonlyArray => { + const target = resolveSshHost(config) const args: Array = [] if (sshKeyPath !== null) { args.push("-i", sshKeyPath) @@ -113,8 +125,8 @@ const buildSshArgs = ( "-o", "UserKnownHostsFile=/dev/null", "-p", - String(config.sshPort), - `${config.sshUser}@localhost` + String(target.port), + `${config.sshUser}@${target.host}` ) if (remoteCommand !== undefined) { args.push(remoteCommand) diff --git a/packages/lib/src/usecases/projects-core.ts b/packages/lib/src/usecases/projects-core.ts index 8b1165b7..ccee63f5 100644 --- a/packages/lib/src/usecases/projects-core.ts +++ b/packages/lib/src/usecases/projects-core.ts @@ -18,6 +18,17 @@ const sshOptions = "-tt -Y -o LogLevel=ERROR -o StrictHostKeyChecking=no -o User export type ProjectLoadError = PlatformError | ConfigNotFoundError | ConfigDecodeError +import { isInsideDocker } from "../shell/docker-env.js" + +// CHANGE: detect DinD for SSH command display +// WHY: in DinD, SSH connects via container name on Docker shared network at port 22 +// PURITY: CORE + +const resolveSshDisplay = (config: TemplateConfig): { host: string; port: number } => + isInsideDocker() + ? { host: config.containerName, port: 22 } + : { host: "localhost", port: config.sshPort } + // CHANGE: use sshpass when no key provided so the command works without interaction // WHY: password = sshUser (set via chpasswd at build time); sshpass embeds it in one command // PURITY: CORE @@ -25,10 +36,12 @@ export type ProjectLoadError = PlatformError | ConfigNotFoundError | ConfigDecod export const buildSshCommand = ( config: TemplateConfig, sshKey: string | null -): string => - sshKey === null - ? `sshpass -p ${config.sshUser} ssh ${sshOptions} -p ${config.sshPort} ${config.sshUser}@localhost` - : `ssh -i ${sshKey} ${sshOptions} -p ${config.sshPort} ${config.sshUser}@localhost` +): string => { + const target = resolveSshDisplay(config) + return sshKey === null + ? `sshpass -p ${config.sshUser} ssh ${sshOptions} -p ${target.port} ${config.sshUser}@${target.host}` + : `ssh -i ${sshKey} ${sshOptions} -p ${target.port} ${config.sshUser}@${target.host}` +} export type ProjectSummary = { readonly projectDir: string diff --git a/packages/lib/src/usecases/projects-ssh.ts b/packages/lib/src/usecases/projects-ssh.ts index 67165d88..661c50ef 100644 --- a/packages/lib/src/usecases/projects-ssh.ts +++ b/packages/lib/src/usecases/projects-ssh.ts @@ -5,6 +5,7 @@ import type { Path as PathService } from "@effect/platform/Path" import { Duration, Effect, pipe, Schedule } from "effect" import { runCommandExitCode, runCommandWithExitCodes } from "../shell/command-runner.js" +import { isInsideDocker } from "../shell/docker-env.js" import { runDockerComposePsFormatted } from "../shell/docker.js" import { CommandFailedError, @@ -27,7 +28,20 @@ import { import { runDockerComposeUpWithPortCheck } from "./projects-up.js" import { ensureTerminalCursorVisible } from "./terminal-cursor.js" +// CHANGE: resolve SSH host and port based on environment +// WHY: in DinD, connect via container name on Docker shared network at port 22; +// outside Docker, use localhost with the mapped host port +// PURITY: CORE +// INVARIANT: DinD → (containerName, 22); host → (localhost, sshPort) +type SshTarget = { readonly host: string; readonly port: number } + +const resolveSshTarget = (item: ProjectItem): SshTarget => + isInsideDocker() + ? { host: item.containerName, port: 22 } + : { host: "localhost", port: item.sshPort } + const buildSshArgs = (item: ProjectItem): ReadonlyArray => { + const target = resolveSshTarget(item) const args: Array = [] if (item.sshKeyPath !== null) { args.push("-i", item.sshKeyPath) @@ -42,17 +56,38 @@ const buildSshArgs = (item: ProjectItem): ReadonlyArray => { "-o", "UserKnownHostsFile=/dev/null", "-p", - String(item.sshPort), - `${item.sshUser}@localhost` + String(target.port), + `${item.sshUser}@${target.host}` ) return args } -const buildSshProbeArgs = (item: ProjectItem): ReadonlyArray => { +// CHANGE: SSH probe uses sshpass when no key is available +// WHY: BatchMode=yes prevents password auth, making probe fail in DinD where no private key exists; +// sshpass with default password (= sshUser) allows authentication +// PURITY: CORE +const buildSshProbeArgs = (item: ProjectItem): { readonly command: string; readonly args: ReadonlyArray } => { + const target = resolveSshTarget(item) const args: Array = [] - if (item.sshKeyPath !== null) { - args.push("-i", item.sshKeyPath) + if (item.sshKeyPath === null) { + return { + command: "sshpass", + args: [ + "-p", item.sshUser, + "ssh", + "-T", + "-o", "ConnectTimeout=2", + "-o", "ConnectionAttempts=1", + "-o", "LogLevel=ERROR", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-p", String(target.port), + `${item.sshUser}@${target.host}`, + "true" + ] + } } + args.push("-i", item.sshKeyPath) args.push( "-T", "-o", @@ -68,22 +103,24 @@ const buildSshProbeArgs = (item: ProjectItem): ReadonlyArray => { "-o", "UserKnownHostsFile=/dev/null", "-p", - String(item.sshPort), - `${item.sshUser}@localhost`, + String(target.port), + `${item.sshUser}@${target.host}`, "true" ) - return args + return { command: "ssh", args } } const waitForSshReady = ( item: ProjectItem ): Effect.Effect => { + const probeSpec = buildSshProbeArgs(item) + const target = resolveSshTarget(item) const probe = Effect.gen(function*(_) { const exitCode = yield* _( runCommandExitCode({ cwd: process.cwd(), - command: "ssh", - args: buildSshProbeArgs(item) + command: probeSpec.command, + args: probeSpec.args }) ) if (exitCode !== 0) { @@ -92,7 +129,7 @@ const waitForSshReady = ( }) return pipe( - Effect.log(`Waiting for SSH on localhost:${item.sshPort} ...`), + Effect.log(`Waiting for SSH on ${target.host}:${target.port} ...`), Effect.zipRight( Effect.retry( probe, diff --git a/packages/lib/tests/usecases/create-project-open-ssh.test.ts b/packages/lib/tests/usecases/create-project-open-ssh.test.ts index 26b618ba..d15582ca 100644 --- a/packages/lib/tests/usecases/create-project-open-ssh.test.ts +++ b/packages/lib/tests/usecases/create-project-open-ssh.test.ts @@ -17,6 +17,10 @@ vi.mock("../../src/usecases/actions/ports.js", () => ({ resolveSshPort: (config: CreateCommand["config"]) => Effect.succeed(config) })) +vi.mock("../../src/shell/docker-env.js", () => ({ + isInsideDocker: () => false +})) + type RecordedCommand = { readonly command: string readonly args: ReadonlyArray