Skip to content

Commit 27042dd

Browse files
authored
Merge pull request #142 from skulidropek/issue-141
feat(auth): create or clone .docker-git repo on GitHub auth
2 parents 65da7d4 + 9df826d commit 27042dd

10 files changed

Lines changed: 669 additions & 87 deletions

File tree

packages/lib/src/usecases/auth-github.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { ensureEnvFile, parseEnvEntries, readEnvText, removeEnvKey, upsertEnvKey
1717
import { ensureGhAuthImage, ghAuthDir, ghAuthRoot, ghImageName } from "./github-auth-image.js"
1818
import { resolvePathFromCwd } from "./path-helpers.js"
1919
import { withFsPathContext } from "./runtime.js"
20+
import { ensureStateDotDockerGitRepo } from "./state-repo-github.js"
2021
import { autoSyncState } from "./state-repo.js"
2122

2223
type GithubTokenEntry = {
@@ -200,7 +201,7 @@ const runGithubInteractiveLogin = (
200201
path: Path.Path,
201202
envPath: string,
202203
command: AuthGithubLoginCommand
203-
): Effect.Effect<void, AuthError | CommandFailedError | PlatformError, GithubRuntime> =>
204+
): Effect.Effect<string, AuthError | CommandFailedError | PlatformError, GithubRuntime> =>
204205
Effect.gen(function*(_) {
205206
const rootPath = resolvePathFromCwd(path, cwd, ghAuthRoot)
206207
const accountLabel = normalizeAccountLabel(command.label, "default")
@@ -214,17 +215,18 @@ const runGithubInteractiveLogin = (
214215
yield* _(ensureEnvFile(fs, path, envPath))
215216
const key = buildGithubTokenKey(command.label)
216217
yield* _(persistGithubToken(fs, envPath, key, resolved))
218+
return resolved
217219
})
218220

219221
// CHANGE: login to GitHub by persisting a token in the shared env file
220222
// WHY: make GH_TOKEN available to all docker-git projects
221223
// QUOTE(ТЗ): "система авторизации"
222224
// REF: user-request-2026-01-28-auth
223225
// SOURCE: n/a
224-
// FORMAT THEOREM: forall t: login(t) -> env(GITHUB_TOKEN)=t
226+
// FORMAT THEOREM: forall t: login(t) -> env(GITHUB_TOKEN)=t ∧ cloned(~/.docker-git)
225227
// PURITY: SHELL
226228
// EFFECT: Effect<void, CommandFailedError | PlatformError, CommandExecutor>
227-
// INVARIANT: token is never logged
229+
// INVARIANT: token is never logged; state repo setup is best-effort
228230
// COMPLEXITY: O(n) where n = |env|
229231
export const authGithubLogin = (
230232
command: AuthGithubLoginCommand
@@ -239,10 +241,12 @@ export const authGithubLogin = (
239241
if (token.length > 0) {
240242
yield* _(ensureEnvFile(fs, path, envPath))
241243
yield* _(persistGithubToken(fs, envPath, key, token))
244+
yield* _(ensureStateDotDockerGitRepo(token))
242245
yield* _(autoSyncState(`chore(state): auth gh ${label}`))
243246
return
244247
}
245-
yield* _(runGithubInteractiveLogin(cwd, fs, path, envPath, command))
248+
const resolvedToken = yield* _(runGithubInteractiveLogin(cwd, fs, path, envPath, command))
249+
yield* _(ensureStateDotDockerGitRepo(resolvedToken))
246250
yield* _(autoSyncState(`chore(state): auth gh ${label}`))
247251
})
248252
)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type * as CommandExecutor from "@effect/platform/CommandExecutor"
2+
import type { PlatformError } from "@effect/platform/Error"
3+
import { Effect } from "effect"
4+
5+
import { runDockerAuthCapture } from "../shell/docker-auth.js"
6+
import { CommandFailedError } from "../shell/errors.js"
7+
import { buildDockerAuthSpec } from "./auth-helpers.js"
8+
import { ghAuthDir, ghImageName } from "./github-auth-image.js"
9+
10+
// CHANGE: extract shared gh-API Docker helpers used by github-fork and state-repo-github
11+
// WHY: avoid code duplication flagged by the duplicate-detection linter
12+
// REF: issue-141
13+
// PURITY: SHELL
14+
// INVARIANT: helpers are stateless and composable
15+
16+
/**
17+
* Run `gh api <args>` inside the auth Docker container and return trimmed stdout.
18+
*
19+
* @pure false
20+
* @effect CommandExecutor (Docker)
21+
* @invariant exits with CommandFailedError on non-zero exit code
22+
* @complexity O(1)
23+
*/
24+
export const runGhApiCapture = (
25+
cwd: string,
26+
hostPath: string,
27+
token: string,
28+
args: ReadonlyArray<string>
29+
): Effect.Effect<string, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
30+
runDockerAuthCapture(
31+
buildDockerAuthSpec({
32+
cwd,
33+
image: ghImageName,
34+
hostPath,
35+
containerPath: ghAuthDir,
36+
env: `GH_TOKEN=${token}`,
37+
args: ["api", ...args],
38+
interactive: false
39+
}),
40+
[0],
41+
(exitCode) => new CommandFailedError({ command: `gh api ${args.join(" ")}`, exitCode })
42+
).pipe(Effect.map((raw) => raw.trim()))
43+
44+
/**
45+
* Like `runGhApiCapture` but returns `null` instead of failing on API errors
46+
* (e.g. HTTP 404 / non-zero exit code).
47+
*
48+
* @pure false
49+
* @effect CommandExecutor (Docker)
50+
* @invariant never fails — errors become null
51+
* @complexity O(1)
52+
*/
53+
export const runGhApiNullable = (
54+
cwd: string,
55+
hostPath: string,
56+
token: string,
57+
args: ReadonlyArray<string>
58+
): Effect.Effect<string | null, PlatformError, CommandExecutor.CommandExecutor> =>
59+
runGhApiCapture(cwd, hostPath, token, args).pipe(
60+
Effect.catchTag("CommandFailedError", () => Effect.succeed("")),
61+
Effect.map((raw) => (raw.length === 0 ? null : raw))
62+
)

packages/lib/src/usecases/github-fork.ts

Lines changed: 4 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,10 @@ import { Effect } from "effect"
66

77
import type { CreateCommand } from "../core/domain.js"
88
import { parseGithubRepoUrl } from "../core/repo.js"
9-
import { runDockerAuthCapture } from "../shell/docker-auth.js"
109
import { CommandFailedError } from "../shell/errors.js"
11-
import { buildDockerAuthSpec } from "./auth-helpers.js"
1210
import { parseEnvEntries, readEnvText } from "./env-file.js"
13-
import { ensureGhAuthImage, ghAuthDir, ghAuthRoot, ghImageName } from "./github-auth-image.js"
11+
import { runGhApiCapture, runGhApiNullable } from "./github-api-helpers.js"
12+
import { ensureGhAuthImage, ghAuthRoot } from "./github-auth-image.js"
1413
import { resolvePathFromCwd } from "./path-helpers.js"
1514
import { withFsPathContext } from "./runtime.js"
1615

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

29-
const runGhApiCapture = (
30-
cwd: string,
31-
hostPath: string,
32-
token: string,
33-
args: ReadonlyArray<string>
34-
): Effect.Effect<string, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
35-
runDockerAuthCapture(
36-
buildDockerAuthSpec({
37-
cwd,
38-
image: ghImageName,
39-
hostPath,
40-
containerPath: ghAuthDir,
41-
env: `GH_TOKEN=${token}`,
42-
args: ["api", ...args],
43-
interactive: false
44-
}),
45-
[0],
46-
(exitCode) => new CommandFailedError({ command: `gh api ${args.join(" ")}`, exitCode })
47-
).pipe(Effect.map((raw) => raw.trim()))
48-
49-
const runGhApiCloneUrl = (
50-
cwd: string,
51-
hostPath: string,
52-
token: string,
53-
args: ReadonlyArray<string>
54-
): Effect.Effect<string | null, PlatformError, CommandExecutor.CommandExecutor> =>
55-
runGhApiCapture(cwd, hostPath, token, args).pipe(
56-
Effect.catchTag("CommandFailedError", () => Effect.succeed("")),
57-
Effect.map((raw) => (raw.length === 0 ? null : raw))
58-
)
59-
6028
const resolveViewerLogin = (
6129
cwd: string,
6230
hostPath: string,
@@ -77,7 +45,7 @@ const resolveRepoCloneUrl = (
7745
token: string,
7846
fullName: string
7947
): Effect.Effect<string | null, PlatformError, CommandExecutor.CommandExecutor> =>
80-
runGhApiCloneUrl(cwd, hostPath, token, [`/repos/${fullName}`, "--jq", ".clone_url"])
48+
runGhApiNullable(cwd, hostPath, token, [`/repos/${fullName}`, "--jq", ".clone_url"])
8149

8250
const createFork = (
8351
cwd: string,
@@ -86,7 +54,7 @@ const createFork = (
8654
owner: string,
8755
repo: string
8856
): Effect.Effect<string | null, PlatformError, CommandExecutor.CommandExecutor> =>
89-
runGhApiCloneUrl(cwd, hostPath, token, [
57+
runGhApiNullable(cwd, hostPath, token, [
9058
"-X",
9159
"POST",
9260
`/repos/${owner}/${repo}/forks`,
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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

Comments
 (0)