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/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/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..64108df5 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.post( + "/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.post( + "/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.post( + "/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) + ) diff --git a/packages/app/src/docker-git/program.ts b/packages/app/src/docker-git/program.ts index 4369ed19..893348a1 100644 --- a/packages/app/src/docker-git/program.ts +++ b/packages/app/src/docker-git/program.ts @@ -19,7 +19,11 @@ import { 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 { applyAllDockerGitProjects, downAllDockerGitProjects, listProjectStatus } from "@effect-template/lib/usecases/projects" +import { + applyAllDockerGitProjects, + downAllDockerGitProjects, + listProjectStatus +} from "@effect-template/lib/usecases/projects" import { exportScrap, importScrap } from "@effect-template/lib/usecases/scrap" import { sessionGistBackup, diff --git a/packages/app/src/docker-git/tmux.ts b/packages/app/src/docker-git/tmux.ts index a2434fab..11b7c26d 100644 --- a/packages/app/src/docker-git/tmux.ts +++ b/packages/app/src/docker-git/tmux.ts @@ -240,12 +240,46 @@ export const listTmuxPanes = ( } }) +// CHANGE: shared session attach logic extracted to avoid code duplication +// WHY: attachTmux and attachTmuxFromProject share identical session management; +// duplicate code triggers vibecode-linter DUPLICATE detection +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: tmux session name is deterministic; old layout is recreated +// COMPLEXITY: O(1) +type TmuxSessionParams = { + readonly session: string + readonly repoDisplayName: string + readonly statusRight: string + readonly sshCommand: string + readonly containerName: string +} + +const attachOrRecreateSession = ( + params: TmuxSessionParams +): Effect.Effect => + Effect.gen(function*(_) { + const hasSessionCode = yield* _(runTmuxExitCode(["has-session", "-t", params.session])) + + if (hasSessionCode === 0) { + const existingLayout = yield* _(readLayoutVersion(params.session)) + if (existingLayout === layoutVersion) { + yield* _(runTmux(["attach", "-t", params.session])) + return + } + yield* _(Effect.logWarning(`tmux session ${params.session} uses an old layout; recreating.`)) + yield* _(runTmux(["kill-session", "-t", params.session])) + } + + yield* _(createLayout(params.session)) + yield* _(configureSession(params.session, params.repoDisplayName, params.statusRight)) + yield* _(setupPanes(params.session, params.sshCommand, params.containerName)) + yield* _(runTmux(["attach", "-t", params.session])) + }) + // CHANGE: attach a tmux workspace for a docker-git project // WHY: provide multi-pane terminal layout for sandbox work // QUOTE(ТЗ): "окей Давай подключим tmux" -// REF: user-request-2026-02-02-tmux -// SOURCE: n/a -// FORMAT THEOREM: forall p: attach(p) -> tmux(p) // PURITY: SHELL // EFFECT: Effect // INVARIANT: tmux session name is deterministic from repo url @@ -270,23 +304,48 @@ export const attachTmux = ( const sshCommand = buildSshCommand(template, sshKey) const repoDisplayName = formatRepoDisplayName(template.repoUrl) const refLabel = formatRepoRefLabel(template.repoRef) - const statusRight = - `SSH: ${template.sshUser}@localhost:${template.sshPort} | Repo: ${repoDisplayName} | Ref: ${refLabel} | Status: Running` - const session = `dg-${deriveRepoSlug(template.repoUrl)}` - const hasSessionCode = yield* _(runTmuxExitCode(["has-session", "-t", session])) + yield* _(attachOrRecreateSession({ + session: `dg-${deriveRepoSlug(template.repoUrl)}`, + repoDisplayName, + statusRight: + `SSH: ${template.sshUser}@localhost:${template.sshPort} | Repo: ${repoDisplayName} | Ref: ${refLabel} | Status: Running`, + sshCommand, + containerName: template.containerName + })) + }) - 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])) - } +// 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 +} - yield* _(createLayout(session)) - yield* _(configureSession(session, repoDisplayName, statusRight)) - yield* _(setupPanes(session, sshCommand, template.containerName)) - yield* _(runTmux(["attach", "-t", session])) +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) + yield* _(attachOrRecreateSession({ + session: `dg-${deriveRepoSlug(project.repoUrl)}`, + repoDisplayName, + statusRight: + `SSH: ${project.sshUser}@localhost:${project.sshPort} | Repo: ${repoDisplayName} | Ref: ${refLabel} | Status: Running`, + sshCommand: project.sshCommand, + containerName: project.containerName + })) }) diff --git a/packages/lib/src/core/templates-entrypoint.ts b/packages/lib/src/core/templates-entrypoint.ts index 8c13276b..aeaea8bb 100644 --- a/packages/lib/src/core/templates-entrypoint.ts +++ b/packages/lib/src/core/templates-entrypoint.ts @@ -11,7 +11,6 @@ import { renderEntrypointZshShell, renderEntrypointZshUserRc } from "./templates-entrypoint/base.js" -import { renderEntrypointDnsRepair } from "./templates-entrypoint/dns-repair.js" import { renderEntrypointClaudeConfig } from "./templates-entrypoint/claude.js" import { renderEntrypointAgentsNotice, @@ -20,6 +19,7 @@ import { renderEntrypointCodexSharedAuth, renderEntrypointMcpPlaywright } from "./templates-entrypoint/codex.js" +import { renderEntrypointDnsRepair } from "./templates-entrypoint/dns-repair.js" import { renderEntrypointGeminiConfig } from "./templates-entrypoint/gemini.js" import { renderEntrypointGitConfig, renderEntrypointGitHooks } from "./templates-entrypoint/git.js" import { renderEntrypointDockerGitBootstrap } from "./templates-entrypoint/nested-docker-git.js" diff --git a/packages/lib/src/core/templates-entrypoint/dns-repair.ts b/packages/lib/src/core/templates-entrypoint/dns-repair.ts index b4a44ead..d5e52b80 100644 --- a/packages/lib/src/core/templates-entrypoint/dns-repair.ts +++ b/packages/lib/src/core/templates-entrypoint/dns-repair.ts @@ -10,7 +10,7 @@ // INVARIANT: after execution, at least one nameserver in /etc/resolv.conf resolves external domains // COMPLEXITY: O(1) per probe attempt, O(max_attempts) worst case export const renderEntrypointDnsRepair = (): string => - `# 0) Ensure DNS resolution works; repair /etc/resolv.conf if Docker DNS is broken + String.raw`# 0) Ensure DNS resolution works; repair /etc/resolv.conf if Docker DNS is broken docker_git_repair_dns() { local test_domain="github.com" local resolv="/etc/resolv.conf" @@ -32,7 +32,7 @@ docker_git_repair_dns() { if [[ "$has_external" -eq 0 ]]; then for ns in $fallback_dns; do - printf "nameserver %s\\n" "$ns" >> "$resolv" + printf "nameserver %s\n" "$ns" >> "$resolv" done echo "[dns-repair] appended fallback nameservers to $resolv" fi diff --git a/packages/lib/src/core/templates/docker-compose.ts b/packages/lib/src/core/templates/docker-compose.ts index 473bd613..11271aba 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 ? "" @@ -78,13 +91,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 dns:\n - 8.8.8.8\n - 8.8.4.4\n - 1.1.1.1\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 dns:\n - 8.8.8.8\n - 8.8.4.4\n - 1.1.1.1\n volumes:\n - ${browserVolumeName}:/data\n networks:\n - ${networkName}\n`, maybeBrowserVolume: ` ${browserVolumeName}:\n` } } @@ -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/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index 6401de0f..d666c56f 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -209,16 +209,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` // CHANGE: add docker-git scripts to Docker image at /opt/docker-git/scripts // WHY: scripts (session-backup, pre-commit guards, knowledge splitter) must be available 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..74f18cd7 --- /dev/null +++ b/packages/lib/src/shell/docker-env.ts @@ -0,0 +1,13 @@ +import * as FileSystem from "@effect/platform/FileSystem" +import { Effect } from "effect" + +// CHANGE: detect Docker-in-Docker environment using @effect/platform +// WHY: SSH host resolution and path mapping differ in DinD vs host environments; +// node:fs is banned by Effect-TS lint rules so we use @effect/platform FileSystem +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: returns true when /.dockerenv exists (standard Docker indicator) +export const isInsideDockerEffect: Effect.Effect = FileSystem.FileSystem.pipe( + Effect.flatMap((fs) => fs.exists("/.dockerenv")), + Effect.orElseSucceed(() => false) +) diff --git a/packages/lib/src/shell/files.ts b/packages/lib/src/shell/files.ts index fbf34c2b..b4a30c9a 100644 --- a/packages/lib/src/shell/files.ts +++ b/packages/lib/src/shell/files.ts @@ -3,8 +3,8 @@ import type * as FileSystem from "@effect/platform/FileSystem" import type * as Path from "@effect/platform/Path" import { Effect, Match } from "effect" -import { type TemplateConfig } from "../core/domain.js" import { dockerGitScriptNames } from "../core/docker-git-scripts.js" +import { type TemplateConfig } from "../core/domain.js" import { resolveComposeResourceLimits, withDefaultResourceLimitIntent } from "../core/resource-limits.js" import { type FileSpec, planFiles } from "../core/templates.js" import { FileExistsError } from "./errors.js" diff --git a/packages/lib/src/usecases/projects-apply-all.ts b/packages/lib/src/usecases/projects-apply-all.ts index ed4628cd..d62d2e0f 100644 --- a/packages/lib/src/usecases/projects-apply-all.ts +++ b/packages/lib/src/usecases/projects-apply-all.ts @@ -37,7 +37,9 @@ export const applyAllDockerGitProjects: Effect.Effect< runDockerComposeUpWithPortCheck(status.projectDir).pipe( Effect.catchTag("DockerCommandError", (error: DockerCommandError) => Effect.logWarning( - `apply failed for ${status.projectDir}: ${renderError(error)}. Check the project docker-compose config (e.g. env files for merge conflicts, port conflicts in docker-compose.yml config) and retry.` + `apply failed for ${status.projectDir}: ${ + renderError(error) + }. Check the project docker-compose config (e.g. env files for merge conflicts, port conflicts in docker-compose.yml config) and retry.` )), Effect.catchTag("ConfigNotFoundError", (error) => Effect.logWarning( diff --git a/packages/lib/src/usecases/projects-core.ts b/packages/lib/src/usecases/projects-core.ts index 420141fe..88a778b2 100644 --- a/packages/lib/src/usecases/projects-core.ts +++ b/packages/lib/src/usecases/projects-core.ts @@ -20,6 +20,10 @@ 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, @@ -28,7 +32,7 @@ export const buildSshCommand = ( const host = ipAddress ?? "localhost" const port = ipAddress ? 22 : config.sshPort return sshKey === null - ? `ssh ${sshOptions} -p ${port} ${config.sshUser}@${host}` + ? `sshpass -p ${config.sshUser} ssh ${sshOptions} -p ${port} ${config.sshUser}@${host}` : `ssh -i ${sshKey} ${sshOptions} -p ${port} ${config.sshUser}@${host}` } diff --git a/packages/lib/src/usecases/projects-ssh.ts b/packages/lib/src/usecases/projects-ssh.ts index 7f102104..671f6814 100644 --- a/packages/lib/src/usecases/projects-ssh.ts +++ b/packages/lib/src/usecases/projects-ssh.ts @@ -28,56 +28,59 @@ import { import { runDockerComposeUpWithPortCheck } from "./projects-up.js" import { ensureTerminalCursorVisible } from "./terminal-cursor.js" -const buildSshArgs = (item: ProjectItem): ReadonlyArray => { - const host = item.ipAddress ?? "localhost" - const port = item.ipAddress ? 22 : item.sshPort - const args: Array = [] - if (item.sshKeyPath !== null) { - args.push("-i", item.sshKeyPath) - } - args.push( - "-tt", - "-Y", - "-o", - "LogLevel=ERROR", - "-o", - "StrictHostKeyChecking=no", - "-o", - "UserKnownHostsFile=/dev/null", - "-p", - String(port), - `${item.sshUser}@${host}` - ) - return args +// CHANGE: wrap ssh args with sshpass when no key is available +// WHY: password = sshUser (set via chpasswd at build time); sshpass embeds it in one command +// PURITY: CORE +// INVARIANT: sshKeyPath !== null → key auth; sshKeyPath === null → sshpass with default password +type SshSpec = { readonly command: string; readonly args: ReadonlyArray } + +const sshSecurityOptions: ReadonlyArray = [ + "-o", + "LogLevel=ERROR", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null" +] + +const sshProbeTimeouts: ReadonlyArray = [ + "-o", + "ConnectTimeout=2", + "-o", + "ConnectionAttempts=1" +] + +const resolveSshTarget = (item: ProjectItem): { host: string; port: number } => ({ + host: item.ipAddress ?? "localhost", + port: item.ipAddress ? 22 : item.sshPort +}) + +const wrapWithSshpass = (item: ProjectItem, args: ReadonlyArray): SshSpec => + item.sshKeyPath === null + ? { command: "sshpass", args: ["-p", item.sshUser, "ssh", ...args] } + : { command: "ssh", args } + +const buildSshArgs = (item: ProjectItem): SshSpec => { + const { host, port } = resolveSshTarget(item) + const keyArgs = item.sshKeyPath === null ? [] : ["-i", item.sshKeyPath] + const args = [...keyArgs, "-tt", "-Y", ...sshSecurityOptions, "-p", String(port), `${item.sshUser}@${host}`] + return wrapWithSshpass(item, args) } -const buildSshProbeArgs = (item: ProjectItem): ReadonlyArray => { - const host = item.ipAddress ?? "localhost" - const port = item.ipAddress ? 22 : item.sshPort - const args: Array = [] - if (item.sshKeyPath !== null) { - args.push("-i", item.sshKeyPath) - } - args.push( +const buildSshProbeArgs = (item: ProjectItem): SshSpec => { + const { host, port } = resolveSshTarget(item) + const authArgs = item.sshKeyPath === null ? [] : ["-i", item.sshKeyPath, "-o", "BatchMode=yes"] + const args = [ + ...authArgs, "-T", - "-o", - "BatchMode=yes", - "-o", - "ConnectTimeout=2", - "-o", - "ConnectionAttempts=1", - "-o", - "LogLevel=ERROR", - "-o", - "StrictHostKeyChecking=no", - "-o", - "UserKnownHostsFile=/dev/null", + ...sshProbeTimeouts, + ...sshSecurityOptions, "-p", String(port), `${item.sshUser}@${host}`, "true" - ) - return args + ] + return wrapWithSshpass(item, args) } const waitForSshReady = ( @@ -85,12 +88,13 @@ const waitForSshReady = ( ): Effect.Effect => { const host = item.ipAddress ?? "localhost" const port = item.ipAddress ? 22 : item.sshPort + const probeSpec = buildSshProbeArgs(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) { @@ -125,15 +129,16 @@ const waitForSshReady = ( // COMPLEXITY: O(1) export const connectProjectSsh = ( item: ProjectItem -): Effect.Effect => - pipe( +): Effect.Effect => { + const sshSpec = buildSshArgs(item) + return pipe( ensureTerminalCursorVisible(), Effect.zipRight( runCommandWithExitCodes( { cwd: process.cwd(), - command: "ssh", - args: buildSshArgs(item) + command: sshSpec.command, + args: sshSpec.args }, [0, 130], (exitCode) => new CommandFailedError({ command: "ssh", exitCode }) @@ -141,6 +146,7 @@ export const connectProjectSsh = ( ), Effect.ensuring(ensureTerminalCursorVisible()) ) +} // CHANGE: ensure docker compose is up before SSH connection // WHY: selected project should auto-start when not running diff --git a/packages/lib/src/usecases/projects.ts b/packages/lib/src/usecases/projects.ts index 3f53ce46..9dd02de8 100644 --- a/packages/lib/src/usecases/projects.ts +++ b/packages/lib/src/usecases/projects.ts @@ -1,3 +1,4 @@ +export { applyAllDockerGitProjects } from "./projects-apply-all.js" export { buildSshCommand, loadProjectItem, @@ -7,7 +8,6 @@ export { type ProjectLoadError, type ProjectStatus } from "./projects-core.js" -export { applyAllDockerGitProjects } from "./projects-apply-all.js" export { deleteDockerGitProject } from "./projects-delete.js" export { downAllDockerGitProjects } from "./projects-down.js" export { listProjectItems, listProjects, listProjectSummaries, listRunningProjectItems } from "./projects-list.js" 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..96bd0ad1 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", () => ({ + isInsideDockerEffect: Effect.succeed(false) +})) + type RecordedCommand = { readonly command: string readonly args: ReadonlyArray