@prover-coder-ai/docker-git 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.jscpd.json +16 -0
- package/.package.json.release.bak +109 -0
- package/CHANGELOG.md +31 -0
- package/README.md +173 -0
- package/biome.json +34 -0
- package/dist/main.js +847 -0
- package/dist/main.js.map +1 -0
- package/dist/src/app/main.js +15 -0
- package/dist/src/app/program.js +61 -0
- package/dist/src/docker-git/cli/input.js +21 -0
- package/dist/src/docker-git/cli/parser-attach.js +19 -0
- package/dist/src/docker-git/cli/parser-auth.js +70 -0
- package/dist/src/docker-git/cli/parser-clone.js +40 -0
- package/dist/src/docker-git/cli/parser-create.js +1 -0
- package/dist/src/docker-git/cli/parser-options.js +101 -0
- package/dist/src/docker-git/cli/parser-panes.js +19 -0
- package/dist/src/docker-git/cli/parser-sessions.js +69 -0
- package/dist/src/docker-git/cli/parser-shared.js +26 -0
- package/dist/src/docker-git/cli/parser-state.js +62 -0
- package/dist/src/docker-git/cli/parser.js +42 -0
- package/dist/src/docker-git/cli/read-command.js +17 -0
- package/dist/src/docker-git/cli/usage.js +99 -0
- package/dist/src/docker-git/main.js +15 -0
- package/dist/src/docker-git/menu-actions.js +115 -0
- package/dist/src/docker-git/menu-create.js +203 -0
- package/dist/src/docker-git/menu-input.js +2 -0
- package/dist/src/docker-git/menu-menu.js +46 -0
- package/dist/src/docker-git/menu-render.js +151 -0
- package/dist/src/docker-git/menu-select.js +131 -0
- package/dist/src/docker-git/menu-shared.js +111 -0
- package/dist/src/docker-git/menu-types.js +19 -0
- package/dist/src/docker-git/menu.js +237 -0
- package/dist/src/docker-git/program.js +38 -0
- package/dist/src/docker-git/tmux.js +176 -0
- package/eslint.config.mts +305 -0
- package/eslint.effect-ts-check.config.mjs +220 -0
- package/linter.config.json +33 -0
- package/package.json +63 -0
- package/src/app/main.ts +18 -0
- package/src/app/program.ts +75 -0
- package/src/docker-git/cli/input.ts +29 -0
- package/src/docker-git/cli/parser-attach.ts +22 -0
- package/src/docker-git/cli/parser-auth.ts +124 -0
- package/src/docker-git/cli/parser-clone.ts +55 -0
- package/src/docker-git/cli/parser-create.ts +3 -0
- package/src/docker-git/cli/parser-options.ts +152 -0
- package/src/docker-git/cli/parser-panes.ts +22 -0
- package/src/docker-git/cli/parser-sessions.ts +101 -0
- package/src/docker-git/cli/parser-shared.ts +51 -0
- package/src/docker-git/cli/parser-state.ts +86 -0
- package/src/docker-git/cli/parser.ts +73 -0
- package/src/docker-git/cli/read-command.ts +26 -0
- package/src/docker-git/cli/usage.ts +112 -0
- package/src/docker-git/main.ts +18 -0
- package/src/docker-git/menu-actions.ts +246 -0
- package/src/docker-git/menu-create.ts +320 -0
- package/src/docker-git/menu-input.ts +2 -0
- package/src/docker-git/menu-menu.ts +58 -0
- package/src/docker-git/menu-render.ts +327 -0
- package/src/docker-git/menu-select.ts +250 -0
- package/src/docker-git/menu-shared.ts +141 -0
- package/src/docker-git/menu-types.ts +94 -0
- package/src/docker-git/menu.ts +339 -0
- package/src/docker-git/program.ts +134 -0
- package/src/docker-git/tmux.ts +292 -0
- package/tests/app/main.test.ts +60 -0
- package/tests/docker-git/entrypoint-auth.test.ts +29 -0
- package/tests/docker-git/parser.test.ts +172 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +20 -0
- package/vite.config.ts +32 -0
- package/vitest.config.ts +85 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { listProjects, readCloneRequest, runDockerGitClone } from "@effect-template/lib"
|
|
2
|
+
import { Console, Effect, Match, pipe } from "effect"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Compose the CLI program as a single effect.
|
|
6
|
+
*
|
|
7
|
+
* @returns Effect that either runs docker-git clone or prints usage.
|
|
8
|
+
*
|
|
9
|
+
* @pure false - uses Console output and spawns commands when cloning
|
|
10
|
+
* @effect Console, CommandExecutor, Path
|
|
11
|
+
* @invariant forall args in Argv: clone(args) -> docker_git_invoked(args)
|
|
12
|
+
* @precondition true
|
|
13
|
+
* @postcondition clone(args) -> docker_git_invoked(args); otherwise usage printed
|
|
14
|
+
* @complexity O(build + clone)
|
|
15
|
+
* @throws Never - all errors are typed in the Effect error channel
|
|
16
|
+
*/
|
|
17
|
+
// CHANGE: replace greeting demo with deterministic usage text
|
|
18
|
+
// WHY: greeting was scaffolding noise and should not ship in docker-git tooling
|
|
19
|
+
// QUOTE(ТЗ): "Можешь удалить использование greting ...? Это старый мусор который остался"
|
|
20
|
+
// REF: user-request-2026-02-06-remove-greeting
|
|
21
|
+
// SOURCE: n/a
|
|
22
|
+
// FORMAT THEOREM: usageText is constant -> deterministic(help)
|
|
23
|
+
// PURITY: CORE
|
|
24
|
+
// EFFECT: n/a
|
|
25
|
+
// INVARIANT: usageText does not depend on argv/env
|
|
26
|
+
// COMPLEXITY: O(1)
|
|
27
|
+
const usageText = [
|
|
28
|
+
"Usage:",
|
|
29
|
+
" pnpm docker-git",
|
|
30
|
+
" pnpm clone <repo-url> [ref]",
|
|
31
|
+
" pnpm list",
|
|
32
|
+
"",
|
|
33
|
+
"Notes:",
|
|
34
|
+
" - docker-git is the interactive TUI.",
|
|
35
|
+
" - clone builds + runs docker-git clone for you."
|
|
36
|
+
].join("\n")
|
|
37
|
+
|
|
38
|
+
// PURITY: SHELL
|
|
39
|
+
// EFFECT: Effect<void, never, Console>
|
|
40
|
+
const runHelp = Console.log(usageText)
|
|
41
|
+
|
|
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>"
|
|
45
|
+
// REF: user-request-2026-01-27
|
|
46
|
+
// SOURCE: n/a
|
|
47
|
+
// FORMAT THEOREM: forall argv: clone(argv) -> docker_git_invoked(argv)
|
|
48
|
+
// PURITY: SHELL
|
|
49
|
+
// EFFECT: Effect<void, Error, Console | CommandExecutor | Path>
|
|
50
|
+
// INVARIANT: help is printed when clone is not requested
|
|
51
|
+
// COMPLEXITY: O(build + clone)
|
|
52
|
+
const runDockerGit = pipe(
|
|
53
|
+
readCloneRequest,
|
|
54
|
+
Effect.flatMap((request) =>
|
|
55
|
+
Match.value(request).pipe(
|
|
56
|
+
Match.when({ _tag: "Clone" }, ({ args }) => runDockerGitClone(args)),
|
|
57
|
+
Match.when({ _tag: "None" }, () => runHelp),
|
|
58
|
+
Match.exhaustive
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const readListFlag = Effect.sync(() => {
|
|
64
|
+
const command = process.argv.slice(2)[0] ?? ""
|
|
65
|
+
return command === "list" || command === "ls"
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
export const program = Effect.gen(function*(_) {
|
|
69
|
+
const isList = yield* _(readListFlag)
|
|
70
|
+
if (isList) {
|
|
71
|
+
yield* _(listProjects)
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
yield* _(runDockerGit)
|
|
75
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import * as Terminal from "@effect/platform/Terminal"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
|
|
4
|
+
import { InputCancelledError, InputReadError } from "@effect-template/lib/shell/errors"
|
|
5
|
+
|
|
6
|
+
const normalizeMessage = (error: Error): string => error.message
|
|
7
|
+
|
|
8
|
+
const toReadError = (error: Error): InputReadError => new InputReadError({ message: normalizeMessage(error) })
|
|
9
|
+
|
|
10
|
+
const mapReadLineError = (_error: Terminal.QuitException): InputCancelledError => new InputCancelledError({})
|
|
11
|
+
|
|
12
|
+
// CHANGE: prompt for a single line of user input
|
|
13
|
+
// WHY: provide an interactive CLI without raw terminal mode issues
|
|
14
|
+
// QUOTE(ТЗ): "Хочу что бы открылось менюшка"
|
|
15
|
+
// REF: user-request-2026-01-07
|
|
16
|
+
// SOURCE: n/a
|
|
17
|
+
// FORMAT THEOREM: forall p: prompt(p) -> line(p)
|
|
18
|
+
// PURITY: SHELL
|
|
19
|
+
// EFFECT: Effect<string, InputCancelledError | InputReadError, never>
|
|
20
|
+
// INVARIANT: restores raw mode if it was enabled before prompting
|
|
21
|
+
// COMPLEXITY: O(1)
|
|
22
|
+
export const promptLine = (
|
|
23
|
+
prompt: string
|
|
24
|
+
): Effect.Effect<string, InputCancelledError | InputReadError, Terminal.Terminal> =>
|
|
25
|
+
Effect.gen(function*(_) {
|
|
26
|
+
const terminal = yield* _(Terminal.Terminal)
|
|
27
|
+
yield* _(terminal.display(prompt).pipe(Effect.mapError(toReadError)))
|
|
28
|
+
return yield* _(terminal.readLine.pipe(Effect.mapError(mapReadLineError)))
|
|
29
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Either } from "effect"
|
|
2
|
+
|
|
3
|
+
import { type AttachCommand, type ParseError } from "@effect-template/lib/core/domain"
|
|
4
|
+
|
|
5
|
+
import { parseProjectDirArgs } from "./parser-shared.js"
|
|
6
|
+
|
|
7
|
+
// CHANGE: parse attach command into a project selection
|
|
8
|
+
// WHY: allow "docker-git attach" to open a tmux workspace
|
|
9
|
+
// QUOTE(ТЗ): "окей Давай подключим tmux"
|
|
10
|
+
// REF: user-request-2026-02-02-tmux
|
|
11
|
+
// SOURCE: n/a
|
|
12
|
+
// FORMAT THEOREM: forall argv: parseAttach(argv) = cmd -> deterministic(cmd)
|
|
13
|
+
// PURITY: CORE
|
|
14
|
+
// EFFECT: Effect<AttachCommand, ParseError, never>
|
|
15
|
+
// INVARIANT: projectDir is never empty
|
|
16
|
+
// COMPLEXITY: O(n) where n = |argv|
|
|
17
|
+
export const parseAttach = (args: ReadonlyArray<string>): Either.Either<AttachCommand, ParseError> => {
|
|
18
|
+
return Either.map(parseProjectDirArgs(args), ({ projectDir }) => ({
|
|
19
|
+
_tag: "Attach",
|
|
20
|
+
projectDir
|
|
21
|
+
}))
|
|
22
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { Either, Match } from "effect"
|
|
2
|
+
|
|
3
|
+
import type { RawOptions } from "@effect-template/lib/core/command-options"
|
|
4
|
+
import {
|
|
5
|
+
type AuthCommand,
|
|
6
|
+
type Command,
|
|
7
|
+
defaultTemplateConfig,
|
|
8
|
+
type ParseError
|
|
9
|
+
} from "@effect-template/lib/core/domain"
|
|
10
|
+
|
|
11
|
+
import { parseRawOptions } from "./parser-options.js"
|
|
12
|
+
|
|
13
|
+
type AuthOptions = {
|
|
14
|
+
readonly envGlobalPath: string
|
|
15
|
+
readonly codexAuthPath: string
|
|
16
|
+
readonly label: string | null
|
|
17
|
+
readonly token: string | null
|
|
18
|
+
readonly scopes: string | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const missingArgument = (name: string): ParseError => ({
|
|
22
|
+
_tag: "MissingRequiredOption",
|
|
23
|
+
option: name
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const invalidArgument = (name: string, reason: string): ParseError => ({
|
|
27
|
+
_tag: "InvalidOption",
|
|
28
|
+
option: name,
|
|
29
|
+
reason
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const normalizeLabel = (value: string | undefined): string | null => {
|
|
33
|
+
const trimmed = value?.trim() ?? ""
|
|
34
|
+
return trimmed.length === 0 ? null : trimmed
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const resolveAuthOptions = (raw: RawOptions): AuthOptions => ({
|
|
38
|
+
envGlobalPath: raw.envGlobalPath ?? defaultTemplateConfig.envGlobalPath,
|
|
39
|
+
codexAuthPath: raw.codexAuthPath ?? defaultTemplateConfig.codexAuthPath,
|
|
40
|
+
label: normalizeLabel(raw.label),
|
|
41
|
+
token: normalizeLabel(raw.token),
|
|
42
|
+
scopes: normalizeLabel(raw.scopes)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const buildGithubCommand = (action: string, options: AuthOptions): Either.Either<AuthCommand, ParseError> =>
|
|
46
|
+
Match.value(action).pipe(
|
|
47
|
+
Match.when("login", () =>
|
|
48
|
+
Either.right<AuthCommand>({
|
|
49
|
+
_tag: "AuthGithubLogin",
|
|
50
|
+
label: options.label,
|
|
51
|
+
token: options.token,
|
|
52
|
+
scopes: options.scopes,
|
|
53
|
+
envGlobalPath: options.envGlobalPath
|
|
54
|
+
})),
|
|
55
|
+
Match.when("status", () =>
|
|
56
|
+
Either.right<AuthCommand>({
|
|
57
|
+
_tag: "AuthGithubStatus",
|
|
58
|
+
envGlobalPath: options.envGlobalPath
|
|
59
|
+
})),
|
|
60
|
+
Match.when("logout", () =>
|
|
61
|
+
Either.right<AuthCommand>({
|
|
62
|
+
_tag: "AuthGithubLogout",
|
|
63
|
+
label: options.label,
|
|
64
|
+
envGlobalPath: options.envGlobalPath
|
|
65
|
+
})),
|
|
66
|
+
Match.orElse(() => Either.left(invalidArgument("auth action", `unknown action '${action}'`)))
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
const buildCodexCommand = (action: string, options: AuthOptions): Either.Either<AuthCommand, ParseError> =>
|
|
70
|
+
Match.value(action).pipe(
|
|
71
|
+
Match.when("login", () =>
|
|
72
|
+
Either.right<AuthCommand>({
|
|
73
|
+
_tag: "AuthCodexLogin",
|
|
74
|
+
label: options.label,
|
|
75
|
+
codexAuthPath: options.codexAuthPath
|
|
76
|
+
})),
|
|
77
|
+
Match.when("status", () =>
|
|
78
|
+
Either.right<AuthCommand>({
|
|
79
|
+
_tag: "AuthCodexStatus",
|
|
80
|
+
label: options.label,
|
|
81
|
+
codexAuthPath: options.codexAuthPath
|
|
82
|
+
})),
|
|
83
|
+
Match.when("logout", () =>
|
|
84
|
+
Either.right<AuthCommand>({
|
|
85
|
+
_tag: "AuthCodexLogout",
|
|
86
|
+
label: options.label,
|
|
87
|
+
codexAuthPath: options.codexAuthPath
|
|
88
|
+
})),
|
|
89
|
+
Match.orElse(() => Either.left(invalidArgument("auth action", `unknown action '${action}'`)))
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
const buildAuthCommand = (
|
|
93
|
+
provider: string,
|
|
94
|
+
action: string,
|
|
95
|
+
options: AuthOptions
|
|
96
|
+
): Either.Either<AuthCommand, ParseError> =>
|
|
97
|
+
Match.value(provider).pipe(
|
|
98
|
+
Match.when("github", () => buildGithubCommand(action, options)),
|
|
99
|
+
Match.when("gh", () => buildGithubCommand(action, options)),
|
|
100
|
+
Match.when("codex", () => buildCodexCommand(action, options)),
|
|
101
|
+
Match.orElse(() => Either.left(invalidArgument("auth provider", `unknown provider '${provider}'`)))
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
// CHANGE: parse docker-git auth subcommands
|
|
105
|
+
// WHY: keep auth flows in the same typed CLI parser
|
|
106
|
+
// QUOTE(ТЗ): "система авторизации"
|
|
107
|
+
// REF: user-request-2026-01-28-auth
|
|
108
|
+
// SOURCE: n/a
|
|
109
|
+
// FORMAT THEOREM: forall argv: parseAuth(argv) = cmd | error
|
|
110
|
+
// PURITY: CORE
|
|
111
|
+
// EFFECT: Effect<Command, ParseError, never>
|
|
112
|
+
// INVARIANT: no IO or side effects
|
|
113
|
+
// COMPLEXITY: O(n) where n = |argv|
|
|
114
|
+
export const parseAuth = (args: ReadonlyArray<string>): Either.Either<Command, ParseError> => {
|
|
115
|
+
if (args.length < 2) {
|
|
116
|
+
return Either.left(missingArgument(args.length === 0 ? "auth provider" : "auth action"))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const provider = args[0] ?? ""
|
|
120
|
+
const action = args[1] ?? ""
|
|
121
|
+
const rest = args.slice(2)
|
|
122
|
+
|
|
123
|
+
return Either.flatMap(parseRawOptions(rest), (raw) => buildAuthCommand(provider, action, resolveAuthOptions(raw)))
|
|
124
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Either } from "effect"
|
|
2
|
+
|
|
3
|
+
import { buildCreateCommand, nonEmpty } from "@effect-template/lib/core/command-builders"
|
|
4
|
+
import type { RawOptions } from "@effect-template/lib/core/command-options"
|
|
5
|
+
import {
|
|
6
|
+
type Command,
|
|
7
|
+
defaultTemplateConfig,
|
|
8
|
+
type ParseError,
|
|
9
|
+
resolveRepoInput
|
|
10
|
+
} from "@effect-template/lib/core/domain"
|
|
11
|
+
|
|
12
|
+
import { parseRawOptions } from "./parser-options.js"
|
|
13
|
+
import { resolveWorkspaceRepoPath, splitPositionalRepo } from "./parser-shared.js"
|
|
14
|
+
|
|
15
|
+
const applyCloneDefaults = (
|
|
16
|
+
raw: RawOptions,
|
|
17
|
+
rawRepoUrl: string,
|
|
18
|
+
resolvedRepo: ReturnType<typeof resolveRepoInput>
|
|
19
|
+
): RawOptions => {
|
|
20
|
+
const repoPath = resolveWorkspaceRepoPath(resolvedRepo)
|
|
21
|
+
const sshUser = raw.sshUser?.trim() ?? defaultTemplateConfig.sshUser
|
|
22
|
+
const homeDir = `/home/${sshUser}`
|
|
23
|
+
return {
|
|
24
|
+
...raw,
|
|
25
|
+
repoUrl: rawRepoUrl,
|
|
26
|
+
outDir: raw.outDir ?? `.docker-git/${repoPath}`,
|
|
27
|
+
targetDir: raw.targetDir ?? `${homeDir}/${repoPath}`
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// CHANGE: parse clone command with positional repo url
|
|
32
|
+
// WHY: allow "docker-git clone <url>" to build + run a container
|
|
33
|
+
// QUOTE(ТЗ): "docker-git clone url"
|
|
34
|
+
// REF: user-request-2026-01-27
|
|
35
|
+
// SOURCE: n/a
|
|
36
|
+
// FORMAT THEOREM: forall argv: parseClone(argv) = cmd -> deterministic(cmd)
|
|
37
|
+
// PURITY: CORE
|
|
38
|
+
// EFFECT: Effect<Command, ParseError, never>
|
|
39
|
+
// INVARIANT: first positional arg is treated as repo url
|
|
40
|
+
// COMPLEXITY: O(n) where n = |argv|
|
|
41
|
+
export const parseClone = (args: ReadonlyArray<string>): Either.Either<Command, ParseError> => {
|
|
42
|
+
const { positionalRepoUrl, restArgs } = splitPositionalRepo(args)
|
|
43
|
+
|
|
44
|
+
return Either.gen(function*(_) {
|
|
45
|
+
const raw = yield* _(parseRawOptions(restArgs))
|
|
46
|
+
const rawRepoUrl = yield* _(nonEmpty("--repo-url", raw.repoUrl ?? positionalRepoUrl))
|
|
47
|
+
const resolvedRepo = resolveRepoInput(rawRepoUrl)
|
|
48
|
+
const withDefaults = applyCloneDefaults(raw, rawRepoUrl, resolvedRepo)
|
|
49
|
+
const withRef = resolvedRepo.repoRef !== undefined && raw.repoRef === undefined
|
|
50
|
+
? { ...withDefaults, repoRef: resolvedRepo.repoRef }
|
|
51
|
+
: withDefaults
|
|
52
|
+
const create = yield* _(buildCreateCommand(withRef))
|
|
53
|
+
return { ...create, waitForClone: true }
|
|
54
|
+
})
|
|
55
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { Either } from "effect"
|
|
2
|
+
|
|
3
|
+
import type { RawOptions } from "@effect-template/lib/core/command-options"
|
|
4
|
+
import type { ParseError } from "@effect-template/lib/core/domain"
|
|
5
|
+
|
|
6
|
+
interface ValueOptionSpec {
|
|
7
|
+
readonly flag: string
|
|
8
|
+
readonly key:
|
|
9
|
+
| "repoUrl"
|
|
10
|
+
| "repoRef"
|
|
11
|
+
| "targetDir"
|
|
12
|
+
| "sshPort"
|
|
13
|
+
| "sshUser"
|
|
14
|
+
| "containerName"
|
|
15
|
+
| "serviceName"
|
|
16
|
+
| "volumeName"
|
|
17
|
+
| "secretsRoot"
|
|
18
|
+
| "authorizedKeysPath"
|
|
19
|
+
| "envGlobalPath"
|
|
20
|
+
| "envProjectPath"
|
|
21
|
+
| "codexAuthPath"
|
|
22
|
+
| "codexHome"
|
|
23
|
+
| "label"
|
|
24
|
+
| "token"
|
|
25
|
+
| "scopes"
|
|
26
|
+
| "message"
|
|
27
|
+
| "outDir"
|
|
28
|
+
| "projectDir"
|
|
29
|
+
| "lines"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const valueOptionSpecs: ReadonlyArray<ValueOptionSpec> = [
|
|
33
|
+
{ flag: "--repo-url", key: "repoUrl" },
|
|
34
|
+
{ flag: "--repo-ref", key: "repoRef" },
|
|
35
|
+
{ flag: "--branch", key: "repoRef" },
|
|
36
|
+
{ flag: "-b", key: "repoRef" },
|
|
37
|
+
{ flag: "--target-dir", key: "targetDir" },
|
|
38
|
+
{ flag: "--ssh-port", key: "sshPort" },
|
|
39
|
+
{ flag: "--ssh-user", key: "sshUser" },
|
|
40
|
+
{ flag: "--container-name", key: "containerName" },
|
|
41
|
+
{ flag: "--service-name", key: "serviceName" },
|
|
42
|
+
{ flag: "--volume-name", key: "volumeName" },
|
|
43
|
+
{ flag: "--secrets-root", key: "secretsRoot" },
|
|
44
|
+
{ flag: "--authorized-keys", key: "authorizedKeysPath" },
|
|
45
|
+
{ flag: "--env-global", key: "envGlobalPath" },
|
|
46
|
+
{ flag: "--env-project", key: "envProjectPath" },
|
|
47
|
+
{ flag: "--codex-auth", key: "codexAuthPath" },
|
|
48
|
+
{ flag: "--codex-home", key: "codexHome" },
|
|
49
|
+
{ flag: "--label", key: "label" },
|
|
50
|
+
{ flag: "--token", key: "token" },
|
|
51
|
+
{ flag: "--scopes", key: "scopes" },
|
|
52
|
+
{ flag: "--message", key: "message" },
|
|
53
|
+
{ flag: "-m", key: "message" },
|
|
54
|
+
{ flag: "--out-dir", key: "outDir" },
|
|
55
|
+
{ flag: "--project-dir", key: "projectDir" },
|
|
56
|
+
{ flag: "--lines", key: "lines" }
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
const valueOptionSpecByFlag: ReadonlyMap<string, ValueOptionSpec> = new Map(
|
|
60
|
+
valueOptionSpecs.map((spec) => [spec.flag, spec])
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
type ValueKey = ValueOptionSpec["key"]
|
|
64
|
+
|
|
65
|
+
const booleanFlagUpdaters: Readonly<Record<string, (raw: RawOptions) => RawOptions>> = {
|
|
66
|
+
"--up": (raw) => ({ ...raw, up: true }),
|
|
67
|
+
"--no-up": (raw) => ({ ...raw, up: false }),
|
|
68
|
+
"--force": (raw) => ({ ...raw, force: true }),
|
|
69
|
+
"--force-env": (raw) => ({ ...raw, forceEnv: true }),
|
|
70
|
+
"--mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: true }),
|
|
71
|
+
"--no-mcp-playwright": (raw) => ({ ...raw, enableMcpPlaywright: false }),
|
|
72
|
+
"--web": (raw) => ({ ...raw, authWeb: true }),
|
|
73
|
+
"--include-default": (raw) => ({ ...raw, includeDefault: true })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: string) => RawOptions } = {
|
|
77
|
+
repoUrl: (raw, value) => ({ ...raw, repoUrl: value }),
|
|
78
|
+
repoRef: (raw, value) => ({ ...raw, repoRef: value }),
|
|
79
|
+
targetDir: (raw, value) => ({ ...raw, targetDir: value }),
|
|
80
|
+
sshPort: (raw, value) => ({ ...raw, sshPort: value }),
|
|
81
|
+
sshUser: (raw, value) => ({ ...raw, sshUser: value }),
|
|
82
|
+
containerName: (raw, value) => ({ ...raw, containerName: value }),
|
|
83
|
+
serviceName: (raw, value) => ({ ...raw, serviceName: value }),
|
|
84
|
+
volumeName: (raw, value) => ({ ...raw, volumeName: value }),
|
|
85
|
+
secretsRoot: (raw, value) => ({ ...raw, secretsRoot: value }),
|
|
86
|
+
authorizedKeysPath: (raw, value) => ({ ...raw, authorizedKeysPath: value }),
|
|
87
|
+
envGlobalPath: (raw, value) => ({ ...raw, envGlobalPath: value }),
|
|
88
|
+
envProjectPath: (raw, value) => ({ ...raw, envProjectPath: value }),
|
|
89
|
+
codexAuthPath: (raw, value) => ({ ...raw, codexAuthPath: value }),
|
|
90
|
+
codexHome: (raw, value) => ({ ...raw, codexHome: value }),
|
|
91
|
+
label: (raw, value) => ({ ...raw, label: value }),
|
|
92
|
+
token: (raw, value) => ({ ...raw, token: value }),
|
|
93
|
+
scopes: (raw, value) => ({ ...raw, scopes: value }),
|
|
94
|
+
message: (raw, value) => ({ ...raw, message: value }),
|
|
95
|
+
outDir: (raw, value) => ({ ...raw, outDir: value }),
|
|
96
|
+
projectDir: (raw, value) => ({ ...raw, projectDir: value }),
|
|
97
|
+
lines: (raw, value) => ({ ...raw, lines: value })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const applyCommandBooleanFlag = (raw: RawOptions, token: string): RawOptions | null => {
|
|
101
|
+
const updater = booleanFlagUpdaters[token]
|
|
102
|
+
return updater ? updater(raw) : null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const applyCommandValueFlag = (
|
|
106
|
+
raw: RawOptions,
|
|
107
|
+
token: string,
|
|
108
|
+
value: string
|
|
109
|
+
): Either.Either<RawOptions, ParseError> => {
|
|
110
|
+
const valueSpec = valueOptionSpecByFlag.get(token)
|
|
111
|
+
if (valueSpec === undefined) {
|
|
112
|
+
return Either.left({ _tag: "UnknownOption", option: token })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const update = valueFlagUpdaters[valueSpec.key]
|
|
116
|
+
return Either.right(update(raw, value))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const parseRawOptions = (args: ReadonlyArray<string>): Either.Either<RawOptions, ParseError> => {
|
|
120
|
+
let index = 0
|
|
121
|
+
let raw: RawOptions = {}
|
|
122
|
+
|
|
123
|
+
while (index < args.length) {
|
|
124
|
+
const token = args[index] ?? ""
|
|
125
|
+
const booleanApplied = applyCommandBooleanFlag(raw, token)
|
|
126
|
+
if (booleanApplied !== null) {
|
|
127
|
+
raw = booleanApplied
|
|
128
|
+
index += 1
|
|
129
|
+
continue
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!token.startsWith("-")) {
|
|
133
|
+
return Either.left({ _tag: "UnexpectedArgument", value: token })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const value = args[index + 1]
|
|
137
|
+
if (value === undefined) {
|
|
138
|
+
return Either.left({ _tag: "MissingOptionValue", option: token })
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const nextRaw = applyCommandValueFlag(raw, token, value)
|
|
142
|
+
if (Either.isLeft(nextRaw)) {
|
|
143
|
+
return Either.left(nextRaw.left)
|
|
144
|
+
}
|
|
145
|
+
raw = nextRaw.right
|
|
146
|
+
index += 2
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return Either.right(raw)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export { type RawOptions } from "@effect-template/lib/core/command-options"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Either } from "effect"
|
|
2
|
+
|
|
3
|
+
import { type PanesCommand, type ParseError } from "@effect-template/lib/core/domain"
|
|
4
|
+
|
|
5
|
+
import { parseProjectDirArgs } from "./parser-shared.js"
|
|
6
|
+
|
|
7
|
+
// CHANGE: parse panes command into a project selection
|
|
8
|
+
// WHY: allow listing tmux panes without attaching
|
|
9
|
+
// QUOTE(ТЗ): "покажи команду ... отобразит терминалы"
|
|
10
|
+
// REF: user-request-2026-02-02-panes
|
|
11
|
+
// SOURCE: n/a
|
|
12
|
+
// FORMAT THEOREM: forall argv: parsePanes(argv) = cmd -> deterministic(cmd)
|
|
13
|
+
// PURITY: CORE
|
|
14
|
+
// EFFECT: Effect<PanesCommand, ParseError, never>
|
|
15
|
+
// INVARIANT: projectDir is never empty
|
|
16
|
+
// COMPLEXITY: O(n) where n = |argv|
|
|
17
|
+
export const parsePanes = (args: ReadonlyArray<string>): Either.Either<PanesCommand, ParseError> => {
|
|
18
|
+
return Either.map(parseProjectDirArgs(args), ({ projectDir }) => ({
|
|
19
|
+
_tag: "Panes",
|
|
20
|
+
projectDir
|
|
21
|
+
}))
|
|
22
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Either, Match } from "effect"
|
|
2
|
+
|
|
3
|
+
import { type ParseError, type SessionsCommand } from "@effect-template/lib/core/domain"
|
|
4
|
+
|
|
5
|
+
import { parseProjectDirWithOptions } from "./parser-shared.js"
|
|
6
|
+
|
|
7
|
+
const defaultLines = 200
|
|
8
|
+
|
|
9
|
+
const parsePositiveInt = (
|
|
10
|
+
option: string,
|
|
11
|
+
raw: string
|
|
12
|
+
): Either.Either<number, ParseError> => {
|
|
13
|
+
const value = Number.parseInt(raw, 10)
|
|
14
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
15
|
+
const error: ParseError = {
|
|
16
|
+
_tag: "InvalidOption",
|
|
17
|
+
option,
|
|
18
|
+
reason: "expected positive integer"
|
|
19
|
+
}
|
|
20
|
+
return Either.left(error)
|
|
21
|
+
}
|
|
22
|
+
return Either.right(value)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const parseList = (args: ReadonlyArray<string>): Either.Either<SessionsCommand, ParseError> =>
|
|
26
|
+
Either.map(parseProjectDirWithOptions(args), ({ projectDir, raw }) => ({
|
|
27
|
+
_tag: "SessionsList",
|
|
28
|
+
projectDir,
|
|
29
|
+
includeDefault: raw.includeDefault === true
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
const parsePidContext = (
|
|
33
|
+
args: ReadonlyArray<string>
|
|
34
|
+
): Either.Either<
|
|
35
|
+
{ readonly pid: number; readonly projectDir: string; readonly raw: { readonly lines?: string } },
|
|
36
|
+
ParseError
|
|
37
|
+
> =>
|
|
38
|
+
Either.gen(function*(_) {
|
|
39
|
+
const pidRaw = args[0]
|
|
40
|
+
if (!pidRaw) {
|
|
41
|
+
const error: ParseError = { _tag: "MissingRequiredOption", option: "pid" }
|
|
42
|
+
return yield* _(Either.left(error))
|
|
43
|
+
}
|
|
44
|
+
const pid = yield* _(parsePositiveInt("pid", pidRaw))
|
|
45
|
+
const { projectDir, raw } = yield* _(parseProjectDirWithOptions(args.slice(1)))
|
|
46
|
+
return { pid, projectDir, raw }
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const parseKill = (args: ReadonlyArray<string>): Either.Either<SessionsCommand, ParseError> =>
|
|
50
|
+
Either.map(parsePidContext(args), ({ pid, projectDir }) => ({
|
|
51
|
+
_tag: "SessionsKill",
|
|
52
|
+
projectDir,
|
|
53
|
+
pid
|
|
54
|
+
}))
|
|
55
|
+
|
|
56
|
+
const parseLogs = (args: ReadonlyArray<string>): Either.Either<SessionsCommand, ParseError> =>
|
|
57
|
+
Either.gen(function*(_) {
|
|
58
|
+
const { pid, projectDir, raw } = yield* _(parsePidContext(args))
|
|
59
|
+
const lines = raw.lines ? yield* _(parsePositiveInt("--lines", raw.lines)) : defaultLines
|
|
60
|
+
return { _tag: "SessionsLogs", projectDir, pid, lines }
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// CHANGE: parse sessions command into list/kill/logs actions
|
|
64
|
+
// WHY: surface container terminal sessions and background processes from CLI
|
|
65
|
+
// QUOTE(ТЗ): "CLI команду которая из докера вернёт запущенные терминал сессии"
|
|
66
|
+
// REF: user-request-2026-02-04-terminal-sessions
|
|
67
|
+
// SOURCE: n/a
|
|
68
|
+
// FORMAT THEOREM: forall argv: parseSessions(argv) = cmd -> deterministic(cmd)
|
|
69
|
+
// PURITY: CORE
|
|
70
|
+
// EFFECT: Effect<SessionsCommand, ParseError, never>
|
|
71
|
+
// INVARIANT: pid/lines must be positive integers
|
|
72
|
+
// COMPLEXITY: O(n) where n = |argv|
|
|
73
|
+
export const parseSessions = (
|
|
74
|
+
args: ReadonlyArray<string>
|
|
75
|
+
): Either.Either<SessionsCommand, ParseError> => {
|
|
76
|
+
if (args.length === 0) {
|
|
77
|
+
return parseList(args)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const first = args[0] ?? ""
|
|
81
|
+
if (first.startsWith("-")) {
|
|
82
|
+
return parseList(args)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const rest = args.slice(1)
|
|
86
|
+
return Match.value(first).pipe(
|
|
87
|
+
Match.when("list", () => parseList(rest)),
|
|
88
|
+
Match.when("kill", () => parseKill(rest)),
|
|
89
|
+
Match.when("stop", () => parseKill(rest)),
|
|
90
|
+
Match.when("logs", () => parseLogs(rest)),
|
|
91
|
+
Match.when("log", () => parseLogs(rest)),
|
|
92
|
+
Match.orElse(() => {
|
|
93
|
+
const error: ParseError = {
|
|
94
|
+
_tag: "InvalidOption",
|
|
95
|
+
option: "sessions",
|
|
96
|
+
reason: `unknown action ${first}`
|
|
97
|
+
}
|
|
98
|
+
return Either.left(error)
|
|
99
|
+
})
|
|
100
|
+
)
|
|
101
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Either } from "effect"
|
|
2
|
+
|
|
3
|
+
import { deriveRepoPathParts, type ParseError, resolveRepoInput } from "@effect-template/lib/core/domain"
|
|
4
|
+
|
|
5
|
+
import { parseRawOptions, type RawOptions } from "./parser-options.js"
|
|
6
|
+
|
|
7
|
+
type PositionalRepo = {
|
|
8
|
+
readonly positionalRepoUrl: string | undefined
|
|
9
|
+
readonly restArgs: ReadonlyArray<string>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const resolveWorkspaceRepoPath = (
|
|
13
|
+
resolvedRepo: ReturnType<typeof resolveRepoInput>
|
|
14
|
+
): string => {
|
|
15
|
+
const baseParts = deriveRepoPathParts(resolvedRepo.repoUrl).pathParts
|
|
16
|
+
const projectParts = resolvedRepo.workspaceSuffix ? [...baseParts, resolvedRepo.workspaceSuffix] : baseParts
|
|
17
|
+
return projectParts.join("/")
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const splitPositionalRepo = (args: ReadonlyArray<string>): PositionalRepo => {
|
|
21
|
+
const first = args[0]
|
|
22
|
+
const positionalRepoUrl = first !== undefined && !first.startsWith("-") ? first : undefined
|
|
23
|
+
const restArgs = positionalRepoUrl ? args.slice(1) : args
|
|
24
|
+
return { positionalRepoUrl, restArgs }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const parseProjectDirWithOptions = (
|
|
28
|
+
args: ReadonlyArray<string>,
|
|
29
|
+
defaultProjectDir: string = "."
|
|
30
|
+
): Either.Either<{ readonly projectDir: string; readonly raw: RawOptions }, ParseError> =>
|
|
31
|
+
Either.gen(function*(_) {
|
|
32
|
+
const { positionalRepoUrl, restArgs } = splitPositionalRepo(args)
|
|
33
|
+
const raw = yield* _(parseRawOptions(restArgs))
|
|
34
|
+
const rawRepoUrl = raw.repoUrl ?? positionalRepoUrl
|
|
35
|
+
const repoPath = rawRepoUrl ? resolveWorkspaceRepoPath(resolveRepoInput(rawRepoUrl)) : null
|
|
36
|
+
const projectDir = raw.projectDir ??
|
|
37
|
+
(repoPath
|
|
38
|
+
? `.docker-git/${repoPath}`
|
|
39
|
+
: defaultProjectDir)
|
|
40
|
+
|
|
41
|
+
return { projectDir, raw }
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
export const parseProjectDirArgs = (
|
|
45
|
+
args: ReadonlyArray<string>,
|
|
46
|
+
defaultProjectDir: string = "."
|
|
47
|
+
): Either.Either<{ readonly projectDir: string }, ParseError> =>
|
|
48
|
+
Either.map(
|
|
49
|
+
parseProjectDirWithOptions(args, defaultProjectDir),
|
|
50
|
+
({ projectDir }) => ({ projectDir })
|
|
51
|
+
)
|