Skip to content
Merged
12 changes: 8 additions & 4 deletions packages/lib/src/usecases/auth-github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { ensureEnvFile, parseEnvEntries, readEnvText, removeEnvKey, upsertEnvKey
import { ensureGhAuthImage, ghAuthDir, ghAuthRoot, ghImageName } from "./github-auth-image.js"
import { resolvePathFromCwd } from "./path-helpers.js"
import { withFsPathContext } from "./runtime.js"
import { ensureStateDotDockerGitRepo } from "./state-repo-github.js"
import { autoSyncState } from "./state-repo.js"

type GithubTokenEntry = {
Expand Down Expand Up @@ -200,7 +201,7 @@ const runGithubInteractiveLogin = (
path: Path.Path,
envPath: string,
command: AuthGithubLoginCommand
): Effect.Effect<void, AuthError | CommandFailedError | PlatformError, GithubRuntime> =>
): Effect.Effect<string, AuthError | CommandFailedError | PlatformError, GithubRuntime> =>
Effect.gen(function*(_) {
const rootPath = resolvePathFromCwd(path, cwd, ghAuthRoot)
const accountLabel = normalizeAccountLabel(command.label, "default")
Expand All @@ -214,17 +215,18 @@ const runGithubInteractiveLogin = (
yield* _(ensureEnvFile(fs, path, envPath))
const key = buildGithubTokenKey(command.label)
yield* _(persistGithubToken(fs, envPath, key, resolved))
return resolved
})

// CHANGE: login to GitHub by persisting a token in the shared env file
// WHY: make GH_TOKEN available to all docker-git projects
// QUOTE(ТЗ): "система авторизации"
// REF: user-request-2026-01-28-auth
// SOURCE: n/a
// FORMAT THEOREM: forall t: login(t) -> env(GITHUB_TOKEN)=t
// FORMAT THEOREM: forall t: login(t) -> env(GITHUB_TOKEN)=t ∧ cloned(~/.docker-git)
// PURITY: SHELL
// EFFECT: Effect<void, CommandFailedError | PlatformError, CommandExecutor>
// INVARIANT: token is never logged
// INVARIANT: token is never logged; state repo setup is best-effort
// COMPLEXITY: O(n) where n = |env|
export const authGithubLogin = (
command: AuthGithubLoginCommand
Expand All @@ -239,10 +241,12 @@ export const authGithubLogin = (
if (token.length > 0) {
yield* _(ensureEnvFile(fs, path, envPath))
yield* _(persistGithubToken(fs, envPath, key, token))
yield* _(ensureStateDotDockerGitRepo(token))
yield* _(autoSyncState(`chore(state): auth gh ${label}`))
return
}
yield* _(runGithubInteractiveLogin(cwd, fs, path, envPath, command))
const resolvedToken = yield* _(runGithubInteractiveLogin(cwd, fs, path, envPath, command))
yield* _(ensureStateDotDockerGitRepo(resolvedToken))
yield* _(autoSyncState(`chore(state): auth gh ${label}`))
})
)
Expand Down
62 changes: 62 additions & 0 deletions packages/lib/src/usecases/github-api-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type * as CommandExecutor from "@effect/platform/CommandExecutor"
import type { PlatformError } from "@effect/platform/Error"
import { Effect } from "effect"

import { runDockerAuthCapture } from "../shell/docker-auth.js"
import { CommandFailedError } from "../shell/errors.js"
import { buildDockerAuthSpec } from "./auth-helpers.js"
import { ghAuthDir, ghImageName } from "./github-auth-image.js"

// CHANGE: extract shared gh-API Docker helpers used by github-fork and state-repo-github
// WHY: avoid code duplication flagged by the duplicate-detection linter
// REF: issue-141
// PURITY: SHELL
// INVARIANT: helpers are stateless and composable

/**
* Run `gh api <args>` inside the auth Docker container and return trimmed stdout.
*
* @pure false
* @effect CommandExecutor (Docker)
* @invariant exits with CommandFailedError on non-zero exit code
* @complexity O(1)
*/
export const runGhApiCapture = (
cwd: string,
hostPath: string,
token: string,
args: ReadonlyArray<string>
): Effect.Effect<string, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
runDockerAuthCapture(
buildDockerAuthSpec({
cwd,
image: ghImageName,
hostPath,
containerPath: ghAuthDir,
env: `GH_TOKEN=${token}`,
args: ["api", ...args],
interactive: false
}),
[0],
(exitCode) => new CommandFailedError({ command: `gh api ${args.join(" ")}`, exitCode })
).pipe(Effect.map((raw) => raw.trim()))

/**
* Like `runGhApiCapture` but returns `null` instead of failing on API errors
* (e.g. HTTP 404 / non-zero exit code).
*
* @pure false
* @effect CommandExecutor (Docker)
* @invariant never fails — errors become null
* @complexity O(1)
*/
export const runGhApiNullable = (
cwd: string,
hostPath: string,
token: string,
args: ReadonlyArray<string>
): Effect.Effect<string | null, PlatformError, CommandExecutor.CommandExecutor> =>
runGhApiCapture(cwd, hostPath, token, args).pipe(
Effect.catchTag("CommandFailedError", () => Effect.succeed("")),
Effect.map((raw) => (raw.length === 0 ? null : raw))
)
40 changes: 4 additions & 36 deletions packages/lib/src/usecases/github-fork.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,10 @@ import { Effect } from "effect"

import type { CreateCommand } from "../core/domain.js"
import { parseGithubRepoUrl } from "../core/repo.js"
import { runDockerAuthCapture } from "../shell/docker-auth.js"
import { CommandFailedError } from "../shell/errors.js"
import { buildDockerAuthSpec } from "./auth-helpers.js"
import { parseEnvEntries, readEnvText } from "./env-file.js"
import { ensureGhAuthImage, ghAuthDir, ghAuthRoot, ghImageName } from "./github-auth-image.js"
import { runGhApiCapture, runGhApiNullable } from "./github-api-helpers.js"
import { ensureGhAuthImage, ghAuthRoot } from "./github-auth-image.js"
import { resolvePathFromCwd } from "./path-helpers.js"
import { withFsPathContext } from "./runtime.js"

Expand All @@ -26,37 +25,6 @@ const resolveGithubToken = (envText: string): string | null => {
return labeled && labeled.value.trim().length > 0 ? labeled.value.trim() : null
}

const runGhApiCapture = (
cwd: string,
hostPath: string,
token: string,
args: ReadonlyArray<string>
): Effect.Effect<string, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
runDockerAuthCapture(
buildDockerAuthSpec({
cwd,
image: ghImageName,
hostPath,
containerPath: ghAuthDir,
env: `GH_TOKEN=${token}`,
args: ["api", ...args],
interactive: false
}),
[0],
(exitCode) => new CommandFailedError({ command: `gh api ${args.join(" ")}`, exitCode })
).pipe(Effect.map((raw) => raw.trim()))

const runGhApiCloneUrl = (
cwd: string,
hostPath: string,
token: string,
args: ReadonlyArray<string>
): Effect.Effect<string | null, PlatformError, CommandExecutor.CommandExecutor> =>
runGhApiCapture(cwd, hostPath, token, args).pipe(
Effect.catchTag("CommandFailedError", () => Effect.succeed("")),
Effect.map((raw) => (raw.length === 0 ? null : raw))
)

const resolveViewerLogin = (
cwd: string,
hostPath: string,
Expand All @@ -77,7 +45,7 @@ const resolveRepoCloneUrl = (
token: string,
fullName: string
): Effect.Effect<string | null, PlatformError, CommandExecutor.CommandExecutor> =>
runGhApiCloneUrl(cwd, hostPath, token, [`/repos/${fullName}`, "--jq", ".clone_url"])
runGhApiNullable(cwd, hostPath, token, [`/repos/${fullName}`, "--jq", ".clone_url"])

const createFork = (
cwd: string,
Expand All @@ -86,7 +54,7 @@ const createFork = (
owner: string,
repo: string
): Effect.Effect<string | null, PlatformError, CommandExecutor.CommandExecutor> =>
runGhApiCloneUrl(cwd, hostPath, token, [
runGhApiNullable(cwd, hostPath, token, [
"-X",
"POST",
`/repos/${owner}/${repo}/forks`,
Expand Down
136 changes: 136 additions & 0 deletions packages/lib/src/usecases/state-repo-github.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import type * as CommandExecutor from "@effect/platform/CommandExecutor"
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"

import { CommandFailedError } from "../shell/errors.js"
import { runGhApiCapture, runGhApiNullable } from "./github-api-helpers.js"
import { ensureGhAuthImage, ghAuthRoot } from "./github-auth-image.js"
import { resolvePathFromCwd } from "./path-helpers.js"
import { withFsPathContext } from "./runtime.js"
import { stateInit } from "./state-repo.js"

// CHANGE: ensure .docker-git repository exists on GitHub after auth
// WHY: on auth, automatically create or clone the state repo for synchronized work
// QUOTE(ТЗ): "как только вызываем docker-git auth github то происходит синхронизация. ОН либо создаёт репозиторий .docker-git либо его клонирует к нам"
// REF: issue-141
// SOURCE: https://github.com/skulidropek/.docker-git
// FORMAT THEOREM: ∀token: login(token) → ∃repo: cloned(repo, ~/.docker-git)
// PURITY: SHELL
// EFFECT: Effect<void, never, FileSystem | Path | CommandExecutor>
// INVARIANT: failures are logged but do not abort the auth flow
// COMPLEXITY: O(1) API calls

type GithubStateRepoRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor

const dotDockerGitRepoName = ".docker-git"
const defaultStateRef = "main"

// PURITY: SHELL
// INVARIANT: fails if login cannot be resolved
const resolveViewerLogin = (
cwd: string,
hostPath: string,
token: string
): Effect.Effect<string, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
Effect.gen(function*(_) {
const raw = yield* _(runGhApiCapture(cwd, hostPath, token, ["/user", "--jq", ".login"]))
if (raw.length === 0) {
return yield* _(Effect.fail(new CommandFailedError({ command: "gh api /user --jq .login", exitCode: 1 })))
}
return raw
})

// PURITY: SHELL
// INVARIANT: returns null if repo does not exist (404)
const getRepoCloneUrl = (
cwd: string,
hostPath: string,
token: string,
login: string
): Effect.Effect<string | null, PlatformError, CommandExecutor.CommandExecutor> =>
runGhApiNullable(cwd, hostPath, token, [
`/repos/${login}/${dotDockerGitRepoName}`,
"--jq",
".clone_url"
])

// PURITY: SHELL
// INVARIANT: returns null if creation fails
const createStateRepo = (
cwd: string,
hostPath: string,
token: string
): Effect.Effect<string | null, PlatformError, CommandExecutor.CommandExecutor> =>
runGhApiNullable(cwd, hostPath, token, [
"-X",
"POST",
"/user/repos",
"-f",
`name=${dotDockerGitRepoName}`,
"-f",
"private=false",
"-f",
"auto_init=true",
"--jq",
".clone_url"
])

/**
* Ensures the .docker-git state repository exists on GitHub and is initialised locally.
*
* On GitHub auth, immediately:
* 1. Resolve the authenticated user's login via the GitHub API
* 2. Check whether `<login>/.docker-git` exists on GitHub
* 3. If missing, create the repository (public, auto-initialised with a README)
* 4. Initialise the local `~/.docker-git` directory as a clone of that repository
*
* All failures are swallowed and logged as warnings so they never abort the auth
* flow itself.
*
* @param token - A valid GitHub personal-access or OAuth token
* @returns Effect<void, never, GithubStateRepoRuntime>
*
* @pure false
* @effect FileSystem, CommandExecutor (Docker gh CLI, git)
* @invariant ∀token ∈ ValidTokens: ensureStateDotDockerGitRepo(token) → cloned(~/.docker-git) ∨ warned
* @precondition token.length > 0
* @postcondition ~/.docker-git is a git repo with origin pointing to github.com/<login>/.docker-git
* @complexity O(1) API calls
* @throws Never - all errors are caught and logged
*/
export const ensureStateDotDockerGitRepo = (
token: string
): Effect.Effect<void, never, GithubStateRepoRuntime> =>
withFsPathContext(({ cwd, fs, path }) =>
Effect.gen(function*(_) {
const ghRoot = resolvePathFromCwd(path, cwd, ghAuthRoot)
yield* _(fs.makeDirectory(ghRoot, { recursive: true }))
yield* _(ensureGhAuthImage(fs, path, cwd, "gh api"))

const login = yield* _(resolveViewerLogin(cwd, ghRoot, token))
let cloneUrl = yield* _(getRepoCloneUrl(cwd, ghRoot, token, login))

if (cloneUrl === null) {
yield* _(Effect.log(`Creating .docker-git repository for ${login}...`))
cloneUrl = yield* _(createStateRepo(cwd, ghRoot, token))
}

if (cloneUrl === null) {
yield* _(Effect.logWarning(`Could not resolve or create .docker-git repository for ${login}`))
return
}

yield* _(Effect.log(`Initializing state repository: ${cloneUrl}`))
yield* _(stateInit({ repoUrl: cloneUrl, repoRef: defaultStateRef, token }))
})
).pipe(
Effect.matchEffect({
onFailure: (error) =>
Effect.logWarning(
`State repo setup failed: ${error instanceof Error ? error.message : String(error)}`
),
onSuccess: () => Effect.void
})
)
Loading
Loading