diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index ad1cc61..158dcfa 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -8,6 +8,7 @@ import * as HttpServerResponse from "@effect/platform/HttpServerResponse" import * as HttpServerError from "@effect/platform/HttpServerError" import * as ParseResult from "effect/ParseResult" import * as Schema from "effect/Schema" +import { renderError, type AppError } from "@effect-template/lib/usecases/errors" import { ApiAuthRequiredError, ApiBadRequestError, ApiConflictError, ApiInternalError, ApiNotFoundError, describeUnknown } from "./api/errors.js" import { @@ -92,6 +93,32 @@ type ApiError = | HttpServerError.RequestError | PlatformError +const appErrorTags = new Set([ + "FileExistsError", + "CloneFailedError", + "AgentFailedError", + "DockerAccessError", + "DockerCommandError", + "ConfigNotFoundError", + "ConfigDecodeError", + "ScrapArchiveInvalidError", + "ScrapArchiveNotFoundError", + "ScrapTargetDirUnsupportedError", + "ScrapWipeRefusedError", + "InputCancelledError", + "InputReadError", + "PortProbeError", + "AuthError", + "CommandFailedError" +]) + +const isAppError = (error: unknown): error is AppError => + typeof error === "object" && + error !== null && + "_tag" in error && + typeof error["_tag"] === "string" && + appErrorTags.has(error["_tag"]) + const jsonResponse = (data: unknown, status: number) => Effect.map(HttpServerResponse.json(data), (response) => HttpServerResponse.setStatus(response, status)) @@ -154,6 +181,10 @@ const errorResponse = (error: ApiError | unknown) => { return jsonResponse({ error: { type: error._tag, message: error.message } }, 500) } + if (isAppError(error)) { + return jsonResponse({ error: { type: error._tag, message: renderError(error) } }, 400) + } + return jsonResponse( { error: { diff --git a/packages/api/src/services/projects.ts b/packages/api/src/services/projects.ts index ba5abaf..8e1dc27 100644 --- a/packages/api/src/services/projects.ts +++ b/packages/api/src/services/projects.ts @@ -6,6 +6,7 @@ import { downAllDockerGitProjects, listProjectItems, readProjectConfig, + renderError, runDockerComposeUpWithPortCheck } from "@effect-template/lib" import * as FileSystem from "@effect/platform/FileSystem" @@ -19,7 +20,7 @@ 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 { ApiInternalError, ApiNotFoundError, ApiBadRequestError } from "../api/errors.js" +import { ApiConflictError, ApiInternalError, ApiNotFoundError, ApiBadRequestError } from "../api/errors.js" import { ensureGithubAuthForCreate } from "./auth.js" import { emitProjectEvent } from "./events.js" @@ -308,7 +309,13 @@ export const createProjectFromRequest = ( }) ) - yield* _(createProject(command)) + yield* _( + createProject(command).pipe( + Effect.catchTag("DockerIdentityConflictError", (error) => + Effect.fail(new ApiConflictError({ message: renderError(error) })) + ) + ) + ) const project = yield* _( resolveCreatedProject( diff --git a/packages/api/tests/projects.test.ts b/packages/api/tests/projects.test.ts index 8ba366e..600d51f 100644 --- a/packages/api/tests/projects.test.ts +++ b/packages/api/tests/projects.test.ts @@ -4,7 +4,8 @@ import { NodeContext } from "@effect/platform-node" import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" -import { seedAuthorizedKeysForCreate } from "../src/services/projects.js" +import { ApiConflictError } from "../src/api/errors.js" +import { createProjectFromRequest, seedAuthorizedKeysForCreate } from "../src/services/projects.js" const withTempDir = ( use: (tempDir: string) => Effect.Effect @@ -86,4 +87,50 @@ describe("projects service", () => { expect(projectContents).toBe(`${hostKey}\n`) }) ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("maps duplicate docker identities to API conflict for create", () => + withTempDir((root) => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const projectsRoot = path.join(root, ".docker-git") + + yield* _( + withProjectsRoot( + projectsRoot, + withWorkingDirectory( + root, + createProjectFromRequest({ + repoUrl: "https://git.example.test/test-owner-a/openclaw_autodeployer.git", + repoRef: "main", + sshPort: "2237", + skipGithubAuth: true, + up: false + }) + ) + ) + ) + + const error = yield* _( + withProjectsRoot( + projectsRoot, + withWorkingDirectory( + root, + createProjectFromRequest({ + repoUrl: "https://git.example.test/test-owner-b/openclaw_autodeployer.git", + repoRef: "main", + sshPort: "2238", + skipGithubAuth: true, + up: false + }).pipe(Effect.flip) + ) + ) + ) + + expect(error).toBeInstanceOf(ApiConflictError) + if (error instanceof ApiConflictError) { + expect(error.message).toContain("Docker identities are already owned") + expect(error.message).toContain("dg-openclaw_autodeployer") + } + }) + ).pipe(Effect.provide(NodeContext.layer))) }) diff --git a/packages/app/src/docker-git/cli/usage.ts b/packages/app/src/docker-git/cli/usage.ts index 0a1851f..89ef947 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -80,7 +80,7 @@ Options: --mcp-playwright | --no-mcp-playwright Enable Playwright MCP + Chromium sidecar (default: --no-mcp-playwright) --auto[=claude|codex] Auto-execute an agent; without value picks by auth, random if both are available --active apply-all: apply only to currently running containers (skip stopped ones) - --force Overwrite existing files, remove conflicting containers, and wipe compose volumes + --force Overwrite existing files, replace conflicting docker-git projects/containers, and wipe compose volumes --force-env Reset project env defaults only (keep workspace volume/data) -h, --help Show this help diff --git a/packages/app/src/docker-git/open-project.ts b/packages/app/src/docker-git/open-project.ts index 7e92c0f..0ec6b43 100644 --- a/packages/app/src/docker-git/open-project.ts +++ b/packages/app/src/docker-git/open-project.ts @@ -1,4 +1,6 @@ -import type { ProjectItem } from "@lib/usecases/projects" +import { defaultTemplateConfig } from "@lib/core/domain" +import { runDockerInspectContainerRuntimeInfo, type DockerContainerRuntimeInfo } from "@lib/shell/docker" +import { buildSshCommand, connectProjectSsh, probeProjectSshReady, type ProjectItem } from "@lib/usecases/projects" import { Effect, pipe } from "effect" import type { OpenCommand } from "@lib/core/domain" @@ -12,9 +14,16 @@ import { resolveApiProjectItem } from "./project-item.js" type OpenResolvedProjectSshDeps = { readonly log: (message: string) => Effect.Effect + readonly resolvePreferredItem: (item: ProjectItem) => Effect.Effect + readonly probeReady: (item: ProjectItem) => Effect.Effect + readonly connect: (item: ProjectItem) => Effect.Effect readonly connectWithUp: (item: ProjectItem) => Effect.Effect } +type ResolveOpenProjectDeps = { + readonly inspectRuntime: (containerName: string) => Effect.Effect +} + const normalizeText = (value: string): string => value.trim().toLowerCase() const normalizePath = (value: string): string => { @@ -96,6 +105,36 @@ const preferSingleRunning = ( return running.length === 1 ? (running[0] ?? null) : null } +const exactRuntimeMatches = ( + selector: string, + projects: ReadonlyArray +): ReadonlyArray => { + const normalizedSelector = normalizeText(selector) + if (normalizedSelector.length === 0) { + return [] + } + return projects.filter((project) => + normalizedSelector === normalizeText(project.containerName) || + normalizedSelector === normalizeText(project.serviceName) + ) +} + +const resolvesExactRuntimeSelector = ( + selector: string, + projects: ReadonlyArray +): ApiProjectDetails | null => { + if (projects.length === 0) { + return null + } + + const matches = exactRuntimeMatches(selector, projects) + if (matches.length !== projects.length) { + return null + } + + return preferSingleRunning(matches) ?? matches[0] ?? null +} + const resolveUniqueProject = ( matches: ReadonlyArray, notFoundMessage: string, @@ -162,6 +201,11 @@ export const selectOpenProject = ( ) if (directMatches.length > 0) { + const exactRuntimeMatch = resolvesExactRuntimeSelector(trimmed, directMatches) + if (exactRuntimeMatch !== null) { + return Effect.succeed(exactRuntimeMatch) + } + return resolveUniqueProject( directMatches, `No docker-git project matched '${trimmed}'.`, @@ -177,6 +221,45 @@ export const selectOpenProject = ( ) } +const uniqueContainerNames = (projects: ReadonlyArray): ReadonlyArray => + Array.from(new Set(projects.map((project) => project.containerName))) + +export const resolveRuntimeOwnedProject = ( + projects: ReadonlyArray, + selector: string | undefined, + deps: ResolveOpenProjectDeps +): Effect.Effect => + Effect.gen(function*(_) { + const trimmed = selector?.trim() ?? "" + const matches = exactRuntimeMatches(trimmed, projects) + if (matches.length === 0) { + return null + } + + for (const containerName of uniqueContainerNames(matches)) { + const runtime = yield* _(deps.inspectRuntime(containerName)) + const ownerDir = runtime?.projectWorkingDir + if (ownerDir === undefined) { + continue + } + const owner = matches.find((project) => normalizePath(project.projectDir) === normalizePath(ownerDir)) + if (owner !== undefined) { + return owner + } + } + + return null + }) + +export const resolveOpenProjectEffect = ( + projects: ReadonlyArray, + selector: string | undefined, + deps: ResolveOpenProjectDeps +): Effect.Effect => + resolveRuntimeOwnedProject(projects, selector, deps).pipe( + Effect.flatMap((ownedProject) => ownedProject === null ? selectOpenProject(projects, selector) : Effect.succeed(ownedProject)) + ) + const listProjectDetails = () => Effect.gen(function*(_) { const summaries = yield* _(listProjects()) @@ -190,20 +273,96 @@ const listProjectDetails = () => return details.filter((project): project is ApiProjectDetails => project !== null) }) +const withProjectItemIpAddress = ( + item: ProjectItem, + ipAddress: string +): ProjectItem => ({ + ...item, + ipAddress, + sshCommand: buildSshCommand( + { + ...defaultTemplateConfig, + containerName: item.containerName, + serviceName: item.serviceName, + sshUser: item.sshUser, + sshPort: item.sshPort, + repoUrl: item.repoUrl, + repoRef: item.repoRef, + targetDir: item.targetDir, + envGlobalPath: item.envGlobalPath, + envProjectPath: item.envProjectPath, + codexAuthPath: item.codexAuthPath, + codexSharedAuthPath: item.codexAuthPath, + codexHome: item.codexHome, + clonedOnHostname: item.clonedOnHostname + }, + item.sshKeyPath, + ipAddress + ) +}) + +const sameConnectionTarget = (left: ProjectItem, right: ProjectItem): boolean => + left.ipAddress === right.ipAddress && + left.sshPort === right.sshPort && + left.sshKeyPath === right.sshKeyPath && + left.sshUser === right.sshUser + +const attemptDirectConnect = ( + item: ProjectItem, + deps: Pick, "connect" | "log" | "probeReady"> +): Effect.Effect => + deps.probeReady(item).pipe( + Effect.flatMap((ready) => + ready + ? pipe( + deps.log(`Opening SSH: ${item.sshCommand}`), + Effect.zipRight(deps.connect(item)), + Effect.as(true) + ) + : Effect.succeed(false) + ) + ) + export const openResolvedProjectSshEffect = ( item: ProjectItem, deps: OpenResolvedProjectSshDeps ) => - pipe( - deps.log(`Opening SSH: ${item.sshCommand}`), - Effect.zipRight(deps.connectWithUp(item)) - ) + Effect.gen(function*(_) { + const preferredItem = yield* _(deps.resolvePreferredItem(item)) + if (preferredItem !== null) { + const connected = yield* _(attemptDirectConnect(preferredItem, deps)) + if (connected) { + return + } + } + + const shouldRetryOriginal = preferredItem === null || !sameConnectionTarget(preferredItem, item) + if (shouldRetryOriginal) { + const connected = yield* _(attemptDirectConnect(item, deps)) + if (connected) { + return + } + } + + yield* _(deps.log(`Opening SSH: ${item.sshCommand}`)) + yield* _(deps.connectWithUp(item)) + }) export const openResolvedProjectSsh = ( item: ProjectItem ) => openResolvedProjectSshEffect(item, { log: (message) => Effect.log(message), + resolvePreferredItem: (selected) => + runDockerInspectContainerRuntimeInfo(process.cwd(), selected.containerName).pipe( + Effect.map((runtime) => + runtime !== null && runtime.ipAddress.length > 0 + ? withProjectItemIpAddress(selected, runtime.ipAddress) + : null + ) + ), + probeReady: (selected) => probeProjectSshReady(selected), + connect: (selected) => connectProjectSsh(selected), connectWithUp: (selected) => connectMenuProjectSshWithUp(selected) }) @@ -212,7 +371,12 @@ export const openExistingProjectSsh = ( ) => Effect.gen(function*(_) { const projects = yield* _(listProjectDetails()) - const project = yield* _(selectOpenProject(projects, command.projectDir ?? command.projectRef)) + const selector = command.projectDir ?? command.projectRef + const project = yield* _( + resolveOpenProjectEffect(projects, selector, { + inspectRuntime: (containerName) => runDockerInspectContainerRuntimeInfo(process.cwd(), containerName) + }) + ) const item = yield* _(resolveApiProjectItem(project)) yield* _(openResolvedProjectSsh(item)) }) diff --git a/packages/app/src/lib/shell/command-runner.ts b/packages/app/src/lib/shell/command-runner.ts index b1dd9eb..0cf27cf 100644 --- a/packages/app/src/lib/shell/command-runner.ts +++ b/packages/app/src/lib/shell/command-runner.ts @@ -80,6 +80,16 @@ const collectUint8Array = (chunks: Chunk.Chunk): Uint8Array => return next }) +const decodeUint8Array = (bytes: Uint8Array): string => new TextDecoder("utf-8").decode(bytes) + +const collectStreamText = ( + stream: Stream.Stream +): Effect.Effect => + pipe(stream, Stream.runCollect, Effect.map((chunks) => decodeUint8Array(collectUint8Array(chunks)))) + +const combineCommandOutput = (stdout: string, stderr: string): string => + [stdout.trim(), stderr.trim()].filter((chunk) => chunk.length > 0).join("\n") + // CHANGE: run a command and capture stdout, draining stderr to prevent buffer deadlock // WHY: if stderr fills the OS buffer (~64 KB) the child process hangs; drain it asynchronously // QUOTE(ТЗ): "система авторизации" @@ -106,7 +116,34 @@ export const runCommandCapture = ( const exitCode = yield* _(process.exitCode) const numericExitCode = Number(exitCode) yield* _(ensureExitCode(numericExitCode, okExitCodes, onFailure)) - return new TextDecoder("utf-8").decode(bytes) + return decodeUint8Array(bytes) + }) + ) + +export const runCommandWithCapturedOutput = ( + spec: RunCommandSpec, + okExitCodes: ReadonlyArray, + onFailure: (exitCode: number, output: string) => E +): Effect.Effect => + Effect.scoped( + Effect.gen(function*(_) { + const executor = yield* _(CommandExecutor.CommandExecutor) + const process = yield* _(executor.start(buildCommand(spec, "pipe", "pipe", "pipe"))) + const [stdout, stderr, exitCode] = yield* _( + Effect.all( + [ + collectStreamText(process.stdout), + collectStreamText(process.stderr), + Effect.map(process.exitCode, (value) => Number(value)) + ], + { concurrency: "unbounded" } + ) + ) + yield* _( + ensureExitCode(exitCode, okExitCodes, (numericExitCode) => + onFailure(numericExitCode, combineCommandOutput(stdout, stderr)) + ) + ) }) ) /* jscpd:ignore-end */ diff --git a/packages/app/src/lib/shell/docker.ts b/packages/app/src/lib/shell/docker.ts index b4d6aa2..fd4df31 100644 --- a/packages/app/src/lib/shell/docker.ts +++ b/packages/app/src/lib/shell/docker.ts @@ -5,7 +5,7 @@ import { ExitCode } from "@effect/platform/CommandExecutor" import type { PlatformError } from "@effect/platform/Error" import { Duration, Effect, pipe, Schedule } from "effect" -import { runCommandCapture, runCommandExitCode, runCommandWithExitCodes } from "./command-runner.js" +import { runCommandCapture, runCommandExitCode, runCommandWithCapturedOutput, runCommandWithExitCodes } from "./command-runner.js" import { composeSpec, resolveDockerComposeEnv } from "./docker-compose-env.js" import { parseInspectNetworkEntry } from "./docker-inspect-parse.js" import { CommandFailedError, DockerCommandError } from "./errors.js" @@ -13,6 +13,19 @@ import { CommandFailedError, DockerCommandError } from "./errors.js" export { classifyDockerAccessIssue, ensureDockerDaemonAccess } from "./docker-daemon-access.js" export { parseDockerPublishedHostPorts, runDockerPsPublishedHostPorts } from "./docker-published-ports.js" +export type DockerContainerRuntimeInfo = { + readonly containerName: string + readonly running: boolean + readonly ipAddress: string + readonly projectWorkingDir?: string | undefined + readonly composeService?: string | undefined +} + +const parseOptionalInspectField = (value: string | undefined): string | undefined => { + const trimmed = value?.trim() ?? "" + return trimmed.length > 0 ? trimmed : undefined +} + const runCompose = ( cwd: string, args: ReadonlyArray, @@ -21,13 +34,13 @@ const runCompose = ( Effect.gen(function*(_) { const env = yield* _(resolveDockerComposeEnv(cwd)) yield* _( - runCommandWithExitCodes( + runCommandWithCapturedOutput( { ...composeSpec(cwd, args), ...(Object.keys(env).length > 0 ? { env } : {}) }, okExitCodes, - (exitCode) => new DockerCommandError({ exitCode }) + (exitCode, output) => new DockerCommandError({ exitCode, ...(output.length > 0 ? { details: output } : {}) }) ) ) }) @@ -302,6 +315,53 @@ export const runDockerInspectContainerIp = ( }) ) +// CHANGE: inspect live Docker runtime ownership and preferred IP for a container +// WHY: allow SSH-open flows to reuse an already running container even when indexed compose state is stale +// QUOTE(ТЗ): "если такой контейнер уже есть то он его и должен был открыть" +// REF: user-request-2026-04-07-open-existing-runtime +// SOURCE: n/a +// FORMAT THEOREM: ∀c: running(c) → inspect_runtime(c) = owner(c), ip(c) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: returns null when the container is missing or not running +// COMPLEXITY: O(command) +export const runDockerInspectContainerRuntimeInfo = ( + cwd: string, + containerName: string +): Effect.Effect => + pipe( + runCommandCapture( + { + cwd, + command: "docker", + args: [ + "inspect", + "-f", + `{{.State.Status}}\t{{with index .Config.Labels "com.docker.compose.project.working_dir"}}{{.}}{{end}}\t{{with index .Config.Labels "com.docker.compose.service"}}{{.}}{{end}}`, + containerName + ] + }, + [Number(ExitCode(0))], + (exitCode) => new DockerCommandError({ exitCode }) + ), + Effect.flatMap((output) => { + const [status, projectWorkingDir, composeService] = output.trim().replaceAll("\\t", "\t").split("\t") + if ((status?.trim() ?? "") !== "running") { + return Effect.succeed(null) + } + return runDockerInspectContainerIp(cwd, containerName).pipe( + Effect.map((ipAddress) => ({ + containerName, + running: true, + ipAddress, + projectWorkingDir: parseOptionalInspectField(projectWorkingDir), + composeService: parseOptionalInspectField(composeService) + })) + ) + }), + Effect.catchAll(() => Effect.succeed(null)) + ) + // CHANGE: inspect the container IP address on the default `bridge` network // WHY: allow callers to decide whether `docker network connect bridge` is needed // QUOTE(ТЗ): "подключиться с внешнего контейнера" diff --git a/packages/app/src/lib/shell/errors.ts b/packages/app/src/lib/shell/errors.ts index 26e73db..bbd0119 100644 --- a/packages/app/src/lib/shell/errors.ts +++ b/packages/app/src/lib/shell/errors.ts @@ -24,6 +24,26 @@ export class InputReadError extends Data.TaggedError("InputReadError")<{ export class DockerCommandError extends Data.TaggedError("DockerCommandError")<{ readonly exitCode: number + readonly details?: string +}> {} + +export type DockerIdentityConflictKind = + | "containerName" + | "browserContainerName" + | "serviceName" + | "volumeName" + | "browserVolumeName" + | "bootstrapVolumeName" + +export type DockerIdentityConflict = { + readonly conflictingProjectDir: string + readonly kind: DockerIdentityConflictKind + readonly name: string +} + +export class DockerIdentityConflictError extends Data.TaggedError("DockerIdentityConflictError")<{ + readonly projectDir: string + readonly conflicts: ReadonlyArray }> {} export type DockerAccessIssue = "PermissionDenied" | "DaemonUnavailable" diff --git a/packages/app/src/lib/usecases/actions/create-project.ts b/packages/app/src/lib/usecases/actions/create-project.ts index 4ea7e0f..cf6d19a 100644 --- a/packages/app/src/lib/usecases/actions/create-project.ts +++ b/packages/app/src/lib/usecases/actions/create-project.ts @@ -5,17 +5,18 @@ import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" import { Effect } from "effect" -import type { CreateCommand, ParseError } from "../../core/domain.js" -import { deriveRepoPathParts } from "../../core/domain.js" +import type { CreateCommand, ParseError, TemplateConfig } from "../../core/domain.js" +import { deriveRepoPathParts, resolveComposeProjectName, resolveProjectBootstrapVolumeName } from "../../core/domain.js" import { runCommandWithExitCodes } from "../../shell/command-runner.js" import { ensureDockerDaemonAccess } from "../../shell/docker.js" -import { CommandFailedError } from "../../shell/errors.js" +import { CommandFailedError, DockerIdentityConflictError } from "../../shell/errors.js" import type { AgentFailedError, AuthError, CloneFailedError, DockerAccessError, DockerCommandError, + DockerIdentityConflict, FileExistsError, PortProbeError } from "../../shell/errors.js" @@ -26,7 +27,13 @@ import { applyGithubForkConfig } from "../github-fork.js" import { validateGithubCloneAuthTokenPreflight } from "../github-token-preflight.js" import { defaultProjectsRoot } from "../menu-helpers.js" import { findSshPrivateKey } from "../path-helpers.js" -import { buildSshCommand, getContainerIpIfInsideContainer } from "../projects-core.js" +import { + buildSshCommand, + getContainerIpIfInsideContainer, + loadProjectIndex, + loadProjectStatus +} from "../projects-core.js" +import { deleteDockerGitProject } from "../projects-delete.js" import { resolveTemplateResourceLimits } from "../resource-limits.js" import { autoSyncState } from "../state-repo.js" import { ensureTerminalCursorVisible } from "../terminal-cursor.js" @@ -44,6 +51,7 @@ type CreateProjectError = | AuthError | DockerAccessError | DockerCommandError + | DockerIdentityConflictError | PortProbeError | ParseError | PlatformError @@ -247,6 +255,97 @@ const resolveRuntimeConfig = ( : { ...finalAgentConfig, clonedOnHostname } }) +type DockerIdentityOwner = Pick + +type DockerIdentityNamespace = "container" | "composeProject" | "volume" + +type DockerIdentityClaim = Omit & { + readonly namespace: DockerIdentityNamespace +} + +const resolveDockerIdentityClaims = ( + config: DockerIdentityOwner +): ReadonlyArray => [ + { namespace: "container", kind: "containerName", name: config.containerName }, + ...(config.enableMcpPlaywright + ? [{ namespace: "container" as const, kind: "browserContainerName" as const, name: `${config.containerName}-browser` }] + : []), + { namespace: "composeProject", kind: "serviceName", name: resolveComposeProjectName(config) }, + { namespace: "volume", kind: "volumeName", name: config.volumeName }, + ...(config.enableMcpPlaywright + ? [{ namespace: "volume" as const, kind: "browserVolumeName" as const, name: `${config.volumeName}-browser` }] + : []), + { namespace: "volume", kind: "bootstrapVolumeName", name: resolveProjectBootstrapVolumeName(config) } +] + +const deleteConflictingProjectsIfNeeded = ( + resolvedOutDir: string, + config: DockerIdentityOwner, + force: boolean +): Effect.Effect => + Effect.gen(function*(_) { + const index = yield* _(loadProjectIndex()) + if (index === null) { + return + } + + const candidateClaims = resolveDockerIdentityClaims(config) + const conflicts: Array = [] + const conflictingProjects = new Map() + + for (const configPath of index.configPaths) { + const status = yield* _( + loadProjectStatus(configPath).pipe( + Effect.match({ + onFailure: () => null, + onSuccess: (value) => value + }) + ) + ) + if (status === null || status.projectDir === resolvedOutDir) { + continue + } + + const existingClaims = resolveDockerIdentityClaims(status.config.template) + const sharedClaims = candidateClaims.flatMap((candidate) => + existingClaims.some( + (existing) => existing.namespace === candidate.namespace && existing.name === candidate.name + ) + ? [{ conflictingProjectDir: status.projectDir, kind: candidate.kind, name: candidate.name }] + : [] + ) + + if (sharedClaims.length === 0) { + continue + } + + conflicts.push(...sharedClaims) + conflictingProjects.set(status.projectDir, { + projectDir: status.projectDir, + repoUrl: status.config.template.repoUrl, + containerName: status.config.template.containerName, + serviceName: status.config.template.serviceName + }) + } + + if (conflicts.length === 0) { + return + } + + if (!force) { + return yield* _(Effect.fail(new DockerIdentityConflictError({ projectDir: resolvedOutDir, conflicts }))) + } + + for (const conflictingProject of conflictingProjects.values()) { + yield* _( + Effect.logWarning( + `Force enabled: replacing conflicting docker-git project ${conflictingProject.projectDir}` + ) + ) + yield* _(deleteDockerGitProject(conflictingProject)) + } + }) + const maybeCleanupAfterAgent = ( waitForAgent: boolean, resolvedOutDir: string @@ -272,6 +371,10 @@ const runCreateProject = ( const resolvedOutDir = path.resolve(ctx.resolveRootPath(command.outDir)) const rootedConfig = resolveRootedConfig(command, ctx) + yield* _( + deleteConflictingProjectsIfNeeded(resolvedOutDir, rootedConfig, command.force) + ) + yield* _(validateGithubCloneAuthTokenPreflight(rootedConfig)) const resolvedConfig = yield* _(resolveCreateConfig(rootedConfig, resolvedOutDir)) diff --git a/packages/app/src/lib/usecases/errors.ts b/packages/app/src/lib/usecases/errors.ts index f8f9d6a..44c8b4e 100644 --- a/packages/app/src/lib/usecases/errors.ts +++ b/packages/app/src/lib/usecases/errors.ts @@ -12,6 +12,7 @@ import type { ConfigNotFoundError, DockerAccessError, DockerCommandError, + DockerIdentityConflictError, FileExistsError, InputCancelledError, InputReadError, @@ -29,6 +30,7 @@ export type AppError = | AgentFailedError | DockerAccessError | DockerCommandError + | DockerIdentityConflictError | ConfigNotFoundError | ConfigDecodeError | ScrapArchiveInvalidError @@ -77,15 +79,38 @@ const renderDockerAccessActionPlan = (issue: DockerAccessError["issue"]): string return issue === "PermissionDenied" ? permissionDeniedPlan.join("\n") : daemonUnavailablePlan.join("\n") } -const renderDockerCommandError = ({ exitCode }: DockerCommandError): string => +const renderDockerCommandError = ({ details, exitCode }: DockerCommandError): string => [ `docker compose failed with exit code ${exitCode}`, + ...(details?.trim().length ? ["Docker output:", details.trim()] : []), "Hint: ensure Docker daemon is running and current user can access /var/run/docker.sock (for example via the docker group).", "Hint: if output above contains 'port is already allocated', retry with a free SSH port via --ssh-port (for example --ssh-port 2235), or stop the conflicting project/container.", "Hint: if output above contains 'all predefined address pools have been fully subnetted', run `docker network prune -f`, configure Docker `default-address-pools`, or use shared network mode (`--network-mode shared`).", "Hint: if output above contains 'lookup auth.docker.io' or 'read udp ... [::1]:53 ... connection refused', fix Docker DNS resolver (set working DNS in host/daemon config) and retry." ].join("\n") +const formatDockerIdentityConflictKind = ( + kind: DockerIdentityConflictError["conflicts"][number]["kind"] +): string => + ({ + containerName: "container name", + browserContainerName: "browser container name", + serviceName: "compose project name", + volumeName: "volume name", + browserVolumeName: "browser volume name", + bootstrapVolumeName: "bootstrap volume name" + })[kind] + +const renderDockerIdentityConflictError = ({ conflicts, projectDir }: DockerIdentityConflictError): string => + [ + `Refusing to create ${projectDir}: Docker identities are already owned by other docker-git projects.`, + ...conflicts.map( + ({ conflictingProjectDir, kind, name }) => + `${formatDockerIdentityConflictKind(kind)} "${name}" already exists in ${conflictingProjectDir}` + ), + "Hint: re-run with --force to replace the conflicting docker-git project, or choose unique --container-name/--service-name/--volume-name." + ].join("\n") + const renderDockerAccessError = ({ details, issue }: DockerAccessError): string => [ renderDockerAccessHeadline(issue), @@ -99,6 +124,7 @@ const renderPrimaryError = (error: NonParseError): string | null => Match.value(error).pipe( Match.when({ _tag: "FileExistsError" }, ({ path }) => `File already exists: ${path} (use --force to overwrite)`), Match.when({ _tag: "DockerCommandError" }, renderDockerCommandError), + Match.when({ _tag: "DockerIdentityConflictError" }, renderDockerIdentityConflictError), Match.when({ _tag: "DockerAccessError" }, renderDockerAccessError), Match.when({ _tag: "CloneFailedError" }, ({ repoRef, repoUrl, targetDir }) => `Clone failed for ${repoUrl} (${repoRef}) into ${targetDir}`), diff --git a/packages/app/src/lib/usecases/projects-delete.ts b/packages/app/src/lib/usecases/projects-delete.ts index 38564b8..64699fd 100644 --- a/packages/app/src/lib/usecases/projects-delete.ts +++ b/packages/app/src/lib/usecases/projects-delete.ts @@ -11,9 +11,15 @@ import { CommandFailedError, type DockerCommandError } from "../shell/errors.js" import { gcProjectNetworkByServiceName } from "./docker-network-gc.js" import { renderError } from "./errors.js" import { defaultProjectsRoot } from "./menu-helpers.js" -import type { ProjectItem } from "./projects-core.js" import { autoSyncState } from "./state-repo.js" +export type DeleteDockerGitProjectTarget = { + readonly projectDir: string + readonly repoUrl: string + readonly containerName: string + readonly serviceName: string +} + const isWithinProjectsRoot = (path: Path.Path, root: string, target: string): boolean => { const relative = path.relative(root, target) if (relative.length === 0) { @@ -50,7 +56,7 @@ const removeContainerByName = ( ) const removeContainersFallback = ( - item: ProjectItem + item: DeleteDockerGitProjectTarget ): Effect.Effect => Effect.gen(function*(_) { yield* _(removeContainerByName(item.projectDir, item.containerName)) @@ -68,7 +74,7 @@ const removeContainersFallback = ( // INVARIANT: never deletes paths outside the projects root // COMPLEXITY: O(docker + fs) export const deleteDockerGitProject = ( - item: ProjectItem + item: DeleteDockerGitProjectTarget ): Effect.Effect< void, PlatformError | DockerCommandError, diff --git a/packages/app/src/lib/usecases/projects-ssh.ts b/packages/app/src/lib/usecases/projects-ssh.ts index 456d813..3294e8e 100644 --- a/packages/app/src/lib/usecases/projects-ssh.ts +++ b/packages/app/src/lib/usecases/projects-ssh.ts @@ -82,21 +82,24 @@ const buildSshProbeArgs = (item: ProjectItem): ReadonlyArray => { return args } +export const probeProjectSshReady = ( + item: ProjectItem +): Effect.Effect => + runCommandExitCode({ + cwd: process.cwd(), + command: "ssh", + args: buildSshProbeArgs(item) + }).pipe(Effect.map((exitCode) => exitCode === 0)) + export const waitForProjectSshReady = ( item: ProjectItem ): Effect.Effect => { const host = item.ipAddress ?? "localhost" const port = item.ipAddress ? 22 : item.sshPort const probe = Effect.gen(function*(_) { - const exitCode = yield* _( - runCommandExitCode({ - cwd: process.cwd(), - command: "ssh", - args: buildSshProbeArgs(item) - }) - ) - if (exitCode !== 0) { - return yield* _(Effect.fail(new CommandFailedError({ command: "ssh wait", exitCode }))) + const ready = yield* _(probeProjectSshReady(item)) + if (!ready) { + return yield* _(Effect.fail(new CommandFailedError({ command: "ssh wait", exitCode: 1 }))) } }) diff --git a/packages/app/src/lib/usecases/projects.ts b/packages/app/src/lib/usecases/projects.ts index 840363c..7a52014 100644 --- a/packages/app/src/lib/usecases/projects.ts +++ b/packages/app/src/lib/usecases/projects.ts @@ -17,6 +17,7 @@ export { connectProjectSsh, connectProjectSshWithUp, listProjectStatus, + probeProjectSshReady, waitForProjectSshReady } from "./projects-ssh.js" export { runDockerComposeUpWithPortCheck } from "./projects-up.js" diff --git a/packages/app/tests/docker-git/create-project-identity-conflict.test.ts b/packages/app/tests/docker-git/create-project-identity-conflict.test.ts new file mode 100644 index 0000000..c9cf364 --- /dev/null +++ b/packages/app/tests/docker-git/create-project-identity-conflict.test.ts @@ -0,0 +1,243 @@ +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { NodeContext } from "@effect/platform-node" +import type { PlatformError } from "@effect/platform/Error" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import { beforeEach, vi } from "vitest" + +import { defaultTemplateConfig, type CreateCommand } from "@lib/core/domain" + +import { DockerIdentityConflictError } from "../../src/lib/shell/errors.js" +import { createProject } from "../../src/lib/usecases/actions/create-project.js" +import type { ProjectStatus } from "../../src/lib/usecases/projects-core.js" + +const resolveSshPortMock = vi.hoisted(() => vi.fn((config: CreateCommand["config"]) => Effect.succeed(config))) +const buildSshCommandMock = vi.hoisted(() => vi.fn(() => "ssh -p 2222 dev@localhost")) +const getContainerIpIfInsideContainerMock = vi.hoisted(() => vi.fn(() => Effect.succeed(undefined))) +const loadProjectIndexMock = vi.hoisted(() => vi.fn()) +const loadProjectStatusMock = vi.hoisted(() => vi.fn()) +const migrateProjectOrchLayoutMock = vi.hoisted(() => vi.fn(() => Effect.void)) +const prepareProjectFilesMock = vi.hoisted(() => vi.fn(() => Effect.succeed([]))) +const autoSyncStateMock = vi.hoisted(() => vi.fn(() => Effect.void)) +const deleteDockerGitProjectMock = vi.hoisted(() => vi.fn(() => Effect.void)) +const runDockerDownCleanupMock = vi.hoisted(() => vi.fn(() => Effect.void)) +const runDockerUpIfNeededMock = vi.hoisted(() => vi.fn(() => Effect.void)) + +vi.mock("../../src/lib/usecases/actions/ports.js", () => ({ + resolveSshPort: resolveSshPortMock +})) + +vi.mock("../../src/lib/usecases/projects-core.js", () => ({ + buildSshCommand: buildSshCommandMock, + getContainerIpIfInsideContainer: getContainerIpIfInsideContainerMock, + loadProjectIndex: loadProjectIndexMock, + loadProjectStatus: loadProjectStatusMock +})) + +vi.mock("../../src/lib/usecases/actions/prepare-files.js", () => ({ + migrateProjectOrchLayout: migrateProjectOrchLayoutMock, + prepareProjectFiles: prepareProjectFilesMock +})) + +vi.mock("../../src/lib/usecases/state-repo.js", () => ({ + autoSyncState: autoSyncStateMock +})) + +vi.mock("../../src/lib/usecases/projects-delete.js", () => ({ + deleteDockerGitProject: deleteDockerGitProjectMock +})) + +vi.mock("../../src/lib/usecases/actions/docker-up.js", () => ({ + runDockerDownCleanup: runDockerDownCleanupMock, + runDockerUpIfNeeded: runDockerUpIfNeededMock +})) + +const withTempDir = ( + use: (tempDir: string) => Effect.Effect +): Effect.Effect => + Effect.scoped( + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const tempDir = yield* _( + fs.makeTempDirectoryScoped({ + prefix: "docker-git-identity-conflict-" + }) + ) + return yield* _(use(tempDir)) + }) + ) + +const makeTemplate = ( + root: string, + overrides: Partial = {} +): CreateCommand["config"] => ({ + ...defaultTemplateConfig, + containerName: "dg-test", + serviceName: "dg-test", + volumeName: "dg-test-home", + repoUrl: "https://git.example.test/test-owner-a/repo.git", + repoRef: "main", + targetDir: "/home/dev/org/repo", + dockerGitPath: `${root}/.docker-git`, + authorizedKeysPath: `${root}/authorized_keys`, + envGlobalPath: `${root}/.orch/env/global.env`, + envProjectPath: `${root}/.orch/env/project.env`, + codexAuthPath: `${root}/.orch/auth/codex`, + codexSharedAuthPath: `${root}/.orch/auth/codex-shared`, + codexHome: "/home/dev/.codex", + dockerNetworkMode: "shared", + dockerSharedNetworkName: "docker-git-shared", + enableMcpPlaywright: false, + ...overrides +}) + +const makeStatus = ( + projectDir: string, + root: string, + overrides: Partial = {} +): ProjectStatus => ({ + projectDir, + config: { + schemaVersion: 1, + template: makeTemplate(root, overrides) + } +}) + +const makeCommand = ( + root: string, + outDir: string, + force: boolean +): CreateCommand => ({ + _tag: "Create", + config: makeTemplate(root), + outDir, + runUp: false, + openSsh: false, + force, + forceEnv: false, + waitForClone: false +}) + +describe("createProject docker identity guard", () => { + beforeEach(() => { + loadProjectIndexMock.mockReset() + loadProjectStatusMock.mockReset() + resolveSshPortMock.mockReset() + migrateProjectOrchLayoutMock.mockReset() + prepareProjectFilesMock.mockReset() + autoSyncStateMock.mockReset() + deleteDockerGitProjectMock.mockReset() + runDockerUpIfNeededMock.mockReset() + runDockerDownCleanupMock.mockReset() + }) + + it.effect("fails when another project already uses the same Docker identity", () => + withTempDir((root) => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const outDir = path.join(root, "candidate") + const existingDir = path.join(root, "existing") + const existingConfigPath = path.join(existingDir, "docker-git.json") + const command = makeCommand(root, outDir, false) + + loadProjectIndexMock.mockReturnValue( + Effect.succeed({ + projectsRoot: path.join(root, ".docker-git"), + configPaths: [existingConfigPath] + }) + ) + loadProjectStatusMock.mockImplementation((configPath: string) => + Effect.succeed( + makeStatus( + existingDir, + root, + configPath === existingConfigPath + ? {} + : { + containerName: "dg-test-other", + serviceName: "dg-test-other", + volumeName: "dg-test-other-home" + } + ) + ) + ) + + const error = yield* _(createProject(command).pipe(Effect.flip)) + + expect(error).toBeInstanceOf(DockerIdentityConflictError) + if (error instanceof DockerIdentityConflictError) { + expect(error.projectDir).toBe(outDir) + expect(error.conflicts).toEqual([ + { conflictingProjectDir: existingDir, kind: "containerName", name: "dg-test" }, + { conflictingProjectDir: existingDir, kind: "serviceName", name: "dg-test" }, + { conflictingProjectDir: existingDir, kind: "volumeName", name: "dg-test-home" }, + { conflictingProjectDir: existingDir, kind: "bootstrapVolumeName", name: "dg-test-home-bootstrap" } + ]) + } + expect(prepareProjectFilesMock).not.toHaveBeenCalled() + expect(deleteDockerGitProjectMock).not.toHaveBeenCalled() + expect(runDockerUpIfNeededMock).not.toHaveBeenCalled() + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("force replaces the conflicting project before recreating", () => + withTempDir((root) => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const outDir = path.join(root, "candidate") + const existingDir = path.join(root, "existing") + const existingConfigPath = path.join(existingDir, "docker-git.json") + const command = makeCommand(root, outDir, true) + + loadProjectIndexMock.mockReturnValue( + Effect.succeed({ + projectsRoot: path.join(root, ".docker-git"), + configPaths: [existingConfigPath] + }) + ) + loadProjectStatusMock.mockReturnValue( + Effect.succeed(makeStatus(existingDir, root)) + ) + + yield* _(createProject(command)) + + expect(deleteDockerGitProjectMock).toHaveBeenCalledTimes(1) + expect(deleteDockerGitProjectMock).toHaveBeenCalledWith({ + projectDir: existingDir, + repoUrl: "https://git.example.test/test-owner-a/repo.git", + containerName: "dg-test", + serviceName: "dg-test" + }) + expect(prepareProjectFilesMock).toHaveBeenCalledTimes(1) + expect(runDockerUpIfNeededMock).toHaveBeenCalledTimes(1) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("allows the same projectDir to be recreated with --force", () => + withTempDir((root) => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + const outDir = path.join(root, "candidate") + const configPath = path.join(outDir, "docker-git.json") + const command = makeCommand(root, outDir, true) + + loadProjectIndexMock.mockReturnValue( + Effect.succeed({ + projectsRoot: path.join(root, ".docker-git"), + configPaths: [configPath] + }) + ) + loadProjectStatusMock.mockReturnValue( + Effect.succeed(makeStatus(outDir, root)) + ) + + yield* _(createProject(command)) + + expect(prepareProjectFilesMock).toHaveBeenCalledTimes(1) + expect(deleteDockerGitProjectMock).not.toHaveBeenCalled() + expect(migrateProjectOrchLayoutMock).toHaveBeenCalledTimes(1) + expect(runDockerUpIfNeededMock).toHaveBeenCalledTimes(1) + }) + ).pipe(Effect.provide(NodeContext.layer))) +}) diff --git a/packages/app/tests/docker-git/docker-runtime-info.test.ts b/packages/app/tests/docker-git/docker-runtime-info.test.ts new file mode 100644 index 0000000..01aa4ee --- /dev/null +++ b/packages/app/tests/docker-git/docker-runtime-info.test.ts @@ -0,0 +1,121 @@ +import * as Command from "@effect/platform/Command" +import * as CommandExecutor from "@effect/platform/CommandExecutor" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import * as Inspectable from "effect/Inspectable" +import * as Sink from "effect/Sink" +import * as Stream from "effect/Stream" + +import { runDockerInspectContainerRuntimeInfo } from "../../src/lib/shell/docker.js" + +type RecordedCommand = { + readonly command: string + readonly args: ReadonlyArray +} + +const encode = (value: string): Uint8Array => new TextEncoder().encode(value) + +const isRuntimeInspect = (command: RecordedCommand): boolean => + command.command === "docker" && + command.args[0] === "inspect" && + command.args[1] === "-f" && + (command.args[2] ?? "").includes(".State.Status") + +const isIpInspect = (command: RecordedCommand): boolean => + command.command === "docker" && + command.args[0] === "inspect" && + command.args[1] === "-f" && + (command.args[2] ?? "").includes("NetworkSettings.Networks") + +const makeFakeExecutor = (outputs: { + readonly runtimeOutput: string + readonly ipOutput: string +}): CommandExecutor.CommandExecutor => { + const start = (command: Command.Command): Effect.Effect => + Effect.gen(function*(_) { + const flattened = Command.flatten(command) + const last = flattened[flattened.length - 1]! + const invocation: RecordedCommand = { + command: last.command, + args: last.args + } + + const stdoutText = isRuntimeInspect(invocation) + ? outputs.runtimeOutput + : isIpInspect(invocation) + ? outputs.ipOutput + : "" + + const stdout = stdoutText.length === 0 + ? Stream.empty + : Stream.succeed(encode(stdoutText)) + + const process: CommandExecutor.Process = { + [CommandExecutor.ProcessTypeId]: CommandExecutor.ProcessTypeId, + pid: CommandExecutor.ProcessId(1), + exitCode: Effect.succeed(CommandExecutor.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: (_signal) => Effect.void, + stderr: Stream.empty, + stdin: Sink.drain, + stdout, + toJSON: () => ({ _tag: "DockerRuntimeInfoTestProcess", command: invocation.command, args: invocation.args }), + [Inspectable.NodeInspectSymbol]: () => ({ + _tag: "DockerRuntimeInfoTestProcess", + command: invocation.command, + args: invocation.args + }), + toString: () => `[DockerRuntimeInfoTestProcess ${invocation.command}]` + } + + return process + }) + + return CommandExecutor.makeExecutor(start) +} + +describe("runDockerInspectContainerRuntimeInfo", () => { + it.effect("parses running runtime ownership even when separators arrive as literal escapes", () => + Effect.gen(function*(_) { + const executor = makeFakeExecutor({ + runtimeOutput: "running\\t/home/dev/.docker-git/test-owner/repo\\tdg-repo\n", + ipOutput: "bridge=172.17.0.15\nproject=10.88.0.2\n" + }) + + const runtime = yield* _( + runDockerInspectContainerRuntimeInfo("/tmp", "dg-repo").pipe( + Effect.provideService(CommandExecutor.CommandExecutor, executor) + ) + ) + + expect(runtime).toEqual({ + containerName: "dg-repo", + running: true, + ipAddress: "172.17.0.15", + projectWorkingDir: "/home/dev/.docker-git/test-owner/repo", + composeService: "dg-repo" + }) + })) + + it.effect("keeps optional compose labels undefined when runtime is unlabeled", () => + Effect.gen(function*(_) { + const executor = makeFakeExecutor({ + runtimeOutput: "running\t\t\n", + ipOutput: "project=10.88.0.4\n" + }) + + const runtime = yield* _( + runDockerInspectContainerRuntimeInfo("/tmp", "dg-repo").pipe( + Effect.provideService(CommandExecutor.CommandExecutor, executor) + ) + ) + + expect(runtime).toEqual({ + containerName: "dg-repo", + running: true, + ipAddress: "10.88.0.4", + projectWorkingDir: undefined, + composeService: undefined + }) + })) +}) diff --git a/packages/app/tests/docker-git/open-project.test.ts b/packages/app/tests/docker-git/open-project.test.ts index bc96402..c4365cb 100644 --- a/packages/app/tests/docker-git/open-project.test.ts +++ b/packages/app/tests/docker-git/open-project.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" import type { ApiProjectDetails } from "../../src/docker-git/api-project-codec.js" -import { openResolvedProjectSshEffect, selectOpenProject } from "../../src/docker-git/open-project.js" +import { openResolvedProjectSshEffect, resolveOpenProjectEffect, selectOpenProject } from "../../src/docker-git/open-project.js" import { makeProjectItem } from "./fixtures/project-item.js" const defaultProject = { @@ -42,7 +42,7 @@ const expectSelectedProject = ( }) describe("selectOpenProject", () => { - it.effect("uses the shared SSH-open effect ordering", () => + it.effect("connects directly when SSH is already reachable", () => Effect.gen(function*(_) { const item = makeProjectItem({ projectDir: "/controller/org/repo/issue-7", @@ -56,9 +56,15 @@ describe("selectOpenProject", () => { Effect.sync(() => { events.push(`log:${message}`) }), - connectWithUp: (selected) => + resolvePreferredItem: () => Effect.succeed(null), + probeReady: () => Effect.succeed(true), + connect: (selected) => Effect.sync(() => { events.push(`connect:${selected.projectDir}`) + }), + connectWithUp: (selected) => + Effect.sync(() => { + events.push(`up:${selected.projectDir}`) }) }) ) @@ -69,6 +75,117 @@ describe("selectOpenProject", () => { ]) })) + it.effect("falls back to docker up when SSH is not yet reachable", () => + Effect.gen(function*(_) { + const item = makeProjectItem({ + projectDir: "/controller/org/repo/issue-8", + sshCommand: "ssh -p 2222 dev@localhost" + }) + const events: Array = [] + + yield* _( + openResolvedProjectSshEffect(item, { + log: (message) => + Effect.sync(() => { + events.push(`log:${message}`) + }), + resolvePreferredItem: () => Effect.succeed(null), + probeReady: () => Effect.succeed(false), + connect: (selected) => + Effect.sync(() => { + events.push(`connect:${selected.projectDir}`) + }), + connectWithUp: (selected) => + Effect.sync(() => { + events.push(`up:${selected.projectDir}`) + }) + }) + ) + + expect(events).toEqual([ + "log:Opening SSH: ssh -p 2222 dev@localhost", + "up:/controller/org/repo/issue-8" + ]) + })) + + it.effect("prefers a live runtime SSH target before falling back to docker up", () => + Effect.gen(function*(_) { + const item = makeProjectItem({ + projectDir: "/controller/org/repo/issue-9", + sshCommand: "ssh -p 2253 dev@localhost", + sshPort: 2253 + }) + const preferred = makeProjectItem({ + ...item, + ipAddress: "172.17.0.15", + sshCommand: "ssh -p 22 dev@172.17.0.15" + }) + const events: Array = [] + + yield* _( + openResolvedProjectSshEffect(item, { + log: (message) => + Effect.sync(() => { + events.push(`log:${message}`) + }), + resolvePreferredItem: () => Effect.succeed(preferred), + probeReady: (selected) => Effect.succeed(selected.ipAddress === "172.17.0.15"), + connect: (selected) => + Effect.sync(() => { + events.push(`connect:${selected.sshCommand}`) + }), + connectWithUp: (selected) => + Effect.sync(() => { + events.push(`up:${selected.projectDir}`) + }) + }) + ) + + expect(events).toEqual([ + "log:Opening SSH: ssh -p 22 dev@172.17.0.15", + "connect:ssh -p 22 dev@172.17.0.15" + ]) + })) + + it.effect("falls back to the original SSH target when live runtime probe fails", () => + Effect.gen(function*(_) { + const item = makeProjectItem({ + projectDir: "/controller/org/repo/issue-10", + sshCommand: "ssh -p 2237 dev@localhost", + sshPort: 2237 + }) + const preferred = makeProjectItem({ + ...item, + ipAddress: "172.17.0.20", + sshCommand: "ssh -p 22 dev@172.17.0.20" + }) + const events: Array = [] + + yield* _( + openResolvedProjectSshEffect(item, { + log: (message) => + Effect.sync(() => { + events.push(`log:${message}`) + }), + resolvePreferredItem: () => Effect.succeed(preferred), + probeReady: (selected) => Effect.succeed(selected.ipAddress !== "172.17.0.20"), + connect: (selected) => + Effect.sync(() => { + events.push(`connect:${selected.sshCommand}`) + }), + connectWithUp: (selected) => + Effect.sync(() => { + events.push(`up:${selected.projectDir}`) + }) + }) + ) + + expect(events).toEqual([ + "log:Opening SSH: ssh -p 2237 dev@localhost", + "connect:ssh -p 2237 dev@localhost" + ]) + })) + it.effect("prefers the single running project when selector is omitted", () => Effect.gen(function*(_) { const stopped = makeProject({ @@ -107,6 +224,92 @@ describe("selectOpenProject", () => { ) })) + it.effect("accepts an exact container selector even when multiple projects reuse the same container name", () => + Effect.gen(function*(_) { + const first = makeProject({ + id: "/controller/testorganization123213/openclaw_autodeployer", + projectDir: "/controller/testorganization123213/openclaw_autodeployer", + displayName: "testorganization123213/openclaw_autodeployer", + repoUrl: "https://github.com/TestOrganization123213/openclaw_autodeployer", + containerName: "dg-openclaw_autodeployer", + serviceName: "dg-openclaw_autodeployer" + }) + const second = makeProject({ + id: "/controller/telegramgpt/openclaw_autodeployer", + projectDir: "/controller/telegramgpt/openclaw_autodeployer", + displayName: "telegramgpt/openclaw_autodeployer", + repoUrl: "https://github.com/TelegramGPT/openclaw_autodeployer", + containerName: "dg-openclaw_autodeployer", + serviceName: "dg-openclaw_autodeployer" + }) + + const resolved = yield* _(selectOpenProject([first, second], "dg-openclaw_autodeployer")) + expect(resolved.projectDir).toBe("/controller/testorganization123213/openclaw_autodeployer") + })) + + it.effect("prefers the runtime owner for exact container selectors when API statuses are stale", () => + Effect.gen(function*(_) { + const first = makeProject({ + id: "/controller/testorganization123213/openclaw_autodeployer", + projectDir: "/controller/testorganization123213/openclaw_autodeployer", + displayName: "testorganization123213/openclaw_autodeployer", + repoUrl: "https://github.com/TestOrganization123213/openclaw_autodeployer", + containerName: "dg-openclaw_autodeployer", + serviceName: "dg-openclaw_autodeployer" + }) + const second = makeProject({ + id: "/controller/telegramgpt/openclaw_autodeployer", + projectDir: "/controller/telegramgpt/openclaw_autodeployer", + displayName: "telegramgpt/openclaw_autodeployer", + repoUrl: "https://github.com/TelegramGPT/openclaw_autodeployer", + containerName: "dg-openclaw_autodeployer", + serviceName: "dg-openclaw_autodeployer" + }) + + const resolved = yield* _( + resolveOpenProjectEffect([first, second], "dg-openclaw_autodeployer", { + inspectRuntime: () => + Effect.succeed({ + containerName: "dg-openclaw_autodeployer", + running: true, + ipAddress: "172.17.0.15", + projectWorkingDir: "/controller/telegramgpt/openclaw_autodeployer", + composeService: "dg-openclaw_autodeployer" + }) + }) + ) + + expect(resolved.projectDir).toBe("/controller/telegramgpt/openclaw_autodeployer") + })) + + it.effect("falls back to selector matching when runtime ownership is unavailable", () => + Effect.gen(function*(_) { + const first = makeProject({ + id: "/controller/testorganization123213/openclaw_autodeployer", + projectDir: "/controller/testorganization123213/openclaw_autodeployer", + displayName: "testorganization123213/openclaw_autodeployer", + repoUrl: "https://github.com/TestOrganization123213/openclaw_autodeployer", + containerName: "dg-openclaw_autodeployer", + serviceName: "dg-openclaw_autodeployer" + }) + const second = makeProject({ + id: "/controller/telegramgpt/openclaw_autodeployer", + projectDir: "/controller/telegramgpt/openclaw_autodeployer", + displayName: "telegramgpt/openclaw_autodeployer", + repoUrl: "https://github.com/TelegramGPT/openclaw_autodeployer", + containerName: "dg-openclaw_autodeployer", + serviceName: "dg-openclaw_autodeployer" + }) + + const resolved = yield* _( + resolveOpenProjectEffect([first, second], "dg-openclaw_autodeployer", { + inspectRuntime: () => Effect.succeed(null) + }) + ) + + expect(resolved.projectDir).toBe("/controller/testorganization123213/openclaw_autodeployer") + })) + it.effect("matches a project by GitHub issue URL", () => Effect.gen(function*(_) { const project = makeProject({ diff --git a/packages/lib/src/shell/command-runner.ts b/packages/lib/src/shell/command-runner.ts index 97a62db..a83635a 100644 --- a/packages/lib/src/shell/command-runner.ts +++ b/packages/lib/src/shell/command-runner.ts @@ -79,6 +79,16 @@ const collectUint8Array = (chunks: Chunk.Chunk): Uint8Array => return next }) +const decodeUint8Array = (bytes: Uint8Array): string => new TextDecoder("utf-8").decode(bytes) + +const collectStreamText = ( + stream: Stream.Stream +): Effect.Effect => + pipe(stream, Stream.runCollect, Effect.map((chunks) => decodeUint8Array(collectUint8Array(chunks)))) + +const combineCommandOutput = (stdout: string, stderr: string): string => + [stdout.trim(), stderr.trim()].filter((chunk) => chunk.length > 0).join("\n") + // CHANGE: run a command and capture stdout, draining stderr to prevent buffer deadlock // WHY: if stderr fills the OS buffer (~64 KB) the child process hangs; drain it asynchronously // QUOTE(ТЗ): "система авторизации" @@ -105,6 +115,33 @@ export const runCommandCapture = ( const exitCode = yield* _(process.exitCode) const numericExitCode = Number(exitCode) yield* _(ensureExitCode(numericExitCode, okExitCodes, onFailure)) - return new TextDecoder("utf-8").decode(bytes) + return decodeUint8Array(bytes) + }) + ) + +export const runCommandWithCapturedOutput = ( + spec: RunCommandSpec, + okExitCodes: ReadonlyArray, + onFailure: (exitCode: number, output: string) => E +): Effect.Effect => + Effect.scoped( + Effect.gen(function*(_) { + const executor = yield* _(CommandExecutor.CommandExecutor) + const process = yield* _(executor.start(buildCommand(spec, "pipe", "pipe", "pipe"))) + const [stdout, stderr, exitCode] = yield* _( + Effect.all( + [ + collectStreamText(process.stdout), + collectStreamText(process.stderr), + Effect.map(process.exitCode, (value) => Number(value)) + ], + { concurrency: "unbounded" } + ) + ) + yield* _( + ensureExitCode(exitCode, okExitCodes, (numericExitCode) => + onFailure(numericExitCode, combineCommandOutput(stdout, stderr)) + ) + ) }) ) diff --git a/packages/lib/src/shell/docker.ts b/packages/lib/src/shell/docker.ts index 57f126b..f6982b5 100644 --- a/packages/lib/src/shell/docker.ts +++ b/packages/lib/src/shell/docker.ts @@ -4,7 +4,7 @@ import { ExitCode } from "@effect/platform/CommandExecutor" import type { PlatformError } from "@effect/platform/Error" import { Duration, Effect, pipe, Schedule } from "effect" -import { runCommandCapture, runCommandExitCode, runCommandWithExitCodes } from "./command-runner.js" +import { runCommandCapture, runCommandExitCode, runCommandWithCapturedOutput, runCommandWithExitCodes } from "./command-runner.js" import { composeSpec, resolveDockerComposeEnv } from "./docker-compose-env.js" import { parseInspectNetworkEntry } from "./docker-inspect-parse.js" import { CommandFailedError, DockerCommandError } from "./errors.js" @@ -20,13 +20,13 @@ const runCompose = ( Effect.gen(function*(_) { const env = yield* _(resolveDockerComposeEnv(cwd)) yield* _( - runCommandWithExitCodes( + runCommandWithCapturedOutput( { ...composeSpec(cwd, args), ...(Object.keys(env).length > 0 ? { env } : {}) }, okExitCodes, - (exitCode) => new DockerCommandError({ exitCode }) + (exitCode, output) => new DockerCommandError({ exitCode, ...(output.length > 0 ? { details: output } : {}) }) ) ) }) diff --git a/packages/lib/src/shell/errors.ts b/packages/lib/src/shell/errors.ts index 38a4b50..5928f0e 100644 --- a/packages/lib/src/shell/errors.ts +++ b/packages/lib/src/shell/errors.ts @@ -23,6 +23,7 @@ export class InputReadError extends Data.TaggedError("InputReadError")<{ export class DockerCommandError extends Data.TaggedError("DockerCommandError")<{ readonly exitCode: number + readonly details?: string }> {} export type DockerAccessIssue = "PermissionDenied" | "DaemonUnavailable" @@ -32,6 +33,25 @@ export class DockerAccessError extends Data.TaggedError("DockerAccessError")<{ readonly details: string }> {} +export type DockerIdentityConflictKind = + | "containerName" + | "browserContainerName" + | "serviceName" + | "volumeName" + | "browserVolumeName" + | "bootstrapVolumeName" + +export type DockerIdentityConflict = { + readonly conflictingProjectDir: string + readonly kind: DockerIdentityConflictKind + readonly name: string +} + +export class DockerIdentityConflictError extends Data.TaggedError("DockerIdentityConflictError")<{ + readonly projectDir: string + readonly conflicts: ReadonlyArray +}> {} + export class CloneFailedError extends Data.TaggedError("CloneFailedError")<{ readonly repoUrl: string readonly repoRef: string diff --git a/packages/lib/src/usecases/actions/create-project.ts b/packages/lib/src/usecases/actions/create-project.ts index 25a9597..842e5a8 100644 --- a/packages/lib/src/usecases/actions/create-project.ts +++ b/packages/lib/src/usecases/actions/create-project.ts @@ -4,17 +4,18 @@ import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" import { Effect } from "effect" -import type { CreateCommand, ParseError } from "../../core/domain.js" -import { deriveRepoPathParts } from "../../core/domain.js" +import type { CreateCommand, ParseError, TemplateConfig } from "../../core/domain.js" +import { deriveRepoPathParts, resolveComposeProjectName, resolveProjectBootstrapVolumeName } from "../../core/domain.js" import { runCommandWithExitCodes } from "../../shell/command-runner.js" import { ensureDockerDaemonAccess } from "../../shell/docker.js" -import { CommandFailedError } from "../../shell/errors.js" +import { CommandFailedError, DockerIdentityConflictError } from "../../shell/errors.js" import type { AgentFailedError, AuthError, CloneFailedError, DockerAccessError, DockerCommandError, + DockerIdentityConflict, FileExistsError, PortProbeError } from "../../shell/errors.js" @@ -25,7 +26,8 @@ import { applyGithubForkConfig } from "../github-fork.js" import { validateGithubCloneAuthTokenPreflight } from "../github-token-preflight.js" import { defaultProjectsRoot } from "../menu-helpers.js" import { findSshPrivateKey } from "../path-helpers.js" -import { buildSshCommand, getContainerIpIfInsideContainer } from "../projects-core.js" +import { buildSshCommand, getContainerIpIfInsideContainer, loadProjectIndex, loadProjectStatus } from "../projects-core.js" +import { deleteDockerGitProject } from "../projects-delete.js" import { resolveTemplateResourceLimits } from "../resource-limits.js" import { autoSyncState } from "../state-repo.js" import { ensureTerminalCursorVisible } from "../terminal-cursor.js" @@ -43,6 +45,7 @@ type CreateProjectError = | AuthError | DockerAccessError | DockerCommandError + | DockerIdentityConflictError | PortProbeError | ParseError | PlatformError @@ -94,6 +97,97 @@ const formatStateSyncLabel = (repoUrl: string): string => { return repoPath.length > 0 ? repoPath : repoUrl } +type DockerIdentityOwner = Pick + +type DockerIdentityNamespace = "container" | "composeProject" | "volume" + +type DockerIdentityClaim = Omit & { + readonly namespace: DockerIdentityNamespace +} + +const resolveDockerIdentityClaims = ( + config: DockerIdentityOwner +): ReadonlyArray => [ + { namespace: "container", kind: "containerName", name: config.containerName }, + ...(config.enableMcpPlaywright + ? [{ namespace: "container" as const, kind: "browserContainerName" as const, name: `${config.containerName}-browser` }] + : []), + { namespace: "composeProject", kind: "serviceName", name: resolveComposeProjectName(config) }, + { namespace: "volume", kind: "volumeName", name: config.volumeName }, + ...(config.enableMcpPlaywright + ? [{ namespace: "volume" as const, kind: "browserVolumeName" as const, name: `${config.volumeName}-browser` }] + : []), + { namespace: "volume", kind: "bootstrapVolumeName", name: resolveProjectBootstrapVolumeName(config) } +] + +const deleteConflictingProjectsIfNeeded = ( + resolvedOutDir: string, + config: DockerIdentityOwner, + force: boolean +): Effect.Effect => + Effect.gen(function*(_) { + const index = yield* _(loadProjectIndex()) + if (index === null) { + return + } + + const candidateClaims = resolveDockerIdentityClaims(config) + const conflicts: Array = [] + const conflictingProjects = new Map() + + for (const configPath of index.configPaths) { + const status = yield* _( + loadProjectStatus(configPath).pipe( + Effect.match({ + onFailure: () => null, + onSuccess: (value) => value + }) + ) + ) + if (status === null || status.projectDir === resolvedOutDir) { + continue + } + + const existingClaims = resolveDockerIdentityClaims(status.config.template) + const sharedClaims = candidateClaims.flatMap((candidate) => + existingClaims.some( + (existing) => existing.namespace === candidate.namespace && existing.name === candidate.name + ) + ? [{ conflictingProjectDir: status.projectDir, kind: candidate.kind, name: candidate.name }] + : [] + ) + + if (sharedClaims.length === 0) { + continue + } + + conflicts.push(...sharedClaims) + conflictingProjects.set(status.projectDir, { + projectDir: status.projectDir, + repoUrl: status.config.template.repoUrl, + containerName: status.config.template.containerName, + serviceName: status.config.template.serviceName + }) + } + + if (conflicts.length === 0) { + return + } + + if (!force) { + return yield* _(Effect.fail(new DockerIdentityConflictError({ projectDir: resolvedOutDir, conflicts }))) + } + + for (const conflictingProject of conflictingProjects.values()) { + yield* _( + Effect.logWarning( + `Force enabled: replacing conflicting docker-git project ${conflictingProject.projectDir}` + ) + ) + yield* _(deleteDockerGitProject(conflictingProject)) + } + }) + const isInteractiveTty = (): boolean => process.stdin.isTTY && process.stdout.isTTY const buildSshArgs = ( @@ -249,6 +343,10 @@ const runCreateProject = ( const resolvedOutDir = path.resolve(ctx.resolveRootPath(command.outDir)) const rootedConfig = resolveRootedConfig(command, ctx) + yield* _( + deleteConflictingProjectsIfNeeded(resolvedOutDir, rootedConfig, command.force) + ) + yield* _(validateGithubCloneAuthTokenPreflight(rootedConfig)) const resolvedConfig = yield* _(resolveCreateConfig(rootedConfig, resolvedOutDir)) diff --git a/packages/lib/src/usecases/errors.ts b/packages/lib/src/usecases/errors.ts index 74c2196..a8966da 100644 --- a/packages/lib/src/usecases/errors.ts +++ b/packages/lib/src/usecases/errors.ts @@ -11,6 +11,7 @@ import type { ConfigNotFoundError, DockerAccessError, DockerCommandError, + DockerIdentityConflictError, FileExistsError, InputCancelledError, InputReadError, @@ -28,6 +29,7 @@ export type AppError = | AgentFailedError | DockerAccessError | DockerCommandError + | DockerIdentityConflictError | ConfigNotFoundError | ConfigDecodeError | ScrapArchiveInvalidError @@ -76,15 +78,38 @@ const renderDockerAccessActionPlan = (issue: DockerAccessError["issue"]): string return issue === "PermissionDenied" ? permissionDeniedPlan.join("\n") : daemonUnavailablePlan.join("\n") } -const renderDockerCommandError = ({ exitCode }: DockerCommandError): string => +const renderDockerCommandError = ({ details, exitCode }: DockerCommandError): string => [ `docker compose failed with exit code ${exitCode}`, + ...(details?.trim().length ? ["Docker output:", details.trim()] : []), "Hint: ensure Docker daemon is running and current user can access /var/run/docker.sock (for example via the docker group).", "Hint: if output above contains 'port is already allocated', retry with a free SSH port via --ssh-port (for example --ssh-port 2235), or stop the conflicting project/container.", "Hint: if output above contains 'all predefined address pools have been fully subnetted', run `docker network prune -f`, configure Docker `default-address-pools`, or use shared network mode (`--network-mode shared`).", "Hint: if output above contains 'lookup auth.docker.io' or 'read udp ... [::1]:53 ... connection refused', fix Docker DNS resolver (set working DNS in host/daemon config) and retry." ].join("\n") +const formatDockerIdentityConflictKind = ( + kind: DockerIdentityConflictError["conflicts"][number]["kind"] +): string => + ({ + containerName: "container name", + browserContainerName: "browser container name", + serviceName: "compose project name", + volumeName: "volume name", + browserVolumeName: "browser volume name", + bootstrapVolumeName: "bootstrap volume name" + })[kind] + +const renderDockerIdentityConflictError = ({ conflicts, projectDir }: DockerIdentityConflictError): string => + [ + `Refusing to create ${projectDir}: Docker identities are already owned by other docker-git projects.`, + ...conflicts.map( + ({ conflictingProjectDir, kind, name }) => + `${formatDockerIdentityConflictKind(kind)} "${name}" already exists in ${conflictingProjectDir}` + ), + "Hint: re-run with --force to replace the conflicting docker-git project, or choose unique --container-name/--service-name/--volume-name." + ].join("\n") + const renderDockerAccessError = ({ details, issue }: DockerAccessError): string => [ renderDockerAccessHeadline(issue), @@ -99,6 +124,7 @@ const renderPrimaryError = (error: NonParseError): string | null => Match.when({ _tag: "FileExistsError" }, ({ path }) => `File already exists: ${path} (use --force to overwrite)`), Match.when({ _tag: "DockerCommandError" }, renderDockerCommandError), Match.when({ _tag: "DockerAccessError" }, renderDockerAccessError), + Match.when({ _tag: "DockerIdentityConflictError" }, renderDockerIdentityConflictError), Match.when({ _tag: "CloneFailedError" }, ({ repoRef, repoUrl, targetDir }) => `Clone failed for ${repoUrl} (${repoRef}) into ${targetDir}`), Match.when({ _tag: "AgentFailedError" }, ({ agentMode, targetDir }) => diff --git a/packages/lib/src/usecases/projects-delete.ts b/packages/lib/src/usecases/projects-delete.ts index d38fc2f..6777c12 100644 --- a/packages/lib/src/usecases/projects-delete.ts +++ b/packages/lib/src/usecases/projects-delete.ts @@ -10,9 +10,15 @@ import { CommandFailedError, type DockerCommandError } from "../shell/errors.js" import { gcProjectNetworkByServiceName } from "./docker-network-gc.js" import { renderError } from "./errors.js" import { defaultProjectsRoot } from "./menu-helpers.js" -import type { ProjectItem } from "./projects-core.js" import { autoSyncState } from "./state-repo.js" +export type DeleteDockerGitProjectTarget = { + readonly projectDir: string + readonly repoUrl: string + readonly containerName: string + readonly serviceName: string +} + const isWithinProjectsRoot = (path: Path.Path, root: string, target: string): boolean => { const relative = path.relative(root, target) if (relative.length === 0) { @@ -49,7 +55,7 @@ const removeContainerByName = ( ) const removeContainersFallback = ( - item: ProjectItem + item: DeleteDockerGitProjectTarget ): Effect.Effect => Effect.gen(function*(_) { yield* _(removeContainerByName(item.projectDir, item.containerName)) @@ -67,7 +73,7 @@ const removeContainersFallback = ( // INVARIANT: never deletes paths outside the projects root // COMPLEXITY: O(docker + fs) export const deleteDockerGitProject = ( - item: ProjectItem + item: DeleteDockerGitProjectTarget ): Effect.Effect< void, PlatformError | DockerCommandError, diff --git a/packages/lib/src/usecases/projects-ssh.ts b/packages/lib/src/usecases/projects-ssh.ts index cecdc8d..39b5d24 100644 --- a/packages/lib/src/usecases/projects-ssh.ts +++ b/packages/lib/src/usecases/projects-ssh.ts @@ -81,21 +81,24 @@ const buildSshProbeArgs = (item: ProjectItem): ReadonlyArray => { return args } +export const probeProjectSshReady = ( + item: ProjectItem +): Effect.Effect => + runCommandExitCode({ + cwd: process.cwd(), + command: "ssh", + args: buildSshProbeArgs(item) + }).pipe(Effect.map((exitCode) => exitCode === 0)) + const waitForSshReady = ( item: ProjectItem ): Effect.Effect => { const host = item.ipAddress ?? "localhost" const port = item.ipAddress ? 22 : item.sshPort const probe = Effect.gen(function*(_) { - const exitCode = yield* _( - runCommandExitCode({ - cwd: process.cwd(), - command: "ssh", - args: buildSshProbeArgs(item) - }) - ) - if (exitCode !== 0) { - return yield* _(Effect.fail(new CommandFailedError({ command: "ssh wait", exitCode }))) + const ready = yield* _(probeProjectSshReady(item)) + if (!ready) { + return yield* _(Effect.fail(new CommandFailedError({ command: "ssh wait", exitCode: 1 }))) } }) @@ -114,6 +117,8 @@ const waitForSshReady = ( ) } +export const waitForProjectSshReady = waitForSshReady + // CHANGE: connect to a project via SSH using its resolved settings // WHY: allow TUI to open a shell immediately after selection // QUOTE(ТЗ): "выбор проекта сразу подключает по SSH" diff --git a/packages/lib/src/usecases/projects.ts b/packages/lib/src/usecases/projects.ts index 3a3e8b7..d63a49f 100644 --- a/packages/lib/src/usecases/projects.ts +++ b/packages/lib/src/usecases/projects.ts @@ -11,5 +11,11 @@ export { export { deleteDockerGitProject } from "./projects-delete.js" export { downAllDockerGitProjects } from "./projects-down.js" export { listProjectItems, listProjects, listProjectSummaries, listRunningProjectItems } from "./projects-list.js" -export { connectProjectSsh, connectProjectSshWithUp, listProjectStatus } from "./projects-ssh.js" +export { + connectProjectSsh, + connectProjectSshWithUp, + listProjectStatus, + probeProjectSshReady, + waitForProjectSshReady +} from "./projects-ssh.js" export { runDockerComposeUpWithPortCheck } from "./projects-up.js" diff --git a/packages/lib/tests/usecases/create-project-docker-identities.test.ts b/packages/lib/tests/usecases/create-project-docker-identities.test.ts new file mode 100644 index 0000000..99f795f --- /dev/null +++ b/packages/lib/tests/usecases/create-project-docker-identities.test.ts @@ -0,0 +1,296 @@ +import * as Command from "@effect/platform/Command" +import * as CommandExecutor from "@effect/platform/CommandExecutor" +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { NodeContext } from "@effect/platform-node" +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import * as Inspectable from "effect/Inspectable" +import * as Sink from "effect/Sink" +import * as Stream from "effect/Stream" +import { vi } from "vitest" + +import type { CreateCommand, TemplateConfig } from "../../src/core/domain.js" +import { DockerIdentityConflictError } from "../../src/shell/errors.js" +import { createProject } from "../../src/usecases/actions/create-project.js" + +vi.mock("../../src/usecases/actions/ports.js", () => ({ + resolveSshPort: (config: CreateCommand["config"]) => Effect.succeed(config) +})) + +type RecordedCommand = { + readonly command: string + readonly args: ReadonlyArray +} + +const withTempDir = ( + use: (tempDir: string) => Effect.Effect +): Effect.Effect => + Effect.scoped( + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const tempDir = yield* _( + fs.makeTempDirectoryScoped({ + prefix: "docker-git-create-identities-" + }) + ) + return yield* _(use(tempDir)) + }) + ) + +const withWorkingDirectory = ( + cwd: string, + effect: Effect.Effect +): Effect.Effect => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.cwd() + process.chdir(cwd) + return previous + }), + () => effect, + (previous) => + Effect.sync(() => { + process.chdir(previous) + }) + ) + +const withProjectsRoot = ( + projectsRoot: string, + effect: Effect.Effect +): Effect.Effect => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = process.env["DOCKER_GIT_PROJECTS_ROOT"] + process.env["DOCKER_GIT_PROJECTS_ROOT"] = projectsRoot + return previous + }), + () => effect, + (previous) => + Effect.sync(() => { + if (previous === undefined) { + delete process.env["DOCKER_GIT_PROJECTS_ROOT"] + return + } + process.env["DOCKER_GIT_PROJECTS_ROOT"] = previous + }) + ) + +const makeFakeExecutor = (recorded: Array): CommandExecutor.CommandExecutor => { + const start = (command: Command.Command): Effect.Effect => + Effect.gen(function*(_) { + const flattened = Command.flatten(command) + for (const entry of flattened) { + recorded.push({ command: entry.command, args: entry.args }) + } + + const invocation = flattened[flattened.length - 1]! + const exitCode = + invocation.command === "git" && invocation.args[0] === "rev-parse" + ? CommandExecutor.ExitCode(1) + : CommandExecutor.ExitCode(0) + + const process: CommandExecutor.Process = { + [CommandExecutor.ProcessTypeId]: CommandExecutor.ProcessTypeId, + pid: CommandExecutor.ProcessId(1), + exitCode: Effect.succeed(exitCode), + isRunning: Effect.succeed(false), + kill: (_signal) => Effect.void, + stderr: Stream.empty, + stdin: Sink.drain, + stdout: Stream.empty, + toJSON: () => ({ _tag: "CreateProjectDockerIdentitiesTestProcess", command: invocation.command, args: invocation.args }), + [Inspectable.NodeInspectSymbol]: () => ({ + _tag: "CreateProjectDockerIdentitiesTestProcess", + command: invocation.command, + args: invocation.args + }), + toString: () => `[CreateProjectDockerIdentitiesTestProcess ${invocation.command}]` + } + + return process + }) + + return CommandExecutor.makeExecutor(start) +} + +const makeTemplate = ( + root: string, + repoUrl: string, + path: Path.Path +): TemplateConfig => ({ + containerName: "dg-openclaw_autodeployer", + serviceName: "dg-openclaw_autodeployer", + sshUser: "dev", + sshPort: 2222, + repoUrl, + repoRef: "main", + skipGithubAuth: true, + targetDir: "/home/dev/workspaces/openclaw_autodeployer", + volumeName: "dg-openclaw_autodeployer-home", + dockerGitPath: path.join(root, ".docker-git"), + authorizedKeysPath: path.join(root, "authorized_keys"), + envGlobalPath: path.join(root, ".orch", "env", "global.env"), + envProjectPath: path.join(root, ".orch", "env", "project.env"), + codexAuthPath: path.join(root, ".orch", "auth", "codex"), + codexSharedAuthPath: path.join(root, ".orch", "auth", "codex-shared"), + codexHome: "/home/dev/.codex", + geminiAuthPath: path.join(root, ".orch", "auth", "gemini"), + geminiHome: "/home/dev/.gemini", + dockerNetworkMode: "shared", + dockerSharedNetworkName: "docker-git-shared", + enableMcpPlaywright: true, + pnpmVersion: "10.27.0" +}) + +const makeCommand = ( + root: string, + outDir: string, + repoUrl: string, + path: Path.Path, + force: boolean +): CreateCommand => ({ + _tag: "Create", + config: makeTemplate(root, repoUrl, path), + outDir, + runUp: false, + openSsh: false, + force, + forceEnv: false, + waitForClone: false +}) + +const runCreate = ( + cwd: string, + projectsRoot: string, + command: CreateCommand, + executor: CommandExecutor.CommandExecutor +) => + withProjectsRoot( + projectsRoot, + withWorkingDirectory( + cwd, + createProject(command).pipe( + Effect.provideService(CommandExecutor.CommandExecutor, executor) + ) + ) + ) + +describe("createProject docker identity invariants", () => { + it.effect("rejects a second project with the same docker identities without force", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const projectsRoot = path.join(root, ".docker-git") + const recorded: Array = [] + const executor = makeFakeExecutor(recorded) + const firstOutDir = path.join(projectsRoot, "telegramgpt", "openclaw_autodeployer") + const secondOutDir = path.join(projectsRoot, "testorganization123213", "openclaw_autodeployer") + + yield* _( + runCreate( + root, + projectsRoot, + makeCommand(root, firstOutDir, "https://git.example.test/test-owner-a/openclaw_autodeployer.git", path, false), + executor + ) + ) + + const error = yield* _( + runCreate( + root, + projectsRoot, + makeCommand( + root, + secondOutDir, + "https://git.example.test/test-owner-b/openclaw_autodeployer.git", + path, + false + ), + executor + ).pipe(Effect.flip) + ) + + expect(error).toBeInstanceOf(DockerIdentityConflictError) + if (error instanceof DockerIdentityConflictError) { + expect(error.conflicts.map((conflict) => conflict.name)).toContain("dg-openclaw_autodeployer") + expect(error.conflicts.map((conflict) => conflict.conflictingProjectDir)).toContain(firstOutDir) + } + + expect(yield* _(fs.exists(secondOutDir))).toBe(false) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("force replaces the conflicting project and keeps docker identities unique", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const projectsRoot = path.join(root, ".docker-git") + const recorded: Array = [] + const executor = makeFakeExecutor(recorded) + const firstOutDir = path.join(projectsRoot, "telegramgpt", "openclaw_autodeployer") + const secondOutDir = path.join(projectsRoot, "testorganization123213", "openclaw_autodeployer") + + yield* _( + runCreate( + root, + projectsRoot, + makeCommand(root, firstOutDir, "https://git.example.test/test-owner-a/openclaw_autodeployer.git", path, false), + executor + ) + ) + + yield* _( + runCreate( + root, + projectsRoot, + makeCommand( + root, + secondOutDir, + "https://git.example.test/test-owner-b/openclaw_autodeployer.git", + path, + true + ), + executor + ) + ) + + expect(yield* _(fs.exists(firstOutDir))).toBe(false) + expect(yield* _(fs.exists(secondOutDir))).toBe(true) + }) + ).pipe(Effect.provide(NodeContext.layer))) + + it.effect("force still allows recreating the same project directory in place", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const projectsRoot = path.join(root, ".docker-git") + const recorded: Array = [] + const executor = makeFakeExecutor(recorded) + const outDir = path.join(projectsRoot, "telegramgpt", "openclaw_autodeployer") + + yield* _( + runCreate( + root, + projectsRoot, + makeCommand(root, outDir, "https://git.example.test/test-owner-a/openclaw_autodeployer.git", path, false), + executor + ) + ) + + yield* _( + runCreate( + root, + projectsRoot, + makeCommand(root, outDir, "https://git.example.test/test-owner-a/openclaw_autodeployer.git", path, true), + executor + ) + ) + + expect(yield* _(fs.exists(path.join(outDir, "docker-git.json")))).toBe(true) + }) + ).pipe(Effect.provide(NodeContext.layer))) +}) diff --git a/packages/lib/tests/usecases/errors.test.ts b/packages/lib/tests/usecases/errors.test.ts index ef6454f..354943c 100644 --- a/packages/lib/tests/usecases/errors.test.ts +++ b/packages/lib/tests/usecases/errors.test.ts @@ -1,13 +1,26 @@ import { describe, expect, it } from "@effect/vitest" -import { DockerAccessError, DockerCommandError, ScrapArchiveNotFoundError } from "../../src/shell/errors.js" +import { + DockerAccessError, + DockerCommandError, + DockerIdentityConflictError, + ScrapArchiveNotFoundError +} from "../../src/shell/errors.js" import { renderError } from "../../src/usecases/errors.js" describe("renderError", () => { it("includes docker daemon access hint for DockerCommandError", () => { - const message = renderError(new DockerCommandError({ exitCode: 1 })) + const message = renderError( + new DockerCommandError({ + exitCode: 1, + details: + 'Error response from daemon: Conflict. The container name "/dg-openclaw_autodeployer-browser" is already in use.' + }) + ) expect(message).toContain("docker compose failed with exit code 1") + expect(message).toContain("Docker output:") + expect(message).toContain("already in use") expect(message).toContain("/var/run/docker.sock") expect(message).toContain("port is already allocated") expect(message).toContain("--ssh-port") @@ -28,6 +41,26 @@ describe("renderError", () => { expect(message).toContain("Details:") }) + it("renders conflicting docker identity guidance", () => { + const message = renderError( + new DockerIdentityConflictError({ + projectDir: "/tmp/new-project", + conflicts: [ + { + conflictingProjectDir: "/tmp/old-project", + kind: "containerName", + name: "dg-openclaw_autodeployer" + } + ] + }) + ) + + expect(message).toContain("Refusing to create /tmp/new-project") + expect(message).toContain('container name "dg-openclaw_autodeployer" already exists in /tmp/old-project') + expect(message).toContain("--force") + expect(message).toContain("--container-name") + }) + it("renders scrap archive missing hint", () => { const message = renderError(new ScrapArchiveNotFoundError({ path: "/tmp/workspace.tar.gz" }))