@prover-coder-ai/docker-git 1.0.16 → 1.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/.package.json.release.bak +1 -1
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +12 -7
  4. package/dist/main.js +24 -7
  5. package/dist/main.js.map +1 -1
  6. package/dist/src/docker-git/cli/parser-auth.js +32 -12
  7. package/dist/src/docker-git/cli/parser.js +1 -1
  8. package/dist/src/docker-git/cli/usage.js +4 -3
  9. package/dist/src/docker-git/menu-actions.js +23 -7
  10. package/dist/src/docker-git/menu-auth-data.js +90 -0
  11. package/dist/src/docker-git/menu-auth-helpers.js +20 -0
  12. package/dist/src/docker-git/menu-auth.js +159 -0
  13. package/dist/src/docker-git/menu-buffer-input.js +9 -0
  14. package/dist/src/docker-git/menu-create.js +5 -9
  15. package/dist/src/docker-git/menu-input-handler.js +70 -28
  16. package/dist/src/docker-git/menu-input-utils.js +47 -0
  17. package/dist/src/docker-git/menu-labeled-env.js +33 -0
  18. package/dist/src/docker-git/menu-project-auth-claude.js +43 -0
  19. package/dist/src/docker-git/menu-project-auth-data.js +165 -0
  20. package/dist/src/docker-git/menu-project-auth.js +124 -0
  21. package/dist/src/docker-git/menu-render-auth.js +45 -0
  22. package/dist/src/docker-git/menu-render-common.js +26 -0
  23. package/dist/src/docker-git/menu-render-layout.js +14 -0
  24. package/dist/src/docker-git/menu-render-project-auth.js +37 -0
  25. package/dist/src/docker-git/menu-render-select.js +11 -4
  26. package/dist/src/docker-git/menu-render.js +4 -13
  27. package/dist/src/docker-git/menu-select-actions.js +66 -0
  28. package/dist/src/docker-git/menu-select-view.js +15 -0
  29. package/dist/src/docker-git/menu-select.js +11 -75
  30. package/dist/src/docker-git/menu-shared.js +86 -17
  31. package/dist/src/docker-git/menu-types.js +3 -1
  32. package/dist/src/docker-git/menu.js +13 -1
  33. package/dist/src/docker-git/program.js +3 -3
  34. package/package.json +1 -1
  35. package/src/docker-git/cli/parser-auth.ts +46 -16
  36. package/src/docker-git/cli/parser-mcp-playwright.ts +0 -1
  37. package/src/docker-git/cli/parser.ts +1 -1
  38. package/src/docker-git/cli/usage.ts +4 -3
  39. package/src/docker-git/menu-actions.ts +31 -12
  40. package/src/docker-git/menu-auth-data.ts +184 -0
  41. package/src/docker-git/menu-auth-helpers.ts +30 -0
  42. package/src/docker-git/menu-auth.ts +311 -0
  43. package/src/docker-git/menu-buffer-input.ts +18 -0
  44. package/src/docker-git/menu-create.ts +5 -11
  45. package/src/docker-git/menu-input-handler.ts +104 -28
  46. package/src/docker-git/menu-input-utils.ts +85 -0
  47. package/src/docker-git/menu-labeled-env.ts +37 -0
  48. package/src/docker-git/menu-project-auth-claude.ts +70 -0
  49. package/src/docker-git/menu-project-auth-data.ts +292 -0
  50. package/src/docker-git/menu-project-auth.ts +271 -0
  51. package/src/docker-git/menu-render-auth.ts +65 -0
  52. package/src/docker-git/menu-render-common.ts +67 -0
  53. package/src/docker-git/menu-render-layout.ts +30 -0
  54. package/src/docker-git/menu-render-project-auth.ts +70 -0
  55. package/src/docker-git/menu-render-select.ts +12 -2
  56. package/src/docker-git/menu-render.ts +5 -29
  57. package/src/docker-git/menu-select-actions.ts +150 -0
  58. package/src/docker-git/menu-select-load.ts +1 -1
  59. package/src/docker-git/menu-select-view.ts +25 -0
  60. package/src/docker-git/menu-select.ts +21 -167
  61. package/src/docker-git/menu-shared.ts +135 -20
  62. package/src/docker-git/menu-types.ts +70 -3
  63. package/src/docker-git/menu.ts +26 -1
  64. package/src/docker-git/program.ts +10 -4
  65. package/tests/docker-git/entrypoint-auth.test.ts +1 -1
