Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ dev_ssh_key.pub

# Local docker-git work dirs
.docker-git/
.e2e/
effect-template1/

# Node / build artifacts
Expand Down
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,3 +373,12 @@ describe("Message invariants", () => {
Каждый эффект — это контролируемое взаимодействие с реальным миром.

ПРИНЦИП: Сначала формализуем, потом программируем.

<!-- docker-git:issue-managed:start -->
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.
<!-- docker-git:issue-managed:end -->
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
3 changes: 2 additions & 1 deletion packages/app/src/docker-git/cli/parser-clone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ export const parseClone = (args: ReadonlyArray<string>): Either.Either<Command,
const withRef = resolvedRepo.repoRef !== undefined && raw.repoRef === undefined
? { ...withDefaults, repoRef: resolvedRepo.repoRef }
: withDefaults
const openSsh = raw.openSsh ?? true
const create = yield* _(buildCreateCommand(withRef))
return { ...create, waitForClone: true }
return { ...create, waitForClone: true, openSsh }
})
}
2 changes: 2 additions & 0 deletions packages/app/src/docker-git/cli/parser-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ type ValueKey = ValueOptionSpec["key"]
const booleanFlagUpdaters: Readonly<Record<string, (raw: RawOptions) => 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 }),
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/docker-git/cli/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Options:
--lines <n> 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)
Expand Down
16 changes: 16 additions & 0 deletions packages/app/tests/docker-git/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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")
}))

Expand All @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions packages/docker-git/src/server/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1455,6 +1456,7 @@ data: ${JSON.stringify(data)}
config: nextTemplate,
outDir: project.directory,
runUp: false,
openSsh: false,
force: true,
forceEnv: false,
waitForClone: false
Expand Down
2 changes: 2 additions & 0 deletions packages/lib/src/core/command-builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -208,6 +209,7 @@ export const buildCreateCommand = (
_tag: "Create",
outDir: paths.outDir,
runUp,
openSsh,
force,
forceEnv,
waitForClone: false,
Expand Down
1 change: 1 addition & 0 deletions packages/lib/src/core/command-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
1 change: 1 addition & 0 deletions packages/lib/src/core/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface CreateCommand {
readonly force: boolean
readonly forceEnv: boolean
readonly waitForClone: boolean
readonly openSsh: boolean
}

export interface MenuCommand {
Expand Down
83 changes: 82 additions & 1 deletion packages/lib/src/usecases/actions/create-project.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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"
Expand Down Expand Up @@ -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<string> => {
const args: Array<string> = []
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<void, never, FileSystem | Path | CommandExecutor>
// INVARIANT: SSH failures do not fail the create/clone command
// COMPLEXITY: O(1) + ssh
const openSshBestEffort = (
template: CreateCommand["config"]
): Effect.Effect<void, never, CreateProjectRuntime> =>
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
Expand Down Expand Up @@ -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<void, CreateProjectError, CreateProjectRuntime> =>
Expand Down
Loading