@prover-coder-ai/docker-git 1.0.15 → 1.0.17

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 (71) hide show
  1. package/.package.json.release.bak +1 -1
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +5 -6
  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 +24 -8
  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 +29 -7
  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-load.js +12 -0
  29. package/dist/src/docker-git/menu-select-order.js +21 -0
  30. package/dist/src/docker-git/menu-select-runtime.js +41 -9
  31. package/dist/src/docker-git/menu-select-view.js +15 -0
  32. package/dist/src/docker-git/menu-select.js +11 -82
  33. package/dist/src/docker-git/menu-shared.js +86 -17
  34. package/dist/src/docker-git/menu-types.js +2 -0
  35. package/dist/src/docker-git/menu.js +13 -1
  36. package/dist/src/docker-git/program.js +3 -3
  37. package/package.json +1 -1
  38. package/src/docker-git/cli/parser-auth.ts +46 -16
  39. package/src/docker-git/cli/parser-mcp-playwright.ts +0 -1
  40. package/src/docker-git/cli/parser.ts +1 -1
  41. package/src/docker-git/cli/usage.ts +4 -3
  42. package/src/docker-git/menu-actions.ts +32 -13
  43. package/src/docker-git/menu-auth-data.ts +184 -0
  44. package/src/docker-git/menu-auth-helpers.ts +30 -0
  45. package/src/docker-git/menu-auth.ts +311 -0
  46. package/src/docker-git/menu-buffer-input.ts +18 -0
  47. package/src/docker-git/menu-create.ts +5 -11
  48. package/src/docker-git/menu-input-handler.ts +104 -28
  49. package/src/docker-git/menu-input-utils.ts +85 -0
  50. package/src/docker-git/menu-labeled-env.ts +37 -0
  51. package/src/docker-git/menu-project-auth-claude.ts +70 -0
  52. package/src/docker-git/menu-project-auth-data.ts +292 -0
  53. package/src/docker-git/menu-project-auth.ts +271 -0
  54. package/src/docker-git/menu-render-auth.ts +65 -0
  55. package/src/docker-git/menu-render-common.ts +67 -0
  56. package/src/docker-git/menu-render-layout.ts +30 -0
  57. package/src/docker-git/menu-render-project-auth.ts +70 -0
  58. package/src/docker-git/menu-render-select.ts +44 -5
  59. package/src/docker-git/menu-render.ts +5 -29
  60. package/src/docker-git/menu-select-actions.ts +150 -0
  61. package/src/docker-git/menu-select-load.ts +33 -0
  62. package/src/docker-git/menu-select-order.ts +37 -0
  63. package/src/docker-git/menu-select-runtime.ts +59 -10
  64. package/src/docker-git/menu-select-view.ts +25 -0
  65. package/src/docker-git/menu-select.ts +22 -195
  66. package/src/docker-git/menu-shared.ts +135 -20
  67. package/src/docker-git/menu-types.ts +71 -2
  68. package/src/docker-git/menu.ts +26 -1
  69. package/src/docker-git/program.ts +10 -4
  70. package/tests/docker-git/entrypoint-auth.test.ts +1 -1
  71. package/tests/docker-git/menu-select-order.test.ts +73 -0
