@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
@@ -28,7 +28,7 @@ Commands:
28
28
  sessions List/kill/log container terminal processes
29
29
  ps, status Show docker compose status for all docker-git projects
30
30
  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
32
32
  state Manage docker-git state directory via git (sync across machines)
33
33
 
34
34
  Options:
@@ -40,7 +40,6 @@ Options:
40
40
  --container-name <name> Docker container name (default: dg-<repo>)
41
41
  --service-name <name> Compose service name (default: dg-<repo>)
42
42
  --volume-name <name> Docker volume name (default: dg-<repo>-home)
43
- --secrets-root <path> Host root for shared secrets (default: n/a)
44
43
  --authorized-keys <path> Host path to authorized_keys (default: <projectsRoot>/authorized_keys)
45
44
  --env-global <path> Host path to shared env file (default: <projectsRoot>/.orch/env/global.env)
46
45
  --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):
72
71
  Auth providers:
73
72
  github, gh GitHub CLI auth (tokens saved to env file)
74
73
  codex Codex CLI auth (stored under .orch/auth/codex)
74
+ claude, cc Claude Code CLI auth (OAuth cache stored under .orch/auth/claude)
75
75
 
76
76
  Auth actions:
77
77
  login Run login flow and store credentials
@@ -80,7 +80,8 @@ Auth actions:
80
80
 
81
81
  Auth options:
82
82
  --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)
84
85
  --scopes <scopes> GitHub scopes (login only, default: repo,workflow,read:org)
85
86
  --env-global <path> Env file path for GitHub tokens (default: <projectsRoot>/.orch/env/global.env)
86
87
  --codex-auth <path> Codex auth root path (default: <projectsRoot>/.orch/auth/codex)
@@ -12,10 +12,11 @@ import {
12
12
  import { runDockerComposeUpWithPortCheck } from "@effect-template/lib/usecases/projects-up"
13
13
  import { Effect, Match, pipe } from "effect"
14
14
 
15
+ import { openAuthMenu } from "./menu-auth.js"
15
16
  import { startCreateView } from "./menu-create.js"
16
- import { loadSelectView } from "./menu-select.js"
17
- import { resumeTui, suspendTui } from "./menu-shared.js"
18
- import { type MenuEnv, type MenuRunner, type MenuState, type ViewState } from "./menu-types.js"
17
+ import { loadSelectView } from "./menu-select-load.js"
18
+ import { withSuspendedTui, writeErrorAndPause } from "./menu-shared.js"
19
+ import { type MenuEnv, type MenuRunner, type MenuState, type MenuViewContext } from "./menu-types.js"
19
20
 
20
21
  // CHANGE: keep menu actions and input parsing in a dedicated module
21
22
  // WHY: reduce cognitive complexity in the TUI entry
@@ -39,9 +40,7 @@ export type MenuContext = {
39
40
  readonly state: MenuState
40
41
  readonly runner: MenuRunner
41
42
  readonly exit: () => void
42
- readonly setView: (view: ViewState) => void
43
- readonly setMessage: (message: string | null) => void
44
- }
43
+ } & MenuViewContext
45
44
 
