@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.
- package/.package.json.release.bak +1 -1
- package/CHANGELOG.md +12 -0
- package/README.md +12 -7
- package/dist/main.js +24 -7
- package/dist/main.js.map +1 -1
- package/dist/src/docker-git/cli/parser-auth.js +32 -12
- package/dist/src/docker-git/cli/parser.js +1 -1
- package/dist/src/docker-git/cli/usage.js +4 -3
- package/dist/src/docker-git/menu-actions.js +23 -7
- package/dist/src/docker-git/menu-auth-data.js +90 -0
- package/dist/src/docker-git/menu-auth-helpers.js +20 -0
- package/dist/src/docker-git/menu-auth.js +159 -0
- package/dist/src/docker-git/menu-buffer-input.js +9 -0
- package/dist/src/docker-git/menu-create.js +5 -9
- package/dist/src/docker-git/menu-input-handler.js +70 -28
- package/dist/src/docker-git/menu-input-utils.js +47 -0
- package/dist/src/docker-git/menu-labeled-env.js +33 -0
- package/dist/src/docker-git/menu-project-auth-claude.js +43 -0
- package/dist/src/docker-git/menu-project-auth-data.js +165 -0
- package/dist/src/docker-git/menu-project-auth.js +124 -0
- package/dist/src/docker-git/menu-render-auth.js +45 -0
- package/dist/src/docker-git/menu-render-common.js +26 -0
- package/dist/src/docker-git/menu-render-layout.js +14 -0
- package/dist/src/docker-git/menu-render-project-auth.js +37 -0
- package/dist/src/docker-git/menu-render-select.js +11 -4
- package/dist/src/docker-git/menu-render.js +4 -13
- package/dist/src/docker-git/menu-select-actions.js +66 -0
- package/dist/src/docker-git/menu-select-view.js +15 -0
- package/dist/src/docker-git/menu-select.js +11 -75
- package/dist/src/docker-git/menu-shared.js +86 -17
- package/dist/src/docker-git/menu-types.js +3 -1
- package/dist/src/docker-git/menu.js +13 -1
- package/dist/src/docker-git/program.js +3 -3
- package/package.json +1 -1
- package/src/docker-git/cli/parser-auth.ts +46 -16
- package/src/docker-git/cli/parser-mcp-playwright.ts +0 -1
- package/src/docker-git/cli/parser.ts +1 -1
- package/src/docker-git/cli/usage.ts +4 -3
- package/src/docker-git/menu-actions.ts +31 -12
- package/src/docker-git/menu-auth-data.ts +184 -0
- package/src/docker-git/menu-auth-helpers.ts +30 -0
- package/src/docker-git/menu-auth.ts +311 -0
- package/src/docker-git/menu-buffer-input.ts +18 -0
- package/src/docker-git/menu-create.ts +5 -11
- package/src/docker-git/menu-input-handler.ts +104 -28
- package/src/docker-git/menu-input-utils.ts +85 -0
- package/src/docker-git/menu-labeled-env.ts +37 -0
- package/src/docker-git/menu-project-auth-claude.ts +70 -0
- package/src/docker-git/menu-project-auth-data.ts +292 -0
- package/src/docker-git/menu-project-auth.ts +271 -0
- package/src/docker-git/menu-render-auth.ts +65 -0
- package/src/docker-git/menu-render-common.ts +67 -0
- package/src/docker-git/menu-render-layout.ts +30 -0
- package/src/docker-git/menu-render-project-auth.ts +70 -0
- package/src/docker-git/menu-render-select.ts +12 -2
- package/src/docker-git/menu-render.ts +5 -29
- package/src/docker-git/menu-select-actions.ts +150 -0
- package/src/docker-git/menu-select-load.ts +1 -1
- package/src/docker-git/menu-select-view.ts +25 -0
- package/src/docker-git/menu-select.ts +21 -167
- package/src/docker-git/menu-shared.ts +135 -20
- package/src/docker-git/menu-types.ts +70 -3
- package/src/docker-git/menu.ts +26 -1
- package/src/docker-git/program.ts +10 -4
- package/tests/docker-git/entrypoint-auth.test.ts +1 -1
|
@@ -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
17
|
import { loadSelectView } from "./menu-select-load.js"
|
|
17
|
-
import {
|
|
18
|
-
import { type MenuEnv, type MenuRunner, type MenuState, type
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -7,6 +7,7 @@ import { Effect, Either, Match, pipe } from "effect"
|
|
|
7
7
|
import { parseArgs } from "./cli/parser.js"
|
|
8
8
|
import { formatParseError, usageText } from "./cli/usage.js"
|
|
9
9
|
|
|
10
|
+
import { nextBufferValue } from "./menu-buffer-input.js"
|
|
10
11
|
import { resetToMenu } from "./menu-shared.js"
|
|
11
12
|
import {
|
|
12
13
|
type CreateInputs,
|
|
@@ -45,7 +46,7 @@ type CreateReturnContext = CreateContext & {
|
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
export const buildCreateArgs = (input: CreateInputs): ReadonlyArray<string> => {
|
|
48
|
-
const args: Array<string> = ["create", "--repo-url", input.repoUrl
|
|
49
|
+
const args: Array<string> = ["create", "--repo-url", input.repoUrl]
|
|
49
50
|
if (input.repoRef.length > 0) {
|
|
50
51
|
args.push("--repo-ref", input.repoRef)
|
|
51
52
|
}
|
|
@@ -106,14 +107,12 @@ export const resolveCreateInputs = (
|
|
|
106
107
|
): CreateInputs => {
|
|
107
108
|
const repoUrl = values.repoUrl ?? ""
|
|
108
109
|
const resolvedRepoRef = repoUrl.length > 0 ? resolveRepoInput(repoUrl).repoRef : undefined
|
|
109
|
-
const secretsRoot = values.secretsRoot ?? joinPath(defaultProjectsRoot(cwd), "secrets")
|
|
110
110
|
const outDir = values.outDir ?? (repoUrl.length > 0 ? resolveDefaultOutDir(cwd, repoUrl) : "")
|
|
111
111
|
|
|
112
112
|
return {
|
|
113
113
|
repoUrl,
|
|
114
114
|
repoRef: values.repoRef ?? resolvedRepoRef ?? "main",
|
|
115
115
|
outDir,
|
|
116
|
-
secretsRoot,
|
|
117
116
|
runUp: values.runUp !== false,
|
|
118
117
|
enableMcpPlaywright: values.enableMcpPlaywright === true,
|
|
119
118
|
force: values.force === true,
|
|
@@ -308,13 +307,8 @@ export const handleCreateInput = (
|
|
|
308
307
|
handleCreateReturn({ ...context, view })
|
|
309
308
|
return
|
|
310
309
|
}
|
|
311
|
-
|
|
312
|
-
if (
|
|
313
|
-
context.setView({ ...view, buffer:
|
|
314
|
-
return
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
if (input.length > 0) {
|
|
318
|
-
context.setView({ ...view, buffer: view.buffer + input })
|
|
310
|
+
const nextBuffer = nextBufferValue(input, key, view.buffer)
|
|
311
|
+
if (nextBuffer !== null) {
|
|
312
|
+
context.setView({ ...view, buffer: nextBuffer })
|
|
319
313
|
}
|
|
320
314
|
}
|