Skip to content

Commit 766a83d

Browse files
authored
fix(shell): refresh Claude OAuth token per CLI launch (#74)
* fix(shell): refresh claude oauth token per cli launch * fix(ci): unblock lint and e2e checks * feat(cli): apply docker-git config to existing project * test(app): deduplicate apply parser cases * fix(apply): infer project dir from current repo context * feat(auth): add codex/claude token labels for create/clone * feat(apply): support token and mcp overrides
1 parent ccb313e commit 766a83d

File tree

26 files changed

+1114
-117
lines changed

26 files changed

+1114
-117
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Either } from "effect"
2+
3+
import { type ApplyCommand, type ParseError } from "@effect-template/lib/core/domain"
4+
5+
import { parseProjectDirWithOptions } from "./parser-shared.js"
6+
7+
// CHANGE: parse "apply" command for existing docker-git projects
8+
// WHY: update managed docker-git config on the current project/container without creating a new project
9+
// QUOTE(ТЗ): "Не создавать новый... а прямо в текущем обновить её на актуальную"
10+
// REF: issue-72-followup-apply-current-config
11+
// SOURCE: n/a
12+
// FORMAT THEOREM: forall argv: parseApply(argv) = cmd -> deterministic(cmd)
13+
// PURITY: CORE
14+
// EFFECT: Effect<ApplyCommand, ParseError, never>
15+
// INVARIANT: projectDir is never empty
16+
// COMPLEXITY: O(n) where n = |argv|
17+
export const parseApply = (
18+
args: ReadonlyArray<string>
19+
): Either.Either<ApplyCommand, ParseError> =>
20+
Either.map(parseProjectDirWithOptions(args), ({ projectDir, raw }) => ({
21+
_tag: "Apply",
22+
projectDir,
23+
runUp: raw.up ?? true,
24+
gitTokenLabel: raw.gitTokenLabel,
25+
codexTokenLabel: raw.codexTokenLabel,
26+
claudeTokenLabel: raw.claudeTokenLabel,
27+
enableMcpPlaywright: raw.enableMcpPlaywright
28+
}))

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