46
45
  export type MenuSelectionContext = MenuContext & {
47
46
  readonly selected: number
@@ -50,6 +49,8 @@ export type MenuSelectionContext = MenuContext & {
50
49
 
51
50
  const actionLabel = (action: MenuAction): string =>
52
51
  Match.value(action).pipe(
52
+ Match.when({ _tag: "Auth" }, () => "Auth profiles"),
53
+ Match.when({ _tag: "ProjectAuth" }, () => "Project auth"),
53
54
  Match.when({ _tag: "Up" }, () => "docker compose up"),
54
55
  Match.when({ _tag: "Status" }, () => "docker compose ps"),
55
56
  Match.when({ _tag: "Logs" }, () => "docker compose logs"),
@@ -67,19 +68,13 @@ const runWithSuspendedTui = (
67
68
  pipe(
68
69
  Effect.sync(() => {
69
70
  context.setMessage(`${label}...`)
70
- suspendTui()
71
71
  }),
72
- Effect.zipRight(effect),
72
+ Effect.zipRight(withSuspendedTui(effect, { onError: (error) => writeErrorAndPause(renderError(error)) })),
73
73
  Effect.tap(() =>
74
74
  Effect.sync(() => {
75
75
  context.setMessage(`${label} finished.`)
76
76
  })
77
77
  ),
78
- Effect.ensuring(
79
- Effect.sync(() => {
80
- resumeTui()
81
- })
82
- ),
83
78
  Effect.asVoid
84
79
  )
85
80
  )
@@ -140,6 +135,8 @@ const handleMenuAction = (
140
135
  Match.when({ _tag: "Quit" }, () => Effect.succeed(quitOutcome)),
141
136
  Match.when({ _tag: "Create" }, () => Effect.succeed(continueOutcome(state))),
142
137
  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))),
143
140
  Match.when({ _tag: "Info" }, () => Effect.succeed(continueOutcome(state))),
144
141
  Match.when({ _tag: "Delete" }, () => Effect.succeed(continueOutcome(state))),
145
142
  Match.when({ _tag: "Up" }, () =>
@@ -171,6 +168,22 @@ const runSelectAction = (context: MenuContext) => {
171
168
  context.runner.runEffect(loadSelectView(listProjectItems, "Connect", context))
172
169
  }
173
170
 
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
+
174
187
  const runDownAllAction = (context: MenuContext) => {
175
188
  context.setMessage(null)
176
189
  runWithSuspendedTui(downAllDockerGitProjects, context, "Stopping all docker-git containers")
@@ -222,6 +235,12 @@ export const handleMenuActionSelection = (action: MenuAction, context: MenuConte
222
235
  Match.when({ _tag: "Select" }, () => {
223
236
  runSelectAction(context)
224
237
  }),
238
+ Match.when({ _tag: "Auth" }, () => {
239
+ runAuthProfilesAction(context)
240
+ }),
241
+ Match.when({ _tag: "ProjectAuth" }, () => {
242
+ runProjectAuthAction(context)
243
+ }),
225
244
  Match.when({ _tag: "Info" }, () => {
226
245
  runInfoAction(context)
227
246
  }),
@@ -0,0 +1,184 @@
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 { ensureEnvFile, parseEnvEntries, readEnvText, upsertEnvKey } from "@effect-template/lib/usecases/env-file"
6
+ import { type AppError } from "@effect-template/lib/usecases/errors"
7
+ import { defaultProjectsRoot } from "@effect-template/lib/usecases/menu-helpers"
8
+ import { autoSyncState } from "@effect-template/lib/usecases/state-repo"
9
+
10
+ import { countAuthAccountDirectories } from "./menu-auth-helpers.js"
11
+ import { buildLabeledEnvKey, countKeyEntries, normalizeLabel } from "./menu-labeled-env.js"
12
+ import type { AuthFlow, AuthSnapshot, MenuEnv } from "./menu-types.js"
13
+
14
+ export type AuthMenuAction = AuthFlow | "Refresh" | "Back"
15
+
16
+ type AuthMenuItem = {
17
+ readonly action: AuthMenuAction
18
+ readonly label: string
19
+ }
20
+
21
+ export type AuthEnvFlow = Extract<AuthFlow, "GithubRemove" | "GitSet" | "GitRemove">
22
+
23
+ export type AuthPromptStep = {
24
+ readonly key: "label" | "token" | "user"
25
+ readonly label: string
26
+ readonly required: boolean
27
+ readonly secret: boolean
28
+ }
29
+
30
+ const authMenuItems: ReadonlyArray<AuthMenuItem> = [
31
+ { action: "GithubOauth", label: "GitHub: login via OAuth (web)" },
32
+ { action: "GithubRemove", label: "GitHub: remove token" },
33
+ { action: "GitSet", label: "Git: add/update credentials" },
34
+ { action: "GitRemove", label: "Git: remove credentials" },
35
+ { action: "ClaudeOauth", label: "Claude Code: login via OAuth (web)" },
36
+ { action: "ClaudeLogout", label: "Claude Code: logout (clear cache)" },
37
+ { action: "Refresh", label: "Refresh snapshot" },
38
+ { action: "Back", label: "Back to main menu" }
39
+ ]
40
+
41
+ const flowSteps: Readonly<Record<AuthFlow, ReadonlyArray<AuthPromptStep>>> = {
42
+ GithubOauth: [
43
+ { key: "label", label: "Label (empty = default)", required: false, secret: false }
44
+ ],
45
+ GithubRemove: [
46
+ { key: "label", label: "Label to remove (empty = default)", required: false, secret: false }
47
+ ],
48
+ GitSet: [
49
+ { key: "label", label: "Label (empty = default)", required: false, secret: false },
50
+ { key: "token", label: "Git auth token", required: true, secret: true },
51
+ { key: "user", label: "Git auth user (empty = x-access-token)", required: false, secret: false }
52
+ ],
53
+ GitRemove: [
54
+ { key: "label", label: "Label to remove (empty = default)", required: false, secret: false }
55
+ ],
56
+ ClaudeOauth: [
57
+ { key: "label", label: "Label (empty = default)", required: false, secret: false }
58
+ ],
59
+ ClaudeLogout: [
60
+ { key: "label", label: "Label to logout (empty = default)", required: false, secret: false }
61
+ ]
62
+ }
63
+
64
+ const flowTitle = (flow: AuthFlow): string =>
65
+ Match.value(flow).pipe(
66
+ Match.when("GithubOauth", () => "GitHub OAuth"),
67
+ Match.when("GithubRemove", () => "GitHub remove"),
68
+ Match.when("GitSet", () => "Git credentials"),
69
+ Match.when("GitRemove", () => "Git remove"),
70
+ Match.when("ClaudeOauth", () => "Claude Code OAuth"),
71
+ Match.when("ClaudeLogout", () => "Claude Code logout"),
72
+ Match.exhaustive
73
+ )
74
+
75
+ export const successMessage = (flow: AuthFlow, label: string): string =>
76
+ Match.value(flow).pipe(
77
+ Match.when("GithubOauth", () => `Saved GitHub token (${label}).`),
78
+ Match.when("GithubRemove", () => `Removed GitHub token (${label}).`),
79
+ Match.when("GitSet", () => `Saved Git credentials (${label}).`),
80
+ Match.when("GitRemove", () => `Removed Git credentials (${label}).`),
81
+ Match.when("ClaudeOauth", () => `Saved Claude Code login (${label}).`),
82
+ Match.when("ClaudeLogout", () => `Logged out Claude Code (${label}).`),
83
+ Match.exhaustive
84
+ )
85
+
86
+ const buildGlobalEnvPath = (cwd: string): string => `${defaultProjectsRoot(cwd)}/.orch/env/global.env`
87
+ const buildClaudeAuthPath = (cwd: string): string => `${defaultProjectsRoot(cwd)}/.orch/auth/claude`
88
+
89
+ type AuthEnvText = {
90
+ readonly fs: FileSystem.FileSystem
91
+ readonly path: Path.Path
92
+ readonly globalEnvPath: string
93
+ readonly claudeAuthPath: string
94
+ readonly envText: string
95
+ }
96
+
97
+ const loadAuthEnvText = (
98
+ cwd: string
99
+ ): Effect.Effect<AuthEnvText, AppError, MenuEnv> =>
100
+ Effect.gen(function*(_) {
101
+ const fs = yield* _(FileSystem.FileSystem)
102
+ const path = yield* _(Path.Path)
103
+ const globalEnvPath = buildGlobalEnvPath(cwd)
104
+ const claudeAuthPath = buildClaudeAuthPath(cwd)
105
+ yield* _(ensureEnvFile(fs, path, globalEnvPath))
106
+ const envText = yield* _(readEnvText(fs, globalEnvPath))
107
+ return { fs, path, globalEnvPath, claudeAuthPath, envText }
108
+ })
109
+
110
+ export const readAuthSnapshot = (
111
+ cwd: string
112
+ ): Effect.Effect<AuthSnapshot, AppError, MenuEnv> =>
113
+ pipe(
114
+ loadAuthEnvText(cwd),
115
+ Effect.flatMap(({ claudeAuthPath, envText, fs, globalEnvPath, path }) =>
116
+ pipe(
117
+ countAuthAccountDirectories(fs, path, claudeAuthPath),
118
+ Effect.map((claudeAuthEntries) => ({
119
+ globalEnvPath,
120
+ claudeAuthPath,
121
+ totalEntries: parseEnvEntries(envText).filter((entry) => entry.value.trim().length > 0).length,
122
+ githubTokenEntries: countKeyEntries(envText, "GITHUB_TOKEN"),
123
+ gitTokenEntries: countKeyEntries(envText, "GIT_AUTH_TOKEN"),
124
+ gitUserEntries: countKeyEntries(envText, "GIT_AUTH_USER"),
125
+ claudeAuthEntries
126
+ }))
127
+ )
128
+ )
129
+ )
130
+
131
+ export const writeAuthFlow = (
132
+ cwd: string,
133
+ flow: AuthEnvFlow,
134
+ values: Readonly<Record<string, string>>
135
+ ): Effect.Effect<void, AppError, MenuEnv> =>
136
+ pipe(
137
+ loadAuthEnvText(cwd),
138
+ Effect.flatMap(({ envText, fs, globalEnvPath }) => {
139
+ const label = values["label"] ?? ""
140
+ const canonicalLabel = (() => {
141
+ const normalized = normalizeLabel(label)
142
+ return normalized.length === 0 || normalized === "DEFAULT" ? "default" : normalized
143
+ })()
144
+ const token = (values["token"] ?? "").trim()
145
+ const user = (values["user"] ?? "").trim()
146
+ const nextText = Match.value(flow).pipe(
147
+ Match.when("GithubRemove", () => upsertEnvKey(envText, buildLabeledEnvKey("GITHUB_TOKEN", label), "")),
148
+ Match.when("GitSet", () => {
149
+ const withToken = upsertEnvKey(envText, buildLabeledEnvKey("GIT_AUTH_TOKEN", label), token)
150
+ const resolvedUser = user.length > 0 ? user : "x-access-token"
151
+ return upsertEnvKey(withToken, buildLabeledEnvKey("GIT_AUTH_USER", label), resolvedUser)
152
+ }),
153
+ Match.when("GitRemove", () => {
154
+ const withoutToken = upsertEnvKey(envText, buildLabeledEnvKey("GIT_AUTH_TOKEN", label), "")
155
+ return upsertEnvKey(withoutToken, buildLabeledEnvKey("GIT_AUTH_USER", label), "")
156
+ }),
157
+ Match.exhaustive
158
+ )
159
+ const syncMessage = Match.value(flow).pipe(
160
+ Match.when("GithubRemove", () => `chore(state): auth gh logout ${canonicalLabel}`),
161
+ Match.when("GitSet", () => `chore(state): auth git ${canonicalLabel}`),
162
+ Match.when("GitRemove", () => `chore(state): auth git logout ${canonicalLabel}`),
163
+ Match.exhaustive
164
+ )
165
+ return pipe(
166
+ fs.writeFileString(globalEnvPath, nextText),
167
+ Effect.zipRight(autoSyncState(syncMessage))
168
+ )
169
+ }),
170
+ Effect.asVoid
171
+ )
172
+
173
+ export const authViewTitle = (flow: AuthFlow): string => flowTitle(flow)
174
+
175
+ export const authViewSteps = (flow: AuthFlow): ReadonlyArray<AuthPromptStep> => flowSteps[flow]
176
+
177
+ export const authMenuLabels = (): ReadonlyArray<string> => authMenuItems.map((item) => item.label)
178
+
179
+ export const authMenuActionByIndex = (index: number): AuthMenuAction | null => {
180
+ const item = authMenuItems[index]
181
+ return item ? item.action : null
182
+ }
183
+
184
+ export const authMenuSize = (): number => authMenuItems.length
@@ -0,0 +1,30 @@
1
+ import type * as FileSystem from "@effect/platform/FileSystem"
2
+ import type * as Path from "@effect/platform/Path"
3
+ import { Effect } from "effect"
4
+
5
+ import type { AppError } from "@effect-template/lib/usecases/errors"
6
+
7
+ export const countAuthAccountDirectories = (
8
+ fs: FileSystem.FileSystem,
9
+ path: Path.Path,
10
+ root: string
11
+ ): Effect.Effect<number, AppError> =>
12
+ Effect.gen(function*(_) {
13
+ const exists = yield* _(fs.exists(root))
14
+ if (!exists) {
15
+ return 0
16
+ }
17
+ const entries = yield* _(fs.readDirectory(root))
18
+ let count = 0
19
+ for (const entry of entries) {
20
+ if (entry === ".image") {
21
+ continue
22
+ }
23
+ const fullPath = path.join(root, entry)
24
+ const info = yield* _(fs.stat(fullPath))
25
+ if (info.type === "Directory") {
26
+ count += 1
27
+ }
28
+ }
29
+ return count
30
+ })
@@ -0,0 +1,311 @@
1
+ import { Effect, Match, pipe } from "effect"
2
+
3
+ import { authClaudeLogin, authClaudeLogout, authGithubLogin, claudeAuthRoot } from "@effect-template/lib/usecases/auth"
4
+ import type { AppError } from "@effect-template/lib/usecases/errors"
5
+ import { renderError } from "@effect-template/lib/usecases/errors"
6
+
7
+ import {
8
+ type AuthMenuAction,
9
+ authMenuActionByIndex,
10
+ authMenuSize,
11
+ authViewSteps,
12
+ readAuthSnapshot,
13
+ successMessage,
14
+ writeAuthFlow
15
+ } from "./menu-auth-data.js"
16
+ import { nextBufferValue } from "./menu-buffer-input.js"
17
+ import { handleMenuNumberInput, submitPromptStep } from "./menu-input-utils.js"
18
+ import { pauseOnError, resetToMenu, resumeSshWithSkipInputs, withSuspendedTui } from "./menu-shared.js"
19
+ import type {
20
+ AuthFlow,
21
+ AuthSnapshot,
22
+ MenuEnv,
23
+ MenuKeyInput,
24
+ MenuRunner,
25
+ MenuState,
26
+ MenuViewContext,
27
+ ViewState
28
+ } from "./menu-types.js"
29
+
30
+ type AuthContext = MenuViewContext & {
31
+ readonly state: MenuState
32
+ readonly runner: MenuRunner
33
+ }
34
+
35
+ type AuthInputContext = AuthContext & {
36
+ readonly setSshActive: (active: boolean) => void
37
+ readonly setSkipInputs: (update: (value: number) => number) => void
38
+ }
39
+
40
+ type AuthPromptView = Extract<ViewState, { readonly _tag: "AuthPrompt" }>
41
+
42
+ const defaultLabel = (value: string): string => {
43
+ const trimmed = value.trim()
44
+ return trimmed.length > 0 ? trimmed : "default"
45
+ }
46
+
47
+ const startAuthMenuWithSnapshot = (
48
+ snapshot: AuthSnapshot,
49
+ context: Pick<MenuViewContext, "setView" | "setMessage">
50
+ ) => {
51
+ context.setView({ _tag: "AuthMenu", selected: 0, snapshot })
52
+ context.setMessage(null)
53
+ }
54
+
55
+ const startAuthPrompt = (
56
+ snapshot: AuthSnapshot,
57
+ flow: AuthFlow,
58
+ context: Pick<MenuViewContext, "setView" | "setMessage">
59
+ ) => {
60
+ context.setView({
61
+ _tag: "AuthPrompt",
62
+ flow,
63
+ step: 0,
64
+ buffer: "",
65
+ values: {},
66
+ snapshot
67
+ })
68
+ context.setMessage(null)
69
+ }
70
+
71
+ const resolveLabelOption = (values: Readonly<Record<string, string>>): string | null => {
72
+ const labelValue = (values["label"] ?? "").trim()
73
+ return labelValue.length > 0 ? labelValue : null
74
+ }
75
+
76
+ const resolveAuthPromptEffect = (
77
+ view: AuthPromptView,
78
+ cwd: string,
79
+ values: Readonly<Record<string, string>>
80
+ ): Effect.Effect<void, AppError, MenuEnv> => {
81
+ const labelOption = resolveLabelOption(values)
82
+ return Match.value(view.flow).pipe(
83
+ Match.when("GithubOauth", () =>
84
+ authGithubLogin({
85
+ _tag: "AuthGithubLogin",
86
+ label: labelOption,
87
+ token: null,
88
+ scopes: null,
89
+ envGlobalPath: view.snapshot.globalEnvPath
90
+ })),
91
+ Match.when("ClaudeOauth", () =>
92
+ authClaudeLogin({
93
+ _tag: "AuthClaudeLogin",
94
+ label: labelOption,
95
+ claudeAuthPath: claudeAuthRoot
96
+ })),
97
+ Match.when("ClaudeLogout", () =>
98
+ authClaudeLogout({
99
+ _tag: "AuthClaudeLogout",
100
+ label: labelOption,
101
+ claudeAuthPath: claudeAuthRoot
102
+ })),
103
+ Match.when("GithubRemove", (flow) => writeAuthFlow(cwd, flow, values)),
104
+ Match.when("GitSet", (flow) => writeAuthFlow(cwd, flow, values)),
105
+ Match.when("GitRemove", (flow) => writeAuthFlow(cwd, flow, values)),
106
+ Match.exhaustive
107
+ )
108
+ }
109
+
110
+ const runAuthPromptEffect = (
111
+ effect: Effect.Effect<void, AppError, MenuEnv>,
112
+ view: AuthPromptView,
113
+ label: string,
114
+ context: AuthInputContext,
115
+ options: { readonly suspendTui: boolean }
116
+ ) => {
117
+ const withOptionalSuspension = options.suspendTui
118
+ ? withSuspendedTui(effect, {
119
+ onError: pauseOnError(renderError),
120
+ onResume: resumeSshWithSkipInputs(context)
121
+ })
122
+ : effect
123
+
124
+ context.setSshActive(options.suspendTui)
125
+ context.runner.runEffect(
126
+ pipe(
127
+ withOptionalSuspension,
128
+ Effect.zipRight(readAuthSnapshot(context.state.cwd)),
129
+ Effect.tap((snapshot) =>
130
+ Effect.sync(() => {
131
+ startAuthMenuWithSnapshot(snapshot, context)
132
+ context.setMessage(successMessage(view.flow, label))
133
+ })
134
+ ),
135
+ Effect.asVoid
136
+ )
137
+ )
138
+ }
139
+
140
+ const loadAuthMenuView = (
141
+ cwd: string,
142
+ context: Pick<MenuViewContext, "setView" | "setMessage">
143
+ ): Effect.Effect<void, AppError, MenuEnv> =>
144
+ pipe(
145
+ readAuthSnapshot(cwd),
146
+ Effect.tap((snapshot) =>
147
+ Effect.sync(() => {
148
+ startAuthMenuWithSnapshot(snapshot, context)
149
+ })
150
+ ),
151
+ Effect.asVoid
152
+ )
153
+
154
+ const runAuthAction = (
155
+ action: AuthMenuAction,
156
+ view: Extract<ViewState, { readonly _tag: "AuthMenu" }>,
157
+ context: AuthContext
158
+ ) => {
159
+ if (action === "Back") {
160
+ resetToMenu(context)
161
+ return
162
+ }
163
+ if (action === "Refresh") {
164
+ context.runner.runEffect(loadAuthMenuView(context.state.cwd, context))
165
+ return
166
+ }
167
+ startAuthPrompt(view.snapshot, action, context)
168
+ }
169
+
170
+ const submitAuthPrompt = (
171
+ view: AuthPromptView,
172
+ context: AuthInputContext
173
+ ) => {
174
+ const steps = authViewSteps(view.flow)
175
+ submitPromptStep(
176
+ view,
177
+ steps,
178
+ context,
179
+ () => {
180
+ startAuthMenuWithSnapshot(view.snapshot, context)
181
+ },
182
+ (nextValues) => {
183
+ const label = defaultLabel(nextValues["label"] ?? "")
184
+ const effect = resolveAuthPromptEffect(view, context.state.cwd, nextValues)
185
+ runAuthPromptEffect(effect, view, label, context, {
186
+ suspendTui: view.flow === "GithubOauth" || view.flow === "ClaudeOauth" || view.flow === "ClaudeLogout"
187
+ })
188
+ }
189
+ )
190
+ }
191
+
192
+ const setAuthMenuSelection = (
193
+ view: Extract<ViewState, { readonly _tag: "AuthMenu" }>,
194
+ selected: number,
195
+ context: AuthContext
196
+ ) => {
197
+ context.setView({
198
+ ...view,
199
+ selected
200
+ })
201
+ }
202
+
203
+ const shiftAuthMenuSelection = (
204
+ view: Extract<ViewState, { readonly _tag: "AuthMenu" }>,
205
+ delta: number,
206
+ context: AuthContext
207
+ ) => {
208
+ const menuSize = authMenuSize()
209
+ const selected = (view.selected + delta + menuSize) % menuSize
210
+ setAuthMenuSelection(view, selected, context)
211
+ }
212
+
213
+ const runAuthMenuSelection = (
214
+ selected: number,
215
+ view: Extract<ViewState, { readonly _tag: "AuthMenu" }>,
216
+ context: AuthContext
217
+ ) => {
218
+ const action = authMenuActionByIndex(selected)
219
+ if (action === null) {
220
+ return
221
+ }
222
+ runAuthAction(action, view, context)
223
+ }
224
+
225
+ const handleAuthMenuNumberInput = (
226
+ input: string,
227
+ view: Extract<ViewState, { readonly _tag: "AuthMenu" }>,
228
+ context: AuthContext
229
+ ) => {
230
+ handleMenuNumberInput(input, context, authMenuActionByIndex, (action) => {
231
+ runAuthAction(action, view, context)
232
+ })
233
+ }
234
+
235
+ const handleAuthMenuInput = (
236
+ input: string,
237
+ key: MenuKeyInput,
238
+ view: Extract<ViewState, { readonly _tag: "AuthMenu" }>,
239
+ context: AuthContext
240
+ ) => {
241
+ if (key.escape) {
242
+ resetToMenu(context)
243
+ return
244
+ }
245
+ if (key.upArrow) {
246
+ shiftAuthMenuSelection(view, -1, context)
247
+ return
248
+ }
249
+ if (key.downArrow) {
250
+ shiftAuthMenuSelection(view, 1, context)
251
+ return
252
+ }
253
+ if (key.return) {
254
+ runAuthMenuSelection(view.selected, view, context)
255
+ return
256
+ }
257
+ handleAuthMenuNumberInput(input, view, context)
258
+ }
259
+
260
+ const handleAuthPromptInput = (
261
+ input: string,
262
+ key: MenuKeyInput,
263
+ view: Extract<ViewState, { readonly _tag: "AuthPrompt" }>,
264
+ context: AuthInputContext
265
+ ) => {
266
+ if (key.escape) {
267
+ startAuthMenuWithSnapshot(view.snapshot, context)
268
+ return
269
+ }
270
+ if (key.return) {
271
+ submitAuthPrompt(view, context)
272
+ return
273
+ }
274
+ setAuthPromptBuffer({ input, key, view, context })
275
+ }
276
+
277
+ type SetAuthPromptBufferArgs = {
278
+ readonly input: string
279
+ readonly key: MenuKeyInput
280
+ readonly view: Extract<ViewState, { readonly _tag: "AuthPrompt" }>
281
+ readonly context: Pick<MenuViewContext, "setView">
282
+ }
283
+
284
+ const setAuthPromptBuffer = (
285
+ args: SetAuthPromptBufferArgs
286
+ ) => {
287
+ const { context, input, key, view } = args
288
+ const nextBuffer = nextBufferValue(input, key, view.buffer)
289
+ if (nextBuffer === null) {
290
+ return
291
+ }
292
+ context.setView({ ...view, buffer: nextBuffer })
293
+ }
294
+
295
+ export const openAuthMenu = (context: AuthContext): void => {
296
+ context.setMessage("Loading auth profiles...")
297
+ context.runner.runEffect(loadAuthMenuView(context.state.cwd, context))
298
+ }
299
+
300
+ export const handleAuthInput = (
301
+ input: string,
302
+ key: MenuKeyInput,
303
+ view: Extract<ViewState, { readonly _tag: "AuthMenu" | "AuthPrompt" }>,
304
+ context: AuthInputContext
305
+ ) => {
306
+ if (view._tag === "AuthMenu") {
307
+ handleAuthMenuInput(input, key, view, context)
308
+ return
309
+ }
310
+ handleAuthPromptInput(input, key, view, context)
311
+ }
@@ -0,0 +1,18 @@
1
+ export type BufferInputKey = {
2
+ readonly backspace?: boolean
3
+ readonly delete?: boolean
4
+ }
5
+
6
+ export const nextBufferValue = (
7
+ input: string,
8
+ key: BufferInputKey,
9
+ buffer: string
10
+ ): string | null => {
11
+ if (key.backspace || key.delete) {
12
+ return buffer.slice(0, -1)
13
+ }
14
+ if (input.length > 0) {
15
+ return buffer + input
16
+ }
17
+ return null
18
+ }