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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ pnpm run docker-git clone https://github.com/agiens/crm/tree/vova-fork --force
# Clone an issue URL (creates isolated workspace + issue branch)
pnpm run docker-git clone https://github.com/agiens/crm/issues/123 --force

# Open an existing docker-git project by repo/issue URL (runs up + tmux attach)
pnpm run docker-git open https://github.com/agiens/crm/issues/123

# Reset only project env defaults (keep workspace volume/data)
pnpm run docker-git clone https://github.com/agiens/crm/issues/123 --force-env

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"changeset-publish": "node -e \"if (!process.env.NPM_TOKEN) { console.log('Skipping publish: NPM_TOKEN is not set'); process.exit(0); }\" && changeset publish",
"changeset-version": "changeset version",
"clone": "pnpm --filter ./packages/app build && node packages/app/dist/main.js clone",
"open": "pnpm --filter ./packages/app build && node packages/app/dist/main.js open",
"docker-git": "pnpm --filter ./packages/app build:docker-git && node packages/app/dist/src/docker-git/main.js",
"e2e": "bash scripts/e2e/run-all.sh",
"e2e:clone-cache": "bash scripts/e2e/clone-cache.sh",
Expand Down
1 change: 1 addition & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"build:docker-git": "vite build --config vite.docker-git.config.ts",
"check": "pnpm run typecheck",
"clone": "pnpm -C ../.. run clone",
"open": "pnpm -C ../.. run open",
"docker-git": "node dist/src/docker-git/main.js",
"list": "pnpm -C ../.. run list",
"prestart": "pnpm run build",
Expand Down
29 changes: 16 additions & 13 deletions packages/app/src/app/program.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { listProjects, readCloneRequest, runDockerGitClone } from "@effect-template/lib"
import { listProjects, readCloneRequest, runDockerGitClone, runDockerGitOpen } from "@effect-template/lib"
import { Console, Effect, Match, pipe } from "effect"

/**
* Compose the CLI program as a single effect.
*
* @returns Effect that either runs docker-git clone or prints usage.
* @returns Effect that either runs docker-git clone/open or prints usage.
*
* @pure false - uses Console output and spawns commands when cloning
* @pure false - uses Console output and spawns commands when running shortcuts
* @effect Console, CommandExecutor, Path
* @invariant forall args in Argv: clone(args) -> docker_git_invoked(args)
* @invariant forall args in Argv: shortcut(args) -> docker_git_invoked(args)
* @precondition true
* @postcondition clone(args) -> docker_git_invoked(args); otherwise usage printed
* @complexity O(build + clone)
* @postcondition shortcut(args) -> docker_git_invoked(args); otherwise usage printed
* @complexity O(build + shortcut)
* @throws Never - all errors are typed in the Effect error channel
*/
// CHANGE: replace greeting demo with deterministic usage text
Expand All @@ -28,32 +28,35 @@ const usageText = [
"Usage:",
" pnpm docker-git",
" pnpm clone <repo-url> [ref]",
" pnpm open <repo-url>",
" pnpm list",
"",
"Notes:",
" - docker-git is the interactive TUI.",
" - clone builds + runs docker-git clone for you."
" - clone builds + runs docker-git clone for you.",
" - open builds + runs docker-git open for existing projects."
].join("\n")

// PURITY: SHELL
// EFFECT: Effect<void, never, Console>
const runHelp = Console.log(usageText)