Lines changed: 62 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ interface ValueOptionSpec {
2424
| "scrapMode"
2525
| "label"
2626
| "gitTokenLabel"
27+
| "codexTokenLabel"
28+
| "claudeTokenLabel"
2729
| "token"
2830
| "scopes"
2931
| "message"
@@ -53,6 +55,8 @@ const valueOptionSpecs: ReadonlyArray<ValueOptionSpec> = [
5355
{ flag: "--mode", key: "scrapMode" },
5456
{ flag: "--label", key: "label" },
5557
{ flag: "--git-token", key: "gitTokenLabel" },
58+
{ flag: "--codex-token", key: "codexTokenLabel" },
59+
{ flag: "--claude-token", key: "claudeTokenLabel" },
5660
{ flag: "--token", key: "token" },
5761
{ flag: "--scopes", key: "scopes" },
5862
{ flag: "--message", key: "message" },
@@ -102,6 +106,8 @@ const valueFlagUpdaters: { readonly [K in ValueKey]: (raw: RawOptions, value: st
102106
scrapMode: (raw, value) => ({ ...raw, scrapMode: value }),
103107
label: (raw, value) => ({ ...raw, label: value }),
104108
gitTokenLabel: (raw, value) => ({ ...raw, gitTokenLabel: value }),
109+
codexTokenLabel: (raw, value) => ({ ...raw, codexTokenLabel: value }),
110+
claudeTokenLabel: (raw, value) => ({ ...raw, claudeTokenLabel: value }),
105111
token: (raw, value) => ({ ...raw, token: value }),
106112
scopes: (raw, value) => ({ ...raw, scopes: value }),
107113
message: (raw, value) => ({ ...raw, message: value }),
@@ -129,47 +135,68 @@ export const applyCommandValueFlag = (
129135
return Either.right(update(raw, value))
130136
}
131137

132-
export const parseRawOptions = (args: ReadonlyArray<string>): Either.Either<RawOptions, ParseError> => {
133-
let index = 0
134-
let raw: RawOptions = {}
138+
type ParseRawOptionsStep =
139+
| { readonly _tag: "ok"; readonly raw: RawOptions; readonly nextIndex: number }
140+
| { readonly _tag: "error"; readonly error: ParseError }
135141

136-
while (index < args.length) {
137-
const token = args[index] ?? ""
138-
const equalIndex = token.indexOf("=")
139-
if (equalIndex > 0 && token.startsWith("-")) {
140-
const flag = token.slice(0, equalIndex)
141-
const inlineValue = token.slice(equalIndex + 1)
142-
const nextRaw = applyCommandValueFlag(raw, flag, inlineValue)
143-
if (Either.isLeft(nextRaw)) {
144-
return Either.left(nextRaw.left)
145-
}
146-
raw = nextRaw.right
147-
index += 1
148-
continue
149-
}
142+
const parseInlineValueToken = (
143+
raw: RawOptions,
144+
token: string
145+
): Either.Either<RawOptions, ParseError> | null => {
146+
const equalIndex = token.indexOf("=")
147+
if (equalIndex <= 0 || !token.startsWith("-")) {
148+
return null
149+
}
150150

151-
const booleanApplied = applyCommandBooleanFlag(raw, token)
152-
if (booleanApplied !== null) {
153-
raw = booleanApplied
154-
index += 1
155-
continue
156-
}
151+
const flag = token.slice(0, equalIndex)
152+
const inlineValue = token.slice(equalIndex + 1)
153+
return applyCommandValueFlag(raw, flag, inlineValue)
154+
}
157155

158-
if (!token.startsWith("-")) {
159-
return Either.left({ _tag: "UnexpectedArgument", value: token })
160-
}
156+
const parseRawOptionsStep = (
157+
args: ReadonlyArray<string>,
158+
index: number,
159+
raw: RawOptions
160+
): ParseRawOptionsStep => {
161+
const token = args[index] ?? ""
162+
const inlineApplied = parseInlineValueToken(raw, token)
163+
if (inlineApplied !== null) {
164+
return Either.isLeft(inlineApplied)
165+
? { _tag: "error", error: inlineApplied.left }
166+
: { _tag: "ok", raw: inlineApplied.right, nextIndex: index + 1 }
167+
}
161168

162-
const value = args[index + 1]
163-
if (value === undefined) {
164-
return Either.left({ _tag: "MissingOptionValue", option: token })
165-
}
169+
const booleanApplied = applyCommandBooleanFlag(raw, token)
170+
if (booleanApplied !== null) {
171+
return { _tag: "ok", raw: booleanApplied, nextIndex: index + 1 }
172+
}
173+
174+
if (!token.startsWith("-")) {
175+
return { _tag: "error", error: { _tag: "UnexpectedArgument", value: token } }
176+
}
177+
178+
const value = args[index + 1]
179+
if (value === undefined) {
180+
return { _tag: "error", error: { _tag: "MissingOptionValue", option: token } }
181+
}
182+
183+
const nextRaw = applyCommandValueFlag(raw, token, value)
184+
return Either.isLeft(nextRaw)
185+
? { _tag: "error", error: nextRaw.left }
186+
: { _tag: "ok", raw: nextRaw.right, nextIndex: index + 2 }
187+
}
188+
189+
export const parseRawOptions = (args: ReadonlyArray<string>): Either.Either<RawOptions, ParseError> => {
190+
let index = 0
191+
let raw: RawOptions = {}
166192

167-
const nextRaw = applyCommandValueFlag(raw, token, value)
168-
if (Either.isLeft(nextRaw)) {
169-
return Either.left(nextRaw.left)
193+
while (index < args.length) {
194+
const step = parseRawOptionsStep(args, index, raw)
195+
if (step._tag === "error") {
196+
return Either.left(step.error)
170197
}
171-
raw = nextRaw.right
172-
index += 2
198+
raw = step.raw
199+
index = step.nextIndex
173200
}
174201

175202
return Either.right(raw)

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Either, Match } from "effect"
22

33
import { type Command, type ParseError } from "@effect-template/lib/core/domain"
44

5+
import { parseApply } from "./parser-apply.js"
56
import { parseAttach } from "./parser-attach.js"
67
import { parseAuth } from "./parser-auth.js"
78
import { parseClone } from "./parser-clone.js"
@@ -74,6 +75,7 @@ export const parseArgs = (args: ReadonlyArray<string>): Either.Either<Command, P
7475
Match.when("auth", () => parseAuth(rest))
7576
)
7677
.pipe(
78+
Match.when("apply", () => parseApply(rest)),
7779
Match.when("state", () => parseState(rest)),
7880
Match.orElse(() => Either.left(unknownCommandError))
7981
)

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { ParseError } from "@effect-template/lib/core/domain"
55
export const usageText = `docker-git menu
66
docker-git create --repo-url <url> [options]
77
docker-git clone <url> [options]
8+
docker-git apply [<url>] [options]
89
docker-git mcp-playwright [<url>] [options]
910
docker-git attach [<url>] [options]
1011
docker-git panes [<url>] [options]
@@ -21,6 +22,7 @@ Commands:
2122
menu Interactive menu (default when no args)
2223
create, init Generate docker development environment
2324
clone Create + run container and clone repo
25+
apply Apply docker-git config to an existing project/container (current dir by default)
2426
mcp-playwright Enable Playwright MCP + Chromium sidecar for an existing project dir
2527
attach, tmux Open tmux workspace for a docker-git project
2628
panes, terms List tmux panes for a docker-git project
@@ -50,6 +52,8 @@ Options:
5052
--archive <path> Scrap snapshot directory (default: .orch/scrap/session)
5153
--mode <session> Scrap mode (default: session)
5254
--git-token <label> Token label for clone/create (maps to GITHUB_TOKEN__<LABEL>, example: agiens)
55+
--codex-token <label> Codex auth label for clone/create (maps to CODEX_AUTH_LABEL, example: agien)
56+
--claude-token <label> Claude auth label for clone/create (maps to CLAUDE_AUTH_LABEL, example: agien)
5357
--wipe | --no-wipe Wipe workspace before scrap import (default: --wipe)
5458
--lines <n> Tail last N lines for sessions logs (default: 200)
5559
--include-default Show default/system processes in sessions list

packages/app/src/docker-git/program.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Command, ParseError } from "@effect-template/lib/core/domain"
22
import { createProject } from "@effect-template/lib/usecases/actions"
3+
import { applyProjectConfig } from "@effect-template/lib/usecases/apply"
34
import {
45
authClaudeLogin,
56
authClaudeLogout,
@@ -97,6 +98,7 @@ const handleNonBaseCommand = (command: NonBaseCommand) =>
9798
Match.when({ _tag: "SessionsKill" }, (cmd) => killTerminalProcess(cmd))
9899
)
99100
.pipe(
101+
Match.when({ _tag: "Apply" }, (cmd) => applyProjectConfig(cmd)),
100102
Match.when({ _tag: "SessionsLogs" }, (cmd) => tailTerminalLogs(cmd)),
101103
Match.when({ _tag: "ScrapExport" }, (cmd) => exportScrap(cmd)),
102104
Match.when({ _tag: "ScrapImport" }, (cmd) => importScrap(cmd)),

packages/app/tests/docker-git/entrypoint-auth.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ describe("renderEntrypoint auth bridge", () => {
2828
expect(entrypoint).toContain("docker_git_upsert_ssh_env \"GH_TOKEN\" \"$EFFECTIVE_GH_TOKEN\"")
2929
expect(entrypoint).toContain("docker_git_upsert_ssh_env \"GIT_AUTH_TOKEN\" \"$EFFECTIVE_GITHUB_TOKEN\"")
3030
expect(entrypoint).toContain("GIT_CREDENTIAL_HELPER_PATH=\"/usr/local/bin/docker-git-credential-helper\"")
31+
expect(entrypoint).toContain("CLAUDE_REAL_BIN=\"/usr/local/bin/.docker-git-claude-real\"")
32+
expect(entrypoint).toContain("CLAUDE_WRAPPER_BIN=\"/usr/local/bin/claude\"")
33+
expect(entrypoint).toContain("cat <<'EOF' > \"$CLAUDE_WRAPPER_BIN\"")
34+
expect(entrypoint).toContain("CLAUDE_CONFIG_DIR=\"${CLAUDE_CONFIG_DIR:-$HOME/.claude}\"")
3135
expect(entrypoint).toContain("token=\"${GITHUB_TOKEN:-}\"")
3236
expect(entrypoint).toContain("token=\"${GH_TOKEN:-}\"")
3337
expect(entrypoint).toContain(String.raw`printf "%s\n" "password=$token"`)

packages/app/tests/docker-git/parser.test.ts

Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,26 @@ const parseOrThrow = (args: ReadonlyArray<string>): Command => {
3333
})
3434
}
3535

36+
type ProjectDirRunUpCommand = Extract<Command, { readonly projectDir: string; readonly runUp: boolean }>
37+
38+
const expectProjectDirRunUpCommand = (
39+
args: ReadonlyArray<string>,
40+
expectedTag: ProjectDirRunUpCommand["_tag"],
41+
expectedProjectDir: string,
42+
expectedRunUp: boolean
43+
) =>
44+
Effect.sync(() => {
45+
const command = parseOrThrow(args)
46+
if (command._tag !== expectedTag) {
47+
throw new Error(`expected ${expectedTag} command`)
48+
}
49+
if (!("projectDir" in command) || !("runUp" in command)) {
50+
throw new Error("expected command with projectDir and runUp")
51+
}
52+
expect(command.projectDir).toBe(expectedProjectDir)
53+
expect(command.runUp).toBe(expectedRunUp)
54+
})
55+
3656
const expectCreateCommand = (
3757
args: ReadonlyArray<string>,
3858
onRight: (command: CreateCommand) => void
@@ -106,6 +126,20 @@ describe("parseArgs", () => {
106126
expect(command.config.gitTokenLabel).toBe("AGIENS")
107127
}))
108128

129+
it.effect("parses clone codex/claude token labels from inline options and normalizes them", () =>
130+
expectCreateCommand(
131+
[
132+
"clone",
133+
"https://github.com/org/repo.git",
134+
"--codex-token= Team A ",
135+
"--claude-token=---AGIENS:::Claude---"
136+
],
137+
(command) => {
138+
expect(command.config.codexAuthLabel).toBe("team-a")
139+
expect(command.config.claudeAuthLabel).toBe("agiens-claude")
140+
}
141+
))
142+
109143
it.effect("supports enabling SSH auto-open for create", () =>
110144
expectCreateCommand(["create", "--repo-url", "https://github.com/org/repo.git", "--ssh"], (command) => {
111145
expect(command.openSsh).toBe(true)
@@ -169,31 +203,53 @@ describe("parseArgs", () => {
169203
}))
170204

171205
it.effect("parses mcp-playwright command in current directory", () =>
206+
expectProjectDirRunUpCommand(["mcp-playwright"], "McpPlaywrightUp", ".", true))
207+
208+
it.effect("parses mcp-playwright command with --no-up", () =>
209+
expectProjectDirRunUpCommand(["mcp-playwright", "--no-up"], "McpPlaywrightUp", ".", false))
210+
211+
it.effect("parses mcp-playwright with positional repo url into project dir", () =>
172212
Effect.sync(() => {
173-
const command = parseOrThrow(["mcp-playwright"])
213+
const command = parseOrThrow(["mcp-playwright", "https://github.com/org/repo.git"])
174214
if (command._tag !== "McpPlaywrightUp") {
175215
throw new Error("expected McpPlaywrightUp command")
176216
}
177-
expect(command.projectDir).toBe(".")
178-
expect(command.runUp).toBe(true)
217+
expect(command.projectDir).toBe(".docker-git/org/repo")
179218
}))
180219

181-
it.effect("parses mcp-playwright command with --no-up", () =>
220+
it.effect("parses apply command in current directory", () =>
221+
expectProjectDirRunUpCommand(["apply"], "Apply", ".", true))
222+
223+
it.effect("parses apply command with --no-up", () =>
224+
expectProjectDirRunUpCommand(["apply", "--no-up"], "Apply", ".", false))
225+
226+
it.effect("parses apply with positional repo url into project dir", () =>
182227
Effect.sync(() => {
183-
const command = parseOrThrow(["mcp-playwright", "--no-up"])
184-
if (command._tag !== "McpPlaywrightUp") {
185-
throw new Error("expected McpPlaywrightUp command")
228+
const command = parseOrThrow(["apply", "https://github.com/org/repo.git"])
229+
if (command._tag !== "Apply") {
230+
throw new Error("expected Apply command")
186231
}
187-
expect(command.runUp).toBe(false)
232+
expect(command.projectDir).toBe(".docker-git/org/repo")
188233
}))
189234

190-
it.effect("parses mcp-playwright with positional repo url into project dir", () =>
235+
it.effect("parses apply token and mcp overrides", () =>
191236
Effect.sync(() => {
192-
const command = parseOrThrow(["mcp-playwright", "https://github.com/org/repo.git"])
193-
if (command._tag !== "McpPlaywrightUp") {
194-
throw new Error("expected McpPlaywrightUp command")
237+
const command = parseOrThrow([
238+
"apply",
239+
"--git-token=agien_main",
240+
"--codex-token=Team A",
241+
"--claude-token=Team B",
242+
"--mcp-playwright",
243+
"--no-up"
244+
])
245+
if (command._tag !== "Apply") {
246+
throw new Error("expected Apply command")
195247
}
196-
expect(command.projectDir).toBe(".docker-git/org/repo")
248+
expect(command.runUp).toBe(false)
249+
expect(command.gitTokenLabel).toBe("agien_main")
250+
expect(command.codexTokenLabel).toBe("Team A")
251+
expect(command.claudeTokenLabel).toBe("Team B")
252+
expect(command.enableMcpPlaywright).toBe(true)
197253
}))
198254

199255
it.effect("parses down-all command", () =>

packages/docker-git/tests/core/templates.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@ describe("planFiles", () => {
9292
expect(entrypointSpec.contents).toContain("npm_config_store_dir")
9393
expect(entrypointSpec.contents).toContain("NPM_CONFIG_CACHE")
9494
expect(entrypointSpec.contents).toContain("YARN_CACHE_FOLDER")
95+
expect(entrypointSpec.contents).toContain("CLAUDE_REAL_BIN=\"/usr/local/bin/.docker-git-claude-real\"")
96+
expect(entrypointSpec.contents).toContain("CLAUDE_WRAPPER_BIN=\"/usr/local/bin/claude\"")
97+
expect(entrypointSpec.contents).toContain("cat <<'EOF' > \"$CLAUDE_WRAPPER_BIN\"")
98+
expect(entrypointSpec.contents).toContain('CLAUDE_CONFIG_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"')
99+
expect(entrypointSpec.contents).toContain("unset CLAUDE_CODE_OAUTH_TOKEN || true")
95100
expect(entrypointSpec.contents).toContain("CLONE_CACHE_ARGS=\"--reference-if-able '$CACHE_REPO_DIR' --dissociate\"")
96101
expect(entrypointSpec.contents).toContain("[clone-cache] using mirror: $CACHE_REPO_DIR")
97102
expect(entrypointSpec.contents).toContain("git clone --progress $CLONE_CACHE_ARGS")

0 commit comments

Comments
 (0)