diff --git a/README.md b/README.md index 84c9ca20..9835747d 100644 --- a/README.md +++ b/README.md @@ -95,12 +95,12 @@ docker-git --help ``` Структура проекта: -APP - CLI + React (Frontend) -LIB - Весь бекенд (Основная бизнес логика) -API - Просто апи сервер поднятный над LIB +APP - CLI + browser frontend (`docker-git browser`) +LIB - основная бизнес-логика +API - только API server над LIB -APP работает только с API, и не имеет доступа к LIB -API работает только с LIB +APP работает с API; единственная пользовательская frontend-поверхность запускается через `docker-git browser`. +API работает только с LIB и не сервит встроенный HTML frontend. ## Runtime contract: host-Docker-backed diff --git a/bun.lock b/bun.lock index fdfd91e2..f53e4acd 100644 --- a/bun.lock +++ b/bun.lock @@ -55,13 +55,10 @@ "@effect/sql": "^0.51.1", "@effect/typeclass": "^0.40.0", "@effect/workflow": "^0.18.1", - "@gridland/bun": "0.4.3", - "@gridland/web": "0.4.3", "@prover-coder-ai/docker-git-session-sync": "workspace:*", "effect": "^3.21.2", "react": "^19.2.6", "react-dom": "^19.2.6", - "react-reconciler": "^0.33.0", "ts-morph": "^28.0.0", "xterm": "^5.3.0", "xterm-addon-fit": "^0.8.0", @@ -396,12 +393,6 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="], - "@gridland/bun": ["@gridland/bun@0.4.3", "", { "dependencies": { "@gridland/utils": "0.4.3", "react": "^19.0.0", "react-reconciler": "0.33.0", "yoga-layout": "^3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.1.86", "@opentui/core-darwin-x64": "0.1.86", "@opentui/core-linux-arm64": "0.1.86", "@opentui/core-linux-x64": "0.1.86" } }, "sha512-pNOK9ncUJKLDiR84E61qHFRW1SG6IrmZNuSRjSmWgzS4rzXhgCPUesQOKz0dBk7dk8c1qcilyV2kEvUD9f1QnA=="], - - "@gridland/utils": ["@gridland/utils@0.4.3", "", { "dependencies": { "react": "^19.0.0" } }, "sha512-FPBw1dPPWyFXpSG/ygsZExc6c0u35HnfXnRpZz+ZUDyezcVDaMIDpdbDlOccmLSt33qGVOTh+pEx9HuS0061vA=="], - - "@gridland/web": ["@gridland/web@0.4.3", "", { "dependencies": { "@gridland/utils": "0.4.3", "diff": "^8.0.3", "events": "^3.3.0", "marked": "^17.0.3", "react": "^19.0.0", "yoga-layout": "^3.2.1" }, "peerDependencies": { "react-reconciler": "0.33.0" } }, "sha512-JtuolfI/LMd33VARYi9qm6S73srzm3XnWK+pJpphwV72kX4zgPMEIqRCjapzB9QMzHOMHL/8lD0PZFiEydRWnw=="], - "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "0.19.1", "@humanwhocodes/retry": "0.4.3" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], @@ -464,14 +455,6 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "1.19.1" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.86", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Zp7q64+d+Dcx6YrH3mRcnHq8EOBnrfc1RvjgSWLhpXr49hY6LzuhqpfZM57aGErPYlR+ff8QM6e5FUkFnDfyjw=="], - - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.86", "", { "os": "darwin", "cpu": "x64" }, "sha512-NcxfjCJm1kLnTMVOpAPdRYNi8W8XdAXNa6N7i9khiVFrl2v5KRQfUjbrSOUYVxFJNc3jKFG6rsn3jEApvn92qA=="], - - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.86", "", { "os": "linux", "cpu": "arm64" }, "sha512-EDHAvqSOr8CXzbDvo1aE5blJ6wu1aSbR2LqoXtoeXHemr2T2W42D2TdIWewG6K+/BuRbzZnqt9wnYFBksLW6lw=="], - - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.86", "", { "os": "linux", "cpu": "x64" }, "sha512-VBaBkVdQDxYV4WcKjb+jgyMS5PiVHepvfaoKWpz1Bq+J01xXW4XPcXyPGkgR1+2R93KzaugEnLscTW4mWtLHlQ=="], - "@oxc-project/types": ["@oxc-project/types@0.130.0", "", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="], "@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "1.0.3", "is-glob": "4.0.3", "micromatch": "4.0.8", "node-addon-api": "7.1.1" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="], @@ -848,8 +831,6 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], - "diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="], "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], @@ -954,8 +935,6 @@ "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], - "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], - "execa": ["execa@4.1.0", "", { "dependencies": { "cross-spawn": "7.0.6", "get-stream": "5.2.0", "human-signals": "1.1.1", "is-stream": "2.0.1", "merge-stream": "2.0.0", "npm-run-path": "4.0.1", "onetime": "5.1.2", "signal-exit": "3.0.7", "strip-final-newline": "2.0.0" } }, "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA=="], "expect": ["expect@29.7.0", "", { "dependencies": { "@jest/expect-utils": "29.7.0", "jest-get-type": "29.6.3", "jest-matcher-utils": "29.7.0", "jest-message-util": "29.7.0", "jest-util": "29.7.0" } }, "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw=="], @@ -1266,8 +1245,6 @@ "markdown-table": ["markdown-table@2.0.0", "", { "dependencies": { "repeat-string": "1.6.1" } }, "sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A=="], - "marked": ["marked@17.0.6", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA=="], - "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "mdast-util-from-markdown": ["mdast-util-from-markdown@0.8.5", "", { "dependencies": { "@types/mdast": "3.0.15", "mdast-util-to-string": "2.0.0", "micromark": "2.11.4", "parse-entities": "2.0.0", "unist-util-stringify-position": "2.0.3" } }, "sha512-2hkTXtYYnr+NubD/g6KGBS/0mFmBcifAsI0yIWRiRo0PjVs6SSOSOdtzbp6kSGnShDN6G5aWZpKQ2lWRy27mWQ=="], @@ -1456,8 +1433,6 @@ "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], - "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "0.27.0" }, "peerDependencies": { "react": "19.2.4" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], - "read-pkg": ["read-pkg@5.2.0", "", { "dependencies": { "@types/normalize-package-data": "2.4.4", "normalize-package-data": "2.5.0", "parse-json": "5.2.0", "type-fest": "0.6.0" } }, "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg=="], "read-pkg-up": ["read-pkg-up@7.0.1", "", { "dependencies": { "find-up": "4.1.0", "read-pkg": "5.2.0", "type-fest": "0.8.1" } }, "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg=="], @@ -1706,8 +1681,6 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "zx": ["zx@8.8.5", "", { "bin": { "zx": "build/cli.js" } }, "sha512-SNgDF5L0gfN7FwVOdEFguY3orU5AkfFZm9B5YSHog/UDHv+lvmd82ZAsOenOkQixigwH2+yyH198AwNdKhj+RA=="], @@ -1924,8 +1897,6 @@ "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], - "react-reconciler/react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], - "read-pkg/type-fest": ["type-fest@0.6.0", "", {}, "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg=="], "read-pkg-up/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "5.0.0", "path-exists": "4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], diff --git a/packages/api/README.md b/packages/api/README.md index 232e3141..e562564e 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -36,14 +36,6 @@ Diagnostic classification + remediation messages live in `packages/app/src/docker-git/controller-docker-diagnostics.ts` and are covered by `packages/app/tests/docker-git/controller-docker-diagnostics.test.ts`. -## UI wrapper - -After API startup open: - -- `http://localhost:3334/` - -This page is a built-in UI shell for manual API checks without CLI. - ## Run (local) ```bash diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index bdd21bc5..234defa4 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -41,7 +41,6 @@ import { UpProjectRequestSchema } from "./api/schema.js" import type { UpProjectRequestInput } from "./api/schema.js" -import { uiHtml, uiScript, uiStyles } from "./ui.js" import { defaultProjectsRoot } from "@effect-template/lib/usecases/menu-helpers" import { resolveWorkspaceRoot } from "@effect-template/lib/shell/workspace-root" import { @@ -762,16 +761,7 @@ const projectProxyResponse = Effect.gen(function*(_) { }) export const makeRouter = () => { - const withUi = HttpRouter.empty.pipe( - HttpRouter.get("/", - Effect.gen(function*(_) { - const request = yield* _(HttpServerRequest.HttpServerRequest) - console.log("GET / request:", request.url, "headers:", request.headers) - return yield* _(textResponse(uiHtml, "text/html; charset=utf-8", 200)) - }).pipe(Effect.catchAll(errorResponse)) - ), - HttpRouter.get("/ui/styles.css", textResponse(uiStyles, "text/css; charset=utf-8", 200)), - HttpRouter.get("/ui/app.js", textResponse(uiScript, "application/javascript; charset=utf-8", 200)), + const withCoreRoutes = HttpRouter.empty.pipe( HttpRouter.get( "/health", Effect.gen(function*(_) { @@ -805,7 +795,7 @@ export const makeRouter = () => { ) ) - const withAuth = withUi.pipe( + const withAuth = withCoreRoutes.pipe( HttpRouter.get( "/auth/github/status", Effect.gen(function*(_) { diff --git a/packages/api/src/services/auth-terminal-sessions.ts b/packages/api/src/services/auth-terminal-sessions.ts index 0ffc5781..93d2b007 100644 --- a/packages/api/src/services/auth-terminal-sessions.ts +++ b/packages/api/src/services/auth-terminal-sessions.ts @@ -70,8 +70,8 @@ const resolveCommandLabel = (request: AuthTerminalSessionRequest): string => { const label = request.label?.trim() const suffix = label === undefined || label.length === 0 ? "" : ` [${label}]` return request.flow === "ClaudeOauth" - ? `docker-git menu auth claude oauth${suffix}` - : `docker-git menu auth gemini oauth${suffix}` + ? `Claude Code OAuth${suffix}` + : `Gemini CLI OAuth${suffix}` } const resolveRunnerArgs = (flow: AuthTerminalFlow, label: string | null | undefined): ReadonlyArray => { diff --git a/packages/api/src/ui.ts b/packages/api/src/ui.ts deleted file mode 100644 index 4e85a4b3..00000000 --- a/packages/api/src/ui.ts +++ /dev/null @@ -1,945 +0,0 @@ -export const uiStyles = `@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap"); - -:root { - --bg: #f6f7f9; - --surface: rgba(255, 255, 255, 0.88); - --surface-strong: #ffffff; - --text: #12222f; - --muted: #516574; - --line: rgba(18, 34, 47, 0.14); - --accent: #0466c8; - --accent-2: #0096c7; - --danger: #b42318; - --ok: #117a65; - --shadow: 0 16px 42px rgba(7, 31, 51, 0.12); - --radius: 16px; -} - -* { - box-sizing: border-box; -} - -html, -body { - margin: 0; - padding: 0; - font-family: "Space Grotesk", "Segoe UI", sans-serif; - color: var(--text); - background: - radial-gradient(circle at 10% -10%, rgba(4, 102, 200, 0.18), transparent 45%), - radial-gradient(circle at 90% 0%, rgba(0, 150, 199, 0.16), transparent 40%), - linear-gradient(180deg, #fbfdff 0%, #f4f7fb 100%); -} - -body { - min-height: 100vh; -} - -.app-shell { - width: min(1260px, 100% - 2rem); - margin: 1rem auto 2rem; -} - -.hero { - padding: 1rem 0; -} - -.hero h1 { - margin: 0; - font-size: clamp(1.4rem, 2vw + 0.6rem, 2.2rem); - letter-spacing: 0.01em; -} - -.hero p { - margin: 0.35rem 0 0; - color: var(--muted); -} - -.toolbar { - display: flex; - gap: 0.6rem; - flex-wrap: wrap; - align-items: center; - background: var(--surface); - border: 1px solid var(--line); - border-radius: var(--radius); - box-shadow: var(--shadow); - padding: 0.8rem; -} - -.toolbar input { - flex: 1 1 260px; -} - -.grid { - display: grid; - grid-template-columns: minmax(300px, 0.95fr) minmax(340px, 1.2fr); - gap: 0.9rem; - margin-top: 0.9rem; -} - -.stack { - display: grid; - gap: 0.9rem; -} - -.panel { - background: var(--surface); - border: 1px solid var(--line); - border-radius: var(--radius); - box-shadow: var(--shadow); - overflow: hidden; -} - -.panel-head { - display: flex; - justify-content: space-between; - align-items: center; - gap: 0.6rem; - padding: 0.72rem 0.85rem; - border-bottom: 1px solid var(--line); - background: linear-gradient(135deg, rgba(4, 102, 200, 0.06), rgba(0, 150, 199, 0.04)); -} - -.panel-head h2, -.panel-head h3 { - margin: 0; - font-size: 1rem; -} - -.panel-body { - padding: 0.82rem; -} - -label { - font-size: 0.8rem; - color: var(--muted); - display: block; - margin-bottom: 0.2rem; -} - -input, -select, -textarea, -button { - font: inherit; -} - -input, -select, -textarea { - width: 100%; - border-radius: 10px; - border: 1px solid var(--line); - background: var(--surface-strong); - padding: 0.52rem 0.6rem; - color: var(--text); -} - -textarea, -pre, -code, -.output, -.events { - font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; -} - -textarea { - min-height: 88px; - resize: vertical; -} - -.row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 0.55rem; -} - -.actions { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; -} - -button { - border: 0; - border-radius: 999px; - padding: 0.45rem 0.86rem; - font-weight: 600; - cursor: pointer; - color: #fff; - background: linear-gradient(135deg, var(--accent), var(--accent-2)); -} - -button[data-variant="ghost"] { - color: var(--text); - border: 1px solid var(--line); - background: #fff; -} - -button[data-variant="danger"] { - background: linear-gradient(135deg, #b42318, #da3f34); -} - -button[data-variant="ok"] { - background: linear-gradient(135deg, #0c8a5c, #0d9d68); -} - -button:disabled { - opacity: 0.62; - cursor: not-allowed; -} - -.project-list { - display: grid; - gap: 0.45rem; - max-height: 350px; - overflow: auto; -} - -.project-card { - border: 1px solid var(--line); - border-radius: 12px; - padding: 0.55rem; - background: #fff; -} - -.project-card.active { - border-color: rgba(4, 102, 200, 0.5); - box-shadow: 0 0 0 2px rgba(4, 102, 200, 0.12); -} - -.project-card .top { - display: flex; - justify-content: space-between; - gap: 0.4rem; - align-items: center; -} - -.badge { - border-radius: 999px; - padding: 0.1rem 0.5rem; - font-size: 0.75rem; - border: 1px solid var(--line); - background: #fff; -} - -.badge.running { - color: var(--ok); - border-color: rgba(17, 122, 101, 0.36); - background: rgba(17, 122, 101, 0.1); -} - -.badge.stopped { - color: #9a4d04; - border-color: rgba(154, 77, 4, 0.3); - background: rgba(154, 77, 4, 0.11); -} - -.kv { - display: grid; - grid-template-columns: 170px 1fr; - gap: 0.3rem 0.6rem; - font-size: 0.85rem; -} - -.kv .k { - color: var(--muted); -} - -pre { - margin: 0; - white-space: pre-wrap; - border: 1px solid var(--line); - border-radius: 10px; - background: #f9fcff; - padding: 0.68rem; - max-height: 240px; - overflow: auto; -} - -.events, -.output { - border: 1px solid var(--line); - border-radius: 10px; - background: #0f1a23; - color: #d8e7f5; - padding: 0.68rem; - min-height: 130px; - max-height: 260px; - overflow: auto; - white-space: pre-wrap; - font-size: 0.8rem; -} - -.agents { - display: grid; - gap: 0.6rem; -} - -.agent-item { - border: 1px solid var(--line); - border-radius: 10px; - padding: 0.56rem; - background: #fff; - display: grid; - gap: 0.45rem; -} - -.agent-item .top { - display: flex; - justify-content: space-between; - gap: 0.4rem; - align-items: center; -} - -.small { - font-size: 0.8rem; - color: var(--muted); -} - -.mono { - font-family: "IBM Plex Mono", ui-monospace, monospace; - word-break: break-word; -} - -.checkbox-row { - display: flex; - align-items: center; - gap: 0.45rem; -} - -.checkbox-row input { - width: auto; -} - -@media (max-width: 980px) { - .grid { - grid-template-columns: 1fr; - } - - .row { - grid-template-columns: 1fr; - } - - .kv { - grid-template-columns: 1fr; - } -} -` - -export const uiHtml = ` - - - - - docker-git API Console - - - -
-
-

docker-git API Console

-

UI-обвязка для тестирования v1 API без CLI

-
- -
-
- - -
- - -
- -
-
-
-
-

Создать проект

-
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
-
- - -
-
- - -
-
-
- - - - - - -
-
- -
-
-
- -
-
-

Проекты

- 0 -
-
-
-
-
-
- -
-
-
-

Проект

- not selected -
-
-
- -
- - - - - - -
- - -

-
-              
- - -
- -
-
-
- -
-
-

Агенты

- -
-
-
-
- - -
-
- - -
-
-
- - -
-
- - -
-
- - -
-
- -
- -
-
-
-
-
- -
-
-

Debug output

