|
| 1 | +import type * as CommandExecutor from "@effect/platform/CommandExecutor" |
| 2 | +import type { PlatformError } from "@effect/platform/Error" |
| 3 | +import type * as FileSystem from "@effect/platform/FileSystem" |
| 4 | +import type * as Path from "@effect/platform/Path" |
| 5 | +import { Effect } from "effect" |
| 6 | + |
| 7 | +import { CommandFailedError } from "../shell/errors.js" |
| 8 | +import { runGhApiCapture, runGhApiNullable } from "./github-api-helpers.js" |
| 9 | +import { ensureGhAuthImage, ghAuthRoot } from "./github-auth-image.js" |
| 10 | +import { resolvePathFromCwd } from "./path-helpers.js" |
| 11 | +import { withFsPathContext } from "./runtime.js" |
| 12 | +import { stateInit } from "./state-repo.js" |
| 13 | + |
| 14 | +// CHANGE: ensure .docker-git repository exists on GitHub after auth |
| 15 | +// WHY: on auth, automatically create or clone the state repo for synchronized work |
| 16 | +// QUOTE(ТЗ): "как только вызываем docker-git auth github то происходит синхронизация. ОН либо создаёт репозиторий .docker-git либо его клонирует к нам" |
| 17 | +// REF: issue-141 |
| 18 | +// SOURCE: https://github.com/skulidropek/.docker-git |
| 19 | +// FORMAT THEOREM: ∀token: login(token) → ∃repo: cloned(repo, ~/.docker-git) |
| 20 | +// PURITY: SHELL |
| 21 | +// EFFECT: Effect<void, never, FileSystem | Path | CommandExecutor> |
| 22 | +// INVARIANT: failures are logged but do not abort the auth flow |
| 23 | +// COMPLEXITY: O(1) API calls |
| 24 | + |
| 25 | +type GithubStateRepoRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor |
| 26 | + |
| 27 | +const dotDockerGitRepoName = ".docker-git" |
| 28 | +const defaultStateRef = "main" |
| 29 | + |
| 30 | +// PURITY: SHELL |
| 31 | +// INVARIANT: fails if login cannot be resolved |
| 32 | +const resolveViewerLogin = ( |
| 33 | + cwd: string, |
| 34 | + hostPath: string, |
| 35 | + token: string |
| 36 | +): Effect.Effect<string, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> => |
| 37 | + Effect.gen(function*(_) { |
| 38 | + const raw = yield* _(runGhApiCapture(cwd, hostPath, token, ["/user", "--jq", ".login"])) |
| 39 | + if (raw.length === 0) { |
| 40 | + return yield* _(Effect.fail(new CommandFailedError({ command: "gh api /user --jq .login", exitCode: 1 }))) |
| 41 | + } |
| 42 | + return raw |
| 43 | + }) |
| 44 | + |
| 45 | +// PURITY: SHELL |
| 46 | +// INVARIANT: returns null if repo does not exist (404) |
| 47 | +const getRepoCloneUrl = ( |
| 48 | + cwd: string, |
| 49 | + hostPath: string, |
| 50 | + token: string, |
| 51 | + login: string |
| 52 | +): Effect.Effect<string | null, PlatformError, CommandExecutor.CommandExecutor> => |
| 53 | + runGhApiNullable(cwd, hostPath, token, [ |
| 54 | + `/repos/${login}/${dotDockerGitRepoName}`, |
| 55 | + "--jq", |
| 56 | + ".clone_url" |
| 57 | + ]) |
| 58 | + |
| 59 | +// PURITY: SHELL |
| 60 | +// INVARIANT: returns null if creation fails |
| 61 | +const createStateRepo = ( |
| 62 | + cwd: string, |
| 63 | + hostPath: string, |
| 64 | + token: string |
| 65 | +): Effect.Effect<string | null, PlatformError, CommandExecutor.CommandExecutor> => |
| 66 | + runGhApiNullable(cwd, hostPath, token, [ |
| 67 | + "-X", |
| 68 | + "POST", |
| 69 | + "/user/repos", |
| 70 | + "-f", |
| 71 | + `name=${dotDockerGitRepoName}`, |
| 72 | + "-f", |
| 73 | + "private=false", |
| 74 | + "-f", |
| 75 | + "auto_init=true", |
| 76 | + "--jq", |
| 77 | + ".clone_url" |
| 78 | + ]) |
| 79 | + |
| 80 | +/** |
| 81 | + * Ensures the .docker-git state repository exists on GitHub and is initialised locally. |
| 82 | + * |
| 83 | + * On GitHub auth, immediately: |
| 84 | + * 1. Resolve the authenticated user's login via the GitHub API |
| 85 | + * 2. Check whether `<login>/.docker-git` exists on GitHub |
| 86 | + * 3. If missing, create the repository (public, auto-initialised with a README) |
| 87 | + * 4. Initialise the local `~/.docker-git` directory as a clone of that repository |
| 88 | + * |
| 89 | + * All failures are swallowed and logged as warnings so they never abort the auth |
| 90 | + * flow itself. |
| 91 | + * |
| 92 | + * @param token - A valid GitHub personal-access or OAuth token |
| 93 | + * @returns Effect<void, never, GithubStateRepoRuntime> |
| 94 | + * |
| 95 | + * @pure false |
| 96 | + * @effect FileSystem, CommandExecutor (Docker gh CLI, git) |
| 97 | + * @invariant ∀token ∈ ValidTokens: ensureStateDotDockerGitRepo(token) → cloned(~/.docker-git) ∨ warned |
| 98 | + * @precondition token.length > 0 |
| 99 | + * @postcondition ~/.docker-git is a git repo with origin pointing to github.com/<login>/.docker-git |
| 100 | + * @complexity O(1) API calls |
| 101 | + * @throws Never - all errors are caught and logged |
| 102 | + */ |
| 103 | +export const ensureStateDotDockerGitRepo = ( |
| 104 | + token: string |
| 105 | +): Effect.Effect<void, never, GithubStateRepoRuntime> => |
| 106 | + withFsPathContext(({ cwd, fs, path }) => |
| 107 | + Effect.gen(function*(_) { |
| 108 | + const ghRoot = resolvePathFromCwd(path, cwd, ghAuthRoot) |
| 109 | + yield* _(fs.makeDirectory(ghRoot, { recursive: true })) |
| 110 | + yield* _(ensureGhAuthImage(fs, path, cwd, "gh api")) |
| 111 | + |
| 112 | + const login = yield* _(resolveViewerLogin(cwd, ghRoot, token)) |
| 113 | + let cloneUrl = yield* _(getRepoCloneUrl(cwd, ghRoot, token, login)) |
| 114 | + |
| 115 | + if (cloneUrl === null) { |
| 116 | + yield* _(Effect.log(`Creating .docker-git repository for ${login}...`)) |
| 117 | + cloneUrl = yield* _(createStateRepo(cwd, ghRoot, token)) |
| 118 | + } |
| 119 | + |
| 120 | + if (cloneUrl === null) { |
| 121 | + yield* _(Effect.logWarning(`Could not resolve or create .docker-git repository for ${login}`)) |
| 122 | + return |
| 123 | + } |
| 124 | + |
| 125 | + yield* _(Effect.log(`Initializing state repository: ${cloneUrl}`)) |
| 126 | + yield* _(stateInit({ repoUrl: cloneUrl, repoRef: defaultStateRef, token })) |
| 127 | + }) |
| 128 | + ).pipe( |
| 129 | + Effect.matchEffect({ |
| 130 | + onFailure: (error) => |
| 131 | + Effect.logWarning( |
| 132 | + `State repo setup failed: ${error instanceof Error ? error.message : String(error)}` |
| 133 | + ), |
| 134 | + onSuccess: () => Effect.void |
| 135 | + }) |
| 136 | + ) |
0 commit comments