// CHANGE: route between clone runner and help based on CLI context
// WHY: allow pnpm run clone <url> while keeping a single entrypoint
// QUOTE(ТЗ): "pnpm run clone <url>"
// CHANGE: route between shortcut runners and help based on CLI context
// WHY: allow pnpm run clone/open <url> while keeping a single entrypoint
// QUOTE(ТЗ): "Добавить команду open."
// REF: user-request-2026-01-27
// SOURCE: n/a
// FORMAT THEOREM: forall argv: clone(argv) -> docker_git_invoked(argv)
// FORMAT THEOREM: forall argv: shortcut(argv) -> docker_git_invoked(argv)
// PURITY: SHELL
// EFFECT: Effect<void, Error, Console | CommandExecutor | Path>
// INVARIANT: help is printed when clone is not requested
// COMPLEXITY: O(build + clone)
// INVARIANT: help is printed when shortcut is not requested
// COMPLEXITY: O(build + shortcut)
const runDockerGit = pipe(
readCloneRequest,
Effect.flatMap((request) =>
Match.value(request).pipe(
Match.when({ _tag: "Clone" }, ({ args }) => runDockerGitClone(args)),
Match.when({ _tag: "Open" }, ({ args }) => runDockerGitOpen(args)),
Match.when({ _tag: "None" }, () => runHelp),
Match.exhaustive
)
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/docker-git/cli/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export const parseArgs = (args: ReadonlyArray<string>): Either.Either<Command, P
Match.when("auth", () => parseAuth(rest))
)
.pipe(
Match.when("open", () => parseAttach(rest)),
Match.when("apply", () => parseApply(rest)),
Match.when("state", () => parseState(rest)),
Match.orElse(() => Either.left(unknownCommandError))
Expand Down
6 changes: 4 additions & 2 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 open [<url>] [options]
docker-git apply [<url>] [options]
docker-git mcp-playwright [<url>] [options]
docker-git attach [<url>] [options]
Expand All @@ -22,9 +23,10 @@ Commands:
menu Interactive menu (default when no args)
create, init Generate docker development environment (repo URL optional)
clone Create + run container and clone repo
open Open existing docker-git project workspace
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
attach, tmux Alias for open
panes, terms List tmux panes for a docker-git project
scrap Export/import project scrap (session snapshot + rebuildable deps)
sessions List/kill/log container terminal processes
Expand All @@ -51,7 +53,7 @@ Options:
--network-mode <mode> Compose network mode: shared|project (default: shared)
--shared-network <name> Shared Docker network name when network-mode=shared (default: docker-git-shared)
--out-dir <path> Output directory (default: <projectsRoot>/<org>/<repo>[/issue-<id>|/pr-<id>])
--project-dir <path> Project directory for attach (default: .)
--project-dir <path> Project directory for open/attach (default: .)
--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)
Expand Down
76 changes: 76 additions & 0 deletions packages/app/tests/docker-git/parser-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { expect } from "@effect/vitest"
import { Effect, Either } from "effect"

import type { Command } from "@effect-template/lib/core/domain"
import { parseArgs } from "../../src/docker-git/cli/parser.js"

export type CreateCommand = Extract<Command, { _tag: "Create" }>
type ProjectDirRunUpCommand = Extract<Command, { readonly projectDir: string; readonly runUp: boolean }>

export const expectParseErrorTag = (
args: ReadonlyArray<string>,
expectedTag: string
) =>
Effect.sync(() => {
const parsed = parseArgs(args)
Either.match(parsed, {
onLeft: (error) => {
expect(error._tag).toBe(expectedTag)
},
onRight: () => {
throw new Error("expected parse error")
}
})
})

export const parseOrThrow = (args: ReadonlyArray<string>): Command => {
const parsed = parseArgs(args)
return Either.match(parsed, {
onLeft: (error) => {
throw new Error(`unexpected error ${error._tag}`)
},
onRight: (command) => command
})
}

export 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)
})

export const expectAttachProjectDirCommand = (
args: ReadonlyArray<string>,
expectedProjectDir: string
) =>
Effect.sync(() => {
const command = parseOrThrow(args)
if (command._tag !== "Attach") {
throw new Error("expected Attach command")
}
expect(command.projectDir).toBe(expectedProjectDir)
})

export const expectCreateCommand = (
args: ReadonlyArray<string>,
onRight: (command: CreateCommand) => void
) =>
Effect.sync(() => {
const command = parseOrThrow(args)
if (command._tag !== "Create") {
throw new Error("expected Create command")
}
onRight(command)
})
84 changes: 14 additions & 70 deletions packages/app/tests/docker-git/parser.test.ts
Original file line number Diff line number Diff line change
@@ -1,69 +1,16 @@
import { describe, expect, it } from "@effect/vitest"
import { Effect, Either } from "effect"
import { Effect } from "effect"

import { type Command, defaultTemplateConfig } from "@effect-template/lib/core/domain"
import { defaultTemplateConfig } from "@effect-template/lib/core/domain"
import { expandContainerHome } from "@effect-template/lib/usecases/scrap-path"
import { parseArgs } from "../../src/docker-git/cli/parser.js"

type CreateCommand = Extract<Command, { _tag: "Create" }>

const expectParseErrorTag = (
args: ReadonlyArray<string>,
expectedTag: string
) =>
Effect.sync(() => {
const parsed = parseArgs(args)
Either.match(parsed, {
onLeft: (error) => {
expect(error._tag).toBe(expectedTag)
},
onRight: () => {
throw new Error("expected parse error")
}
})
})

