@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,320 @@
|
|
|
1
|
+
import { type CreateCommand, deriveRepoPathParts, resolveRepoInput } from "@effect-template/lib/core/domain"
|
|
2
|
+
import { createProject } from "@effect-template/lib/usecases/actions"
|
|
3
|
+
import type { AppError } from "@effect-template/lib/usecases/errors"
|
|
4
|
+
import { defaultProjectsRoot } from "@effect-template/lib/usecases/menu-helpers"
|
|
5
|
+
import * as Path from "@effect/platform/Path"
|
|
6
|
+
import { Effect, Either, Match, pipe } from "effect"
|
|
7
|
+
import { parseArgs } from "./cli/parser.js"
|
|
8
|
+
import { formatParseError, usageText } from "./cli/usage.js"
|
|
9
|
+
|
|
10
|
+
import { resetToMenu } from "./menu-shared.js"
|
|
11
|
+
import {
|
|
12
|
+
type CreateInputs,
|
|
13
|
+
type CreateStep,
|
|
14
|
+
createSteps,
|
|
15
|
+
type MenuEnv,
|
|
16
|
+
type MenuState,
|
|
17
|
+
type ViewState
|
|
18
|
+
} from "./menu-types.js"
|
|
19
|
+
|
|
20
|
+
// CHANGE: move create-flow handling into a dedicated module
|
|
21
|
+
// WHY: keep TUI entry slim and satisfy lint constraints
|
|
22
|
+
// QUOTE(ТЗ): "TUI? Красивый, удобный"
|
|
23
|
+
// REF: user-request-2026-02-01-tui
|
|
24
|
+
// SOURCE: n/a
|
|
25
|
+
// FORMAT THEOREM: forall s: step(s) -> step'(s)
|
|
26
|
+
// PURITY: SHELL
|
|
27
|
+
// EFFECT: Effect<void, AppError, FileSystem | Path | CommandExecutor>
|
|
28
|
+
// INVARIANT: outDir resolves to a stable repo path
|
|
29
|
+
// COMPLEXITY: O(1) per keypress
|
|
30
|
+
|
|
31
|
+
type Mutable<T> = { -readonly [K in keyof T]: T[K] }
|
|
32
|
+
|
|
33
|
+
type CreateRunner = { readonly runEffect: (effect: Effect.Effect<void, AppError, MenuEnv>) => void }
|
|
34
|
+
|
|
35
|
+
type CreateContext = {
|
|
36
|
+
readonly state: MenuState
|
|
37
|
+
readonly setView: (view: ViewState) => void
|
|
38
|
+
readonly setMessage: (message: string | null) => void
|
|
39
|
+
readonly runner: CreateRunner
|
|
40
|
+
readonly setActiveDir: (dir: string | null) => void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type CreateReturnContext = CreateContext & {
|
|
44
|
+
readonly view: Extract<ViewState, { readonly _tag: "Create" }>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const buildCreateArgs = (input: CreateInputs): ReadonlyArray<string> => {
|
|
48
|
+
const args: Array<string> = ["create", "--repo-url", input.repoUrl, "--secrets-root", input.secretsRoot]
|
|
49
|
+
if (input.repoRef.length > 0) {
|
|
50
|
+
args.push("--repo-ref", input.repoRef)
|
|
51
|
+
}
|
|
52
|
+
args.push("--out-dir", input.outDir)
|
|
53
|
+
if (!input.runUp) {
|
|
54
|
+
args.push("--no-up")
|
|
55
|
+
}
|
|
56
|
+
if (input.enableMcpPlaywright) {
|
|
57
|
+
args.push("--mcp-playwright")
|
|
58
|
+
}
|
|
59
|
+
if (input.force) {
|
|
60
|
+
args.push("--force")
|
|
61
|
+
}
|
|
62
|
+
if (input.forceEnv) {
|
|
63
|
+
args.push("--force-env")
|
|
64
|
+
}
|
|
65
|
+
return args
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const trimLeftSlash = (value: string): string => {
|
|
69
|
+
let start = 0
|
|
70
|
+
while (start < value.length && value[start] === "/") {
|
|
71
|
+
start += 1
|
|
72
|
+
}
|
|
73
|
+
return value.slice(start)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const trimRightSlash = (value: string): string => {
|
|
77
|
+
let end = value.length
|
|
78
|
+
while (end > 0 && value[end - 1] === "/") {
|
|
79
|
+
end -= 1
|
|
80
|
+
}
|
|
81
|
+
return value.slice(0, end)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const joinPath = (...parts: ReadonlyArray<string>): string => {
|
|
85
|
+
const cleaned = parts
|
|
86
|
+
.filter((part) => part.length > 0)
|
|
87
|
+
.map((part, index) => {
|
|
88
|
+
if (index === 0) {
|
|
89
|
+
return trimRightSlash(part)
|
|
90
|
+
}
|
|
91
|
+
return trimRightSlash(trimLeftSlash(part))
|
|
92
|
+
})
|
|
93
|
+
return cleaned.join("/")
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const resolveDefaultOutDir = (cwd: string, repoUrl: string): string => {
|
|
97
|
+
const resolvedRepo = resolveRepoInput(repoUrl)
|
|
98
|
+
const baseParts = deriveRepoPathParts(resolvedRepo.repoUrl).pathParts
|
|
99
|
+
const projectParts = resolvedRepo.workspaceSuffix ? [...baseParts, resolvedRepo.workspaceSuffix] : baseParts
|
|
100
|
+
return joinPath(defaultProjectsRoot(cwd), ...projectParts)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const resolveCreateInputs = (
|
|
104
|
+
cwd: string,
|
|
105
|
+
values: Partial<CreateInputs>
|
|
106
|
+
): CreateInputs => {
|
|
107
|
+
const repoUrl = values.repoUrl ?? ""
|
|
108
|
+
const resolvedRepoRef = repoUrl.length > 0 ? resolveRepoInput(repoUrl).repoRef : undefined
|
|
109
|
+
const secretsRoot = values.secretsRoot ?? joinPath(defaultProjectsRoot(cwd), "secrets")
|
|
110
|
+
const outDir = values.outDir ?? (repoUrl.length > 0 ? resolveDefaultOutDir(cwd, repoUrl) : "")
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
repoUrl,
|
|
114
|
+
repoRef: values.repoRef ?? resolvedRepoRef ?? "main",
|
|
115
|
+
outDir,
|
|
116
|
+
secretsRoot,
|
|
117
|
+
runUp: values.runUp !== false,
|
|
118
|
+
enableMcpPlaywright: values.enableMcpPlaywright === true,
|
|
119
|
+
force: values.force === true,
|
|
120
|
+
forceEnv: values.forceEnv === true
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const parseYesDefault = (input: string, fallback: boolean): boolean => {
|
|
125
|
+
const normalized = input.trim().toLowerCase()
|
|
126
|
+
if (normalized === "y" || normalized === "yes") {
|
|
127
|
+
return true
|
|
128
|
+
}
|
|
129
|
+
if (normalized === "n" || normalized === "no") {
|
|
130
|
+
return false
|
|
131
|
+
}
|
|
132
|
+
return fallback
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const applyCreateCommand = (
|
|
136
|
+
state: MenuState,
|
|
137
|
+
create: CreateCommand
|
|
138
|
+
): Effect.Effect<{ readonly _tag: "Continue"; readonly state: MenuState }, AppError, MenuEnv> =>
|
|
139
|
+
Effect.gen(function*(_) {
|
|
140
|
+
const path = yield* _(Path.Path)
|
|
141
|
+
const resolvedOutDir = path.resolve(create.outDir)
|
|
142
|
+
yield* _(createProject(create))
|
|
143
|
+
return { _tag: "Continue", state: { ...state, activeDir: resolvedOutDir } }
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const isCreateCommand = (command: { readonly _tag: string }): command is CreateCommand => command._tag === "Create"
|
|
147
|
+
|
|
148
|
+
const buildCreateEffect = (
|
|
149
|
+
command: { readonly _tag: string },
|
|
150
|
+
state: MenuState,
|
|
151
|
+
setActiveDir: (dir: string | null) => void,
|
|
152
|
+
setMessage: (message: string | null) => void
|
|
153
|
+
): Effect.Effect<void, AppError, MenuEnv> => {
|
|
154
|
+
if (isCreateCommand(command)) {
|
|
155
|
+
return pipe(
|
|
156
|
+
applyCreateCommand(state, command),
|
|
157
|
+
Effect.tap((outcome) =>
|
|
158
|
+
Effect.sync(() => {
|
|
159
|
+
setActiveDir(outcome.state.activeDir)
|
|
160
|
+
})
|
|
161
|
+
),
|
|
162
|
+
Effect.asVoid
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
if (command._tag === "Help") {
|
|
166
|
+
return Effect.sync(() => {
|
|
167
|
+
setMessage(usageText)
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
return Effect.void
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const applyCreateStep = (input: {
|
|
174
|
+
readonly step: CreateStep
|
|
175
|
+
readonly buffer: string
|
|
176
|
+
readonly currentDefaults: CreateInputs
|
|
177
|
+
readonly nextValues: Partial<Mutable<CreateInputs>>
|
|
178
|
+
readonly cwd: string
|
|
179
|
+
readonly setMessage: (message: string | null) => void
|
|
180
|
+
}): boolean =>
|
|
181
|
+
Match.value(input.step).pipe(
|
|
182
|
+
Match.when("repoUrl", () => {
|
|
183
|
+
if (input.buffer.length === 0) {
|
|
184
|
+
input.setMessage("Repo URL is required.")
|
|
185
|
+
return false
|
|
186
|
+
}
|
|
187
|
+
input.nextValues.repoUrl = input.buffer
|
|
188
|
+
input.nextValues.outDir = resolveDefaultOutDir(input.cwd, input.buffer)
|
|
189
|
+
return true
|
|
190
|
+
}),
|
|
191
|
+
Match.when("repoRef", () => {
|
|
192
|
+
input.nextValues.repoRef = input.buffer.length > 0 ? input.buffer : input.currentDefaults.repoRef
|
|
193
|
+
return true
|
|
194
|
+
}),
|
|
195
|
+
Match.when("outDir", () => {
|
|
196
|
+
input.nextValues.outDir = input.buffer.length > 0 ? input.buffer : input.currentDefaults.outDir
|
|
197
|
+
return true
|
|
198
|
+
}),
|
|
199
|
+
Match.when("runUp", () => {
|
|
200
|
+
input.nextValues.runUp = parseYesDefault(input.buffer, input.currentDefaults.runUp)
|
|
201
|
+
return true
|
|
202
|
+
}),
|
|
203
|
+
Match.when("mcpPlaywright", () => {
|
|
204
|
+
input.nextValues.enableMcpPlaywright = parseYesDefault(
|
|
205
|
+
input.buffer,
|
|
206
|
+
input.currentDefaults.enableMcpPlaywright
|
|
207
|
+
)
|
|
208
|
+
return true
|
|
209
|
+
}),
|
|
210
|
+
Match.when("force", () => {
|
|
211
|
+
input.nextValues.force = parseYesDefault(input.buffer, input.currentDefaults.force)
|
|
212
|
+
return true
|
|
213
|
+
}),
|
|
214
|
+
Match.exhaustive
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
const finalizeCreateFlow = (input: {
|
|
218
|
+
readonly state: MenuState
|
|
219
|
+
readonly nextValues: Partial<CreateInputs>
|
|
220
|
+
readonly setView: (view: ViewState) => void
|
|
221
|
+
readonly setMessage: (message: string | null) => void
|
|
222
|
+
readonly runner: CreateRunner
|
|
223
|
+
readonly setActiveDir: (dir: string | null) => void
|
|
224
|
+
}) => {
|
|
225
|
+
const inputs = resolveCreateInputs(input.state.cwd, input.nextValues)
|
|
226
|
+
if (inputs.repoUrl.length === 0) {
|
|
227
|
+
input.setMessage("Repo URL is required.")
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const parsed = parseArgs(buildCreateArgs(inputs))
|
|
232
|
+
if (Either.isLeft(parsed)) {
|
|
233
|
+
input.setMessage(formatParseError(parsed.left))
|
|
234
|
+
input.setView({ _tag: "Menu" })
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const effect = buildCreateEffect(parsed.right, input.state, input.setActiveDir, input.setMessage)
|
|
239
|
+
input.runner.runEffect(effect)
|
|
240
|
+
input.setView({ _tag: "Menu" })
|
|
241
|
+
input.setMessage(null)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const handleCreateReturn = (context: CreateReturnContext) => {
|
|
245
|
+
const step = createSteps[context.view.step]
|
|
246
|
+
if (!step) {
|
|
247
|
+
context.setView({ _tag: "Menu" })
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const buffer = context.view.buffer.trim()
|
|
252
|
+
const currentDefaults = resolveCreateInputs(context.state.cwd, context.view.values)
|
|
253
|
+
const nextValues: Partial<Mutable<CreateInputs>> = { ...context.view.values }
|
|
254
|
+
const updated = applyCreateStep({
|
|
255
|
+
step,
|
|
256
|
+
buffer,
|
|
257
|
+
currentDefaults,
|
|
258
|
+
nextValues,
|
|
259
|
+
cwd: context.state.cwd,
|
|
260
|
+
setMessage: context.setMessage
|
|
261
|
+
})
|
|
262
|
+
if (!updated) {
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const nextStep = context.view.step + 1
|
|
267
|
+
if (nextStep < createSteps.length) {
|
|
268
|
+
context.setView({ _tag: "Create", step: nextStep, buffer: "", values: nextValues })
|
|
269
|
+
context.setMessage(null)
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
finalizeCreateFlow({
|
|
274
|
+
state: context.state,
|
|
275
|
+
nextValues,
|
|
276
|
+
setView: context.setView,
|
|
277
|
+
setMessage: context.setMessage,
|
|
278
|
+
runner: context.runner,
|
|
279
|
+
setActiveDir: context.setActiveDir
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export const startCreateView = (
|
|
284
|
+
setView: (view: ViewState) => void,
|
|
285
|
+
setMessage: (message: string | null) => void,
|
|
286
|
+
buffer = ""
|
|
287
|
+
) => {
|
|
288
|
+
setView({ _tag: "Create", step: 0, buffer, values: {} })
|
|
289
|
+
setMessage(null)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export const handleCreateInput = (
|
|
293
|
+
input: string,
|
|
294
|
+
key: {
|
|
295
|
+
readonly escape?: boolean
|
|
296
|
+
readonly return?: boolean
|
|
297
|
+
readonly backspace?: boolean
|
|
298
|
+
readonly delete?: boolean
|
|
299
|
+
},
|
|
300
|
+
view: Extract<ViewState, { readonly _tag: "Create" }>,
|
|
301
|
+
context: CreateContext
|
|
302
|
+
) => {
|
|
303
|
+
if (key.escape) {
|
|
304
|
+
resetToMenu(context)
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
if (key.return) {
|
|
308
|
+
handleCreateReturn({ ...context, view })
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (key.backspace || key.delete) {
|
|
313
|
+
context.setView({ ...view, buffer: view.buffer.slice(0, -1) })
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (input.length > 0) {
|
|
318
|
+
context.setView({ ...view, buffer: view.buffer + input })
|
|
319
|
+
}
|
|
320
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { parseMenuSelection } from "@effect-template/lib/core/domain"
|
|
2
|
+
import { isRepoUrlInput } from "@effect-template/lib/usecases/menu-helpers"
|
|
3
|
+
import { Either } from "effect"
|
|
4
|
+
|
|
5
|
+
import { handleMenuActionSelection, type MenuSelectionContext } from "./menu-actions.js"
|
|
6
|
+
import { startCreateView } from "./menu-create.js"
|
|
7
|
+
import { menuItems } from "./menu-types.js"
|
|
8
|
+
|
|
9
|
+
const handleMenuNavigation = (
|
|
10
|
+
key: { readonly upArrow?: boolean; readonly downArrow?: boolean },
|
|
11
|
+
setSelected: (update: (value: number) => number) => void
|
|
12
|
+
) => {
|
|
13
|
+
if (key.upArrow) {
|
|
14
|
+
setSelected((prev) => (prev === 0 ? menuItems.length - 1 : prev - 1))
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
if (key.downArrow) {
|
|
18
|
+
setSelected((prev) => (prev === menuItems.length - 1 ? 0 : prev + 1))
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const handleMenuEnter = (context: MenuSelectionContext) => {
|
|
23
|
+
const action = menuItems[context.selected]?.id
|
|
24
|
+
if (!action) {
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
handleMenuActionSelection(action, context)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const handleMenuTextInput = (input: string, context: MenuSelectionContext): boolean => {
|
|
31
|
+
const trimmed = input.trim()
|
|
32
|
+
if (trimmed.length > 0 && isRepoUrlInput(trimmed)) {
|
|
33
|
+
startCreateView(context.setView, context.setMessage, trimmed)
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
36
|
+
const selection = parseMenuSelection(input)
|
|
37
|
+
if (Either.isRight(selection)) {
|
|
38
|
+
handleMenuActionSelection(selection.right, context)
|
|
39
|
+
return true
|
|
40
|
+
}
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const handleMenuInput = (
|
|
45
|
+
input: string,
|
|
46
|
+
key: { readonly upArrow?: boolean; readonly downArrow?: boolean; readonly return?: boolean },
|
|
47
|
+
context: MenuSelectionContext
|
|
48
|
+
) => {
|
|
49
|
+
if (key.upArrow || key.downArrow) {
|
|
50
|
+
handleMenuNavigation(key, context.setSelected)
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
if (key.return) {
|
|
54
|
+
handleMenuEnter(context)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
handleMenuTextInput(input, context)
|
|
58
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { Match } from "effect"
|
|
2
|
+
import { Box, Text } from "ink"
|
|
3
|
+
import React from "react"
|
|
4
|
+
|
|
5
|
+
import type { ProjectItem } from "@effect-template/lib/usecases/projects"
|
|
6
|
+
import type { CreateInputs, CreateStep } from "./menu-types.js"
|
|
7
|
+
import { createSteps, menuItems } from "./menu-types.js"
|
|
8
|
+
|
|
9
|
+
// CHANGE: render menu views with Ink without JSX
|
|
10
|
+
// WHY: keep UI logic separate from input/state reducers
|
|
11
|
+
// QUOTE(ТЗ): "TUI? Красивый, удобный"
|
|
12
|
+
// REF: user-request-2026-02-01-tui
|
|
13
|
+
// SOURCE: n/a
|
|
14
|
+
// FORMAT THEOREM: forall v: view(v) -> render(v)
|
|
15
|
+
// PURITY: SHELL
|
|
16
|
+
// EFFECT: n/a
|
|
17
|
+
// INVARIANT: menu renders all items once
|
|
18
|
+
// COMPLEXITY: O(n)
|
|
19
|
+
|
|
20
|
+
export const renderStepLabel = (step: CreateStep, defaults: CreateInputs): string =>
|
|
21
|
+
Match.value(step).pipe(
|
|
22
|
+
Match.when("repoUrl", () => "Repo URL"),
|
|
23
|
+
Match.when("repoRef", () => `Repo ref [${defaults.repoRef}]`),
|
|
24
|
+
Match.when("outDir", () => `Output dir [${defaults.outDir}]`),
|
|
25
|
+
Match.when("runUp", () => `Run docker compose up now? [${defaults.runUp ? "Y" : "n"}]`),
|
|
26
|
+
Match.when(
|
|
27
|
+
"mcpPlaywright",
|
|
28
|
+
() => `Enable Playwright MCP (Chromium sidecar)? [${defaults.enableMcpPlaywright ? "y" : "N"}]`
|
|
29
|
+
),
|
|
30
|
+
Match.when(
|
|
31
|
+
"force",
|
|
32
|
+
() => `Force recreate (overwrite files + wipe volumes)? [${defaults.force ? "y" : "N"}]`
|
|
33
|
+
),
|
|
34
|
+
Match.exhaustive
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
const renderMessage = (message: string | null): React.ReactElement | null => {
|
|
38
|
+
if (!message) {
|
|
39
|
+
return null
|
|
40
|
+
}
|
|
41
|
+
return React.createElement(
|
|
42
|
+
Box,
|
|
43
|
+
{ marginTop: 1 },
|
|
44
|
+
React.createElement(Text, { color: "magenta" }, message)
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const renderLayout = (
|
|
49
|
+
title: string,
|
|
50
|
+
body: ReadonlyArray<React.ReactElement>,
|
|
51
|
+
message: string | null
|
|
52
|
+
): React.ReactElement => {
|
|
53
|
+
const el = React.createElement
|
|
54
|
+
const messageView = renderMessage(message)
|
|
55
|
+
const tail = messageView ? [messageView] : []
|
|
56
|
+
return el(
|
|
57
|
+
Box,
|
|
58
|
+
{ flexDirection: "column", padding: 1, borderStyle: "round" },
|
|
59
|
+
el(Text, { color: "cyan", bold: true }, title),
|
|
60
|
+
...body,
|
|
61
|
+
...tail
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const compactElements = (
|
|
66
|
+
items: ReadonlyArray<React.ReactElement | null>
|
|
67
|
+
): ReadonlyArray<React.ReactElement> => items.filter((item): item is React.ReactElement => item !== null)
|
|
68
|
+
|
|
69
|
+
const renderMenuHints = (el: typeof React.createElement): React.ReactElement =>
|
|
70
|
+
el(
|
|
71
|
+
Box,
|
|
72
|
+
{ marginTop: 1, flexDirection: "column" },
|
|
73
|
+
el(Text, { color: "gray" }, "Hints:"),
|
|
74
|
+
el(Text, { color: "gray" }, " - Paste repo URL to create directly."),
|
|
75
|
+
el(
|
|
76
|
+
Text,
|
|
77
|
+
{ color: "gray" },
|
|
78
|
+
" - Aliases: create/c, select/s, info/i, status/ps, logs/l, down/d, down-all/da, delete/del, quit/q"
|
|
79
|
+
),
|
|
80
|
+
el(Text, { color: "gray" }, " - Use arrows and Enter to run.")
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
const renderMenuMessage = (
|
|
84
|
+
el: typeof React.createElement,
|
|
85
|
+
message: string | null
|
|
86
|
+
): React.ReactElement | null => {
|
|
87
|
+
if (!message || message.length === 0) {
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
return el(
|
|
91
|
+
Box,
|
|
92
|
+
{ marginTop: 1, flexDirection: "column" },
|
|
93
|
+
...message
|
|
94
|
+
.split("\n")
|
|
95
|
+
.map((line, index) => el(Text, { key: `${index}-${line}`, color: "magenta" }, line))
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const renderMenu = (
|
|
100
|
+
cwd: string,
|
|
101
|
+
activeDir: string | null,
|
|
102
|
+
selected: number,
|
|
103
|
+
busy: boolean,
|
|
104
|
+
message: string | null
|
|
105
|
+
): React.ReactElement => {
|
|
106
|
+
const el = React.createElement
|
|
107
|
+
const activeLabel = `Active: ${activeDir ?? "(none)"}`
|
|
108
|
+
const cwdLabel = `CWD: ${cwd}`
|
|
109
|
+
const items = menuItems.map((item, index) => {
|
|
110
|
+
const indexLabel = `${index + 1})`
|
|
111
|
+
const prefix = index === selected ? ">" : " "
|
|
112
|
+
return el(
|
|
113
|
+
Text,
|
|
114
|
+
{ key: item.label, color: index === selected ? "green" : "white" },
|
|
115
|
+
`${prefix} ${indexLabel} ${item.label}`
|
|
116
|
+
)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const busyView = busy
|
|
120
|
+
? el(Box, { marginTop: 1 }, el(Text, { color: "yellow" }, "Running..."))
|
|
121
|
+
: null
|
|
122
|
+
|
|
123
|
+
const messageView = renderMenuMessage(el, message)
|
|
124
|
+
const hints = renderMenuHints(el)
|
|
125
|
+
|
|
126
|
+
return renderLayout(
|
|
127
|
+
"docker-git",
|
|
128
|
+
compactElements([
|
|
129
|
+
el(Text, null, activeLabel),
|
|
130
|
+
el(Text, null, cwdLabel),
|
|
131
|
+
el(Box, { flexDirection: "column", marginTop: 1 }, ...items),
|
|
132
|
+
hints,
|
|
133
|
+
busyView,
|
|
134
|
+
messageView
|
|
135
|
+
]),
|
|
136
|
+
null
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export const renderCreate = (
|
|
141
|
+
label: string,
|
|
142
|
+
buffer: string,
|
|
143
|
+
message: string | null,
|
|
144
|
+
stepIndex: number,
|
|
145
|
+
defaults: CreateInputs
|
|
146
|
+
): React.ReactElement => {
|
|
147
|
+
const el = React.createElement
|
|
148
|
+
const steps = createSteps.map((step, index) =>
|
|
149
|
+
el(
|
|
150
|
+
Text,
|
|
151
|
+
{ key: step, color: index === stepIndex ? "green" : "gray" },
|
|
152
|
+
`${index === stepIndex ? ">" : " "} ${renderStepLabel(step, defaults)}`
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
return renderLayout(
|
|
156
|
+
"docker-git / Create",
|
|
157
|
+
[
|
|
158
|
+
el(Box, { flexDirection: "column", marginTop: 1 }, ...steps),
|
|
159
|
+
el(
|
|
160
|
+
Box,
|
|
161
|
+
{ marginTop: 1 },
|
|
162
|
+
el(Text, null, `${label}: `),
|
|
163
|
+
el(Text, { color: "green" }, buffer)
|
|
164
|
+
),
|
|
165
|
+
el(Box, { marginTop: 1 }, el(Text, { color: "gray" }, "Enter = next, Esc = cancel."))
|
|
166
|
+
],
|
|
167
|
+
message
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const formatRepoRef = (repoRef: string): string => {
|
|
172
|
+
const trimmed = repoRef.trim()
|
|
173
|
+
const prPrefix = "refs/pull/"
|
|
174
|
+
if (trimmed.startsWith(prPrefix)) {
|
|
175
|
+
const rest = trimmed.slice(prPrefix.length)
|
|
176
|
+
const number = rest.split("/")[0] ?? rest
|
|
177
|
+
return `PR#${number}`
|
|
178
|
+
}
|
|
179
|
+
return trimmed.length > 0 ? trimmed : "main"
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const renderSelectDetails = (
|
|
183
|
+
el: typeof React.createElement,
|
|
184
|
+
purpose: SelectPurpose,
|
|
185
|
+
item: ProjectItem | undefined
|
|
186
|
+
): ReadonlyArray<React.ReactElement> => {
|
|
187
|
+
if (!item) {
|
|
188
|
+
return [el(Text, { color: "gray", wrap: "truncate" }, "No project selected.")]
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const refLabel = formatRepoRef(item.repoRef)
|
|
192
|
+
const authSuffix = item.authorizedKeysExists ? "" : " (missing)"
|
|
193
|
+
|
|
194
|
+
return Match.value(purpose).pipe(
|
|
195
|
+
Match.when("Info", () => [
|
|
196
|
+
el(Text, { color: "cyan", bold: true, wrap: "truncate" }, "Connection info"),
|
|
197
|
+
el(Text, { wrap: "wrap" }, `Project directory: ${item.projectDir}`),
|
|
198
|
+
el(Text, { wrap: "wrap" }, `Container: ${item.containerName}`),
|
|
199
|
+
el(Text, { wrap: "wrap" }, `Service: ${item.serviceName}`),
|
|
200
|
+
el(Text, { wrap: "wrap" }, `SSH command: ${item.sshCommand}`),
|
|
201
|
+
el(Text, { wrap: "wrap" }, `Repo: ${item.repoUrl} (${refLabel})`),
|
|
202
|
+
el(Text, { wrap: "wrap" }, `Workspace: ${item.targetDir}`),
|
|
203
|
+
el(Text, { wrap: "wrap" }, `Authorized keys: ${item.authorizedKeysPath}${authSuffix}`),
|
|
204
|
+
el(Text, { wrap: "wrap" }, `Env global: ${item.envGlobalPath}`),
|
|
205
|
+
el(Text, { wrap: "wrap" }, `Env project: ${item.envProjectPath}`),
|
|
206
|
+
el(Text, { wrap: "wrap" }, `Codex auth: ${item.codexAuthPath} -> ${item.codexHome}`)
|
|
207
|
+
]),
|
|
208
|
+
Match.when("Delete", () => [
|
|
209
|
+
el(Text, { color: "cyan", bold: true, wrap: "truncate" }, "Delete project"),
|
|
210
|
+
el(Text, { wrap: "wrap" }, `Project directory: ${item.projectDir}`),
|
|
211
|
+
el(Text, { wrap: "wrap" }, `Container: ${item.containerName}`),
|
|
212
|
+
el(Text, { wrap: "wrap" }, `Repo: ${item.repoUrl} (${refLabel})`),
|
|
213
|
+
el(Text, { wrap: "wrap" }, "Removes the project folder (no git history rewrite).")
|
|
214
|
+
]),
|
|
215
|
+
Match.orElse(() => [
|
|
216
|
+
el(Text, { color: "cyan", bold: true, wrap: "truncate" }, "Details"),
|
|
217
|
+
el(Text, { wrap: "truncate" }, `Repo: ${item.repoUrl}`),
|
|
218
|
+
el(Text, { wrap: "truncate" }, `Ref: ${item.repoRef}`),
|
|
219
|
+
el(Text, { wrap: "truncate" }, `Project dir: ${item.projectDir}`),
|
|
220
|
+
el(Text, { wrap: "truncate" }, `Workspace: ${item.targetDir}`),
|
|
221
|
+
el(Text, { wrap: "truncate" }, `SSH: ${item.sshCommand}`)
|
|
222
|
+
])
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
type SelectPurpose = "Connect" | "Down" | "Info" | "Delete"
|
|
227
|
+
|
|
228
|
+
const selectTitle = (purpose: SelectPurpose): string =>
|
|
229
|
+
Match.value(purpose).pipe(
|
|
230
|
+
Match.when("Connect", () => "docker-git / Select project"),
|
|
231
|
+
Match.when("Down", () => "docker-git / Stop container"),
|
|
232
|
+
Match.when("Info", () => "docker-git / Show connection info"),
|
|
233
|
+
Match.when("Delete", () => "docker-git / Delete project"),
|
|
234
|
+
Match.exhaustive
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
const selectHint = (purpose: SelectPurpose): string =>
|
|
238
|
+
Match.value(purpose).pipe(
|
|
239
|
+
Match.when("Connect", () => "Enter = select + SSH, Esc = back"),
|
|
240
|
+
Match.when("Down", () => "Enter = stop container, Esc = back"),
|
|
241
|
+
Match.when("Info", () => "Use arrows to browse details, Enter = set active, Esc = back"),
|
|
242
|
+
Match.when("Delete", () => "Enter = ask/confirm delete, Esc = cancel"),
|
|
243
|
+
Match.exhaustive
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
const buildSelectLabels = (
|
|
247
|
+
items: ReadonlyArray<ProjectItem>,
|
|
248
|
+
selected: number
|
|
249
|
+
): ReadonlyArray<string> =>
|
|
250
|
+
items.map((item, index) => {
|
|
251
|
+
const prefix = index === selected ? ">" : " "
|
|
252
|
+
const refLabel = formatRepoRef(item.repoRef)
|
|
253
|
+
return `${prefix} ${index + 1}. ${item.displayName} (${refLabel})`
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
const computeListWidth = (labels: ReadonlyArray<string>): number => {
|
|
257
|
+
const maxLabelWidth = labels.length > 0 ? Math.max(...labels.map((label) => label.length)) : 24
|
|
258
|
+
return Math.min(Math.max(maxLabelWidth + 2, 28), 54)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const renderSelectListBox = (
|
|
262
|
+
el: typeof React.createElement,
|
|
263
|
+
items: ReadonlyArray<ProjectItem>,
|
|
264
|
+
selected: number,
|
|
265
|
+
labels: ReadonlyArray<string>,
|
|
266
|
+
width: number
|
|
267
|
+
): React.ReactElement => {
|
|
268
|
+
const list = labels.map((label, index) =>
|
|
269
|
+
el(
|
|
270
|
+
Text,
|
|
271
|
+
{
|
|
272
|
+
key: items[index]?.projectDir ?? String(index),
|
|
273
|
+
color: index === selected ? "green" : "white",
|
|
274
|
+
wrap: "truncate"
|
|
275
|
+
},
|
|
276
|
+
label
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
return el(
|
|
281
|
+
Box,
|
|
282
|
+
{ flexDirection: "column", width },
|
|
283
|
+
...(list.length > 0 ? list : [el(Text, { color: "gray" }, "No projects found.")])
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const renderSelectDetailsBox = (
|
|
288
|
+
el: typeof React.createElement,
|
|
289
|
+
purpose: SelectPurpose,
|
|
290
|
+
items: ReadonlyArray<ProjectItem>,
|
|
291
|
+
selected: number
|
|
292
|
+
): React.ReactElement => {
|
|
293
|
+
const details = renderSelectDetails(el, purpose, items[selected])
|
|
294
|
+
return el(
|
|
295
|
+
Box,
|
|
296
|
+
{ flexDirection: "column", marginLeft: 2, flexGrow: 1 },
|
|
297
|
+
...details
|
|
298
|
+
)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export const renderSelect = (
|
|
302
|
+
purpose: SelectPurpose,
|
|
303
|
+
items: ReadonlyArray<ProjectItem>,
|
|
304
|
+
selected: number,
|
|
305
|
+
confirmDelete: boolean,
|
|
306
|
+
message: string | null
|
|
307
|
+
): React.ReactElement => {
|
|
308
|
+
const el = React.createElement
|
|
309
|
+
const listLabels = buildSelectLabels(items, selected)
|
|
310
|
+
const listWidth = computeListWidth(listLabels)
|
|
311
|
+
const listBox = renderSelectListBox(el, items, selected, listLabels, listWidth)
|
|
312
|
+
const detailsBox = renderSelectDetailsBox(el, purpose, items, selected)
|
|
313
|
+
const baseHint = selectHint(purpose)
|
|
314
|
+
const deleteHint = purpose === "Delete" && confirmDelete
|
|
315
|
+
? "Confirm mode: Enter = delete now, Esc = cancel"
|
|
316
|
+
: baseHint
|
|
317
|
+
const hints = el(Box, { marginTop: 1 }, el(Text, { color: "gray" }, deleteHint))
|
|
318
|
+
|
|
319
|
+
return renderLayout(
|
|
320
|
+
selectTitle(purpose),
|
|
321
|
+
[
|
|
322
|
+
el(Box, { flexDirection: "row", marginTop: 1 }, listBox, detailsBox),
|
|
323
|
+
hints
|
|
324
|
+
],
|
|
325
|
+
message
|
|
326
|
+
)
|
|
327
|
+
}
|