@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
@@ -1,53 +1,19 @@
1
- import { runDockerComposeDown } from "@effect-template/lib/shell/docker"
2
- import type { AppError } from "@effect-template/lib/usecases/errors"
3
- import { mcpPlaywrightUp } from "@effect-template/lib/usecases/mcp-playwright"
4
- import {
5
- connectProjectSshWithUp,
6
- deleteDockerGitProject,
7
- listRunningProjectItems,
8
- type ProjectItem
9
- } from "@effect-template/lib/usecases/projects"
10
-
11
- import { Effect, Match, pipe } from "effect"
12
-
13
- import { buildConnectEffect, isConnectMcpToggleInput } from "./menu-select-connect.js"
14
- import { loadRuntimeByProject, runtimeForSelection } from "./menu-select-runtime.js"
15
- import { resetToMenu, resumeTui, suspendTui } from "./menu-shared.js"
16
- import type {
17
- MenuEnv,
18
- MenuKeyInput,
19
- MenuRunner,
20
- MenuViewContext,
21
- SelectProjectRuntime,
22
- ViewState
23
- } from "./menu-types.js"
24
-
25
- type SelectContext = MenuViewContext & {
26
- readonly activeDir: string | null
27
- readonly runner: MenuRunner
28
- readonly setSshActive: (active: boolean) => void
29
- readonly setSkipInputs: (update: (value: number) => number) => void
30
- }
1
+ import { Match } from "effect"
31
2
 
32
- const emptyRuntimeByProject = (): Readonly<Record<string, SelectProjectRuntime>> => ({})
33
-
34
- export const startSelectView = (
35
- items: ReadonlyArray<ProjectItem>,
36
- purpose: "Connect" | "Down" | "Info" | "Delete",
37
- context: Pick<SelectContext, "setView" | "setMessage">,
38
- runtimeByProject: Readonly<Record<string, SelectProjectRuntime>> = emptyRuntimeByProject()
39
- ) => {
40
- context.setMessage(null)
41
- context.setView({
42
- _tag: "SelectProject",
43
- purpose,
44
- items,
45
- runtimeByProject,
46
- selected: 0,
47
- confirmDelete: false,
48
- connectEnableMcpPlaywright: false
49
- })
50
- }
3
+ import {
4
+ runAuthSelection,
5
+ runConnectSelection,
6
+ runDeleteSelection,
7
+ runDownSelection,
8
+ runInfoSelection,
9
+ type SelectContext
10
+ } from "./menu-select-actions.js"
11
+ import { isConnectMcpToggleInput } from "./menu-select-connect.js"
12
+ import { runtimeForSelection } from "./menu-select-runtime.js"
13
+ import { resetToMenu } from "./menu-shared.js"
14
+ import type { MenuKeyInput, ViewState } from "./menu-types.js"
15
+
16
+ export { startSelectView } from "./menu-select-view.js"
51
17
 
