Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 }))
})
).pipe(
Effect.matchEffect({
onFailure: (error) =>
Effect.logWarning(
`State repo setup failed: ${error instanceof Error ? error.message : String(error)}`
),
onSuccess: () => Effect.void
})
)
4 changes: 3 additions & 1 deletion packages/lib/src/usecases/state-repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Effect, pipe } from "effect"
import { runCommandExitCode } from "../shell/command-runner.js"
import { CommandFailedError } from "../shell/errors.js"
import { defaultProjectsRoot } from "./menu-helpers.js"
import { adoptRemoteHistoryIfOrphan } from "./state-repo/adopt-remote.js"
import { autoSyncEnvKey, autoSyncStrictEnvKey, isAutoSyncEnabled, isTruthyEnv } from "./state-repo/env.js"
import {
git,
Expand Down Expand Up @@ -184,7 +185,7 @@ const initRepoIfNeeded = (
return
}

yield* _(git(root, ["init"], gitBaseEnv))
yield* _(git(root, ["init", "--initial-branch=main"], gitBaseEnv))
}).pipe(Effect.asVoid)

const ensureOriginRemote = (
Expand Down Expand Up @@ -221,6 +222,7 @@ export const stateInit = (

yield* _(initRepoIfNeeded(fs, path, root, input))
yield* _(ensureOriginRemote(root, input.repoUrl))
yield* _(adoptRemoteHistoryIfOrphan(root, input.repoRef))
yield* _(checkoutBranchBestEffort(root, input.repoRef))
yield* _(ensureStateGitignore(fs, path, root))

Expand Down
Loading
Loading