diff --git a/README.md b/README.md index 5d0806e7..9ed4a275 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,9 @@ Disable sharing (per-project auth): Enable during create/clone: - Add `--mcp-playwright` +Enable for an existing project directory (preserves `.orch/env/project.env` and volumes): +- `docker-git mcp-playwright [] [--project-dir ]` + This will: - Create a Chromium sidecar container: `dg--browser` - Configure Codex MCP server `playwright` inside the dev container @@ -119,7 +122,8 @@ Common toggles: MCP errors in `codex` UI: - `No such file or directory (os error 2)` for `playwright`: - `~/.codex/config.toml` contains `[mcp_servers.playwright]`, but the container was created without `--mcp-playwright`. - - Fix: recreate with `--force --mcp-playwright` (or remove the block from `config.toml`). + - Fix (recommended): run `docker-git mcp-playwright []` to enable it for the existing project. + - Fix (recreate): recreate with `--force-env --mcp-playwright` (keeps volumes) or `--force --mcp-playwright` (wipes volumes). - `handshaking ... initialize response`: - The configured MCP command is not a real MCP server (example: `command="echo"`). diff --git a/packages/app/src/docker-git/cli/parser-mcp-playwright.ts b/packages/app/src/docker-git/cli/parser-mcp-playwright.ts new file mode 100644 index 00000000..2a4179c8 --- /dev/null +++ b/packages/app/src/docker-git/cli/parser-mcp-playwright.ts @@ -0,0 +1,25 @@ +import { Either } from "effect" + +import { type McpPlaywrightUpCommand, type ParseError } from "@effect-template/lib/core/domain" + +import { parseProjectDirWithOptions } from "./parser-shared.js" + +// CHANGE: parse "mcp-playwright" command for existing docker-git projects +// WHY: allow enabling Playwright MCP in an already created container/project dir +// QUOTE(ТЗ): "Добавить возможность поднимать MCP Playrgiht в контейнере который уже создан" +// REF: issue-29 +// SOURCE: n/a +// FORMAT THEOREM: forall argv: parseMcpPlaywright(argv) = cmd -> deterministic(cmd) +// PURITY: CORE +// EFFECT: Effect +// INVARIANT: projectDir is never empty +// COMPLEXITY: O(n) where n = |argv| +export const parseMcpPlaywright = ( + args: ReadonlyArray +): Either.Either => + Either.map(parseProjectDirWithOptions(args), ({ projectDir, raw }) => ({ + _tag: "McpPlaywrightUp", + projectDir, + runUp: raw.up ?? true + })) + diff --git a/packages/app/src/docker-git/cli/parser.ts b/packages/app/src/docker-git/cli/parser.ts index 150d3230..829f4da9 100644 --- a/packages/app/src/docker-git/cli/parser.ts +++ b/packages/app/src/docker-git/cli/parser.ts @@ -6,6 +6,7 @@ import { parseAttach } from "./parser-attach.js" import { parseAuth } from "./parser-auth.js" import { parseClone } from "./parser-clone.js" import { buildCreateCommand } from "./parser-create.js" +import { parseMcpPlaywright } from "./parser-mcp-playwright.js" import { parseRawOptions } from "./parser-options.js" import { parsePanes } from "./parser-panes.js" import { parseScrap } from "./parser-scrap.js" @@ -61,6 +62,7 @@ export const parseArgs = (args: ReadonlyArray): Either.Either parsePanes(rest)), Match.when("sessions", () => parseSessions(rest)), Match.when("scrap", () => parseScrap(rest)), + Match.when("mcp-playwright", () => parseMcpPlaywright(rest)), Match.when("help", () => Either.right(helpCommand)), Match.when("ps", () => Either.right(statusCommand)), Match.when("status", () => Either.right(statusCommand)), @@ -69,8 +71,10 @@ export const parseArgs = (args: ReadonlyArray): Either.Either Either.right(downAllCommand)), Match.when("menu", () => Either.right(menuCommand)), Match.when("ui", () => Either.right(menuCommand)), - Match.when("auth", () => parseAuth(rest)), - Match.when("state", () => parseState(rest)) + Match.when("auth", () => parseAuth(rest)) + ) + .pipe( + Match.when("state", () => parseState(rest)), + Match.orElse(() => Either.left(unknownCommandError)) ) - .pipe(Match.orElse(() => Either.left(unknownCommandError))) } diff --git a/packages/app/src/docker-git/cli/usage.ts b/packages/app/src/docker-git/cli/usage.ts index e50f7622..2b0f7334 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -5,6 +5,7 @@ import type { ParseError } from "@effect-template/lib/core/domain" export const usageText = `docker-git menu docker-git create --repo-url [options] docker-git clone [options] +docker-git mcp-playwright [] [options] docker-git attach [] [options] docker-git panes [] [options] docker-git scrap [] [options] @@ -20,6 +21,7 @@ Commands: menu Interactive menu (default when no args) create, init Generate docker development environment clone Create + run container and clone repo + mcp-playwright Enable Playwright MCP + Chromium sidecar for an existing project dir attach, tmux Open tmux workspace for a docker-git project panes, terms List tmux panes for a docker-git project scrap Export/import project scrap (session snapshot + rebuildable deps) diff --git a/packages/app/src/docker-git/program.ts b/packages/app/src/docker-git/program.ts index 83fbd1be..7057d6c4 100644 --- a/packages/app/src/docker-git/program.ts +++ b/packages/app/src/docker-git/program.ts @@ -10,6 +10,7 @@ import { } from "@effect-template/lib/usecases/auth" import type { AppError } from "@effect-template/lib/usecases/errors" import { renderError } from "@effect-template/lib/usecases/errors" +import { mcpPlaywrightUp } from "@effect-template/lib/usecases/mcp-playwright" import { downAllDockerGitProjects, listProjectStatus } from "@effect-template/lib/usecases/projects" import { exportScrap, importScrap } from "@effect-template/lib/usecases/scrap" import { @@ -92,7 +93,10 @@ const handleNonBaseCommand = (command: NonBaseCommand) => Match.when({ _tag: "ScrapExport" }, (cmd) => exportScrap(cmd)), Match.when({ _tag: "ScrapImport" }, (cmd) => importScrap(cmd)) ) - .pipe(Match.exhaustive) + .pipe( + Match.when({ _tag: "McpPlaywrightUp" }, (cmd) => mcpPlaywrightUp(cmd)), + Match.exhaustive + ) // CHANGE: compose CLI program with typed errors and shell effects // WHY: keep a thin entry layer over pure parsing and template generation diff --git a/packages/app/tests/docker-git/parser.test.ts b/packages/app/tests/docker-git/parser.test.ts index a7f8ff2e..117657fa 100644 --- a/packages/app/tests/docker-git/parser.test.ts +++ b/packages/app/tests/docker-git/parser.test.ts @@ -136,6 +136,34 @@ describe("parseArgs", () => { expect(command.projectDir).toBe(".docker-git/org/repo/issue-7") })) + it.effect("parses mcp-playwright command in current directory", () => + Effect.sync(() => { + const command = parseOrThrow(["mcp-playwright"]) + if (command._tag !== "McpPlaywrightUp") { + throw new Error("expected McpPlaywrightUp command") + } + expect(command.projectDir).toBe(".") + expect(command.runUp).toBe(true) + })) + + it.effect("parses mcp-playwright command with --no-up", () => + Effect.sync(() => { + const command = parseOrThrow(["mcp-playwright", "--no-up"]) + if (command._tag !== "McpPlaywrightUp") { + throw new Error("expected McpPlaywrightUp command") + } + expect(command.runUp).toBe(false) + })) + + it.effect("parses mcp-playwright with positional repo url into project dir", () => + Effect.sync(() => { + const command = parseOrThrow(["mcp-playwright", "https://github.com/org/repo.git"]) + if (command._tag !== "McpPlaywrightUp") { + throw new Error("expected McpPlaywrightUp command") + } + expect(command.projectDir).toBe(".docker-git/org/repo") + })) + it.effect("parses down-all command", () => Effect.sync(() => { const command = parseOrThrow(["down-all"]) diff --git a/packages/lib/src/core/domain.ts b/packages/lib/src/core/domain.ts index 4a03a382..ad710656 100644 --- a/packages/lib/src/core/domain.ts +++ b/packages/lib/src/core/domain.ts @@ -98,6 +98,12 @@ export interface ScrapImportCommand { readonly mode: ScrapMode } +export interface McpPlaywrightUpCommand { + readonly _tag: "McpPlaywrightUp" + readonly projectDir: string + readonly runUp: boolean +} + export interface HelpCommand { readonly _tag: "Help" readonly message: string @@ -213,6 +219,7 @@ export type Command = | PanesCommand | SessionsCommand | ScrapCommand + | McpPlaywrightUpCommand | HelpCommand | StatusCommand | DownAllCommand diff --git a/packages/lib/src/usecases/mcp-playwright.ts b/packages/lib/src/usecases/mcp-playwright.ts new file mode 100644 index 00000000..ea9df1fe --- /dev/null +++ b/packages/lib/src/usecases/mcp-playwright.ts @@ -0,0 +1,90 @@ +import type { CommandExecutor } from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" +import type { FileSystem } from "@effect/platform/FileSystem" +import type { Path } from "@effect/platform/Path" +import { Effect } from "effect" + +import type { McpPlaywrightUpCommand, TemplateConfig } from "../core/domain.js" +import { readProjectConfig } from "../shell/config.js" +import { ensureDockerDaemonAccess } from "../shell/docker.js" +import type { + ConfigDecodeError, + ConfigNotFoundError, + DockerAccessError, + DockerCommandError, + FileExistsError, + PortProbeError +} from "../shell/errors.js" +import { writeProjectFiles } from "../shell/files.js" +import { ensureCodexConfigFile } from "./auth-sync.js" +import { runDockerComposeUpWithPortCheck } from "./projects-up.js" + +type McpPlaywrightFilesError = ConfigNotFoundError | ConfigDecodeError | FileExistsError | PlatformError +type McpPlaywrightFilesEnv = FileSystem | Path + +const enableInTemplate = (template: TemplateConfig): TemplateConfig => ({ + ...template, + enableMcpPlaywright: true +}) + +// CHANGE: enable Playwright MCP in an existing docker-git project directory (files only) +// WHY: allow adding the browser sidecar + MCP server config without wiping env or volumes +// QUOTE(ТЗ): "Добавить возможность поднимать MCP Playrgiht в контейнере который уже создан" +// REF: issue-29 +// SOURCE: n/a +// FORMAT THEOREM: forall p: enable(p) -> template(p).enableMcpPlaywright = true +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: does not rewrite .orch/env/project.env (only managed templates + docker-git.json) +// COMPLEXITY: O(n) where n = |managed_files| +export const enableMcpPlaywrightProjectFiles = ( + projectDir: string +): Effect.Effect => + Effect.gen(function*(_) { + const config = yield* _(readProjectConfig(projectDir)) + const alreadyEnabled = config.template.enableMcpPlaywright + const updated = alreadyEnabled ? config.template : enableInTemplate(config.template) + + yield* _( + alreadyEnabled + ? Effect.log("Playwright MCP is already enabled for this project.") + : Effect.log("Enabling Playwright MCP for this project (templates only)...") + ) + + yield* _(writeProjectFiles(projectDir, updated, true)) + yield* _(ensureCodexConfigFile(projectDir, updated.codexAuthPath)) + + return updated + }) + +export type McpPlaywrightUpError = + | McpPlaywrightFilesError + | DockerAccessError + | DockerCommandError + | PortProbeError + +type McpPlaywrightUpEnv = McpPlaywrightFilesEnv | CommandExecutor + +// CHANGE: enable Playwright MCP in an existing project dir and bring docker compose up +// WHY: upgrade already created containers to support browser automation without forcing full recreation flows +// QUOTE(ТЗ): "Добавить возможность поднимать MCP Playrgiht в контейнере который уже создан" +// REF: issue-29 +// SOURCE: n/a +// FORMAT THEOREM: forall p: up(p) -> running(p-browser) OR docker_error +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: volumes are preserved (no docker compose down -v) +// COMPLEXITY: O(command) +export const mcpPlaywrightUp = ( + command: McpPlaywrightUpCommand +): Effect.Effect => + Effect.gen(function*(_) { + const updated = yield* _(enableMcpPlaywrightProjectFiles(command.projectDir)) + + if (!command.runUp) { + return updated + } + + yield* _(ensureDockerDaemonAccess(process.cwd())) + return yield* _(runDockerComposeUpWithPortCheck(command.projectDir)) + }) diff --git a/packages/lib/tests/usecases/mcp-playwright.test.ts b/packages/lib/tests/usecases/mcp-playwright.test.ts new file mode 100644 index 00000000..9a246e01 --- /dev/null +++ b/packages/lib/tests/usecases/mcp-playwright.test.ts @@ -0,0 +1,129 @@ +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 type { TemplateConfig } from "../../src/core/domain.js" +import { enableMcpPlaywrightProjectFiles } from "../../src/usecases/mcp-playwright.js" +import { prepareProjectFiles } from "../../src/usecases/actions/prepare-files.js" + +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-mcp-playwright-" + }) + ) + return yield* _(use(tempDir)) + }) + ) + +const makeGlobalConfig = (root: string, path: Path.Path): 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" +}) + +const makeProjectConfig = ( + outDir: string, + enableMcpPlaywright: boolean, + path: Path.Path +): 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(outDir, ".docker-git"), + authorizedKeysPath: path.join(outDir, "authorized_keys"), + envGlobalPath: path.join(outDir, ".orch/env/global.env"), + envProjectPath: path.join(outDir, ".orch/env/project.env"), + codexAuthPath: path.join(outDir, ".orch/auth/codex"), + codexSharedAuthPath: path.join(outDir, ".orch/auth/codex-shared"), + codexHome: "/home/dev/.codex", + enableMcpPlaywright, + pnpmVersion: "10.27.0" +}) + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null + +const readEnableMcpPlaywrightFlag = (value: unknown): boolean | undefined => { + if (!isRecord(value)) { + return undefined + } + + const template = value.template + if (!isRecord(template)) { + return undefined + } + + const flag = template.enableMcpPlaywright + return typeof flag === "boolean" ? flag : undefined +} + +describe("enableMcpPlaywrightProjectFiles", () => { + it.effect("enables Playwright MCP for an existing project without rewriting env files", () => + withTempDir((root) => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + const outDir = path.join(root, "project") + const globalConfig = makeGlobalConfig(root, path) + const withoutMcp = makeProjectConfig(outDir, false, path) + + yield* _( + prepareProjectFiles(outDir, root, globalConfig, withoutMcp, { + force: false, + forceEnv: false + }) + ) + + const envProjectPath = path.join(outDir, ".orch/env/project.env") + yield* _(fs.writeFileString(envProjectPath, "# custom env\nCUSTOM_KEY=1\n")) + + yield* _(enableMcpPlaywrightProjectFiles(outDir)) + + const envAfter = yield* _(fs.readFileString(envProjectPath)) + expect(envAfter).toContain("CUSTOM_KEY=1") + + const composeAfter = yield* _(fs.readFileString(path.join(outDir, "docker-compose.yml"))) + expect(composeAfter).toContain("dg-test-browser") + expect(composeAfter).toContain('MCP_PLAYWRIGHT_ENABLE: "1"') + + const dockerfileAfter = yield* _(fs.readFileString(path.join(outDir, "Dockerfile"))) + expect(dockerfileAfter).toContain("@playwright/mcp") + + const browserDockerfileExists = yield* _(fs.exists(path.join(outDir, "Dockerfile.browser"))) + const startExtraExists = yield* _(fs.exists(path.join(outDir, "mcp-playwright-start-extra.sh"))) + expect(browserDockerfileExists).toBe(true) + expect(startExtraExists).toBe(true) + + const configAfterText = yield* _(fs.readFileString(path.join(outDir, "docker-git.json"))) + const configAfter = yield* _(Effect.sync((): unknown => JSON.parse(configAfterText))) + expect(readEnableMcpPlaywrightFlag(configAfter)).toBe(true) + }) + ).pipe(Effect.provide(NodeContext.layer))) +}) diff --git a/scripts/npx b/scripts/npx index 0b479314..ebc16dbb 100755 --- a/scripts/npx +++ b/scripts/npx @@ -34,4 +34,3 @@ if [ "${1-}" = "" ]; then fi exec pnpm exec "$@" -