Skip to content

Commit 9035463

Browse files
committed
feat(app): add open command for existing repo workspaces
1 parent 6295546 commit 9035463

File tree

10 files changed

+145
-41
lines changed

10 files changed

+145
-41
lines changed

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)

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,15 @@ describe("parseArgs", () => {
217217
expect(command.projectDir).toBe(".docker-git/org/repo/issue-7")
218218
}))
219219

220+
it.effect("parses open with GitHub issue url into issue workspace", () =>
221+
Effect.sync(() => {
222+
const command = parseOrThrow(["open", "https://github.com/org/repo/issues/7"])
223+
if (command._tag !== "Attach") {
224+
throw new Error("expected Attach command")
225+
}
226+
expect(command.projectDir).toBe(".docker-git/org/repo/issue-7")
227+
}))
228+
220229
it.effect("parses mcp-playwright command in current directory", () =>
221230
expectProjectDirRunUpCommand(["mcp-playwright"], "McpPlaywrightUp", ".", true))
222231

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
}

packages/lib/src/shell/clone.ts

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ import { CommandFailedError } from "./errors.js"
1010

1111
const successExitCode = Number(ExitCode(0))
1212

13-
// CHANGE: read clone request from process argv and npm lifecycle metadata
14-
// WHY: allow pnpm run clone <url> to work without "--"
15-
// QUOTE(ТЗ): "pnpm run clone <url>"
13+
// CHANGE: read shortcut requests from process argv and npm lifecycle metadata
14+
// WHY: allow pnpm run clone/open <url> to work without "--"
15+
// QUOTE(ТЗ): "Добавить команду open. ... Просто открывает существующий по ссылке"
1616
// REF: user-request-2026-01-27
1717
// SOURCE: n/a
1818
// FORMAT THEOREM: forall env: read(env) -> deterministic(request)
@@ -24,17 +24,8 @@ export const readCloneRequest: Effect.Effect<CloneRequest> = Effect.sync(() =>
2424
resolveCloneRequest(process.argv.slice(2), process.env["npm_lifecycle_event"])
2525
)
2626

27-
// CHANGE: run docker-git clone by building and invoking its CLI
28-
// WHY: reuse docker-git without mutating its codebase
29-
// QUOTE(ТЗ): "docker git мы никак не изменяем"
30-
// REF: user-request-2026-01-27
31-
// SOURCE: n/a
32-
// FORMAT THEOREM: forall args: build && run(args) -> docker_git_invoked(args)
33-
// PURITY: SHELL
34-
// EFFECT: Effect<void, CommandFailedError | PlatformError, CommandExecutor | Path>
35-
// INVARIANT: build runs before clone command
36-
// COMPLEXITY: O(build + clone)
37-
export const runDockerGitClone = (
27+
const runDockerGitCommand = (
28+
commandName: "clone" | "open",
3829
args: ReadonlyArray<string>
3930
): Effect.Effect<
4031
void,
@@ -47,7 +38,7 @@ export const runDockerGitClone = (
4738
const appRoot = path.join(workspaceRoot, "packages", "app")
4839
const dockerGitCli = path.join(appRoot, "dist", "src", "docker-git", "main.js")
4940
const buildLabel = `pnpm -C ${appRoot} build:docker-git`
50-
const cloneLabel = `node ${dockerGitCli} clone`
41+
const runLabel = `node ${dockerGitCli} ${commandName}`
5142

5243
yield* _(
5344
runCommandWithExitCodes(
@@ -58,9 +49,45 @@ export const runDockerGitClone = (
5849
)
5950
yield* _(
6051
runCommandWithExitCodes(
61-
{ cwd: workspaceRoot, command: "node", args: [dockerGitCli, "clone", ...args] },
52+
{ cwd: workspaceRoot, command: "node", args: [dockerGitCli, commandName, ...args] },
6253
[successExitCode],
63-
(exitCode) => new CommandFailedError({ command: cloneLabel, exitCode })
54+
(exitCode) => new CommandFailedError({ command: runLabel, exitCode })
6455
)
6556
)
6657
})
58+
59+
// CHANGE: run docker-git clone by building and invoking its CLI
60+
// WHY: reuse docker-git without mutating its codebase
61+
// QUOTE(ТЗ): "docker git мы никак не изменяем"
62+
// REF: user-request-2026-01-27
63+
// SOURCE: n/a
64+
// FORMAT THEOREM: forall args: build && run(args) -> docker_git_invoked(args)
65+
// PURITY: SHELL
66+
// EFFECT: Effect<void, CommandFailedError | PlatformError, CommandExecutor | Path>
67+
// INVARIANT: build runs before clone command
68+
// COMPLEXITY: O(build + clone)
69+
export const runDockerGitClone = (
70+
args: ReadonlyArray<string>
71+
): Effect.Effect<
72+
void,
73+
CommandFailedError | PlatformError,
74+
CommandExecutor.CommandExecutor | Path.Path
75+
> => runDockerGitCommand("clone", args)
76+
77+
// CHANGE: run docker-git open by building and invoking its CLI
78+
// WHY: mirror clone shortcut behavior for opening an existing repo workspace
79+
// QUOTE(ТЗ): "Добавить команду open. ... Просто открывает существующий по ссылке"
80+
// REF: user-request-2026-02-20-open-command
81+
// SOURCE: n/a
82+
// FORMAT THEOREM: forall args: build && run(args) -> docker_git_open_invoked(args)
83+
// PURITY: SHELL
84+
// EFFECT: Effect<void, CommandFailedError | PlatformError, CommandExecutor | Path>
85+
// INVARIANT: build runs before open command
86+
// COMPLEXITY: O(build + open)
87+
export const runDockerGitOpen = (
88+
args: ReadonlyArray<string>
89+
): Effect.Effect<
90+
void,
91+
CommandFailedError | PlatformError,
92+
CommandExecutor.CommandExecutor | Path.Path
93+
> => runDockerGitCommand("open", args)
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, expect, it } from "@effect/vitest"
2+
import { Effect } from "effect"
3+
4+
import { resolveCloneRequest } from "../../src/core/clone.js"
5+
6+
describe("resolveCloneRequest", () => {
7+
it.effect("parses clone from argv", () =>
8+
Effect.sync(() => {
9+
expect(resolveCloneRequest(["clone", "https://github.com/org/repo.git"], undefined)).toEqual({
10+
_tag: "Clone",
11+
args: ["https://github.com/org/repo.git"]
12+
})
13+
}))
14+
15+
it.effect("parses open from argv", () =>
16+
Effect.sync(() => {
17+
expect(resolveCloneRequest(["open", "https://github.com/org/repo/issues/7"], undefined)).toEqual({
18+
_tag: "Open",
19+
args: ["https://github.com/org/repo/issues/7"]
20+
})
21+
}))
22+
23+
it.effect("parses open from npm lifecycle", () =>
24+
Effect.sync(() => {
25+
expect(resolveCloneRequest(["open", "https://github.com/org/repo.git"], "open")).toEqual({
26+
_tag: "Open",
27+
args: ["https://github.com/org/repo.git"]
28+
})
29+
}))
30+
31+
it.effect("returns none for unrelated argv", () =>
32+
Effect.sync(() => {
33+
expect(resolveCloneRequest(["list"], undefined)).toEqual({
34+
_tag: "None"
35+
})
36+
}))
37+
})

0 commit comments

Comments
 (0)