@@ -1,5 +1,7 @@
1
+ import { handleAuthInput } from "./menu-auth.js"
1
2
  import { handleCreateInput } from "./menu-create.js"
2
3
  import { handleMenuInput } from "./menu-menu.js"
4
+ import { handleProjectAuthInput } from "./menu-project-auth.js"
3
5
  import { handleSelectInput } from "./menu-select.js"
4
6
  import type { MenuKeyInput, MenuRunner, MenuState, MenuViewContext, ViewState } from "./menu-types.js"
5
7
 
@@ -20,6 +22,8 @@ export type MenuInputContext = MenuViewContext & {
20
22
  readonly exit: () => void
21
23
  }
22
24
 
25
+ type ActiveView = Exclude<ViewState, { readonly _tag: "Menu" }>
26
+
23
27
  const activateInput = (
24
28
  input: string,
25
29
  key: Pick<MenuKeyInput, "upArrow" | "downArrow" | "return">,
@@ -59,43 +63,79 @@ const shouldHandleMenuInput = (
59
63
  return activation.allowProcessing
60
64
  }
61
65
 
62
- export const handleUserInput = (
66
+ const handleMenuViewInput = (
63
67
  input: string,
64
68
  key: MenuKeyInput,
65
69
  context: MenuInputContext
66
70
  ) => {
67
- if (context.busy || context.sshActive) {
71
+ if (!shouldHandleMenuInput(input, key, context)) {
68
72
  return
69
73
  }
74
+ handleMenuInput(input, key, {
75
+ selected: context.selected,
76
+ setSelected: context.setSelected,
77
+ state: context.state,
78
+ runner: context.runner,
79
+ exit: context.exit,
80
+ setView: context.setView,
81
+ setMessage: context.setMessage,
82
+ setActiveDir: context.setActiveDir
83
+ })
84
+ }
70
85
 
71
- if (context.view._tag === "Menu") {
72
- if (!shouldHandleMenuInput(input, key, context)) {
73
- return
74
- }
75
- handleMenuInput(input, key, {
76
- selected: context.selected,
77
- setSelected: context.setSelected,
78
- state: context.state,
79
- runner: context.runner,
80
- exit: context.exit,
81
- setView: context.setView,
82
- setMessage: context.setMessage
83
- })
84
- return
85
- }
86
+ const handleCreateViewInput = (
87
+ input: string,
88
+ key: MenuKeyInput,
89
+ view: Extract<ViewState, { readonly _tag: "Create" }>,
90
+ context: MenuInputContext
91
+ ) => {
92
+ handleCreateInput(input, key, view, {
93
+ state: context.state,
94
+ setView: context.setView,
95
+ setMessage: context.setMessage,
96
+ runner: context.runner,
97
+ setActiveDir: context.setActiveDir
98
+ })
99
+ }
86
100
 
87
- if (context.view._tag === "Create") {
88
- handleCreateInput(input, key, context.view, {
89
- state: context.state,
90
- setView: context.setView,
91
- setMessage: context.setMessage,
92
- runner: context.runner,
93
- setActiveDir: context.setActiveDir
94
- })
95
- return
96
- }
101
+ const handleAuthViewInput = (
102
+ input: string,
103
+ key: MenuKeyInput,
104
+ view: Extract<ViewState, { readonly _tag: "AuthMenu" | "AuthPrompt" }>,
105
+ context: MenuInputContext
106
+ ) => {
107
+ handleAuthInput(input, key, view, {
108
+ state: context.state,
109
+ setView: context.setView,
110
+ setMessage: context.setMessage,
111
+ setActiveDir: context.setActiveDir,
112
+ runner: context.runner,
113
+ setSshActive: context.setSshActive,
114
+ setSkipInputs: context.setSkipInputs
115
+ })
116
+ }
97
117
 
98
- handleSelectInput(input, key, context.view, {
118
+ const handleProjectAuthViewInput = (
119
+ input: string,
120
+ key: MenuKeyInput,
121
+ view: Extract<ViewState, { readonly _tag: "ProjectAuthMenu" | "ProjectAuthPrompt" }>,
122
+ context: MenuInputContext
123
+ ) => {
124
+ handleProjectAuthInput(input, key, view, {
125
+ runner: context.runner,
126
+ setView: context.setView,
127
+ setMessage: context.setMessage,
128
+ setActiveDir: context.setActiveDir
129
+ })
130
+ }
131
+
132
+ const handleSelectViewInput = (
133
+ input: string,
134
+ key: MenuKeyInput,
135
+ view: Extract<ViewState, { readonly _tag: "SelectProject" }>,
136
+ context: MenuInputContext
137
+ ) => {
138
+ handleSelectInput(input, key, view, {
99
139
  setView: context.setView,
100
140
  setMessage: context.setMessage,
101
141
  setActiveDir: context.setActiveDir,
@@ -105,3 +145,39 @@ export const handleUserInput = (
105
145
  setSkipInputs: context.setSkipInputs
106
146
  })
107
147
  }
148
+
149
+ const handleActiveViewInput = (
150
+ input: string,
151
+ key: MenuKeyInput,
152
+ view: ActiveView,
153
+ context: MenuInputContext
154
+ ) => {
155
+ if (view._tag === "Create") {
156
+ handleCreateViewInput(input, key, view, context)
157
+ return
158
+ }
159
+ if (view._tag === "AuthMenu" || view._tag === "AuthPrompt") {
160
+ handleAuthViewInput(input, key, view, context)
161
+ return
162
+ }
163
+ if (view._tag === "ProjectAuthMenu" || view._tag === "ProjectAuthPrompt") {
164
+ handleProjectAuthViewInput(input, key, view, context)
165
+ return
166
+ }
167
+ handleSelectViewInput(input, key, view, context)
168
+ }
169
+
170
+ export const handleUserInput = (
171
+ input: string,
172
+ key: MenuKeyInput,
173
+ context: MenuInputContext
174
+ ) => {
175
+ if (context.busy || context.sshActive) {
176
+ return
177
+ }
178
+ if (context.view._tag === "Menu") {
179
+ handleMenuViewInput(input, key, context)
180
+ return
181
+ }
182
+ handleActiveViewInput(input, key, context.view, context)
183
+ }
@@ -0,0 +1,85 @@
1
+ export const parseMenuIndex = (input: string): number | null => {
2
+ const trimmed = input.trim()
3
+ if (trimmed.length === 0) {
4
+ return null
5
+ }
6
+ const parsed = Number(trimmed)
7
+ if (!Number.isInteger(parsed)) {
8
+ return null
9
+ }
10
+ const index = parsed - 1
11
+ return index >= 0 ? index : null
12
+ }
13
+
14
+ type PromptStep = {
15
+ readonly key: string
16
+ readonly label: string
17
+ readonly required: boolean
18
+ }
19
+
20
+ type PromptView = {
21
+ readonly step: number
22
+ readonly buffer: string
23
+ readonly values: Readonly<Record<string, string>>
24
+ }
25
+
26
+ type PromptContext<V extends PromptView> = {
27
+ readonly setView: (view: V) => void
28
+ readonly setMessage: (message: string | null) => void
29
+ }
30
+
31
+ export const submitPromptStep = <V extends PromptView>(
32
+ view: V,
33
+ steps: ReadonlyArray<PromptStep>,
34
+ context: PromptContext<V>,
35
+ onCancel: () => void,
36
+ onSubmit: (values: Readonly<Record<string, string>>) => void
37
+ ): void => {
38
+ const step = steps[view.step]
39
+ if (!step) {
40
+ onCancel()
41
+ return
42
+ }
43
+
44
+ const value = view.buffer.trim()
45
+ if (step.required && value.length === 0) {
46
+ context.setMessage(`${step.label} is required.`)
47
+ return
48
+ }
49
+
50
+ const nextValues: Readonly<Record<string, string>> = { ...view.values, [step.key]: value }
51
+ const nextStep = view.step + 1
52
+ if (nextStep < steps.length) {
53
+ context.setView({ ...view, step: nextStep, buffer: "", values: nextValues })
54
+ context.setMessage(null)
55
+ return
56
+ }
57
+
58
+ onSubmit(nextValues)
59
+ }
60
+
61
+ type MenuNumberInputContext = {
62
+ readonly setMessage: (message: string | null) => void
63
+ }
64
+
65
+ export const handleMenuNumberInput = <A>(
66
+ input: string,
67
+ context: MenuNumberInputContext,
68
+ actionByIndex: (index: number) => A | null,
69
+ runAction: (action: A) => void
70
+ ): void => {
71
+ const index = parseMenuIndex(input)
72
+ if (index === null) {
73
+ if (input.trim().length > 0) {
74
+ context.setMessage("Use arrows + Enter, or type a number from the list.")
75
+ }
76
+ return
77
+ }
78
+
79
+ const action = actionByIndex(index)
80
+ if (action === null) {
81
+ context.setMessage(`Unknown action: ${input.trim()}`)
82
+ return
83
+ }
84
+ runAction(action)
85
+ }
@@ -0,0 +1,37 @@
1
+ import { parseEnvEntries } from "@effect-template/lib/usecases/env-file"
2
+
3
+ export const normalizeLabel = (value: string): string => {
4
+ const trimmed = value.trim()
5
+ if (trimmed.length === 0) {
6
+ return ""
7
+ }
8
+ const normalized = trimmed
9
+ .toUpperCase()
10
+ .replaceAll(/[^A-Z0-9]+/g, "_")
11
+
12
+ let start = 0
13
+ while (start < normalized.length && normalized[start] === "_") {
14
+ start += 1
15
+ }
16
+ let end = normalized.length
17
+ while (end > start && normalized[end - 1] === "_") {
18
+ end -= 1
19
+ }
20
+ const cleaned = normalized.slice(start, end)
21
+ return cleaned.length > 0 ? cleaned : ""
22
+ }
23
+
24
+ export const buildLabeledEnvKey = (baseKey: string, label: string): string => {
25
+ const normalized = normalizeLabel(label)
26
+ if (normalized.length === 0 || normalized === "DEFAULT") {
27
+ return baseKey
28
+ }
29
+ return `${baseKey}__${normalized}`
30
+ }
31
+
32
+ export const countKeyEntries = (envText: string, baseKey: string): number => {
33
+ const prefix = `${baseKey}__`
34
+ return parseEnvEntries(envText)
35
+ .filter((entry) => entry.value.trim().length > 0 && (entry.key === baseKey || entry.key.startsWith(prefix)))
36
+ .length
37
+ }
@@ -0,0 +1,70 @@
1
+ import type { PlatformError } from "@effect/platform/Error"
2
+ import type * as FileSystem from "@effect/platform/FileSystem"
3
+ import { Effect } from "effect"
4
+
5
+ const oauthTokenFileName = ".oauth-token"
6
+ const legacyConfigFileName = ".config.json"
7
+
8
+ const hasFileAtPath = (
9
+ fs: FileSystem.FileSystem,
10
+ filePath: string
11
+ ): Effect.Effect<boolean, PlatformError> =>
12
+ Effect.gen(function*(_) {
13
+ const exists = yield* _(fs.exists(filePath))
14
+ if (!exists) {
15
+ return false
16
+ }
17
+ const info = yield* _(fs.stat(filePath))
18
+ return info.type === "File"
19
+ })
20
+
21
+ const hasNonEmptyOauthToken = (
22
+ fs: FileSystem.FileSystem,
23
+ tokenPath: string
24
+ ): Effect.Effect<boolean, PlatformError> =>
25
+ Effect.gen(function*(_) {
26
+ const hasFile = yield* _(hasFileAtPath(fs, tokenPath))
27
+ if (!hasFile) {
28
+ return false
29
+ }
30
+ const tokenValue = yield* _(fs.readFileString(tokenPath), Effect.orElseSucceed(() => ""))
31
+ return tokenValue.trim().length > 0
32
+ })
33
+
34
+ const hasLegacyClaudeAuthFile = (
35
+ fs: FileSystem.FileSystem,
36
+ accountPath: string
37
+ ): Effect.Effect<boolean, PlatformError> =>
38
+ Effect.gen(function*(_) {
39
+ const entries = yield* _(fs.readDirectory(accountPath))
40
+ for (const entry of entries) {
41
+ if (!entry.startsWith(".claude") || !entry.endsWith(".json")) {
42
+ continue
43
+ }
44
+ const isFile = yield* _(hasFileAtPath(fs, `${accountPath}/${entry}`))
45
+ if (isFile) {
46
+ return true
47
+ }
48
+ }
49
+ return false
50
+ })
51
+
52
+ export const hasClaudeAccountCredentials = (
53
+ fs: FileSystem.FileSystem,
54
+ accountPath: string
55
+ ): Effect.Effect<boolean, PlatformError> =>
56
+ hasFileAtPath(fs, `${accountPath}/${legacyConfigFileName}`).pipe(
57
+ Effect.flatMap((hasConfig) => {
58
+ if (hasConfig) {
59
+ return Effect.succeed(true)
60
+ }
61
+ return hasNonEmptyOauthToken(fs, `${accountPath}/${oauthTokenFileName}`).pipe(
62
+ Effect.flatMap((hasOauthToken) => {
63
+ if (hasOauthToken) {
64
+ return Effect.succeed(true)
65
+ }
66
+ return hasLegacyClaudeAuthFile(fs, accountPath)
67
+ })
68
+ )
69
+ })
70
+ )
@@ -0,0 +1,292 @@
1
+ import * as FileSystem from "@effect/platform/FileSystem"
2
+ import * as Path from "@effect/platform/Path"
3
+ import { Effect, Match, pipe } from "effect"
4
+
5
+ import { AuthError } from "@effect-template/lib/shell/errors"
6
+ import { normalizeAccountLabel } from "@effect-template/lib/usecases/auth-helpers"
7
+ import { ensureEnvFile, findEnvValue, readEnvText, upsertEnvKey } from "@effect-template/lib/usecases/env-file"
8
+ import type { AppError } from "@effect-template/lib/usecases/errors"
9
+ import { defaultProjectsRoot } from "@effect-template/lib/usecases/menu-helpers"
10
+ import type { ProjectItem } from "@effect-template/lib/usecases/projects"
11
+ import { autoSyncState } from "@effect-template/lib/usecases/state-repo"
12
+
13
+ import { countAuthAccountDirectories } from "./menu-auth-helpers.js"
14
+ import { buildLabeledEnvKey, countKeyEntries, normalizeLabel } from "./menu-labeled-env.js"
15
+ import { hasClaudeAccountCredentials } from "./menu-project-auth-claude.js"
16
+ import type { MenuEnv, ProjectAuthFlow, ProjectAuthSnapshot } from "./menu-types.js"
17
+
18
+ export type ProjectAuthMenuAction = ProjectAuthFlow | "Refresh" | "Back"
19
+
20
+ type ProjectAuthMenuItem = {
21
+ readonly action: ProjectAuthMenuAction
22
+ readonly label: string
23
+ }
24
+
25
+ export type ProjectAuthPromptStep = {
26
+ readonly key: "label"
27
+ readonly label: string
28
+ readonly required: boolean
29
+ readonly secret: boolean
30
+ }
31
+
32
+ const projectAuthMenuItems: ReadonlyArray<ProjectAuthMenuItem> = [
33
+ { action: "ProjectGithubConnect", label: "Project: GitHub connect label" },
34
+ { action: "ProjectGithubDisconnect", label: "Project: GitHub disconnect" },
35
+ { action: "ProjectGitConnect", label: "Project: Git connect label" },
36
+ { action: "ProjectGitDisconnect", label: "Project: Git disconnect" },
37
+ { action: "ProjectClaudeConnect", label: "Project: Claude connect label" },
38
+ { action: "ProjectClaudeDisconnect", label: "Project: Claude disconnect" },
39
+ { action: "Refresh", label: "Refresh snapshot" },
40
+ { action: "Back", label: "Back to main menu" }
41
+ ]
42
+
43
+ const flowSteps: Readonly<Record<ProjectAuthFlow, ReadonlyArray<ProjectAuthPromptStep>>> = {
44
+ ProjectGithubConnect: [
45
+ { key: "label", label: "Label (empty = default)", required: false, secret: false }
46
+ ],
47
+ ProjectGithubDisconnect: [],
48
+ ProjectGitConnect: [
49
+ { key: "label", label: "Label (empty = default)", required: false, secret: false }
50
+ ],
51
+ ProjectGitDisconnect: [],
52
+ ProjectClaudeConnect: [
53
+ { key: "label", label: "Label (empty = default)", required: false, secret: false }
54
+ ],
55
+ ProjectClaudeDisconnect: []
56
+ }
57
+
58
+ const resolveCanonicalLabel = (value: string): string => {
59
+ const normalized = normalizeLabel(value)
60
+ return normalized.length === 0 || normalized === "DEFAULT" ? "default" : normalized
61
+ }
62
+
63
+ const githubTokenBaseKey = "GITHUB_TOKEN"
64
+ const gitTokenBaseKey = "GIT_AUTH_TOKEN"
65
+ const gitUserBaseKey = "GIT_AUTH_USER"
66
+
67
+ const projectGithubLabelKey = "GITHUB_AUTH_LABEL"
68
+ const projectGitLabelKey = "GIT_AUTH_LABEL"
69
+ const projectClaudeLabelKey = "CLAUDE_AUTH_LABEL"
70
+
71
+ const defaultGitUser = "x-access-token"
72
+
73
+ type ProjectAuthEnvText = {
74
+ readonly fs: FileSystem.FileSystem
75
+ readonly path: Path.Path
76
+ readonly globalEnvPath: string
77
+ readonly projectEnvPath: string
78
+ readonly claudeAuthPath: string
79
+ readonly globalEnvText: string
80
+ readonly projectEnvText: string
81
+ }
82
+
83
+ const buildGlobalEnvPath = (cwd: string): string => `${defaultProjectsRoot(cwd)}/.orch/env/global.env`
84
+ const buildClaudeAuthPath = (cwd: string): string => `${defaultProjectsRoot(cwd)}/.orch/auth/claude`
85
+
86
+ const loadProjectAuthEnvText = (
87
+ project: ProjectItem
88
+ ): Effect.Effect<ProjectAuthEnvText, AppError, MenuEnv> =>
89
+ Effect.gen(function*(_) {
90
+ const fs = yield* _(FileSystem.FileSystem)
91
+ const path = yield* _(Path.Path)
92
+ const globalEnvPath = buildGlobalEnvPath(process.cwd())
93
+ const claudeAuthPath = buildClaudeAuthPath(process.cwd())
94
+ yield* _(ensureEnvFile(fs, path, globalEnvPath))
95
+ yield* _(ensureEnvFile(fs, path, project.envProjectPath))
96
+ const globalEnvText = yield* _(readEnvText(fs, globalEnvPath))
97
+ const projectEnvText = yield* _(readEnvText(fs, project.envProjectPath))
98
+ return {
99
+ fs,
100
+ path,
101
+ globalEnvPath,
102
+ projectEnvPath: project.envProjectPath,
103
+ claudeAuthPath,
104
+ globalEnvText,
105
+ projectEnvText
106
+ }
107
+ })
108
+
109
+ export const readProjectAuthSnapshot = (
110
+ project: ProjectItem
111
+ ): Effect.Effect<ProjectAuthSnapshot, AppError, MenuEnv> =>
112
+ pipe(
113
+ loadProjectAuthEnvText(project),
114
+ Effect.flatMap(({ claudeAuthPath, fs, globalEnvPath, globalEnvText, path, projectEnvPath, projectEnvText }) =>
115
+ pipe(
116
+ countAuthAccountDirectories(fs, path, claudeAuthPath),
117
+ Effect.map((claudeAuthEntries) => ({
118
+ projectDir: project.projectDir,
119
+ projectName: project.displayName,
120
+ envGlobalPath: globalEnvPath,
121
+ envProjectPath: projectEnvPath,
122
+ claudeAuthPath,
123
+ githubTokenEntries: countKeyEntries(globalEnvText, githubTokenBaseKey),
124
+ gitTokenEntries: countKeyEntries(globalEnvText, gitTokenBaseKey),
125
+ claudeAuthEntries,
126
+ activeGithubLabel: findEnvValue(projectEnvText, projectGithubLabelKey),
127
+ activeGitLabel: findEnvValue(projectEnvText, projectGitLabelKey),
128
+ activeClaudeLabel: findEnvValue(projectEnvText, projectClaudeLabelKey)
129
+ }))
130
+ )
131
+ )
132
+ )
133
+
134
+ const missingSecret = (
135
+ provider: string,
136
+ label: string,
137
+ envPath: string
138
+ ): AuthError =>
139
+ new AuthError({
140
+ message: `${provider} not connected: label '${label}' not found in ${envPath}`
141
+ })
142
+
143
+ type ProjectEnvUpdateSpec = {
144
+ readonly fs: FileSystem.FileSystem
145
+ readonly rawLabel: string
146
+ readonly canonicalLabel: string
147
+ readonly globalEnvPath: string
148
+ readonly globalEnvText: string
149
+ readonly projectEnvText: string
150
+ readonly claudeAuthPath: string
151
+ }
152
+
153
+ const updateProjectGithubConnect = (spec: ProjectEnvUpdateSpec): Effect.Effect<string, AppError> => {
154
+ const key = buildLabeledEnvKey(githubTokenBaseKey, spec.rawLabel)
155
+ const token = findEnvValue(spec.globalEnvText, key)
156
+ if (token === null) {
157
+ return Effect.fail(missingSecret("GitHub token", spec.canonicalLabel, spec.globalEnvPath))
158
+ }
159
+ const withGitToken = upsertEnvKey(spec.projectEnvText, "GIT_AUTH_TOKEN", token)
160
+ const withGhToken = upsertEnvKey(withGitToken, "GH_TOKEN", token)
161
+ const withoutGitLabel = upsertEnvKey(withGhToken, projectGitLabelKey, "")
162
+ return Effect.succeed(upsertEnvKey(withoutGitLabel, projectGithubLabelKey, spec.canonicalLabel))
163
+ }
164
+
165
+ const clearProjectGitLabels = (envText: string): string => {
166
+ const withoutGhToken = upsertEnvKey(envText, "GH_TOKEN", "")
167
+ const withoutGitLabel = upsertEnvKey(withoutGhToken, projectGitLabelKey, "")
168
+ return upsertEnvKey(withoutGitLabel, projectGithubLabelKey, "")
169
+ }
170
+
171
+ const updateProjectGithubDisconnect = (spec: ProjectEnvUpdateSpec): Effect.Effect<string> => {
172
+ const withoutGitToken = upsertEnvKey(spec.projectEnvText, "GIT_AUTH_TOKEN", "")
173
+ return Effect.succeed(clearProjectGitLabels(withoutGitToken))
174
+ }
175
+
176
+ const updateProjectGitConnect = (spec: ProjectEnvUpdateSpec): Effect.Effect<string, AppError> => {
177
+ const tokenKey = buildLabeledEnvKey(gitTokenBaseKey, spec.rawLabel)
178
+ const userKey = buildLabeledEnvKey(gitUserBaseKey, spec.rawLabel)
179
+ const token = findEnvValue(spec.globalEnvText, tokenKey)
180
+ if (token === null) {
181
+ return Effect.fail(missingSecret("Git credentials", spec.canonicalLabel, spec.globalEnvPath))
182
+ }
183
+ const defaultUser = findEnvValue(spec.globalEnvText, gitUserBaseKey) ?? defaultGitUser
184
+ const user = findEnvValue(spec.globalEnvText, userKey) ?? defaultUser
185
+ const withToken = upsertEnvKey(spec.projectEnvText, "GIT_AUTH_TOKEN", token)
186
+ const withUser = upsertEnvKey(withToken, "GIT_AUTH_USER", user)
187
+ const withGhToken = upsertEnvKey(withUser, "GH_TOKEN", token)
188
+ const withGitLabel = upsertEnvKey(withGhToken, projectGitLabelKey, spec.canonicalLabel)
189
+ return Effect.succeed(upsertEnvKey(withGitLabel, projectGithubLabelKey, spec.canonicalLabel))
190
+ }
191
+
192
+ const updateProjectGitDisconnect = (spec: ProjectEnvUpdateSpec): Effect.Effect<string> => {
193
+ const withoutToken = upsertEnvKey(spec.projectEnvText, "GIT_AUTH_TOKEN", "")
194
+ const withoutUser = upsertEnvKey(withoutToken, "GIT_AUTH_USER", "")
195
+ return Effect.succeed(clearProjectGitLabels(withoutUser))
196
+ }
197
+
198
+ const updateProjectClaudeConnect = (spec: ProjectEnvUpdateSpec): Effect.Effect<string, AppError> => {
199
+ const accountLabel = normalizeAccountLabel(spec.rawLabel, "default")
200
+ const accountPath = `${spec.claudeAuthPath}/${accountLabel}`
201
+ return Effect.gen(function*(_) {
202
+ const exists = yield* _(spec.fs.exists(accountPath))
203
+ if (!exists) {
204
+ return yield* _(Effect.fail(missingSecret("Claude Code login", spec.canonicalLabel, spec.claudeAuthPath)))
205
+ }
206
+
207
+ const hasCredentials = yield* _(
208
+ hasClaudeAccountCredentials(spec.fs, accountPath),
209
+ Effect.orElseSucceed(() => false)
210
+ )
211
+ if (hasCredentials) {
212
+ return upsertEnvKey(spec.projectEnvText, projectClaudeLabelKey, spec.canonicalLabel)
213
+ }
214
+
215
+ return yield* _(Effect.fail(missingSecret("Claude Code login", spec.canonicalLabel, spec.claudeAuthPath)))
216
+ })
217
+ }
218
+
219
+ const updateProjectClaudeDisconnect = (spec: ProjectEnvUpdateSpec): Effect.Effect<string> => {
220
+ return Effect.succeed(upsertEnvKey(spec.projectEnvText, projectClaudeLabelKey, ""))
221
+ }
222
+
223
+ const resolveProjectEnvUpdate = (
224
+ flow: ProjectAuthFlow,
225
+ spec: ProjectEnvUpdateSpec
226
+ ): Effect.Effect<string, AppError> =>
227
+ Match.value(flow).pipe(
228
+ Match.when("ProjectGithubConnect", () => updateProjectGithubConnect(spec)),
229
+ Match.when("ProjectGithubDisconnect", () => updateProjectGithubDisconnect(spec)),
230
+ Match.when("ProjectGitConnect", () => updateProjectGitConnect(spec)),
231
+ Match.when("ProjectGitDisconnect", () => updateProjectGitDisconnect(spec)),
232
+ Match.when("ProjectClaudeConnect", () => updateProjectClaudeConnect(spec)),
233
+ Match.when("ProjectClaudeDisconnect", () => updateProjectClaudeDisconnect(spec)),
234
+ Match.exhaustive
235
+ )
236
+
237
+ export const writeProjectAuthFlow = (
238
+ project: ProjectItem,
239
+ flow: ProjectAuthFlow,
240
+ values: Readonly<Record<string, string>>
241
+ ): Effect.Effect<void, AppError, MenuEnv> =>
242
+ pipe(
243
+ loadProjectAuthEnvText(project),
244
+ Effect.flatMap(({ claudeAuthPath, fs, globalEnvPath, globalEnvText, projectEnvPath, projectEnvText }) => {
245
+ const rawLabel = values["label"] ?? ""
246
+ const canonicalLabel = resolveCanonicalLabel(rawLabel)
247
+ const spec: ProjectEnvUpdateSpec = {
248
+ fs,
249
+ rawLabel,
250
+ canonicalLabel,
251
+ globalEnvPath,
252
+ globalEnvText,
253
+ projectEnvText,
254
+ claudeAuthPath
255
+ }
256
+ const nextProjectEnv = resolveProjectEnvUpdate(flow, spec)
257
+ const syncMessage = Match.value(flow).pipe(
258
+ Match.when("ProjectGithubConnect", () =>
259
+ `chore(state): project auth gh ${canonicalLabel} ${project.displayName}`),
260
+ Match.when("ProjectGithubDisconnect", () =>
261
+ `chore(state): project auth gh logout ${project.displayName}`),
262
+ Match.when(
263
+ "ProjectGitConnect",
264
+ () => `chore(state): project auth git ${canonicalLabel} ${project.displayName}`
265
+ ),
266
+ Match.when("ProjectGitDisconnect", () => `chore(state): project auth git logout ${project.displayName}`),
267
+ Match.when(
268
+ "ProjectClaudeConnect",
269
+ () => `chore(state): project auth claude ${canonicalLabel} ${project.displayName}`
270
+ ),
271
+ Match.when("ProjectClaudeDisconnect", () => `chore(state): project auth claude logout ${project.displayName}`),
272
+ Match.exhaustive
273
+ )
274
+ return pipe(
275
+ nextProjectEnv,
276
+ Effect.flatMap((nextText) => fs.writeFileString(projectEnvPath, nextText)),
277
+ Effect.zipRight(autoSyncState(syncMessage))
278
+ )
279
+ }),
280
+ Effect.asVoid
281
+ )
282
+
283
+ export const projectAuthViewSteps = (flow: ProjectAuthFlow): ReadonlyArray<ProjectAuthPromptStep> => flowSteps[flow]
284
+
285
+ export const projectAuthMenuLabels = (): ReadonlyArray<string> => projectAuthMenuItems.map((item) => item.label)
286
+
287
+ export const projectAuthMenuActionByIndex = (index: number): ProjectAuthMenuAction | null => {
288
+ const item = projectAuthMenuItems[index]
289
+ return item ? item.action : null
290
+ }
291
+
292
+ export const projectAuthMenuSize = (): number => projectAuthMenuItems.length