const parseOrThrow = (args: ReadonlyArray<string>): Command => {
const parsed = parseArgs(args)
return Either.match(parsed, {
onLeft: (error) => {
throw new Error(`unexpected error ${error._tag}`)
},
onRight: (command) => 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
) =>
Effect.sync(() => {
const command = parseOrThrow(args)
if (command._tag !== "Create") {
throw new Error("expected Create command")
}
onRight(command)
})
import {
type CreateCommand,
expectAttachProjectDirCommand,
expectCreateCommand,
expectParseErrorTag,
expectProjectDirRunUpCommand,
parseOrThrow
} from "./parser-helpers.js"

const expectCreateDefaults = (command: CreateCommand) => {
expect(command.config.repoUrl).toBe("https://github.com/org/repo.git")
Expand Down Expand Up @@ -209,13 +156,10 @@ describe("parseArgs", () => {
}))

it.effect("parses attach with GitHub issue url into issue workspace", () =>
Effect.sync(() => {
const command = parseOrThrow(["attach", "https://github.com/org/repo/issues/7"])
if (command._tag !== "Attach") {
throw new Error("expected Attach command")
}
expect(command.projectDir).toBe(".docker-git/org/repo/issue-7")
}))
expectAttachProjectDirCommand(["attach", "https://github.com/org/repo/issues/7"], ".docker-git/org/repo/issue-7"))

it.effect("parses open with GitHub issue url into issue workspace", () =>
expectAttachProjectDirCommand(["open", "https://github.com/org/repo/issues/7"], ".docker-git/org/repo/issue-7"))

it.effect("parses mcp-playwright command in current directory", () =>
expectProjectDirRunUpCommand(["mcp-playwright"], "McpPlaywrightUp", ".", true))
Expand Down
38 changes: 29 additions & 9 deletions packages/lib/src/core/clone.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export type CloneRequest =
| { readonly _tag: "Clone"; readonly args: ReadonlyArray<string> }
| { readonly _tag: "Open"; readonly args: ReadonlyArray<string> }
| { readonly _tag: "None" }

const emptyRequest: CloneRequest = { _tag: "None" }
Expand All @@ -9,32 +10,51 @@ const toCloneRequest = (args: ReadonlyArray<string>): CloneRequest => ({
args
})

// CHANGE: resolve a clone request from argv + npm lifecycle metadata
// WHY: support pnpm run clone <url> without requiring "--"
// QUOTE(ТЗ): "pnpm run clone <url>"
const toOpenRequest = (args: ReadonlyArray<string>): CloneRequest => ({
_tag: "Open",
args
})

const resolveLifecycleArgs = (
argv: ReadonlyArray<string>,
command: "clone" | "open"
): ReadonlyArray<string> => {
if (argv.length === 0) {
return []
}
const [first, ...rest] = argv
return first === command ? rest : argv
}

// CHANGE: resolve clone/open shortcut requests from argv + npm lifecycle metadata
// WHY: support pnpm run clone/open <url> without requiring "--"
// QUOTE(ТЗ): "Добавить команду open. ... Просто открывает существующий по ссылке"
// REF: user-request-2026-01-27
// SOURCE: n/a
// FORMAT THEOREM: forall a,e: resolve(a,e) -> deterministic
// PURITY: CORE
// EFFECT: Effect<CloneRequest, never, never>
// INVARIANT: clone requested only when argv[0] == "clone" or npmLifecycleEvent == "clone"
// INVARIANT: command requested only when argv[0] or npmLifecycleEvent is clone/open
// COMPLEXITY: O(n)
export const resolveCloneRequest = (
argv: ReadonlyArray<string>,
npmLifecycleEvent: string | undefined
): CloneRequest => {
if (npmLifecycleEvent === "clone") {
if (argv.length > 0) {
const [first, ...rest] = argv
return first === "clone" ? toCloneRequest(rest) : toCloneRequest(argv)
}
return toCloneRequest(resolveLifecycleArgs(argv, "clone"))
}

return toCloneRequest([])
if (npmLifecycleEvent === "open") {
return toOpenRequest(resolveLifecycleArgs(argv, "open"))
}

if (argv.length > 0 && argv[0] === "clone") {
return toCloneRequest(argv.slice(1))
}

if (argv.length > 0 && argv[0] === "open") {
return toOpenRequest(argv.slice(1))
}

return emptyRequest
}
Loading