Skip to content

Commit 070ccf9

Browse files
authored
Merge pull request #166 from ProverCoderAI/issue-163
fix(shell): hint when github auth is missing for state sync
2 parents 6b9356f + 712bcac commit 070ccf9

File tree

6 files changed

+250
-92
lines changed

6 files changed

+250
-92
lines changed

packages/lib/src/usecases/state-repo.ts

Lines changed: 19 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type * as CommandExecutor from "@effect/platform/CommandExecutor"
22
import type { PlatformError } from "@effect/platform/Error"
33
import * as FileSystem from "@effect/platform/FileSystem"
44
import * as Path from "@effect/platform/Path"
5-
import { Effect, pipe } from "effect"
5+
import { Effect } from "effect"
66
import { runCommandExitCode } from "../shell/command-runner.js"
77
import { CommandFailedError } from "../shell/errors.js"
88
import { defaultProjectsRoot } from "./menu-helpers.js"
@@ -17,17 +17,19 @@ import {
1717
isGitRepo,
1818
successExitCode
1919
} from "./state-repo/git-commands.js"
20+
import {
21+
githubAuthLoginHint,
22+
normalizeOriginUrlIfNeeded,
23+
shouldLogGithubAuthHintForStateSyncFailure
24+
} from "./state-repo/github-auth-state.js"
2025
import type { GitAuthEnv } from "./state-repo/github-auth.js"
2126
import { isGithubHttpsRemote, resolveGithubToken, withGithubAskpassEnv } from "./state-repo/github-auth.js"
2227
import { ensureStateGitignore } from "./state-repo/gitignore.js"
2328
import { runStateSyncOps, runStateSyncWithToken } from "./state-repo/sync-ops.js"
2429