@@ -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
@@ -0,0 +1,271 @@
1
+ import { Effect, Match, pipe } from "effect"
2
+
3
+ import type { AppError } from "@effect-template/lib/usecases/errors"
4
+ import type { ProjectItem } from "@effect-template/lib/usecases/projects"
5
+
6
+ import { nextBufferValue } from "./menu-buffer-input.js"
7
+ import { handleMenuNumberInput, submitPromptStep } from "./menu-input-utils.js"
8
+ import {
9
+ type ProjectAuthMenuAction,
10
+ projectAuthMenuActionByIndex,
11
+ projectAuthMenuSize,
12
+ projectAuthViewSteps,
13
+ readProjectAuthSnapshot,
14
+ writeProjectAuthFlow
15
+ } from "./menu-project-auth-data.js"
16
+ import { resetToMenu } from "./menu-shared.js"
17
+ import type {
18
+ MenuEnv,
19
+ MenuKeyInput,
20
+ MenuRunner,
21
+ MenuViewContext,
22
+ ProjectAuthFlow,
23
+ ProjectAuthSnapshot,
24
+ ViewState
25
+ } from "./menu-types.js"
26
+
27
+ type ProjectAuthContext = Pick<MenuViewContext, "setView" | "setMessage" | "setActiveDir"> & {
28
+ readonly runner: MenuRunner
29
+ }
30
+
31
+ type ProjectAuthContextWithProject = ProjectAuthContext & {
32
+ readonly project: ProjectItem
33
+ }
34
+
35
+ const startProjectAuthMenu = (
36
+ project: ProjectItem,
37
+ snapshot: ProjectAuthSnapshot,
38
+ context: Pick<MenuViewContext, "setView" | "setMessage">
39
+ ) => {
40
+ context.setView({ _tag: "ProjectAuthMenu", selected: 0, project, snapshot })
41
+ context.setMessage(null)
42
+ }
43
+
44
+ const startProjectAuthPrompt = (
45
+ project: ProjectItem,
46
+ snapshot: ProjectAuthSnapshot,
47
+ flow: ProjectAuthFlow,
48
+ context: Pick<MenuViewContext, "setView" | "setMessage">
49
+ ) => {
50
+ context.setView({
51
+ _tag: "ProjectAuthPrompt",
52
+ flow,
53
+ step: 0,
54
+ buffer: "",
55
+ values: {},
56
+ project,
57
+ snapshot
58
+ })
59
+ context.setMessage(null)
60
+ }
61
+
62
+ const loadProjectAuthMenuView = (
63
+ project: ProjectItem,
64
+ context: Pick<MenuViewContext, "setView" | "setMessage">
65
+ ): Effect.Effect<void, AppError, MenuEnv> =>
66
+ pipe(
67
+ readProjectAuthSnapshot(project),
68
+ Effect.tap((snapshot) =>
69
+ Effect.sync(() => {
70
+ startProjectAuthMenu(project, snapshot, context)
71
+ })
72
+ ),
73
+ Effect.asVoid
74
+ )
75
+
76
+ const successMessage = (flow: ProjectAuthFlow, label: string): string =>
77
+ Match.value(flow).pipe(
78
+ Match.when("ProjectGithubConnect", () => `Connected GitHub label (${label}) to project.`),
79
+ Match.when("ProjectGithubDisconnect", () => "Disconnected GitHub from project."),
80
+ Match.when("ProjectGitConnect", () => `Connected Git label (${label}) to project.`),
81
+ Match.when("ProjectGitDisconnect", () => "Disconnected Git from project."),
82
+ Match.when("ProjectClaudeConnect", () => `Connected Claude label (${label}) to project.`),
83
+ Match.when("ProjectClaudeDisconnect", () => "Disconnected Claude from project."),
84
+ Match.exhaustive
85
+ )
86
+
87
+ const runProjectAuthEffect = (
88
+ project: ProjectItem,
89
+ flow: ProjectAuthFlow,
90
+ values: Readonly<Record<string, string>>,
91
+ label: string,
92
+ context: ProjectAuthContext
93
+ ) => {
94
+ context.runner.runEffect(
95
+ pipe(
96
+ writeProjectAuthFlow(project, flow, values),
97
+ Effect.zipRight(readProjectAuthSnapshot(project)),
98
+ Effect.tap((snapshot) =>
99
+ Effect.sync(() => {
100
+ startProjectAuthMenu(project, snapshot, context)
101
+ context.setMessage(successMessage(flow, label))
102
+ })
103
+ ),
104
+ Effect.asVoid
105
+ )
106
+ )
107
+ }
108
+
109
+ const submitProjectAuthPrompt = (
110
+ view: Extract<ViewState, { readonly _tag: "ProjectAuthPrompt" }>,
111
+ context: ProjectAuthContext
112
+ ) => {
113
+ const steps = projectAuthViewSteps(view.flow)
114
+ submitPromptStep(
115
+ view,
116
+ steps,
117
+ context,
118
+ () => {
119
+ startProjectAuthMenu(view.project, view.snapshot, context)
120
+ },
121
+ (nextValues) => {
122
+ const rawLabel = (nextValues["label"] ?? "").trim()
123
+ const label = rawLabel.length > 0 ? rawLabel : "default"
124
+ runProjectAuthEffect(view.project, view.flow, nextValues, label, context)
125
+ }
126
+ )
127
+ }
128
+
129
+ const runProjectAuthAction = (
130
+ action: ProjectAuthMenuAction,
131
+ view: Extract<ViewState, { readonly _tag: "ProjectAuthMenu" }>,
132
+ context: ProjectAuthContext
133
+ ) => {
134
+ if (action === "Back") {
135
+ resetToMenu(context)
136
+ return
137
+ }
138
+ if (action === "Refresh") {
139
+ context.runner.runEffect(loadProjectAuthMenuView(view.project, context))
140
+ return
141
+ }
142
+
143
+ if (
144
+ action === "ProjectGithubDisconnect" || action === "ProjectGitDisconnect" || action === "ProjectClaudeDisconnect"
145
+ ) {
146
+ runProjectAuthEffect(view.project, action, {}, "default", context)
147
+ return
148
+ }
149
+
150
+ startProjectAuthPrompt(view.project, view.snapshot, action, context)
151
+ }
152
+
153
+ const setProjectAuthMenuSelection = (
154
+ view: Extract<ViewState, { readonly _tag: "ProjectAuthMenu" }>,
155
+ selected: number,
156
+ context: Pick<MenuViewContext, "setView">
157
+ ) => {
158
+ context.setView({ ...view, selected })
159
+ }
160
+
161
+ const shiftProjectAuthMenuSelection = (
162
+ view: Extract<ViewState, { readonly _tag: "ProjectAuthMenu" }>,
163
+ delta: number,
164
+ context: Pick<MenuViewContext, "setView">
165
+ ) => {
166
+ const menuSize = projectAuthMenuSize()
167
+ const selected = (view.selected + delta + menuSize) % menuSize
168
+ setProjectAuthMenuSelection(view, selected, context)
169
+ }
170
+
171
+ const runProjectAuthMenuSelection = (
172
+ selected: number,
173
+ view: Extract<ViewState, { readonly _tag: "ProjectAuthMenu" }>,
174
+ context: ProjectAuthContext
175
+ ) => {
176
+ const action = projectAuthMenuActionByIndex(selected)
177
+ if (action === null) {
178
+ return
179
+ }
180
+ runProjectAuthAction(action, view, context)
181
+ }
182
+
183
+ const handleProjectAuthMenuNumberInput = (
184
+ input: string,
185
+ view: Extract<ViewState, { readonly _tag: "ProjectAuthMenu" }>,
186
+ context: ProjectAuthContext
187
+ ) => {
188
+ handleMenuNumberInput(
189
+ input,
190
+ context,
191
+ projectAuthMenuActionByIndex,
192
+ (action) => {
193
+ runProjectAuthAction(action, view, context)
194
+ }
195
+ )
196
+ }
197
+
198
+ const handleProjectAuthMenuInput = (
199
+ input: string,
200
+ key: MenuKeyInput,
201
+ view: Extract<ViewState, { readonly _tag: "ProjectAuthMenu" }>,
202
+ context: ProjectAuthContext
203
+ ) => {
204
+ if (key.escape) {
205
+ resetToMenu(context)
206
+ return
207
+ }
208
+ if (key.upArrow) {
209
+ shiftProjectAuthMenuSelection(view, -1, context)
210
+ return
211
+ }
212
+ if (key.downArrow) {
213
+ shiftProjectAuthMenuSelection(view, 1, context)
214
+ return
215
+ }
216
+ if (key.return) {
217
+ runProjectAuthMenuSelection(view.selected, view, context)
218
+ return
219
+ }
220
+ handleProjectAuthMenuNumberInput(input, view, context)
221
+ }
222
+
223
+ type SetPromptBufferArgs = {
224
+ readonly input: string
225
+ readonly key: MenuKeyInput
226
+ readonly view: Extract<ViewState, { readonly _tag: "ProjectAuthPrompt" }>
227
+ readonly context: Pick<MenuViewContext, "setView">
228
+ }
229
+
230
+ const setProjectAuthPromptBuffer = (args: SetPromptBufferArgs) => {
231
+ const nextBuffer = nextBufferValue(args.input, args.key, args.view.buffer)
232
+ if (nextBuffer === null) {
233
+ return
234
+ }
235
+ args.context.setView({ ...args.view, buffer: nextBuffer })
236
+ }
237
+
238
+ const handleProjectAuthPromptInput = (
239
+ input: string,
240
+ key: MenuKeyInput,
241
+ view: Extract<ViewState, { readonly _tag: "ProjectAuthPrompt" }>,
242
+ context: ProjectAuthContext
243
+ ) => {
244
+ if (key.escape) {
245
+ startProjectAuthMenu(view.project, view.snapshot, context)
246
+ return
247
+ }
248
+ if (key.return) {
249
+ submitProjectAuthPrompt(view, context)
250
+ return
251
+ }
252
+ setProjectAuthPromptBuffer({ input, key, view, context })
253
+ }
254
+
255
+ export const openProjectAuthMenu = (context: ProjectAuthContextWithProject): void => {
256
+ context.setMessage(`Loading project auth (${context.project.displayName})...`)
257
+ context.runner.runEffect(loadProjectAuthMenuView(context.project, context))
258
+ }
259
+
260
+ export const handleProjectAuthInput = (
261
+ input: string,
262
+ key: MenuKeyInput,
263
+ view: Extract<ViewState, { readonly _tag: "ProjectAuthMenu" | "ProjectAuthPrompt" }>,
264
+ context: ProjectAuthContext
265
+ ) => {
266
+ if (view._tag === "ProjectAuthMenu") {
267
+ handleProjectAuthMenuInput(input, key, view, context)
268
+ return
269
+ }
270
+ handleProjectAuthPromptInput(input, key, view, context)
271
+ }
@@ -0,0 +1,65 @@
1
+ import { Box, Text } from "ink"
2
+ import React from "react"
3
+
4
+ import { authMenuLabels, authViewSteps, authViewTitle } from "./menu-auth-data.js"
5
+ import {
6
+ renderMenuHelp,
7
+ renderPromptLayout,
8
+ renderSelectableMenuList,
9
+ resolvePromptState
10
+ } from "./menu-render-common.js"
11
+ import { renderLayout } from "./menu-render-layout.js"
12
+ import type { AuthSnapshot, ViewState } from "./menu-types.js"
13
+
14
+ const renderCountLine = (title: string, count: number): string => `${title}: ${count}`
15
+
16
+ export const renderAuthMenu = (
17
+ snapshot: AuthSnapshot,
18
+ selected: number,
19
+ message: string | null
20
+ ): React.ReactElement => {
21
+ const el = React.createElement
22
+ const list = renderSelectableMenuList(authMenuLabels(), selected)
23
+ return renderLayout(
24
+ "docker-git / Auth profiles",
25
+ [
26
+ el(Text, null, `Global env: ${snapshot.globalEnvPath}`),
27
+ el(Text, null, `Claude auth: ${snapshot.claudeAuthPath}`),
28
+ el(Text, { color: "gray" }, renderCountLine("Entries", snapshot.totalEntries)),
29
+ el(Text, { color: "gray" }, renderCountLine("GitHub tokens", snapshot.githubTokenEntries)),
30
+ el(Text, { color: "gray" }, renderCountLine("Git tokens", snapshot.gitTokenEntries)),
31
+ el(Text, { color: "gray" }, renderCountLine("Git users", snapshot.gitUserEntries)),
32
+ el(Text, { color: "gray" }, renderCountLine("Claude logins", snapshot.claudeAuthEntries)),
33
+ el(Box, { flexDirection: "column", marginTop: 1 }, ...list),
34
+ renderMenuHelp("Use arrows + Enter, or type a number.")
35
+ ],
36
+ message
37
+ )
38
+ }
39
+
40
+ export const renderAuthPrompt = (
41
+ view: Extract<ViewState, { readonly _tag: "AuthPrompt" }>,
42
+ message: string | null
43
+ ): React.ReactElement => {
44
+ const el = React.createElement
45
+ const { prompt, visibleBuffer } = resolvePromptState(authViewSteps(view.flow), view.step, view.buffer)
46
+ let helpLine = "Enter = next, Esc = cancel."
47
+ if (view.flow === "GithubOauth" || view.flow === "ClaudeOauth") {
48
+ helpLine = "Enter = start OAuth, Esc = cancel."
49
+ } else if (view.flow === "ClaudeLogout") {
50
+ helpLine = "Enter = logout, Esc = cancel."
51
+ }
52
+ return renderPromptLayout({
53
+ title: `docker-git / Auth / ${authViewTitle(view.flow)}`,
54
+ header: [
55
+ el(Text, { color: "gray" }, `Global env: ${view.snapshot.globalEnvPath}`),
56
+ ...(view.flow === "ClaudeOauth" || view.flow === "ClaudeLogout"
57
+ ? [el(Text, { color: "gray" }, `Claude auth: ${view.snapshot.claudeAuthPath}`)]
58
+ : [])
59
+ ],
60
+ prompt,
61
+ visibleBuffer,
62
+ helpLine,
63
+ message
64
+ })
65
+ }
@@ -0,0 +1,67 @@
1
+ import { Box, Text } from "ink"
2
+ import React from "react"
3
+
4
+ import { renderLayout } from "./menu-render-layout.js"
5
+
6
+ export const renderSelectableMenuList = (
7
+ labels: ReadonlyArray<string>,
8
+ selected: number
9
+ ): ReadonlyArray<React.ReactElement> => {
10
+ const el = React.createElement
11
+ return labels.map((label, index) =>
12
+ el(
13
+ Text,
14
+ { key: `${index}-${label}`, color: index === selected ? "green" : "white" },
15
+ `${index === selected ? ">" : " "} ${index + 1}) ${label}`
16
+ )
17
+ )
18
+ }
19
+
20
+ export const renderMenuHelp = (primaryLine: string): React.ReactElement => {
21
+ const el = React.createElement
22
+ return el(
23
+ Box,
24
+ { marginTop: 1, flexDirection: "column" },
25
+ el(Text, { color: "gray" }, primaryLine),
26
+ el(Text, { color: "gray" }, "Esc returns to the main menu.")
27
+ )
28
+ }
29
+
30
+ type PromptStepLike = {
31
+ readonly label: string
32
+ readonly secret: boolean
33
+ }
34
+
35
+ export const resolvePromptState = (
36
+ steps: ReadonlyArray<PromptStepLike>,
37
+ step: number,
38
+ buffer: string
39
+ ): { readonly prompt: string; readonly visibleBuffer: string } => {
40
+ const current = steps[step]
41
+ const prompt = current?.label ?? "Value"
42
+ const isSecret = current?.secret === true
43
+ const visibleBuffer = isSecret ? "*".repeat(buffer.length) : buffer
44
+ return { prompt, visibleBuffer }
45
+ }
46
+
47
+ type RenderPromptArgs = {
48
+ readonly title: string
49
+ readonly header: ReadonlyArray<React.ReactElement>
50
+ readonly prompt: string
51
+ readonly visibleBuffer: string
52
+ readonly helpLine: string
53
+ readonly message: string | null
54
+ }
55
+
56
+ export const renderPromptLayout = (args: RenderPromptArgs): React.ReactElement => {
57
+ const el = React.createElement
58
+ return renderLayout(
59
+ args.title,
60
+ [
61
+ ...args.header,
62
+ el(Box, { marginTop: 1 }, el(Text, null, `${args.prompt}: `), el(Text, { color: "green" }, args.visibleBuffer)),
63
+ el(Box, { marginTop: 1, flexDirection: "column" }, el(Text, { color: "gray" }, args.helpLine))
64
+ ],
65
+ args.message
66
+ )
67
+ }