- -
-
-
-
-
-
- - - - -` - -export const uiScript = ` -(() => { - const state = { - baseUrl: '', - projectId: '', - project: null, - projects: [], - agents: [], - eventSource: null, - eventCursor: 0 - }; - - const byId = (id) => document.getElementById(id); - - const views = { - baseUrl: byId('base-url'), - projectsCount: byId('projects-count'), - projectsList: byId('projects-list'), - activeProjectId: byId('active-project-id'), - projectDetails: byId('project-details'), - projectOutput: byId('project-output'), - eventsLog: byId('events-log'), - debugOutput: byId('debug-output'), - agentProvider: byId('agent-provider'), - agentLabel: byId('agent-label'), - agentCommand: byId('agent-command'), - agentCwd: byId('agent-cwd'), - agentEnv: byId('agent-env'), - agentsList: byId('agents-list'), - createRepoUrl: byId('create-repo-url'), - createRepoRef: byId('create-repo-ref'), - createSshPort: byId('create-ssh-port'), - createNetworkMode: byId('create-network-mode'), - createCpu: byId('create-cpu'), - createRam: byId('create-ram'), - createUp: byId('create-up'), - createForce: byId('create-force'), - createForceEnv: byId('create-force-env') - }; - - const appendDebug = (label, payload) => { - const stamp = new Date().toISOString(); - const line = '[' + stamp + '] ' + label + '\\n' + (typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2)); - views.debugOutput.textContent = (line + '\\n\\n' + views.debugOutput.textContent).slice(0, 24000); - }; - - const normalizeBase = (value) => { - const trimmed = String(value || '').trim(); - if (!trimmed) { - return window.location.origin; - } - return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed; - }; - - const projectPath = (projectId, suffix) => '/projects/' + encodeURIComponent(projectId) + suffix; - - const request = async (path, init) => { - const base = normalizeBase(views.baseUrl.value); - state.baseUrl = base; - const url = base + path; - const response = await fetch(url, init || {}); - const text = await response.text(); - let json = null; - try { - json = text ? JSON.parse(text) : null; - } catch (_error) { - json = { raw: text }; - } - - if (!response.ok) { - appendDebug('HTTP ' + response.status + ' ' + path, json); - throw new Error((json && json.error && json.error.message) || ('HTTP ' + response.status)); - } - - appendDebug('HTTP ' + response.status + ' ' + path, json || text); - return json; - }; - - const setProjectOutput = (value) => { - views.projectOutput.textContent = value || ''; - }; - - const renderProjectDetails = () => { - views.activeProjectId.textContent = state.projectId || 'not selected'; - if (!state.project) { - views.projectDetails.innerHTML = '
Выберите проект слева
'; - return; - } - - const details = [ - ['displayName', state.project.displayName], - ['repo', state.project.repoUrl + ' @ ' + state.project.repoRef], - ['status', state.project.status + ' (' + state.project.statusLabel + ')'], - ['container', state.project.containerName], - ['service', state.project.serviceName], - ['ssh', state.project.sshCommand], - ['targetDir', state.project.targetDir] - ]; - - views.projectDetails.innerHTML = details.map(([k, v]) => '
' + k + '
' + String(v) + '
').join(''); - }; - - const renderProjects = () => { - views.projectsCount.textContent = String(state.projects.length); - if (state.projects.length === 0) { - views.projectsList.innerHTML = '
Проекты не найдены
'; - return; - } - - views.projectsList.innerHTML = state.projects.map((item) => { - const activeClass = item.id === state.projectId ? ' active' : ''; - const badgeClass = item.status === 'running' ? 'running' : (item.status === 'stopped' ? 'stopped' : ''); - return [ - '
', - '
', - '' + item.displayName + '', - '' + item.status + '', - '
', - '
' + item.repoRef + '
', - '
', - '
' - ].join(''); - }).join(''); - - views.projectsList.querySelectorAll('button[data-project-id]').forEach((button) => { - button.addEventListener('click', () => { - selectProject(button.getAttribute('data-project-id') || ''); - }); - }); - }; - - const loadProjects = async () => { - const payload = await request('/projects'); - state.projects = (payload && payload.projects) || []; - renderProjects(); - - if (!state.projectId && state.projects.length > 0) { - await selectProject(state.projects[0].id); - } - }; - - const loadProject = async () => { - if (!state.projectId) { - return; - } - const payload = await request(projectPath(state.projectId, '')); - state.project = payload.project; - renderProjectDetails(); - }; - - const selectProject = async (projectId) => { - if (!projectId) { - return; - } - state.projectId = projectId; - renderProjects(); - await loadProject(); - await loadAgents(); - }; - - const loadAgents = async () => { - if (!state.projectId) { - views.agentsList.innerHTML = '
Сначала выберите проект
'; - return; - } - - const payload = await request(projectPath(state.projectId, '/agents')); - state.agents = (payload && payload.sessions) || []; - renderAgents(); - }; - - const renderAgents = () => { - if (!state.projectId) { - views.agentsList.innerHTML = '
Сначала выберите проект
'; - return; - } - - if (state.agents.length === 0) { - views.agentsList.innerHTML = '
Агенты не запущены
'; - return; - } - - views.agentsList.innerHTML = state.agents.map((agent) => { - return [ - '
', - '
', - '' + agent.label + '', - '' + agent.status + '', - '
', - '
' + agent.id + '
', - '
' + agent.command + '
', - '
', - '', - '', - '', - '
', - '
' - ].join(''); - }).join(''); - - views.agentsList.querySelectorAll('button[data-action]').forEach((button) => { - button.addEventListener('click', async () => { - const action = button.getAttribute('data-action') || ''; - const agentId = button.getAttribute('data-agent-id') || ''; - if (!agentId || !state.projectId) { - return; - } - - if (action === 'stop') { - await request(projectPath(state.projectId, '/agents/' + encodeURIComponent(agentId) + '/stop'), { method: 'POST' }); - await loadAgents(); - return; - } - - if (action === 'logs') { - const payload = await request(projectPath(state.projectId, '/agents/' + encodeURIComponent(agentId) + '/logs?lines=250')); - const lines = (payload.entries || []).map((entry) => entry.at + ' [' + entry.stream + '] ' + entry.line); - setProjectOutput(lines.join('\\n')); - return; - } - - if (action === 'attach') { - const payload = await request(projectPath(state.projectId, '/agents/' + encodeURIComponent(agentId) + '/attach')); - setProjectOutput(JSON.stringify(payload.attach, null, 2)); - } - }); - }); - }; - - const clearEvents = () => { - views.eventsLog.textContent = ''; - }; - - const appendEvent = (event, payload) => { - const line = event + ' ' + JSON.stringify(payload); - views.eventsLog.textContent = (line + '\\n' + views.eventsLog.textContent).slice(0, 24000); - }; - - const stopEventStream = () => { - if (state.eventSource) { - state.eventSource.close(); - state.eventSource = null; - appendEvent('system', { message: 'events stopped' }); - } - }; - - const startEventStream = () => { - if (!state.projectId) { - throw new Error('Выберите проект перед запуском SSE'); - } - - stopEventStream(); - const base = normalizeBase(views.baseUrl.value); - const url = base + projectPath(state.projectId, '/events?cursor=' + state.eventCursor); - const source = new EventSource(url); - state.eventSource = source; - - source.onmessage = (event) => { - if (!event.data) { - return; - } - try { - const payload = JSON.parse(event.data); - if (payload && payload.seq) { - state.eventCursor = payload.seq; - } - appendEvent(event.type || 'message', payload); - } catch (_error) { - appendEvent(event.type || 'message', event.data); - } - }; - - source.addEventListener('snapshot', (event) => { - try { - const payload = JSON.parse(event.data || '{}'); - state.eventCursor = payload.cursor || state.eventCursor; - appendEvent('snapshot', payload); - } catch (_error) { - appendEvent('snapshot', event.data || ''); - } - }); - - source.onerror = () => { - appendEvent('system', { message: 'events connection error' }); - }; - - appendEvent('system', { message: 'events started', url }); - }; - - const actionProject = async (suffix, method) => { - if (!state.projectId) { - throw new Error('Сначала выберите проект'); - } - await request(projectPath(state.projectId, suffix), { method: method || 'POST' }); - await loadProject(); - await loadProjects(); - }; - - const runProjectRead = async (suffix) => { - if (!state.projectId) { - throw new Error('Сначала выберите проект'); - } - const payload = await request(projectPath(state.projectId, suffix)); - setProjectOutput(payload.output || ''); - }; - - const parseEnvLines = (raw) => { - return String(raw || '') - .split(/\\r?\\n/) - .map((line) => line.trim()) - .filter((line) => line.length > 0 && line.includes('=')) - .map((line) => { - const idx = line.indexOf('='); - return { key: line.slice(0, idx).trim(), value: line.slice(idx + 1) }; - }) - .filter((entry) => entry.key.length > 0); - }; - - const createProject = async () => { - const body = { - repoUrl: views.createRepoUrl.value.trim() || undefined, - repoRef: views.createRepoRef.value.trim() || undefined, - sshPort: views.createSshPort.value.trim() || undefined, - cpuLimit: views.createCpu.value.trim() || undefined, - ramLimit: views.createRam.value.trim() || undefined, - dockerNetworkMode: views.createNetworkMode.value.trim() || undefined, - up: views.createUp.checked, - force: views.createForce.checked, - forceEnv: views.createForceEnv.checked, - openSsh: false - }; - - await request('/projects', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body) - }); - - await loadProjects(); - }; - - const createAgent = async () => { - if (!state.projectId) { - throw new Error('Сначала выберите проект'); - } - - const body = { - provider: views.agentProvider.value, - label: views.agentLabel.value.trim() || undefined, - command: views.agentCommand.value.trim() || undefined, - cwd: views.agentCwd.value.trim() || undefined, - env: parseEnvLines(views.agentEnv.value) - }; - - await request(projectPath(state.projectId, '/agents'), { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body) - }); - - await loadAgents(); - }; - - const withUiError = (fn) => async () => { - try { - await fn(); - } catch (error) { - appendDebug('UI error', String(error)); - window.alert(String(error)); - } - }; - - const wireActions = () => { - byId('btn-clear-output').addEventListener('click', () => { - views.debugOutput.textContent = ''; - views.eventsLog.textContent = ''; - views.projectOutput.textContent = ''; - }); - - byId('btn-health').addEventListener('click', withUiError(async () => { - const payload = await request('/health'); - window.alert('Health: ' + JSON.stringify(payload)); - })); - - byId('btn-projects-refresh').addEventListener('click', withUiError(loadProjects)); - byId('btn-create-project').addEventListener('click', withUiError(createProject)); - - byId('btn-up').addEventListener('click', withUiError(() => actionProject('/up', 'POST'))); - byId('btn-down').addEventListener('click', withUiError(() => actionProject('/down', 'POST'))); - byId('btn-recreate').addEventListener('click', withUiError(() => actionProject('/recreate', 'POST'))); - byId('btn-delete').addEventListener('click', withUiError(async () => { - if (!state.projectId) { - throw new Error('Сначала выберите проект'); - } - const ok = window.confirm('Удалить проект ' + state.projectId + '?'); - if (!ok) { - return; - } - await request(projectPath(state.projectId, ''), { method: 'DELETE' }); - stopEventStream(); - state.projectId = ''; - state.project = null; - state.agents = []; - renderProjectDetails(); - renderAgents(); - await loadProjects(); - })); - - byId('btn-ps').addEventListener('click', withUiError(() => runProjectRead('/ps'))); - byId('btn-logs').addEventListener('click', withUiError(() => runProjectRead('/logs'))); - - byId('btn-events-start').addEventListener('click', withUiError(async () => { - clearEvents(); - startEventStream(); - })); - byId('btn-events-stop').addEventListener('click', () => stopEventStream()); - - byId('btn-agent-start').addEventListener('click', withUiError(createAgent)); - byId('btn-agents-refresh').addEventListener('click', withUiError(loadAgents)); - }; - - const bootstrap = async () => { - views.baseUrl.value = window.location.origin; - wireActions(); - renderProjectDetails(); - renderAgents(); - await loadProjects(); - }; - - window.addEventListener('beforeunload', () => stopEventStream()); - - bootstrap().catch((error) => { - appendDebug('bootstrap failure', String(error)); - }); -})(); -` diff --git a/packages/api/tests/ui.test.ts b/packages/api/tests/ui.test.ts deleted file mode 100644 index c876de38..00000000 --- a/packages/api/tests/ui.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from "@effect/vitest" -import { Effect } from "effect" - -import { uiHtml, uiScript, uiStyles } from "../src/ui.js" - -describe("api ui wrapper", () => { - it.effect("contains basic shell and API hooks", () => - Effect.sync(() => { - expect(uiHtml).toContain("docker-git API Console") - expect(uiHtml).toContain("/ui/app.js") - expect(uiScript).toContain("/projects") - expect(uiStyles).toContain(".panel") - })) -}) diff --git a/packages/app/eslint.config.mts b/packages/app/eslint.config.mts index 65095463..be9b8eac 100644 --- a/packages/app/eslint.config.mts +++ b/packages/app/eslint.config.mts @@ -281,8 +281,7 @@ export default defineConfig( }, { files: [ - "src/docker-git/menu-create-shared.ts", - "src/docker-git/menu-render.ts", + "src/web/create-flow.ts", "src/web/actions-projects.ts", "src/web/app-ready-controller.ts", "src/web/app-ready-main-panels.tsx", @@ -328,7 +327,7 @@ export default defineConfig( }, { files: [ - "src/docker-git/menu-create-shared.ts", + "src/web/create-flow.ts", "src/web/app-ready-terminal-screen.tsx", "src/web/panel-content.tsx", "src/web/panel-create-select.tsx", diff --git a/packages/app/eslint/no-lib-imports.mjs b/packages/app/eslint/no-lib-imports.mjs index af0abc36..1c0496f6 100644 --- a/packages/app/eslint/no-lib-imports.mjs +++ b/packages/app/eslint/no-lib-imports.mjs @@ -13,11 +13,9 @@ const isRelativeLocalLibImport = (value) => /^(?:\.\.\/|\.\/)+(?:src\/)?lib(?:\/ /** @param {string | undefined} filePath */ const isFrontendSurfaceFile = (filePath) => { const normalized = (filePath ?? "").replaceAll("\\", "/") - return normalized.startsWith("src/app/") || - normalized.startsWith("src/docker-git/") || + return normalized.startsWith("src/docker-git/") || normalized.startsWith("src/web/") || normalized.startsWith("tests/") || - normalized.includes("/src/app/") || normalized.includes("/src/docker-git/") || normalized.includes("/src/web/") || normalized.includes("/tests/") diff --git a/packages/app/package.json b/packages/app/package.json index 9a19db4c..944c2257 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,7 +1,7 @@ { "name": "@prover-coder-ai/docker-git", "version": "1.1.13", - "description": "docker-git Bun and Gridland CLI plus browser frontend", + "description": "docker-git CLI plus browser frontend", "main": "dist/src/docker-git/main.js", "bin": { "docker-git": "dist/src/docker-git/main.js" @@ -14,11 +14,10 @@ }, "scripts": { "prebuild": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../lib build", - "build": "bun run build:app && bun run build:docker-git", - "build:app": "vite build --ssr src/app/main.ts", + "build": "bun run build:docker-git", "build:web": "vite build --config vite.web.config.ts", "prepack": "bun run build:docker-git", - "dev": "vite build --watch --ssr src/app/main.ts", + "dev": "vite build --config vite.docker-git.config.ts --watch", "dev:web": "vite --config vite.web.config.ts", "serve:web": "bun scripts/serve-dist-web.mjs", "prelint": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../lib build", @@ -74,12 +73,9 @@ "@effect/sql": "^0.51.1", "@effect/typeclass": "^0.40.0", "@effect/workflow": "^0.18.1", - "@gridland/bun": "0.4.3", - "@gridland/web": "0.4.3", "effect": "^3.21.2", "react": "^19.2.6", "react-dom": "^19.2.6", - "react-reconciler": "^0.33.0", "ts-morph": "^28.0.0", "xterm": "^5.3.0", "xterm-addon-fit": "^0.8.0" diff --git a/packages/app/src/app/main.ts b/packages/app/src/app/main.ts deleted file mode 100644 index 3ab2b46a..00000000 --- a/packages/app/src/app/main.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { NodeContext, NodeRuntime } from "@effect/platform-node" -import { Effect, pipe } from "effect" - -import { program } from "./program.js" - -// CHANGE: run the program through the Node platform runtime with its layer -// WHY: ensure effects execute under the platform runtime with proper teardown/logging behavior -// QUOTE(TZ): "\u0414\u0430 \u0434\u0430\u0432\u0430\u0439 \u0442\u0430\u043a \u044d\u0442\u043e \u0431\u043e\u043b\u0435\u0435 \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u0430\u044f \u0440\u0435\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u044f" -// REF: user-2025-12-19-platform-node -// SOURCE: https://effect.website/docs/platform/runtime/ "runMain helps you execute a main effect with built-in error handling, logging, and signal management." -// FORMAT THEOREM: forall args in Argv: decode(args) = v -> runMain(program) -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: program executed with NodeContext.layer -// COMPLEXITY: O(1)/O(1) -const main = pipe(program, Effect.provide(NodeContext.layer)) - -NodeRuntime.runMain(main) diff --git a/packages/app/src/app/program.ts b/packages/app/src/app/program.ts deleted file mode 100644 index 12d48e22..00000000 --- a/packages/app/src/app/program.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Console, Effect, Match, pipe } from "effect" -import { listProjects, renderProjectSummaryLine } from "../docker-git/api-client.js" -import { readCloneRequest, runDockerGitClone, runDockerGitOpen } from "../docker-git/frontend-lib/shell/clone.js" - -/** - * Compose the CLI program as a single effect. - * - * @returns Effect that either runs docker-git clone/open or prints usage. - * - * @pure false - uses Console output and spawns commands when running shortcuts - * @effect Console, CommandExecutor, Path - * @invariant forall args in Argv: shortcut(args) -> docker_git_invoked(args) - * @precondition true - * @postcondition shortcut(args) -> docker_git_invoked(args); otherwise usage printed - * @complexity O(build + shortcut) - * @throws Never - all errors are typed in the Effect error channel - */ -// CHANGE: replace greeting demo with deterministic usage text -// WHY: greeting was scaffolding noise and should not ship in docker-git tooling -// QUOTE(ТЗ): "Можешь удалить использование greting ...? Это старый мусор который остался" -// REF: user-request-2026-02-06-remove-greeting -// SOURCE: n/a -// FORMAT THEOREM: usageText is constant -> deterministic(help) -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: usageText does not depend on argv/env -// COMPLEXITY: O(1) -const usageText = [ - "Usage:", - " bun run docker-git", - " bun run clone [ref]", - " bun run open ", - " bun run list", - "", - "Notes:", - " - docker-git is the interactive TUI.", - " - clone builds + runs docker-git clone for you.", - " - open builds + runs docker-git open for existing projects." -].join("\n") - -// PURITY: SHELL -// EFFECT: Effect -const runHelp = Console.log(usageText) - -// CHANGE: route between shortcut runners and help based on CLI context -// WHY: allow bun run clone/open while keeping a single entrypoint -// QUOTE(ТЗ): "Добавить команду open." -// REF: user-request-2026-01-27 -// SOURCE: n/a -// FORMAT THEOREM: forall argv: shortcut(argv) -> docker_git_invoked(argv) -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: help is printed when shortcut is not requested -// COMPLEXITY: O(build + shortcut) -const runDockerGit = pipe( - readCloneRequest, - Effect.flatMap((request) => - Match.value(request).pipe( - Match.when({ _tag: "Clone" }, ({ args }) => runDockerGitClone(args)), - Match.when({ _tag: "Open" }, ({ args }) => runDockerGitOpen(args)), - Match.when({ _tag: "None" }, () => runHelp), - Match.exhaustive - ) - ) -) - -const readListFlag = Effect.sync(() => { - const command = process.argv.slice(2)[0] ?? "" - return command === "list" || command === "ls" -}) - -export const program = Effect.gen(function*(_) { - const isList = yield* _(readListFlag) - if (isList) { - const projects = yield* _(listProjects()) - if (projects.length === 0) { - yield* _(Console.log("No docker-git projects found.")) - return - } - for (const project of projects) { - yield* _(Console.log(renderProjectSummaryLine(project))) - } - return - } - yield* _(runDockerGit) -}) diff --git a/packages/app/src/docker-git/api-auth-codec.ts b/packages/app/src/docker-git/api-auth-codec.ts index c8ceeb86..0301ad47 100644 --- a/packages/app/src/docker-git/api-auth-codec.ts +++ b/packages/app/src/docker-git/api-auth-codec.ts @@ -1,5 +1,5 @@ import { asObject, asString, type JsonValue } from "./api-json.js" -import type { AuthSnapshot, ProjectAuthSnapshot } from "./menu-types.js" +import type { AuthSnapshot, ProjectAuthSnapshot } from "../web/api-types.js" type RawAuthSnapshot = { readonly globalEnvPath: string | null diff --git a/packages/app/src/docker-git/api-client.ts b/packages/app/src/docker-git/api-client.ts index 4f5f1495..ceabc246 100644 --- a/packages/app/src/docker-git/api-client.ts +++ b/packages/app/src/docker-git/api-client.ts @@ -107,7 +107,7 @@ export const listProjects = () => ) // CHANGE: expose DB-only project details already returned by GET /projects -// WHY: TUI Select/Open must not issue N follow-up project reads for the same `.docker-git` inventory +// WHY: project selection and open flows must not issue N follow-up project reads for the same `.docker-git` inventory // QUOTE(ТЗ): "А должен ходить только в .docker-git папку и читать данные из неё если необходимо" // REF: user-message-2026-04-21-db-only-project-list // SOURCE: n/a diff --git a/packages/app/src/docker-git/cli/parser.ts b/packages/app/src/docker-git/cli/parser.ts index 509b199f..bffa0c08 100644 --- a/packages/app/src/docker-git/cli/parser.ts +++ b/packages/app/src/docker-git/cli/parser.ts @@ -19,7 +19,6 @@ import { usageText } from "./usage.js" const isHelpFlag = (token: string): boolean => token === "--help" || token === "-h" const helpCommand: Command = { _tag: "Help", message: usageText } -const menuCommand: Command = { _tag: "Menu" } const browserCommand: Command = { _tag: "Browser" } const statusCommand: Command = { _tag: "Status" } const downAllCommand: Command = { _tag: "DownAll" } @@ -53,7 +52,7 @@ const parseCreate = (args: ReadonlyArray): Either.Either): Either.Either => { if (args.length === 0) { - return Either.right(menuCommand) + return Either.right(helpCommand) } if (args.some((arg) => isHelpFlag(arg))) { @@ -85,13 +84,10 @@ export const parseArgs = (args: ReadonlyArray): Either.Either Either.right(statusCommand)), Match.when("down-all", () => Either.right(downAllCommand)), Match.when("stop-all", () => Either.right(downAllCommand)), - Match.when("kill-all", () => Either.right(downAllCommand)), - Match.when("menu", () => Either.right(menuCommand)), - Match.when("ui", () => Either.right(menuCommand)) + Match.when("kill-all", () => Either.right(downAllCommand)) ) .pipe( Match.when("browser", () => Either.right(browserCommand)), - Match.when("web", () => Either.right(browserCommand)), Match.when("apply-all", () => parseApplyAll(rest)), Match.when("update-all", () => parseApplyAll(rest)), Match.when("auth", () => parseAuth(rest)), diff --git a/packages/app/src/docker-git/cli/usage.ts b/packages/app/src/docker-git/cli/usage.ts index aef71909..55190676 100644 --- a/packages/app/src/docker-git/cli/usage.ts +++ b/packages/app/src/docker-git/cli/usage.ts @@ -1,7 +1,6 @@ export { formatParseError } from "../frontend-lib/core/parse-errors.js" -export const usageText = `docker-git menu -docker-git browser +export const usageText = `docker-git browser docker-git create [--repo-url ] [options] docker-git clone [options] docker-git open [] [options] @@ -20,7 +19,6 @@ docker-git auth [options] docker-git state [options] Commands: - menu Interactive menu (default when no args) browser Build and serve the browser frontend for the docker-git controller create, init Generate docker development environment (repo URL optional) clone Create + run container and clone repo diff --git a/packages/app/src/docker-git/frontend-lib/core/domain.ts b/packages/app/src/docker-git/frontend-lib/core/domain.ts index a65fee05..c565586a 100644 --- a/packages/app/src/docker-git/frontend-lib/core/domain.ts +++ b/packages/app/src/docker-git/frontend-lib/core/domain.ts @@ -22,8 +22,6 @@ export type { AuthGitlabLogoutCommand, AuthGitlabStatusCommand } from "./auth-domain.js" -export type { MenuAction, ParseError } from "./menu.js" -export { parseMenuSelection } from "./menu.js" export { deriveRepoPathParts, deriveRepoSlug, resolveRepoInput } from "./repo.js" export type { SessionsCommand, @@ -73,6 +71,14 @@ export const sshUserNamePatternDescription = "^[a-z_][a-z0-9_-]{0,31}$" // COMPLEXITY: O(n)/O(1) where n = |value| export const isUnixUserName = (value: string): boolean => unixUserNamePattern.test(value) +export type ParseError = + | { readonly _tag: "UnknownCommand"; readonly command: string } + | { readonly _tag: "UnknownOption"; readonly option: string } + | { readonly _tag: "MissingOptionValue"; readonly option: string } + | { readonly _tag: "MissingRequiredOption"; readonly option: string } + | { readonly _tag: "InvalidOption"; readonly option: string; readonly reason: string } + | { readonly _tag: "UnexpectedArgument"; readonly value: string } + export interface TemplateConfig { readonly containerName: string readonly serviceName: string @@ -127,10 +133,6 @@ export interface CreateCommand { readonly openSsh: boolean } -export interface MenuCommand { - readonly _tag: "Menu" -} - export interface BrowserCommand { readonly _tag: "Browser" } @@ -233,7 +235,6 @@ export type ScrapCommand = export type Command = | CreateCommand - | MenuCommand | BrowserCommand | AttachCommand | OpenCommand diff --git a/packages/app/src/docker-git/frontend-lib/core/menu.ts b/packages/app/src/docker-git/frontend-lib/core/menu.ts deleted file mode 100644 index e6e4f686..00000000 --- a/packages/app/src/docker-git/frontend-lib/core/menu.ts +++ /dev/null @@ -1,113 +0,0 @@ -/* jscpd:ignore-start */ -import { Either } from "effect" - -export type MenuAction = - | { readonly _tag: "Create" } - | { readonly _tag: "Select" } - | { readonly _tag: "Auth" } - | { readonly _tag: "ProjectAuth" } - | { readonly _tag: "Info" } - | { readonly _tag: "Up" } - | { readonly _tag: "Status" } - | { readonly _tag: "Logs" } - | { readonly _tag: "Down" } - | { readonly _tag: "DownAll" } - | { readonly _tag: "Delete" } - | { readonly _tag: "Quit" } - -export type ParseError = - | { readonly _tag: "UnknownCommand"; readonly command: string } - | { readonly _tag: "UnknownOption"; readonly option: string } - | { readonly _tag: "MissingOptionValue"; readonly option: string } - | { readonly _tag: "MissingRequiredOption"; readonly option: string } - | { readonly _tag: "InvalidOption"; readonly option: string; readonly reason: string } - | { readonly _tag: "UnexpectedArgument"; readonly value: string } - -const normalizeMenuInput = (input: string): string => input.trim().toLowerCase() - -const menuAliasMap = new Map([ - ["1", { _tag: "Create" }], - ["create", { _tag: "Create" }], - ["c", { _tag: "Create" }], - ["2", { _tag: "Select" }], - ["select", { _tag: "Select" }], - ["s", { _tag: "Select" }], - ["3", { _tag: "Auth" }], - ["auth", { _tag: "Auth" }], - ["a", { _tag: "Auth" }], - ["4", { _tag: "ProjectAuth" }], - ["project-auth", { _tag: "ProjectAuth" }], - ["projectauth", { _tag: "ProjectAuth" }], - ["pa", { _tag: "ProjectAuth" }], - ["5", { _tag: "Info" }], - ["info", { _tag: "Info" }], - ["i", { _tag: "Info" }], - ["up", { _tag: "Up" }], - ["u", { _tag: "Up" }], - ["start", { _tag: "Up" }], - ["6", { _tag: "Status" }], - ["status", { _tag: "Status" }], - ["ps", { _tag: "Status" }], - ["7", { _tag: "Logs" }], - ["logs", { _tag: "Logs" }], - ["log", { _tag: "Logs" }], - ["l", { _tag: "Logs" }], - ["8", { _tag: "Down" }], - ["down", { _tag: "Down" }], - ["stop", { _tag: "Down" }], - ["d", { _tag: "Down" }], - ["9", { _tag: "DownAll" }], - ["down-all", { _tag: "DownAll" }], - ["downall", { _tag: "DownAll" }], - ["stop-all", { _tag: "DownAll" }], - ["stopall", { _tag: "DownAll" }], - ["kill-all", { _tag: "DownAll" }], - ["killall", { _tag: "DownAll" }], - ["da", { _tag: "DownAll" }], - ["10", { _tag: "Delete" }], - ["delete", { _tag: "Delete" }], - ["del", { _tag: "Delete" }], - ["remove", { _tag: "Delete" }], - ["rm", { _tag: "Delete" }], - ["0", { _tag: "Quit" }], - ["11", { _tag: "Quit" }], - ["quit", { _tag: "Quit" }], - ["q", { _tag: "Quit" }], - ["exit", { _tag: "Quit" }] -]) - -const resolveMenuAction = (normalized: string): MenuAction | undefined => menuAliasMap.get(normalized) - -// CHANGE: decode interactive menu input into a typed action -// WHY: keep menu parsing pure and reusable across shells -// QUOTE(ТЗ): "Хочу что бы открылось менюшка" -// REF: user-request-2026-01-07 -// SOURCE: n/a -// FORMAT THEOREM: forall s: parseMenu(s) = a -> deterministic(a) -// PURITY: CORE -// EFFECT: Effect -// INVARIANT: unknown input maps to InvalidOption -// COMPLEXITY: O(1) -export const parseMenuSelection = (input: string): Either.Either => { - const normalized = normalizeMenuInput(input) - - if (normalized.length === 0) { - return Either.left({ - _tag: "InvalidOption", - option: "menu", - reason: "empty selection" - }) - } - - const action = resolveMenuAction(normalized) - if (action === undefined) { - return Either.left({ - _tag: "InvalidOption", - option: "menu", - reason: `unknown selection: ${input}` - }) - } - - return Either.right(action) -} -/* jscpd:ignore-end */ diff --git a/packages/app/src/docker-git/frontend-lib/shell/terminal-cursor.ts b/packages/app/src/docker-git/frontend-lib/shell/terminal-cursor.ts index 3c66c8c2..b951bc08 100644 --- a/packages/app/src/docker-git/frontend-lib/shell/terminal-cursor.ts +++ b/packages/app/src/docker-git/frontend-lib/shell/terminal-cursor.ts @@ -137,10 +137,10 @@ const restoreSttySnapshot = (snapshot: string): Effect.Effect sane_tty(t) // PURITY: SHELL diff --git a/packages/app/src/docker-git/gridland-bun.d.ts b/packages/app/src/docker-git/gridland-bun.d.ts deleted file mode 100644 index 6d4c51f0..00000000 --- a/packages/app/src/docker-git/gridland-bun.d.ts +++ /dev/null @@ -1,93 +0,0 @@ -declare module "@gridland/bun" { - import type { ComponentType, CSSProperties, ReactNode } from "react" - - type GridlandSize = number | string - type GridlandMaybe = A | undefined - - export type GridlandBoxProps = { - readonly alignItems?: GridlandMaybe - readonly backgroundColor?: GridlandMaybe - readonly border?: GridlandMaybe - readonly borderColor?: GridlandMaybe - readonly borderStyle?: GridlandMaybe<"rounded" | "single"> - readonly children?: ReactNode - readonly color?: GridlandMaybe - readonly flexDirection?: GridlandMaybe - readonly flexGrow?: GridlandMaybe - readonly flexWrap?: GridlandMaybe - readonly gap?: GridlandMaybe - readonly height?: GridlandMaybe - readonly justifyContent?: GridlandMaybe - readonly marginBottom?: GridlandMaybe - readonly marginLeft?: GridlandMaybe - readonly marginRight?: GridlandMaybe - readonly marginTop?: GridlandMaybe - readonly padding?: GridlandMaybe - readonly width?: GridlandMaybe - } - - export type GridlandKeyEvent = { - readonly ctrl?: boolean - readonly meta?: boolean - readonly name?: string - readonly raw?: string - readonly sequence?: string - readonly shift?: boolean - } - - export type GridlandRenderer = { - readonly destroy: () => void - readonly once: (event: string, listener: () => void) => void - readonly start: () => void - } - - export type GridlandRoot = { - readonly render: (node: ReactNode) => void - readonly unmount: () => void - } - - export type GridlandKeyboardOptions = { - readonly focusId?: GridlandMaybe - readonly global?: GridlandMaybe - readonly release?: GridlandMaybe - readonly selectedOnly?: GridlandMaybe - } - - export type GridlandRendererOptions = { - readonly exitOnCtrlC?: GridlandMaybe - readonly useConsole?: GridlandMaybe - readonly useMouse?: GridlandMaybe - } - - export type GridlandInputProps = { - readonly ariaLabel?: GridlandMaybe - readonly autoFocus?: GridlandMaybe - readonly placeholder?: GridlandMaybe - readonly value: string - } - - export type GridlandTextProps = GridlandBoxProps & { - readonly bold?: GridlandMaybe - readonly truncate?: GridlandMaybe - } - - export const Box: ComponentType - export const Input: ComponentType - export const Text: ComponentType - - export const createCliRenderer: (config?: GridlandRendererOptions) => PromiseLike - export const createRoot: (renderer: GridlandRenderer) => GridlandRoot - export const useKeyboard: ( - handler: (key: GridlandKeyEvent) => void, - options?: GridlandKeyboardOptions - ) => void - - export type GridlandModule = { - readonly Box: typeof Box - readonly Input: typeof Input - readonly Text: typeof Text - readonly createCliRenderer: typeof createCliRenderer - readonly createRoot: typeof createRoot - readonly useKeyboard: typeof useKeyboard - } -} diff --git a/packages/app/src/docker-git/menu-actions.ts b/packages/app/src/docker-git/menu-actions.ts deleted file mode 100644 index efd79da2..00000000 --- a/packages/app/src/docker-git/menu-actions.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { Effect, Match, pipe } from "effect" - -import { downAllProjects, downProject, upProject } from "./api-client.js" -import { - listMenuProjectItems, - listMenuRunningProjectItems, - renderMenuProjectLogs, - renderMenuProjectPs, - renderMenuProjectSummaries -} from "./menu-api.js" -import { openAuthMenu } from "./menu-auth.js" -import { startCreateView } from "./menu-create.js" -import type { MenuError } from "./menu-errors.js" -import { renderMenuError } from "./menu-errors.js" -import { openProjectAuthSelection } from "./menu-project-auth.js" -import { loadSelectView } from "./menu-select-load.js" -import { withSuspendedTui, writeErrorAndPause } from "./menu-shared.js" -import { type MenuAction, type MenuEnv, type MenuRunner, type MenuState, type MenuViewContext } from "./menu-types.js" - -// CHANGE: keep menu actions and input parsing in a dedicated module -// WHY: reduce cognitive complexity in the TUI entry -// QUOTE(ТЗ): "TUI? Красивый, удобный" -// REF: user-request-2026-02-01-tui -// SOURCE: n/a -// FORMAT THEOREM: forall a: action(a) -> effect(a) -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: menu selection runs exactly one action -// COMPLEXITY: O(1) per keypress - -export type MenuContext = { - readonly state: MenuState - readonly runner: MenuRunner - readonly exit: () => void -} & MenuViewContext - -export type MenuSelectionContext = MenuContext & { - readonly selected: number - readonly setSelected: (update: (value: number) => number) => void - readonly setSkipInputs: (update: (value: number) => number) => void -} - -const actionLabel = (action: MenuAction): string => - Match.value(action).pipe( - Match.when({ _tag: "Auth" }, () => "Auth profiles"), - Match.when({ _tag: "ProjectAuth" }, () => "Project auth"), - Match.when({ _tag: "Up" }, () => "docker compose up"), - Match.when({ _tag: "Status" }, () => "docker compose ps"), - Match.when({ _tag: "Logs" }, () => "docker compose logs"), - Match.when({ _tag: "Down" }, () => "docker compose down"), - Match.when({ _tag: "DownAll" }, () => "docker compose down (all projects)"), - Match.orElse(() => "action") - ) - -const runWithSuspendedTui = ( - effect: Effect.Effect, - context: MenuContext, - label: string -) => { - context.runner.runEffect( - pipe( - Effect.sync(() => { - context.setMessage(`${label}...`) - }), - Effect.zipRight( - withSuspendedTui(effect, { - onError: (error) => writeErrorAndPause(renderMenuError(error)) - }) - ), - Effect.tap(() => - Effect.sync(() => { - context.setMessage(`${label} finished.`) - }) - ), - Effect.asVoid - ) - ) -} - -const requireActiveProjectId = (context: MenuContext): string | null => { - if (context.state.activeDir !== null) { - return context.state.activeDir - } - - context.setMessage( - "No active project. Use Create or Select project before running this action." - ) - return null -} - -const runCreateAction = (context: MenuContext) => { - startCreateView(context.setView, context.setMessage) -} - -const runSelectAction = (context: MenuContext) => { - context.setMessage(null) - context.runner.runEffect(loadSelectView(listMenuProjectItems, "Connect", context)) -} - -const runAuthProfilesAction = (context: MenuContext) => { - openAuthMenu({ - state: context.state, - runner: context.runner, - setView: context.setView, - setMessage: context.setMessage, - setActiveDir: context.setActiveDir - }) -} - -const runProjectAuthAction = (context: MenuContext) => { - if (context.state.activeDir !== null) { - context.runner.runEffect( - pipe( - listMenuProjectItems, - Effect.flatMap((items) => { - const selected = items.find((item) => item.projectDir === context.state.activeDir) - if (selected === undefined) { - return Effect.sync(() => { - context.setActiveDir(null) - context.setMessage("Active project is no longer available. Select a project again.") - context.runner.runEffect(loadSelectView(listMenuProjectItems, "Auth", context)) - }) - } - return Effect.sync(() => { - openProjectAuthSelection(selected, context) - }) - }) - ) - ) - return - } - - context.setMessage(null) - context.runner.runEffect(loadSelectView(listMenuProjectItems, "Auth", context)) -} - -const runDownAllAction = (context: MenuContext) => { - context.setMessage(null) - runWithSuspendedTui(downAllProjects(), context, "Stopping all docker-git containers") -} - -const runDownAction = (context: MenuContext, action: MenuAction) => { - context.setMessage(null) - if (context.state.activeDir === null) { - context.runner.runEffect(loadSelectView(listMenuRunningProjectItems, "Down", context)) - return - } - - runComposeAction(action, context) -} - -const runInfoAction = (context: MenuContext) => { - context.setMessage(null) - context.runner.runEffect(loadSelectView(listMenuProjectItems, "Info", context)) -} - -const runDeleteAction = (context: MenuContext) => { - context.setMessage(null) - context.runner.runEffect(loadSelectView(listMenuProjectItems, "Delete", context)) -} - -const runComposeAction = (action: MenuAction, context: MenuContext) => { - if (action._tag === "Status" && context.state.activeDir === null) { - runWithSuspendedTui(renderMenuProjectSummaries(), context, "Loading project status") - return - } - - const projectId = requireActiveProjectId(context) - if (projectId === null) { - return - } - - const effect = Match.value(action).pipe( - Match.when({ _tag: "Up" }, () => upProject(projectId)), - Match.when({ _tag: "Status" }, () => renderMenuProjectPs(projectId)), - Match.when({ _tag: "Logs" }, () => renderMenuProjectLogs(projectId)), - Match.when({ _tag: "Down" }, () => downProject(projectId)), - Match.orElse(() => Effect.void) - ) - - runWithSuspendedTui(effect, context, actionLabel(action)) -} - -const runQuitAction = (context: MenuContext) => { - context.setMessage(null) - context.exit() -} - -export const handleMenuActionSelection = (action: MenuAction, context: MenuContext) => { - Match.value(action).pipe( - Match.when({ _tag: "Create" }, () => { - runCreateAction(context) - }), - Match.when({ _tag: "Select" }, () => { - runSelectAction(context) - }), - Match.when({ _tag: "Auth" }, () => { - runAuthProfilesAction(context) - }), - Match.when({ _tag: "ProjectAuth" }, () => { - runProjectAuthAction(context) - }), - Match.when({ _tag: "Info" }, () => { - runInfoAction(context) - }), - Match.when({ _tag: "Delete" }, () => { - runDeleteAction(context) - }), - Match.when({ _tag: "Up" }, (selected) => { - runComposeAction(selected, context) - }), - Match.when({ _tag: "Status" }, (selected) => { - runComposeAction(selected, context) - }), - Match.when({ _tag: "Logs" }, (selected) => { - runComposeAction(selected, context) - }), - Match.when({ _tag: "Down" }, (selected) => { - runDownAction(context, selected) - }), - Match.when({ _tag: "DownAll" }, () => { - runDownAllAction(context) - }), - Match.when({ _tag: "Quit" }, () => { - runQuitAction(context) - }), - Match.exhaustive - ) -} diff --git a/packages/app/src/docker-git/menu-api.ts b/packages/app/src/docker-git/menu-api.ts deleted file mode 100644 index 3a79d7fc..00000000 --- a/packages/app/src/docker-git/menu-api.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Effect, pipe } from "effect" - -import { - deleteProject, - downProject, - githubStatus, - listProjectDetails, - listProjects, - readProjectLogs, - readProjectPs, - renderProjectSummaryLine -} from "./api-client.js" -import { asObject, asString, type JsonValue } from "./api-json.js" -import type { AuthGithubStatusCommand } from "./frontend-lib/core/auth-domain.js" -import type { MenuError } from "./menu-errors.js" -import type { MenuEnv } from "./menu-types.js" -import { type ProjectItem, resolveApiProjectItem } from "./project-item.js" - -const menuGithubStatusCommand: AuthGithubStatusCommand = { - _tag: "AuthGithubStatus", - envGlobalPath: "" -} - -const compact = (values: ReadonlyArray): ReadonlyArray => - values.filter((value): value is A => value !== null) - -const decodeGithubSummary = (payload: JsonValue): string => { - const object = asObject(payload) - const status = asObject(object?.["status"] ?? object) - return asString(status?.["summary"]) ?? "Controller GitHub auth status loaded." -} - -const renderOutput = (label: string, output: string) => - Effect.log(output.trim().length > 0 ? output : `${label}: no output.`) - -const listMenuProjectItemsByStatus = ( - status: "running" | null -): Effect.Effect, MenuError, MenuEnv> => - pipe( - listProjectDetails(), - Effect.map((projects) => - compact( - (status === null ? projects : projects.filter((project) => project.status === status)) - .map((project) => resolveApiProjectItem(project)) - ) - ) - ) - -export const listMenuProjectItems: Effect.Effect, MenuError, MenuEnv> = - listMenuProjectItemsByStatus(null) - -export const listMenuRunningProjectItems: Effect.Effect, MenuError, MenuEnv> = - listMenuProjectItems - -export const renderMenuProjectSummaries = () => - pipe( - listProjects(), - Effect.flatMap((projects) => { - if (projects.length === 0) { - return Effect.log("No docker-git projects found.") - } - - return Effect.forEach(projects, (project) => Effect.log(renderProjectSummaryLine(project)), { - discard: true - }) - }) - ) - -export const deleteMenuProject = (item: ProjectItem) => deleteProject(item.projectDir) - -export const downMenuProject = (item: ProjectItem) => downProject(item.projectDir) - -export const renderMenuProjectPs = (projectId: string) => - pipe(readProjectPs(projectId), Effect.flatMap((output) => renderOutput("docker compose ps", output))) - -export const renderMenuProjectLogs = (projectId: string) => - pipe(readProjectLogs(projectId), Effect.flatMap((output) => renderOutput("docker compose logs", output))) - -export const renderGithubAuthStatusSummary = () => - pipe( - githubStatus(menuGithubStatusCommand), - Effect.map((payload) => decodeGithubSummary(payload)) - ) diff --git a/packages/app/src/docker-git/menu-auth-data.ts b/packages/app/src/docker-git/menu-auth-data.ts deleted file mode 100644 index 6c5e05b0..00000000 --- a/packages/app/src/docker-git/menu-auth-data.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Effect } from "effect" - -import { normalizeOptionalText } from "../shared/optional-text.js" -import { loadAuthSnapshot, runAuthMenuFlow as submitAuthMenuFlow } from "./api-auth-menu-client.js" -import type { AuthEnvFlow } from "./menu-auth-shared.js" -import type { MenuError } from "./menu-errors.js" -import type { AuthSnapshot, MenuEnv } from "./menu-types.js" - -export { - authMenuActionByIndex, - authMenuLabels, - authMenuSize, - authViewSteps, - authViewTitle, - successMessage -} from "./menu-auth-shared.js" -export type { AuthEnvFlow, AuthMenuAction, AuthPromptStep } from "./menu-auth-shared.js" - -const decodeSnapshot = (snapshot: AuthSnapshot | null): Effect.Effect => - snapshot === null - ? Effect.fail({ - _tag: "ApiRequestError", - method: "GET", - path: "/auth/menu", - message: "Controller returned an invalid auth snapshot." - }) - : Effect.succeed(snapshot) - -export const readAuthSnapshot = ( - _cwd: string -): Effect.Effect => - loadAuthSnapshot().pipe(Effect.flatMap((snapshot) => decodeSnapshot(snapshot))) - -export const writeAuthFlow = ( - _cwd: string, - flow: AuthEnvFlow, - values: Readonly> -): Effect.Effect => - submitAuthMenuFlow({ - flow, - label: normalizeOptionalText(values["label"]), - token: normalizeOptionalText(values["token"]), - user: normalizeOptionalText(values["user"]), - apiKey: normalizeOptionalText(values["apiKey"]) - }).pipe( - Effect.flatMap((snapshot) => decodeSnapshot(snapshot)), - Effect.asVoid - ) diff --git a/packages/app/src/docker-git/menu-auth-effects.ts b/packages/app/src/docker-git/menu-auth-effects.ts deleted file mode 100644 index 50152b4c..00000000 --- a/packages/app/src/docker-git/menu-auth-effects.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { Effect, Match, pipe } from "effect" - -import { createAuthTerminalSession, githubLogin } from "./api-client.js" -import { readAuthSnapshot, successMessage, writeAuthFlow } from "./menu-auth-data.js" -import type { MenuError } from "./menu-errors.js" -import type { AuthSnapshot, MenuEnv, MenuRunner, MenuViewContext, ViewState } from "./menu-types.js" -import { attachTerminalSession } from "./terminal-session-client.js" - -type AuthPromptView = Extract - -type AuthEffectContext = MenuViewContext & { - readonly runner: MenuRunner - readonly setSshActive: (active: boolean) => void - readonly setSkipInputs: (update: (value: number) => number) => void - readonly cwd: string -} - -const missingAuthTerminalSessionError = (provider: "ClaudeOauth" | "GeminiOauth"): MenuError => ({ - _tag: "ApiRequestError", - method: "POST", - path: "/auth/terminal-sessions", - message: `Controller did not create a terminal session for ${provider}.` -}) - -const resolveLabelOption = (values: Readonly>): string | null => { - const labelValue = (values["label"] ?? "").trim() - return labelValue.length > 0 ? labelValue : null -} - -const resolveTerminalAuthEffect = ( - provider: "ClaudeOauth" | "GeminiOauth", - labelOption: string | null -): Effect.Effect => - createAuthTerminalSession(provider, labelOption).pipe( - Effect.flatMap((session) => - session === null - ? Effect.fail(missingAuthTerminalSessionError(provider)) - : attachTerminalSession({ - header: provider === "ClaudeOauth" ? "Claude Code OAuth" : "Gemini CLI OAuth", - session, - websocketPath: `/auth/terminal-sessions/${encodeURIComponent(session.id)}/ws` - }) - ) - ) - -export const resolveAuthPromptEffect = ( - view: AuthPromptView, - cwd: string, - values: Readonly> -): Effect.Effect => { - const labelOption = resolveLabelOption(values) - return Match.value(view.flow).pipe( - Match.when("GithubOauth", () => - githubLogin({ - _tag: "AuthGithubLogin", - label: labelOption, - token: null, - scopes: null, - envGlobalPath: view.snapshot.globalEnvPath - }).pipe(Effect.asVoid)), - Match.when("ClaudeOauth", () => resolveTerminalAuthEffect("ClaudeOauth", labelOption)), - Match.when("ClaudeLogout", (flow) => writeAuthFlow(cwd, flow, values)), - Match.when("GeminiOauth", () => resolveTerminalAuthEffect("GeminiOauth", labelOption)), - Match.when("GeminiApiKey", (flow) => writeAuthFlow(cwd, flow, values)), - Match.when("GeminiLogout", (flow) => writeAuthFlow(cwd, flow, values)), - Match.when("GithubRemove", (flow) => writeAuthFlow(cwd, flow, values)), - Match.when("GitSet", (flow) => writeAuthFlow(cwd, flow, values)), - Match.when("GitRemove", (flow) => writeAuthFlow(cwd, flow, values)), - Match.exhaustive - ) -} - -export const startAuthMenuWithSnapshot = ( - snapshot: AuthSnapshot, - context: Pick -): void => { - context.setView({ _tag: "AuthMenu", selected: 0, snapshot }) - context.setMessage(null) -} - -export const runAuthPromptEffect = ( - effect: Effect.Effect, - view: AuthPromptView, - label: string, - context: AuthEffectContext, - options: { readonly suspendTui: boolean } -): void => { - const program = pipe( - effect, - Effect.zipRight(readAuthSnapshot(context.cwd)), - Effect.tap((snapshot) => - Effect.sync(() => { - startAuthMenuWithSnapshot(snapshot, context) - context.setMessage(successMessage(view.flow, label)) - }) - ), - Effect.ensuring( - Effect.sync(() => { - if (options.suspendTui) { - context.setSshActive(false) - context.setSkipInputs(() => 2) - } - }) - ), - Effect.asVoid - ) - - context.setSshActive(options.suspendTui) - if (options.suspendTui) { - context.runner.runInteractiveEffect(program) - return - } - context.runner.runEffect(program) -} diff --git a/packages/app/src/docker-git/menu-auth-helpers.ts b/packages/app/src/docker-git/menu-auth-helpers.ts deleted file mode 100644 index cdd3a4bc..00000000 --- a/packages/app/src/docker-git/menu-auth-helpers.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { PlatformError } from "@effect/platform/Error" -import type * as FileSystem from "@effect/platform/FileSystem" -import type * as Path from "@effect/platform/Path" -import { Effect } from "effect" - -export const countAuthAccountDirectories = ( - fs: FileSystem.FileSystem, - path: Path.Path, - root: string -): Effect.Effect => - Effect.gen(function*(_) { - const exists = yield* _(fs.exists(root)) - if (!exists) { - return 0 - } - const entries = yield* _(fs.readDirectory(root)) - let count = 0 - for (const entry of entries) { - if (entry === ".image") { - continue - } - const fullPath = path.join(root, entry) - const info = yield* _(fs.stat(fullPath)) - if (info.type === "Directory") { - count += 1 - } - } - return count - }) diff --git a/packages/app/src/docker-git/menu-auth-snapshot-builder.ts b/packages/app/src/docker-git/menu-auth-snapshot-builder.ts deleted file mode 100644 index 1f241b4c..00000000 --- a/packages/app/src/docker-git/menu-auth-snapshot-builder.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { PlatformError } from "@effect/platform/Error" -import type * as FileSystem from "@effect/platform/FileSystem" -import type * as Path from "@effect/platform/Path" -import { Effect, pipe } from "effect" - -import { countAuthAccountDirectories } from "./menu-auth-helpers.js" - -export type AuthAccountCounts = { - readonly claudeAuthEntries: number - readonly geminiAuthEntries: number -} - -export const countAuthAccountEntries = ( - fs: FileSystem.FileSystem, - path: Path.Path, - claudeAuthPath: string, - geminiAuthPath: string -): Effect.Effect => - pipe( - Effect.all({ - claudeAuthEntries: countAuthAccountDirectories(fs, path, claudeAuthPath), - geminiAuthEntries: countAuthAccountDirectories(fs, path, geminiAuthPath) - }) - ) diff --git a/packages/app/src/docker-git/menu-auth.ts b/packages/app/src/docker-git/menu-auth.ts deleted file mode 100644 index 314969c0..00000000 --- a/packages/app/src/docker-git/menu-auth.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { Effect, pipe } from "effect" - -import { - type AuthMenuAction, - authMenuActionByIndex, - authMenuSize, - authViewSteps, - readAuthSnapshot -} from "./menu-auth-data.js" -import { resolveAuthPromptEffect, runAuthPromptEffect, startAuthMenuWithSnapshot } from "./menu-auth-effects.js" -import { nextBufferValue } from "./menu-buffer-input.js" -import type { MenuError } from "./menu-errors.js" -import { handleMenuNumberInput, submitPromptStep } from "./menu-input-utils.js" -import { resetToMenu } from "./menu-shared.js" -import type { - AuthFlow, - AuthSnapshot, - MenuEnv, - MenuKeyInput, - MenuRunner, - MenuState, - MenuViewContext, - ViewState -} from "./menu-types.js" - -type AuthContext = MenuViewContext & { - readonly state: MenuState - readonly runner: MenuRunner -} - -type AuthInputContext = AuthContext & { - readonly setSshActive: (active: boolean) => void - readonly setSkipInputs: (update: (value: number) => number) => void -} - -type AuthPromptView = Extract - -const defaultLabel = (value: string): string => { - const trimmed = value.trim() - return trimmed.length > 0 ? trimmed : "default" -} - -const startAuthPrompt = ( - snapshot: AuthSnapshot, - flow: AuthFlow, - context: Pick -) => { - context.setView({ - _tag: "AuthPrompt", - flow, - step: 0, - buffer: "", - values: {}, - snapshot - }) - context.setMessage(null) -} - -const loadAuthMenuView = ( - cwd: string, - context: Pick -): Effect.Effect => - pipe( - readAuthSnapshot(cwd), - Effect.tap((snapshot) => - Effect.sync(() => { - startAuthMenuWithSnapshot(snapshot, context) - }) - ), - Effect.asVoid - ) - -const runAuthAction = ( - action: AuthMenuAction, - view: Extract, - context: AuthContext -) => { - if (action === "Back") { - resetToMenu(context) - return - } - if (action === "Refresh") { - context.runner.runEffect(loadAuthMenuView(context.state.cwd, context)) - return - } - startAuthPrompt(view.snapshot, action, context) -} - -const submitAuthPrompt = (view: AuthPromptView, context: AuthInputContext) => { - const steps = authViewSteps(view.flow) - submitPromptStep( - view, - steps, - context, - () => { - startAuthMenuWithSnapshot(view.snapshot, context) - }, - (nextValues) => { - const label = defaultLabel(nextValues["label"] ?? "") - const effect = resolveAuthPromptEffect(view, context.state.cwd, nextValues) - runAuthPromptEffect(effect, view, label, { ...context, cwd: context.state.cwd }, { - suspendTui: view.flow === "GithubOauth" || view.flow === "ClaudeOauth" || view.flow === "ClaudeLogout" || - view.flow === "GeminiOauth" - }) - } - ) -} - -const setAuthMenuSelection = ( - view: Extract, - selected: number, - context: AuthContext -) => { - context.setView({ - ...view, - selected - }) -} - -const shiftAuthMenuSelection = ( - view: Extract, - delta: number, - context: AuthContext -) => { - const menuSize = authMenuSize() - const selected = (view.selected + delta + menuSize) % menuSize - setAuthMenuSelection(view, selected, context) -} - -const runAuthMenuSelection = ( - selected: number, - view: Extract, - context: AuthContext -) => { - const action = authMenuActionByIndex(selected) - if (action === null) { - return - } - runAuthAction(action, view, context) -} - -const handleAuthMenuNumberInput = ( - input: string, - view: Extract, - context: AuthContext -) => { - handleMenuNumberInput(input, context, authMenuActionByIndex, (action) => { - runAuthAction(action, view, context) - }) -} - -const handleAuthMenuInput = ( - input: string, - key: MenuKeyInput, - view: Extract, - context: AuthContext -) => { - if (key.escape) { - resetToMenu(context) - return - } - if (key.upArrow) { - shiftAuthMenuSelection(view, -1, context) - return - } - if (key.downArrow) { - shiftAuthMenuSelection(view, 1, context) - return - } - if (key.return) { - runAuthMenuSelection(view.selected, view, context) - return - } - handleAuthMenuNumberInput(input, view, context) -} - -type SetAuthPromptBufferArgs = { - readonly input: string - readonly key: MenuKeyInput - readonly view: Extract - readonly context: Pick -} - -const setAuthPromptBuffer = (args: SetAuthPromptBufferArgs) => { - const { context, input, key, view } = args - const nextBuffer = nextBufferValue(input, key, view.buffer) - if (nextBuffer === null) { - return - } - context.setView({ ...view, buffer: nextBuffer }) -} - -const handleAuthPromptInput = ( - input: string, - key: MenuKeyInput, - view: Extract, - context: AuthInputContext -) => { - if (key.escape) { - startAuthMenuWithSnapshot(view.snapshot, context) - return - } - if (key.return) { - submitAuthPrompt(view, context) - return - } - setAuthPromptBuffer({ input, key, view, context }) -} - -export const openAuthMenu = (context: AuthContext): void => { - context.setMessage("Loading auth profiles...") - context.runner.runEffect(loadAuthMenuView(context.state.cwd, context)) -} - -export const handleAuthInput = ( - input: string, - key: MenuKeyInput, - view: Extract, - context: AuthInputContext -) => { - if (view._tag === "AuthMenu") { - handleAuthMenuInput(input, key, view, context) - return - } - handleAuthPromptInput(input, key, view, context) -} diff --git a/packages/app/src/docker-git/menu-create.ts b/packages/app/src/docker-git/menu-create.ts deleted file mode 100644 index 4525f49f..00000000 --- a/packages/app/src/docker-git/menu-create.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { Effect, Either, pipe } from "effect" -import { type CreateCommand } from "./frontend-lib/core/domain.js" - -import { createProject as createProjectViaApi } from "./api-client.js" -import { parseArgs } from "./cli/parser.js" -import { formatParseError, usageText } from "./cli/usage.js" -import type { MenuError } from "./menu-errors.js" - -import { nextBufferValue } from "./menu-buffer-input.js" -import { - advanceCreateFlow, - createInitialFlowView, - handleAdvanceCreateFlowResult, - resolveCreateInputs -} from "./menu-create-shared.js" -import { resetToMenu } from "./menu-shared.js" -import { type CreateInputs, type MenuEnv, type MenuState, type ViewState } from "./menu-types.js" - -// CHANGE: move create-flow handling into a dedicated module -// WHY: keep TUI entry slim and satisfy lint constraints -// QUOTE(ТЗ): "TUI? Красивый, удобный" -// REF: user-request-2026-02-01-tui -// SOURCE: n/a -// FORMAT THEOREM: forall s: step(s) -> step'(s) -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: outDir resolves to a stable repo path -// COMPLEXITY: O(1) per keypress - -type CreateRunner = { readonly runEffect: (effect: Effect.Effect) => void } - -type CreateContext = { - readonly state: MenuState - readonly setView: (view: ViewState) => void - readonly setMessage: (message: string | null) => void - readonly runner: CreateRunner - readonly setActiveDir: (dir: string | null) => void -} - -type CreateReturnContext = CreateContext & { - readonly view: Extract -} - -type OptionalCreateArg = { - readonly value: string - readonly args: readonly [string, string] -} - -const optionalCreateArgs = (input: CreateInputs): ReadonlyArray => [ - { value: input.repoUrl, args: ["--repo-url", input.repoUrl] }, - { value: input.repoRef, args: ["--repo-ref", input.repoRef] }, - { value: input.outDir, args: ["--out-dir", input.outDir] }, - { value: input.cpuLimit, args: ["--cpu", input.cpuLimit] }, - { value: input.ramLimit, args: ["--ram", input.ramLimit] }, - { value: input.gpu === "all" ? input.gpu : "", args: ["--gpu", input.gpu] } -] - -const booleanCreateFlags = (input: CreateInputs): ReadonlyArray => - [ - input.runUp ? null : "--no-up", - input.enableMcpPlaywright ? "--mcp-playwright" : null, - input.force ? "--force" : null, - input.forceEnv ? "--force-env" : null - ].filter((value): value is string => value !== null) - -export const buildCreateArgs = (input: CreateInputs): ReadonlyArray => { - const args: Array = ["create"] - - for (const spec of optionalCreateArgs(input)) { - if (spec.value.length > 0) { - args.push(spec.args[0], spec.args[1]) - } - } - - for (const flag of booleanCreateFlags(input)) { - args.push(flag) - } - return args -} - -const applyCreateCommand = ( - state: MenuState, - create: CreateCommand -): Effect.Effect<{ readonly _tag: "Continue"; readonly state: MenuState }, MenuError, MenuEnv> => - Effect.gen(function*(_) { - const project = yield* _(createProjectViaApi(create)) - return { - _tag: "Continue", - state: { ...state, activeDir: project?.projectDir ?? create.outDir } - } - }) - -const isCreateCommand = (command: { readonly _tag: string }): command is CreateCommand => command._tag === "Create" - -const buildCreateEffect = ( - command: { readonly _tag: string }, - state: MenuState, - setActiveDir: (dir: string | null) => void, - setMessage: (message: string | null) => void -): Effect.Effect => { - if (isCreateCommand(command)) { - return pipe( - applyCreateCommand(state, command), - Effect.tap((outcome) => - Effect.sync(() => { - setActiveDir(outcome.state.activeDir) - }) - ), - Effect.asVoid - ) - } - if (command._tag === "Help") { - return Effect.sync(() => { - setMessage(usageText) - }) - } - return Effect.void -} - -const finalizeCreateFlow = (input: { - readonly state: MenuState - readonly nextValues: Partial - readonly setView: (view: ViewState) => void - readonly setMessage: (message: string | null) => void - readonly runner: CreateRunner - readonly setActiveDir: (dir: string | null) => void -}) => { - const inputs = resolveCreateInputs(input.state.cwd, input.nextValues) - const parsed = parseArgs(buildCreateArgs(inputs)) - if (Either.isLeft(parsed)) { - input.setMessage(formatParseError(parsed.left)) - input.setView({ _tag: "Menu" }) - return - } - - const effect = buildCreateEffect(parsed.right, input.state, input.setActiveDir, input.setMessage) - input.runner.runEffect(effect) - input.setView({ _tag: "Menu" }) - input.setMessage(null) -} - -const handleCreateReturn = ( - context: CreateReturnContext, - quickCreate = false -) => { - const next = advanceCreateFlow(context.state.cwd, context.view, { quickCreate }) - handleAdvanceCreateFlowResult(next, { - onComplete: (inputs) => { - finalizeCreateFlow({ - state: context.state, - nextValues: inputs, - setView: context.setView, - setMessage: context.setMessage, - runner: context.runner, - setActiveDir: context.setActiveDir - }) - }, - onContinue: (view) => { - context.setView({ _tag: "Create", ...view }) - context.setMessage(null) - }, - onError: (error) => { - context.setMessage(formatParseError(error)) - } - }) -} - -export const startCreateView = ( - setView: (view: ViewState) => void, - setMessage: (message: string | null) => void, - buffer = "" -) => { - setView({ _tag: "Create", ...createInitialFlowView(buffer) }) - setMessage(null) -} - -export const handleCreateInput = ( - input: string, - key: { - readonly escape?: boolean - readonly return?: boolean - readonly shift?: boolean - readonly backspace?: boolean - readonly delete?: boolean - }, - view: Extract, - context: CreateContext -) => { - if (key.escape) { - resetToMenu(context) - return - } - if (key.return) { - handleCreateReturn({ ...context, view }, key.shift === true) - return - } - const nextBuffer = nextBufferValue(input, key, view.buffer) - if (nextBuffer !== null) { - context.setView({ ...view, buffer: nextBuffer }) - } -} - -export { resolveCreateInputs } from "./menu-create-shared.js" diff --git a/packages/app/src/docker-git/menu-errors.ts b/packages/app/src/docker-git/menu-errors.ts deleted file mode 100644 index b9d72727..00000000 --- a/packages/app/src/docker-git/menu-errors.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { HostError } from "./host-errors.js" -import { renderCliError } from "./host-errors.js" - -export type MenuError = HostError - -export const renderMenuError = (error: MenuError): string => renderCliError(error) diff --git a/packages/app/src/docker-git/menu-gridland-runtime.tsx b/packages/app/src/docker-git/menu-gridland-runtime.tsx deleted file mode 100644 index ba031682..00000000 --- a/packages/app/src/docker-git/menu-gridland-runtime.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import { Effect, pipe } from "effect" -import React, { useMemo } from "react" - -import type { GridlandKeyEvent, GridlandModule, GridlandRenderer } from "@gridland/bun" - -import { createGridlandPrimitives } from "../ui/primitives-gridland.js" -import { UiProvider } from "../ui/primitives.js" -import { handleUserInput, type MenuInputContext } from "./menu-input-handler.js" - -type InputReadError = { - readonly _tag: "InputReadError" - readonly message: string -} - -const blockedInputNames = new Set([ - "backspace", - "del", - "delete", - "down", - "enter", - "escape", - "pagedown", - "pageup", - "return", - "tab", - "up" -]) - -const isBlockedInputName = (name: string | undefined): boolean => blockedInputNames.has(name ?? "") - -const resolveSequencedKeyboardInput = (event: GridlandKeyEvent): string | null => { - if (typeof event.sequence !== "string" || event.sequence.length === 0 || isBlockedInputName(event.name)) { - return null - } - return event.sequence -} - -const resolveNamedKeyboardInput = (event: GridlandKeyEvent): string | null => { - if (typeof event.name !== "string" || event.name.length !== 1 || isBlockedInputName(event.name)) { - return null - } - return event.name -} - -const resolveKeyboardInput = (event: GridlandKeyEvent): string => { - if (event.ctrl || event.meta) { - return "" - } - return resolveSequencedKeyboardInput(event) ?? resolveNamedKeyboardInput(event) ?? "" -} - -const toMenuKeyInput = (event: GridlandKeyEvent) => { - const name = event.name - return { - backspace: name === "backspace", - delete: name === "delete" || name === "del", - downArrow: name === "down", - escape: name === "escape", - return: name === "enter" || name === "return", - shift: event.shift === true, - upArrow: name === "up" - } as const -} - -const toInputReadError = (error: Error | string): InputReadError => ({ - _tag: "InputReadError", - message: error instanceof Error ? error.message : error -}) - -const waitForRendererDestroy = (renderer: GridlandRenderer): Effect.Effect => - Effect.async((resume) => { - renderer.once("destroy", () => { - resume(Effect.void) - }) - }) - -const loadGridlandModule = (): Effect.Effect => - Effect.tryPromise({ - try: () => import("@gridland/bun"), - catch: (error) => toInputReadError(error instanceof Error ? error : String(error)) - }) - -const createGridlandRenderer = (gridland: GridlandModule): Effect.Effect => - Effect.tryPromise({ - try: () => - gridland.createCliRenderer({ - exitOnCtrlC: false, - useConsole: false, - useMouse: false - }), - catch: (error) => toInputReadError(error instanceof Error ? error : String(error)) - }) - -type GridlandAppFactory = (args: { - readonly exit: () => void - readonly gridland: GridlandModule -}) => React.ReactElement - -const runEmbeddedGridlandMenu = (renderApp: GridlandAppFactory): Effect.Effect => - Effect.gen(function*() { - const gridland = yield* loadGridlandModule() - const renderer = yield* createGridlandRenderer(gridland) - const root = gridland.createRoot(renderer) - let exiting = false - - const exit = () => { - if (exiting) { - return - } - exiting = true - root.unmount() - renderer.destroy() - } - - root.render( - renderApp({ - exit, - gridland - }) - ) - - renderer.start() - yield* pipe( - waitForRendererDestroy(renderer), - Effect.ensuring( - Effect.sync(() => { - if (!exiting) { - root.unmount() - } - }) - ) - ) - }) - -export const runGridlandMenu = (renderApp: GridlandAppFactory): Effect.Effect => - runEmbeddedGridlandMenu(renderApp) - -type GridlandMenuRuntimeContext = - & Pick< - MenuInputContext, - | "busy" - | "exit" - | "inputStage" - | "runner" - | "selected" - | "setActiveDir" - | "setInputStage" - | "setMessage" - | "setSelected" - | "setSkipInputs" - | "setSshActive" - | "setView" - | "sshActive" - | "state" - | "view" - > - & { - readonly ignoreUntil: number - readonly ready: boolean - readonly skipInputs: number - } - -const shouldIgnoreKeyEvent = (context: GridlandMenuRuntimeContext): boolean => - !context.ready || Date.now() < context.ignoreUntil - -const shouldConsumeSkippedInput = (context: GridlandMenuRuntimeContext): boolean => context.skipInputs > 0 - -const consumeSkippedInput = (context: GridlandMenuRuntimeContext): void => { - context.setSkipInputs((value) => (value > 0 ? value - 1 : 0)) -} - -const handleCtrlC = (event: GridlandKeyEvent, context: GridlandMenuRuntimeContext): boolean => { - if (!(event.ctrl && event.name === "c")) { - return false - } - if (!context.sshActive) { - context.exit() - } - return true -} - -export const useGridlandMenuInput = (gridland: GridlandModule, context: GridlandMenuRuntimeContext): void => { - gridland.useKeyboard((event) => { - if (handleCtrlC(event, context) || shouldIgnoreKeyEvent(context)) { - return - } - if (shouldConsumeSkippedInput(context)) { - consumeSkippedInput(context) - return - } - handleUserInput(resolveKeyboardInput(event), toMenuKeyInput(event), context) - }) -} - -export const GridlandMenuProvider = ( - { - children, - gridland - }: { - readonly children: React.ReactNode - readonly gridland: GridlandModule - } -): React.ReactElement => { - const primitives = useMemo(() => createGridlandPrimitives(gridland), [gridland]) - return React.createElement(UiProvider, { primitives }, children) -} diff --git a/packages/app/src/docker-git/menu-input-handler.ts b/packages/app/src/docker-git/menu-input-handler.ts deleted file mode 100644 index 6bd6695a..00000000 --- a/packages/app/src/docker-git/menu-input-handler.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { handleAuthInput } from "./menu-auth.js" -import { handleCreateInput } from "./menu-create.js" -import { handleMenuInput } from "./menu-menu.js" -import { handleProjectAuthInput } from "./menu-project-auth.js" -import { handleSelectInput } from "./menu-select.js" -import type { MenuKeyInput, MenuRunner, MenuState, MenuViewContext, ViewState } from "./menu-types.js" - -export type InputStage = "cold" | "active" - -export type MenuInputContext = MenuViewContext & { - readonly busy: boolean - readonly view: ViewState - readonly inputStage: InputStage - readonly setInputStage: (stage: InputStage) => void - readonly selected: number - readonly setSelected: (update: (value: number) => number) => void - readonly setSkipInputs: (update: (value: number) => number) => void - readonly sshActive: boolean - readonly setSshActive: (active: boolean) => void - readonly state: MenuState - readonly runner: MenuRunner - readonly exit: () => void -} - -type ActiveView = Exclude - -const activateInput = ( - input: string, - key: Pick, - context: Pick -): { readonly activated: boolean; readonly allowProcessing: boolean } => { - if (context.inputStage === "active") { - return { activated: false, allowProcessing: true } - } - - const normalized = input.trim() - const hasMenuInput = normalized.length > 0 || input.length > 0 - const hasMenuActionKey = key.upArrow || key.downArrow || key.return - if (hasMenuInput || hasMenuActionKey) { - context.setInputStage("active") - return { activated: true, allowProcessing: true } - } - - return { activated: false, allowProcessing: false } -} - -const shouldHandleMenuInput = ( - input: string, - key: Pick, - context: Pick -): boolean => { - const activation = activateInput(input, key, context) - if (activation.activated && !activation.allowProcessing) { - return false - } - return activation.allowProcessing -} - -const handleMenuViewInput = ( - input: string, - key: MenuKeyInput, - context: MenuInputContext -) => { - if (!shouldHandleMenuInput(input, key, context)) { - return - } - handleMenuInput(input, key, { - selected: context.selected, - setSelected: context.setSelected, - setSkipInputs: context.setSkipInputs, - state: context.state, - runner: context.runner, - exit: context.exit, - setView: context.setView, - setMessage: context.setMessage, - setActiveDir: context.setActiveDir - }) -} - -const handleCreateViewInput = ( - input: string, - key: MenuKeyInput, - view: Extract, - context: MenuInputContext -) => { - handleCreateInput(input, key, view, { - state: context.state, - setView: context.setView, - setMessage: context.setMessage, - runner: context.runner, - setActiveDir: context.setActiveDir - }) -} - -const handleAuthViewInput = ( - input: string, - key: MenuKeyInput, - view: Extract, - context: MenuInputContext -) => { - handleAuthInput(input, key, view, { - state: context.state, - setView: context.setView, - setMessage: context.setMessage, - setActiveDir: context.setActiveDir, - runner: context.runner, - setSshActive: context.setSshActive, - setSkipInputs: context.setSkipInputs - }) -} - -const handleProjectAuthViewInput = ( - input: string, - key: MenuKeyInput, - view: Extract, - context: MenuInputContext -) => { - handleProjectAuthInput(input, key, view, { - runner: context.runner, - setView: context.setView, - setMessage: context.setMessage, - setActiveDir: context.setActiveDir - }) -} - -const handleSelectViewInput = ( - input: string, - key: MenuKeyInput, - view: Extract, - context: MenuInputContext -) => { - handleSelectInput(input, key, view, { - setView: context.setView, - setMessage: context.setMessage, - setActiveDir: context.setActiveDir, - activeDir: context.state.activeDir, - runner: context.runner, - setSshActive: context.setSshActive, - setSkipInputs: context.setSkipInputs - }) -} - -const handleActiveViewInput = ( - input: string, - key: MenuKeyInput, - view: ActiveView, - context: MenuInputContext -) => { - if (view._tag === "Create") { - handleCreateViewInput(input, key, view, context) - return - } - if (view._tag === "AuthMenu" || view._tag === "AuthPrompt") { - handleAuthViewInput(input, key, view, context) - return - } - if (view._tag === "ProjectAuthMenu" || view._tag === "ProjectAuthPrompt") { - handleProjectAuthViewInput(input, key, view, context) - return - } - handleSelectViewInput(input, key, view, context) -} - -export const handleUserInput = ( - input: string, - key: MenuKeyInput, - context: MenuInputContext -) => { - if (context.busy || context.sshActive) { - return - } - if (context.view._tag === "Menu") { - handleMenuViewInput(input, key, context) - return - } - handleActiveViewInput(input, key, context.view, context) -} diff --git a/packages/app/src/docker-git/menu-input-utils.ts b/packages/app/src/docker-git/menu-input-utils.ts deleted file mode 100644 index 3aa40c24..00000000 --- a/packages/app/src/docker-git/menu-input-utils.ts +++ /dev/null @@ -1,85 +0,0 @@ -export const parseMenuIndex = (input: string): number | null => { - const trimmed = input.trim() - if (trimmed.length === 0) { - return null - } - const parsed = Number(trimmed) - if (!Number.isInteger(parsed)) { - return null - } - const index = parsed - 1 - return index >= 0 ? index : null -} - -type PromptStep = { - readonly key: string - readonly label: string - readonly required: boolean -} - -type PromptView = { - readonly step: number - readonly buffer: string - readonly values: Readonly> -} - -type PromptContext = { - readonly setView: (view: V) => void - readonly setMessage: (message: string | null) => void -} - -export const submitPromptStep = ( - view: V, - steps: ReadonlyArray, - context: PromptContext, - onCancel: () => void, - onSubmit: (values: Readonly>) => void -): void => { - const step = steps[view.step] - if (!step) { - onCancel() - return - } - - const value = view.buffer.trim() - if (step.required && value.length === 0) { - context.setMessage(`${step.label} is required.`) - return - } - - const nextValues: Readonly> = { ...view.values, [step.key]: value } - const nextStep = view.step + 1 - if (nextStep < steps.length) { - context.setView({ ...view, step: nextStep, buffer: "", values: nextValues }) - context.setMessage(null) - return - } - - onSubmit(nextValues) -} - -type MenuNumberInputContext = { - readonly setMessage: (message: string | null) => void -} - -export const handleMenuNumberInput = ( - input: string, - context: MenuNumberInputContext, - actionByIndex: (index: number) => A | null, - runAction: (action: A) => void -): void => { - const index = parseMenuIndex(input) - if (index === null) { - if (input.trim().length > 0) { - context.setMessage("Use arrows + Enter, or type a number from the list.") - } - return - } - - const action = actionByIndex(index) - if (action === null) { - context.setMessage(`Unknown action: ${input.trim()}`) - return - } - runAction(action) -} diff --git a/packages/app/src/docker-git/menu-input.ts b/packages/app/src/docker-git/menu-input.ts deleted file mode 100644 index 4c154067..00000000 --- a/packages/app/src/docker-git/menu-input.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { resolveCreateInputs } from "./menu-create-shared.js" -export { buildCreateArgs, handleCreateInput, startCreateView } from "./menu-create.js" -export { handleMenuInput } from "./menu-menu.js" diff --git a/packages/app/src/docker-git/menu-labeled-env.ts b/packages/app/src/docker-git/menu-labeled-env.ts deleted file mode 100644 index 1bb861ea..00000000 --- a/packages/app/src/docker-git/menu-labeled-env.ts +++ /dev/null @@ -1,50 +0,0 @@ -type EnvEntry = { - readonly key: string - readonly value: string -} - -const parseEnvEntries = (input: string): ReadonlyArray => { - const entries: Array = [] - for (const rawLine of input.replaceAll("\r\n", "\n").replaceAll("\r", "\n").split("\n")) { - const line = rawLine.trim() - if (line.length === 0 || line.startsWith("#")) { - continue - } - const normalized = line.startsWith("export ") ? line.slice("export ".length).trimStart() : line - const equalsIndex = normalized.indexOf("=") - if (equalsIndex <= 0) { - continue - } - const key = normalized.slice(0, equalsIndex).trim() - const value = normalized.slice(equalsIndex + 1).trim() - if (key.length === 0) { - continue - } - entries.push({ key, value }) - } - return entries -} - -export const normalizeLabel = (value: string): string => { - const parts = value - .trim() - .toUpperCase() - .split(/[^A-Z0-9]+/u) - .filter((part) => part.length > 0) - return parts.join("_") -} - -export const buildLabeledEnvKey = (baseKey: string, label: string): string => { - const normalized = normalizeLabel(label) - if (normalized.length === 0 || normalized === "DEFAULT") { - return baseKey - } - return `${baseKey}__${normalized}` -} - -export const countKeyEntries = (envText: string, baseKey: string): number => { - const prefix = `${baseKey}__` - return parseEnvEntries(envText) - .filter((entry) => entry.value.trim().length > 0 && (entry.key === baseKey || entry.key.startsWith(prefix))) - .length -} diff --git a/packages/app/src/docker-git/menu-menu.ts b/packages/app/src/docker-git/menu-menu.ts deleted file mode 100644 index 021ab4c2..00000000 --- a/packages/app/src/docker-git/menu-menu.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { Either } from "effect" - -import { handleMenuActionSelection, type MenuSelectionContext } from "./menu-actions.js" -import { startCreateView } from "./menu-create.js" -import { type MenuAction, menuItems } from "./menu-types.js" - -const isRepoUrlInput = (input: string): boolean => { - const trimmed = input.trim().toLowerCase() - return trimmed.startsWith("http://") || - trimmed.startsWith("https://") || - trimmed.startsWith("ssh://") || - trimmed.startsWith("git@") -} - -const menuAliasMap = new Map([ - ["1", { _tag: "Create" }], - ["create", { _tag: "Create" }], - ["c", { _tag: "Create" }], - ["2", { _tag: "Select" }], - ["select", { _tag: "Select" }], - ["s", { _tag: "Select" }], - ["3", { _tag: "Auth" }], - ["auth", { _tag: "Auth" }], - ["a", { _tag: "Auth" }], - ["4", { _tag: "ProjectAuth" }], - ["project-auth", { _tag: "ProjectAuth" }], - ["projectauth", { _tag: "ProjectAuth" }], - ["pa", { _tag: "ProjectAuth" }], - ["5", { _tag: "Info" }], - ["info", { _tag: "Info" }], - ["i", { _tag: "Info" }], - ["6", { _tag: "Up" }], - ["up", { _tag: "Up" }], - ["u", { _tag: "Up" }], - ["start", { _tag: "Up" }], - ["7", { _tag: "Status" }], - ["status", { _tag: "Status" }], - ["ps", { _tag: "Status" }], - ["8", { _tag: "Logs" }], - ["logs", { _tag: "Logs" }], - ["log", { _tag: "Logs" }], - ["l", { _tag: "Logs" }], - ["9", { _tag: "Down" }], - ["down", { _tag: "Down" }], - ["stop", { _tag: "Down" }], - ["d", { _tag: "Down" }], - ["10", { _tag: "DownAll" }], - ["down-all", { _tag: "DownAll" }], - ["downall", { _tag: "DownAll" }], - ["stop-all", { _tag: "DownAll" }], - ["stopall", { _tag: "DownAll" }], - ["kill-all", { _tag: "DownAll" }], - ["killall", { _tag: "DownAll" }], - ["da", { _tag: "DownAll" }], - ["11", { _tag: "Delete" }], - ["delete", { _tag: "Delete" }], - ["del", { _tag: "Delete" }], - ["remove", { _tag: "Delete" }], - ["rm", { _tag: "Delete" }], - ["0", { _tag: "Quit" }], - ["12", { _tag: "Quit" }], - ["quit", { _tag: "Quit" }], - ["q", { _tag: "Quit" }], - ["exit", { _tag: "Quit" }] -]) - -const parseMenuSelection = ( - input: string -): Either.Either => { - const normalized = input.trim().toLowerCase() - if (normalized.length === 0) { - return Either.left({ _tag: "InvalidOption", option: "menu", reason: "empty selection" }) - } - - const action = menuAliasMap.get(normalized) - return action === undefined - ? Either.left({ _tag: "InvalidOption", option: "menu", reason: `unknown selection: ${input}` }) - : Either.right(action) -} - -const handleMenuNavigation = ( - key: { readonly upArrow?: boolean; readonly downArrow?: boolean }, - setSelected: (update: (value: number) => number) => void -) => { - if (key.upArrow) { - setSelected((prev) => (prev === 0 ? menuItems.length - 1 : prev - 1)) - return - } - if (key.downArrow) { - setSelected((prev) => (prev === menuItems.length - 1 ? 0 : prev + 1)) - } -} - -const handleMenuEnter = (context: MenuSelectionContext) => { - const action = menuItems[context.selected]?.id - if (!action) { - return - } - handleMenuActionSelection(action, context) -} - -const handleMenuTextInput = (input: string, context: MenuSelectionContext): boolean => { - const trimmed = input.trim() - if (trimmed.length > 0 && isRepoUrlInput(trimmed)) { - context.setSkipInputs(() => 1) - startCreateView(context.setView, context.setMessage, trimmed) - return true - } - const selection = parseMenuSelection(input) - if (Either.isRight(selection)) { - context.setSkipInputs(() => 1) - handleMenuActionSelection(selection.right, context) - return true - } - return false -} - -export const handleMenuInput = ( - input: string, - key: { readonly upArrow?: boolean; readonly downArrow?: boolean; readonly return?: boolean }, - context: MenuSelectionContext -) => { - if (key.upArrow || key.downArrow) { - handleMenuNavigation(key, context.setSelected) - return - } - if (key.return) { - handleMenuEnter(context) - return - } - handleMenuTextInput(input, context) -} diff --git a/packages/app/src/docker-git/menu-project-auth-claude.ts b/packages/app/src/docker-git/menu-project-auth-claude.ts deleted file mode 100644 index c25e26c0..00000000 --- a/packages/app/src/docker-git/menu-project-auth-claude.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { PlatformError } from "@effect/platform/Error" -import type * as FileSystem from "@effect/platform/FileSystem" -import { Effect } from "effect" - -import { hasFileAtPath } from "./menu-project-auth-helpers.js" - -const oauthTokenFileName = ".oauth-token" -const legacyConfigFileName = ".config.json" -const credentialsFileName = ".credentials.json" -const nestedCredentialsFileName = ".claude/.credentials.json" - -const hasNonEmptyOauthToken = ( - fs: FileSystem.FileSystem, - tokenPath: string -): Effect.Effect => - Effect.gen(function*(_) { - const hasFile = yield* _(hasFileAtPath(fs, tokenPath)) - if (!hasFile) { - return false - } - const tokenValue = yield* _(fs.readFileString(tokenPath), Effect.orElseSucceed(() => "")) - return tokenValue.trim().length > 0 - }) - -const hasLegacyClaudeAuthFile = ( - fs: FileSystem.FileSystem, - accountPath: string -): Effect.Effect => - Effect.gen(function*(_) { - const entries = yield* _(fs.readDirectory(accountPath)) - for (const entry of entries) { - if (!entry.startsWith(".claude") || !entry.endsWith(".json")) { - continue - } - const isFile = yield* _(hasFileAtPath(fs, `${accountPath}/${entry}`)) - if (isFile) { - return true - } - } - return false - }) - -export const hasClaudeAccountCredentials = ( - fs: FileSystem.FileSystem, - accountPath: string -): Effect.Effect => - hasFileAtPath(fs, `${accountPath}/${credentialsFileName}`).pipe( - Effect.flatMap((hasCredentialsFile) => { - if (hasCredentialsFile) { - return Effect.succeed(true) - } - return hasFileAtPath(fs, `${accountPath}/${nestedCredentialsFileName}`) - }), - Effect.flatMap((hasNestedCredentialsFile) => { - if (hasNestedCredentialsFile) { - return Effect.succeed(true) - } - return hasFileAtPath(fs, `${accountPath}/${legacyConfigFileName}`) - }), - Effect.flatMap((hasConfig) => { - if (hasConfig) { - return Effect.succeed(true) - } - return hasNonEmptyOauthToken(fs, `${accountPath}/${oauthTokenFileName}`).pipe( - Effect.flatMap((hasOauthToken) => { - if (hasOauthToken) { - return Effect.succeed(true) - } - return hasLegacyClaudeAuthFile(fs, accountPath) - }) - ) - }) - ) diff --git a/packages/app/src/docker-git/menu-project-auth-data.ts b/packages/app/src/docker-git/menu-project-auth-data.ts deleted file mode 100644 index 08852e4a..00000000 --- a/packages/app/src/docker-git/menu-project-auth-data.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Effect } from "effect" - -import { normalizeOptionalText } from "../shared/optional-text.js" -import { loadProjectAuthSnapshot, runProjectAuthFlow as submitProjectAuthFlow } from "./api-auth-menu-client.js" -import type { MenuError } from "./menu-errors.js" -import type { MenuEnv, ProjectAuthFlow, ProjectAuthSnapshot } from "./menu-types.js" -import type { ProjectItem } from "./project-item.js" - -export { - projectAuthMenuActionByIndex, - projectAuthMenuLabels, - projectAuthMenuSize, - projectAuthSuccessMessage, - projectAuthViewSteps -} from "./menu-project-auth-shared.js" -export type { ProjectAuthMenuAction, ProjectAuthPromptStep } from "./menu-project-auth-shared.js" - -const decodeSnapshot = ( - projectId: string, - snapshot: ProjectAuthSnapshot | null -): Effect.Effect => - snapshot === null - ? Effect.fail({ - _tag: "ApiRequestError", - method: "GET", - path: `/projects/${projectId}/auth/menu`, - message: `Controller returned an invalid project auth snapshot for ${projectId}.` - }) - : Effect.succeed(snapshot) - -export const readProjectAuthSnapshot = ( - project: ProjectItem -): Effect.Effect => - loadProjectAuthSnapshot(project.projectDir).pipe( - Effect.flatMap((snapshot) => decodeSnapshot(project.projectDir, snapshot)) - ) - -export const writeProjectAuthFlow = ( - project: ProjectItem, - flow: ProjectAuthFlow, - values: Readonly> -): Effect.Effect => - submitProjectAuthFlow(project.projectDir, { - flow, - label: normalizeOptionalText(values["label"]) - }).pipe( - Effect.flatMap((snapshot) => decodeSnapshot(project.projectDir, snapshot)), - Effect.asVoid - ) diff --git a/packages/app/src/docker-git/menu-project-auth-gemini.ts b/packages/app/src/docker-git/menu-project-auth-gemini.ts deleted file mode 100644 index c16d93f2..00000000 --- a/packages/app/src/docker-git/menu-project-auth-gemini.ts +++ /dev/null @@ -1,116 +0,0 @@ -import type { PlatformError } from "@effect/platform/Error" -import type * as FileSystem from "@effect/platform/FileSystem" -import { Effect } from "effect" - -import { hasFileAtPath } from "./menu-project-auth-helpers.js" - -// CHANGE: add Gemini CLI account credentials check for project auth -// WHY: enable Gemini CLI authentication verification at project level (API key or OAuth) -// QUOTE(ТЗ): "Добавь поддержку gemini CLI", "Типо ждал пока мы вставим ссылку" -// REF: issue-146, PR-147 comment from skulidropek -// SOURCE: https://geminicli.com/docs/get-started/authentication/ -// FORMAT THEOREM: forall accountPath: hasGeminiAccountCredentials(fs, accountPath) = boolean | PlatformError -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: returns true only if valid API key or OAuth credentials exist -// COMPLEXITY: O(1) - -const apiKeyFileName = ".api-key" -const envFileName = ".env" -const geminiCredentialsDir = ".gemini" - -const hasNonEmptyApiKey = ( - fs: FileSystem.FileSystem, - apiKeyPath: string -): Effect.Effect => - Effect.gen(function*(_) { - const hasFile = yield* _(hasFileAtPath(fs, apiKeyPath)) - if (!hasFile) { - return false - } - const keyValue = yield* _(fs.readFileString(apiKeyPath), Effect.orElseSucceed(() => "")) - return keyValue.trim().length > 0 - }) - -const hasApiKeyInEnvFile = ( - fs: FileSystem.FileSystem, - envFilePath: string -): Effect.Effect => - Effect.gen(function*(_) { - const hasFile = yield* _(hasFileAtPath(fs, envFilePath)) - if (!hasFile) { - return false - } - const envContent = yield* _(fs.readFileString(envFilePath), Effect.orElseSucceed(() => "")) - const lines = envContent.split("\n") - for (const line of lines) { - const trimmed = line.trim() - if (trimmed.startsWith("GEMINI_API_KEY=")) { - const value = trimmed.slice("GEMINI_API_KEY=".length).replaceAll(/^['"]|['"]$/g, "").trim() - if (value.length > 0) { - return true - } - } - } - return false - }) - -// CHANGE: check for OAuth credentials in .gemini directory -// WHY: Gemini CLI stores OAuth tokens in ~/.gemini after successful OAuth flow -// QUOTE(ТЗ): "Типо ждал пока мы вставим ссылку" -// REF: issue-146, PR-147 comment -// FORMAT THEOREM: hasOauthCredentials(fs, credentialsDir) -> boolean -// PURITY: SHELL -// INVARIANT: checks for existence of OAuth credential files -// COMPLEXITY: O(n) where n = number of possible credential files -const geminiOauthCredentialFiles: ReadonlyArray = [ - "oauth-tokens.json", - "credentials.json", - "application_default_credentials.json" -] - -const checkAnyFileExists = ( - fs: FileSystem.FileSystem, - basePath: string, - fileNames: ReadonlyArray -): Effect.Effect => { - const [first, ...rest] = fileNames - if (first === undefined) { - return Effect.succeed(false) - } - return hasFileAtPath(fs, `${basePath}/${first}`).pipe( - Effect.flatMap((exists) => exists ? Effect.succeed(true) : checkAnyFileExists(fs, basePath, rest)) - ) -} - -const hasOauthCredentials = ( - fs: FileSystem.FileSystem, - accountPath: string -): Effect.Effect => { - const credentialsDir = `${accountPath}/${geminiCredentialsDir}` - return hasFileAtPath(fs, credentialsDir).pipe( - Effect.flatMap((dirExists) => - dirExists ? checkAnyFileExists(fs, credentialsDir, geminiOauthCredentialFiles) : Effect.succeed(false) - ) - ) -} - -export const hasGeminiAccountCredentials = ( - fs: FileSystem.FileSystem, - accountPath: string -): Effect.Effect => - hasNonEmptyApiKey(fs, `${accountPath}/${apiKeyFileName}`).pipe( - Effect.flatMap((hasApiKey) => { - if (hasApiKey) { - return Effect.succeed(true) - } - return hasApiKeyInEnvFile(fs, `${accountPath}/${envFileName}`).pipe( - Effect.flatMap((hasEnvApiKey) => { - if (hasEnvApiKey) { - return Effect.succeed(true) - } - return hasOauthCredentials(fs, accountPath) - }) - ) - }) - ) diff --git a/packages/app/src/docker-git/menu-project-auth-helpers.ts b/packages/app/src/docker-git/menu-project-auth-helpers.ts deleted file mode 100644 index 97ae6a97..00000000 --- a/packages/app/src/docker-git/menu-project-auth-helpers.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { PlatformError } from "@effect/platform/Error" -import type * as FileSystem from "@effect/platform/FileSystem" -import { Effect } from "effect" - -export const hasFileAtPath = ( - fs: FileSystem.FileSystem, - filePath: string -): Effect.Effect => - Effect.gen(function*(_) { - const exists = yield* _(fs.exists(filePath)) - if (!exists) { - return false - } - const info = yield* _(fs.stat(filePath)) - return info.type === "File" - }) diff --git a/packages/app/src/docker-git/menu-project-auth.ts b/packages/app/src/docker-git/menu-project-auth.ts deleted file mode 100644 index d355e64a..00000000 --- a/packages/app/src/docker-git/menu-project-auth.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { Effect, pipe } from "effect" - -import { nextBufferValue } from "./menu-buffer-input.js" -import type { MenuError } from "./menu-errors.js" -import { handleMenuNumberInput, submitPromptStep } from "./menu-input-utils.js" -import { - type ProjectAuthMenuAction, - projectAuthMenuActionByIndex, - projectAuthMenuSize, - projectAuthSuccessMessage, - projectAuthViewSteps, - readProjectAuthSnapshot, - writeProjectAuthFlow -} from "./menu-project-auth-data.js" -import { resetToMenu } from "./menu-shared.js" -import type { - MenuEnv, - MenuKeyInput, - MenuRunner, - MenuViewContext, - ProjectAuthFlow, - ProjectAuthSnapshot, - ViewState -} from "./menu-types.js" -import type { ProjectItem } from "./project-item.js" - -type ProjectAuthContext = Pick & { - readonly runner: MenuRunner -} - -type ProjectAuthContextWithProject = ProjectAuthContext & { - readonly project: ProjectItem -} - -const startProjectAuthMenu = ( - project: ProjectItem, - snapshot: ProjectAuthSnapshot, - context: Pick -) => { - context.setView({ _tag: "ProjectAuthMenu", selected: 0, project, snapshot }) - context.setMessage(null) -} - -const startProjectAuthPrompt = ( - project: ProjectItem, - snapshot: ProjectAuthSnapshot, - flow: ProjectAuthFlow, - context: Pick -) => { - context.setView({ - _tag: "ProjectAuthPrompt", - flow, - step: 0, - buffer: "", - values: {}, - project, - snapshot - }) - context.setMessage(null) -} - -const loadProjectAuthMenuView = ( - project: ProjectItem, - context: Pick -): Effect.Effect => - pipe( - readProjectAuthSnapshot(project), - Effect.tap((snapshot) => - Effect.sync(() => { - startProjectAuthMenu(project, snapshot, context) - }) - ), - Effect.asVoid - ) - -const runProjectAuthEffect = ( - project: ProjectItem, - flow: ProjectAuthFlow, - values: Readonly>, - label: string, - context: ProjectAuthContext -) => { - context.runner.runEffect( - pipe( - writeProjectAuthFlow(project, flow, values), - Effect.zipRight(readProjectAuthSnapshot(project)), - Effect.tap((snapshot) => - Effect.sync(() => { - startProjectAuthMenu(project, snapshot, context) - context.setMessage(projectAuthSuccessMessage(flow, label)) - }) - ), - Effect.asVoid - ) - ) -} - -const submitProjectAuthPrompt = ( - view: Extract, - context: ProjectAuthContext -) => { - const steps = projectAuthViewSteps(view.flow) - submitPromptStep( - view, - steps, - context, - () => { - startProjectAuthMenu(view.project, view.snapshot, context) - }, - (nextValues) => { - const rawLabel = (nextValues["label"] ?? "").trim() - const label = rawLabel.length > 0 ? rawLabel : "default" - runProjectAuthEffect(view.project, view.flow, nextValues, label, context) - } - ) -} - -const runProjectAuthAction = ( - action: ProjectAuthMenuAction, - view: Extract, - context: ProjectAuthContext -) => { - if (action === "Back") { - resetToMenu(context) - return - } - if (action === "Refresh") { - context.runner.runEffect(loadProjectAuthMenuView(view.project, context)) - return - } - - if ( - action === "ProjectGithubDisconnect" || - action === "ProjectGitDisconnect" || - action === "ProjectClaudeDisconnect" || - action === "ProjectGeminiDisconnect" - ) { - runProjectAuthEffect(view.project, action, {}, "default", context) - return - } - - startProjectAuthPrompt(view.project, view.snapshot, action, context) -} - -const setProjectAuthMenuSelection = ( - view: Extract, - selected: number, - context: Pick -) => { - context.setView({ ...view, selected }) -} - -const shiftProjectAuthMenuSelection = ( - view: Extract, - delta: number, - context: Pick -) => { - const menuSize = projectAuthMenuSize() - const selected = (view.selected + delta + menuSize) % menuSize - setProjectAuthMenuSelection(view, selected, context) -} - -const runProjectAuthMenuSelection = ( - selected: number, - view: Extract, - context: ProjectAuthContext -) => { - const action = projectAuthMenuActionByIndex(selected) - if (action === null) { - return - } - runProjectAuthAction(action, view, context) -} - -const handleProjectAuthMenuNumberInput = ( - input: string, - view: Extract, - context: ProjectAuthContext -) => { - handleMenuNumberInput( - input, - context, - projectAuthMenuActionByIndex, - (action) => { - runProjectAuthAction(action, view, context) - } - ) -} - -const handleProjectAuthMenuInput = ( - input: string, - key: MenuKeyInput, - view: Extract, - context: ProjectAuthContext -) => { - if (key.escape) { - resetToMenu(context) - return - } - if (key.upArrow) { - shiftProjectAuthMenuSelection(view, -1, context) - return - } - if (key.downArrow) { - shiftProjectAuthMenuSelection(view, 1, context) - return - } - if (key.return) { - runProjectAuthMenuSelection(view.selected, view, context) - return - } - handleProjectAuthMenuNumberInput(input, view, context) -} - -type SetPromptBufferArgs = { - readonly input: string - readonly key: MenuKeyInput - readonly view: Extract - readonly context: Pick -} - -const setProjectAuthPromptBuffer = (args: SetPromptBufferArgs) => { - const nextBuffer = nextBufferValue(args.input, args.key, args.view.buffer) - if (nextBuffer === null) { - return - } - args.context.setView({ ...args.view, buffer: nextBuffer }) -} - -const handleProjectAuthPromptInput = ( - input: string, - key: MenuKeyInput, - view: Extract, - context: ProjectAuthContext -) => { - if (key.escape) { - startProjectAuthMenu(view.project, view.snapshot, context) - return - } - if (key.return) { - submitProjectAuthPrompt(view, context) - return - } - setProjectAuthPromptBuffer({ input, key, view, context }) -} - -export const openProjectAuthMenu = (context: ProjectAuthContextWithProject): void => { - context.setMessage(`Loading project auth (${context.project.displayName})...`) - context.runner.runEffect(loadProjectAuthMenuView(context.project, context)) -} - -export const openProjectAuthSelection = ( - project: ProjectItem, - context: ProjectAuthContext -): void => { - openProjectAuthMenu({ project, ...context }) -} - -export const handleProjectAuthInput = ( - input: string, - key: MenuKeyInput, - view: Extract, - context: ProjectAuthContext -) => { - if (view._tag === "ProjectAuthMenu") { - handleProjectAuthMenuInput(input, key, view, context) - return - } - handleProjectAuthPromptInput(input, key, view, context) -} diff --git a/packages/app/src/docker-git/menu-render-auth.ts b/packages/app/src/docker-git/menu-render-auth.ts deleted file mode 100644 index 7d0f0d56..00000000 --- a/packages/app/src/docker-git/menu-render-auth.ts +++ /dev/null @@ -1,65 +0,0 @@ -import React from "react" - -import { Box, Text } from "../ui/primitives.js" -import { authMenuLabels, authViewSteps, authViewTitle } from "./menu-auth-data.js" -import { - renderMenuHelp, - renderPromptLayout, - renderSelectableMenuList, - resolvePromptState -} from "./menu-render-common.js" -import { renderLayout } from "./menu-render-layout.js" -import type { AuthSnapshot, ViewState } from "./menu-types.js" - -const renderCountLine = (title: string, count: number): string => `${title}: ${count}` - -export const renderAuthMenu = ( - snapshot: AuthSnapshot, - selected: number, - message: string | null -): React.ReactElement => { - const el = React.createElement - const list = renderSelectableMenuList(authMenuLabels(), selected) - return renderLayout( - "docker-git / Auth profiles", - [ - el(Text, null, `Global env: ${snapshot.globalEnvPath}`), - el(Text, null, `Claude auth: ${snapshot.claudeAuthPath}`), - el(Text, { fg: "gray" }, renderCountLine("Entries", snapshot.totalEntries)), - el(Text, { fg: "gray" }, renderCountLine("GitHub tokens", snapshot.githubTokenEntries)), - el(Text, { fg: "gray" }, renderCountLine("Git tokens", snapshot.gitTokenEntries)), - el(Text, { fg: "gray" }, renderCountLine("Git users", snapshot.gitUserEntries)), - el(Text, { fg: "gray" }, renderCountLine("Claude logins", snapshot.claudeAuthEntries)), - el(Box, { flexDirection: "column", marginTop: 1 }, ...list), - renderMenuHelp("Use arrows + Enter, or type a number.") - ], - message - ) -} - -export const renderAuthPrompt = ( - view: Extract, - message: string | null -): React.ReactElement => { - const el = React.createElement - const { prompt, visibleBuffer } = resolvePromptState(authViewSteps(view.flow), view.step, view.buffer) - let helpLine = "Enter = next, Esc = cancel." - if (view.flow === "GithubOauth" || view.flow === "ClaudeOauth") { - helpLine = "Enter = start OAuth, Esc = cancel." - } else if (view.flow === "ClaudeLogout") { - helpLine = "Enter = logout, Esc = cancel." - } - return renderPromptLayout({ - title: `docker-git / Auth / ${authViewTitle(view.flow)}`, - header: [ - el(Text, { fg: "gray" }, `Global env: ${view.snapshot.globalEnvPath}`), - ...(view.flow === "ClaudeOauth" || view.flow === "ClaudeLogout" - ? [el(Text, { fg: "gray" }, `Claude auth: ${view.snapshot.claudeAuthPath}`)] - : []) - ], - prompt, - visibleBuffer, - helpLine, - message - }) -} diff --git a/packages/app/src/docker-git/menu-render-common.ts b/packages/app/src/docker-git/menu-render-common.ts deleted file mode 100644 index ac5858c2..00000000 --- a/packages/app/src/docker-git/menu-render-common.ts +++ /dev/null @@ -1,53 +0,0 @@ -import React from "react" - -import { HelpLines, PromptScreen, SelectableList } from "../ui/shared.js" - -export const renderSelectableMenuList = ( - labels: ReadonlyArray, - selected: number -): ReadonlyArray => { - return SelectableList({ - labels: labels.map((label, index) => `${index + 1}) ${label}`), - selectedIndex: selected - }) -} - -export const renderMenuHelp = (primaryLine: string): React.ReactElement => - HelpLines({ lines: [primaryLine, "Esc returns to the main menu."] }) - -type PromptStepLike = { - readonly label: string - readonly secret: boolean -} - -export const resolvePromptState = ( - steps: ReadonlyArray, - step: number, - buffer: string -): { readonly prompt: string; readonly visibleBuffer: string } => { - const current = steps[step] - const prompt = current?.label ?? "Value" - const isSecret = current?.secret === true - const visibleBuffer = isSecret ? "*".repeat(buffer.length) : buffer - return { prompt, visibleBuffer } -} - -type RenderPromptArgs = { - readonly title: string - readonly header: ReadonlyArray - readonly prompt: string - readonly visibleBuffer: string - readonly helpLine: string - readonly message: string | null -} - -export const renderPromptLayout = (args: RenderPromptArgs): React.ReactElement => { - return React.createElement(PromptScreen, { - header: [...args.header], - helpLines: [args.helpLine], - message: args.message, - prompt: args.prompt, - title: args.title, - value: args.visibleBuffer - }) -} diff --git a/packages/app/src/docker-git/menu-render-layout.ts b/packages/app/src/docker-git/menu-render-layout.ts deleted file mode 100644 index 066c156b..00000000 --- a/packages/app/src/docker-git/menu-render-layout.ts +++ /dev/null @@ -1,15 +0,0 @@ -import React from "react" - -import { ScreenLayout } from "../ui/shared.js" - -export const renderLayout = ( - title: string, - body: ReadonlyArray, - message: string | null -): React.ReactElement => { - return React.createElement(ScreenLayout, { - body: [...body], - message, - title - }) -} diff --git a/packages/app/src/docker-git/menu-render-project-auth.ts b/packages/app/src/docker-git/menu-render-project-auth.ts deleted file mode 100644 index c151f9d4..00000000 --- a/packages/app/src/docker-git/menu-render-project-auth.ts +++ /dev/null @@ -1,70 +0,0 @@ -import React from "react" - -import { Box, Text } from "../ui/primitives.js" -import { projectAuthMenuLabels, projectAuthViewSteps } from "./menu-project-auth-data.js" -import { - renderMenuHelp, - renderPromptLayout, - renderSelectableMenuList, - resolvePromptState -} from "./menu-render-common.js" -import { renderLayout } from "./menu-render-layout.js" -import type { ProjectAuthSnapshot, ViewState } from "./menu-types.js" - -const renderActiveLabel = (value: string | null): string => value ?? "(not set)" - -const renderCountLine = (title: string, count: number): string => `${title}: ${count}` - -export const renderProjectAuthMenu = ( - snapshot: ProjectAuthSnapshot, - selected: number, - message: string | null -): React.ReactElement => { - const el = React.createElement - const list = renderSelectableMenuList(projectAuthMenuLabels(), selected) - - return renderLayout( - "docker-git / Project auth", - [ - el(Text, null, `Project: ${snapshot.projectName}`), - el(Text, { fg: "gray" }, `Dir: ${snapshot.projectDir}`), - el(Text, { fg: "gray" }, `Project env: ${snapshot.envProjectPath}`), - el(Text, { fg: "gray" }, `Global env: ${snapshot.envGlobalPath}`), - el(Text, { fg: "gray" }, `Claude auth: ${snapshot.claudeAuthPath}`), - el( - Box, - { marginTop: 1, flexDirection: "column" }, - el(Text, { fg: "gray" }, `GitHub label: ${renderActiveLabel(snapshot.activeGithubLabel)}`), - el(Text, { fg: "gray" }, renderCountLine("Available GitHub tokens", snapshot.githubTokenEntries)), - el(Text, { fg: "gray" }, `Git label: ${renderActiveLabel(snapshot.activeGitLabel)}`), - el(Text, { fg: "gray" }, renderCountLine("Available Git tokens", snapshot.gitTokenEntries)), - el(Text, { fg: "gray" }, `Claude label: ${renderActiveLabel(snapshot.activeClaudeLabel)}`), - el(Text, { fg: "gray" }, renderCountLine("Available Claude logins", snapshot.claudeAuthEntries)) - ), - el(Box, { flexDirection: "column", marginTop: 1 }, ...list), - renderMenuHelp("Use arrows + Enter, or type a number from the list.") - ], - message - ) -} - -export const renderProjectAuthPrompt = ( - view: Extract, - message: string | null -): React.ReactElement => { - const el = React.createElement - const { prompt, visibleBuffer } = resolvePromptState(projectAuthViewSteps(view.flow), view.step, view.buffer) - - return renderPromptLayout({ - title: "docker-git / Project auth / Set label", - header: [ - el(Text, { fg: "gray" }, `Project: ${view.snapshot.projectName}`), - el(Text, { fg: "gray" }, `Project env: ${view.snapshot.envProjectPath}`), - el(Text, { fg: "gray" }, `Global env: ${view.snapshot.envGlobalPath}`) - ], - prompt, - visibleBuffer, - helpLine: "Enter = apply, Esc = cancel.", - message - }) -} diff --git a/packages/app/src/docker-git/menu-render-select.ts b/packages/app/src/docker-git/menu-render-select.ts deleted file mode 100644 index 22f3c4d5..00000000 --- a/packages/app/src/docker-git/menu-render-select.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type React from "react" - -import { Text } from "../ui/primitives.js" -import { buildSelectDetailsModel, type SelectPurpose } from "./menu-select-presenter.js" -import type { SelectProjectRuntime } from "./menu-types.js" -import type { ProjectItem } from "./project-item.js" - -const computeListWidth = (labels: ReadonlyArray): number => { - const maxLabelWidth = labels.length > 0 ? Math.max(...labels.map((label) => label.length)) : 24 - return Math.min(Math.max(maxLabelWidth + 2, 28), 54) -} - -const readStdoutColumns = (): number | null => { - const columns = process.stdout.columns - if (typeof columns !== "number" || !Number.isFinite(columns) || columns <= 0) { - return null - } - return columns -} - -const readStdoutRows = (): number | null => { - const rows = process.stdout.rows - if (typeof rows !== "number" || !Number.isFinite(rows) || rows <= 0) { - return null - } - return rows -} - -const computeSelectListMaxRows = (): number => { - const rows = readStdoutRows() - if (rows === null) { - return 12 - } - return Math.max(6, rows - 14) -} - -export type SelectColumnWidths = { - readonly detailsWidth: number | null - readonly listWidth: number -} - -type RenderSelectDetailsInput = { - readonly connectEnableMcpPlaywright: boolean - readonly detailsWidth: number | null - readonly item: ProjectItem | undefined - readonly purpose: SelectPurpose - readonly runtimeByProject: Readonly> -} - -export const computeSelectColumnWidths = (labels: ReadonlyArray): SelectColumnWidths => { - const preferredListWidth = computeListWidth(labels) - const columns = readStdoutColumns() - if (columns === null) { - return { detailsWidth: null, listWidth: preferredListWidth } - } - - const layoutFrameWidth = 4 - const columnGapWidth = 2 - const minimumListWidth = 12 - const comfortableDetailsWidth = 24 - const innerWidth = Math.max(2, columns - layoutFrameWidth) - const availableColumns = Math.max(2, innerWidth - columnGapWidth) - const maxListWidth = Math.max(minimumListWidth, availableColumns - comfortableDetailsWidth) - const listWidth = Math.max(minimumListWidth, Math.min(preferredListWidth, maxListWidth)) - const detailsWidth = Math.max(1, availableColumns - listWidth) - - return { detailsWidth, listWidth } -} - -export const renderSelectDetails = ( - el: typeof React.createElement, - input: RenderSelectDetailsInput -): ReadonlyArray => { - const runtime = input.item === undefined - ? { running: false, sshSessions: 0, startedAtIso: null, startedAtEpochMs: null } - : (input.runtimeByProject[input.item.projectDir] ?? { - running: false, - sshSessions: 0, - startedAtIso: null, - startedAtEpochMs: null - }) - const details = buildSelectDetailsModel(input.purpose, input.item, runtime, input.connectEnableMcpPlaywright) - const widthProps = input.detailsWidth === null ? {} : { width: input.detailsWidth } - return [ - el(Text, { fg: "cyan", bold: true, wrap: "truncate", ...widthProps }, details.title), - ...details.lines.map((line, index) => - el(Text, { key: `${details.title}-${index}`, wrap: "wrap", ...widthProps }, line) - ) - ] -} - -export { computeListWidth, computeSelectListMaxRows } - -export { - buildSelectLabels, - buildSelectListWindow, - selectHint, - type SelectPurpose, - selectTitle -} from "./menu-select-presenter.js" diff --git a/packages/app/src/docker-git/menu-render.ts b/packages/app/src/docker-git/menu-render.ts deleted file mode 100644 index de3c1d0a..00000000 --- a/packages/app/src/docker-git/menu-render.ts +++ /dev/null @@ -1,297 +0,0 @@ -import React from "react" - -import { Box, Text } from "../ui/primitives.js" -import { renderCreateStepLabel } from "./menu-create-shared.js" -import { renderLayout } from "./menu-render-layout.js" -import { - buildSelectLabels, - buildSelectListWindow, - computeSelectColumnWidths, - computeSelectListMaxRows, - renderSelectDetails, - selectHint, - type SelectPurpose, - selectTitle -} from "./menu-render-select.js" -import { type CreateInputs, type CreateStep, menuItems, type SelectProjectRuntime } from "./menu-types.js" -import type { ProjectItem } from "./project-item.js" - -// CHANGE: render menu views with Ink without JSX -// WHY: keep UI logic separate from input/state reducers -// QUOTE(ТЗ): "TUI? Красивый, удобный" -// REF: user-request-2026-02-01-tui -// SOURCE: n/a -// FORMAT THEOREM: forall v: view(v) -> render(v) -// PURITY: SHELL -// EFFECT: n/a -// INVARIANT: menu renders all items once -// COMPLEXITY: O(n) - -const compactElements = ( - items: ReadonlyArray -): ReadonlyArray => items.filter((item): item is React.ReactElement => item !== null) - -const renderMenuHints = (el: typeof React.createElement): React.ReactElement => - el( - Box, - { marginTop: 1, flexDirection: "column" }, - el(Text, { fg: "gray" }, "Hints:"), - el(Text, { fg: "gray" }, " - Paste repo URL or URL + flags to start create."), - el( - Text, - { fg: "gray" }, - " - Aliases: create/c, select/s, auth/a, project-auth/pa, info/i, status/ps, logs/l, down/d, down-all/da, delete/del, quit/q" - ), - el(Text, { fg: "gray" }, " - Use arrows and Enter to run.") - ) - -const renderMenuMessage = ( - el: typeof React.createElement, - message: string | null -): React.ReactElement | null => { - if (!message || message.length === 0) { - return null - } - return el( - Box, - { marginTop: 1, flexDirection: "column" }, - ...message - .split("\n") - .map((line, index) => el(Text, { key: `${index}-${line}`, fg: "magenta" }, line)) - ) -} - -type MenuRenderInput = { - readonly cwd: string - readonly activeDir: string | null - readonly runningDockerGitContainers: number - readonly selected: number - readonly busy: boolean - readonly message: string | null -} - -type CreateRenderInput = { - readonly buffer: string - readonly defaults: CreateInputs - readonly label: string - readonly message: string | null - readonly stepIndex: number - readonly steps: ReadonlyArray -} - -export const renderMenu = (input: MenuRenderInput): React.ReactElement => { - const { activeDir, busy, cwd, message, runningDockerGitContainers, selected } = input - const el = React.createElement - const activeLabel = `Active: ${activeDir ?? "(none)"}` - const runningLabel = `Running docker-git containers: ${runningDockerGitContainers}` - const cwdLabel = `CWD: ${cwd}` - const items = menuItems.map((item, index) => { - const indexLabel = `${index + 1})` - const prefix = index === selected ? ">" : " " - return el( - Text, - { key: item.label, fg: index === selected ? "green" : "white" }, - `${prefix} ${indexLabel} ${item.label}` - ) - }) - - const busyView = busy - ? el(Box, { marginTop: 1 }, el(Text, { fg: "yellow" }, "Running...")) - : null - - const messageView = renderMenuMessage(el, message) - const hints = renderMenuHints(el) - - return renderLayout( - "docker-git", - compactElements([ - el(Text, null, activeLabel), - el(Text, null, runningLabel), - el(Text, null, cwdLabel), - el(Box, { flexDirection: "column", marginTop: 1 }, ...items), - hints, - busyView, - messageView - ]), - null - ) -} - -export const renderCreate = (input: CreateRenderInput): React.ReactElement => { - const { buffer, defaults, label, message, stepIndex, steps } = input - const el = React.createElement - const hint = stepIndex === 0 - ? "Enter = next, Shift+Enter = quick create, Esc = cancel." - : "Enter = next, Esc = cancel." - const stepViews = steps.map((step, index) => - el( - Text, - { key: step, fg: index === stepIndex ? "green" : "gray" }, - `${index === stepIndex ? ">" : " "} ${renderCreateStepLabel(step, defaults)}` - ) - ) - return renderLayout( - "docker-git / Create", - [ - el(Box, { flexDirection: "column", marginTop: 1 }, ...stepViews), - el( - Box, - { marginTop: 1 }, - el(Text, null, `${label}: `), - el(Text, { fg: "green" }, buffer) - ), - el(Box, { marginTop: 1 }, el(Text, { fg: "gray" }, hint)) - ], - message - ) -} - -export { renderAuthMenu, renderAuthPrompt } from "./menu-render-auth.js" -export { renderProjectAuthMenu, renderProjectAuthPrompt } from "./menu-render-project-auth.js" - -const renderSelectListBox = ( - el: typeof React.createElement, - items: ReadonlyArray, - selected: number, - labels: ReadonlyArray, - width: number -): React.ReactElement => { - const window = buildSelectListWindow(labels.length, selected, computeSelectListMaxRows()) - const hiddenAbove = window.start - const hiddenBelow = labels.length - window.end - const visibleLabels = labels.slice(window.start, window.end) - const list = visibleLabels.map((label, offset) => { - const index = window.start + offset - return el( - Text, - { - key: items[index]?.projectDir ?? String(index), - fg: index === selected ? "green" : "white", - width, - wrap: "truncate" - }, - label - ) - }) - - const before = hiddenAbove > 0 - ? [el(Text, { fg: "gray", width, wrap: "truncate" }, `[scroll] ${hiddenAbove} more above`)] - : [] - const after = hiddenBelow > 0 - ? [el(Text, { fg: "gray", width, wrap: "truncate" }, `[scroll] ${hiddenBelow} more below`)] - : [] - const listBody = list.length > 0 ? list : [el(Text, { fg: "gray", width, wrap: "truncate" }, "No projects found.")] - - return el( - Box, - { flexDirection: "column", width }, - ...before, - ...listBody, - ...after - ) -} - -type SelectDetailsBoxInput = { - readonly detailsWidth: number | null - readonly purpose: SelectPurpose - readonly items: ReadonlyArray - readonly selected: number - readonly runtimeByProject: Readonly> - readonly connectEnableMcpPlaywright: boolean -} - -const renderSelectDetailsBox = ( - el: typeof React.createElement, - input: SelectDetailsBoxInput -): React.ReactElement => { - const details = renderSelectDetails( - el, - { - connectEnableMcpPlaywright: input.connectEnableMcpPlaywright, - detailsWidth: input.detailsWidth, - item: input.items[input.selected], - purpose: input.purpose, - runtimeByProject: input.runtimeByProject - } - ) - return el( - Box, - { - flexDirection: "column", - marginLeft: 2, - ...(input.detailsWidth === null ? { flexGrow: 1 } : { width: input.detailsWidth }) - }, - ...details - ) -} - -type RenderSelectInput = { - readonly purpose: SelectPurpose - readonly items: ReadonlyArray - readonly selected: number - readonly query: string - readonly runtimeByProject: Readonly> - readonly confirmDelete: boolean - readonly connectEnableMcpPlaywright: boolean - readonly message: string | null -} - -const selectConfirmHint = ( - purpose: SelectPurpose, - confirmDelete: boolean, - connectEnableMcpPlaywright: boolean -): string => { - if (purpose === "Delete" && confirmDelete) { - return "Confirm mode: Enter = delete now, Esc = cancel" - } - if (purpose === "Down" && confirmDelete) { - return "Confirm mode: Enter = stop now, Esc = cancel" - } - return selectHint(purpose, connectEnableMcpPlaywright) -} - -const renderSelectSearch = ( - el: typeof React.createElement, - query: string -): React.ReactElement => - el( - Box, - { marginTop: 1 }, - el( - Text, - { fg: "gray", wrap: "truncate" }, - `Search container/project: ${query.length === 0 ? "(type to filter)" : query}` - ) - ) - -export const renderSelect = (input: RenderSelectInput): React.ReactElement => { - const { confirmDelete, connectEnableMcpPlaywright, items, message, purpose, query, runtimeByProject, selected } = - input - const el = React.createElement - const listLabels = buildSelectLabels(items, selected, purpose, runtimeByProject) - const { detailsWidth, listWidth } = computeSelectColumnWidths(listLabels) - const listBox = renderSelectListBox(el, items, selected, listLabels, listWidth) - const detailsBox = renderSelectDetailsBox(el, { - detailsWidth, - purpose, - items, - selected, - runtimeByProject, - connectEnableMcpPlaywright - }) - const confirmHint = selectConfirmHint(purpose, confirmDelete, connectEnableMcpPlaywright) - const hints = el(Box, { marginTop: 1 }, el(Text, { fg: "gray" }, confirmHint)) - const search = renderSelectSearch(el, query) - - return renderLayout( - selectTitle(purpose), - [ - search, - el(Box, { flexDirection: "row", marginTop: 1 }, listBox, detailsBox), - hints - ], - message - ) -} - -export { renderCreateStepLabel as renderStepLabel } from "./menu-create-shared.js" diff --git a/packages/app/src/docker-git/menu-select-actions.ts b/packages/app/src/docker-git/menu-select-actions.ts deleted file mode 100644 index b58bbbf8..00000000 --- a/packages/app/src/docker-git/menu-select-actions.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { Effect, pipe } from "effect" - -import { deleteMenuProject, downMenuProject, listMenuRunningProjectItems } from "./menu-api.js" -import { renderMenuError } from "./menu-errors.js" -import { openProjectAuthSelection } from "./menu-project-auth.js" -import { buildConnectEffect } from "./menu-select-connect.js" -import { loadRuntimeByProject } from "./menu-select-runtime.js" -import { startSelectView } from "./menu-select-view.js" -import { pauseOnError, resetToMenu, resumeWithSkipInputs, withSuspendedTui } from "./menu-shared.js" -import type { MenuRunner, MenuViewContext } from "./menu-types.js" -import { openResolvedProjectSshViaControllerWithUp } from "./open-project.js" -import type { ProjectItem } from "./project-item.js" - -export type SelectContext = MenuViewContext & { - readonly activeDir: string | null - readonly runner: MenuRunner - readonly setSshActive: (active: boolean) => void - readonly setSkipInputs: (update: (value: number) => number) => void -} - -export const runConnectSelection = ( - selected: ProjectItem, - context: SelectContext, - enableMcpPlaywright: boolean -) => { - if (enableMcpPlaywright) { - context.setMessage( - "Playwright MCP pre-connect toggle is not routed through the controller yet." - ) - return - } - - context.setMessage(`Connecting to ${selected.displayName}...`) - context.setSshActive(true) - context.runner.runInteractiveEffect( - pipe( - buildConnectEffect(selected, false, { - connectWithUp: (item) => openResolvedProjectSshViaControllerWithUp(item), - enableMcpPlaywright: () => Effect.void - }), - Effect.tap(() => - Effect.sync(() => { - context.setMessage("SSH session ended. Press Esc to return to the menu.") - }) - ), - Effect.ensuring( - Effect.sync(() => { - context.setSshActive(false) - context.setSkipInputs(() => 2) - }) - ), - Effect.asVoid - ) - ) -} - -export const runDownSelection = (selected: ProjectItem, context: SelectContext) => { - context.setMessage(`Stopping ${selected.displayName}...`) - context.runner.runEffect( - withSuspendedTui( - pipe( - downMenuProject(selected), - Effect.zipRight(listMenuRunningProjectItems), - Effect.flatMap((items) => - pipe( - loadRuntimeByProject(items), - Effect.map((runtimeByProject) => ({ items, runtimeByProject })) - ) - ), - Effect.tap(({ items, runtimeByProject }) => - Effect.sync(() => { - if (items.length === 0) { - resetToMenu(context) - context.setMessage("No running docker-git containers.") - return - } - startSelectView(items, "Down", context, runtimeByProject) - context.setMessage("Container stopped. Select another to stop, or Esc to return.") - }) - ), - Effect.asVoid - ), - { - onError: pauseOnError(renderMenuError), - onResume: resumeWithSkipInputs(context) - } - ) - ) -} - -export const runInfoSelection = (selected: ProjectItem, context: SelectContext) => { - context.setMessage(`Details for ${selected.displayName} are shown on the right. Press Esc to return to the menu.`) -} - -export const runAuthSelection = (selected: ProjectItem, context: SelectContext) => { - openProjectAuthSelection(selected, context) -} - -export const runDeleteSelection = (selected: ProjectItem, context: SelectContext) => { - context.setMessage(`Deleting ${selected.displayName}...`) - context.runner.runEffect( - pipe( - withSuspendedTui( - deleteMenuProject(selected).pipe( - Effect.tap(() => - Effect.sync(() => { - if (context.activeDir === selected.projectDir) { - context.setActiveDir(null) - } - context.setView({ _tag: "Menu" }) - }) - ), - Effect.asVoid - ), - { - onError: pauseOnError(renderMenuError), - onResume: resumeWithSkipInputs(context) - } - ), - Effect.tap(() => - Effect.sync(() => { - context.setMessage("Project deleted.") - }) - ), - Effect.asVoid - ) - ) -} diff --git a/packages/app/src/docker-git/menu-select-connect.ts b/packages/app/src/docker-git/menu-select-connect.ts deleted file mode 100644 index e0673237..00000000 --- a/packages/app/src/docker-git/menu-select-connect.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Effect } from "effect" - -import type { ProjectItem } from "./project-item.js" - -type ConnectDeps = { - readonly connectWithUp: ( - item: ProjectItem - ) => Effect.Effect - readonly enableMcpPlaywright: ( - projectDir: string - ) => Effect.Effect -} - -const normalizedInput = (input: string): string => input.trim().toLowerCase() - -export const isConnectMcpToggleInput = (input: string): boolean => normalizedInput(input) === "p" - -export const buildConnectEffect = ( - selected: ProjectItem, - enableMcpPlaywright: boolean, - deps: ConnectDeps -): Effect.Effect => - enableMcpPlaywright - ? deps.enableMcpPlaywright(selected.projectDir).pipe( - Effect.zipRight(deps.connectWithUp(selected)) - ) - : deps.connectWithUp(selected) diff --git a/packages/app/src/docker-git/menu-select-load.ts b/packages/app/src/docker-git/menu-select-load.ts deleted file mode 100644 index 192132dd..00000000 --- a/packages/app/src/docker-git/menu-select-load.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Effect, pipe } from "effect" - -import { loadRuntimeByProject } from "./menu-select-runtime.js" -import { startSelectView } from "./menu-select.js" -import type { MenuEnv, MenuViewContext } from "./menu-types.js" -import type { ProjectItem } from "./project-item.js" - -export const loadSelectView = ( - effect: Effect.Effect, E, MenuEnv>, - purpose: "Connect" | "Down" | "Info" | "Delete" | "Auth", - context: Pick -): Effect.Effect => - pipe( - effect, - Effect.flatMap((items) => - pipe( - loadRuntimeByProject(items), - Effect.flatMap((runtimeByProject) => - Effect.sync(() => { - if (items.length === 0) { - context.setMessage( - purpose === "Down" - ? "No running docker-git containers." - : "No docker-git projects found." - ) - return - } - startSelectView(items, purpose, context, runtimeByProject) - }) - ) - ) - ) - ) diff --git a/packages/app/src/docker-git/menu-select-runtime.ts b/packages/app/src/docker-git/menu-select-runtime.ts deleted file mode 100644 index 8ec1753e..00000000 --- a/packages/app/src/docker-git/menu-select-runtime.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Effect } from "effect" - -import type { MenuEnv, SelectProjectRuntime, ViewState } from "./menu-types.js" -import type { ProjectItem } from "./project-item.js" - -const stoppedRuntime = (): SelectProjectRuntime => ({ - running: false, - sshSessions: 0, - startedAtIso: null, - startedAtEpochMs: null -}) - -const toRuntimeMap = ( - entries: ReadonlyArray -): Readonly> => { - const runtimeByProject: Record = {} - for (const [projectDir, runtime] of entries) { - runtimeByProject[projectDir] = runtime - } - return runtimeByProject -} - -// CHANGE: enrich select items with runtime state and SSH session counts -// WHY: prevent stopping/deleting containers that are currently used via SSH -// QUOTE(ТЗ): "писать скок SSH подключений к контейнеру сейчас" -// REF: issue-47 -// SOURCE: n/a -// FORMAT THEOREM: forall p: api_runtime(p) -> {running(p), ssh_sessions(p), started_at(p)} -// PURITY: CORE -// EFFECT: Effect, never, MenuEnv> -// INVARIANT: runtime map is derived only from API payload already loaded for the view -// COMPLEXITY: O(n) -export const loadRuntimeByProject = ( - items: ReadonlyArray -): Effect.Effect>, never, MenuEnv> => - Effect.succeed( - toRuntimeMap( - items.map((item): readonly [string, SelectProjectRuntime] => [ - item.projectDir, - { - running: item.status === "running", - sshSessions: item.sshSessions, - startedAtIso: item.startedAtIso, - startedAtEpochMs: item.startedAtEpochMs - } - ]) - ) - ) - -export const runtimeForSelection = ( - view: Extract, - selected: ProjectItem -): SelectProjectRuntime => view.runtimeByProject[selected.projectDir] ?? stoppedRuntime() diff --git a/packages/app/src/docker-git/menu-select-view.ts b/packages/app/src/docker-git/menu-select-view.ts deleted file mode 100644 index 603a01d9..00000000 --- a/packages/app/src/docker-git/menu-select-view.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { ProjectItem } from "./project-item.js" - -import { filterProjectItemsByQuery } from "./menu-select-filter.js" -import { sortItemsByLaunchTime } from "./menu-select-order.js" -import type { MenuViewContext, SelectProjectRuntime } from "./menu-types.js" - -const emptyRuntimeByProject = (): Readonly> => ({}) - -export const startSelectView = ( - items: ReadonlyArray, - purpose: "Connect" | "Down" | "Info" | "Delete" | "Auth", - context: Pick, - runtimeByProject: Readonly> = emptyRuntimeByProject() -) => { - const sortedItems = sortItemsByLaunchTime(items, runtimeByProject) - context.setMessage(null) - context.setView({ - _tag: "SelectProject", - purpose, - allItems: sortedItems, - items: filterProjectItemsByQuery(sortedItems, ""), - query: "", - runtimeByProject, - selected: 0, - confirmDelete: false, - connectEnableMcpPlaywright: false - }) -} diff --git a/packages/app/src/docker-git/menu-select.ts b/packages/app/src/docker-git/menu-select.ts deleted file mode 100644 index 6c4c9b25..00000000 --- a/packages/app/src/docker-git/menu-select.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { Match } from "effect" - -import { nextBufferValue } from "./menu-buffer-input.js" -import { - runAuthSelection, - runConnectSelection, - runDeleteSelection, - runDownSelection, - runInfoSelection, - type SelectContext -} from "./menu-select-actions.js" -import { isConnectMcpToggleInput } from "./menu-select-connect.js" -import { filterProjectItemsByQuery } from "./menu-select-filter.js" -import { runtimeForSelection } from "./menu-select-runtime.js" -import { resetToMenu } from "./menu-shared.js" -import type { MenuKeyInput, ViewState } from "./menu-types.js" - -export { startSelectView } from "./menu-select-view.js" - -const clampIndex = (value: number, size: number): number => { - if (size <= 0) { - return 0 - } - if (value < 0) { - return 0 - } - if (value >= size) { - return size - 1 - } - return value -} - -const updateSelectSearch = ( - view: Extract, - query: string -): Extract => { - const selectedProjectDir = view.items[view.selected]?.projectDir - const items = filterProjectItemsByQuery(view.allItems, query) - const nextSelected = selectedProjectDir === undefined - ? 0 - : items.findIndex((item) => item.projectDir === selectedProjectDir) - return { - ...view, - confirmDelete: false, - items, - query, - selected: clampIndex(nextSelected, items.length) - } -} - -const selectSearchMessage = ( - view: Extract -): string | null => - view.query.length === 0 - ? null - : `Search "${view.query}": ${view.items.length}/${view.allItems.length} project(s).` - -const handleSelectSearchInput = ( - input: string, - key: MenuKeyInput, - view: Extract, - context: SelectContext -): boolean => { - const nextQuery = nextBufferValue(input, key, view.query) - if (nextQuery === null) { - return false - } - const nextView = updateSelectSearch(view, nextQuery) - context.setView(nextView) - context.setMessage(selectSearchMessage(nextView)) - return true -} - -export const handleSelectInput = ( - input: string, - key: MenuKeyInput, - view: Extract, - context: SelectContext -) => { - if (key.escape) { - resetToMenu(context) - return - } - if (handleConnectOptionToggle(input, view, context)) { - return - } - if (handleSelectNavigation(key, view, context)) { - return - } - if (key.return) { - handleSelectReturn(view, context) - return - } - if (handleSelectSearchInput(input, key, view, context)) { - return - } - if (input.trim().length > 0) { - context.setMessage("Type to search by container/project name, arrows + Enter to select, Esc to cancel.") - } -} - -const handleConnectOptionToggle = ( - input: string, - view: Extract, - context: Pick -): boolean => { - if (view.purpose !== "Connect" || view.query.length > 0 || input !== "P" || !isConnectMcpToggleInput(input)) { - return false - } - context.setMessage( - "Playwright MCP pre-connect toggle is not routed through the controller yet." - ) - return true -} - -const handleSelectNavigation = ( - key: MenuKeyInput, - view: Extract, - context: SelectContext -): boolean => { - if (key.upArrow) { - const next = clampIndex(view.selected - 1, view.items.length) - context.setView({ ...view, selected: next, confirmDelete: false }) - return true - } - if (key.downArrow) { - const next = clampIndex(view.selected + 1, view.items.length) - context.setView({ ...view, selected: next, confirmDelete: false }) - return true - } - return false -} - -const formatSshSessionsLabel = (sshSessions: number): string => - sshSessions === 1 ? "1 active SSH session" : `${sshSessions} active SSH sessions` - -const handleSelectReturn = ( - view: Extract, - context: SelectContext -) => { - const selected = view.items[view.selected] - if (!selected) { - context.setMessage("No project selected.") - resetToMenu(context) - return - } - const selectedRuntime = runtimeForSelection(view, selected) - const sshSessionsLabel = formatSshSessionsLabel(selectedRuntime.sshSessions) - - Match.value(view.purpose).pipe( - Match.when("Connect", () => { - context.setActiveDir(selected.projectDir) - runConnectSelection(selected, context, view.connectEnableMcpPlaywright) - }), - Match.when("Auth", () => { - context.setActiveDir(selected.projectDir) - runAuthSelection(selected, context) - }), - Match.when("Down", () => { - if (selectedRuntime.sshSessions > 0 && !view.confirmDelete) { - context.setMessage( - `${selected.containerName} has ${sshSessionsLabel}. Press Enter again to stop, Esc to cancel.` - ) - context.setView({ ...view, confirmDelete: true }) - return - } - context.setActiveDir(selected.projectDir) - runDownSelection(selected, context) - }), - Match.when("Info", () => { - context.setActiveDir(selected.projectDir) - runInfoSelection(selected, context) - }), - Match.when("Delete", () => { - if (!view.confirmDelete) { - const activeSshWarning = selectedRuntime.sshSessions > 0 ? ` ${sshSessionsLabel}.` : "" - context.setMessage( - `Really delete ${selected.displayName}?${activeSshWarning} Press Enter again to confirm, Esc to cancel.` - ) - context.setView({ ...view, confirmDelete: true }) - return - } - runDeleteSelection(selected, context) - }), - Match.exhaustive - ) -} diff --git a/packages/app/src/docker-git/menu-shared.ts b/packages/app/src/docker-git/menu-shared.ts deleted file mode 100644 index 3357382f..00000000 --- a/packages/app/src/docker-git/menu-shared.ts +++ /dev/null @@ -1,294 +0,0 @@ -import type { MenuViewContext, ViewState } from "./menu-types.js" - -import { Effect, pipe } from "effect" -import { repairInteractiveTerminal, type TerminalCursorRuntime } from "./frontend-lib/shell/terminal-cursor.js" - -// CHANGE: share menu escape handling across flows -// WHY: avoid duplicated logic in TUI handlers -// QUOTE(ТЗ): "А ты можешь сделать удобный выбор проектов?" -// REF: user-request-2026-02-02-select-project -// SOURCE: n/a -// FORMAT THEOREM: forall s: escape(s) -> menu(s) -// PURITY: SHELL -// EFFECT: n/a -// INVARIANT: always resets message on escape -// COMPLEXITY: O(1) - -type MenuResetContext = Pick - -type OutputWrite = typeof process.stdout.write - -let stdoutPatched = false -let stdoutMuted = false -let baseStdoutWrite: OutputWrite | null = null -let baseStderrWrite: OutputWrite | null = null -const primaryScreenEscape = "\u001B[?1049l\r\u001B[2K" - -const wrapWrite = (baseWrite: OutputWrite): OutputWrite => -( - chunk: string | Uint8Array, - encoding?: BufferEncoding | ((err?: Error | null) => void), - cb?: (err?: Error | null) => void -) => { - if (stdoutMuted) { - const callback = typeof encoding === "function" ? encoding : cb - if (typeof callback === "function") { - callback() - } - return true - } - if (typeof encoding === "function") { - return baseWrite(chunk, encoding) - } - return baseWrite(chunk, encoding, cb) -} - -const writeTerminalControl = (text: string): void => { - ensureStdoutPatched() - const write = baseStdoutWrite ?? process.stdout.write.bind(process.stdout) - write(text) -} - -const disableTerminalInputModes = (): void => { - // Disable mouse/input modes that can leak across TUI <-> SSH transitions. - writeTerminalControl( - "\u001B[0m" + - "\u001B[?25h" + - "\u001B[?1l" + - "\u001B>" + - "\u001B[?1000l\u001B[?1002l\u001B[?1003l\u001B[?1005l\u001B[?1006l\u001B[?1015l\u001B[?1007l" + - "\u001B[?1004l\u001B[?2004l" + - "\u001B[>4;0m\u001B[>4m\u001B[ { - if (stdoutPatched) { - return - } - baseStdoutWrite = process.stdout.write.bind(process.stdout) - baseStderrWrite = process.stderr.write.bind(process.stderr) - - process.stdout.write = wrapWrite(baseStdoutWrite) - process.stderr.write = wrapWrite(baseStderrWrite) - stdoutPatched = true -} - -// CHANGE: allow writing to the terminal even while stdout is muted -// WHY: we mute Ink renders during interactive commands, but still need to show prompts/errors -// REF: user-request-2026-02-18-tui-output-hidden -// SOURCE: n/a -// PURITY: SHELL -// EFFECT: n/a -// INVARIANT: bypasses the mute wrapper safely -export const writeToTerminal = (text: string): void => { - writeTerminalControl(text) -} - -// CHANGE: keep the user on the primary screen until they acknowledge -// WHY: otherwise output from failed docker/gh commands gets hidden again when TUI resumes -// REF: user-request-2026-02-18-tui-output-hidden -// SOURCE: n/a -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: no-op when stdin/stdout aren't TTY (CI/e2e) -export const pauseForEnter = ( - prompt = "Press Enter to return to docker-git..." -): Effect.Effect => { - if (!process.stdin.isTTY || !process.stdout.isTTY) { - return Effect.void - } - - return Effect.async((resume) => { - // Ensure the prompt isn't glued to the last command line. - writeToTerminal(`\n${prompt}\n`) - process.stdin.resume() - - const cleanup = () => { - process.stdin.off("data", onData) - } - - const onData = () => { - cleanup() - resume(Effect.void) - } - - process.stdin.on("data", onData) - - return Effect.sync(() => { - cleanup() - }) - }).pipe(Effect.asVoid) -} - -export const writeErrorAndPause = (renderedError: string): Effect.Effect => - pipe( - Effect.sync(() => { - writeToTerminal(`\n[docker-git] ${renderedError}\n`) - }), - Effect.zipRight(pauseForEnter()), - Effect.asVoid - ) - -export const withSuspendedTui = ( - effect: Effect.Effect, - options?: { - readonly onError?: (error: E) => Effect.Effect - readonly onResume?: () => void - } -): Effect.Effect => { - const withError = options?.onError - ? pipe(effect, Effect.tapError((error) => Effect.ignore(options.onError?.(error) ?? Effect.void))) - : effect - - return pipe( - suspendTui(), - Effect.zipRight(withError), - Effect.ensuring( - pipe( - resumeTui(), - Effect.zipRight( - Effect.sync(() => { - options?.onResume?.() - }) - ) - ) - ) - ) -} - -export type SkipInputsContext = { - readonly setSkipInputs: (update: (value: number) => number) => void -} - -export type SshActiveContext = { - readonly setSshActive: (active: boolean) => void -} - -export const resumeWithSkipInputs = (context: SkipInputsContext, extra?: () => void) => () => { - extra?.() - context.setSkipInputs(() => 2) -} - -export const resumeSshWithSkipInputs = (context: SkipInputsContext & SshActiveContext) => - resumeWithSkipInputs(context, () => { - context.setSshActive(false) - }) - -export const pauseOnError = (render: (error: E) => string) => (error: E): Effect.Effect => - writeErrorAndPause(render(error)) - -// CHANGE: toggle stdout write muting for Ink rendering -// WHY: allow SSH sessions to own the terminal without TUI redraws -// QUOTE(ТЗ): "при изменении разершения он всё ломает?" -// REF: user-request-2026-02-05-ssh-resize -// SOURCE: n/a -// FORMAT THEOREM: ∀m ∈ {true,false}: muted = m -// PURITY: SHELL -// EFFECT: n/a -// INVARIANT: stdout wrapper is installed at most once -// COMPLEXITY: O(1) -const setStdoutMuted = (muted: boolean): void => { - ensureStdoutPatched() - stdoutMuted = muted -} - -const setStdoutMutedEffect = (muted: boolean): Effect.Effect => - Effect.sync(() => { - setStdoutMuted(muted) - }) - -const writeTerminalControlEffect = (text: string): Effect.Effect => - Effect.sync(() => { - writeTerminalControl(text) - }) - -const setRawModeEffect = (enabled: boolean): Effect.Effect => - process.stdin.isTTY && typeof process.stdin.setRawMode === "function" - ? pipe( - Effect.try(() => { - process.stdin.setRawMode(enabled) - }), - Effect.ignore - ) - : Effect.void - -const whenStdoutTty = (effect: Effect.Effect) => - process.stdout.isTTY ? effect : Effect.void - -const preparePrimaryScreen = (): Effect.Effect => - Effect.gen(function*(_) { - yield* _(setStdoutMutedEffect(true)) - yield* _(repairInteractiveTerminal(writeTerminalControl)) - yield* _(writeTerminalControlEffect(primaryScreenEscape)) - }) - -// CHANGE: temporarily suspend TUI rendering when running interactive commands -// WHY: avoid mixed output from docker/ssh and the Ink UI -// QUOTE(ТЗ): "Почему так кривокосо всё отображается?" -// REF: user-request-2026-02-02-tui-output -// SOURCE: n/a -// FORMAT THEOREM: forall cmd: suspend -> cleanOutput(cmd) -// PURITY: SHELL -// EFFECT: n/a -// INVARIANT: only toggles when TTY is available -// COMPLEXITY: O(1) -export const suspendTui = (): Effect.Effect => - whenStdoutTty( - preparePrimaryScreen().pipe( - // Switch back to the primary screen so interactive commands (ssh/gh/codex) - // can render normally. Do not clear it: users may need scrollback (OAuth codes/URLs). - Effect.asVoid - ) - ) - -// CHANGE: restore TUI rendering after interactive commands -// WHY: return to Ink UI without broken terminal state -// QUOTE(ТЗ): "Почему так кривокосо всё отображается?" -// REF: user-request-2026-02-02-tui-output -// SOURCE: n/a -// FORMAT THEOREM: forall cmd: resume -> tuiVisible(cmd) -// PURITY: SHELL -// EFFECT: n/a -// INVARIANT: only toggles when TTY is available -// COMPLEXITY: O(1) -export const resumeTui = (): Effect.Effect => - whenStdoutTty( - Effect.gen(function*(_) { - yield* _(repairInteractiveTerminal(writeTerminalControl)) - // Return to the alternate screen for Ink rendering. - yield* _(writeTerminalControlEffect("\u001B[?1049h\u001B[2J\u001B[H")) - yield* _(setRawModeEffect(true)) - yield* _(Effect.sync(() => { - disableTerminalInputModes() - })) - yield* _(setStdoutMutedEffect(false)) - }) - ) - -export const leaveTui = (): Effect.Effect => - whenStdoutTty( - Effect.gen(function*(_) { - // Ensure we don't leave the terminal in a broken "mouse reporting" mode. - yield* _(preparePrimaryScreen()) - // Restore the primary screen on exit without clearing it (keeps useful scrollback). - yield* _(setRawModeEffect(false)) - yield* _(setStdoutMutedEffect(false)) - }) - ) - -export const resetToMenu = (context: MenuResetContext): void => { - const view: ViewState = { _tag: "Menu" } - context.setView(view) - context.setMessage(null) -} diff --git a/packages/app/src/docker-git/menu-startup.ts b/packages/app/src/docker-git/menu-startup.ts deleted file mode 100644 index e1ad08c0..00000000 --- a/packages/app/src/docker-git/menu-startup.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { ProjectItem } from "./project-item.js" - -export type MenuStartupSnapshot = { - readonly activeDir: string | null - readonly runningDockerGitContainers: number - readonly message: string | null -} - -const dockerGitContainerPrefix = "dg-" - -const emptySnapshot = (): MenuStartupSnapshot => ({ - activeDir: null, - runningDockerGitContainers: 0, - message: null -}) - -const uniqueDockerGitContainerNames = ( - runningContainerNames: ReadonlyArray -): ReadonlyArray => [ - ...new Set(runningContainerNames.filter((name) => name.startsWith(dockerGitContainerPrefix))) -] - -const detectKnownRunningProjects = ( - items: ReadonlyArray, - runningDockerGitNames: ReadonlyArray -): ReadonlyArray => { - const runningSet = new Set(runningDockerGitNames) - return items.filter((item) => runningSet.has(item.containerName)) -} - -const renderRunningHint = (runningCount: number): string => - runningCount === 1 - ? "Detected 1 running docker-git container." - : `Detected ${runningCount} running docker-git containers.` - -// CHANGE: infer initial menu state from currently running docker-git containers -// WHY: avoid "(none)" confusion when containers are already up outside this TUI session -// QUOTE(ISSUE): "У меня запущены контейнеры от docker-git но он говорит что они не запущены" -// REF: issue-13 -// SOURCE: n/a -// FORMAT THEOREM: forall startupState: snapshot(startupState) -> deterministic(menuState) -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: activeDir is set only when exactly one known project is running -// COMPLEXITY: O(|containers| + |projects|) -export const resolveMenuStartupSnapshot = ( - items: ReadonlyArray -): MenuStartupSnapshot => { - const runningDockerGitNames = uniqueDockerGitContainerNames( - items - .filter((item) => item.status === "running") - .map((item) => item.containerName) - ) - if (runningDockerGitNames.length === 0) { - return emptySnapshot() - } - - const knownRunningProjects = detectKnownRunningProjects(items, runningDockerGitNames) - if (knownRunningProjects.length === 1 && runningDockerGitNames.length === 1) { - const selected = knownRunningProjects[0] - if (!selected) { - return emptySnapshot() - } - return { - activeDir: selected.projectDir, - runningDockerGitContainers: 1, - message: `Auto-selected active project: ${selected.displayName}.` - } - } - - if (knownRunningProjects.length === 0) { - return { - activeDir: null, - runningDockerGitContainers: runningDockerGitNames.length, - message: `${renderRunningHint(runningDockerGitNames.length)} No matching project config found.` - } - } - - return { - activeDir: null, - runningDockerGitContainers: runningDockerGitNames.length, - message: `${renderRunningHint(runningDockerGitNames.length)} Use Select project to choose active.` - } -} - -export const defaultMenuStartupSnapshot = emptySnapshot diff --git a/packages/app/src/docker-git/menu-state.ts b/packages/app/src/docker-git/menu-state.ts deleted file mode 100644 index 2e3834af..00000000 --- a/packages/app/src/docker-git/menu-state.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { NodeContext } from "@effect/platform-node" -import { Effect, pipe } from "effect" -import { useEffect, useMemo, useRef, useState } from "react" - -import { listMenuProjectItems } from "./menu-api.js" -import type { MenuError } from "./menu-errors.js" -import { renderMenuError } from "./menu-errors.js" -import type { InputStage } from "./menu-input-handler.js" -import { writeErrorAndPause } from "./menu-shared.js" -import { defaultMenuStartupSnapshot, resolveMenuStartupSnapshot } from "./menu-startup.js" -import type { MenuEnv, MenuState, ViewState } from "./menu-types.js" - -export type InteractiveMenuEffect = Effect.Effect - -export type QueueInteractiveEffect = (effect: InteractiveMenuEffect) => void - -export type MenuSnapshot = { - readonly activeDir: string | null - readonly runningDockerGitContainers: number - readonly selected: number - readonly busy: boolean - readonly message: string | null - readonly view: ViewState - readonly inputStage: InputStage - readonly ready: boolean - readonly skipInputs: number - readonly sshActive: boolean - readonly startupLoaded: boolean -} - -export type MenuSnapshotStore = { - current: MenuSnapshot -} - -// CHANGE: make a fresh CLI TUI process the first user key after readiness gates -// WHY: ready/ignoreUntil already filters bootstrap noise; skipping valid input breaks menu liveness -// QUOTE(ТЗ): "Почему-то CLI TUI не работает" -// REF: issue-274 -// SOURCE: n/a -// FORMAT THEOREM: forall key in ValidMenuInput: ready(menu) -> processed(key) -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: initial snapshot has no synthetic skipped user inputs -// COMPLEXITY: O(1) -export const defaultMenuSnapshot = (): MenuSnapshot => ({ - activeDir: null, - runningDockerGitContainers: 0, - selected: 0, - busy: false, - message: null, - view: { _tag: "Menu" }, - inputStage: "active", - ready: false, - skipInputs: 0, - sshActive: false, - startupLoaded: false -}) - -const withBusyCleanup = ( - effect: Effect.Effect, - setBusy: (busy: boolean) => void -): Effect.Effect => - pipe( - effect, - Effect.ensuring( - Effect.sync(() => { - setBusy(false) - }) - ) - ) - -const handleMenuError = (setMessage: (message: string | null) => void) => (error: MenuError) => - Effect.sync(() => { - setMessage(renderMenuError(error)) - }) - -const handleInteractiveMenuError = (setMessage: (message: string | null) => void) => (error: MenuError) => { - const message = renderMenuError(error) - return pipe( - writeErrorAndPause(message), - Effect.zipRight(Effect.sync(() => { - setMessage(message) - })) - ) -} - -const useRunEffect = ( - setBusy: (busy: boolean) => void, - setMessage: (message: string | null) => void -) => - function(effect: Effect.Effect) { - setBusy(true) - const program = withBusyCleanup( - pipe( - effect, - Effect.matchEffect({ - onFailure: handleMenuError(setMessage), - onSuccess: () => Effect.void - }) - ), - setBusy - ) - void Effect.runPromise(Effect.provide(program, NodeContext.layer)) - } - -const useRunInteractiveEffect = ( - setBusy: (busy: boolean) => void, - setMessage: (message: string | null) => void, - queueInteractiveEffect: QueueInteractiveEffect -) => - function(effect: Effect.Effect) { - setBusy(true) - queueInteractiveEffect( - withBusyCleanup( - pipe( - effect, - Effect.matchEffect({ - onFailure: handleInteractiveMenuError(setMessage), - onSuccess: () => Effect.void - }) - ), - setBusy - ) - ) - } - -const useRunner = ( - setBusy: (busy: boolean) => void, - setMessage: (message: string | null) => void, - queueInteractiveEffect: QueueInteractiveEffect -) => { - const runEffect = useRunEffect(setBusy, setMessage) - const runInteractiveEffect = useRunInteractiveEffect(setBusy, setMessage, queueInteractiveEffect) - return useMemo(() => ({ runEffect, runInteractiveEffect }), [runEffect, runInteractiveEffect]) -} - -const useMountedRef = () => { - const mountedRef = useRef(true) - useEffect(() => { - return () => { - mountedRef.current = false - } - }, []) - return mountedRef -} - -const useSnapshotCommit = ( - store: MenuSnapshotStore, - setSnapshot: (snapshot: MenuSnapshot) => void, - mountedRef: { readonly current: boolean } -) => -(update: (snapshot: MenuSnapshot) => MenuSnapshot): void => { - const nextSnapshot = update(store.current) - store.current = nextSnapshot - if (mountedRef.current) { - setSnapshot(nextSnapshot) - } -} - -const useMenuSetters = ( - store: MenuSnapshotStore, - setSnapshot: (snapshot: MenuSnapshot) => void, - mountedRef: { readonly current: boolean } -) => { - const commit = useSnapshotCommit(store, setSnapshot, mountedRef) - return useMemo(() => ({ - setActiveDir: (value: string | null) => { - commit((snapshot) => ({ ...snapshot, activeDir: value })) - }, - setBusy: (value: boolean) => { - commit((snapshot) => ({ ...snapshot, busy: value })) - }, - setInputStage: (value: InputStage) => { - commit((snapshot) => ({ ...snapshot, inputStage: value })) - }, - setMessage: (value: string | null) => { - commit((snapshot) => ({ ...snapshot, message: value })) - }, - setReady: (value: boolean) => { - commit((snapshot) => ({ ...snapshot, ready: value })) - }, - setRunningDockerGitContainers: (value: number) => { - commit((snapshot) => ({ ...snapshot, runningDockerGitContainers: value })) - }, - setSelected: (update: (value: number) => number) => { - commit((snapshot) => ({ ...snapshot, selected: update(snapshot.selected) })) - }, - setSkipInputs: (update: (value: number) => number) => { - commit((snapshot) => ({ ...snapshot, skipInputs: update(snapshot.skipInputs) })) - }, - setSshActive: (value: boolean) => { - commit((snapshot) => ({ ...snapshot, sshActive: value })) - }, - setView: (value: ViewState) => { - commit((snapshot) => ({ ...snapshot, view: value })) - } - }), [commit]) -} - -export const useMenuState = (store: MenuSnapshotStore, queueInteractiveEffect: QueueInteractiveEffect) => { - const [snapshot, setSnapshot] = useState(store.current) - const mountedRef = useMountedRef() - const setters = useMenuSetters(store, setSnapshot, mountedRef) - const ignoreUntil = useMemo(() => Date.now() + 400, []) - const state = useMemo(() => ({ cwd: process.cwd(), activeDir: snapshot.activeDir }), [snapshot.activeDir]) - const runner = useRunner(setters.setBusy, setters.setMessage, queueInteractiveEffect) - return { ...snapshot, ...setters, ignoreUntil, state, runner } -} - -export const useReadyGate = (setReady: (ready: boolean) => void) => { - useEffect(() => { - const timer = setTimeout(() => { - setReady(true) - }, 150) - return () => { - clearTimeout(timer) - } - }, [setReady]) -} - -export const useStartupSnapshot = ( - store: MenuSnapshotStore, - setActiveDir: (value: string | null) => void, - setRunningDockerGitContainers: (value: number) => void, - setMessage: (message: string | null) => void -) => { - useEffect(() => { - if (store.current.startupLoaded) { - return - } - let cancelled = false - const startup = pipe( - listMenuProjectItems, - Effect.map((items) => resolveMenuStartupSnapshot(items)), - Effect.match({ - onFailure: (error: MenuError) => ({ ...defaultMenuStartupSnapshot(), message: renderMenuError(error) }), - onSuccess: (snapshot) => snapshot - }), - Effect.provide(NodeContext.layer) - ) - void Effect.runPromise(startup).then((snapshot) => { - if (cancelled) { - return - } - store.current = { ...store.current, startupLoaded: true } - setRunningDockerGitContainers(snapshot.runningDockerGitContainers) - setMessage(snapshot.message) - if (snapshot.activeDir !== null) { - setActiveDir(snapshot.activeDir) - } - }) - return () => { - cancelled = true - } - }, [setActiveDir, setMessage, setRunningDockerGitContainers, store]) -} - -export const useSigintGuard = (exit: () => void, sshActive: boolean) => { - useEffect(() => { - const handleSigint = () => { - if (!sshActive) { - exit() - } - } - process.on("SIGINT", handleSigint) - return () => { - process.off("SIGINT", handleSigint) - } - }, [exit, sshActive]) -} diff --git a/packages/app/src/docker-git/menu-types.ts b/packages/app/src/docker-git/menu-types.ts deleted file mode 100644 index 7bd32792..00000000 --- a/packages/app/src/docker-git/menu-types.ts +++ /dev/null @@ -1,206 +0,0 @@ -import type * as CommandExecutor from "@effect/platform/CommandExecutor" -import type * as FileSystem from "@effect/platform/FileSystem" -import type * as Path from "@effect/platform/Path" -import type * as Effect from "effect/Effect" - -import type { GpuMode } from "./frontend-lib/core/domain.js" -import type { MenuError } from "./menu-errors.js" -import type { ProjectItem } from "./project-item.js" - -// CHANGE: isolate TUI types/constants into a shared module -// WHY: keep menu rendering and input handling small and focused -// QUOTE(ТЗ): "TUI? Красивый, удобный" -// REF: user-request-2026-02-01-tui -// SOURCE: n/a -// FORMAT THEOREM: forall s: state(s) -> wellTyped(s) -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: createSteps is ordered and total over CreateStep -// COMPLEXITY: O(1) - -export type MenuState = { - readonly cwd: string - readonly activeDir: string | null -} - -export type MenuEnv = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor - -export type MenuRunner = { - readonly runEffect: (effect: Effect.Effect) => void - readonly runInteractiveEffect: (effect: Effect.Effect) => void -} - -export type MenuViewContext = { - readonly setView: (view: ViewState) => void - readonly setMessage: (message: string | null) => void - readonly setActiveDir: (dir: string | null) => void -} - -export type MenuKeyInput = { - readonly upArrow?: boolean - readonly downArrow?: boolean - readonly return?: boolean - readonly escape?: boolean - readonly shift?: boolean - readonly backspace?: boolean - readonly delete?: boolean -} - -export type CreateInputs = { - readonly repoUrl: string - readonly repoRef: string - readonly outDir: string - readonly cpuLimit: string - readonly ramLimit: string - readonly gpu: GpuMode - readonly runUp: boolean - readonly enableMcpPlaywright: boolean - readonly force: boolean - readonly forceEnv: boolean -} - -export type CreateStep = - | "repoUrl" - | "repoRef" - | "outDir" - | "cpuLimit" - | "ramLimit" - | "gpu" - | "runUp" - | "mcpPlaywright" - | "force" - -export const createSteps: ReadonlyArray = [ - "repoUrl", - "cpuLimit", - "ramLimit", - "gpu", - "runUp", - "mcpPlaywright", - "force" -] - -export type AuthFlow = - | "GithubOauth" - | "GithubRemove" - | "GitSet" - | "GitRemove" - | "ClaudeOauth" - | "ClaudeLogout" - | "GeminiOauth" - | "GeminiApiKey" - | "GeminiLogout" - -export interface AuthSnapshot { - readonly globalEnvPath: string - readonly claudeAuthPath: string - readonly geminiAuthPath: string - readonly totalEntries: number - readonly githubTokenEntries: number - readonly gitTokenEntries: number - readonly gitUserEntries: number - readonly claudeAuthEntries: number - readonly geminiAuthEntries: number -} - -export type ProjectAuthFlow = - | "ProjectGithubConnect" - | "ProjectGithubDisconnect" - | "ProjectGitConnect" - | "ProjectGitDisconnect" - | "ProjectClaudeConnect" - | "ProjectClaudeDisconnect" - | "ProjectGeminiConnect" - | "ProjectGeminiDisconnect" - -export interface ProjectAuthSnapshot { - readonly projectDir: string - readonly projectName: string - readonly envGlobalPath: string - readonly envProjectPath: string - readonly claudeAuthPath: string - readonly geminiAuthPath: string - readonly githubTokenEntries: number - readonly gitTokenEntries: number - readonly claudeAuthEntries: number - readonly geminiAuthEntries: number - readonly activeGithubLabel: string | null - readonly activeGitLabel: string | null - readonly activeClaudeLabel: string | null - readonly activeGeminiLabel: string | null -} - -export type ViewState = - | { readonly _tag: "Menu" } - | { readonly _tag: "Create"; readonly step: number; readonly buffer: string; readonly values: Partial } - | { readonly _tag: "AuthMenu"; readonly selected: number; readonly snapshot: AuthSnapshot } - | { - readonly _tag: "AuthPrompt" - readonly flow: AuthFlow - readonly step: number - readonly buffer: string - readonly values: Readonly> - readonly snapshot: AuthSnapshot - } - | { - readonly _tag: "ProjectAuthMenu" - readonly selected: number - readonly project: ProjectItem - readonly snapshot: ProjectAuthSnapshot - } - | { - readonly _tag: "ProjectAuthPrompt" - readonly flow: ProjectAuthFlow - readonly step: number - readonly buffer: string - readonly values: Readonly> - readonly project: ProjectItem - readonly snapshot: ProjectAuthSnapshot - } - | { - readonly _tag: "SelectProject" - readonly purpose: "Connect" | "Down" | "Info" | "Delete" | "Auth" - readonly allItems: ReadonlyArray - readonly items: ReadonlyArray - readonly query: string - readonly runtimeByProject: Readonly> - readonly selected: number - readonly confirmDelete: boolean - readonly connectEnableMcpPlaywright: boolean - } - -export type SelectProjectRuntime = { - readonly running: boolean - readonly sshSessions: number - readonly startedAtIso: string | null - readonly startedAtEpochMs: number | null -} - -export type MenuAction = - | { readonly _tag: "Create" } - | { readonly _tag: "Select" } - | { readonly _tag: "Auth" } - | { readonly _tag: "ProjectAuth" } - | { readonly _tag: "Info" } - | { readonly _tag: "Up" } - | { readonly _tag: "Status" } - | { readonly _tag: "Logs" } - | { readonly _tag: "Down" } - | { readonly _tag: "DownAll" } - | { readonly _tag: "Delete" } - | { readonly _tag: "Quit" } - -export const menuItems: ReadonlyArray<{ readonly id: MenuAction; readonly label: string }> = [ - { id: { _tag: "Create" }, label: "Create project" }, - { id: { _tag: "Select" }, label: "Select project" }, - { id: { _tag: "Auth" }, label: "Auth profiles (keys)" }, - { id: { _tag: "ProjectAuth" }, label: "Project auth (bind labels)" }, - { id: { _tag: "Info" }, label: "Show connection info" }, - { id: { _tag: "Up" }, label: "docker compose up" }, - { id: { _tag: "Status" }, label: "docker compose ps" }, - { id: { _tag: "Logs" }, label: "docker compose logs --tail=200" }, - { id: { _tag: "Down" }, label: "docker compose down" }, - { id: { _tag: "DownAll" }, label: "docker compose down (ALL projects)" }, - { id: { _tag: "Delete" }, label: "Delete project (folder + container)" }, - { id: { _tag: "Quit" }, label: "Quit" } -] diff --git a/packages/app/src/docker-git/menu.ts b/packages/app/src/docker-git/menu.ts deleted file mode 100644 index a6f67d91..00000000 --- a/packages/app/src/docker-git/menu.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { Effect, pipe } from "effect" -import React, { useCallback } from "react" - -import type { GridlandModule } from "@gridland/bun" - -import { renderMenuProjectSummaries } from "./menu-api.js" -import { renderCreateStepLabel, resolveCreateFlowSteps, resolveCreateInputs } from "./menu-create-shared.js" -import type { MenuError } from "./menu-errors.js" -import { GridlandMenuProvider, runGridlandMenu, useGridlandMenuInput } from "./menu-gridland-runtime.js" -import { - renderAuthMenu, - renderAuthPrompt, - renderCreate, - renderMenu, - renderProjectAuthMenu, - renderProjectAuthPrompt, - renderSelect -} from "./menu-render.js" -import { leaveTui } from "./menu-shared.js" -import { - defaultMenuSnapshot, - type InteractiveMenuEffect, - type MenuSnapshotStore, - type QueueInteractiveEffect, - useMenuState, - useReadyGate, - useSigintGuard, - useStartupSnapshot -} from "./menu-state.js" -import { type MenuEnv, type MenuState, type ViewState } from "./menu-types.js" - -const gridlandBootstrapError = (message: string): MenuError => ({ - _tag: "TerminalSessionClientError", - message -}) - -type RenderContext = { - readonly state: MenuState - readonly view: ViewState - readonly activeDir: string | null - readonly runningDockerGitContainers: number - readonly selected: number - readonly busy: boolean - readonly message: string | null -} - -const renderView = (context: RenderContext) => { - if (context.view._tag === "Menu") { - return renderMenu({ - cwd: context.state.cwd, - activeDir: context.activeDir, - runningDockerGitContainers: context.runningDockerGitContainers, - selected: context.selected, - busy: context.busy, - message: context.message - }) - } - - if (context.view._tag === "Create") { - const currentDefaults = resolveCreateInputs(context.state.cwd, context.view.values) - const steps = resolveCreateFlowSteps(context.view.values) - const step = steps[context.view.step] ?? "repoUrl" - const label = renderCreateStepLabel(step, currentDefaults) - - return renderCreate({ - buffer: context.view.buffer, - defaults: currentDefaults, - label, - message: context.message, - stepIndex: context.view.step, - steps - }) - } - - if (context.view._tag === "AuthMenu") { - return renderAuthMenu(context.view.snapshot, context.view.selected, context.message) - } - - if (context.view._tag === "AuthPrompt") { - return renderAuthPrompt(context.view, context.message) - } - - if (context.view._tag === "ProjectAuthMenu") { - return renderProjectAuthMenu(context.view.snapshot, context.view.selected, context.message) - } - - if (context.view._tag === "ProjectAuthPrompt") { - return renderProjectAuthPrompt(context.view, context.message) - } - - return renderSelect({ - purpose: context.view.purpose, - items: context.view.items, - selected: context.view.selected, - query: context.view.query, - runtimeByProject: context.view.runtimeByProject, - confirmDelete: context.view.confirmDelete, - connectEnableMcpPlaywright: context.view.connectEnableMcpPlaywright, - message: context.message - }) -} - -type GridlandTuiAppProps = { - readonly exit: () => void - readonly gridland: GridlandModule - readonly store: MenuSnapshotStore - readonly queueInteractiveEffect: QueueInteractiveEffect -} - -const GridlandTuiApp = ({ exit, gridland, queueInteractiveEffect, store }: GridlandTuiAppProps) => { - const requestInteractiveEffect = useCallback( - (effect: InteractiveMenuEffect) => { - queueInteractiveEffect(effect) - exit() - }, - [exit, queueInteractiveEffect] - ) - const menu = useMenuState(store, requestInteractiveEffect) - - useReadyGate(menu.setReady) - useStartupSnapshot(store, menu.setActiveDir, menu.setRunningDockerGitContainers, menu.setMessage) - useSigintGuard(exit, menu.sshActive) - useGridlandMenuInput(gridland, { ...menu, exit }) - - return React.createElement( - GridlandMenuProvider, - { - children: renderView({ - state: menu.state, - view: menu.view, - activeDir: menu.activeDir, - runningDockerGitContainers: menu.runningDockerGitContainers, - selected: menu.selected, - busy: menu.busy, - message: menu.message - }), - gridland - } - ) -} - -const runGridlandMenuOnce = ( - store: MenuSnapshotStore, - queueInteractiveEffect: QueueInteractiveEffect -): Effect.Effect => - pipe( - runGridlandMenu((args) => - React.createElement(GridlandTuiApp, { - ...args, - store, - queueInteractiveEffect - }) - ), - Effect.mapError((error) => gridlandBootstrapError(error.message)), - Effect.ensuring(leaveTui()), - Effect.asVoid - ) - -const restoreMenuAfterInteractiveEffect = (store: MenuSnapshotStore): void => { - store.current = { - ...store.current, - busy: false, - ready: false, - skipInputs: 2, - sshActive: false - } -} - -// CHANGE: provide an interactive TUI menu for docker-git -// WHY: allow dynamic selection and inline create flow without raw prompts -// QUOTE(ТЗ): "TUI? Красивый, удобный" -// REF: user-request-2026-02-01-tui -// SOURCE: n/a -// FORMAT THEOREM: forall s: tui(s) -> state transitions -// PURITY: SHELL -// EFFECT: Effect -// INVARIANT: app exits only on Quit or ctrl+c -// COMPLEXITY: O(1) per input -// -// CHANGE: guard against non-TTY environments (Docker without -t) -// WHY: interactive Gridland host still requires a real TTY; without one -// fall back to the project summary renderer. -// QUOTE(ТЗ): "вечный цикл зависания на TUI из за ошибки Raw mode is not supported" -// REF: issue-100 -// SOURCE: n/a -// FORMAT THEOREM: ∀ env: isTTY(env) → renderTui ∧ ¬isTTY(env) → listProjects(api) -// INVARIANT: Gridland host only starts when stdin.isTTY ∧ stdout.isTTY -const runInteractiveMenu = (): Effect.Effect => - Effect.gen(function*(_) { - const store: MenuSnapshotStore = { current: defaultMenuSnapshot() } - const queuedInteractiveEffect: { current: InteractiveMenuEffect | null } = { current: null } - let keepRunning = true - - while (keepRunning) { - yield* _( - runGridlandMenuOnce(store, (effect) => { - queuedInteractiveEffect.current = effect - }) - ) - - const nextInteractiveEffect = queuedInteractiveEffect.current - if (nextInteractiveEffect === null) { - keepRunning = false - continue - } - - queuedInteractiveEffect.current = null - yield* _( - pipe( - leaveTui(), - Effect.zipRight(nextInteractiveEffect), - Effect.ensuring( - pipe( - Effect.sync(() => { - restoreMenuAfterInteractiveEffect(store) - }), - Effect.zipRight(leaveTui()) - ) - ) - ) - ) - } - }) - -export const runMenu: Effect.Effect = pipe( - Effect.sync(() => process.stdin.isTTY && process.stdout.isTTY), - Effect.flatMap((hasTty) => (hasTty ? runInteractiveMenu() : renderMenuProjectSummaries())) -) - -export type MenuRuntimeError = MenuError diff --git a/packages/app/src/docker-git/open-project-ssh.ts b/packages/app/src/docker-git/open-project-ssh.ts index 2881e6a8..516ed231 100644 --- a/packages/app/src/docker-git/open-project-ssh.ts +++ b/packages/app/src/docker-git/open-project-ssh.ts @@ -11,7 +11,7 @@ import { CommandFailedError } from "./frontend-lib/shell/errors.js" import { withPreservedTerminalState } from "./frontend-lib/shell/terminal-cursor.js" import { findSshPrivateKey } from "./frontend-lib/usecases/path-helpers.js" import type { HostError } from "./host-errors.js" -import { writeToTerminal } from "./menu-shared.js" +import { writeToTerminal } from "./terminal-output.js" import { type ProjectItem, projectItemFromApiDetails } from "./project-item.js" import { attachTerminalSession } from "./terminal-session-client.js" diff --git a/packages/app/src/docker-git/program.ts b/packages/app/src/docker-git/program.ts index 71b9612c..607c6226 100644 --- a/packages/app/src/docker-git/program.ts +++ b/packages/app/src/docker-git/program.ts @@ -28,7 +28,6 @@ import { type ControllerRuntime, ensureControllerReady } from "./controller.js" import type { CliError, UnsupportedCommandError } from "./host-errors.js" import { renderCliError } from "./host-errors.js" import { autoOpenProjectSsh } from "./host-ssh.js" -import { runMenu } from "./menu.js" import { openExistingProjectSsh } from "./open-project.js" import { dispatchRoutedAuthCommand, isRoutedAuthCommand } from "./program-auth.js" import { unsupportedOperationalCommands, type UnsupportedOperationalCommandTag } from "./program-unsupported.js" @@ -183,7 +182,7 @@ const unsupportedOperationalCommand = ( type DirectOperationalCommand = Extract< OperationalCommand, - { readonly _tag: "Menu" | "Browser" | "Create" | "Open" | "Status" | "DownAll" | "ApplyAll" } + { readonly _tag: "Browser" | "Create" | "Open" | "Status" | "DownAll" | "ApplyAll" } > type RoutedOperationalCommand = Exclude const dispatchRoutedOperationalCommand = ( @@ -208,7 +207,6 @@ const dispatchOperationalCommand = ( command: OperationalCommand ): Effect.Effect => Match.value(command).pipe( - Match.when({ _tag: "Menu" }, () => withControllerReady(runMenu)), Match.when({ _tag: "Browser" }, () => runBrowserFrontendCommand), Match.when({ _tag: "Create" }, handleCreateCommand), Match.when({ _tag: "Open" }, handleOpenCommand), diff --git a/packages/app/src/docker-git/terminal-output.ts b/packages/app/src/docker-git/terminal-output.ts new file mode 100644 index 00000000..e6ff267b --- /dev/null +++ b/packages/app/src/docker-git/terminal-output.ts @@ -0,0 +1,3 @@ +export const writeToTerminal = (text: string): void => { + process.stdout.write(text) +} diff --git a/packages/app/src/docker-git/terminal-session-client.ts b/packages/app/src/docker-git/terminal-session-client.ts index f1e89e8b..4db2d027 100644 --- a/packages/app/src/docker-git/terminal-session-client.ts +++ b/packages/app/src/docker-git/terminal-session-client.ts @@ -4,7 +4,7 @@ import { Effect, Either } from "effect" import { type TerminalServerMessage, TerminalServerMessageSchema } from "../shared/terminal-session-schema.js" import type { ApiTerminalSession } from "./api-client.js" import { resolveApiBaseUrl } from "./controller.js" -import { writeToTerminal } from "./menu-shared.js" +import { writeToTerminal } from "./terminal-output.js" export type TerminalSessionClientError = { readonly _tag: "TerminalSessionClientError" diff --git a/packages/app/src/lib/core/domain.ts b/packages/app/src/lib/core/domain.ts index 74bb7a64..a651a204 100644 --- a/packages/app/src/lib/core/domain.ts +++ b/packages/app/src/lib/core/domain.ts @@ -22,8 +22,6 @@ export type { AuthGitlabLogoutCommand, AuthGitlabStatusCommand } from "./auth-domain.js" -export type { MenuAction, ParseError } from "./menu.js" -export { parseMenuSelection } from "./menu.js" export { deriveRepoPathParts, deriveRepoSlug, resolveRepoInput } from "./repo.js" export type { SessionsCommand, @@ -73,6 +71,14 @@ export const sshUserNamePatternDescription = "^[a-z_][a-z0-9_-]{0,31}$" // COMPLEXITY: O(n)/O(1) where n = |value| export const isUnixUserName = (value: string): boolean => unixUserNamePattern.test(value) +export type ParseError = + | { readonly _tag: "UnknownCommand"; readonly command: string } + | { readonly _tag: "UnknownOption"; readonly option: string } + | { readonly _tag: "MissingOptionValue"; readonly option: string } + | { readonly _tag: "MissingRequiredOption"; readonly option: string } + | { readonly _tag: "InvalidOption"; readonly option: string; readonly reason: string } + | { readonly _tag: "UnexpectedArgument"; readonly value: string } + export interface TemplateConfig { readonly containerName: string readonly serviceName: string @@ -127,10 +133,6 @@ export interface CreateCommand { readonly openSsh: boolean } -export interface MenuCommand { - readonly _tag: "Menu" -} - export interface AttachCommand { readonly _tag: "Attach" readonly projectDir: string @@ -229,7 +231,6 @@ export type ScrapCommand = export type Command = | CreateCommand - | MenuCommand | AttachCommand | OpenCommand | PanesCommand diff --git a/packages/app/src/lib/core/menu.ts b/packages/app/src/lib/core/menu.ts deleted file mode 100644 index e6e4f686..00000000 --- a/packages/app/src/lib/core/menu.ts +++ /dev/null @@ -1,113 +0,0 @@ -/* jscpd:ignore-start */ -import { Either } from "effect" - -export type MenuAction = - | { readonly _tag: "Create" } - | { readonly _tag: "Select" } - | { readonly _tag: "Auth" } - | { readonly _tag: "ProjectAuth" } - | { readonly _tag: "Info" } - | { readonly _tag: "Up" } - | { readonly _tag: "Status" } - | { readonly _tag: "Logs" } - | { readonly _tag: "Down" } - | { readonly _tag: "DownAll" } - | { readonly _tag: "Delete" } - | { readonly _tag: "Quit" } - -export type ParseError = - | { readonly _tag: "UnknownCommand"; readonly command: string } - | { readonly _tag: "UnknownOption"; readonly option: string } - | { readonly _tag: "MissingOptionValue"; readonly option: string } - | { readonly _tag: "MissingRequiredOption"; readonly option: string } - | { readonly _tag: "InvalidOption"; readonly option: string; readonly reason: string } - | { readonly _tag: "UnexpectedArgument"; readonly value: string } - -const normalizeMenuInput = (input: string): string => input.trim().toLowerCase() - -const menuAliasMap = new Map([ - ["1", { _tag: "Create" }], - ["create", { _tag: "Create" }], - ["c", { _tag: "Create" }], - ["2", { _tag: "Select" }], - ["select", { _tag: "Select" }], - ["s", { _tag: "Select" }], - ["3", { _tag: "Auth" }], - ["auth", { _tag: "Auth" }], - ["a", { _tag: "Auth" }], - ["4", { _tag: "ProjectAuth" }], - ["project-auth", { _tag: "ProjectAuth" }], - ["projectauth", { _tag: "ProjectAuth" }], - ["pa", { _tag: "ProjectAuth" }], - ["5", { _tag: "Info" }], - ["info", { _tag: "Info" }], - ["i", { _tag: "Info" }], - ["up", { _tag: "Up" }], - ["u", { _tag: "Up" }], - ["start", { _tag: "Up" }], - ["6", { _tag: "Status" }], - ["status", { _tag: "Status" }], - ["ps", { _tag: "Status" }], - ["7", { _tag: "Logs" }], - ["logs", { _tag: "Logs" }], - ["log", { _tag: "Logs" }], - ["l", { _tag: "Logs" }], - ["8", { _tag: "Down" }], - ["down", { _tag: "Down" }], - ["stop", { _tag: "Down" }], - ["d", { _tag: "Down" }], - ["9", { _tag: "DownAll" }], - ["down-all", { _tag: "DownAll" }], - ["downall", { _tag: "DownAll" }], - ["stop-all", { _tag: "DownAll" }], - ["stopall", { _tag: "DownAll" }], - ["kill-all", { _tag: "DownAll" }], - ["killall", { _tag: "DownAll" }], - ["da", { _tag: "DownAll" }], - ["10", { _tag: "Delete" }], - ["delete", { _tag: "Delete" }], - ["del", { _tag: "Delete" }], - ["remove", { _tag: "Delete" }], - ["rm", { _tag: "Delete" }], - ["0", { _tag: "Quit" }], - ["11", { _tag: "Quit" }], - ["quit", { _tag: "Quit" }], - ["q", { _tag: "Quit" }], - ["exit", { _tag: "Quit" }] -]) - -const resolveMenuAction = (normalized: string): MenuAction | undefined => menuAliasMap.get(normalized) - -// CHANGE: decode interactive menu input into a typed action -// WHY: keep menu parsing pure and reusable across shells -// QUOTE(ТЗ): "Хочу что бы открылось менюшка" -// REF: user-request-2026-01-07 -// SOURCE: n/a -// FORMAT THEOREM: forall s: parseMenu(s) = a -> deterministic(a) -// PURITY: CORE -// EFFECT: Effect -// INVARIANT: unknown input maps to InvalidOption -// COMPLEXITY: O(1) -export const parseMenuSelection = (input: string): Either.Either => { - const normalized = normalizeMenuInput(input) - - if (normalized.length === 0) { - return Either.left({ - _tag: "InvalidOption", - option: "menu", - reason: "empty selection" - }) - } - - const action = resolveMenuAction(normalized) - if (action === undefined) { - return Either.left({ - _tag: "InvalidOption", - option: "menu", - reason: `unknown selection: ${input}` - }) - } - - return Either.right(action) -} -/* jscpd:ignore-end */ diff --git a/packages/app/src/lib/usecases/auth-gemini.ts b/packages/app/src/lib/usecases/auth-gemini.ts index b71b10e8..49d599ca 100644 --- a/packages/app/src/lib/usecases/auth-gemini.ts +++ b/packages/app/src/lib/usecases/auth-gemini.ts @@ -72,10 +72,10 @@ export const authGeminiLoginCli = ( yield* _(Effect.log("1. API Key (recommended for simplicity):")) yield* _(Effect.log(" - Go to https://ai.google.dev/aistudio")) yield* _(Effect.log(" - Create or retrieve your API key")) - yield* _(Effect.log(" - Use: docker-git menu -> Auth profiles -> Gemini CLI: set API key")) + yield* _(Effect.log(" - Use: docker-git browser -> Auth profiles -> Gemini CLI: set API key")) yield* _(Effect.log("")) yield* _(Effect.log("2. OAuth (Sign in with Google):")) - yield* _(Effect.log(" - Use: docker-git menu -> Auth profiles -> Gemini CLI: login via OAuth")) + yield* _(Effect.log(" - Use: docker-git browser -> Auth profiles -> Gemini CLI: login via OAuth")) yield* _(Effect.log(" - Follow the prompts to authenticate with your Google account")) }) diff --git a/packages/app/src/lib/usecases/projects-delete.ts b/packages/app/src/lib/usecases/projects-delete.ts index 64699fde..a8c9f626 100644 --- a/packages/app/src/lib/usecases/projects-delete.ts +++ b/packages/app/src/lib/usecases/projects-delete.ts @@ -63,7 +63,7 @@ const removeContainersFallback = ( yield* _(removeContainerByName(item.projectDir, `${item.containerName}-browser`)) }) -// CHANGE: delete a docker-git project directory (state) selected in the TUI +// CHANGE: delete a docker-git project directory (state) selected by a caller // WHY: allow removing unwanted projects without rewriting git history (just delete the folder) // QUOTE(ТЗ): "Сделай возможность так же удалять мусорный для меня контейнер... Не нужно чистить гит историю. Пусть просто папку с ним удалит" // REF: user-request-2026-02-09-delete-project diff --git a/packages/app/src/lib/usecases/projects-down.ts b/packages/app/src/lib/usecases/projects-down.ts index dff47174..6eed5aaa 100644 --- a/packages/app/src/lib/usecases/projects-down.ts +++ b/packages/app/src/lib/usecases/projects-down.ts @@ -12,7 +12,7 @@ import { renderError } from "./errors.js" import { forEachProjectStatus, loadProjectIndex, renderProjectStatusHeader } from "./projects-core.js" // CHANGE: provide a "stop all" helper for docker-git managed projects -// WHY: allow quickly stopping all running docker-git containers from the CLI/TUI +// WHY: allow quickly stopping all running docker-git containers from CLI/API callers // QUOTE(ТЗ): "Выведи сюда возможность убивать все контейнеры" // REF: user-request-2026-02-06-stop-all // SOURCE: n/a diff --git a/packages/app/src/lib/usecases/projects-list.ts b/packages/app/src/lib/usecases/projects-list.ts index 45d6ab35..06d1de25 100644 --- a/packages/app/src/lib/usecases/projects-list.ts +++ b/packages/app/src/lib/usecases/projects-list.ts @@ -66,7 +66,7 @@ export const listProjects: Effect.Effect< ) // CHANGE: collect docker-git connection info lines without logging -// WHY: allow TUI to render connection info inline +// WHY: allow API and browser flows to render connection info inline // QUOTE(ТЗ): "А кнопка \"Show connection info\" ничего не отображает" // REF: user-request-2026-02-01-tui-info // SOURCE: n/a @@ -130,7 +130,7 @@ export const listProjectSummaries: Effect.Effect< ListProjectsContext > = listProjectValues(loadProjectSummary, renderProjectSummary, emptySummaries) -// CHANGE: load docker-git projects for TUI selection +// CHANGE: load docker-git projects for structured selection // WHY: provide structured project data without noisy logs // QUOTE(ТЗ): "А ты можешь сделать удобный выбор проектов?" // REF: user-request-2026-02-02-select-project diff --git a/packages/app/src/lib/usecases/projects-ssh.ts b/packages/app/src/lib/usecases/projects-ssh.ts index 845c4d34..e61e7725 100644 --- a/packages/app/src/lib/usecases/projects-ssh.ts +++ b/packages/app/src/lib/usecases/projects-ssh.ts @@ -155,7 +155,7 @@ const connectPreparedProjectSsh = ( ) // CHANGE: connect to a project via SSH using its resolved settings -// WHY: allow TUI to open a shell immediately after selection +// WHY: allow project selection flows to open a shell immediately after selection // QUOTE(ТЗ): "выбор проекта сразу подключает по SSH" // REF: user-request-2026-02-02-select-ssh // SOURCE: n/a diff --git a/packages/app/src/lib/usecases/state-repo/git-commands.ts b/packages/app/src/lib/usecases/state-repo/git-commands.ts index 71841775..a8f3a743 100644 --- a/packages/app/src/lib/usecases/state-repo/git-commands.ts +++ b/packages/app/src/lib/usecases/state-repo/git-commands.ts @@ -9,7 +9,7 @@ import { CommandFailedError } from "../../shell/errors.js" export const successExitCode = Number(ExitCode(0)) export const gitBaseEnv: Readonly> = { - // Avoid blocking on interactive credential prompts in CI / TUI contexts. + // Avoid blocking on interactive credential prompts in CI/API contexts. GIT_TERMINAL_PROMPT: "0", // Avoid SSH hanging on host key prompts or passphrases GIT_SSH_COMMAND: "ssh -o BatchMode=yes", diff --git a/packages/app/src/lib/usecases/terminal-cursor.ts b/packages/app/src/lib/usecases/terminal-cursor.ts index 006bc44c..fb7c8012 100644 --- a/packages/app/src/lib/usecases/terminal-cursor.ts +++ b/packages/app/src/lib/usecases/terminal-cursor.ts @@ -184,10 +184,10 @@ const restoreTerminalState = ( export const ensureTerminalCursorVisible = (): Effect.Effect => repairInteractiveTerminalEffect() -// CHANGE: share the low-level tty repair across SSH launch and TUI suspend/resume -// WHY: both paths must reset the same controlling terminal before interactive output +// CHANGE: share the low-level tty repair across interactive terminal launches +// WHY: terminal sessions must reset the same controlling terminal before interactive output // QUOTE(ТЗ): "при подключении по SSH контейнер забаганный. Кривокосо печатается текст" -// REF: user-request-2026-04-20-menu-select-ssh-terminal +// REF: user-request-2026-04-20-ssh-terminal // SOURCE: n/a // FORMAT THEOREM: forall t: interactive(t) -> sane_tty(t) // PURITY: SHELL diff --git a/packages/app/src/ui/primitives-gridland.tsx b/packages/app/src/ui/primitives-gridland.tsx deleted file mode 100644 index 3393086f..00000000 --- a/packages/app/src/ui/primitives-gridland.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import React, { type JSX } from "react" - -import type { GridlandInputProps, GridlandModule } from "@gridland/bun" - -import type { UiBoxProps, UiButtonProps, UiTextInputProps, UiTextProps } from "./primitives.js" - -const renderInputValue = (props: UiTextInputProps): string => { - if (props.value.length === 0) { - return props.placeholder ?? "" - } - return props.secret ? "*".repeat(props.value.length) : props.value -} - -const inputProps = (props: UiTextInputProps): GridlandInputProps => ({ - placeholder: props.placeholder, - value: renderInputValue(props) -}) - -type GridlandBoxHostProps = { - readonly alignItems?: UiBoxProps["alignItems"] - readonly backgroundColor?: string - readonly border?: boolean - readonly borderColor?: string - readonly borderStyle?: UiBoxProps["borderStyle"] - readonly children?: UiBoxProps["children"] - readonly flexDirection?: UiBoxProps["flexDirection"] - readonly flexGrow?: number - readonly flexWrap?: UiBoxProps["flexWrap"] - readonly fg?: string - readonly gap?: UiBoxProps["gap"] - readonly height?: UiBoxProps["height"] - readonly justifyContent?: UiBoxProps["justifyContent"] - readonly marginBottom?: UiBoxProps["marginBottom"] - readonly marginLeft?: UiBoxProps["marginLeft"] - readonly marginRight?: UiBoxProps["marginRight"] - readonly marginTop?: UiBoxProps["marginTop"] - readonly onClick?: UiBoxProps["onClick"] - readonly padding?: UiBoxProps["padding"] - readonly width?: UiBoxProps["width"] -} - -type GridlandTextHostProps = GridlandBoxHostProps & { - readonly bg?: string - readonly bold?: boolean - readonly truncate?: boolean -} - -const boxProps = ({ children, ...props }: UiBoxProps): GridlandBoxHostProps => ({ - ...props, - children -}) - -const textProps = ({ backgroundColor, children, wrap, ...props }: UiTextProps): GridlandTextHostProps => ({ - ...props, - ...(backgroundColor === undefined ? {} : { bg: backgroundColor }), - ...(wrap === "truncate" ? { truncate: true } : {}), - children -}) - -// CHANGE: render Gridland primitives through host component tags instead of helper functions -// WHY: @gridland/bun helper functions return Gridland vnode objects, not React elements; using them as JSX -// children causes React reconciliation to reject the returned object tree in the TTY menu runtime -// QUOTE(ТЗ): "ЧТо бы я мог меню открыть" -// REF: user-request-2026-04-13-gridland-menu-fix -// SOURCE: n/a -// FORMAT THEOREM: ∀p: hostTag(p) → reactElement(p) ∧ renderable(p) -// PURITY: SHELL -// EFFECT: n/a -// INVARIANT: Gridland TUI primitives always return React elements with supported host tags -// COMPLEXITY: O(1) -export const createGridlandPrimitives = (_gridland: GridlandModule) => - ({ - Box: ({ children, ...props }: UiBoxProps): JSX.Element => - React.createElement("box", { - ...boxProps({ ...props, children }) - }), - Button: ({ label, onPress }: UiButtonProps): JSX.Element => - React.createElement("text", { - children: `[${label}]`, - fg: "cyan", - onClick: onPress - }), - Text: ({ children, ...props }: UiTextProps): JSX.Element => - React.createElement("text", { - ...textProps({ ...props, children }) - }), - TextInput: (props: UiTextInputProps): JSX.Element => - React.createElement("input", { - ...inputProps(props), - focused: props.autoFocus, - onChange: props.onChange, - onSubmit: () => { - props.onEnter?.(false) - } - }) - }) as const diff --git a/packages/app/src/web/action-prompt.ts b/packages/app/src/web/action-prompt.ts index 47f4edea..73104dd2 100644 --- a/packages/app/src/web/action-prompt.ts +++ b/packages/app/src/web/action-prompt.ts @@ -1,5 +1,5 @@ -import { type AuthMenuAction, authViewSteps, authViewTitle } from "../docker-git/menu-auth-shared.js" -import { type ProjectAuthMenuAction, projectAuthViewSteps } from "../docker-git/menu-project-auth-shared.js" +import { type AuthMenuAction, authViewSteps, authViewTitle } from "./auth-flow.js" +import { type ProjectAuthMenuAction, projectAuthViewSteps } from "./project-auth-flow.js" export type ActionPromptStep = { readonly key: string diff --git a/packages/app/src/web/actions-auth.ts b/packages/app/src/web/actions-auth.ts index 69e35b00..e49e5dcc 100644 --- a/packages/app/src/web/actions-auth.ts +++ b/packages/app/src/web/actions-auth.ts @@ -5,12 +5,12 @@ import { authViewSteps, authViewTitle, successMessage as authSuccessMessage -} from "../docker-git/menu-auth-shared.js" +} from "./auth-flow.js" import { type ProjectAuthMenuAction, projectAuthSuccessMessage, projectAuthViewSteps -} from "../docker-git/menu-project-auth-shared.js" +} from "./project-auth-flow.js" import { type ActionPromptState, createAuthActionPrompt, diff --git a/packages/app/src/web/actions-project-create.ts b/packages/app/src/web/actions-project-create.ts index c65140d9..47d97fdb 100644 --- a/packages/app/src/web/actions-project-create.ts +++ b/packages/app/src/web/actions-project-create.ts @@ -1,8 +1,8 @@ import * as ParseResult from "@effect/schema/ParseResult" import { Either } from "effect" -import { createProjectDraftFromInputs } from "../docker-git/menu-create-shared.js" -import type { CreateInputs } from "../docker-git/menu-types.js" +import { createProjectDraftFromInputs } from "./create-flow.js" +import type { CreateInputs } from "./create-types.js" import { readEventPayloadString } from "./actions-event-payload.js" import { appendOutputLine, appendOutputLineHandler, notifyProjectEventRateLimit } from "./actions-output.js" import { type BrowserActionContext, withBusy } from "./actions-shared.js" diff --git a/packages/app/src/web/actions-projects.ts b/packages/app/src/web/actions-projects.ts index 5cf90232..19d438bf 100644 --- a/packages/app/src/web/actions-projects.ts +++ b/packages/app/src/web/actions-projects.ts @@ -459,10 +459,10 @@ export const runProjectMenuAction = ( loadSelectedProjectSkills(context) return } - runProjectMenuCommand(currentMenu, context) + runProjectBrowserCommand(currentMenu, context) } -const runProjectMenuCommand = ( +const runProjectBrowserCommand = ( currentMenu: Exclude< BrowserMenuTag, | "Auth" diff --git a/packages/app/src/web/api.ts b/packages/app/src/web/api.ts index 80aff3a5..63528c0b 100644 --- a/packages/app/src/web/api.ts +++ b/packages/app/src/web/api.ts @@ -1,7 +1,7 @@ import { Effect } from "effect" -import { sortSelectItemsByLaunchTime } from "../docker-git/menu-select-order.js" -import type { SelectProjectRuntime } from "../docker-git/menu-types.js" +import { sortSelectItemsByLaunchTime } from "./project-select-order.js" +import type { SelectProjectRuntime } from "./project-select-types.js" import type { AuthMenuRequestBody, ProjectAuthMenuRequestBody } from "../shared/auth-menu-request.js" import { requestJson, requestText, requestTextStream, resolveApiBaseUrl } from "./api-http.js" import { @@ -81,8 +81,8 @@ const dashboardRuntimeByProject = ( ]) ) -// CHANGE: keep WEB `/select/` project order identical to CLI Select -// WHY: both surfaces must use the same launch-time ordering and tie-breakers +// CHANGE: keep browser project order consistent across panels +// WHY: browser surfaces must use the same launch-time ordering and tie-breakers // QUOTE(ТЗ): "мы можем иметь 1 в 1 логику что в CLI что на WEB?" // REF: user-message-2026-04-21-unify-cli-web-select // SOURCE: n/a diff --git a/packages/app/src/web/app-ready-actions.ts b/packages/app/src/web/app-ready-actions.ts index 8bc1423c..d9af5706 100644 --- a/packages/app/src/web/app-ready-actions.ts +++ b/packages/app/src/web/app-ready-actions.ts @@ -1,5 +1,5 @@ -import { authMenuActionByIndex } from "../docker-git/menu-auth-shared.js" -import { projectAuthMenuActionByIndex } from "../docker-git/menu-project-auth-shared.js" +import { authMenuActionByIndex } from "./auth-flow.js" +import { projectAuthMenuActionByIndex } from "./project-auth-flow.js" import { runBrowserAuthAction, runBrowserProjectAuthAction } from "./actions.js" import type { BrowserActionContext } from "./actions.js" import type { BrowserMenuTag } from "./menu.js" diff --git a/packages/app/src/web/app-ready-create.ts b/packages/app/src/web/app-ready-create.ts index 7068100e..e34e9c02 100644 --- a/packages/app/src/web/app-ready-create.ts +++ b/packages/app/src/web/app-ready-create.ts @@ -1,13 +1,13 @@ import { type Dispatch, type SetStateAction, useEffect } from "react" import { formatParseError } from "../docker-git/cli/usage.js" -import { nextBufferValue } from "../docker-git/menu-buffer-input.js" +import { nextBufferValue } from "./buffer-input.js" import { advanceCreateFlow, type CreateFlowView, createInitialFlowView, handleAdvanceCreateFlowResult -} from "../docker-git/menu-create-shared.js" +} from "./create-flow.js" import { submitCreateInputs } from "./actions-projects.js" import { requireGithubAuthConfigured } from "./actions-shared.js" import type { BrowserActionContext } from "./actions.js" diff --git a/packages/app/src/web/app-ready-layout.tsx b/packages/app/src/web/app-ready-layout.tsx index b9d7e5a1..986ae53a 100644 --- a/packages/app/src/web/app-ready-layout.tsx +++ b/packages/app/src/web/app-ready-layout.tsx @@ -1,6 +1,6 @@ import type { JSX } from "react" -import type { CreateFlowView } from "../docker-git/menu-create-shared.js" +import type { CreateFlowView } from "./create-flow.js" import type { ActionPromptState } from "./action-prompt.js" import type { AuthSnapshot, @@ -140,7 +140,7 @@ const terminalWorkspacePadding = (viewportLayout: ViewportLayout): string => { const HeaderTitle = ({ compact }: Pick): JSX.Element => ( docker-git browser - {compact ? null : Gridland menu shell} + {compact ? null : browser workspace} ) diff --git a/packages/app/src/web/app-ready-main-panels.tsx b/packages/app/src/web/app-ready-main-panels.tsx index bc0624bf..287bb019 100644 --- a/packages/app/src/web/app-ready-main-panels.tsx +++ b/packages/app/src/web/app-ready-main-panels.tsx @@ -87,7 +87,7 @@ const MainMenuRoute = ( ): JSX.Element => ( diff --git a/packages/app/src/web/app-ready-shortcut-runtime.ts b/packages/app/src/web/app-ready-shortcut-runtime.ts index dce83586..f0bf7b8e 100644 --- a/packages/app/src/web/app-ready-shortcut-runtime.ts +++ b/packages/app/src/web/app-ready-shortcut-runtime.ts @@ -1,6 +1,6 @@ import type { Dispatch, SetStateAction } from "react" -import type { CreateFlowView } from "../docker-git/menu-create-shared.js" +import type { CreateFlowView } from "./create-flow.js" import type { ActionPromptState } from "./action-prompt.js" import type { BrowserActionContext } from "./actions.js" import { runBrowserMenuAction } from "./actions.js" diff --git a/packages/app/src/web/app-ready-state.ts b/packages/app/src/web/app-ready-state.ts index 70e8d662..5ba58ee8 100644 --- a/packages/app/src/web/app-ready-state.ts +++ b/packages/app/src/web/app-ready-state.ts @@ -1,6 +1,6 @@ import { type Dispatch, type SetStateAction, useState } from "react" -import type { CreateFlowView } from "../docker-git/menu-create-shared.js" +import type { CreateFlowView } from "./create-flow.js" import type { ActionPromptState } from "./action-prompt.js" import type { BrowserActionContext } from "./actions.js" import type { diff --git a/packages/app/src/docker-git/menu-auth-shared.ts b/packages/app/src/web/auth-flow.ts similarity index 96% rename from packages/app/src/docker-git/menu-auth-shared.ts rename to packages/app/src/web/auth-flow.ts index 7e185224..e0fc105d 100644 --- a/packages/app/src/docker-git/menu-auth-shared.ts +++ b/packages/app/src/web/auth-flow.ts @@ -1,6 +1,15 @@ import { Match } from "effect" -import type { AuthFlow } from "./menu-types.js" +export type AuthFlow = + | "GithubOauth" + | "GithubRemove" + | "GitSet" + | "GitRemove" + | "ClaudeOauth" + | "ClaudeLogout" + | "GeminiOauth" + | "GeminiApiKey" + | "GeminiLogout" export type AuthMenuAction = AuthFlow | "Refresh" | "Back" diff --git a/packages/app/src/docker-git/menu-buffer-input.ts b/packages/app/src/web/buffer-input.ts similarity index 100% rename from packages/app/src/docker-git/menu-buffer-input.ts rename to packages/app/src/web/buffer-input.ts diff --git a/packages/app/src/docker-git/menu-create-shared.ts b/packages/app/src/web/create-flow.ts similarity index 97% rename from packages/app/src/docker-git/menu-create-shared.ts rename to packages/app/src/web/create-flow.ts index d925145c..b945a62f 100644 --- a/packages/app/src/docker-git/menu-create-shared.ts +++ b/packages/app/src/web/create-flow.ts @@ -7,13 +7,13 @@ import { isGpuMode, type ParseError, resolveRepoInput -} from "./frontend-lib/core/domain.js" -import { defaultProjectsRoot } from "./frontend-lib/usecases/menu-helpers.js" +} from "../docker-git/frontend-lib/core/domain.js" +import { defaultProjectsRoot } from "../docker-git/frontend-lib/usecases/menu-helpers.js" -import { buildCreateCommand } from "./cli/parser-create.js" -import { parseRawOptions } from "./cli/parser-options.js" -import { splitPositionalRepo } from "./cli/parser-shared.js" -import { type CreateInputs, type CreateStep, createSteps } from "./menu-types.js" +import { buildCreateCommand } from "../docker-git/cli/parser-create.js" +import { parseRawOptions } from "../docker-git/cli/parser-options.js" +import { splitPositionalRepo } from "../docker-git/cli/parser-shared.js" +import { type CreateInputs, type CreateStep, createSteps } from "./create-types.js" type Mutable = { -readonly [K in keyof T]: T[K] } diff --git a/packages/app/src/web/create-types.ts b/packages/app/src/web/create-types.ts new file mode 100644 index 00000000..6b61687a --- /dev/null +++ b/packages/app/src/web/create-types.ts @@ -0,0 +1,35 @@ +import type { GpuMode } from "../docker-git/frontend-lib/core/domain.js" + +export type CreateInputs = { + readonly repoUrl: string + readonly repoRef: string + readonly outDir: string + readonly cpuLimit: string + readonly ramLimit: string + readonly gpu: GpuMode + readonly runUp: boolean + readonly enableMcpPlaywright: boolean + readonly force: boolean + readonly forceEnv: boolean +} + +export type CreateStep = + | "repoUrl" + | "repoRef" + | "outDir" + | "cpuLimit" + | "ramLimit" + | "gpu" + | "runUp" + | "mcpPlaywright" + | "force" + +export const createSteps: ReadonlyArray = [ + "repoUrl", + "cpuLimit", + "ramLimit", + "gpu", + "runUp", + "mcpPlaywright", + "force" +] diff --git a/packages/app/src/web/github-auth-gate.ts b/packages/app/src/web/github-auth-gate.ts index 1f4065eb..8d54ff7c 100644 --- a/packages/app/src/web/github-auth-gate.ts +++ b/packages/app/src/web/github-auth-gate.ts @@ -1,4 +1,4 @@ -import { authMenuActionByIndex } from "../docker-git/menu-auth-shared.js" +import { authMenuActionByIndex } from "./auth-flow.js" import type { ActionPromptState } from "./action-prompt.js" import type { GithubAuthStatus } from "./api.js" diff --git a/packages/app/src/web/menu.ts b/packages/app/src/web/menu.ts index 441654b1..a56d0968 100644 --- a/packages/app/src/web/menu.ts +++ b/packages/app/src/web/menu.ts @@ -1,5 +1,3 @@ -import { menuItems } from "../docker-git/menu-types.js" - export type BrowserMenuTag = | "Create" | "Select" @@ -39,9 +37,29 @@ const browserMenuOrder: ReadonlyArray = [ "Quit" ] +const browserMenuLabels: Readonly> = { + Auth: "Auth profiles (keys)", + Browser: "Open browser", + Create: "Create project", + Databases: "Databases", + Delete: "Delete project (folder + container)", + Down: "docker compose down", + DownAll: "docker compose down (ALL projects)", + Info: "Show connection info", + Logs: "docker compose logs --tail=200", + Ports: "Port forwards", + ProjectAuth: "Project auth (bind labels)", + Prompts: "Prompts", + Quit: "Quit", + Select: "Select project", + Skills: "Skills", + Status: "docker compose ps", + Tasks: "Tasks" +} + export const browserMenuItems = browserMenuOrder.map((tag) => ({ tag, - label: menuItems.find((item) => item.id._tag === tag)?.label ?? tag + label: browserMenuLabels[tag] })) export const browserMenuIndex = (tag: BrowserMenuTag): number => { diff --git a/packages/app/src/web/panel-auth.tsx b/packages/app/src/web/panel-auth.tsx index f520f560..755892f1 100644 --- a/packages/app/src/web/panel-auth.tsx +++ b/packages/app/src/web/panel-auth.tsx @@ -1,6 +1,6 @@ import type { JSX } from "react" -import { authMenuActionByIndex, authMenuLabels } from "../docker-git/menu-auth-shared.js" +import { authMenuActionByIndex, authMenuLabels } from "./auth-flow.js" import type { ActionPromptState } from "./action-prompt.js" import type { AuthSnapshot, GithubAuthStatus } from "./api.js" import { Box, Text } from "./elements.js" diff --git a/packages/app/src/web/panel-content.tsx b/packages/app/src/web/panel-content.tsx index b7d0deed..5cfc1d48 100644 --- a/packages/app/src/web/panel-content.tsx +++ b/packages/app/src/web/panel-content.tsx @@ -1,7 +1,7 @@ import { Match } from "effect" import type { JSX } from "react" -import type { CreateFlowView } from "../docker-git/menu-create-shared.js" +import type { CreateFlowView } from "./create-flow.js" import type { ActionPromptState } from "./action-prompt.js" import type { AuthSnapshot, GithubAuthStatus, ProjectAuthSnapshot, ProjectDetails, ProjectSummary } from "./api.js" import { Box, Text } from "./elements.js" diff --git a/packages/app/src/web/panel-create-select.tsx b/packages/app/src/web/panel-create-select.tsx index 45600e3f..a19be626 100644 --- a/packages/app/src/web/panel-create-select.tsx +++ b/packages/app/src/web/panel-create-select.tsx @@ -6,8 +6,8 @@ import { renderCreateStepLabel, resolveCreateFlowSteps, resolveCreateInputs -} from "../docker-git/menu-create-shared.js" -import type { CreateStep } from "../docker-git/menu-types.js" +} from "./create-flow.js" +import type { CreateStep } from "./create-types.js" import { Box, Button, Text, TextInput } from "../ui/primitives.js" import { HelpLines } from "../ui/shared.js" diff --git a/packages/app/src/web/panel-layout.tsx b/packages/app/src/web/panel-layout.tsx index e6e60f42..1dc8f649 100644 --- a/packages/app/src/web/panel-layout.tsx +++ b/packages/app/src/web/panel-layout.tsx @@ -45,7 +45,7 @@ const menuListTopMargin = (compact: boolean): number | string => compact ? "6px" const MenuHeader = ({ compact }: Pick): JSX.Element => ( - {compact ? "docker-git menu" : "bun run docker-git"} + {compact ? "docker-git browser" : "bun run docker-git -- browser"} {compact ? null : browser inheritance shell} ) diff --git a/packages/app/src/web/panel-project-auth.tsx b/packages/app/src/web/panel-project-auth.tsx index 03b25a13..a2e804b1 100644 --- a/packages/app/src/web/panel-project-auth.tsx +++ b/packages/app/src/web/panel-project-auth.tsx @@ -1,6 +1,6 @@ import type { JSX } from "react" -import { projectAuthMenuLabels } from "../docker-git/menu-project-auth-shared.js" +import { projectAuthMenuLabels } from "./project-auth-flow.js" import type { ActionPromptState } from "./action-prompt.js" import type { ProjectAuthSnapshot } from "./api.js" import { Box, Text } from "./elements.js" diff --git a/packages/app/src/web/panel-project-details.tsx b/packages/app/src/web/panel-project-details.tsx index dd6ff2e9..5b4ab394 100644 --- a/packages/app/src/web/panel-project-details.tsx +++ b/packages/app/src/web/panel-project-details.tsx @@ -8,7 +8,7 @@ import { type SelectPurpose, selectTitle, stoppedRuntime -} from "../docker-git/menu-select-presenter.js" +} from "./project-select-presenter.js" import { Box, Text } from "../ui/primitives.js" import { HelpLines } from "../ui/shared.js" import { loadProjectTerminalSessions, type ProjectDetails, type ProjectSummary, type TerminalSession } from "./api.js" diff --git a/packages/app/src/web/panel-project-list.tsx b/packages/app/src/web/panel-project-list.tsx index eb755a29..0e9e392c 100644 --- a/packages/app/src/web/panel-project-list.tsx +++ b/packages/app/src/web/panel-project-list.tsx @@ -1,6 +1,6 @@ import type { JSX } from "react" -import { buildSelectLabels, type SelectPurpose } from "../docker-git/menu-select-presenter.js" +import { buildSelectLabels, type SelectPurpose } from "./project-select-presenter.js" import { TextInput } from "../ui/primitives.js" import type { DashboardData } from "./api.js" import { Box, Text } from "./elements.js" diff --git a/packages/app/src/docker-git/menu-project-auth-shared.ts b/packages/app/src/web/project-auth-flow.ts similarity index 92% rename from packages/app/src/docker-git/menu-project-auth-shared.ts rename to packages/app/src/web/project-auth-flow.ts index 6250fe5f..02047421 100644 --- a/packages/app/src/docker-git/menu-project-auth-shared.ts +++ b/packages/app/src/web/project-auth-flow.ts @@ -1,6 +1,14 @@ import { Match } from "effect" -import type { ProjectAuthFlow } from "./menu-types.js" +export type ProjectAuthFlow = + | "ProjectGithubConnect" + | "ProjectGithubDisconnect" + | "ProjectGitConnect" + | "ProjectGitDisconnect" + | "ProjectClaudeConnect" + | "ProjectClaudeDisconnect" + | "ProjectGeminiConnect" + | "ProjectGeminiDisconnect" export type ProjectAuthMenuAction = ProjectAuthFlow | "Refresh" | "Back" diff --git a/packages/app/src/web/project-search.ts b/packages/app/src/web/project-search.ts index 58f4abc4..53089553 100644 --- a/packages/app/src/web/project-search.ts +++ b/packages/app/src/web/project-search.ts @@ -1,8 +1,8 @@ -import { filterSelectItemsByQuery } from "../docker-git/menu-select-filter.js" +import { filterSelectItemsByQuery } from "./project-select-search.js" import type { DashboardData, ProjectSummary } from "./api.js" -// CHANGE: use Select search semantics for WEB project search -// WHY: container-name search must be identical in CLI Select and WEB ProjectPicker +// CHANGE: use shared browser project search semantics +// WHY: container-name search must be identical across browser project pickers // QUOTE(ТЗ): "Можешь добавить ещё поиск контейнеров по имени?" // REF: user-message-2026-04-22-container-name-search // SOURCE: n/a diff --git a/packages/app/src/docker-git/menu-select-order.ts b/packages/app/src/web/project-select-order.ts similarity index 72% rename from packages/app/src/docker-git/menu-select-order.ts rename to packages/app/src/web/project-select-order.ts index 7a06a547..e1abc475 100644 --- a/packages/app/src/docker-git/menu-select-order.ts +++ b/packages/app/src/web/project-select-order.ts @@ -1,6 +1,4 @@ -import type { ProjectItem } from "./project-item.js" - -import type { SelectProjectRuntime } from "./menu-types.js" +import type { SelectProjectRuntime } from "./project-select-types.js" const defaultRuntime = (): SelectProjectRuntime => ({ running: false, @@ -22,12 +20,12 @@ const runtimeForKey = ( projectKey: string ): SelectProjectRuntime => runtimeByProject[projectKey] ?? defaultRuntime() -// CHANGE: make CLI and WEB select order share one pure comparator -// WHY: `/select/` must present projects in the same order as the TUI select view +// CHANGE: make browser project selection order use one pure comparator +// WHY: project lists must present the same launch-time ordering across browser panels // QUOTE(ТЗ): "мы можем иметь 1 в 1 логику что в CLI что на WEB?" // REF: user-message-2026-04-21-unify-cli-web-select // SOURCE: n/a -// FORMAT THEOREM: forall xs: sort_web(xs) = sort_cli(xs) when accessors identify the same project/runtime fields +// FORMAT THEOREM: forall xs: sort_browser(xs) is deterministic when accessors identify the same project/runtime fields // PURITY: CORE // EFFECT: none // INVARIANT: newer launch timestamps sort first; missing timestamps sort last @@ -55,12 +53,3 @@ export const sortSelectItemsByLaunchTime = ( const displayNameOrder = accessors.displayName(left).localeCompare(accessors.displayName(right)) return displayNameOrder === 0 ? leftKey.localeCompare(rightKey) : displayNameOrder }) - -export const sortItemsByLaunchTime = ( - items: ReadonlyArray, - runtimeByProject: Readonly> -): ReadonlyArray => - sortSelectItemsByLaunchTime(items, runtimeByProject, { - displayName: (item) => item.displayName, - projectKey: (item) => item.projectDir - }) diff --git a/packages/app/src/docker-git/menu-select-presenter.ts b/packages/app/src/web/project-select-presenter.ts similarity index 99% rename from packages/app/src/docker-git/menu-select-presenter.ts rename to packages/app/src/web/project-select-presenter.ts index 5cfb9625..66b5619f 100644 --- a/packages/app/src/docker-git/menu-select-presenter.ts +++ b/packages/app/src/web/project-select-presenter.ts @@ -1,6 +1,6 @@ import { Match } from "effect" -import type { SelectProjectRuntime } from "./menu-types.js" +import type { SelectProjectRuntime } from "./project-select-types.js" export type SelectPurpose = "Connect" | "Down" | "Info" | "Delete" | "Auth" diff --git a/packages/app/src/docker-git/menu-select-filter.ts b/packages/app/src/web/project-select-search.ts similarity index 76% rename from packages/app/src/docker-git/menu-select-filter.ts rename to packages/app/src/web/project-select-search.ts index 8153313c..95f0d70c 100644 --- a/packages/app/src/docker-git/menu-select-filter.ts +++ b/packages/app/src/web/project-select-search.ts @@ -1,5 +1,3 @@ -import type { ProjectItem } from "./project-item.js" - export type SelectSearchAccessors = { readonly clonedOnHostname: (item: A) => string | undefined readonly containerName: (item: A) => string | undefined @@ -29,8 +27,8 @@ const searchableValues = ( const hasTerm = (values: ReadonlyArray, term: string): boolean => values.some((value) => value.includes(term)) -// CHANGE: share project search semantics between CLI Select and WEB ProjectPicker -// WHY: selecting by container name must behave identically across both surfaces +// CHANGE: share project search semantics between browser project pickers +// WHY: selecting by container name must behave identically across browser panels // QUOTE(ТЗ): "Можешь добавить ещё поиск контейнеров по имени?" // REF: user-message-2026-04-22-container-name-search // SOURCE: n/a @@ -53,16 +51,3 @@ export const filterSelectItemsByQuery = ( return terms.every((term) => hasTerm(values, term)) }) } - -export const filterProjectItemsByQuery = ( - items: ReadonlyArray, - query: string -): ReadonlyArray => - filterSelectItemsByQuery(items, query, { - clonedOnHostname: (item) => item.clonedOnHostname, - containerName: (item) => item.containerName, - displayName: (item) => item.displayName, - projectKey: (item) => item.projectDir, - repoRef: (item) => item.repoRef, - repoUrl: (item) => item.repoUrl - }) diff --git a/packages/app/src/web/project-select-types.ts b/packages/app/src/web/project-select-types.ts new file mode 100644 index 00000000..f6f8b114 --- /dev/null +++ b/packages/app/src/web/project-select-types.ts @@ -0,0 +1,6 @@ +export type SelectProjectRuntime = { + readonly running: boolean + readonly sshSessions: number + readonly startedAtIso: string | null + readonly startedAtEpochMs: number | null +} diff --git a/packages/app/tests/app/main.test.ts b/packages/app/tests/app/main.test.ts deleted file mode 100644 index 9c52b143..00000000 --- a/packages/app/tests/app/main.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { NodeContext } from "@effect/platform-node" -import { describe, expect, it } from "@effect/vitest" -import { Effect, pipe } from "effect" -import { vi } from "vitest" - -import { program } from "../../src/app/program.js" - -const withLogSpy = Effect.acquireRelease( - Effect.sync(() => vi.spyOn(console, "log").mockImplementation(() => {})), - (spy) => - Effect.sync(() => { - spy.mockRestore() - }) -) - -const withArgv = (nextArgv: ReadonlyArray) => - Effect.acquireRelease( - Effect.sync(() => { - const previous = process.argv - process.argv = [...nextArgv] - return previous - }), - (previous) => - Effect.sync(() => { - process.argv = previous - }) - ) - -type UsageCase = { - readonly argv: ReadonlyArray - readonly needle: string -} - -const usageCases: ReadonlyArray = [ - { argv: ["node", "main"], needle: "bun run docker-git" }, - { argv: ["node", "main", "Alice"], needle: "Usage:" } -] - -const runUsageCase = ({ - argv, - needle -}: UsageCase) => - Effect.scoped( - Effect.gen(function*(_) { - const logSpy = yield* _(withLogSpy) - yield* _(withArgv(argv)) - yield* _(pipe(program, Effect.provide(NodeContext.layer))) - yield* _( - Effect.sync(() => { - expect(logSpy).toHaveBeenCalledTimes(1) - expect(logSpy).toHaveBeenLastCalledWith( - expect.stringContaining(needle) - ) - }) - ) - }) - ) - -describe("main program", () => { - it.effect("prints usage for invalid invocations", () => - pipe( - Effect.forEach(usageCases, runUsageCase, { concurrency: 1 }), - Effect.asVoid - )) -}) diff --git a/packages/app/tests/docker-git/actions-project-create.test.ts b/packages/app/tests/docker-git/actions-project-create.test.ts index 5032d24a..c6d1ee62 100644 --- a/packages/app/tests/docker-git/actions-project-create.test.ts +++ b/packages/app/tests/docker-git/actions-project-create.test.ts @@ -3,9 +3,9 @@ import { Effect } from "effect" import * as fc from "fast-check" import { beforeEach, vi } from "vitest" -import type { CreateInputs } from "../../src/docker-git/menu-types.js" import { submitCreateInputs } from "../../src/web/actions-project-create.js" import type { ApiEvent, loadProjectDetails, ProjectDetails, startCreateProject } from "../../src/web/api.js" +import type { CreateInputs } from "../../src/web/create-types.js" import type { openProjectEventStream } from "../../src/web/project-events.js" import { makeBrowserActionContext, waitForAssertion } from "./browser-action-context-fixture.js" diff --git a/packages/app/tests/docker-git/app-ready-create.test.ts b/packages/app/tests/docker-git/app-ready-create.test.ts index 8a3437cf..02a4a279 100644 --- a/packages/app/tests/docker-git/app-ready-create.test.ts +++ b/packages/app/tests/docker-git/app-ready-create.test.ts @@ -3,15 +3,11 @@ import type { Dispatch, SetStateAction } from "react" import { beforeEach, describe, expect, it, vi } from "vitest" import { deriveRepoPathParts, resolveRepoInput } from "../../src/docker-git/frontend-lib/core/domain.js" -import { - type CreateFlowView, - createInitialFlowView, - resolveCreateFlowSteps -} from "../../src/docker-git/menu-create-shared.js" -import type { CreateInputs } from "../../src/docker-git/menu-types.js" import type { submitCreateInputs } from "../../src/web/actions-projects.js" import type { GithubAuthStatus } from "../../src/web/api.js" import { submitCreateView } from "../../src/web/app-ready-create.js" +import { type CreateFlowView, createInitialFlowView, resolveCreateFlowSteps } from "../../src/web/create-flow.js" +import type { CreateInputs } from "../../src/web/create-types.js" import { makeBrowserActionContext } from "./browser-action-context-fixture.js" const submitCreateInputsMock = vi.hoisted(() => vi.fn()) diff --git a/packages/app/tests/docker-git/app-ready-shortcuts.test.ts b/packages/app/tests/docker-git/app-ready-shortcuts.test.ts index 25e4bccb..aa23f5cc 100644 --- a/packages/app/tests/docker-git/app-ready-shortcuts.test.ts +++ b/packages/app/tests/docker-git/app-ready-shortcuts.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from "vitest" -import { createInitialFlowView } from "../../src/docker-git/menu-create-shared.js" import type { DashboardData } from "../../src/web/api.js" import { type BrowserShortcutArgs, dispatchBrowserShortcut } from "../../src/web/app-ready-shortcut-runtime.js" import { @@ -11,6 +10,7 @@ import { shouldRefreshProjectDetails, usesProjectPrimaryNavigation } from "../../src/web/app-ready-shortcuts.js" +import { createInitialFlowView } from "../../src/web/create-flow.js" import { makeBrowserActionContext } from "./browser-action-context-fixture.js" const makeEvent = (key: string): ShortcutKeyboardEvent => { diff --git a/packages/app/tests/docker-git/menu-create-shared.test.ts b/packages/app/tests/docker-git/create-flow.test.ts similarity index 95% rename from packages/app/tests/docker-git/menu-create-shared.test.ts rename to packages/app/tests/docker-git/create-flow.test.ts index 875cb62c..a4be008a 100644 --- a/packages/app/tests/docker-git/menu-create-shared.test.ts +++ b/packages/app/tests/docker-git/create-flow.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from "vitest" -import { - advanceCreateFlow, - createInitialFlowView, - resolveCreateFlowSteps -} from "../../src/docker-git/menu-create-shared.js" +import { advanceCreateFlow, createInitialFlowView, resolveCreateFlowSteps } from "../../src/web/create-flow.js" const expectContinueResult = ( next: ReturnType @@ -39,7 +35,7 @@ const expectFeatureRepoDefaults = ( expect(value.outDir).toBe(defaultRoot) } -describe("menu-create-shared", () => { +describe("create-flow", () => { const cwd = process.cwd() const defaultRoot = `${process.env["HOME"] ?? cwd}/.docker-git/org/repo` diff --git a/packages/app/tests/docker-git/menu-api.test.ts b/packages/app/tests/docker-git/menu-api.test.ts deleted file mode 100644 index e78318c2..00000000 --- a/packages/app/tests/docker-git/menu-api.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import * as NodeRuntime from "@effect/platform-node" -import { describe, expect, it } from "@effect/vitest" -import { Effect } from "effect" -import { beforeEach, vi } from "vitest" - -import type { ProjectItem } from "../../src/docker-git/project-item.js" -import { makeProjectItem } from "./fixtures/project-item.js" - -type ProjectItemsEffect = Effect.Effect> - -const listProjectDetailsMock = vi.hoisted(() => vi.fn<() => ProjectItemsEffect>(() => Effect.succeed([]))) -const listProjectsMock = vi.hoisted(() => vi.fn(() => Effect.succeed([]))) -const getProjectMock = vi.hoisted(() => vi.fn(() => Effect.succeed(null))) - -vi.mock("../../src/docker-git/api-client.js", () => ({ - deleteProject: vi.fn(() => Effect.void), - downProject: vi.fn(() => Effect.void), - getProject: getProjectMock, - githubStatus: vi.fn(() => Effect.succeed({})), - listProjectDetails: listProjectDetailsMock, - listProjects: listProjectsMock, - readProjectLogs: vi.fn(() => Effect.succeed("")), - readProjectPs: vi.fn(() => Effect.succeed("")), - renderProjectSummaryLine: vi.fn(() => "project") -})) - -const loadMenuApi = () => Effect.promise(() => import("../../src/docker-git/menu-api.js")) - -describe("menu-api project inventory", () => { - beforeEach(() => { - listProjectDetailsMock.mockReset() - listProjectDetailsMock.mockImplementation(() => Effect.succeed([])) - listProjectsMock.mockReset() - listProjectsMock.mockImplementation(() => Effect.succeed([])) - getProjectMock.mockReset() - getProjectMock.mockImplementation(() => Effect.succeed(null)) - vi.resetModules() - }) - - it.effect("loads select items from one detailed projects response without per-project fan-out", () => - Effect.gen(function*(_) { - const first = makeProjectItem({ id: "/db/one", projectDir: "/db/one", displayName: "org/one" }) - const second = makeProjectItem({ id: "/db/two", projectDir: "/db/two", displayName: "org/two" }) - listProjectDetailsMock.mockImplementation(() => Effect.succeed([first, second])) - - const { listMenuProjectItems } = yield* _(loadMenuApi()) - const items = yield* _(listMenuProjectItems.pipe(Effect.provide(NodeRuntime.NodeContext.layer))) - - expect(items).toEqual([first, second]) - expect(listProjectDetailsMock).toHaveBeenCalledTimes(1) - expect(listProjectsMock).not.toHaveBeenCalled() - expect(getProjectMock).not.toHaveBeenCalled() - })) - - it.effect("keeps stop selection DB-only when live runtime is not part of inventory", () => - Effect.gen(function*(_) { - const project = makeProjectItem({ id: "/db/project", projectDir: "/db/project", status: "unknown" }) - listProjectDetailsMock.mockImplementation(() => Effect.succeed([project])) - - const { listMenuRunningProjectItems } = yield* _(loadMenuApi()) - const items = yield* _(listMenuRunningProjectItems.pipe(Effect.provide(NodeRuntime.NodeContext.layer))) - - expect(items).toEqual([project]) - expect(listProjectDetailsMock).toHaveBeenCalledTimes(1) - expect(getProjectMock).not.toHaveBeenCalled() - })) -}) diff --git a/packages/app/tests/docker-git/menu-input-handler.test.ts b/packages/app/tests/docker-git/menu-input-handler.test.ts deleted file mode 100644 index 20999834..00000000 --- a/packages/app/tests/docker-git/menu-input-handler.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it, vi } from "vitest" - -import { handleUserInput } from "../../src/docker-git/menu-input-handler.js" -import type { MenuInputContext } from "../../src/docker-git/menu-input-handler.js" -import { defaultMenuSnapshot } from "../../src/docker-git/menu-state.js" - -const makeContext = (inputStage: "cold" | "active"): MenuInputContext & { - readonly runnerRunEffect: ReturnType - readonly setViewMock: ReturnType - readonly setMessageMock: ReturnType - readonly setInputStageMock: ReturnType - readonly setSkipInputsMock: ReturnType -} => { - let currentInputStage = inputStage - const runnerRunEffect = vi.fn() - const runnerRunInteractiveEffect = vi.fn() - const setViewMock = vi.fn() - const setMessageMock = vi.fn() - const setSkipInputsMock = vi.fn() - const setInputStageMock = vi.fn((next: "cold" | "active") => { - currentInputStage = next - }) - - return { - busy: false, - view: { _tag: "Menu" }, - get inputStage() { - return currentInputStage - }, - setInputStage: setInputStageMock, - selected: 0, - setSelected: vi.fn(), - setSkipInputs: setSkipInputsMock, - sshActive: false, - setSshActive: vi.fn(), - state: { cwd: process.cwd(), activeDir: null }, - runner: { runEffect: runnerRunEffect, runInteractiveEffect: runnerRunInteractiveEffect }, - exit: vi.fn(), - setView: setViewMock, - setMessage: setMessageMock, - setActiveDir: vi.fn(), - runnerRunEffect, - setViewMock, - setMessageMock, - setInputStageMock, - setSkipInputsMock - } -} - -describe("menu-input-handler", () => { - it("handles the first single-character alias on cold start", () => { - const context = makeContext("cold") - - handleUserInput("s", {}, context) - - expect(context.setInputStageMock).toHaveBeenCalledWith("active") - expect(context.runnerRunEffect).toHaveBeenCalledTimes(1) - expect(context.setSkipInputsMock).toHaveBeenCalledTimes(1) - expect(context.setViewMock).not.toHaveBeenCalled() - }) - - it("allows the same alias once input is already active", () => { - const context = makeContext("active") - - handleUserInput("s", {}, context) - - expect(context.runnerRunEffect).toHaveBeenCalledTimes(1) - expect(context.setSkipInputsMock).toHaveBeenCalledTimes(1) - }) - - it("starts a fresh TUI snapshot without synthetic skipped inputs", () => { - const snapshot = defaultMenuSnapshot() - - expect(snapshot.inputStage).toBe("active") - expect(snapshot.skipInputs).toBe(0) - }) -}) diff --git a/packages/app/tests/docker-git/menu-select-actions.test.ts b/packages/app/tests/docker-git/menu-select-actions.test.ts deleted file mode 100644 index 2225430f..00000000 --- a/packages/app/tests/docker-git/menu-select-actions.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { NodeContext } from "@effect/platform-node" -import { Effect } from "effect" -import { beforeEach, describe, expect, it, vi } from "vitest" - -import type { MenuError } from "../../src/docker-git/menu-errors.js" -import type { MenuEnv } from "../../src/docker-git/menu-types.js" -import type { ProjectItem } from "../../src/docker-git/project-item.js" -import { makeProjectItem } from "./fixtures/project-item.js" - -const openResolvedProjectSshViaControllerWithUpMock = vi.hoisted(() => vi.fn((_item: ProjectItem) => Effect.void)) -const openResolvedProjectSshWithUpMock = vi.hoisted(() => vi.fn((_item: ProjectItem) => Effect.void)) - -vi.mock("../../src/docker-git/menu-api.js", () => ({ - deleteMenuProject: vi.fn(() => Effect.void), - downMenuProject: vi.fn(() => Effect.void), - listMenuRunningProjectItems: Effect.succeed([]) -})) - -vi.mock("../../src/docker-git/menu-errors.js", () => ({ - renderMenuError: vi.fn(() => "menu error") -})) - -vi.mock("../../src/docker-git/menu-project-auth.js", () => ({ - openProjectAuthSelection: vi.fn() -})) - -vi.mock("../../src/docker-git/menu-select-runtime.js", () => ({ - loadRuntimeByProject: vi.fn(() => Effect.succeed({})) -})) - -vi.mock("../../src/docker-git/menu-select-view.js", () => ({ - startSelectView: vi.fn() -})) - -vi.mock("../../src/docker-git/menu-shared.js", () => ({ - pauseOnError: vi.fn(() => {}), - resetToMenu: vi.fn(), - resumeSshWithSkipInputs: vi.fn(() => {}), - resumeWithSkipInputs: vi.fn(() => {}), - withSuspendedTui: (effect: Effect.Effect) => effect -})) - -vi.mock("../../src/docker-git/open-project.js", () => ({ - openResolvedProjectSshViaControllerWithUp: openResolvedProjectSshViaControllerWithUpMock, - openResolvedProjectSshWithUp: openResolvedProjectSshWithUpMock -})) - -const loadMenuSelectActions = Effect.tryPromise({ - try: () => import("../../src/docker-git/menu-select-actions.js"), - catch: (error) => (error instanceof Error ? error : new Error(String(error))) -}) - -const makeContext = () => { - const messages: Array = [] - const sshActiveStates: Array = [] - const runnerRunEffect = vi.fn((effect: Effect.Effect) => { - Effect.runSync(effect.pipe(Effect.provide(NodeContext.layer))) - }) - const runnerRunInteractiveEffect = vi.fn((effect: Effect.Effect) => { - Effect.runSync(effect.pipe(Effect.provide(NodeContext.layer))) - }) - - return { - activeDir: null, - messages, - runner: { - runEffect: runnerRunEffect, - runInteractiveEffect: runnerRunInteractiveEffect - }, - runnerRunEffect, - runnerRunInteractiveEffect, - setActiveDir: vi.fn(), - setMessage: (message: string | null) => { - messages.push(message) - }, - setSkipInputs: vi.fn(), - setSshActive: (active: boolean) => { - sshActiveStates.push(active) - }, - setView: vi.fn(), - sshActiveStates - } -} - -describe("menu-select-actions", () => { - beforeEach(() => { - openResolvedProjectSshViaControllerWithUpMock.mockReset() - openResolvedProjectSshViaControllerWithUpMock.mockImplementation((_item: ProjectItem) => Effect.void) - openResolvedProjectSshWithUpMock.mockReset() - openResolvedProjectSshWithUpMock.mockImplementation((_item: ProjectItem) => Effect.void) - vi.resetModules() - }) - - it("routes Connect + SSH through the controller session launcher", () => - Effect.gen(function*(_) { - const { runConnectSelection } = yield* _(loadMenuSelectActions) - const selected = makeProjectItem({ - projectDir: "/controller/provercoderai/docker-git/main" - }) - const context = makeContext() - - runConnectSelection(selected, context, false) - - expect(openResolvedProjectSshViaControllerWithUpMock).toHaveBeenCalledTimes(1) - expect(openResolvedProjectSshViaControllerWithUpMock).toHaveBeenCalledWith(selected) - expect(openResolvedProjectSshWithUpMock).not.toHaveBeenCalled() - expect(context.runnerRunInteractiveEffect).toHaveBeenCalledTimes(1) - expect(context.runnerRunEffect).not.toHaveBeenCalled() - expect(context.messages).toEqual([ - `Connecting to ${selected.displayName}...`, - "SSH session ended. Press Esc to return to the menu." - ]) - expect(context.sshActiveStates).toEqual([true, false]) - }).pipe(Effect.runPromise)) -}) diff --git a/packages/app/tests/docker-git/menu-select-connect.test.ts b/packages/app/tests/docker-git/menu-select-connect.test.ts deleted file mode 100644 index 8da45428..00000000 --- a/packages/app/tests/docker-git/menu-select-connect.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { Effect } from "effect" -import { describe, expect, it } from "vitest" - -import type { ProjectItem } from "../../src/docker-git/project-item.js" - -import { selectHint } from "../../src/docker-git/menu-render-select.js" -import { buildConnectEffect, isConnectMcpToggleInput } from "../../src/docker-git/menu-select-connect.js" -import { recordEvent } from "./fixtures/event-recorder.js" -import { makeProjectItem } from "./fixtures/project-item.js" - -const makeConnectDeps = (events: Array) => ({ - connectWithUp: (selected: ProjectItem) => recordEvent(events, `connect:${selected.projectDir}`), - enableMcpPlaywright: (projectDir: string) => recordEvent(events, `enable:${projectDir}`) -}) - -const workspaceProject = () => - makeProjectItem({ - projectDir: "/home/dev/provercoderai/docker-git/workspaces/org/repo", - authorizedKeysPath: "/home/dev/provercoderai/docker-git/workspaces/org/repo/authorized_keys", - envGlobalPath: "/home/dev/provercoderai/docker-git/workspaces/org/repo/.orch/env/global.env", - envProjectPath: "/home/dev/provercoderai/docker-git/workspaces/org/repo/.orch/env/project.env", - codexAuthPath: "/home/dev/provercoderai/docker-git/workspaces/org/repo/.orch/auth/codex" - }) - -describe("menu-select-connect", () => { - it("runs Playwright enable before SSH when toggle is ON", () => { - const item = workspaceProject() - const events: Array = [] - Effect.runSync(buildConnectEffect(item, true, makeConnectDeps(events))) - expect(events).toEqual([`enable:${item.projectDir}`, `connect:${item.projectDir}`]) - }) - - it("skips Playwright enable when toggle is OFF", () => { - const item = workspaceProject() - const events: Array = [] - Effect.runSync(buildConnectEffect(item, false, makeConnectDeps(events))) - expect(events).toEqual([`connect:${item.projectDir}`]) - }) - - it("parses connect toggle key from user input", () => { - expect(isConnectMcpToggleInput("p")).toBe(true) - expect(isConnectMcpToggleInput(" P ")).toBe(true) - expect(isConnectMcpToggleInput("x")).toBe(false) - expect(isConnectMcpToggleInput("")).toBe(false) - }) - - it("renders connect hint with current Playwright toggle state", () => { - expect(selectHint("Connect", true)).toBe("Enter = start if needed + SSH, Esc = back") - expect(selectHint("Connect", false)).toBe("Enter = start if needed + SSH, Esc = back") - }) -}) diff --git a/packages/app/tests/docker-git/menu-shared.test.ts b/packages/app/tests/docker-git/menu-shared.test.ts deleted file mode 100644 index ddf9a191..00000000 --- a/packages/app/tests/docker-git/menu-shared.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { NodeContext } from "@effect/platform-node" -import { describe, expect, it } from "@effect/vitest" -import { Effect } from "effect" -import { afterEach, beforeEach, vi } from "vitest" - -const repairInteractiveTerminalMock = vi.hoisted(() => - vi.fn<(fallbackWrite?: (chunk: string) => void) => Effect.Effect>() -) - -vi.mock("../../src/docker-git/frontend-lib/shell/terminal-cursor.js", () => ({ - repairInteractiveTerminal: repairInteractiveTerminalMock -})) - -const primaryScreenEscape = "\u001B[?1049l\r\u001B[2K" -const alternateScreenEscape = "\u001B[?1049h\u001B[2J\u001B[H" -const inputModesEscape = "\u001B[0m" + - "\u001B[?25h" + - "\u001B[?1l" + - "\u001B>" + - "\u001B[?1000l\u001B[?1002l\u001B[?1003l\u001B[?1005l\u001B[?1006l\u001B[?1015l\u001B[?1007l" + - "\u001B[?1004l\u001B[?2004l" + - "\u001B[>4;0m\u001B[>4m\u001B[", `write:${primaryScreenEscape}`] -const alternateScreenResumeEvents = [ - "repair", - "write:", - `write:${alternateScreenEscape}`, - "raw:true", - `write:${inputModesEscape}` -] - -const originalStdoutWrite: typeof process.stdout.write = process.stdout.write.bind(process.stdout) -const originalStderrWrite: typeof process.stderr.write = process.stderr.write.bind(process.stderr) -const originalStdinTty = process.stdin.isTTY -const originalStdoutTty = process.stdout.isTTY -const originalSetRawMode = Reflect.get(process.stdin, "setRawMode") - -const loadMenuShared = Effect.tryPromise({ - try: () => import("../../src/docker-git/menu-shared.js"), - catch: (error) => (error instanceof Error ? error : new Error(String(error))) -}) - -const restoreTerminalBindings = (): void => { - process.stdout.write = originalStdoutWrite - process.stderr.write = originalStderrWrite - Object.defineProperty(process.stdin, "setRawMode", { configurable: true, value: originalSetRawMode }) - Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: originalStdinTty }) - Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: originalStdoutTty }) -} - -const createRawModeStub = (events: Array): typeof process.stdin.setRawMode => - function setRawModeStub(this: typeof process.stdin, enabled: boolean) { - events.push(`raw:${String(enabled)}`) - return this - } - -const createWriteStub = ( - events: Array -): typeof process.stdout.write => - function writeStub( - chunk: string | Uint8Array, - encoding?: BufferEncoding | ((err?: Error | null) => void), - cb?: (err?: Error | null) => void - ) { - events.push(`write:${String(chunk)}`) - const callback = typeof encoding === "function" ? encoding : cb - callback?.() - return true - } - -const installPatchedTerminal = (events: Array): void => { - process.stdin.setRawMode = createRawModeStub(events) - process.stdout.write = createWriteStub(events) - process.stderr.write = createWriteStub(events) -} - -const installRepairRecorder = (events: Array): void => { - repairInteractiveTerminalMock.mockImplementation((fallbackWrite) => - Effect.sync(() => { - events.push("repair") - fallbackWrite?.("") - }) - ) -} - -const createMenuSharedFixture = () => - Effect.gen(function*(_) { - const events: Array = [] - installPatchedTerminal(events) - installRepairRecorder(events) - const menuShared = yield* _(loadMenuShared) - return { events, menuShared } - }) - -describe("menu-shared terminal boundary", () => { - beforeEach(() => { - vi.resetModules() - repairInteractiveTerminalMock.mockReset() - Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true }) - Object.defineProperty(process.stdout, "isTTY", { configurable: true, value: true }) - }) - - afterEach(() => { - restoreTerminalBindings() - }) - - it.effect("mutes TUI writes before handing the terminal to SSH", () => - Effect.gen(function*(_) { - const { - events, - menuShared: { suspendTui, writeToTerminal } - } = yield* _(createMenuSharedFixture()) - - yield* _(suspendTui().pipe(Effect.provide(NodeContext.layer))) - process.stdout.write("hidden gridland frame") - writeToTerminal("visible ssh header") - - expect(events).toEqual([ - "repair", - "write:", - `write:${primaryScreenEscape}`, - "write:visible ssh header" - ]) - expect(events).not.toContain("raw:false") - })) - - it.effect("restores the alternate screen before unmuting TUI writes", () => - Effect.gen(function*(_) { - const { - events, - menuShared: { resumeTui, suspendTui } - } = yield* _(createMenuSharedFixture()) - - yield* _(suspendTui().pipe(Effect.provide(NodeContext.layer))) - events.length = 0 - repairInteractiveTerminalMock.mockClear() - installRepairRecorder(events) - - yield* _(resumeTui().pipe(Effect.provide(NodeContext.layer))) - process.stdout.write("restored gridland frame") - - expect(events).toEqual([ - "repair", - "write:", - `write:${alternateScreenEscape}`, - "raw:true", - `write:${inputModesEscape}`, - "write:restored gridland frame" - ]) - })) - - it.effect("runs the wrapped effect between suspend and resume", () => - Effect.gen(function*(_) { - const { - events, - menuShared: { withSuspendedTui, writeToTerminal } - } = yield* _(createMenuSharedFixture()) - - yield* _( - withSuspendedTui( - Effect.sync(() => { - events.push("effect") - writeToTerminal("ssh output") - }), - { - onResume: () => { - events.push("resume") - } - } - ).pipe(Effect.provide(NodeContext.layer)) - ) - - expect(events).toEqual([ - ...primaryScreenRepairEvents, - "effect", - "write:ssh output", - ...alternateScreenResumeEvents, - "resume" - ]) - })) - - it.effect("restores the primary screen on exit with a clean current line", () => - Effect.gen(function*(_) { - const { - events, - menuShared: { leaveTui } - } = yield* _(createMenuSharedFixture()) - - yield* _(leaveTui().pipe(Effect.provide(NodeContext.layer))) - - expect(events).toEqual([...primaryScreenRepairEvents, "raw:false"]) - })) -}) diff --git a/packages/app/tests/docker-git/menu-startup.test.ts b/packages/app/tests/docker-git/menu-startup.test.ts deleted file mode 100644 index 8871c76c..00000000 --- a/packages/app/tests/docker-git/menu-startup.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from "vitest" - -import { resolveMenuStartupSnapshot } from "../../src/docker-git/menu-startup.js" -import { makeProjectItem } from "./fixtures/project-item.js" - -describe("menu-startup", () => { - it("returns empty snapshot when no docker-git containers are running", () => { - const snapshot = resolveMenuStartupSnapshot([makeProjectItem({ status: "stopped" })]) - - expect(snapshot).toEqual({ - activeDir: null, - runningDockerGitContainers: 0, - message: null - }) - }) - - it("auto-selects active project when exactly one known docker-git container is running", () => { - const item = makeProjectItem({ status: "running", statusLabel: "Up 1 minute" }) - const snapshot = resolveMenuStartupSnapshot([item]) - - expect(snapshot.activeDir).toBe(item.projectDir) - expect(snapshot.runningDockerGitContainers).toBe(1) - expect(snapshot.message).toContain(item.displayName) - }) - - it("does not auto-select when multiple docker-git containers are running", () => { - const first = makeProjectItem({ - containerName: "dg-one", - displayName: "org/one", - projectDir: "/home/dev/.docker-git/org-one" - }) - const second = makeProjectItem({ - containerName: "dg-two", - displayName: "org/two", - projectDir: "/home/dev/.docker-git/org-two", - status: "running", - statusLabel: "Up 2 minutes" - }) - const snapshot = resolveMenuStartupSnapshot([ - { ...first, status: "running", statusLabel: "Up 1 minute" }, - second - ]) - - expect(snapshot.activeDir).toBeNull() - expect(snapshot.runningDockerGitContainers).toBe(2) - expect(snapshot.message).toContain("Use Select project") - }) - - it("keeps an empty snapshot when API reports no running projects", () => { - const snapshot = resolveMenuStartupSnapshot([]) - - expect(snapshot).toEqual({ - activeDir: null, - runningDockerGitContainers: 0, - message: null - }) - }) -}) diff --git a/packages/app/tests/docker-git/parser-browser.test.ts b/packages/app/tests/docker-git/parser-browser.test.ts index d08b3649..b06a1edb 100644 --- a/packages/app/tests/docker-git/parser-browser.test.ts +++ b/packages/app/tests/docker-git/parser-browser.test.ts @@ -1,12 +1,23 @@ import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" -import { parseOrThrow } from "./parser-helpers.js" +import { expectParseErrorTag, parseOrThrow } from "./parser-helpers.js" describe("parseArgs browser frontend", () => { - it.effect("parses browser aliases", () => + it.effect("parses browser as the only frontend launch command", () => Effect.sync(() => { expect(parseOrThrow(["browser"])._tag).toBe("Browser") - expect(parseOrThrow(["web"])._tag).toBe("Browser") })) + + it.effect("prints help when no command is provided", () => + Effect.sync(() => { + expect(parseOrThrow([])._tag).toBe("Help") + })) + + it.effect("rejects removed frontend aliases", () => + Effect.all([ + expectParseErrorTag(["menu"], "UnknownCommand"), + expectParseErrorTag(["ui"], "UnknownCommand"), + expectParseErrorTag(["web"], "UnknownCommand") + ]).pipe(Effect.asVoid)) }) diff --git a/packages/app/tests/docker-git/program.test.ts b/packages/app/tests/docker-git/program.test.ts index 9ba7bdad..bc78bb1f 100644 --- a/packages/app/tests/docker-git/program.test.ts +++ b/packages/app/tests/docker-git/program.test.ts @@ -7,13 +7,11 @@ import type { Command } from "../../src/docker-git/frontend-lib/core/domain.js" const ensureControllerReadyMock = vi.hoisted(() => vi.fn(() => Effect.void)) const runBrowserFrontendCommandMock = vi.hoisted(() => vi.fn(() => Effect.void)) -const runMenuCallMock = vi.hoisted(() => vi.fn(() => {})) const readCommandMock = vi.hoisted(() => vi.fn<() => Command>()) const codexLoginMock = vi.hoisted(() => vi.fn(() => Effect.void)) const gitlabLoginMock = vi.hoisted(() => vi.fn(() => Effect.succeed({ ok: true }))) const readStatePullMock = vi.hoisted(() => vi.fn(() => Effect.succeed("State pull completed."))) -const menuCommand: Extract = { _tag: "Menu" } const browserCommand: Extract = { _tag: "Browser" } const codexLoginCommand: Extract = { _tag: "AuthCodexLogin", @@ -70,27 +68,20 @@ vi.mock("../../src/docker-git/api-client.js", () => ({ syncState: vi.fn(() => Effect.succeed("State sync completed.")) })) -vi.mock("../../src/docker-git/menu.js", () => ({ - runMenu: Effect.sync(() => { - runMenuCallMock() - }) -})) - const runProgram = () => Effect.gen(function*(_) { const { program } = yield* _(Effect.promise(() => import("../../src/docker-git/program.js"))) yield* _(program.pipe(Effect.provide(NodeContext.layer))) }) -describe("program menu dispatch", () => { +describe("program dispatch", () => { beforeEach(() => { ensureControllerReadyMock.mockReset() ensureControllerReadyMock.mockImplementation(() => Effect.void) runBrowserFrontendCommandMock.mockReset() runBrowserFrontendCommandMock.mockImplementation(() => Effect.void) - runMenuCallMock.mockReset() readCommandMock.mockReset() - readCommandMock.mockReturnValue(menuCommand) + readCommandMock.mockReturnValue(browserCommand) codexLoginMock.mockReset() codexLoginMock.mockImplementation(() => Effect.void) gitlabLoginMock.mockReset() @@ -101,15 +92,6 @@ describe("program menu dispatch", () => { vi.resetModules() }) - it.effect("routes menu through controller bootstrap instead of unsupported-command path", () => - Effect.gen(function*(_) { - yield* _(runProgram()) - - expect(ensureControllerReadyMock).toHaveBeenCalledTimes(1) - expect(runMenuCallMock).toHaveBeenCalledTimes(1) - expect(process.exitCode ?? 0).toBe(0) - })) - it.effect("routes browser frontend through the browser command runner", () => Effect.gen(function*(_) { readCommandMock.mockReturnValue(browserCommand) diff --git a/packages/app/tests/docker-git/menu-select-order.test.ts b/packages/app/tests/docker-git/project-select.test.ts similarity index 84% rename from packages/app/tests/docker-git/menu-select-order.test.ts rename to packages/app/tests/docker-git/project-select.test.ts index 72c6f82e..983e7c63 100644 --- a/packages/app/tests/docker-git/menu-select-order.test.ts +++ b/packages/app/tests/docker-git/project-select.test.ts @@ -1,12 +1,13 @@ import { describe, expect, it } from "vitest" -import { buildSelectLabels, buildSelectListWindow } from "../../src/docker-git/menu-render-select.js" -import { filterProjectItemsByQuery } from "../../src/docker-git/menu-select-filter.js" -import { sortItemsByLaunchTime, sortSelectItemsByLaunchTime } from "../../src/docker-git/menu-select-order.js" -import type { SelectProjectRuntime } from "../../src/docker-git/menu-types.js" +import type { ProjectItem } from "../../src/docker-git/project-item.js" import type { ProjectSummary } from "../../src/web/api-schema.js" import { sortDashboardProjects } from "../../src/web/api.js" import { filterProjectSummariesByQuery } from "../../src/web/project-search.js" +import { sortSelectItemsByLaunchTime } from "../../src/web/project-select-order.js" +import { buildSelectLabels, buildSelectListWindow } from "../../src/web/project-select-presenter.js" +import { filterSelectItemsByQuery } from "../../src/web/project-select-search.js" +import type { SelectProjectRuntime } from "../../src/web/project-select-types.js" import { makeProjectItem } from "./fixtures/project-item.js" const makeRuntime = ( @@ -40,7 +41,29 @@ const makeProjectSummary = ( ...overrides }) -describe("menu-select order", () => { +const sortProjectItemsByLaunchTime = ( + items: ReadonlyArray, + runtimeByProject: Readonly> +): ReadonlyArray => + sortSelectItemsByLaunchTime(items, runtimeByProject, { + displayName: (item) => item.displayName, + projectKey: (item) => item.projectDir + }) + +const filterProjectItemsByQuery = ( + items: ReadonlyArray, + query: string +): ReadonlyArray => + filterSelectItemsByQuery(items, query, { + clonedOnHostname: (item) => item.clonedOnHostname, + containerName: (item) => item.containerName, + displayName: (item) => item.displayName, + projectKey: (item) => item.projectDir, + repoRef: (item) => item.repoRef, + repoUrl: (item) => item.repoUrl + }) + +describe("project select helpers", () => { it("sorts projects by last container start time (newest first)", () => { const newest = makeProjectItem({ projectDir: "/home/dev/.docker-git/newest", displayName: "org/newest" }) const older = makeProjectItem({ projectDir: "/home/dev/.docker-git/older", displayName: "org/older" }) @@ -63,7 +86,7 @@ describe("menu-select order", () => { [neverStarted.projectDir]: makeRuntime() } - const sorted = sortItemsByLaunchTime([neverStarted, older, newest], runtimeByProject) + const sorted = sortProjectItemsByLaunchTime([neverStarted, older, newest], runtimeByProject) expect(sorted.map((item) => item.projectDir)).toEqual([ newest.projectDir, older.projectDir, @@ -136,7 +159,7 @@ describe("menu-select order", () => { ]) }) - it("filters CLI Select projects by container name", () => { + it("filters project items by container name", () => { const api = makeProjectItem({ projectDir: "/home/dev/.docker-git/api", displayName: "org/api", diff --git a/packages/app/tests/docker-git/terminal-session-client.test.ts b/packages/app/tests/docker-git/terminal-session-client.test.ts index 24188a66..f1d62f22 100644 --- a/packages/app/tests/docker-git/terminal-session-client.test.ts +++ b/packages/app/tests/docker-git/terminal-session-client.test.ts @@ -11,7 +11,7 @@ vi.mock("../../src/docker-git/controller.js", () => ({ resolveApiBaseUrl: resolveApiBaseUrlMock })) -vi.mock("../../src/docker-git/menu-shared.js", () => ({ +vi.mock("../../src/docker-git/terminal-output.js", () => ({ writeToTerminal: writeToTerminalMock })) diff --git a/packages/app/tsconfig.build.json b/packages/app/tsconfig.build.json index 9b8b523c..ab04a6f5 100644 --- a/packages/app/tsconfig.build.json +++ b/packages/app/tsconfig.build.json @@ -4,5 +4,5 @@ "types": [] }, "include": ["src/**/*"], - "exclude": ["dist", "node_modules", "tests/**/*", "vite.config.ts", "vitest.config.ts"] + "exclude": ["dist", "node_modules", "tests/**/*", "vite.docker-git.config.ts", "vite.web.config.ts", "vitest.config.ts"] } diff --git a/packages/app/tsconfig.json b/packages/app/tsconfig.json index ac6e0290..e1433593 100644 --- a/packages/app/tsconfig.json +++ b/packages/app/tsconfig.json @@ -17,7 +17,7 @@ "eslint/**/*", "src/**/*", "tests/**/*", - "vite.config.ts", + "vite.docker-git.config.ts", "vite.web.config.ts", "vitest.config.ts" ], diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts deleted file mode 100644 index 3cea056b..00000000 --- a/packages/app/vite.config.ts +++ /dev/null @@ -1,45 +0,0 @@ -import path from "node:path" -import { fileURLToPath } from "node:url" -import { defineConfig } from "vite" - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -export default defineConfig({ - publicDir: false, - resolve: { - alias: [ - { - find: /^@lib\/(.*)$/u, - replacement: path.resolve(__dirname, "src/lib") + "/$1.ts" - }, - { - find: "@lib", - replacement: path.resolve(__dirname, "src/lib/index.ts") - }, - { - find: /^@\/(.*)$/u, - replacement: path.resolve(__dirname, "src") + "/$1" - }, - { - find: "@", - replacement: path.resolve(__dirname, "src") - } - ] - }, - build: { - target: "node20", - outDir: "dist", - sourcemap: true, - ssr: "src/app/main.ts", - rollupOptions: { - output: { - format: "es", - entryFileNames: "main.js" - } - } - }, - ssr: { - target: "node" - } -}) diff --git a/packages/app/vite.docker-git.config.ts b/packages/app/vite.docker-git.config.ts index 52d0bca8..c3f06abc 100644 --- a/packages/app/vite.docker-git.config.ts +++ b/packages/app/vite.docker-git.config.ts @@ -37,7 +37,6 @@ export default defineConfig({ sourcemap: true, ssr: "src/docker-git/main.ts", rollupOptions: { - external: ["@gridland/bun"], output: { format: "es", entryFileNames: "src/docker-git/main.js" diff --git a/packages/app/vite.web.config.ts b/packages/app/vite.web.config.ts index 66fb11d1..e8d48618 100644 --- a/packages/app/vite.web.config.ts +++ b/packages/app/vite.web.config.ts @@ -3,7 +3,6 @@ import type { IncomingMessage } from "node:http" import type { Duplex } from "node:stream" import { fileURLToPath } from "node:url" -import { gridlandWebPlugin } from "@gridland/web/vite-plugin" import react from "@vitejs/plugin-react" import { defineConfig, loadEnv, type PluginOption } from "vite" import { type RawData, WebSocket, WebSocketServer } from "ws" @@ -166,7 +165,6 @@ export default defineConfig(({ mode }) => { return { plugins: [ terminalWebSocketProxyPlugin(apiTarget), - ...gridlandWebPlugin(), react() ], publicDir: false, diff --git a/packages/lib/src/core/domain.ts b/packages/lib/src/core/domain.ts index 4baa2d56..aa673f85 100644 --- a/packages/lib/src/core/domain.ts +++ b/packages/lib/src/core/domain.ts @@ -21,8 +21,6 @@ export type { AuthGitlabLogoutCommand, AuthGitlabStatusCommand } from "./auth-domain.js" -export type { MenuAction, ParseError } from "./menu.js" -export { parseMenuSelection } from "./menu.js" export { deriveRepoPathParts, deriveRepoSlug, resolveRepoInput } from "./repo.js" export type { SessionsCommand, @@ -72,6 +70,14 @@ export const sshUserNamePatternDescription = "^[a-z_][a-z0-9_-]{0,31}$" // COMPLEXITY: O(n)/O(1) where n = |value| export const isUnixUserName = (value: string): boolean => unixUserNamePattern.test(value) +export type ParseError = + | { readonly _tag: "UnknownCommand"; readonly command: string } + | { readonly _tag: "UnknownOption"; readonly option: string } + | { readonly _tag: "MissingOptionValue"; readonly option: string } + | { readonly _tag: "MissingRequiredOption"; readonly option: string } + | { readonly _tag: "InvalidOption"; readonly option: string; readonly reason: string } + | { readonly _tag: "UnexpectedArgument"; readonly value: string } + export interface TemplateConfig { readonly containerName: string readonly serviceName: string @@ -126,10 +132,6 @@ export interface CreateCommand { readonly openSsh: boolean } -export interface MenuCommand { - readonly _tag: "Menu" -} - export interface AttachCommand { readonly _tag: "Attach" readonly projectDir: string @@ -228,7 +230,6 @@ export type ScrapCommand = export type Command = | CreateCommand - | MenuCommand | AttachCommand | OpenCommand | PanesCommand diff --git a/packages/lib/src/core/menu.ts b/packages/lib/src/core/menu.ts deleted file mode 100644 index 1b32151a..00000000 --- a/packages/lib/src/core/menu.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { Either } from "effect" - -export type MenuAction = - | { readonly _tag: "Create" } - | { readonly _tag: "Select" } - | { readonly _tag: "Auth" } - | { readonly _tag: "ProjectAuth" } - | { readonly _tag: "Info" } - | { readonly _tag: "Up" } - | { readonly _tag: "Status" } - | { readonly _tag: "Logs" } - | { readonly _tag: "Down" } - | { readonly _tag: "DownAll" } - | { readonly _tag: "Delete" } - | { readonly _tag: "Quit" } - -export type ParseError = - | { readonly _tag: "UnknownCommand"; readonly command: string } - | { readonly _tag: "UnknownOption"; readonly option: string } - | { readonly _tag: "MissingOptionValue"; readonly option: string } - | { readonly _tag: "MissingRequiredOption"; readonly option: string } - | { readonly _tag: "InvalidOption"; readonly option: string; readonly reason: string } - | { readonly _tag: "UnexpectedArgument"; readonly value: string } - -const normalizeMenuInput = (input: string): string => input.trim().toLowerCase() - -const menuAliasMap = new Map([ - ["1", { _tag: "Create" }], - ["create", { _tag: "Create" }], - ["c", { _tag: "Create" }], - ["2", { _tag: "Select" }], - ["select", { _tag: "Select" }], - ["s", { _tag: "Select" }], - ["3", { _tag: "Auth" }], - ["auth", { _tag: "Auth" }], - ["a", { _tag: "Auth" }], - ["4", { _tag: "ProjectAuth" }], - ["project-auth", { _tag: "ProjectAuth" }], - ["projectauth", { _tag: "ProjectAuth" }], - ["pa", { _tag: "ProjectAuth" }], - ["5", { _tag: "Info" }], - ["info", { _tag: "Info" }], - ["i", { _tag: "Info" }], - ["up", { _tag: "Up" }], - ["u", { _tag: "Up" }], - ["start", { _tag: "Up" }], - ["6", { _tag: "Status" }], - ["status", { _tag: "Status" }], - ["ps", { _tag: "Status" }], - ["7", { _tag: "Logs" }], - ["logs", { _tag: "Logs" }], - ["log", { _tag: "Logs" }], - ["l", { _tag: "Logs" }], - ["8", { _tag: "Down" }], - ["down", { _tag: "Down" }], - ["stop", { _tag: "Down" }], - ["d", { _tag: "Down" }], - ["9", { _tag: "DownAll" }], - ["down-all", { _tag: "DownAll" }], - ["downall", { _tag: "DownAll" }], - ["stop-all", { _tag: "DownAll" }], - ["stopall", { _tag: "DownAll" }], - ["kill-all", { _tag: "DownAll" }], - ["killall", { _tag: "DownAll" }], - ["da", { _tag: "DownAll" }], - ["10", { _tag: "Delete" }], - ["delete", { _tag: "Delete" }], - ["del", { _tag: "Delete" }], - ["remove", { _tag: "Delete" }], - ["rm", { _tag: "Delete" }], - ["0", { _tag: "Quit" }], - ["11", { _tag: "Quit" }], - ["quit", { _tag: "Quit" }], - ["q", { _tag: "Quit" }], - ["exit", { _tag: "Quit" }] -]) - -const resolveMenuAction = (normalized: string): MenuAction | undefined => menuAliasMap.get(normalized) - -// CHANGE: decode interactive menu input into a typed action -// WHY: keep menu parsing pure and reusable across shells -// QUOTE(ТЗ): "Хочу что бы открылось менюшка" -// REF: user-request-2026-01-07 -// SOURCE: n/a -// FORMAT THEOREM: forall s: parseMenu(s) = a -> deterministic(a) -// PURITY: CORE -// EFFECT: Effect -// INVARIANT: unknown input maps to InvalidOption -// COMPLEXITY: O(1) -export const parseMenuSelection = (input: string): Either.Either => { - const normalized = normalizeMenuInput(input) - - if (normalized.length === 0) { - return Either.left({ - _tag: "InvalidOption", - option: "menu", - reason: "empty selection" - }) - } - - const action = resolveMenuAction(normalized) - if (action === undefined) { - return Either.left({ - _tag: "InvalidOption", - option: "menu", - reason: `unknown selection: ${input}` - }) - } - - return Either.right(action) -} diff --git a/packages/lib/src/usecases/auth-gemini.ts b/packages/lib/src/usecases/auth-gemini.ts index 9ed0f004..316254af 100644 --- a/packages/lib/src/usecases/auth-gemini.ts +++ b/packages/lib/src/usecases/auth-gemini.ts @@ -71,10 +71,10 @@ export const authGeminiLoginCli = ( yield* _(Effect.log("1. API Key (recommended for simplicity):")) yield* _(Effect.log(" - Go to https://ai.google.dev/aistudio")) yield* _(Effect.log(" - Create or retrieve your API key")) - yield* _(Effect.log(" - Use: docker-git menu -> Auth profiles -> Gemini CLI: set API key")) + yield* _(Effect.log(" - Use: docker-git browser -> Auth profiles -> Gemini CLI: set API key")) yield* _(Effect.log("")) yield* _(Effect.log("2. OAuth (Sign in with Google):")) - yield* _(Effect.log(" - Use: docker-git menu -> Auth profiles -> Gemini CLI: login via OAuth")) + yield* _(Effect.log(" - Use: docker-git browser -> Auth profiles -> Gemini CLI: login via OAuth")) yield* _(Effect.log(" - Follow the prompts to authenticate with your Google account")) }) diff --git a/packages/lib/src/usecases/projects-delete.ts b/packages/lib/src/usecases/projects-delete.ts index 6777c121..d1779f06 100644 --- a/packages/lib/src/usecases/projects-delete.ts +++ b/packages/lib/src/usecases/projects-delete.ts @@ -62,7 +62,7 @@ const removeContainersFallback = ( yield* _(removeContainerByName(item.projectDir, `${item.containerName}-browser`)) }) -// CHANGE: delete a docker-git project directory (state) selected in the TUI +// CHANGE: delete a docker-git project directory (state) selected by a caller // WHY: allow removing unwanted projects without rewriting git history (just delete the folder) // QUOTE(ТЗ): "Сделай возможность так же удалять мусорный для меня контейнер... Не нужно чистить гит историю. Пусть просто папку с ним удалит" // REF: user-request-2026-02-09-delete-project diff --git a/packages/lib/src/usecases/projects-down.ts b/packages/lib/src/usecases/projects-down.ts index 4588ffa3..970f2409 100644 --- a/packages/lib/src/usecases/projects-down.ts +++ b/packages/lib/src/usecases/projects-down.ts @@ -11,7 +11,7 @@ import { renderError } from "./errors.js" import { forEachProjectStatus, loadProjectIndex, renderProjectStatusHeader } from "./projects-core.js" // CHANGE: provide a "stop all" helper for docker-git managed projects -// WHY: allow quickly stopping all running docker-git containers from the CLI/TUI +// WHY: allow quickly stopping all running docker-git containers from CLI/API callers // QUOTE(ТЗ): "Выведи сюда возможность убивать все контейнеры" // REF: user-request-2026-02-06-stop-all // SOURCE: n/a diff --git a/packages/lib/src/usecases/projects-list.ts b/packages/lib/src/usecases/projects-list.ts index cb4b7bb5..98eea3b8 100644 --- a/packages/lib/src/usecases/projects-list.ts +++ b/packages/lib/src/usecases/projects-list.ts @@ -65,7 +65,7 @@ export const listProjects: Effect.Effect< ) // CHANGE: collect docker-git connection info lines without logging -// WHY: allow TUI to render connection info inline +// WHY: allow API and browser flows to render connection info inline // QUOTE(ТЗ): "А кнопка \"Show connection info\" ничего не отображает" // REF: user-request-2026-02-01-tui-info // SOURCE: n/a @@ -129,7 +129,7 @@ export const listProjectSummaries: Effect.Effect< ListProjectsContext > = listProjectValues(loadProjectSummary, renderProjectSummary, emptySummaries) -// CHANGE: load docker-git projects for TUI selection +// CHANGE: load docker-git projects for structured selection // WHY: provide structured project data without noisy logs // QUOTE(ТЗ): "А ты можешь сделать удобный выбор проектов?" // REF: user-request-2026-02-02-select-project diff --git a/packages/lib/src/usecases/projects-ssh.ts b/packages/lib/src/usecases/projects-ssh.ts index 2692b0ce..638661fe 100644 --- a/packages/lib/src/usecases/projects-ssh.ts +++ b/packages/lib/src/usecases/projects-ssh.ts @@ -154,7 +154,7 @@ const connectPreparedProjectSsh = ( ) // CHANGE: connect to a project via SSH using its resolved settings -// WHY: allow TUI to open a shell immediately after selection +// WHY: allow project selection flows to open a shell immediately after selection // QUOTE(ТЗ): "выбор проекта сразу подключает по SSH" // REF: user-request-2026-02-02-select-ssh // SOURCE: n/a diff --git a/packages/lib/src/usecases/state-repo/git-commands.ts b/packages/lib/src/usecases/state-repo/git-commands.ts index 2702db84..3781772f 100644 --- a/packages/lib/src/usecases/state-repo/git-commands.ts +++ b/packages/lib/src/usecases/state-repo/git-commands.ts @@ -8,7 +8,7 @@ import { CommandFailedError } from "../../shell/errors.js" export const successExitCode = Number(ExitCode(0)) export const gitBaseEnv: Readonly> = { - // Avoid blocking on interactive credential prompts in CI / TUI contexts. + // Avoid blocking on interactive credential prompts in CI/API contexts. GIT_TERMINAL_PROMPT: "0", // Avoid SSH hanging on host key prompts or passphrases GIT_SSH_COMMAND: "ssh -o BatchMode=yes", diff --git a/packages/lib/tests/core/menu.test.ts b/packages/lib/tests/core/menu.test.ts deleted file mode 100644 index 963bf055..00000000 --- a/packages/lib/tests/core/menu.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from "@effect/vitest" -import { Either, Effect } from "effect" - -import { parseMenuSelection } from "../../src/core/menu.js" - -describe("parseMenuSelection", () => { - it.effect("parses auth aliases", () => - Effect.sync(() => { - const byWord = parseMenuSelection("auth") - const byShort = parseMenuSelection("a") - const byNumber = parseMenuSelection("3") - expect(Either.isRight(byWord) && byWord.right._tag === "Auth").toBe(true) - expect(Either.isRight(byShort) && byShort.right._tag === "Auth").toBe(true) - expect(Either.isRight(byNumber) && byNumber.right._tag === "Auth").toBe(true) - })) - - it.effect("parses project auth aliases", () => - Effect.sync(() => { - const byWord = parseMenuSelection("project-auth") - const byShort = parseMenuSelection("pa") - const byNumber = parseMenuSelection("4") - expect(Either.isRight(byWord) && byWord.right._tag === "ProjectAuth").toBe(true) - expect(Either.isRight(byShort) && byShort.right._tag === "ProjectAuth").toBe(true) - expect(Either.isRight(byNumber) && byNumber.right._tag === "ProjectAuth").toBe(true) - })) - - it.effect("keeps quit aliases valid", () => - Effect.sync(() => { - const byZero = parseMenuSelection("0") - const byEleven = parseMenuSelection("11") - expect(Either.isRight(byZero) && byZero.right._tag === "Quit").toBe(true) - expect(Either.isRight(byEleven) && byEleven.right._tag === "Quit").toBe(true) - })) -}) diff --git a/packages/lib/vite.config.ts b/packages/lib/vite.config.ts deleted file mode 100644 index 091bc027..00000000 --- a/packages/lib/vite.config.ts +++ /dev/null @@ -1,30 +0,0 @@ -import path from "node:path" -import { fileURLToPath } from "node:url" -import { defineConfig } from "vite" - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -export default defineConfig({ - publicDir: false, - resolve: { - alias: { - "@": path.resolve(__dirname, "src") - } - }, - build: { - target: "node20", - outDir: "dist", - sourcemap: true, - ssr: "src/app/main.ts", - rollupOptions: { - output: { - format: "es", - entryFileNames: "main.js" - } - } - }, - ssr: { - target: "node" - } -}) diff --git a/scripts/e2e/issue-61-auth-labels.sh b/scripts/e2e/issue-61-auth-labels.sh index 227c5153..10a6a5d4 100755 --- a/scripts/e2e/issue-61-auth-labels.sh +++ b/scripts/e2e/issue-61-auth-labels.sh @@ -93,7 +93,7 @@ grep -Fq -- "GITHUB_TOKEN__AGIENS=$agiens_token" "$ROOT/.orch/env/global.env" \ [[ "$(git --git-dir "$REMOTE" log -1 --pretty=%s)" == "chore(state): auth gh AGIENS" ]] \ || fail "expected latest remote commit to come from labeled GH auth" -# 2) Set labeled Git credentials + stub Claude Code OAuth cache via the same menu logic (non-interactive). +# 2) Set labeled Git credentials + stub Claude Code OAuth cache via the controller API (non-interactive). PROJECT_DIR="$ROOT/e2e/project-1" PROJECT_ENV="$PROJECT_DIR/.orch/env/project.env" mkdir -p "$(dirname "$PROJECT_ENV")" @@ -112,8 +112,7 @@ import { Effect } from "effect" import { mkdirSync, writeFileSync } from "node:fs" import { join } from "node:path" -import { writeAuthFlow } from "./dist/src/docker-git/menu-auth-data.js" -import { writeProjectAuthFlow } from "./dist/src/docker-git/menu-project-auth-data.js" +import { runAuthMenuFlow, runProjectAuthFlow } from "./dist/src/docker-git/api-auth-menu-client.js" const projectDir = process.env.PROJECT_DIR ?? "" const envProjectPath = process.env.PROJECT_ENV_PATH ?? "" @@ -126,15 +125,18 @@ if (gitToken.length === 0) { throw new Error("missing GIT_TOKEN_VALUE") } -const project = { - projectDir, - displayName: "e2e/project-1", - envProjectPath -} +const requireSnapshot = (snapshot, label) => + snapshot === null ? Effect.fail(new Error(`${label} returned an invalid snapshot`)) : Effect.void const main = Effect.gen(function*(_) { // Create labeled profiles in ~/.docker-git/.orch/env/global.env - yield* _(writeAuthFlow(process.cwd(), "GitSet", { label: "agiens", token: gitToken, user: "x-access-token" })) + yield* _(runAuthMenuFlow({ + flow: "GitSet", + label: "agiens", + token: gitToken, + user: "x-access-token", + apiKey: null + }).pipe(Effect.flatMap((snapshot) => requireSnapshot(snapshot, "auth flow")))) // Stub a Claude Code OAuth cache for the same label so project binding can validate it. const root = process.env.DOCKER_GIT_PROJECTS_ROOT ?? "" @@ -146,9 +148,18 @@ const main = Effect.gen(function*(_) { writeFileSync(join(claudeAuthDir, ".config.json"), JSON.stringify({}), "utf8") // Bind them into the project env. - yield* _(writeProjectAuthFlow(project, "ProjectGithubConnect", { label: "agiens" })) - yield* _(writeProjectAuthFlow(project, "ProjectGitConnect", { label: "agiens" })) - yield* _(writeProjectAuthFlow(project, "ProjectClaudeConnect", { label: "agiens" })) + yield* _(runProjectAuthFlow(projectDir, { + flow: "ProjectGithubConnect", + label: "agiens" + }).pipe(Effect.flatMap((snapshot) => requireSnapshot(snapshot, "project GitHub auth flow")))) + yield* _(runProjectAuthFlow(projectDir, { + flow: "ProjectGitConnect", + label: "agiens" + }).pipe(Effect.flatMap((snapshot) => requireSnapshot(snapshot, "project Git auth flow")))) + yield* _(runProjectAuthFlow(projectDir, { + flow: "ProjectClaudeConnect", + label: "agiens" + }).pipe(Effect.flatMap((snapshot) => requireSnapshot(snapshot, "project Claude auth flow")))) }) NodeRuntime.runMain(Effect.provide(main, NodeContext.layer))