Skip to content

Commit 725126a

Browse files
authored
feat(app): add open command for existing repo workspaces (#82)
* feat(app): add open command for existing repo workspaces * test(app): deduplicate open parser assertion block * test(app): extract parser helpers to satisfy max-lines
1 parent 6295546 commit 725126a

11 files changed

Lines changed: 226 additions & 111 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ pnpm run docker-git clone https://github.com/agiens/crm/tree/vova-fork --force
2727
# Clone an issue URL (creates isolated workspace + issue branch)
2828
pnpm run docker-git clone https://github.com/agiens/crm/issues/123 --force
2929

30+
# Open an existing docker-git project by repo/issue URL (runs up + tmux attach)
31+
pnpm run docker-git open https://github.com/agiens/crm/issues/123
32+
3033
# Reset only project env defaults (keep workspace volume/data)
3134
pnpm run docker-git clone https://github.com/agiens/crm/issues/123 --force-env
3235

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"changeset-publish": "node -e \"if (!process.env.NPM_TOKEN) { console.log('Skipping publish: NPM_TOKEN is not set'); process.exit(0); }\" && changeset publish",
1616
"changeset-version": "changeset version",
1717
"clone": "pnpm --filter ./packages/app build && node packages/app/dist/main.js clone",
18+
"open": "pnpm --filter ./packages/app build && node packages/app/dist/main.js open",
1819
"docker-git": "pnpm --filter ./packages/app build:docker-git && node packages/app/dist/src/docker-git/main.js",
1920
"e2e": "bash scripts/e2e/run-all.sh",
2021
"e2e:clone-cache": "bash scripts/e2e/clone-cache.sh",

packages/app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
"build:docker-git": "vite build --config vite.docker-git.config.ts",
2424
"check": "pnpm run typecheck",
2525
"clone": "pnpm -C ../.. run clone",
26+
"open": "pnpm -C ../.. run open",
2627
"docker-git": "node dist/src/docker-git/main.js",
2728
"list": "pnpm -C ../.. run list",
2829
"prestart": "pnpm run build",

packages/app/src/app/program.ts

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
1-
import { listProjects, readCloneRequest, runDockerGitClone } from "@effect-template/lib"
1+
import { listProjects, readCloneRequest, runDockerGitClone, runDockerGitOpen } from "@effect-template/lib"
22
import { Console, Effect, Match, pipe } from "effect"
33

44
/**
55
* Compose the CLI program as a single effect.
66
*
7-
* @returns Effect that either runs docker-git clone or prints usage.
7+
* @returns Effect that either runs docker-git clone/open or prints usage.
88
*
9-
* @pure false - uses Console output and spawns commands when cloning
9+
* @pure false - uses Console output and spawns commands when running shortcuts
1010
* @effect Console, CommandExecutor, Path
11-
* @invariant forall args in Argv: clone(args) -> docker_git_invoked(args)
11+
* @invariant forall args in Argv: shortcut(args) -> docker_git_invoked(args)
1212
* @precondition true
13-
* @postcondition clone(args) -> docker_git_invoked(args); otherwise usage printed
14-
* @complexity O(build + clone)
13+
* @postcondition shortcut(args) -> docker_git_invoked(args); otherwise usage printed
14+
* @complexity O(build + shortcut)
1515
* @throws Never - all errors are typed in the Effect error channel
1616
*/
1717
// CHANGE: replace greeting demo with deterministic usage text
@@ -28,32 +28,35 @@ const usageText = [
2828
"Usage:",
2929
" pnpm docker-git",
3030
" pnpm clone <repo-url> [ref]",
31+
" pnpm open <repo-url>",
3132
" pnpm list",
3233
"",
3334
"Notes:",
3435
" - docker-git is the interactive TUI.",
35-
" - clone builds + runs docker-git clone for you."
36+
" - clone builds + runs docker-git clone for you.",
37+
" - open builds + runs docker-git open for existing projects."
3638
].join("\n")
3739

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

42-
// CHANGE: route between clone runner and help based on CLI context
43-
// WHY: allow pnpm run clone <url> while keeping a single entrypoint
44-
// QUOTE(ТЗ): "pnpm run clone <url>"
44+
// CHANGE: route between shortcut runners and help based on CLI context
45+
// WHY: allow pnpm run clone/open <url> while keeping a single entrypoint
46+
// QUOTE(ТЗ): "Добавить команду open."
4547
// REF: user-request-2026-01-27
4648
// SOURCE: n/a
47-
// FORMAT THEOREM: forall argv: clone(argv) -> docker_git_invoked(argv)
49+
// FORMAT THEOREM: forall argv: shortcut(argv) -> docker_git_invoked(argv)
4850
// PURITY: SHELL
4951
// EFFECT: Effect<void, Error, Console | CommandExecutor | Path>
50-
// INVARIANT: help is printed when clone is not requested
51-
// COMPLEXITY: O(build + clone)
52+
// INVARIANT: help is printed when shortcut is not requested
53+
// COMPLEXITY: O(build + shortcut)
5254
const runDockerGit = pipe(
5355
readCloneRequest,
5456
Effect.flatMap((request) =>
5557
Match.value(request).pipe(
5658
Match.when({ _tag: "Clone" }, ({ args }) => runDockerGitClone(args)),
59+
Match.when({ _tag: "Open" }, ({ args }) => runDockerGitOpen(args)),
5760
Match.when({ _tag: "None" }, () => runHelp),
5861
Match.exhaustive
5962
)

packages/app/src/docker-git/cli/parser.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export const parseArgs = (args: ReadonlyArray<string>): Either.Either<Command, P
7575
Match.when("auth", () => parseAuth(rest))
7676
)
7777
.pipe(
78+
Match.when("open", () => parseAttach(rest)),
7879
Match.when("apply", () => parseApply(rest)),
7980
Match.when("state", () => parseState(rest)),
8081
Match.orElse(() => Either.left(unknownCommandError))

packages/app/src/docker-git/cli/usage.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { ParseError } from "@effect-template/lib/core/domain"
55
export const usageText = `docker-git menu
66
docker-git create [--repo-url <url>] [options]
77
docker-git clone <url> [options]
8+
docker-git open [<url>] [options]
89
docker-git apply [<url>] [options]
910
docker-git mcp-playwright [<url>] [options]
1011
docker-git attach [<url>] [options]
@@ -22,9 +23,10 @@ Commands:
2223
menu Interactive menu (default when no args)
2324
create, init Generate docker development environment (repo URL optional)
2425
clone Create + run container and clone repo
26+
open Open existing docker-git project workspace
2527
apply Apply docker-git config to an existing project/container (current dir by default)
2628
mcp-playwright Enable Playwright MCP + Chromium sidecar for an existing project dir
27-
attach, tmux Open tmux workspace for a docker-git project
29+
attach, tmux Alias for open
2830
panes, terms List tmux panes for a docker-git project
2931
scrap Export/import project scrap (session snapshot + rebuildable deps)
3032
sessions List/kill/log container terminal processes
@@ -51,7 +53,7 @@ Options:
5153
--network-mode <mode> Compose network mode: shared|project (default: shared)
5254
--shared-network <name> Shared Docker network name when network-mode=shared (default: docker-git-shared)
5355
--out-dir <path> Output directory (default: <projectsRoot>/<org>/<repo>[/issue-<id>|/pr-<id>])
54-
--project-dir <path> Project directory for attach (default: .)
56+
--project-dir <path> Project directory for open/attach (default: .)
5557
--archive <path> Scrap snapshot directory (default: .orch/scrap/session)
5658
--mode <session> Scrap mode (default: session)
5759
--git-token <label> Token label for clone/create (maps to GITHUB_TOKEN__<LABEL>, example: agiens)
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { expect } from "@effect/vitest"
2+
import { Effect, Either } from "effect"
3+
4+
import type { Command } from "@effect-template/lib/core/domain"
5+
import { parseArgs } from "../../src/docker-git/cli/parser.js"
6+
7+
export type CreateCommand = Extract<Command, { _tag: "Create" }>
8+
type ProjectDirRunUpCommand = Extract<Command, { readonly projectDir: string; readonly runUp: boolean }>
9+
10+
export const expectParseErrorTag = (
11+
args: ReadonlyArray<string>,
12+
expectedTag: string
13+
) =>
14+
Effect.sync(() => {
15+
const parsed = parseArgs(args)
16+
Either.match(parsed, {
17+
onLeft: (error) => {
18+
expect(error._tag).toBe(expectedTag)
19+
},
20+
onRight: () => {
21+
throw new Error("expected parse error")
22+
}
23+
})
24+
})
25+
26+
export const parseOrThrow = (args: ReadonlyArray<string>): Command => {
27+
const parsed = parseArgs(args)
28+
return Either.match(parsed, {
29+
onLeft: (error) => {
30+
throw new Error(`unexpected error ${error._tag}`)
31+
},
32+
onRight: (command) => command
33+
})
34+
}
35+
36+
export const expectProjectDirRunUpCommand = (
37+
args: ReadonlyArray<string>,
38+
expectedTag: ProjectDirRunUpCommand["_tag"],
39+
expectedProjectDir: string,
40+
expectedRunUp: boolean
41+
) =>
42+
Effect.sync(() => {
43+
const command = parseOrThrow(args)
44+
if (command._tag !== expectedTag) {
45+
throw new Error(`expected ${expectedTag} command`)
46+
}
47+
if (!("projectDir" in command) || !("runUp" in command)) {
48+
throw new Error("expected command with projectDir and runUp")
49+
}
50+
expect(command.projectDir).toBe(expectedProjectDir)
51+
expect(command.runUp).toBe(expectedRunUp)
52+
})
53+
54+
export const expectAttachProjectDirCommand = (
55+
args: ReadonlyArray<string>,
56+
expectedProjectDir: string
57+
) =>
58+
Effect.sync(() => {
59+
const command = parseOrThrow(args)
60+
if (command._tag !== "Attach") {
61+
throw new Error("expected Attach command")
62+
}
63+
expect(command.projectDir).toBe(expectedProjectDir)
64+
})
65+
66+
export const expectCreateCommand = (
67+
args: ReadonlyArray<string>,
68+
onRight: (command: CreateCommand) => void
69+
) =>
70+
Effect.sync(() => {
71+
const command = parseOrThrow(args)
72+
if (command._tag !== "Create") {
73+
throw new Error("expected Create command")
74+
}
75+
onRight(command)
76+
})

packages/app/tests/docker-git/parser.test.ts

Lines changed: 14 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,16 @@
11
import { describe, expect, it } from "@effect/vitest"
2-
import { Effect, Either } from "effect"
2+
import { Effect } from "effect"
33

4-
import { type Command, defaultTemplateConfig } from "@effect-template/lib/core/domain"
4+
import { defaultTemplateConfig } from "@effect-template/lib/core/domain"
55
import { expandContainerHome } from "@effect-template/lib/usecases/scrap-path"
6-
import { parseArgs } from "../../src/docker-git/cli/parser.js"
7-
8-
type CreateCommand = Extract<Command, { _tag: "Create" }>
9-
10-
const expectParseErrorTag = (
11-
args: ReadonlyArray<string>,
12-
expectedTag: string
13-
) =>
14-
Effect.sync(() => {
15-
const parsed = parseArgs(args)
16-
Either.match(parsed, {
17-
onLeft: (error) => {
18-
expect(error._tag).toBe(expectedTag)
19-
},
20-
onRight: () => {
21-
throw new Error("expected parse error")
22-
}
23-
})
24-
})
25-
26-
const parseOrThrow = (args: ReadonlyArray<string>): Command => {
27-
const parsed = parseArgs(args)
28-
return Either.match(parsed, {
29-
onLeft: (error) => {
30-
throw new Error(`unexpected error ${error._tag}`)
31-
},
32-
onRight: (command) => command
33-
})
34-
}
35-
36-
type ProjectDirRunUpCommand = Extract<Command, { readonly projectDir: string; readonly runUp: boolean }>
37-
38-
const expectProjectDirRunUpCommand = (
39-
args: ReadonlyArray<string>,
40-
expectedTag: ProjectDirRunUpCommand["_tag"],
41-
expectedProjectDir: string,
42-
expectedRunUp: boolean
43-
) =>
44-
Effect.sync(() => {
45-
const command = parseOrThrow(args)
46-
if (command._tag !== expectedTag) {
47-
throw new Error(`expected ${expectedTag} command`)
48-
}
49-
if (!("projectDir" in command) || !("runUp" in command)) {
50-
throw new Error("expected command with projectDir and runUp")
51-
}
52-
expect(command.projectDir).toBe(expectedProjectDir)
53-
expect(command.runUp).toBe(expectedRunUp)
54-
})
55-
56-
const expectCreateCommand = (
57-
args: ReadonlyArray<string>,
58-
onRight: (command: CreateCommand) => void
59-
) =>
60-
Effect.sync(() => {
61-
const command = parseOrThrow(args)
62-
if (command._tag !== "Create") {
63-
throw new Error("expected Create command")
64-
}
65-
onRight(command)
66-
})
6+
import {
7+
type CreateCommand,
8+
expectAttachProjectDirCommand,
9+
expectCreateCommand,
10+
expectParseErrorTag,
11+
expectProjectDirRunUpCommand,
12+
parseOrThrow
13+
} from "./parser-helpers.js"
6714

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

211158
it.effect("parses attach with GitHub issue url into issue workspace", () =>
212-
Effect.sync(() => {
213-
const command = parseOrThrow(["attach", "https://github.com/org/repo/issues/7"])
214-
if (command._tag !== "Attach") {
215-
throw new Error("expected Attach command")
216-
}
217-
expect(command.projectDir).toBe(".docker-git/org/repo/issue-7")
218-
}))
159+
expectAttachProjectDirCommand(["attach", "https://github.com/org/repo/issues/7"], ".docker-git/org/repo/issue-7"))
160+
161+
it.effect("parses open with GitHub issue url into issue workspace", () =>
162+
expectAttachProjectDirCommand(["open", "https://github.com/org/repo/issues/7"], ".docker-git/org/repo/issue-7"))
219163

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

packages/lib/src/core/clone.ts

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export type CloneRequest =
22
| { readonly _tag: "Clone"; readonly args: ReadonlyArray<string> }
3+
| { readonly _tag: "Open"; readonly args: ReadonlyArray<string> }
34
| { readonly _tag: "None" }
45

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

12-
// CHANGE: resolve a clone request from argv + npm lifecycle metadata
13-
// WHY: support pnpm run clone <url> without requiring "--"
14-
// QUOTE(ТЗ): "pnpm run clone <url>"
13+
const toOpenRequest = (args: ReadonlyArray<string>): CloneRequest => ({
14+
_tag: "Open",
15+
args
16+
})
17+
18+
const resolveLifecycleArgs = (
19+
argv: ReadonlyArray<string>,
20+
command: "clone" | "open"
21+
): ReadonlyArray<string> => {
22+
if (argv.length === 0) {
23+
return []
24+
}
25+
const [first, ...rest] = argv
26+
return first === command ? rest : argv
27+
}
28+
29+
// CHANGE: resolve clone/open shortcut requests from argv + npm lifecycle metadata
30+
// WHY: support pnpm run clone/open <url> without requiring "--"
31+
// QUOTE(ТЗ): "Добавить команду open. ... Просто открывает существующий по ссылке"
1532
// REF: user-request-2026-01-27
1633
// SOURCE: n/a
1734
// FORMAT THEOREM: forall a,e: resolve(a,e) -> deterministic
1835
// PURITY: CORE
1936
// EFFECT: Effect<CloneRequest, never, never>
20-
// INVARIANT: clone requested only when argv[0] == "clone" or npmLifecycleEvent == "clone"
37+
// INVARIANT: command requested only when argv[0] or npmLifecycleEvent is clone/open
2138
// COMPLEXITY: O(n)
2239
export const resolveCloneRequest = (
2340
argv: ReadonlyArray<string>,
2441
npmLifecycleEvent: string | undefined
2542
): CloneRequest => {
2643
if (npmLifecycleEvent === "clone") {
27-
if (argv.length > 0) {
28-
const [first, ...rest] = argv
29-
return first === "clone" ? toCloneRequest(rest) : toCloneRequest(argv)
30-
}
44+
return toCloneRequest(resolveLifecycleArgs(argv, "clone"))
45+
}
3146

32-
return toCloneRequest([])
47+
if (npmLifecycleEvent === "open") {
48+
return toOpenRequest(resolveLifecycleArgs(argv, "open"))
3349
}
3450

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

55+
if (argv.length > 0 && argv[0] === "open") {
56+
return toOpenRequest(argv.slice(1))
57+
}
58+
3959
return emptyRequest
4060
}

0 commit comments

Comments
 (0)