52
18
  const clampIndex = (value: number, size: number): number => {
53
19
  if (size <= 0) {
@@ -123,122 +89,8 @@ const handleSelectNavigation = (
123
89
  return false
124
90
  }
125
91
 
126
- const runWithSuspendedTui = (
127
- context: Pick<SelectContext, "runner" | "setMessage" | "setSkipInputs">,
128
- effect: Effect.Effect<void, AppError, MenuEnv>,
129
- onResume: () => void,
130
- doneMessage: string
131
- ) => {
132
- context.runner.runEffect(
133
- pipe(
134
- Effect.sync(suspendTui),
135
- Effect.zipRight(effect),
136
- Effect.ensuring(
137
- Effect.sync(() => {
138
- resumeTui()
139
- onResume()
140
- context.setSkipInputs(() => 2)
141
- })
142
- ),
143
- Effect.tap(() =>
144
- Effect.sync(() => {
145
- context.setMessage(doneMessage)
146
- })
147
- )
148
- )
149
- )
150
- }
151
-
152
- const runConnectSelection = (
153
- selected: ProjectItem,
154
- context: SelectContext,
155
- enableMcpPlaywright: boolean
156
- ) => {
157
- context.setMessage(
158
- enableMcpPlaywright
159
- ? `Enabling Playwright MCP for ${selected.displayName}, then connecting...`
160
- : `Connecting to ${selected.displayName}...`
161
- )
162
- context.setSshActive(true)
163
- runWithSuspendedTui(
164
- context,
165
- buildConnectEffect(selected, enableMcpPlaywright, {
166
- connectWithUp: (item) =>
167
- connectProjectSshWithUp(item).pipe(
168
- Effect.mapError((error): AppError => error)
169
- ),
170
- enableMcpPlaywright: (projectDir) =>
171
- mcpPlaywrightUp({ _tag: "McpPlaywrightUp", projectDir, runUp: false }).pipe(
172
- Effect.asVoid,
173
- Effect.mapError((error): AppError => error)
174
- )
175
- }),
176
- () => {
177
- context.setSshActive(false)
178
- },
179
- "SSH session ended. Press Esc to return to the menu."
180
- )
181
- }
182
-
183
- const runDownSelection = (selected: ProjectItem, context: SelectContext) => {
184
- context.setMessage(`Stopping ${selected.displayName}...`)
185
- context.runner.runEffect(
186
- pipe(
187
- Effect.sync(suspendTui),
188
- Effect.zipRight(runDockerComposeDown(selected.projectDir)),
189
- Effect.zipRight(listRunningProjectItems),
190
- Effect.flatMap((items) =>
191
- pipe(
192
- loadRuntimeByProject(items),
193
- Effect.map((runtimeByProject) => ({ items, runtimeByProject }))
194
- )
195
- ),
196
- Effect.tap(({ items, runtimeByProject }) =>
197
- Effect.sync(() => {
198
- if (items.length === 0) {
199
- resetToMenu(context)
200
- context.setMessage("No running docker-git containers.")
201
- return
202
- }
203
- startSelectView(items, "Down", context, runtimeByProject)
204
- context.setMessage("Container stopped. Select another to stop, or Esc to return.")
205
- })
206
- ),
207
- Effect.ensuring(
208
- Effect.sync(() => {
209
- resumeTui()
210
- context.setSkipInputs(() => 2)
211
- })
212
- ),
213
- Effect.asVoid
214
- )
215
- )
216
- }
217
-
218
- const runInfoSelection = (selected: ProjectItem, context: SelectContext) => {
219
- context.setMessage(`Details for ${selected.displayName} are shown on the right. Press Esc to return to the menu.`)
220
- }
221
-
222
- const runDeleteSelection = (selected: ProjectItem, context: SelectContext) => {
223
- context.setMessage(`Deleting ${selected.displayName}...`)
224
- runWithSuspendedTui(
225
- context,
226
- deleteDockerGitProject(selected).pipe(
227
- Effect.tap(() =>
228
- Effect.sync(() => {
229
- if (context.activeDir === selected.projectDir) {
230
- context.setActiveDir(null)
231
- }
232
- context.setView({ _tag: "Menu" })
233
- })
234
- )
235
- ),
236
- () => {
237
- // Only return to menu on success (see Effect.tap above).
238
- },
239
- "Project deleted."
240
- )
241
- }
92
+ const formatSshSessionsLabel = (sshSessions: number): string =>
93
+ sshSessions === 1 ? "1 active SSH session" : `${sshSessions} active SSH sessions`
242
94
 
243
95
  const handleSelectReturn = (
244
96
  view: Extract<ViewState, { readonly _tag: "SelectProject" }>,
@@ -251,15 +103,17 @@ const handleSelectReturn = (
251
103
  return
252
104
  }
253
105
  const selectedRuntime = runtimeForSelection(view, selected)
254
- const sshSessionsLabel = selectedRuntime.sshSessions === 1
255
- ? "1 active SSH session"
256
- : `${selectedRuntime.sshSessions} active SSH sessions`
106
+ const sshSessionsLabel = formatSshSessionsLabel(selectedRuntime.sshSessions)
257
107
 
258
108
  Match.value(view.purpose).pipe(
259
109
  Match.when("Connect", () => {
260
110
  context.setActiveDir(selected.projectDir)
261
111
  runConnectSelection(selected, context, view.connectEnableMcpPlaywright)
262
112
  }),
113
+ Match.when("Auth", () => {
114
+ context.setActiveDir(selected.projectDir)
115
+ runAuthSelection(selected, context)
116
+ }),
263
117
  Match.when("Down", () => {
264
118
  if (selectedRuntime.sshSessions > 0 && !view.confirmDelete) {
265
119
  context.setMessage(
@@ -289,30 +143,3 @@ const handleSelectReturn = (
289
143
  Match.exhaustive
290
144
  )
291
145
  }
292
-
293
- export const loadSelectView = <E>(
294
- effect: Effect.Effect<ReadonlyArray<ProjectItem>, E, MenuEnv>,
295
- purpose: "Connect" | "Down" | "Info" | "Delete",
296
- context: Pick<SelectContext, "setView" | "setMessage">
297
- ): Effect.Effect<void, E, MenuEnv> =>
298
- pipe(
299
- effect,
300
- Effect.flatMap((items) =>
301
- pipe(
302
- loadRuntimeByProject(items),
303
- Effect.flatMap((runtimeByProject) =>
304
- Effect.sync(() => {
305
- if (items.length === 0) {
306
- context.setMessage(
307
- purpose === "Down"
308
- ? "No running docker-git containers."
309
- : "No docker-git projects found."
310
- )
311
- return
312
- }
313
- startSelectView(items, purpose, context, runtimeByProject)
314
- })
315
- )
316
- )
317
- )
318
- )
@@ -1,5 +1,7 @@
1
1
  import type { MenuViewContext, ViewState } from "./menu-types.js"
2
2
 
3
+ import { Effect, pipe } from "effect"
4
+
3
5
  // CHANGE: share menu escape handling across flows
4
6
  // WHY: avoid duplicated logic in TUI handlers
5
7
  // QUOTE(ТЗ): "А ты можешь сделать удобный выбор проектов?"
@@ -13,10 +15,31 @@ import type { MenuViewContext, ViewState } from "./menu-types.js"
13
15
 
14
16
  type MenuResetContext = Pick<MenuViewContext, "setView" | "setMessage">
15
17
 
16
- type StdoutWrite = typeof process.stdout.write
18
+ type OutputWrite = typeof process.stdout.write
17
19
 
18
20
  let stdoutPatched = false
19
21
  let stdoutMuted = false
22
+ let baseStdoutWrite: OutputWrite | null = null
23
+ let baseStderrWrite: OutputWrite | null = null
24
+
25
+ const wrapWrite = (baseWrite: OutputWrite): OutputWrite =>
26
+ (
27
+ chunk: string | Uint8Array,
28
+ encoding?: BufferEncoding | ((err?: Error | null) => void),
29
+ cb?: (err?: Error | null) => void
30
+ ) => {
31
+ if (stdoutMuted) {
32
+ const callback = typeof encoding === "function" ? encoding : cb
33
+ if (typeof callback === "function") {
34
+ callback()
35
+ }
36
+ return true
37
+ }
38
+ if (typeof encoding === "function") {
39
+ return baseWrite(chunk, encoding)
40
+ }
41
+ return baseWrite(chunk, encoding, cb)
42
+ }
20
43
 
21
44
  const disableMouseModes = (): void => {
22
45
  // Disable xterm/urxvt mouse tracking and "alternate scroll" mode (wheel -> arrow keys).
@@ -39,28 +62,116 @@ const ensureStdoutPatched = (): void => {
39
62
  if (stdoutPatched) {
40
63
  return
41
64
  }
42
- const baseWrite: StdoutWrite = process.stdout.write.bind(process.stdout)
43
- const mutedWrite: StdoutWrite = (
44
- chunk: string | Uint8Array,
45
- encoding?: BufferEncoding | ((err?: Error | null) => void),
46
- cb?: (err?: Error | null) => void
47
- ) => {
48
- if (stdoutMuted) {
49
- const callback = typeof encoding === "function" ? encoding : cb
50
- if (typeof callback === "function") {
51
- callback()
52
- }
53
- return true
65
+ baseStdoutWrite = process.stdout.write.bind(process.stdout)
66
+ baseStderrWrite = process.stderr.write.bind(process.stderr)
67
+
68
+ process.stdout.write = wrapWrite(baseStdoutWrite)
69
+ process.stderr.write = wrapWrite(baseStderrWrite)
70
+ stdoutPatched = true
71
+ }
72
+
73
+ // CHANGE: allow writing to the terminal even while stdout is muted
74
+ // WHY: we mute Ink renders during interactive commands, but still need to show prompts/errors
75
+ // REF: user-request-2026-02-18-tui-output-hidden
76
+ // SOURCE: n/a
77
+ // PURITY: SHELL
78
+ // EFFECT: n/a
79
+ // INVARIANT: bypasses the mute wrapper safely
80
+ export const writeToTerminal = (text: string): void => {
81
+ ensureStdoutPatched()
82
+ const write = baseStdoutWrite ?? process.stdout.write.bind(process.stdout)
83
+ write(text)
84
+ }
85
+
86
+ // CHANGE: keep the user on the primary screen until they acknowledge
87
+ // WHY: otherwise output from failed docker/gh commands gets hidden again when TUI resumes
88
+ // REF: user-request-2026-02-18-tui-output-hidden
89
+ // SOURCE: n/a
90
+ // PURITY: SHELL
91
+ // EFFECT: Effect<void, never, never>
92
+ // INVARIANT: no-op when stdin/stdout aren't TTY (CI/e2e)
93
+ export const pauseForEnter = (
94
+ prompt = "Press Enter to return to docker-git..."
95
+ ): Effect.Effect<void> => {
96
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
97
+ return Effect.void
98
+ }
99
+
100
+ return Effect.async((resume) => {
101
+ // Ensure the prompt isn't glued to the last command line.
102
+ writeToTerminal(`\n${prompt}\n`)
103
+ process.stdin.resume()
104
+
105
+ const cleanup = () => {
106
+ process.stdin.off("data", onData)
54
107
  }
55
- if (typeof encoding === "function") {
56
- return baseWrite(chunk, encoding)
108
+
109
+ const onData = () => {
110
+ cleanup()
111
+ resume(Effect.void)
57
112
  }
58
- return baseWrite(chunk, encoding, cb)
113
+
114
+ process.stdin.on("data", onData)
115
+
116
+ return Effect.sync(() => {
117
+ cleanup()
118
+ })
119
+ }).pipe(Effect.asVoid)
120
+ }
121
+
122
+ export const writeErrorAndPause = (renderedError: string): Effect.Effect<void> =>
123
+ pipe(
124
+ Effect.sync(() => {
125
+ writeToTerminal(`\n[docker-git] ${renderedError}\n`)
126
+ }),
127
+ Effect.zipRight(pauseForEnter()),
128
+ Effect.asVoid
129
+ )
130
+
131
+ export const withSuspendedTui = <A, E, R>(
132
+ effect: Effect.Effect<A, E, R>,
133
+ options?: {
134
+ readonly onError?: (error: E) => Effect.Effect<void>
135
+ readonly onResume?: () => void
59
136
  }
60
- process.stdout.write = mutedWrite
61
- stdoutPatched = true
137
+ ): Effect.Effect<A, E, R> => {
138
+ const withError = options?.onError
139
+ ? pipe(effect, Effect.tapError((error) => Effect.ignore(options.onError?.(error) ?? Effect.void)))
140
+ : effect
141
+
142
+ return pipe(
143
+ Effect.sync(suspendTui),
144
+ Effect.zipRight(withError),
145
+ Effect.ensuring(
146
+ Effect.sync(() => {
147
+ resumeTui()
148
+ options?.onResume?.()
149
+ })
150
+ )
151
+ )
152
+ }
153
+
154
+ export type SkipInputsContext = {
155
+ readonly setSkipInputs: (update: (value: number) => number) => void
156
+ }
157
+
158
+ export type SshActiveContext = {
159
+ readonly setSshActive: (active: boolean) => void
62
160
  }
63
161
 
162
+ export const resumeWithSkipInputs = (context: SkipInputsContext, extra?: () => void) => () => {
163
+ extra?.()
164
+ context.setSkipInputs(() => 2)
165
+ }
166
+
167
+ export const resumeSshWithSkipInputs = (context: SkipInputsContext & SshActiveContext) =>
168
+ resumeWithSkipInputs(context, () => {
169
+ context.setSshActive(false)
170
+ })
171
+
172
+ export const pauseOnError = <E>(render: (error: E) => string) => (error: E): Effect.Effect<void> =>
173
+ writeErrorAndPause(render(error))
174
+
64
175
  // CHANGE: toggle stdout write muting for Ink rendering
65
176
  // WHY: allow SSH sessions to own the terminal without TUI redraws
66
177
  // QUOTE(ТЗ): "при изменении разершения он всё ломает?"
@@ -94,7 +205,9 @@ export const suspendTui = (): void => {
94
205
  if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
95
206
  process.stdin.setRawMode(false)
96
207
  }
97
- process.stdout.write("\u001B[?1049l\u001B[2J\u001B[H")
208
+ // Switch back to the primary screen so interactive commands (ssh/gh/codex)
209
+ // can render normally. Do not clear it: users may need scrollback (OAuth codes/URLs).
210
+ process.stdout.write("\u001B[?1049l")
98
211
  setStdoutMuted(true)
99
212
  }
100
213
 
@@ -114,6 +227,7 @@ export const resumeTui = (): void => {
114
227
  }
115
228
  setStdoutMuted(false)
116
229
  disableMouseModes()
230
+ // Return to the alternate screen for Ink rendering.
117
231
  process.stdout.write("\u001B[?1049h\u001B[2J\u001B[H")
118
232
  if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
119
233
  process.stdin.setRawMode(true)
@@ -128,7 +242,8 @@ export const leaveTui = (): void => {
128
242
  // Ensure we don't leave the terminal in a broken "mouse reporting" mode.
129
243
  setStdoutMuted(false)
130
244
  disableMouseModes()
131
- process.stdout.write("\u001B[?1049l\u001B[2J\u001B[H")
245
+ // Restore the primary screen on exit without clearing it (keeps useful scrollback).
246
+ process.stdout.write("\u001B[?1049l")
132
247
  if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
133
248
  process.stdin.setRawMode(false)
134
249
  }
@@ -40,13 +40,14 @@ export type MenuKeyInput = {
40
40
  readonly downArrow?: boolean
41
41
  readonly return?: boolean
42
42
  readonly escape?: boolean
43
+ readonly backspace?: boolean
44
+ readonly delete?: boolean
43
45
  }
44
46
 
45
47
  export type CreateInputs = {
46
48
  readonly repoUrl: string
47
49
  readonly repoRef: string
48
50
  readonly outDir: string
49
- readonly secretsRoot: string
50
51
  readonly runUp: boolean
51
52
  readonly enableMcpPlaywright: boolean
52
53
  readonly force: boolean
@@ -70,12 +71,76 @@ export const createSteps: ReadonlyArray<CreateStep> = [
70
71
  "force"
71
72
  ]
72
73
 
74
+ export type AuthFlow =
75
+ | "GithubOauth"
76
+ | "GithubRemove"
77
+ | "GitSet"
78
+ | "GitRemove"
79
+ | "ClaudeOauth"
80
+ | "ClaudeLogout"
81
+
82
+ export interface AuthSnapshot {
83
+ readonly globalEnvPath: string
84
+ readonly claudeAuthPath: string
85
+ readonly totalEntries: number
86
+ readonly githubTokenEntries: number
87
+ readonly gitTokenEntries: number
88
+ readonly gitUserEntries: number
89
+ readonly claudeAuthEntries: number
90
+ }
91
+
92
+ export type ProjectAuthFlow =
93
+ | "ProjectGithubConnect"
94
+ | "ProjectGithubDisconnect"
95
+ | "ProjectGitConnect"
96
+ | "ProjectGitDisconnect"
97
+ | "ProjectClaudeConnect"
98
+ | "ProjectClaudeDisconnect"
99
+
100
+ export interface ProjectAuthSnapshot {
101
+ readonly projectDir: string
102
+ readonly projectName: string
103
+ readonly envGlobalPath: string
104
+ readonly envProjectPath: string
105
+ readonly claudeAuthPath: string
106
+ readonly githubTokenEntries: number
107
+ readonly gitTokenEntries: number
108
+ readonly claudeAuthEntries: number
109
+ readonly activeGithubLabel: string | null
110
+ readonly activeGitLabel: string | null
111
+ readonly activeClaudeLabel: string | null
112
+ }
113
+
73
114
  export type ViewState =
74
115
  | { readonly _tag: "Menu" }
75
116
  | { readonly _tag: "Create"; readonly step: number; readonly buffer: string; readonly values: Partial<CreateInputs> }
117
+ | { readonly _tag: "AuthMenu"; readonly selected: number; readonly snapshot: AuthSnapshot }
118
+ | {
119
+ readonly _tag: "AuthPrompt"
120
+ readonly flow: AuthFlow
121
+ readonly step: number
122
+ readonly buffer: string
123
+ readonly values: Readonly<Record<string, string>>
124
+ readonly snapshot: AuthSnapshot
125
+ }
126
+ | {
127
+ readonly _tag: "ProjectAuthMenu"
128
+ readonly selected: number
129
+ readonly project: ProjectItem
130
+ readonly snapshot: ProjectAuthSnapshot
131
+ }
132
+ | {
133
+ readonly _tag: "ProjectAuthPrompt"
134
+ readonly flow: ProjectAuthFlow
135
+ readonly step: number
136
+ readonly buffer: string
137
+ readonly values: Readonly<Record<string, string>>
138
+ readonly project: ProjectItem
139
+ readonly snapshot: ProjectAuthSnapshot
140
+ }
76
141
  | {
77
142
  readonly _tag: "SelectProject"
78
- readonly purpose: "Connect" | "Down" | "Info" | "Delete"
143
+ readonly purpose: "Connect" | "Down" | "Info" | "Delete" | "Auth"
79
144
  readonly items: ReadonlyArray<ProjectItem>
80
145
  readonly runtimeByProject: Readonly<Record<string, SelectProjectRuntime>>
81
146
  readonly selected: number
@@ -86,11 +151,15 @@ export type ViewState =
86
151
  export type SelectProjectRuntime = {
87
152
  readonly running: boolean
88
153
  readonly sshSessions: number
154
+ readonly startedAtIso: string | null
155
+ readonly startedAtEpochMs: number | null
89
156
  }
90
157
 
91
158
  export const menuItems: ReadonlyArray<{ readonly id: MenuAction; readonly label: string }> = [
92
159
  { id: { _tag: "Create" }, label: "Create project" },
93
160
  { id: { _tag: "Select" }, label: "Select project" },
161
+ { id: { _tag: "Auth" }, label: "Auth profiles (keys)" },
162
+ { id: { _tag: "ProjectAuth" }, label: "Project auth (bind labels)" },
94
163
  { id: { _tag: "Info" }, label: "Show connection info" },
95
164
  { id: { _tag: "Status" }, label: "docker compose ps" },
96
165
  { id: { _tag: "Logs" }, label: "docker compose logs --tail=200" },
@@ -9,7 +9,16 @@ import React, { useEffect, useMemo, useState } from "react"
9
9
 
10
10
  import { resolveCreateInputs } from "./menu-create.js"
11
11
  import { handleUserInput, type InputStage } from "./menu-input-handler.js"
12
- import { renderCreate, renderMenu, renderSelect, renderStepLabel } from "./menu-render.js"
12
+ import {
13
+ renderAuthMenu,
14
+ renderAuthPrompt,
15
+ renderCreate,
16
+ renderMenu,
17
+ renderProjectAuthMenu,
18
+ renderProjectAuthPrompt,
19
+ renderSelect,
20
+ renderStepLabel
21
+ } from "./menu-render.js"
13
22
  import { leaveTui, resumeTui } from "./menu-shared.js"
14
23
  import { defaultMenuStartupSnapshot, resolveMenuStartupSnapshot } from "./menu-startup.js"
15
24
  import { createSteps, type MenuEnv, type MenuState, type ViewState } from "./menu-types.js"
@@ -82,6 +91,22 @@ const renderView = (context: RenderContext) => {
82
91
  return renderCreate(label, context.view.buffer, context.message, context.view.step, currentDefaults)
83
92
  }
84
93
 
94
+ if (context.view._tag === "AuthMenu") {
95
+ return renderAuthMenu(context.view.snapshot, context.view.selected, context.message)
96
+ }
97
+
98
+ if (context.view._tag === "AuthPrompt") {
99
+ return renderAuthPrompt(context.view, context.message)
100
+ }
101
+
102
+ if (context.view._tag === "ProjectAuthMenu") {
103
+ return renderProjectAuthMenu(context.view.snapshot, context.view.selected, context.message)
104
+ }
105
+
106
+ if (context.view._tag === "ProjectAuthPrompt") {
107
+ return renderProjectAuthPrompt(context.view, context.message)
108
+ }
109
+
85
110
  return renderSelect({
86
111
  purpose: context.view.purpose,
87
112
  items: context.view.items,
@@ -1,6 +1,9 @@
1
1
  import type { Command, ParseError } from "@effect-template/lib/core/domain"
2
2
  import { createProject } from "@effect-template/lib/usecases/actions"
3
3
  import {
4
+ authClaudeLogin,
5
+ authClaudeLogout,
6
+ authClaudeStatus,
4
7
  authCodexLogin,
5
8
  authCodexLogout,
6
9
  authCodexStatus,
@@ -85,15 +88,18 @@ const handleNonBaseCommand = (command: NonBaseCommand) =>
85
88
  Match.when({ _tag: "AuthCodexLogin" }, (cmd) => authCodexLogin(cmd)),
86
89
  Match.when({ _tag: "AuthCodexStatus" }, (cmd) => authCodexStatus(cmd)),
87
90
  Match.when({ _tag: "AuthCodexLogout" }, (cmd) => authCodexLogout(cmd)),
91
+ Match.when({ _tag: "AuthClaudeLogin" }, (cmd) => authClaudeLogin(cmd)),
92
+ Match.when({ _tag: "AuthClaudeStatus" }, (cmd) => authClaudeStatus(cmd)),
93
+ Match.when({ _tag: "AuthClaudeLogout" }, (cmd) => authClaudeLogout(cmd)),
88
94
  Match.when({ _tag: "Attach" }, (cmd) => attachTmux(cmd)),
89
95
  Match.when({ _tag: "Panes" }, (cmd) => listTmuxPanes(cmd)),
90
96
  Match.when({ _tag: "SessionsList" }, (cmd) => listTerminalSessions(cmd)),
91
- Match.when({ _tag: "SessionsKill" }, (cmd) => killTerminalProcess(cmd)),
92
- Match.when({ _tag: "SessionsLogs" }, (cmd) => tailTerminalLogs(cmd)),
93
- Match.when({ _tag: "ScrapExport" }, (cmd) => exportScrap(cmd)),
94
- Match.when({ _tag: "ScrapImport" }, (cmd) => importScrap(cmd))
97
+ Match.when({ _tag: "SessionsKill" }, (cmd) => killTerminalProcess(cmd))
95
98
  )
96
99
  .pipe(
100
+ Match.when({ _tag: "SessionsLogs" }, (cmd) => tailTerminalLogs(cmd)),
101
+ Match.when({ _tag: "ScrapExport" }, (cmd) => exportScrap(cmd)),
102
+ Match.when({ _tag: "ScrapImport" }, (cmd) => importScrap(cmd)),
97
103
  Match.when({ _tag: "McpPlaywrightUp" }, (cmd) => mcpPlaywrightUp(cmd)),
98
104
  Match.exhaustive
99
105
  )
@@ -19,7 +19,7 @@ describe("renderEntrypoint auth bridge", () => {
19
19
  expect(entrypoint).toContain("GITHUB_TOKEN=\"${GITHUB_TOKEN:-${GH_TOKEN:-}}\"")
20
20
  expect(entrypoint).toContain("if [[ -n \"$GH_TOKEN\" || -n \"$GITHUB_TOKEN\" ]]; then")
21
21
  expect(entrypoint).toContain(String.raw`printf "export GITHUB_TOKEN=%q\n" "$EFFECTIVE_GITHUB_TOKEN"`)
22
- expect(entrypoint).toContain(String.raw`printf "%s\n" "GITHUB_TOKEN=$EFFECTIVE_GITHUB_TOKEN" >> "$SSH_ENV_PATH"`)
22
+ expect(entrypoint).toContain("docker_git_upsert_ssh_env \"GITHUB_TOKEN\" \"$EFFECTIVE_GITHUB_TOKEN\"")
23
23
  expect(entrypoint).toContain("GIT_CREDENTIAL_HELPER_PATH=\"/usr/local/bin/docker-git-credential-helper\"")
24
24
  expect(entrypoint).toContain("token=\"$GITHUB_TOKEN\"")
25
25
  expect(entrypoint).toContain("token=\"$GH_TOKEN\"")