diff --git a/.gitignore b/.gitignore index 8a999b65..f5b65d74 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ dev_ssh_key.pub # Local docker-git work dirs .docker-git/ +.e2e/ effect-template1/ # Node / build artifacts diff --git a/AGENTS.md b/AGENTS.md index 7c87c6cd..4b74344f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -373,3 +373,12 @@ describe("Message invariants", () => { Каждый эффект — это контролируемое взаимодействие с реальным миром. ПРИНЦИП: Сначала формализуем, потом программируем. + + +Issue workspace: #39 +Issue URL: https://github.com/ProverCoderAI/docker-git/issues/39 +Workspace path: /home/dev/provercoderai/docker-git/issue-39 + +Работай только над этим issue, если пользователь не попросил другое. +Если нужен первоисточник требований, открой Issue URL. + diff --git a/package.json b/package.json index 4f19c18a..f365c6f4 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,9 @@ "@parcel/watcher", "msgpackr-extract", "unrs-resolver" - ] + ], + "patchedDependencies": { + "@ton-ai-core/vibecode-linter@1.0.6": "patches/@ton-ai-core__vibecode-linter@1.0.6.patch" + } } } diff --git a/packages/app/src/docker-git/cli/parser-clone.ts b/packages/app/src/docker-git/cli/parser-clone.ts index 59945a5e..5aed993a 100644 --- a/packages/app/src/docker-git/cli/parser-clone.ts +++ b/packages/app/src/docker-git/cli/parser-clone.ts @@ -49,7 +49,8 @@ export const parseClone = (args: ReadonlyArray): Either.Either RawOptions>> = { "--up": (raw) => ({ ...raw, up: true }), "--no-up": (raw) => ({ ...raw, up: false }), + "--ssh": (raw) => ({ ...raw, openSsh: true }), + "--no-ssh": (raw) => ({ ...raw, openSsh: false }), "--force": (raw) => ({ ...raw, force: true }), "--force-env": (raw) => ({ ...raw, forceEnv: true }), "--mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: true }), diff --git a/packages/app/src/docker-git/cli/usage.ts b/packages/app/src/docker-git/cli/usage.ts index e50f7622..a50f7a01 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -52,6 +52,7 @@ Options: --lines Tail last N lines for sessions logs (default: 200) --include-default Show default/system processes in sessions list --up | --no-up Run docker compose up after init (default: --up) + --ssh | --no-ssh Auto-open SSH after create/clone (default: clone=--ssh, create=--no-ssh) --mcp-playwright | --no-mcp-playwright Enable Playwright MCP + Chromium sidecar (default: --no-mcp-playwright) --force Overwrite existing files and wipe compose volumes (docker compose down -v) --force-env Reset project env defaults only (keep workspace volume/data) diff --git a/packages/app/tests/docker-git/parser.test.ts b/packages/app/tests/docker-git/parser.test.ts index a7f8ff2e..bc6b1b2d 100644 --- a/packages/app/tests/docker-git/parser.test.ts +++ b/packages/app/tests/docker-git/parser.test.ts @@ -56,6 +56,8 @@ describe("parseArgs", () => { it.effect("parses create command with defaults", () => expectCreateCommand(["create", "--repo-url", "https://github.com/org/repo.git"], (command) => { expectCreateDefaults(command) + expect(command.openSsh).toBe(false) + expect(command.waitForClone).toBe(false) expect(command.config.containerName).toBe("dg-repo") expect(command.config.serviceName).toBe("dg-repo") expect(command.config.volumeName).toBe("dg-repo-home") @@ -67,6 +69,8 @@ describe("parseArgs", () => { expect(command.config.repoUrl).toBe("https://github.com/org/repo.git") expect(command.config.repoRef).toBe("issue-9") expect(command.outDir).toBe(".docker-git/org/repo/issue-9") + expect(command.openSsh).toBe(false) + expect(command.waitForClone).toBe(false) expect(command.config.containerName).toBe("dg-repo-issue-9") expect(command.config.serviceName).toBe("dg-repo-issue-9") expect(command.config.volumeName).toBe("dg-repo-issue-9-home") @@ -77,6 +81,8 @@ describe("parseArgs", () => { it.effect("parses clone command with positional repo url", () => expectCreateCommand(["clone", "https://github.com/org/repo.git"], (command) => { expectCreateDefaults(command) + expect(command.openSsh).toBe(true) + expect(command.waitForClone).toBe(true) expect(command.config.targetDir).toBe("/home/dev/org/repo") })) @@ -85,6 +91,16 @@ describe("parseArgs", () => { expect(command.config.repoRef).toBe("feature-x") })) + it.effect("supports disabling SSH auto-open for clone", () => + expectCreateCommand(["clone", "https://github.com/org/repo.git", "--no-ssh"], (command) => { + expect(command.openSsh).toBe(false) + })) + + it.effect("supports enabling SSH auto-open for create", () => + expectCreateCommand(["create", "--repo-url", "https://github.com/org/repo.git", "--ssh"], (command) => { + expect(command.openSsh).toBe(true) + })) + it.effect("parses force-env flag for clone", () => expectCreateCommand(["clone", "https://github.com/org/repo.git", "--force-env"], (command) => { expect(command.force).toBe(false) diff --git a/packages/docker-git/src/server/http.ts b/packages/docker-git/src/server/http.ts index df22fe97..7b812d7a 100644 --- a/packages/docker-git/src/server/http.ts +++ b/packages/docker-git/src/server/http.ts @@ -1115,6 +1115,7 @@ export const makeRouter = ({ cwd, projectsRoot, webRoot, vendorRoot, terminalPor config: nextTemplate, outDir: project.directory, runUp: false, + openSsh: false, force: true, forceEnv: false, waitForClone: false @@ -1455,6 +1456,7 @@ data: ${JSON.stringify(data)} config: nextTemplate, outDir: project.directory, runUp: false, + openSsh: false, force: true, forceEnv: false, waitForClone: false diff --git a/packages/lib/src/core/command-builders.ts b/packages/lib/src/core/command-builders.ts index 5bf7e8e8..6c775a35 100644 --- a/packages/lib/src/core/command-builders.ts +++ b/packages/lib/src/core/command-builders.ts @@ -200,6 +200,7 @@ export const buildCreateCommand = ( const names = yield* _(resolveNames(raw, repo.projectSlug)) const paths = yield* _(resolvePaths(raw, repo.projectSlug, repo.repoPath)) const runUp = raw.up ?? true + const openSsh = raw.openSsh ?? false const force = raw.force ?? false const forceEnv = raw.forceEnv ?? false const enableMcpPlaywright = raw.enableMcpPlaywright ?? false @@ -208,6 +209,7 @@ export const buildCreateCommand = ( _tag: "Create", outDir: paths.outDir, runUp, + openSsh, force, forceEnv, waitForClone: false, diff --git a/packages/lib/src/core/command-options.ts b/packages/lib/src/core/command-options.ts index 902b2721..d2b3bdd9 100644 --- a/packages/lib/src/core/command-options.ts +++ b/packages/lib/src/core/command-options.ts @@ -39,6 +39,7 @@ export interface RawOptions { readonly lines?: string readonly includeDefault?: boolean readonly up?: boolean + readonly openSsh?: boolean readonly force?: boolean readonly forceEnv?: boolean } diff --git a/packages/lib/src/core/domain.ts b/packages/lib/src/core/domain.ts index 4a03a382..98a1bd98 100644 --- a/packages/lib/src/core/domain.ts +++ b/packages/lib/src/core/domain.ts @@ -36,6 +36,7 @@ export interface CreateCommand { readonly force: boolean readonly forceEnv: boolean readonly waitForClone: boolean + readonly openSsh: boolean } export interface MenuCommand { diff --git a/packages/lib/src/usecases/actions/create-project.ts b/packages/lib/src/usecases/actions/create-project.ts index 2772f55b..6fd09e50 100644 --- a/packages/lib/src/usecases/actions/create-project.ts +++ b/packages/lib/src/usecases/actions/create-project.ts @@ -1,12 +1,14 @@ import type * as CommandExecutor from "@effect/platform/CommandExecutor" import type { PlatformError } from "@effect/platform/Error" -import type * as FileSystem from "@effect/platform/FileSystem" +import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" import { Effect } from "effect" import type { CreateCommand } from "../../core/domain.js" import { deriveRepoPathParts } from "../../core/domain.js" +import { runCommandWithExitCodes } from "../../shell/command-runner.js" import { ensureDockerDaemonAccess } from "../../shell/docker.js" +import { CommandFailedError } from "../../shell/errors.js" import type { CloneFailedError, DockerAccessError, @@ -15,8 +17,11 @@ import type { PortProbeError } from "../../shell/errors.js" import { logDockerAccessInfo } from "../access-log.js" +import { renderError } from "../errors.js" import { applyGithubForkConfig } from "../github-fork.js" import { defaultProjectsRoot } from "../menu-helpers.js" +import { findSshPrivateKey } from "../path-helpers.js" +import { buildSshCommand } from "../projects-core.js" import { autoSyncState } from "../state-repo.js" import { runDockerUpIfNeeded } from "./docker-up.js" import { buildProjectConfigs, resolveDockerGitRootRelativePath } from "./paths.js" @@ -80,6 +85,72 @@ const formatStateSyncLabel = (repoUrl: string): string => { return repoPath.length > 0 ? repoPath : repoUrl } +const isInteractiveTty = (): boolean => process.stdin.isTTY === true && process.stdout.isTTY === true + +const buildSshArgs = ( + config: CreateCommand["config"], + sshKeyPath: string | null +): ReadonlyArray => { + const args: Array = [] + if (sshKeyPath !== null) { + args.push("-i", sshKeyPath) + } + args.push( + "-tt", + "-Y", + "-o", + "LogLevel=ERROR", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + "-p", + String(config.sshPort), + `${config.sshUser}@localhost` + ) + return args +} + +// CHANGE: auto-open SSH after environment is created (best-effort) +// WHY: clone flow should drop the user into the container without manual copy/paste +// QUOTE(ТЗ): "Мне надо что бы он сразу открыл SSH" +// REF: issue-39 +// SOURCE: n/a +// FORMAT THEOREM: forall c: openSsh(c) -> ssh_session_started(c) || warning_logged(c) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: SSH failures do not fail the create/clone command +// COMPLEXITY: O(1) + ssh +const openSshBestEffort = ( + template: CreateCommand["config"] +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + + const sshKey = yield* _(findSshPrivateKey(fs, path, process.cwd())) + const sshCommand = buildSshCommand(template, sshKey) + + yield* _(Effect.log(`Opening SSH: ${sshCommand}`)) + yield* _( + runCommandWithExitCodes( + { + cwd: process.cwd(), + command: "ssh", + args: buildSshArgs(template, sshKey) + }, + [0, 130], + (exitCode) => new CommandFailedError({ command: "ssh", exitCode }) + ) + ) + }).pipe( + Effect.asVoid, + Effect.matchEffect({ + onFailure: (error) => Effect.logWarning(`SSH auto-open failed: ${renderError(error)}`), + onSuccess: () => Effect.void + }) + ) + const runCreateProject = ( path: Path.Path, command: CreateCommand @@ -118,6 +189,16 @@ const runCreateProject = ( } yield* _(autoSyncState(`chore(state): update ${formatStateSyncLabel(projectConfig.repoUrl)}`)) + + if (command.openSsh) { + if (!command.runUp) { + yield* _(Effect.logWarning("Skipping SSH auto-open: docker compose up disabled (--no-up).")) + } else if (!isInteractiveTty()) { + yield* _(Effect.logWarning("Skipping SSH auto-open: not running in an interactive TTY.")) + } else { + yield* _(openSshBestEffort(projectConfig)) + } + } }).pipe(Effect.asVoid) export const createProject = (command: CreateCommand): Effect.Effect => diff --git a/packages/lib/tests/usecases/create-project-open-ssh.test.ts b/packages/lib/tests/usecases/create-project-open-ssh.test.ts new file mode 100644 index 00000000..ae07708d --- /dev/null +++ b/packages/lib/tests/usecases/create-project-open-ssh.test.ts @@ -0,0 +1,207 @@ +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 type { CreateCommand, TemplateConfig } from "../../src/core/domain.js" +import { createProject } from "../../src/usecases/actions/create-project.js" + +type RecordedCommand = { + readonly command: string + readonly args: ReadonlyArray +} + +type ProcessPatch = { + readonly prevProjectsRoot: string | undefined + readonly prevStdinTty: boolean | undefined + readonly prevStdoutTty: boolean | undefined +} + +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-open-ssh-" + }) + ) + return yield* _(use(tempDir)) + }) + ) + +const patchProcessForInteractiveSsh = (projectsRoot: string): Effect.Effect => + Effect.sync(() => { + const prevProjectsRoot = process.env["DOCKER_GIT_PROJECTS_ROOT"] + const prevStdinTty = process.stdin.isTTY + const prevStdoutTty = process.stdout.isTTY + + process.env["DOCKER_GIT_PROJECTS_ROOT"] = projectsRoot + Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true }) + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }) + + return { prevProjectsRoot, prevStdinTty, prevStdoutTty } + }) + +const restorePatchedProcess = (patch: ProcessPatch): Effect.Effect => + Effect.sync(() => { + if (patch.prevProjectsRoot === undefined) { + delete process.env["DOCKER_GIT_PROJECTS_ROOT"] + } else { + process.env["DOCKER_GIT_PROJECTS_ROOT"] = patch.prevProjectsRoot + } + Object.defineProperty(process.stdin, "isTTY", { value: patch.prevStdinTty, configurable: true }) + Object.defineProperty(process.stdout, "isTTY", { value: patch.prevStdoutTty, configurable: true }) + }) + +const withInteractiveProcess = ( + projectsRoot: string, + use: Effect.Effect +): Effect.Effect => + Effect.scoped( + Effect.acquireRelease(patchProcessForInteractiveSsh(projectsRoot), restorePatchedProcess).pipe( + Effect.flatMap(() => use) + ) + ) + +const encode = (value: string): Uint8Array => new TextEncoder().encode(value) + +const commandIncludes = (args: ReadonlyArray, needle: string): boolean => args.includes(needle) + +const decideExitCode = (cmd: RecordedCommand): number => { + if (cmd.command === "git" && cmd.args[0] === "rev-parse") { + // Auto-sync should detect "not a repo" and exit early. + return 1 + } + + if (cmd.command === "docker" && cmd.args[0] === "exec") { + if (commandIncludes(cmd.args, "/run/docker-git/clone.failed")) { + return 1 + } + if (commandIncludes(cmd.args, "/run/docker-git/clone.done")) { + return 0 + } + } + + return 0 +} + +const decideStdout = (cmd: RecordedCommand): string => { + if (cmd.command === "docker" && cmd.args[0] === "inspect") { + // Keep it empty so ensureDockerDnsHost skips /etc/hosts modifications in tests. + return "" + } + return "" +} + +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 last = flattened[flattened.length - 1] + const invocation: RecordedCommand = { command: last.command, args: last.args } + const exit = decideExitCode(invocation) + const stdoutText = decideStdout(invocation) + 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(exit)), + isRunning: Effect.succeed(false), + kill: (_signal) => Effect.void, + stderr: Stream.empty, + stdin: Sink.drain, + stdout, + toJSON: () => ({ _tag: "TestProcess", command: invocation.command, args: invocation.args, exit }), + [Inspectable.NodeInspectSymbol]: () => ({ _tag: "TestProcess", command: invocation.command, args: invocation.args }), + toString: () => `[TestProcess ${invocation.command}]` + } + + return process + }) + + return CommandExecutor.makeExecutor(start) +} + +const makeCommand = (root: string, outDir: string, path: Path.Path): CreateCommand => { + const template: TemplateConfig = { + containerName: "dg-test", + serviceName: "dg-test", + sshUser: "dev", + sshPort: 2222, + repoUrl: "https://github.com/org/repo.git", + repoRef: "main", + targetDir: "/home/dev/org/repo", + volumeName: "dg-test-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", + enableMcpPlaywright: false, + pnpmVersion: "10.27.0" + } + + return { + _tag: "Create", + config: template, + outDir, + runUp: true, + openSsh: true, + force: true, + forceEnv: false, + waitForClone: true + } +} + +describe("createProject (openSsh)", () => { + it.effect("runs ssh after clone completion when openSsh=true", () => + withTempDir((root) => + Effect.gen(function*(_) { + const path = yield* _(Path.Path) + + const outDir = path.join(root, "project") + const recorded: Array = [] + const executor = makeFakeExecutor(recorded) + const command = makeCommand(root, outDir, path) + + yield* _( + withInteractiveProcess( + path.join(root, "state"), + createProject(command).pipe(Effect.provideService(CommandExecutor.CommandExecutor, executor)) + ) + ) + + const sshInvocations = recorded.filter((entry) => entry.command === "ssh") + expect(sshInvocations).toHaveLength(1) + const ssh = sshInvocations[0]! + expect(ssh.args).toContain("-p") + expect(ssh.args).toContain("2222") + expect(ssh.args).toContain("dev@localhost") + + const cloneDoneIndex = recorded.findIndex( + (entry) => entry.command === "docker" && entry.args[0] === "exec" && entry.args.includes("/run/docker-git/clone.done") + ) + const sshIndex = recorded.findIndex((entry) => entry.command === "ssh") + expect(cloneDoneIndex).toBeGreaterThanOrEqual(0) + expect(sshIndex).toBeGreaterThan(cloneDoneIndex) + }) + ) + .pipe(Effect.provide(NodeContext.layer)) + ) +}) diff --git a/packages/web/scripts/terminal-ws.mjs b/packages/web/scripts/terminal-ws.mjs index 57c167b7..83c6c300 100644 --- a/packages/web/scripts/terminal-ws.mjs +++ b/packages/web/scripts/terminal-ws.mjs @@ -179,6 +179,7 @@ const runRecreateFlow = async (projectDir, send) => { config: config.template, outDir: projectDir, runUp: false, + openSsh: false, force: true, forceEnv: false, waitForClone: false diff --git a/patches/@ton-ai-core__vibecode-linter@1.0.6.patch b/patches/@ton-ai-core__vibecode-linter@1.0.6.patch new file mode 100644 index 00000000..986a5e6b --- /dev/null +++ b/patches/@ton-ai-core__vibecode-linter@1.0.6.patch @@ -0,0 +1,15 @@ +diff --git a/dist/shell/utils/dependencies.js b/dist/shell/utils/dependencies.js +index bd96968de92e45ef4543abb7781bd71d333c8090..47a6bf82594a74b1acefbe24c2e00b0171dc3cf6 100644 +--- a/dist/shell/utils/dependencies.js ++++ b/dist/shell/utils/dependencies.js +@@ -42,7 +42,9 @@ const DEPENDENCIES = [ + { + name: "TypeScript", + command: "tsc", +- checkCommand: "npx tsc --version", ++ // npm@11+ `npx tsc` resolves to the unrelated `tsc` package. ++ // We just need the TypeScript compiler API dependency to be present. ++ checkCommand: "node -e \"require('typescript')\"", + installCommand: "npm install", + required: true, + }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fe72578..69e9e1ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + '@ton-ai-core/vibecode-linter@1.0.6': + hash: f1c1c4c58bdb59306606b68007b20230987779acff18507650a013d40de72b55 + path: patches/@ton-ai-core__vibecode-linter@1.0.6.patch + importers: .: @@ -101,7 +106,7 @@ importers: version: 0.0.13(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@ton-ai-core/vibecode-linter': specifier: ^1.0.6 - version: 1.0.6 + version: 1.0.6(patch_hash=f1c1c4c58bdb59306606b68007b20230987779acff18507650a013d40de72b55) '@types/node': specifier: ^24.10.9 version: 24.10.9 @@ -304,7 +309,7 @@ importers: version: 0.0.13(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@ton-ai-core/vibecode-linter': specifier: ^1.0.6 - version: 1.0.6 + version: 1.0.6(patch_hash=f1c1c4c58bdb59306606b68007b20230987779acff18507650a013d40de72b55) '@types/node': specifier: ^24.10.9 version: 24.10.9 @@ -5777,7 +5782,7 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.18 - '@ton-ai-core/vibecode-linter@1.0.6': + '@ton-ai-core/vibecode-linter@1.0.6(patch_hash=f1c1c4c58bdb59306606b68007b20230987779acff18507650a013d40de72b55)': dependencies: ajv: 8.17.1 effect: 3.19.14 diff --git a/scripts/e2e/opencode-autoconnect.sh b/scripts/e2e/opencode-autoconnect.sh index d1459a0f..13168084 100755 --- a/scripts/e2e/opencode-autoconnect.sh +++ b/scripts/e2e/opencode-autoconnect.sh @@ -23,6 +23,17 @@ SSH_PORT="$(( (RANDOM % 1000) + 20000 ))" export DOCKER_GIT_PROJECTS_ROOT="$ROOT" export DOCKER_GIT_STATE_AUTO_SYNC=0 +REPO_URL="https://github.com/octocat/Hello-World/issues/1" +TARGET_DIR="/home/dev/octocat/hello-world/issue-1" + +SSH_LOG_PATH="$ROOT/ssh.log" +SSH_WRAPPER_BIN="$ROOT/.e2e-bin" + +fail() { + echo "e2e/opencode-autoconnect: $*" >&2 + exit 1 +} + on_error() { local line="$1" echo "e2e/opencode-autoconnect: failed at line $line" >&2 @@ -66,6 +77,20 @@ trap cleanup EXIT mkdir -p "$ROOT/.orch/auth/codex" "$ROOT/.orch/env" : > "$ROOT/authorized_keys" +# Wrap `ssh` so CI doesn't hang in interactive mode; we only assert the invocation. +mkdir -p "$SSH_WRAPPER_BIN" +cat > "$SSH_WRAPPER_BIN/ssh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +: "${SSH_LOG_PATH:?SSH_LOG_PATH is required}" +printf "ssh %s\n" "$*" >> "$SSH_LOG_PATH" +exit 0 +EOF +chmod +x "$SSH_WRAPPER_BIN/ssh" +export PATH="$SSH_WRAPPER_BIN:$PATH" +export SSH_LOG_PATH + # Seed a fake (but structurally valid) Codex auth.json so the entrypoint can # auto-connect OpenCode without manual /connect. node <<'NODE' > "$ROOT/.orch/auth/codex/auth.json" @@ -101,14 +126,19 @@ OPENCODE_SHARE_AUTH=1 OPENCODE_AUTO_CONNECT=1 EOF_ENV -pnpm run docker-git clone https://github.com/octocat/Hello-World \ - --force \ - --repo-ref master \ - --ssh-port "$SSH_PORT" \ - --out-dir "$OUT_DIR_REL" \ - --container-name "$CONTAINER_NAME" \ - --service-name "$SERVICE_NAME" \ - --volume-name "$VOLUME_NAME" +# Auto-open SSH happens only in an interactive TTY; wrap with `script` to allocate a pseudo-TTY. +command -v script >/dev/null 2>&1 || fail "missing 'script' command (util-linux)" +: > "$SSH_LOG_PATH" +chmod 0666 "$SSH_LOG_PATH" || true +script -q -e -c "pnpm run docker-git clone \"$REPO_URL\" --force --ssh-port \"$SSH_PORT\" --out-dir \"$OUT_DIR_REL\" --container-name \"$CONTAINER_NAME\" --service-name \"$SERVICE_NAME\" --volume-name \"$VOLUME_NAME\"" /dev/null + +[[ -s "$SSH_LOG_PATH" ]] || fail "expected ssh to be invoked; log is empty: $SSH_LOG_PATH" +grep -q -- "dev@localhost" "$SSH_LOG_PATH" || fail "expected ssh args to include dev@localhost" +grep -q -- "-p $SSH_PORT" "$SSH_LOG_PATH" || fail "expected ssh args to include -p $SSH_PORT" + +docker exec -u dev "$CONTAINER_NAME" bash -lc "test -d '$TARGET_DIR/.git'" || fail "expected repo to be cloned at: $TARGET_DIR" +branch="$(docker exec -u dev "$CONTAINER_NAME" bash -lc "cd '$TARGET_DIR' && git rev-parse --abbrev-ref HEAD")" +[[ "$branch" == "issue-1" ]] || fail "expected HEAD branch issue-1, got: $branch" # Basic sanity checks. docker ps --format '{{.Names}}' | grep -qx "$CONTAINER_NAME"