2530
type StateRepoEnv = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor
26-
2731
const resolveStateRoot = (path: Path.Path, cwd: string): string => path.resolve(defaultProjectsRoot(cwd))
28-
2932
const managedRepositoryCachePaths: ReadonlyArray<string> = [".cache/git-mirrors", ".cache/packages"]
30-
3133
const ensureStateIgnoreAndUntrackCaches = (
3234
fs: FileSystem.FileSystem,
3335
path: Path.Path,
@@ -53,7 +55,6 @@ export const stateSync = (
5355
const fs = yield* _(FileSystem.FileSystem)
5456
const path = yield* _(Path.Path)
5557
const root = resolveStateRoot(path, process.cwd())
56-
5758
const repoExit = yield* _(gitExitCode(root, ["rev-parse", "--is-inside-work-tree"], gitBaseEnv))
5859
if (repoExit !== successExitCode) {
5960
yield* _(Effect.logWarning(`State dir is not a git repository: ${root}`))
@@ -62,9 +63,7 @@ export const stateSync = (
6263
Effect.fail(new CommandFailedError({ command: "git rev-parse --is-inside-work-tree", exitCode: repoExit }))
6364
)
6465
}
65-
6666
yield* _(ensureStateIgnoreAndUntrackCaches(fs, path, root))
67-
6867
const originUrlExit = yield* _(gitExitCode(root, ["remote", "get-url", "origin"], gitBaseEnv))
6968
if (originUrlExit !== successExitCode) {
7069
yield* _(Effect.logWarning(`State dir has no origin remote: ${root}`))
@@ -73,33 +72,38 @@ export const stateSync = (
7372
Effect.fail(new CommandFailedError({ command: "git remote get-url origin", exitCode: originUrlExit }))
7473
)
7574
}
76-
const originUrl = yield* _(
75+
const rawOriginUrl = yield* _(
7776
gitCapture(root, ["remote", "get-url", "origin"], gitBaseEnv).pipe(Effect.map((value) => value.trim()))
7877
)
78+
const originUrl = yield* _(normalizeOriginUrlIfNeeded(root, rawOriginUrl))
7979
const token = yield* _(resolveGithubToken(fs, path, root))
8080
const syncEffect = token && token.length > 0 && isGithubHttpsRemote(originUrl)
8181
? runStateSyncWithToken(token, root, originUrl, message)
8282
: runStateSyncOps(root, originUrl, message, gitBaseEnv)
83-
84-
yield* _(syncEffect)
83+
yield* _(
84+
syncEffect.pipe(
85+
Effect.tapError((error) =>
86+
shouldLogGithubAuthHintForStateSyncFailure(originUrl, token, error)
87+
? Effect.logWarning(githubAuthLoginHint)
88+
: Effect.void
89+
)
90+
)
91+
)
8592
}).pipe(Effect.asVoid)
8693

8794
export const autoSyncState = (message: string): Effect.Effect<void, never, StateRepoEnv> =>
8895
Effect.gen(function*(_) {
8996
const path = yield* _(Path.Path)
9097
const root = resolveStateRoot(path, process.cwd())
91-
9298
const repoOk = yield* _(isGitRepo(root))
9399
if (!repoOk) {
94100
return
95101
}
96-
97102
const originOk = yield* _(hasOriginRemote(root))
98103
const enabled = isAutoSyncEnabled(process.env[autoSyncEnvKey], originOk)
99104
if (!enabled) {
100105
return
101106
}
102-
103107
const strictValue = process.env[autoSyncStrictEnvKey]
104108
const strict = strictValue !== undefined && strictValue.trim().length > 0 ? isTruthyEnv(strictValue) : false
105109
const effect = stateSync(message)
@@ -243,80 +247,5 @@ export const stateInit = (
243247
: doInit(gitBaseEnv)
244248
}
245249

246-
export const stateStatus = Effect.gen(function*(_) {
247-
const path = yield* _(Path.Path)
248-
const root = resolveStateRoot(path, process.cwd())
249-
const output = yield* _(gitCapture(root, ["status", "-sb", "--porcelain=v1"], gitBaseEnv))
250-
yield* _(Effect.log(output.trim().length > 0 ? output.trimEnd() : "(clean)"))
251-
}).pipe(Effect.asVoid)
252-
253-
export const statePull = Effect.gen(function*(_) {
254-
const fs = yield* _(FileSystem.FileSystem)
255-
const path = yield* _(Path.Path)
256-
const root = resolveStateRoot(path, process.cwd())
257-
const originUrlExit = yield* _(gitExitCode(root, ["remote", "get-url", "origin"], gitBaseEnv))
258-
if (originUrlExit !== successExitCode) {
259-
yield* _(git(root, ["pull", "--rebase"], gitBaseEnv))
260-
return
261-
}
262-
const originUrl = yield* _(
263-
gitCapture(root, ["remote", "get-url", "origin"], gitBaseEnv).pipe(Effect.map((value) => value.trim()))
264-
)
265-
const token = yield* _(resolveGithubToken(fs, path, root))
266-
const effect = token && token.length > 0 && isGithubHttpsRemote(originUrl)
267-
? withGithubAskpassEnv(token, (env) => git(root, ["pull", "--rebase"], env))
268-
: git(root, ["pull", "--rebase"], gitBaseEnv)
269-
yield* _(effect)
270-
}).pipe(Effect.asVoid)
271-
272-
export const statePush = Effect.gen(function*(_) {
273-
const fs = yield* _(FileSystem.FileSystem)
274-
const path = yield* _(Path.Path)
275-
const root = resolveStateRoot(path, process.cwd())
276-
const originUrlExit = yield* _(gitExitCode(root, ["remote", "get-url", "origin"], gitBaseEnv))
277-
if (originUrlExit !== successExitCode) {
278-
yield* _(git(root, ["push", "-u", "origin", "HEAD"], gitBaseEnv))
279-
return
280-
}
281-
const originUrl = yield* _(
282-
gitCapture(root, ["remote", "get-url", "origin"], gitBaseEnv).pipe(Effect.map((value) => value.trim()))
283-
)
284-
const token = yield* _(resolveGithubToken(fs, path, root))
285-
const effect = token && token.length > 0 && isGithubHttpsRemote(originUrl)
286-
? withGithubAskpassEnv(
287-
token,
288-
(env) =>
289-
pipe(
290-
gitCapture(root, ["rev-parse", "--abbrev-ref", "HEAD"], env),
291-
Effect.map((value) => value.trim()),
292-
Effect.map((branch) => (branch === "HEAD" ? "main" : branch)),
293-
Effect.flatMap((branch) => git(root, ["push", "--no-verify", originUrl, `HEAD:refs/heads/${branch}`], env))
294-
)
295-
)
296-
: git(root, ["push", "--no-verify", "-u", "origin", "HEAD"], gitBaseEnv)
297-
yield* _(effect)
298-
}).pipe(Effect.asVoid)
299-
300-
export const stateCommit = (
301-
message: string
302-
): Effect.Effect<
303-
void,
304-
CommandFailedError | PlatformError,
305-
FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor
306-
> =>
307-
Effect.gen(function*(_) {
308-
const fs = yield* _(FileSystem.FileSystem)
309-
const path = yield* _(Path.Path)
310-
const root = resolveStateRoot(path, process.cwd())
311-
312-
yield* _(ensureStateIgnoreAndUntrackCaches(fs, path, root))
313-
yield* _(git(root, ["add", "-A"], gitBaseEnv))
314-
const diffExit = yield* _(gitExitCode(root, ["diff", "--cached", "--quiet"], gitBaseEnv))
315-
316-
if (diffExit === successExitCode) {
317-
yield* _(Effect.log("Nothing to commit."))
318-
return
319-
}
320-
321-
yield* _(git(root, ["commit", "-m", message], gitBaseEnv))
322-
}).pipe(Effect.asVoid)
250+
export { stateCommit, stateStatus } from "./state-repo/local-ops.js"
251+
export { statePull, statePush } from "./state-repo/pull-push.js"
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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+
import type { CommandFailedError } from "../../shell/errors.js"
7+
import { git, gitBaseEnv, gitCapture } from "./git-commands.js"
8+
import {
9+
isGithubHttpsRemote,
10+
normalizeGithubHttpsRemote,
11+
requiresGithubAuthHint,
12+
resolveGithubToken
13+
} from "./github-auth.js"
14+
15+
export const githubAuthLoginHint =
16+
"GitHub is not authorized for docker-git. To use state sync, run: docker-git auth github login --web"
17+
18+
export const normalizeOriginUrlIfNeeded = (
19+
root: string,
20+
originUrl: string
21+
): Effect.Effect<string, CommandFailedError | PlatformError, CommandExecutor.CommandExecutor> =>
22+
Effect.gen(function*(_) {
23+
const normalized = normalizeGithubHttpsRemote(originUrl)
24+
if (normalized === null || normalized === originUrl) {
25+
return originUrl
26+
}
27+
yield* _(git(root, ["remote", "set-url", "origin", normalized], gitBaseEnv))
28+
return normalized
29+
})
30+
31+
export const resolveStateGithubContext = (
32+
fs: FileSystem.FileSystem,
33+
path: Path.Path,
34+
root: string
35+
): Effect.Effect<
36+
{ readonly originUrl: string; readonly token: string | null; readonly authHintNeeded: boolean },
37+
CommandFailedError | PlatformError,
38+
FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor
39+
> =>
40+
Effect.gen(function*(_) {
41+
const rawOriginUrl = yield* _(
42+
gitCapture(root, ["remote", "get-url", "origin"], gitBaseEnv).pipe(Effect.map((value) => value.trim()))
43+
)
44+
const originUrl = yield* _(normalizeOriginUrlIfNeeded(root, rawOriginUrl))
45+
const token = yield* _(resolveGithubToken(fs, path, root))
46+
return {
47+
originUrl,
48+
token,
49+
authHintNeeded: requiresGithubAuthHint(originUrl, token)
50+
}
51+
})
52+
53+
export const shouldLogGithubAuthHintForStateSyncFailure = (
54+
originUrl: string,
55+
token: string | null,
56+
error: CommandFailedError | PlatformError
57+
): boolean =>
58+
requiresGithubAuthHint(originUrl, token) ||
59+
(isGithubHttpsRemote(originUrl) &&
60+
error._tag === "CommandFailedError" &&
61+
error.command === "git fetch origin --prune")
62+
63+
export const withGithubAuthHintOnFailure = <A, E, R>(
64+
effect: Effect.Effect<A, E, R>,
65+
enabled: boolean
66+
): Effect.Effect<A, E, R> =>
67+
effect.pipe(
68+
Effect.tapError(() => enabled ? Effect.logWarning(githubAuthLoginHint) : Effect.void)
69+
)

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { gitBaseEnv } from "./git-commands.js"
77

88
const githubTokenKey = "GITHUB_TOKEN"
99

10-
const githubHttpsRemoteRe = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/
10+
const githubHttpsRemoteRe = /^https:\/\/(?:[^/]+@)?github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/
1111
const githubSshRemoteRe = /^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/
1212
const githubSshUrlRemoteRe = /^ssh:\/\/git@github\.com\/([^/]+)\/(.+?)(?:\.git)?$/
1313

@@ -43,7 +43,18 @@ export const tryBuildGithubCompareUrl = (
4343
}?expand=1`
4444
}
4545

46-
export const isGithubHttpsRemote = (url: string): boolean => /^https:\/\/github\.com\//.test(url.trim())
46+
export const isGithubHttpsRemote = (url: string): boolean => /^https:\/\/(?:[^/]+@)?github\.com\//.test(url.trim())
47+
48+
export const normalizeGithubHttpsRemote = (url: string): string | null => {
49+
if (!isGithubHttpsRemote(url)) {
50+
return null
51+
}
52+
const parts = tryParseGithubRemoteParts(url)
53+
return parts === null ? null : `https://github.com/${parts.owner}/${parts.repo}.git`
54+
}
55+
56+
export const requiresGithubAuthHint = (originUrl: string, token: string | null | undefined): boolean =>
57+
isGithubHttpsRemote(originUrl) && (token?.trim() ?? "").length === 0
4758

4859
const resolveTokenFromProcessEnv = (): string | null => {
4960
const github = process.env["GITHUB_TOKEN"]
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type * as CommandExecutor from "@effect/platform/CommandExecutor"
2+
import type { PlatformError } from "@effect/platform/Error"
3+
import * as FileSystem from "@effect/platform/FileSystem"
4+
import * as Path from "@effect/platform/Path"
5+
import { Effect } from "effect"
6+
import type { CommandFailedError } from "../../shell/errors.js"
7+
import { defaultProjectsRoot } from "../menu-helpers.js"
8+
import { git, gitBaseEnv, gitCapture, gitExitCode, successExitCode } from "./git-commands.js"
9+
import { ensureStateGitignore } from "./gitignore.js"
10+
11+
type StateRepoEnv = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor
12+
13+
const resolveStateRoot = (path: Path.Path, cwd: string): string => path.resolve(defaultProjectsRoot(cwd))
14+
15+
const managedRepositoryCachePaths: ReadonlyArray<string> = [".cache/git-mirrors", ".cache/packages"]
16+
17+
const ensureStateIgnoreAndUntrackCaches = (
18+
fs: FileSystem.FileSystem,
19+
path: Path.Path,
20+
root: string
21+
): Effect.Effect<void, CommandFailedError | PlatformError, StateRepoEnv> =>
22+
Effect.gen(function*(_) {
23+
yield* _(ensureStateGitignore(fs, path, root))
24+
yield* _(git(root, ["rm", "-r", "--cached", "--ignore-unmatch", ...managedRepositoryCachePaths], gitBaseEnv))
25+
}).pipe(Effect.asVoid)
26+
27+
export const stateStatus = Effect.gen(function*(_) {
28+
const path = yield* _(Path.Path)
29+
const root = resolveStateRoot(path, process.cwd())
30+
const output = yield* _(gitCapture(root, ["status", "-sb", "--porcelain=v1"], gitBaseEnv))
31+
yield* _(Effect.log(output.trim().length > 0 ? output.trimEnd() : "(clean)"))
32+
}).pipe(Effect.asVoid)
33+
34+
export const stateCommit = (
35+
message: string
36+
): Effect.Effect<
37+
void,
38+
CommandFailedError | PlatformError,
39+
FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor
40+
> =>
41+
Effect.gen(function*(_) {
42+
const fs = yield* _(FileSystem.FileSystem)
43+
const path = yield* _(Path.Path)
44+
const root = resolveStateRoot(path, process.cwd())
45+
yield* _(ensureStateIgnoreAndUntrackCaches(fs, path, root))
46+
yield* _(git(root, ["add", "-A"], gitBaseEnv))
47+
const diffExit = yield* _(gitExitCode(root, ["diff", "--cached", "--quiet"], gitBaseEnv))
48+
if (diffExit === successExitCode) {
49+
yield* _(Effect.log("Nothing to commit."))
50+
return
51+
}
52+
yield* _(git(root, ["commit", "-m", message], gitBaseEnv))
53+
}).pipe(Effect.asVoid)
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type * as CommandExecutor from "@effect/platform/CommandExecutor"
2+
import type { PlatformError } from "@effect/platform/Error"
3+
import * as FileSystem from "@effect/platform/FileSystem"
4+
import * as Path from "@effect/platform/Path"
5+
import { Effect, pipe } from "effect"
6+
import type { CommandFailedError } from "../../shell/errors.js"
7+
import { defaultProjectsRoot } from "../menu-helpers.js"
8+
import { git, gitBaseEnv, gitCapture, gitExitCode, successExitCode } from "./git-commands.js"
9+
import { resolveStateGithubContext, withGithubAuthHintOnFailure } from "./github-auth-state.js"
10+
import { isGithubHttpsRemote, withGithubAskpassEnv } from "./github-auth.js"
11+
12+
const resolveStateRoot = (path: Path.Path, cwd: string): string => path.resolve(defaultProjectsRoot(cwd))
13+
14+
export const statePull: Effect.Effect<
15+
void,
16+
CommandFailedError | PlatformError,
17+
FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor
18+
> = Effect.gen(function*(_) {
19+
const fs = yield* _(FileSystem.FileSystem)
20+
const path = yield* _(Path.Path)
21+
const root = resolveStateRoot(path, process.cwd())
22+
const originUrlExit = yield* _(gitExitCode(root, ["remote", "get-url", "origin"], gitBaseEnv))
23+
if (originUrlExit !== successExitCode) {
24+
yield* _(git(root, ["pull", "--rebase"], gitBaseEnv))
25+
return
26+
}
27+
const auth = yield* _(resolveStateGithubContext(fs, path, root))
28+
const effect = auth.token && auth.token.length > 0 && isGithubHttpsRemote(auth.originUrl)
29+
? withGithubAskpassEnv(auth.token, (env) => git(root, ["pull", "--rebase"], env))
30+
: git(root, ["pull", "--rebase"], gitBaseEnv)
31+
yield* _(withGithubAuthHintOnFailure(effect, auth.authHintNeeded))
32+
}).pipe(Effect.asVoid)
33+
34+
export const statePush: Effect.Effect<
35+
void,
36+
CommandFailedError | PlatformError,
37+
FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor
38+
> = Effect.gen(function*(_) {
39+
const fs = yield* _(FileSystem.FileSystem)
40+
const path = yield* _(Path.Path)
41+
const root = resolveStateRoot(path, process.cwd())
42+
const originUrlExit = yield* _(gitExitCode(root, ["remote", "get-url", "origin"], gitBaseEnv))
43+
if (originUrlExit !== successExitCode) {
44+
yield* _(git(root, ["push", "-u", "origin", "HEAD"], gitBaseEnv))
45+
return
46+
}
47+
const auth = yield* _(resolveStateGithubContext(fs, path, root))
48+
const effect = auth.token && auth.token.length > 0 && isGithubHttpsRemote(auth.originUrl)
49+
? withGithubAskpassEnv(
50+
auth.token,
51+
(env) =>
52+
pipe(
53+
gitCapture(root, ["rev-parse", "--abbrev-ref", "HEAD"], env),
54+
Effect.map((value) => value.trim()),
55+
Effect.map((branch) => (branch === "HEAD" ? "main" : branch)),
56+
Effect.flatMap((branch) =>
57+
git(root, ["push", "--no-verify", auth.originUrl, `HEAD:refs/heads/${branch}`], env)
58+
)
59+
)
60+
)
61+
: git(root, ["push", "--no-verify", "-u", "origin", "HEAD"], gitBaseEnv)
62+
yield* _(withGithubAuthHintOnFailure(effect, auth.authHintNeeded))
63+
}).pipe(Effect.asVoid)

0 commit comments

Comments
 (0)