Skip to content

Commit 1cea777

Browse files
authored
feat(auth): multi-profile .orch auth + GH OAuth TUI + state auto-sync (#63)
* feat(auth): multi-profile .orch auth + GH OAuth TUI + state auto-sync (#61) * fix(tui): avoid async/Promise in suspended flows * fix(tui): shared suspended wrapper + dedupe * fix(state): push via https when token available * fix(state): avoid ssh push prompts (use https url) * fix(state): bypass hooks for automated pushes * test(e2e): cover issue-61 labeled auth * feat(auth): claude code oauth profiles * fix(auth): claude oauth login UX * fix(auth): prompt for Claude OAuth code * fix(auth): avoid docker -t for Claude oauth * fix(auth): visible Claude OAuth code prompt * fix(auth): add Claude OAuth progress logs * fix(auth): switch Claude OAuth to setup-token flow * fix(ci): pass effect lint and auth bridge test
1 parent 96ea20d commit 1cea777

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

71 files changed

+4173
-630
lines changed

AGENTS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -375,9 +375,9 @@ describe("Message invariants", () => {
375375
ПРИНЦИП: Сначала формализуем, потом программируем.
376376

377377
<!-- docker-git:issue-managed:start -->
378-
Issue workspace: #39
379-
Issue URL: https://github.com/ProverCoderAI/docker-git/issues/39
380-
Workspace path: /home/dev/provercoderai/docker-git/issue-39
378+
Issue workspace: #61
379+
Issue URL: https://github.com/ProverCoderAI/docker-git/issues/61
380+
Workspace path: /home/dev/provercoderai/docker-git/issue-61
381381

382382
Работай только над этим issue, если пользователь не попросил другое.
383383
Если нужен первоисточник требований, открой Issue URL.

README.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,18 +59,17 @@ Structure (simplified):
5959
authorized_keys
6060
.orch/
6161
env/
62-
global.env
62+
global.env # shared tokens/keys (GitHub, Git, Claude) with labels
6363
auth/
64-
codex/ # shared Codex auth cache (credentials)
65-
gh/ # shared GitHub auth (optional)
64+
codex/ # shared Codex auth/config (when CODEX_SHARE_AUTH=1)
65+
gh/ # GH CLI auth cache for OAuth login container
6666
<owner>/<repo>/
6767
docker-compose.yml
6868
Dockerfile
6969
entrypoint.sh
7070
docker-git.json
7171
.orch/
7272
env/
73-
global.env # copied/synced from root .orch/env/global.env
7473
project.env # per-project env knobs (see below)
7574
auth/
7675
codex/ # project-local Codex state (sessions/logs/tmp/etc)
@@ -79,7 +78,7 @@ Structure (simplified):
7978
## Codex Auth: Shared Credentials, Per-Project Sessions
8079

8180
Default behavior:
82-
- Shared credentials live in `/home/dev/.codex-shared/auth.json` (mounted from projects root).
81+
- Shared credentials live in `/home/dev/.codex-shared/auth.json` (mounted from `<projectsRoot>/.orch/auth/codex`).
8382
- Each project keeps its own Codex state under `/home/dev/.codex/` (mounted from project `.orch/auth/codex`).
8483
- The entrypoint links `/home/dev/.codex/auth.json -> /home/dev/.codex-shared/auth.json`.
8584

@@ -160,7 +159,7 @@ Clone auth error (`Invalid username or token`):
160159
```bash
161160
pnpm run docker-git auth github status
162161
pnpm run docker-git auth github logout
163-
pnpm run docker-git auth github login --token '<GITHUB_TOKEN>'
162+
pnpm run docker-git auth github login --web
164163
pnpm run docker-git auth github status
165164
```
166165
- Token requirements:

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
"changeset-version": "changeset version",
1717
"clone": "pnpm --filter ./packages/app build && node packages/app/dist/main.js clone",
1818
"docker-git": "pnpm --filter ./packages/app build:docker-git && node packages/app/dist/src/docker-git/main.js",
19+
"e2e": "bash scripts/e2e/run-all.sh",
20+
"e2e:clone-cache": "bash scripts/e2e/clone-cache.sh",
21+
"e2e:login-context": "bash scripts/e2e/login-context.sh",
22+
"e2e:opencode-autoconnect": "bash scripts/e2e/opencode-autoconnect.sh",
1923
"list": "pnpm --filter ./packages/app build && node packages/app/dist/main.js list",
2024
"dev": "pnpm --filter ./packages/app dev",
2125
"lint": "pnpm --filter ./packages/app lint && pnpm --filter ./packages/lib lint",

packages/app/src/docker-git/cli/parser-auth.ts

Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,18 @@
11
import { Either, Match } from "effect"
22

33
import type { RawOptions } from "@effect-template/lib/core/command-options"
4-
import {
5-
type AuthCommand,
6-
type Command,
7-
defaultTemplateConfig,
8-
type ParseError
9-
} from "@effect-template/lib/core/domain"
4+
import { type AuthCommand, type Command, type ParseError } from "@effect-template/lib/core/domain"
105

116
import { parseRawOptions } from "./parser-options.js"
127

138
type AuthOptions = {
149
readonly envGlobalPath: string
1510
readonly codexAuthPath: string
11+
readonly claudeAuthPath: string
1612
readonly label: string | null
1713
readonly token: string | null
1814
readonly scopes: string | null
15+
readonly authWeb: boolean
1916
}
2017

2118
const missingArgument = (name: string): ParseError => ({
@@ -34,24 +31,32 @@ const normalizeLabel = (value: string | undefined): string | null => {
3431
return trimmed.length === 0 ? null : trimmed
3532
}
3633

34+
const defaultEnvGlobalPath = ".docker-git/.orch/env/global.env"
35+
const defaultCodexAuthPath = ".docker-git/.orch/auth/codex"
36+
const defaultClaudeAuthPath = ".docker-git/.orch/auth/claude"
37+
3738
const resolveAuthOptions = (raw: RawOptions): AuthOptions => ({
38-
envGlobalPath: raw.envGlobalPath ?? defaultTemplateConfig.envGlobalPath,
39-
codexAuthPath: raw.codexAuthPath ?? defaultTemplateConfig.codexAuthPath,
39+
envGlobalPath: raw.envGlobalPath ?? defaultEnvGlobalPath,
40+
codexAuthPath: raw.codexAuthPath ?? defaultCodexAuthPath,
41+
claudeAuthPath: defaultClaudeAuthPath,
4042
label: normalizeLabel(raw.label),
4143
token: normalizeLabel(raw.token),
42-
scopes: normalizeLabel(raw.scopes)
44+
scopes: normalizeLabel(raw.scopes),
45+
authWeb: raw.authWeb === true
4346
})
4447

4548
const buildGithubCommand = (action: string, options: AuthOptions): Either.Either<AuthCommand, ParseError> =>
4649
Match.value(action).pipe(
4750
Match.when("login", () =>
48-
Either.right<AuthCommand>({
49-
_tag: "AuthGithubLogin",
50-
label: options.label,
51-
token: options.token,
52-
scopes: options.scopes,
53-
envGlobalPath: options.envGlobalPath
54-
})),
51+
options.authWeb && options.token !== null
52+
? Either.left(invalidArgument("--token", "cannot be combined with --web"))
53+
: Either.right<AuthCommand>({
54+
_tag: "AuthGithubLogin",
55+
label: options.label,
56+
token: options.authWeb ? null : options.token,
57+
scopes: options.scopes,
58+
envGlobalPath: options.envGlobalPath
59+
})),
5560
Match.when("status", () =>
5661
Either.right<AuthCommand>({
5762
_tag: "AuthGithubStatus",
@@ -89,6 +94,29 @@ const buildCodexCommand = (action: string, options: AuthOptions): Either.Either<
8994
Match.orElse(() => Either.left(invalidArgument("auth action", `unknown action '${action}'`)))
9095
)
9196

97+
const buildClaudeCommand = (action: string, options: AuthOptions): Either.Either<AuthCommand, ParseError> =>
98+
Match.value(action).pipe(
99+
Match.when("login", () =>
100+
Either.right<AuthCommand>({
101+
_tag: "AuthClaudeLogin",
102+
label: options.label,
103+
claudeAuthPath: options.claudeAuthPath
104+
})),
105+
Match.when("status", () =>
106+
Either.right<AuthCommand>({
107+
_tag: "AuthClaudeStatus",
108+
label: options.label,
109+
claudeAuthPath: options.claudeAuthPath
110+
})),
111+
Match.when("logout", () =>
112+
Either.right<AuthCommand>({
113+
_tag: "AuthClaudeLogout",
114+
label: options.label,
115+
claudeAuthPath: options.claudeAuthPath
116+
})),
117+
Match.orElse(() => Either.left(invalidArgument("auth action", `unknown action '${action}'`)))
118+
)
119+
92120
const buildAuthCommand = (
93121
provider: string,
94122
action: string,
@@ -98,6 +126,8 @@ const buildAuthCommand = (
98126
Match.when("github", () => buildGithubCommand(action, options)),
99127
Match.when("gh", () => buildGithubCommand(action, options)),
100128
Match.when("codex", () => buildCodexCommand(action, options)),
129+
Match.when("claude", () => buildClaudeCommand(action, options)),
130+
Match.when("cc", () => buildClaudeCommand(action, options)),
101131
Match.orElse(() => Either.left(invalidArgument("auth provider", `unknown provider '${provider}'`)))
102132
)
103133

packages/app/src/docker-git/cli/parser-mcp-playwright.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,3 @@ export const parseMcpPlaywright = (
2222
projectDir,
2323
runUp: raw.up ?? true
2424
}))
25-

packages/app/src/docker-git/cli/parser.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const statusCommand: Command = { _tag: "Status" }
2222
const downAllCommand: Command = { _tag: "DownAll" }
2323

2424
const parseCreate = (args: ReadonlyArray<string>): Either.Either<Command, ParseError> =>
25-
Either.flatMap(parseRawOptions(args), buildCreateCommand)
25+
Either.flatMap(parseRawOptions(args), (raw) => buildCreateCommand(raw))
2626

2727
// CHANGE: parse CLI arguments into a typed command
2828
// WHY: enforce deterministic, pure parsing before any effects run

packages/app/src/docker-git/cli/usage.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ Commands:
2828
sessions List/kill/log container terminal processes
2929
ps, status Show docker compose status for all docker-git projects
3030
down-all Stop all docker-git containers (docker compose down)
31-
auth Manage GitHub/Codex auth for docker-git
31+
auth Manage GitHub/Codex/Claude Code auth for docker-git
3232
state Manage docker-git state directory via git (sync across machines)
3333
3434
Options:
@@ -40,7 +40,6 @@ Options:
4040
--container-name <name> Docker container name (default: dg-<repo>)
4141
--service-name <name> Compose service name (default: dg-<repo>)
4242
--volume-name <name> Docker volume name (default: dg-<repo>-home)
43-
--secrets-root <path> Host root for shared secrets (default: n/a)
4443
--authorized-keys <path> Host path to authorized_keys (default: <projectsRoot>/authorized_keys)
4544
--env-global <path> Host path to shared env file (default: <projectsRoot>/.orch/env/global.env)
4645
--env-project <path> Host path to project env file (default: ./.orch/env/project.env)
@@ -72,6 +71,7 @@ Container runtime env (set via .orch/env/project.env):
7271
Auth providers:
7372
github, gh GitHub CLI auth (tokens saved to env file)
7473
codex Codex CLI auth (stored under .orch/auth/codex)
74+
claude, cc Claude Code CLI auth (OAuth cache stored under .orch/auth/claude)
7575
7676
Auth actions:
7777
login Run login flow and store credentials
@@ -80,7 +80,8 @@ Auth actions:
8080
8181
Auth options:
8282
--label <label> Account label (default: default)
83-
--token <token> GitHub token override (login only)
83+
--token <token> GitHub token override (login only; useful for non-interactive/CI)
84+
--web Force OAuth web flow (login only; ignores --token)
8485
--scopes <scopes> GitHub scopes (login only, default: repo,workflow,read:org)
8586
--env-global <path> Env file path for GitHub tokens (default: <projectsRoot>/.orch/env/global.env)
8687
--codex-auth <path> Codex auth root path (default: <projectsRoot>/.orch/auth/codex)

packages/app/src/docker-git/menu-actions.ts

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ import {
1212
import { runDockerComposeUpWithPortCheck } from "@effect-template/lib/usecases/projects-up"
1313
import { Effect, Match, pipe } from "effect"
1414

15+
import { openAuthMenu } from "./menu-auth.js"
1516
import { startCreateView } from "./menu-create.js"
1617
import { loadSelectView } from "./menu-select-load.js"
17-
import { resumeTui, suspendTui } from "./menu-shared.js"
18-
import { type MenuEnv, type MenuRunner, type MenuState, type ViewState } from "./menu-types.js"
18+
import { withSuspendedTui, writeErrorAndPause } from "./menu-shared.js"
19+
import { type MenuEnv, type MenuRunner, type MenuState, type MenuViewContext } from "./menu-types.js"
1920

2021
// CHANGE: keep menu actions and input parsing in a dedicated module
2122
// WHY: reduce cognitive complexity in the TUI entry
@@ -39,9 +40,7 @@ export type MenuContext = {
3940
readonly state: MenuState
4041
readonly runner: MenuRunner
4142
readonly exit: () => void
42-
readonly setView: (view: ViewState) => void
43-
readonly setMessage: (message: string | null) => void
44-
}
43+
} & MenuViewContext
4544

4645
export type MenuSelectionContext = MenuContext & {
4746
readonly selected: number
@@ -50,6 +49,8 @@ export type MenuSelectionContext = MenuContext & {
5049

5150
const actionLabel = (action: MenuAction): string =>
5251
Match.value(action).pipe(
52+
Match.when({ _tag: "Auth" }, () => "Auth profiles"),
53+
Match.when({ _tag: "ProjectAuth" }, () => "Project auth"),
5354
Match.when({ _tag: "Up" }, () => "docker compose up"),
5455
Match.when({ _tag: "Status" }, () => "docker compose ps"),
5556
Match.when({ _tag: "Logs" }, () => "docker compose logs"),
@@ -67,19 +68,13 @@ const runWithSuspendedTui = (
6768
pipe(
6869
Effect.sync(() => {
6970
context.setMessage(`${label}...`)
70-
suspendTui()
7171
}),
72-
Effect.zipRight(effect),
72+
Effect.zipRight(withSuspendedTui(effect, { onError: (error) => writeErrorAndPause(renderError(error)) })),
7373
Effect.tap(() =>
7474
Effect.sync(() => {
7575
context.setMessage(`${label} finished.`)
7676
})
7777
),
78-
Effect.ensuring(
79-
Effect.sync(() => {
80-
resumeTui()
81-
})
82-
),
8378
Effect.asVoid
8479
)
8580
)
@@ -140,6 +135,8 @@ const handleMenuAction = (
140135
Match.when({ _tag: "Quit" }, () => Effect.succeed(quitOutcome)),
141136
Match.when({ _tag: "Create" }, () => Effect.succeed(continueOutcome(state))),
142137
Match.when({ _tag: "Select" }, () => Effect.succeed(continueOutcome(state))),
138+
Match.when({ _tag: "Auth" }, () => Effect.succeed(continueOutcome(state))),
139+
Match.when({ _tag: "ProjectAuth" }, () => Effect.succeed(continueOutcome(state))),
143140
Match.when({ _tag: "Info" }, () => Effect.succeed(continueOutcome(state))),
144141
Match.when({ _tag: "Delete" }, () => Effect.succeed(continueOutcome(state))),
145142
Match.when({ _tag: "Up" }, () =>
@@ -171,6 +168,22 @@ const runSelectAction = (context: MenuContext) => {
171168
context.runner.runEffect(loadSelectView(listProjectItems, "Connect", context))
172169
}
173170

171+
const runAuthProfilesAction = (context: MenuContext) => {
172+
context.setMessage(null)
173+
openAuthMenu({
174+
state: context.state,
175+
runner: context.runner,
176+
setView: context.setView,
177+
setMessage: context.setMessage,
178+
setActiveDir: context.setActiveDir
179+
})
180+
}
181+
182+
const runProjectAuthAction = (context: MenuContext) => {
183+
context.setMessage(null)
184+
context.runner.runEffect(loadSelectView(listProjectItems, "Auth", context))
185+
}
186+
174187
const runDownAllAction = (context: MenuContext) => {
175188
context.setMessage(null)
176189
runWithSuspendedTui(downAllDockerGitProjects, context, "Stopping all docker-git containers")
@@ -222,6 +235,12 @@ export const handleMenuActionSelection = (action: MenuAction, context: MenuConte
222235
Match.when({ _tag: "Select" }, () => {
223236
runSelectAction(context)
224237
}),
238+
Match.when({ _tag: "Auth" }, () => {
239+
runAuthProfilesAction(context)
240+
}),
241+
Match.when({ _tag: "ProjectAuth" }, () => {
242+
runProjectAuthAction(context)
243+
}),
225244
Match.when({ _tag: "Info" }, () => {
226245
runInfoAction(context)
227246
}),

0 commit comments

Comments
 (0)