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
28 changes: 28 additions & 0 deletions packages/app/src/docker-git/cli/parser-apply.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Either } from "effect"

import { type ApplyCommand, type ParseError } from "@effect-template/lib/core/domain"

import { parseProjectDirWithOptions } from "./parser-shared.js"

// CHANGE: parse "apply" command for existing docker-git projects
// WHY: update managed docker-git config on the current project/container without creating a new project
// QUOTE(ТЗ): "Не создавать новый... а прямо в текущем обновить её на актуальную"
// REF: issue-72-followup-apply-current-config
// SOURCE: n/a
// FORMAT THEOREM: forall argv: parseApply(argv) = cmd -> deterministic(cmd)
// PURITY: CORE
// EFFECT: Effect<ApplyCommand, ParseError, never>
// INVARIANT: projectDir is never empty
// COMPLEXITY: O(n) where n = |argv|
export const parseApply = (
args: ReadonlyArray<string>
): Either.Either<ApplyCommand, ParseError> =>
Either.map(parseProjectDirWithOptions(args), ({ projectDir, raw }) => ({
_tag: "Apply",
projectDir,
runUp: raw.up ?? true,
gitTokenLabel: raw.gitTokenLabel,
codexTokenLabel: raw.codexTokenLabel,
claudeTokenLabel: raw.claudeTokenLabel,
enableMcpPlaywright: raw.enableMcpPlaywright
}))
97 changes: 62 additions & 35 deletions packages/app/src/docker-git/cli/parser-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ interface ValueOptionSpec {
| "scrapMode"
| "label"
| "gitTokenLabel"
| "codexTokenLabel"
| "claudeTokenLabel"
| "token"
| "scopes"
| "message"
Expand Down Expand Up @@ -53,6 +55,8 @@ const valueOptionSpecs: ReadonlyArray<ValueOptionSpec> = [
{ flag: "--mode", key: "scrapMode" },
{ flag: "--label", key: "label" },
{ flag: "--git-token", key: "gitTokenLabel" },
{ flag: "--codex-token", key: "codexTokenLabel" },
{ flag: "--claude-token", key: "claudeTokenLabel" },
{ flag: "--token", key: "token" },
{ flag: "--scopes", key: "scopes" },
{ flag: "--message", key: "message" },
Expand Down Expand Up @@ -102,6 +106,8 @@ const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: st
scrapMode: (raw, value) => ({ ...raw, scrapMode: value }),
label: (raw, value) => ({ ...raw, label: value }),
gitTokenLabel: (raw, value) => ({ ...raw, gitTokenLabel: value }),
codexTokenLabel: (raw, value) => ({ ...raw, codexTokenLabel: value }),
claudeTokenLabel: (raw, value) => ({ ...raw, claudeTokenLabel: value }),
token: (raw, value) => ({ ...raw, token: value }),
scopes: (raw, value) => ({ ...raw, scopes: value }),
message: (raw, value) => ({ ...raw, message: value }),
Expand Down Expand Up @@ -129,47 +135,68 @@ export const applyCommandValueFlag = (
return Either.right(update(raw, value))
}

export const parseRawOptions = (args: ReadonlyArray<string>): Either.Either<RawOptions, ParseError> => {
let index = 0
let raw: RawOptions = {}
type ParseRawOptionsStep =
| { readonly _tag: "ok"; readonly raw: RawOptions; readonly nextIndex: number }
| { readonly _tag: "error"; readonly error: ParseError }

while (index < args.length) {
const token = args[index] ?? ""
const equalIndex = token.indexOf("=")
if (equalIndex > 0 && token.startsWith("-")) {
const flag = token.slice(0, equalIndex)
const inlineValue = token.slice(equalIndex + 1)
const nextRaw = applyCommandValueFlag(raw, flag, inlineValue)
if (Either.isLeft(nextRaw)) {
return Either.left(nextRaw.left)
}
raw = nextRaw.right
index += 1
continue
}
const parseInlineValueToken = (
raw: RawOptions,
token: string
): Either.Either<RawOptions, ParseError> | null => {
const equalIndex = token.indexOf("=")
if (equalIndex <= 0 || !token.startsWith("-")) {
return null
}

const booleanApplied = applyCommandBooleanFlag(raw, token)
if (booleanApplied !== null) {
raw = booleanApplied
index += 1
continue
}
const flag = token.slice(0, equalIndex)
const inlineValue = token.slice(equalIndex + 1)
return applyCommandValueFlag(raw, flag, inlineValue)
}

if (!token.startsWith("-")) {
return Either.left({ _tag: "UnexpectedArgument", value: token })
}
const parseRawOptionsStep = (
args: ReadonlyArray<string>,
index: number,
raw: RawOptions
): ParseRawOptionsStep => {
const token = args[index] ?? ""
const inlineApplied = parseInlineValueToken(raw, token)
if (inlineApplied !== null) {
return Either.isLeft(inlineApplied)
? { _tag: "error", error: inlineApplied.left }
: { _tag: "ok", raw: inlineApplied.right, nextIndex: index + 1 }
}

const value = args[index + 1]
if (value === undefined) {
return Either.left({ _tag: "MissingOptionValue", option: token })
}
const booleanApplied = applyCommandBooleanFlag(raw, token)
if (booleanApplied !== null) {
return { _tag: "ok", raw: booleanApplied, nextIndex: index + 1 }
}

if (!token.startsWith("-")) {
return { _tag: "error", error: { _tag: "UnexpectedArgument", value: token } }
}

const value = args[index + 1]
if (value === undefined) {
return { _tag: "error", error: { _tag: "MissingOptionValue", option: token } }
}

const nextRaw = applyCommandValueFlag(raw, token, value)
return Either.isLeft(nextRaw)
? { _tag: "error", error: nextRaw.left }
: { _tag: "ok", raw: nextRaw.right, nextIndex: index + 2 }
}

export const parseRawOptions = (args: ReadonlyArray<string>): Either.Either<RawOptions, ParseError> => {
let index = 0
let raw: RawOptions = {}

const nextRaw = applyCommandValueFlag(raw, token, value)
if (Either.isLeft(nextRaw)) {
return Either.left(nextRaw.left)
while (index < args.length) {
const step = parseRawOptionsStep(args, index, raw)
if (step._tag === "error") {
return Either.left(step.error)
}
raw = nextRaw.right
index += 2
raw = step.raw
index = step.nextIndex
}

return Either.right(raw)
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/docker-git/cli/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Either, Match } from "effect"

import { type Command, type ParseError } from "@effect-template/lib/core/domain"

import { parseApply } from "./parser-apply.js"
import { parseAttach } from "./parser-attach.js"
import { parseAuth } from "./parser-auth.js"
import { parseClone } from "./parser-clone.js"
Expand Down Expand Up @@ -74,6 +75,7 @@ export const parseArgs = (args: ReadonlyArray<string>): Either.Either<Command, P
Match.when("auth", () => parseAuth(rest))
)
.pipe(
Match.when("apply", () => parseApply(rest)),
Match.when("state", () => parseState(rest)),
Match.orElse(() => Either.left(unknownCommandError))
)
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/docker-git/cli/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ParseError } from "@effect-template/lib/core/domain"
export const usageText = `docker-git menu
docker-git create --repo-url <url> [options]
docker-git clone <url> [options]
docker-git apply [<url>] [options]
docker-git mcp-playwright [<url>] [options]
docker-git attach [<url>] [options]
docker-git panes [<url>] [options]
Expand All @@ -21,6 +22,7 @@ Commands:
menu Interactive menu (default when no args)
create, init Generate docker development environment
clone Create + run container and clone repo
apply Apply docker-git config to an existing project/container (current dir by default)
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
Expand Down Expand Up @@ -50,6 +52,8 @@ Options:
--archive <path> Scrap snapshot directory (default: .orch/scrap/session)
--mode <session> Scrap mode (default: session)
--git-token <label> Token label for clone/create (maps to GITHUB_TOKEN__<LABEL>, example: agiens)
--codex-token <label> Codex auth label for clone/create (maps to CODEX_AUTH_LABEL, example: agien)
--claude-token <label> Claude auth label for clone/create (maps to CLAUDE_AUTH_LABEL, example: agien)
--wipe | --no-wipe Wipe workspace before scrap import (default: --wipe)
--lines <n> Tail last N lines for sessions logs (default: 200)
--include-default Show default/system processes in sessions list
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/docker-git/program.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Command, ParseError } from "@effect-template/lib/core/domain"
import { createProject } from "@effect-template/lib/usecases/actions"
import { applyProjectConfig } from "@effect-template/lib/usecases/apply"
import {
authClaudeLogin,
authClaudeLogout,
Expand Down Expand Up @@ -97,6 +98,7 @@ const handleNonBaseCommand = (command: NonBaseCommand) =>
Match.when({ _tag: "SessionsKill" }, (cmd) => killTerminalProcess(cmd))
)
.pipe(
Match.when({ _tag: "Apply" }, (cmd) => applyProjectConfig(cmd)),
Match.when({ _tag: "SessionsLogs" }, (cmd) => tailTerminalLogs(cmd)),
Match.when({ _tag: "ScrapExport" }, (cmd) => exportScrap(cmd)),
Match.when({ _tag: "ScrapImport" }, (cmd) => importScrap(cmd)),
Expand Down
4 changes: 4 additions & 0 deletions packages/app/tests/docker-git/entrypoint-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ describe("renderEntrypoint auth bridge", () => {
expect(entrypoint).toContain("docker_git_upsert_ssh_env \"GH_TOKEN\" \"$EFFECTIVE_GH_TOKEN\"")
expect(entrypoint).toContain("docker_git_upsert_ssh_env \"GIT_AUTH_TOKEN\" \"$EFFECTIVE_GITHUB_TOKEN\"")
expect(entrypoint).toContain("GIT_CREDENTIAL_HELPER_PATH=\"/usr/local/bin/docker-git-credential-helper\"")
expect(entrypoint).toContain("CLAUDE_REAL_BIN=\"/usr/local/bin/.docker-git-claude-real\"")
expect(entrypoint).toContain("CLAUDE_WRAPPER_BIN=\"/usr/local/bin/claude\"")
expect(entrypoint).toContain("cat <<'EOF' > \"$CLAUDE_WRAPPER_BIN\"")
expect(entrypoint).toContain("CLAUDE_CONFIG_DIR=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"")
expect(entrypoint).toContain("token=\"${GITHUB_TOKEN:-}\"")
expect(entrypoint).toContain("token=\"${GH_TOKEN:-}\"")
expect(entrypoint).toContain(String.raw`printf "%s\n" "password=$token"`)
Expand Down
82 changes: 69 additions & 13 deletions packages/app/tests/docker-git/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,26 @@ const parseOrThrow = (args: ReadonlyArray<string>): Command => {
})
}

type ProjectDirRunUpCommand = Extract<Command, { readonly projectDir: string; readonly runUp: boolean }>

const expectProjectDirRunUpCommand = (
args: ReadonlyArray<string>,
expectedTag: ProjectDirRunUpCommand["_tag"],
expectedProjectDir: string,
expectedRunUp: boolean
) =>
Effect.sync(() => {
const command = parseOrThrow(args)
if (command._tag !== expectedTag) {
throw new Error(`expected ${expectedTag} command`)
}
if (!("projectDir" in command) || !("runUp" in command)) {
throw new Error("expected command with projectDir and runUp")
}
expect(command.projectDir).toBe(expectedProjectDir)
expect(command.runUp).toBe(expectedRunUp)
})

const expectCreateCommand = (
args: ReadonlyArray<string>,
onRight: (command: CreateCommand) => void
Expand Down Expand Up @@ -106,6 +126,20 @@ describe("parseArgs", () => {
expect(command.config.gitTokenLabel).toBe("AGIENS")
}))

it.effect("parses clone codex/claude token labels from inline options and normalizes them", () =>
expectCreateCommand(
[
"clone",
"https://github.com/org/repo.git",
"--codex-token= Team A ",
"--claude-token=---AGIENS:::Claude---"
],
(command) => {
expect(command.config.codexAuthLabel).toBe("team-a")
expect(command.config.claudeAuthLabel).toBe("agiens-claude")
}
))

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)
Expand Down Expand Up @@ -169,31 +203,53 @@ describe("parseArgs", () => {
}))

it.effect("parses mcp-playwright command in current directory", () =>
expectProjectDirRunUpCommand(["mcp-playwright"], "McpPlaywrightUp", ".", true))

it.effect("parses mcp-playwright command with --no-up", () =>
expectProjectDirRunUpCommand(["mcp-playwright", "--no-up"], "McpPlaywrightUp", ".", false))

it.effect("parses mcp-playwright with positional repo url into project dir", () =>
Effect.sync(() => {
const command = parseOrThrow(["mcp-playwright"])
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(".")
expect(command.runUp).toBe(true)
expect(command.projectDir).toBe(".docker-git/org/repo")
}))

it.effect("parses mcp-playwright command with --no-up", () =>
it.effect("parses apply command in current directory", () =>
expectProjectDirRunUpCommand(["apply"], "Apply", ".", true))

it.effect("parses apply command with --no-up", () =>
expectProjectDirRunUpCommand(["apply", "--no-up"], "Apply", ".", false))

it.effect("parses apply with positional repo url into project dir", () =>
Effect.sync(() => {
const command = parseOrThrow(["mcp-playwright", "--no-up"])
if (command._tag !== "McpPlaywrightUp") {
throw new Error("expected McpPlaywrightUp command")
const command = parseOrThrow(["apply", "https://github.com/org/repo.git"])
if (command._tag !== "Apply") {
throw new Error("expected Apply command")
}
expect(command.runUp).toBe(false)
expect(command.projectDir).toBe(".docker-git/org/repo")
}))

it.effect("parses mcp-playwright with positional repo url into project dir", () =>
it.effect("parses apply token and mcp overrides", () =>
Effect.sync(() => {
const command = parseOrThrow(["mcp-playwright", "https://github.com/org/repo.git"])
if (command._tag !== "McpPlaywrightUp") {
throw new Error("expected McpPlaywrightUp command")
const command = parseOrThrow([
"apply",
"--git-token=agien_main",
"--codex-token=Team A",
"--claude-token=Team B",
"--mcp-playwright",
"--no-up"
])
if (command._tag !== "Apply") {
throw new Error("expected Apply command")
}
expect(command.projectDir).toBe(".docker-git/org/repo")
expect(command.runUp).toBe(false)
expect(command.gitTokenLabel).toBe("agien_main")
expect(command.codexTokenLabel).toBe("Team A")
expect(command.claudeTokenLabel).toBe("Team B")
expect(command.enableMcpPlaywright).toBe(true)
}))

it.effect("parses down-all command", () =>
Expand Down
5 changes: 5 additions & 0 deletions packages/docker-git/tests/core/templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ describe("planFiles", () => {
expect(entrypointSpec.contents).toContain("npm_config_store_dir")
expect(entrypointSpec.contents).toContain("NPM_CONFIG_CACHE")
expect(entrypointSpec.contents).toContain("YARN_CACHE_FOLDER")
expect(entrypointSpec.contents).toContain("CLAUDE_REAL_BIN=\"/usr/local/bin/.docker-git-claude-real\"")
expect(entrypointSpec.contents).toContain("CLAUDE_WRAPPER_BIN=\"/usr/local/bin/claude\"")
expect(entrypointSpec.contents).toContain("cat <<'EOF' > \"$CLAUDE_WRAPPER_BIN\"")
expect(entrypointSpec.contents).toContain('CLAUDE_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"')
expect(entrypointSpec.contents).toContain("unset CLAUDE_CODE_OAUTH_TOKEN || true")
expect(entrypointSpec.contents).toContain("CLONE_CACHE_ARGS=\"--reference-if-able '$CACHE_REPO_DIR' --dissociate\"")
expect(entrypointSpec.contents).toContain("[clone-cache] using mirror: $CACHE_REPO_DIR")
expect(entrypointSpec.contents).toContain("git clone --progress $CLONE_CACHE_ARGS")
Expand Down
Loading