@prover-coder-ai/docker-git 1.0.16 → 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 (65) hide show
  1. package/.package.json.release.bak +1 -1
  2. package/CHANGELOG.md +6 -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 +23 -7
  10. package/dist/src/docker-git/menu-auth-data.js +90 -0
  11. package/dist/src/docker-git/menu-auth-helpers.js +20 -0
  12. package/dist/src/docker-git/menu-auth.js +159 -0
  13. package/dist/src/docker-git/menu-buffer-input.js +9 -0
  14. package/dist/src/docker-git/menu-create.js +5 -9
  15. package/dist/src/docker-git/menu-input-handler.js +70 -28
  16. package/dist/src/docker-git/menu-input-utils.js +47 -0
  17. package/dist/src/docker-git/menu-labeled-env.js +33 -0
  18. package/dist/src/docker-git/menu-project-auth-claude.js +43 -0
  19. package/dist/src/docker-git/menu-project-auth-data.js +165 -0
  20. package/dist/src/docker-git/menu-project-auth.js +124 -0
  21. package/dist/src/docker-git/menu-render-auth.js +45 -0
  22. package/dist/src/docker-git/menu-render-common.js +26 -0
  23. package/dist/src/docker-git/menu-render-layout.js +14 -0
  24. package/dist/src/docker-git/menu-render-project-auth.js +37 -0
  25. package/dist/src/docker-git/menu-render-select.js +10 -3
  26. package/dist/src/docker-git/menu-render.js +4 -13
  27. package/dist/src/docker-git/menu-select-actions.js +66 -0
  28. package/dist/src/docker-git/menu-select-view.js +15 -0
  29. package/dist/src/docker-git/menu-select.js +11 -75
  30. package/dist/src/docker-git/menu-shared.js +86 -17
  31. package/dist/src/docker-git/menu-types.js +2 -0
  32. package/dist/src/docker-git/menu.js +13 -1
  33. package/dist/src/docker-git/program.js +3 -3
  34. package/package.json +1 -1
  35. package/src/docker-git/cli/parser-auth.ts +46 -16
  36. package/src/docker-git/cli/parser-mcp-playwright.ts +0 -1
  37. package/src/docker-git/cli/parser.ts +1 -1
  38. package/src/docker-git/cli/usage.ts +4 -3
  39. package/src/docker-git/menu-actions.ts +31 -12
  40. package/src/docker-git/menu-auth-data.ts +184 -0
  41. package/src/docker-git/menu-auth-helpers.ts +30 -0
  42. package/src/docker-git/menu-auth.ts +311 -0
  43. package/src/docker-git/menu-buffer-input.ts +18 -0
  44. package/src/docker-git/menu-create.ts +5 -11
  45. package/src/docker-git/menu-input-handler.ts +104 -28
  46. package/src/docker-git/menu-input-utils.ts +85 -0
  47. package/src/docker-git/menu-labeled-env.ts +37 -0
  48. package/src/docker-git/menu-project-auth-claude.ts +70 -0
  49. package/src/docker-git/menu-project-auth-data.ts +292 -0
  50. package/src/docker-git/menu-project-auth.ts +271 -0
  51. package/src/docker-git/menu-render-auth.ts +65 -0
  52. package/src/docker-git/menu-render-common.ts +67 -0
  53. package/src/docker-git/menu-render-layout.ts +30 -0
  54. package/src/docker-git/menu-render-project-auth.ts +70 -0
  55. package/src/docker-git/menu-render-select.ts +11 -1
  56. package/src/docker-git/menu-render.ts +5 -29
  57. package/src/docker-git/menu-select-actions.ts +150 -0
  58. package/src/docker-git/menu-select-load.ts +1 -1
  59. package/src/docker-git/menu-select-view.ts +25 -0
  60. package/src/docker-git/menu-select.ts +21 -167
  61. package/src/docker-git/menu-shared.ts +135 -20
  62. package/src/docker-git/menu-types.ts +69 -2
  63. package/src/docker-git/menu.ts +26 -1
  64. package/src/docker-git/program.ts +10 -4
  65. package/tests/docker-git/entrypoint-auth.test.ts +1 -1
@@ -0,0 +1,47 @@
1
+ export const parseMenuIndex = (input) => {
2
+ const trimmed = input.trim();
3
+ if (trimmed.length === 0) {
4
+ return null;
5
+ }
6
+ const parsed = Number(trimmed);
7
+ if (!Number.isInteger(parsed)) {
8
+ return null;
9
+ }
10
+ const index = parsed - 1;
11
+ return index >= 0 ? index : null;
12
+ };
13
+ export const submitPromptStep = (view, steps, context, onCancel, onSubmit) => {
14
+ const step = steps[view.step];
15
+ if (!step) {
16
+ onCancel();
17
+ return;
18
+ }
19
+ const value = view.buffer.trim();
20
+ if (step.required && value.length === 0) {
21
+ context.setMessage(`${step.label} is required.`);
22
+ return;
23
+ }
24
+ const nextValues = { ...view.values, [step.key]: value };
25
+ const nextStep = view.step + 1;
26
+ if (nextStep < steps.length) {
27
+ context.setView({ ...view, step: nextStep, buffer: "", values: nextValues });
28
+ context.setMessage(null);
29
+ return;
30
+ }
31
+ onSubmit(nextValues);
32
+ };
33
+ export const handleMenuNumberInput = (input, context, actionByIndex, runAction) => {
34
+ const index = parseMenuIndex(input);
35
+ if (index === null) {
36
+ if (input.trim().length > 0) {
37
+ context.setMessage("Use arrows + Enter, or type a number from the list.");
38
+ }
39
+ return;
40
+ }
41
+ const action = actionByIndex(index);
42
+ if (action === null) {
43
+ context.setMessage(`Unknown action: ${input.trim()}`);
44
+ return;
45
+ }
46
+ runAction(action);
47
+ };
@@ -0,0 +1,33 @@
1
+ import { parseEnvEntries } from "@effect-template/lib/usecases/env-file";
2
+ export const normalizeLabel = (value) => {
3
+ const trimmed = value.trim();
4
+ if (trimmed.length === 0) {
5
+ return "";
6
+ }
7
+ const normalized = trimmed
8
+ .toUpperCase()
9
+ .replaceAll(/[^A-Z0-9]+/g, "_");
10
+ let start = 0;
11
+ while (start < normalized.length && normalized[start] === "_") {
12
+ start += 1;
13
+ }
14
+ let end = normalized.length;
15
+ while (end > start && normalized[end - 1] === "_") {
16
+ end -= 1;
17
+ }
18
+ const cleaned = normalized.slice(start, end);
19
+ return cleaned.length > 0 ? cleaned : "";
20
+ };
21
+ export const buildLabeledEnvKey = (baseKey, label) => {
22
+ const normalized = normalizeLabel(label);
23
+ if (normalized.length === 0 || normalized === "DEFAULT") {
24
+ return baseKey;
25
+ }
26
+ return `${baseKey}__${normalized}`;
27
+ };
28
+ export const countKeyEntries = (envText, baseKey) => {
29
+ const prefix = `${baseKey}__`;
30
+ return parseEnvEntries(envText)
31
+ .filter((entry) => entry.value.trim().length > 0 && (entry.key === baseKey || entry.key.startsWith(prefix)))
32
+ .length;
33
+ };
@@ -0,0 +1,43 @@
1
+ import { Effect } from "effect";
2
+ const oauthTokenFileName = ".oauth-token";
3
+ const legacyConfigFileName = ".config.json";
4
+ const hasFileAtPath = (fs, filePath) => Effect.gen(function* (_) {
5
+ const exists = yield* _(fs.exists(filePath));
6
+ if (!exists) {
7
+ return false;
8
+ }
9
+ const info = yield* _(fs.stat(filePath));
10
+ return info.type === "File";
11
+ });
12
+ const hasNonEmptyOauthToken = (fs, tokenPath) => Effect.gen(function* (_) {
13
+ const hasFile = yield* _(hasFileAtPath(fs, tokenPath));
14
+ if (!hasFile) {
15
+ return false;
16
+ }
17
+ const tokenValue = yield* _(fs.readFileString(tokenPath), Effect.orElseSucceed(() => ""));
18
+ return tokenValue.trim().length > 0;
19
+ });
20
+ const hasLegacyClaudeAuthFile = (fs, accountPath) => Effect.gen(function* (_) {
21
+ const entries = yield* _(fs.readDirectory(accountPath));
22
+ for (const entry of entries) {
23
+ if (!entry.startsWith(".claude") || !entry.endsWith(".json")) {
24
+ continue;
25
+ }
26
+ const isFile = yield* _(hasFileAtPath(fs, `${accountPath}/${entry}`));
27
+ if (isFile) {
28
+ return true;
29
+ }
30
+ }
31
+ return false;
32
+ });
33
+ export const hasClaudeAccountCredentials = (fs, accountPath) => hasFileAtPath(fs, `${accountPath}/${legacyConfigFileName}`).pipe(Effect.flatMap((hasConfig) => {
34
+ if (hasConfig) {
35
+ return Effect.succeed(true);
36
+ }
37
+ return hasNonEmptyOauthToken(fs, `${accountPath}/${oauthTokenFileName}`).pipe(Effect.flatMap((hasOauthToken) => {
38
+ if (hasOauthToken) {
39
+ return Effect.succeed(true);
40
+ }
41
+ return hasLegacyClaudeAuthFile(fs, accountPath);
42
+ }));
43
+ }));
@@ -0,0 +1,165 @@
1
+ import * as FileSystem from "@effect/platform/FileSystem";
2
+ import * as Path from "@effect/platform/Path";
3
+ import { Effect, Match, pipe } from "effect";
4
+ import { AuthError } from "@effect-template/lib/shell/errors";
5
+ import { normalizeAccountLabel } from "@effect-template/lib/usecases/auth-helpers";
6
+ import { ensureEnvFile, findEnvValue, readEnvText, upsertEnvKey } from "@effect-template/lib/usecases/env-file";
7
+ import { defaultProjectsRoot } from "@effect-template/lib/usecases/menu-helpers";
8
+ import { autoSyncState } from "@effect-template/lib/usecases/state-repo";
9
+ import { countAuthAccountDirectories } from "./menu-auth-helpers.js";
10
+ import { buildLabeledEnvKey, countKeyEntries, normalizeLabel } from "./menu-labeled-env.js";
11
+ import { hasClaudeAccountCredentials } from "./menu-project-auth-claude.js";
12
+ const projectAuthMenuItems = [
13
+ { action: "ProjectGithubConnect", label: "Project: GitHub connect label" },
14
+ { action: "ProjectGithubDisconnect", label: "Project: GitHub disconnect" },
15
+ { action: "ProjectGitConnect", label: "Project: Git connect label" },
16
+ { action: "ProjectGitDisconnect", label: "Project: Git disconnect" },
17
+ { action: "ProjectClaudeConnect", label: "Project: Claude connect label" },
18
+ { action: "ProjectClaudeDisconnect", label: "Project: Claude disconnect" },
19
+ { action: "Refresh", label: "Refresh snapshot" },
20
+ { action: "Back", label: "Back to main menu" }
21
+ ];
22
+ const flowSteps = {
23
+ ProjectGithubConnect: [
24
+ { key: "label", label: "Label (empty = default)", required: false, secret: false }
25
+ ],
26
+ ProjectGithubDisconnect: [],
27
+ ProjectGitConnect: [
28
+ { key: "label", label: "Label (empty = default)", required: false, secret: false }
29
+ ],
30
+ ProjectGitDisconnect: [],
31
+ ProjectClaudeConnect: [
32
+ { key: "label", label: "Label (empty = default)", required: false, secret: false }
33
+ ],
34
+ ProjectClaudeDisconnect: []
35
+ };
36
+ const resolveCanonicalLabel = (value) => {
37
+ const normalized = normalizeLabel(value);
38
+ return normalized.length === 0 || normalized === "DEFAULT" ? "default" : normalized;
39
+ };
40
+ const githubTokenBaseKey = "GITHUB_TOKEN";
41
+ const gitTokenBaseKey = "GIT_AUTH_TOKEN";
42
+ const gitUserBaseKey = "GIT_AUTH_USER";
43
+ const projectGithubLabelKey = "GITHUB_AUTH_LABEL";
44
+ const projectGitLabelKey = "GIT_AUTH_LABEL";
45
+ const projectClaudeLabelKey = "CLAUDE_AUTH_LABEL";
46
+ const defaultGitUser = "x-access-token";
47
+ const buildGlobalEnvPath = (cwd) => `${defaultProjectsRoot(cwd)}/.orch/env/global.env`;
48
+ const buildClaudeAuthPath = (cwd) => `${defaultProjectsRoot(cwd)}/.orch/auth/claude`;
49
+ const loadProjectAuthEnvText = (project) => Effect.gen(function* (_) {
50
+ const fs = yield* _(FileSystem.FileSystem);
51
+ const path = yield* _(Path.Path);
52
+ const globalEnvPath = buildGlobalEnvPath(process.cwd());
53
+ const claudeAuthPath = buildClaudeAuthPath(process.cwd());
54
+ yield* _(ensureEnvFile(fs, path, globalEnvPath));
55
+ yield* _(ensureEnvFile(fs, path, project.envProjectPath));
56
+ const globalEnvText = yield* _(readEnvText(fs, globalEnvPath));
57
+ const projectEnvText = yield* _(readEnvText(fs, project.envProjectPath));
58
+ return {
59
+ fs,
60
+ path,
61
+ globalEnvPath,
62
+ projectEnvPath: project.envProjectPath,
63
+ claudeAuthPath,
64
+ globalEnvText,
65
+ projectEnvText
66
+ };
67
+ });
68
+ export const readProjectAuthSnapshot = (project) => pipe(loadProjectAuthEnvText(project), Effect.flatMap(({ claudeAuthPath, fs, globalEnvPath, globalEnvText, path, projectEnvPath, projectEnvText }) => pipe(countAuthAccountDirectories(fs, path, claudeAuthPath), Effect.map((claudeAuthEntries) => ({
69
+ projectDir: project.projectDir,
70
+ projectName: project.displayName,
71
+ envGlobalPath: globalEnvPath,
72
+ envProjectPath: projectEnvPath,
73
+ claudeAuthPath,
74
+ githubTokenEntries: countKeyEntries(globalEnvText, githubTokenBaseKey),
75
+ gitTokenEntries: countKeyEntries(globalEnvText, gitTokenBaseKey),
76
+ claudeAuthEntries,
77
+ activeGithubLabel: findEnvValue(projectEnvText, projectGithubLabelKey),
78
+ activeGitLabel: findEnvValue(projectEnvText, projectGitLabelKey),
79
+ activeClaudeLabel: findEnvValue(projectEnvText, projectClaudeLabelKey)
80
+ })))));
81
+ const missingSecret = (provider, label, envPath) => new AuthError({
82
+ message: `${provider} not connected: label '${label}' not found in ${envPath}`
83
+ });
84
+ const updateProjectGithubConnect = (spec) => {
85
+ const key = buildLabeledEnvKey(githubTokenBaseKey, spec.rawLabel);
86
+ const token = findEnvValue(spec.globalEnvText, key);
87
+ if (token === null) {
88
+ return Effect.fail(missingSecret("GitHub token", spec.canonicalLabel, spec.globalEnvPath));
89
+ }
90
+ const withGitToken = upsertEnvKey(spec.projectEnvText, "GIT_AUTH_TOKEN", token);
91
+ const withGhToken = upsertEnvKey(withGitToken, "GH_TOKEN", token);
92
+ const withoutGitLabel = upsertEnvKey(withGhToken, projectGitLabelKey, "");
93
+ return Effect.succeed(upsertEnvKey(withoutGitLabel, projectGithubLabelKey, spec.canonicalLabel));
94
+ };
95
+ const clearProjectGitLabels = (envText) => {
96
+ const withoutGhToken = upsertEnvKey(envText, "GH_TOKEN", "");
97
+ const withoutGitLabel = upsertEnvKey(withoutGhToken, projectGitLabelKey, "");
98
+ return upsertEnvKey(withoutGitLabel, projectGithubLabelKey, "");
99
+ };
100
+ const updateProjectGithubDisconnect = (spec) => {
101
+ const withoutGitToken = upsertEnvKey(spec.projectEnvText, "GIT_AUTH_TOKEN", "");
102
+ return Effect.succeed(clearProjectGitLabels(withoutGitToken));
103
+ };
104
+ const updateProjectGitConnect = (spec) => {
105
+ const tokenKey = buildLabeledEnvKey(gitTokenBaseKey, spec.rawLabel);
106
+ const userKey = buildLabeledEnvKey(gitUserBaseKey, spec.rawLabel);
107
+ const token = findEnvValue(spec.globalEnvText, tokenKey);
108
+ if (token === null) {
109
+ return Effect.fail(missingSecret("Git credentials", spec.canonicalLabel, spec.globalEnvPath));
110
+ }
111
+ const defaultUser = findEnvValue(spec.globalEnvText, gitUserBaseKey) ?? defaultGitUser;
112
+ const user = findEnvValue(spec.globalEnvText, userKey) ?? defaultUser;
113
+ const withToken = upsertEnvKey(spec.projectEnvText, "GIT_AUTH_TOKEN", token);
114
+ const withUser = upsertEnvKey(withToken, "GIT_AUTH_USER", user);
115
+ const withGhToken = upsertEnvKey(withUser, "GH_TOKEN", token);
116
+ const withGitLabel = upsertEnvKey(withGhToken, projectGitLabelKey, spec.canonicalLabel);
117
+ return Effect.succeed(upsertEnvKey(withGitLabel, projectGithubLabelKey, spec.canonicalLabel));
118
+ };
119
+ const updateProjectGitDisconnect = (spec) => {
120
+ const withoutToken = upsertEnvKey(spec.projectEnvText, "GIT_AUTH_TOKEN", "");
121
+ const withoutUser = upsertEnvKey(withoutToken, "GIT_AUTH_USER", "");
122
+ return Effect.succeed(clearProjectGitLabels(withoutUser));
123
+ };
124
+ const updateProjectClaudeConnect = (spec) => {
125
+ const accountLabel = normalizeAccountLabel(spec.rawLabel, "default");
126
+ const accountPath = `${spec.claudeAuthPath}/${accountLabel}`;
127
+ return Effect.gen(function* (_) {
128
+ const exists = yield* _(spec.fs.exists(accountPath));
129
+ if (!exists) {
130
+ return yield* _(Effect.fail(missingSecret("Claude Code login", spec.canonicalLabel, spec.claudeAuthPath)));
131
+ }
132
+ const hasCredentials = yield* _(hasClaudeAccountCredentials(spec.fs, accountPath), Effect.orElseSucceed(() => false));
133
+ if (hasCredentials) {
134
+ return upsertEnvKey(spec.projectEnvText, projectClaudeLabelKey, spec.canonicalLabel);
135
+ }
136
+ return yield* _(Effect.fail(missingSecret("Claude Code login", spec.canonicalLabel, spec.claudeAuthPath)));
137
+ });
138
+ };
139
+ const updateProjectClaudeDisconnect = (spec) => {
140
+ return Effect.succeed(upsertEnvKey(spec.projectEnvText, projectClaudeLabelKey, ""));
141
+ };
142
+ const resolveProjectEnvUpdate = (flow, spec) => Match.value(flow).pipe(Match.when("ProjectGithubConnect", () => updateProjectGithubConnect(spec)), Match.when("ProjectGithubDisconnect", () => updateProjectGithubDisconnect(spec)), Match.when("ProjectGitConnect", () => updateProjectGitConnect(spec)), Match.when("ProjectGitDisconnect", () => updateProjectGitDisconnect(spec)), Match.when("ProjectClaudeConnect", () => updateProjectClaudeConnect(spec)), Match.when("ProjectClaudeDisconnect", () => updateProjectClaudeDisconnect(spec)), Match.exhaustive);
143
+ export const writeProjectAuthFlow = (project, flow, values) => pipe(loadProjectAuthEnvText(project), Effect.flatMap(({ claudeAuthPath, fs, globalEnvPath, globalEnvText, projectEnvPath, projectEnvText }) => {
144
+ const rawLabel = values["label"] ?? "";
145
+ const canonicalLabel = resolveCanonicalLabel(rawLabel);
146
+ const spec = {
147
+ fs,
148
+ rawLabel,
149
+ canonicalLabel,
150
+ globalEnvPath,
151
+ globalEnvText,
152
+ projectEnvText,
153
+ claudeAuthPath
154
+ };
155
+ const nextProjectEnv = resolveProjectEnvUpdate(flow, spec);
156
+ const syncMessage = Match.value(flow).pipe(Match.when("ProjectGithubConnect", () => `chore(state): project auth gh ${canonicalLabel} ${project.displayName}`), Match.when("ProjectGithubDisconnect", () => `chore(state): project auth gh logout ${project.displayName}`), Match.when("ProjectGitConnect", () => `chore(state): project auth git ${canonicalLabel} ${project.displayName}`), Match.when("ProjectGitDisconnect", () => `chore(state): project auth git logout ${project.displayName}`), Match.when("ProjectClaudeConnect", () => `chore(state): project auth claude ${canonicalLabel} ${project.displayName}`), Match.when("ProjectClaudeDisconnect", () => `chore(state): project auth claude logout ${project.displayName}`), Match.exhaustive);
157
+ return pipe(nextProjectEnv, Effect.flatMap((nextText) => fs.writeFileString(projectEnvPath, nextText)), Effect.zipRight(autoSyncState(syncMessage)));
158
+ }), Effect.asVoid);
159
+ export const projectAuthViewSteps = (flow) => flowSteps[flow];
160
+ export const projectAuthMenuLabels = () => projectAuthMenuItems.map((item) => item.label);
161
+ export const projectAuthMenuActionByIndex = (index) => {
162
+ const item = projectAuthMenuItems[index];
163
+ return item ? item.action : null;
164
+ };
165
+ export const projectAuthMenuSize = () => projectAuthMenuItems.length;
@@ -0,0 +1,124 @@
1
+ import { Effect, Match, pipe } from "effect";
2
+ import { nextBufferValue } from "./menu-buffer-input.js";
3
+ import { handleMenuNumberInput, submitPromptStep } from "./menu-input-utils.js";
4
+ import { projectAuthMenuActionByIndex, projectAuthMenuSize, projectAuthViewSteps, readProjectAuthSnapshot, writeProjectAuthFlow } from "./menu-project-auth-data.js";
5
+ import { resetToMenu } from "./menu-shared.js";
6
+ const startProjectAuthMenu = (project, snapshot, context) => {
7
+ context.setView({ _tag: "ProjectAuthMenu", selected: 0, project, snapshot });
8
+ context.setMessage(null);
9
+ };
10
+ const startProjectAuthPrompt = (project, snapshot, flow, context) => {
11
+ context.setView({
12
+ _tag: "ProjectAuthPrompt",
13
+ flow,
14
+ step: 0,
15
+ buffer: "",
16
+ values: {},
17
+ project,
18
+ snapshot
19
+ });
20
+ context.setMessage(null);
21
+ };
22
+ const loadProjectAuthMenuView = (project, context) => pipe(readProjectAuthSnapshot(project), Effect.tap((snapshot) => Effect.sync(() => {
23
+ startProjectAuthMenu(project, snapshot, context);
24
+ })), Effect.asVoid);
25
+ const successMessage = (flow, label) => Match.value(flow).pipe(Match.when("ProjectGithubConnect", () => `Connected GitHub label (${label}) to project.`), Match.when("ProjectGithubDisconnect", () => "Disconnected GitHub from project."), Match.when("ProjectGitConnect", () => `Connected Git label (${label}) to project.`), Match.when("ProjectGitDisconnect", () => "Disconnected Git from project."), Match.when("ProjectClaudeConnect", () => `Connected Claude label (${label}) to project.`), Match.when("ProjectClaudeDisconnect", () => "Disconnected Claude from project."), Match.exhaustive);
26
+ const runProjectAuthEffect = (project, flow, values, label, context) => {
27
+ context.runner.runEffect(pipe(writeProjectAuthFlow(project, flow, values), Effect.zipRight(readProjectAuthSnapshot(project)), Effect.tap((snapshot) => Effect.sync(() => {
28
+ startProjectAuthMenu(project, snapshot, context);
29
+ context.setMessage(successMessage(flow, label));
30
+ })), Effect.asVoid));
31
+ };
32
+ const submitProjectAuthPrompt = (view, context) => {
33
+ const steps = projectAuthViewSteps(view.flow);
34
+ submitPromptStep(view, steps, context, () => {
35
+ startProjectAuthMenu(view.project, view.snapshot, context);
36
+ }, (nextValues) => {
37
+ const rawLabel = (nextValues["label"] ?? "").trim();
38
+ const label = rawLabel.length > 0 ? rawLabel : "default";
39
+ runProjectAuthEffect(view.project, view.flow, nextValues, label, context);
40
+ });
41
+ };
42
+ const runProjectAuthAction = (action, view, context) => {
43
+ if (action === "Back") {
44
+ resetToMenu(context);
45
+ return;
46
+ }
47
+ if (action === "Refresh") {
48
+ context.runner.runEffect(loadProjectAuthMenuView(view.project, context));
49
+ return;
50
+ }
51
+ if (action === "ProjectGithubDisconnect" || action === "ProjectGitDisconnect" || action === "ProjectClaudeDisconnect") {
52
+ runProjectAuthEffect(view.project, action, {}, "default", context);
53
+ return;
54
+ }
55
+ startProjectAuthPrompt(view.project, view.snapshot, action, context);
56
+ };
57
+ const setProjectAuthMenuSelection = (view, selected, context) => {
58
+ context.setView({ ...view, selected });
59
+ };
60
+ const shiftProjectAuthMenuSelection = (view, delta, context) => {
61
+ const menuSize = projectAuthMenuSize();
62
+ const selected = (view.selected + delta + menuSize) % menuSize;
63
+ setProjectAuthMenuSelection(view, selected, context);
64
+ };
65
+ const runProjectAuthMenuSelection = (selected, view, context) => {
66
+ const action = projectAuthMenuActionByIndex(selected);
67
+ if (action === null) {
68
+ return;
69
+ }
70
+ runProjectAuthAction(action, view, context);
71
+ };
72
+ const handleProjectAuthMenuNumberInput = (input, view, context) => {
73
+ handleMenuNumberInput(input, context, projectAuthMenuActionByIndex, (action) => {
74
+ runProjectAuthAction(action, view, context);
75
+ });
76
+ };
77
+ const handleProjectAuthMenuInput = (input, key, view, context) => {
78
+ if (key.escape) {
79
+ resetToMenu(context);
80
+ return;
81
+ }
82
+ if (key.upArrow) {
83
+ shiftProjectAuthMenuSelection(view, -1, context);
84
+ return;
85
+ }
86
+ if (key.downArrow) {
87
+ shiftProjectAuthMenuSelection(view, 1, context);
88
+ return;
89
+ }
90
+ if (key.return) {
91
+ runProjectAuthMenuSelection(view.selected, view, context);
92
+ return;
93
+ }
94
+ handleProjectAuthMenuNumberInput(input, view, context);
95
+ };
96
+ const setProjectAuthPromptBuffer = (args) => {
97
+ const nextBuffer = nextBufferValue(args.input, args.key, args.view.buffer);
98
+ if (nextBuffer === null) {
99
+ return;
100
+ }
101
+ args.context.setView({ ...args.view, buffer: nextBuffer });
102
+ };
103
+ const handleProjectAuthPromptInput = (input, key, view, context) => {
104
+ if (key.escape) {
105
+ startProjectAuthMenu(view.project, view.snapshot, context);
106
+ return;
107
+ }
108
+ if (key.return) {
109
+ submitProjectAuthPrompt(view, context);
110
+ return;
111
+ }
112
+ setProjectAuthPromptBuffer({ input, key, view, context });
113
+ };
114
+ export const openProjectAuthMenu = (context) => {
115
+ context.setMessage(`Loading project auth (${context.project.displayName})...`);
116
+ context.runner.runEffect(loadProjectAuthMenuView(context.project, context));
117
+ };
118
+ export const handleProjectAuthInput = (input, key, view, context) => {
119
+ if (view._tag === "ProjectAuthMenu") {
120
+ handleProjectAuthMenuInput(input, key, view, context);
121
+ return;
122
+ }
123
+ handleProjectAuthPromptInput(input, key, view, context);
124
+ };
@@ -0,0 +1,45 @@
1
+ import { Box, Text } from "ink";
2
+ import React from "react";
3
+ import { authMenuLabels, authViewSteps, authViewTitle } from "./menu-auth-data.js";
4
+ import { renderMenuHelp, renderPromptLayout, renderSelectableMenuList, resolvePromptState } from "./menu-render-common.js";
5
+ import { renderLayout } from "./menu-render-layout.js";
6
+ const renderCountLine = (title, count) => `${title}: ${count}`;
7
+ export const renderAuthMenu = (snapshot, selected, message) => {
8
+ const el = React.createElement;
9
+ const list = renderSelectableMenuList(authMenuLabels(), selected);
10
+ return renderLayout("docker-git / Auth profiles", [
11
+ el(Text, null, `Global env: ${snapshot.globalEnvPath}`),
12
+ el(Text, null, `Claude auth: ${snapshot.claudeAuthPath}`),
13
+ el(Text, { color: "gray" }, renderCountLine("Entries", snapshot.totalEntries)),
14
+ el(Text, { color: "gray" }, renderCountLine("GitHub tokens", snapshot.githubTokenEntries)),
15
+ el(Text, { color: "gray" }, renderCountLine("Git tokens", snapshot.gitTokenEntries)),
16
+ el(Text, { color: "gray" }, renderCountLine("Git users", snapshot.gitUserEntries)),
17
+ el(Text, { color: "gray" }, renderCountLine("Claude logins", snapshot.claudeAuthEntries)),
18
+ el(Box, { flexDirection: "column", marginTop: 1 }, ...list),
19
+ renderMenuHelp("Use arrows + Enter, or type a number.")
20
+ ], message);
21
+ };
22
+ export const renderAuthPrompt = (view, message) => {
23
+ const el = React.createElement;
24
+ const { prompt, visibleBuffer } = resolvePromptState(authViewSteps(view.flow), view.step, view.buffer);
25
+ let helpLine = "Enter = next, Esc = cancel.";
26
+ if (view.flow === "GithubOauth" || view.flow === "ClaudeOauth") {
27
+ helpLine = "Enter = start OAuth, Esc = cancel.";
28
+ }
29
+ else if (view.flow === "ClaudeLogout") {
30
+ helpLine = "Enter = logout, Esc = cancel.";
31
+ }
32
+ return renderPromptLayout({
33
+ title: `docker-git / Auth / ${authViewTitle(view.flow)}`,
34
+ header: [
35
+ el(Text, { color: "gray" }, `Global env: ${view.snapshot.globalEnvPath}`),
36
+ ...(view.flow === "ClaudeOauth" || view.flow === "ClaudeLogout"
37
+ ? [el(Text, { color: "gray" }, `Claude auth: ${view.snapshot.claudeAuthPath}`)]
38
+ : [])
39
+ ],
40
+ prompt,
41
+ visibleBuffer,
42
+ helpLine,
43
+ message
44
+ });
45
+ };
@@ -0,0 +1,26 @@
1
+ import { Box, Text } from "ink";
2
+ import React from "react";
3
+ import { renderLayout } from "./menu-render-layout.js";
4
+ export const renderSelectableMenuList = (labels, selected) => {
5
+ const el = React.createElement;
6
+ return labels.map((label, index) => el(Text, { key: `${index}-${label}`, color: index === selected ? "green" : "white" }, `${index === selected ? ">" : " "} ${index + 1}) ${label}`));
7
+ };
8
+ export const renderMenuHelp = (primaryLine) => {
9
+ const el = React.createElement;
10
+ return el(Box, { marginTop: 1, flexDirection: "column" }, el(Text, { color: "gray" }, primaryLine), el(Text, { color: "gray" }, "Esc returns to the main menu."));
11
+ };
12
+ export const resolvePromptState = (steps, step, buffer) => {
13
+ const current = steps[step];
14
+ const prompt = current?.label ?? "Value";
15
+ const isSecret = current?.secret === true;
16
+ const visibleBuffer = isSecret ? "*".repeat(buffer.length) : buffer;
17
+ return { prompt, visibleBuffer };
18
+ };
19
+ export const renderPromptLayout = (args) => {
20
+ const el = React.createElement;
21
+ return renderLayout(args.title, [
22
+ ...args.header,
23
+ el(Box, { marginTop: 1 }, el(Text, null, `${args.prompt}: `), el(Text, { color: "green" }, args.visibleBuffer)),
24
+ el(Box, { marginTop: 1, flexDirection: "column" }, el(Text, { color: "gray" }, args.helpLine))
25
+ ], args.message);
26
+ };
@@ -0,0 +1,14 @@
1
+ import { Box, Text } from "ink";
2
+ import React from "react";
3
+ const renderMessage = (message) => {
4
+ if (!message) {
5
+ return null;
6
+ }
7
+ return React.createElement(Box, { marginTop: 1 }, React.createElement(Text, { color: "magenta" }, message));
8
+ };
9
+ export const renderLayout = (title, body, message) => {
10
+ const el = React.createElement;
11
+ const messageView = renderMessage(message);
12
+ const tail = messageView ? [messageView] : [];
13
+ return el(Box, { flexDirection: "column", padding: 1, borderStyle: "round" }, el(Text, { color: "cyan", bold: true }, title), ...body, ...tail);
14
+ };
@@ -0,0 +1,37 @@
1
+ import { Box, Text } from "ink";
2
+ import React from "react";
3
+ import { projectAuthMenuLabels, projectAuthViewSteps } from "./menu-project-auth-data.js";
4
+ import { renderMenuHelp, renderPromptLayout, renderSelectableMenuList, resolvePromptState } from "./menu-render-common.js";
5
+ import { renderLayout } from "./menu-render-layout.js";
6
+ const renderActiveLabel = (value) => value ?? "(not set)";
7
+ const renderCountLine = (title, count) => `${title}: ${count}`;
8
+ export const renderProjectAuthMenu = (snapshot, selected, message) => {
9
+ const el = React.createElement;
10
+ const list = renderSelectableMenuList(projectAuthMenuLabels(), selected);
11
+ return renderLayout("docker-git / Project auth", [
12
+ el(Text, null, `Project: ${snapshot.projectName}`),
13
+ el(Text, { color: "gray" }, `Dir: ${snapshot.projectDir}`),
14
+ el(Text, { color: "gray" }, `Project env: ${snapshot.envProjectPath}`),
15
+ el(Text, { color: "gray" }, `Global env: ${snapshot.envGlobalPath}`),
16
+ el(Text, { color: "gray" }, `Claude auth: ${snapshot.claudeAuthPath}`),
17
+ el(Box, { marginTop: 1, flexDirection: "column" }, el(Text, { color: "gray" }, `GitHub label: ${renderActiveLabel(snapshot.activeGithubLabel)}`), el(Text, { color: "gray" }, renderCountLine("Available GitHub tokens", snapshot.githubTokenEntries)), el(Text, { color: "gray" }, `Git label: ${renderActiveLabel(snapshot.activeGitLabel)}`), el(Text, { color: "gray" }, renderCountLine("Available Git tokens", snapshot.gitTokenEntries)), el(Text, { color: "gray" }, `Claude label: ${renderActiveLabel(snapshot.activeClaudeLabel)}`), el(Text, { color: "gray" }, renderCountLine("Available Claude logins", snapshot.claudeAuthEntries))),
18
+ el(Box, { flexDirection: "column", marginTop: 1 }, ...list),
19
+ renderMenuHelp("Use arrows + Enter, or type a number from the list.")
20
+ ], message);
21
+ };
22
+ export const renderProjectAuthPrompt = (view, message) => {
23
+ const el = React.createElement;
24
+ const { prompt, visibleBuffer } = resolvePromptState(projectAuthViewSteps(view.flow), view.step, view.buffer);
25
+ return renderPromptLayout({
26
+ title: "docker-git / Project auth / Set label",
27
+ header: [
28
+ el(Text, { color: "gray" }, `Project: ${view.snapshot.projectName}`),
29
+ el(Text, { color: "gray" }, `Project env: ${view.snapshot.envProjectPath}`),
30
+ el(Text, { color: "gray" }, `Global env: ${view.snapshot.envGlobalPath}`)
31
+ ],
32
+ prompt,
33
+ visibleBuffer,
34
+ helpLine: "Enter = apply, Esc = cancel.",
35
+ message
36
+ });
37
+ };
@@ -26,8 +26,8 @@ const renderStartedAtCompact = (runtime) => runtime.startedAtEpochMs === null ?
26
26
  const renderStartedAtDetailed = (runtime) => runtime.startedAtEpochMs === null ? "not available" : formatUtcTimestamp(runtime.startedAtEpochMs, true);
27
27
  const runtimeForProject = (runtimeByProject, item) => runtimeByProject[item.projectDir] ?? stoppedRuntime();
28
28
  const renderRuntimeLabel = (runtime) => `${runtime.running ? "running" : "stopped"}, ssh=${runtime.sshSessions}, started=${renderStartedAtCompact(runtime)}`;
29
- export const selectTitle = (purpose) => Match.value(purpose).pipe(Match.when("Connect", () => "docker-git / Select project"), Match.when("Down", () => "docker-git / Stop container"), Match.when("Info", () => "docker-git / Show connection info"), Match.when("Delete", () => "docker-git / Delete project"), Match.exhaustive);
30
- export const selectHint = (purpose, connectEnableMcpPlaywright) => Match.value(purpose).pipe(Match.when("Connect", () => `Enter = select + SSH, P = toggle Playwright MCP (${connectEnableMcpPlaywright ? "on" : "off"}), Esc = back`), Match.when("Down", () => "Enter = stop container, Esc = back"), Match.when("Info", () => "Use arrows to browse details, Enter = set active, Esc = back"), Match.when("Delete", () => "Enter = ask/confirm delete, Esc = cancel"), Match.exhaustive);
29
+ export const selectTitle = (purpose) => Match.value(purpose).pipe(Match.when("Connect", () => "docker-git / Select project"), Match.when("Auth", () => "docker-git / Project auth"), Match.when("Down", () => "docker-git / Stop container"), Match.when("Info", () => "docker-git / Show connection info"), Match.when("Delete", () => "docker-git / Delete project"), Match.exhaustive);
30
+ export const selectHint = (purpose, connectEnableMcpPlaywright) => Match.value(purpose).pipe(Match.when("Connect", () => `Enter = select + SSH, P = toggle Playwright MCP (${connectEnableMcpPlaywright ? "on" : "off"}), Esc = back`), Match.when("Auth", () => "Enter = open project auth menu, Esc = back"), Match.when("Down", () => "Enter = stop container, Esc = back"), Match.when("Info", () => "Use arrows to browse details, Enter = set active, Esc = back"), Match.when("Delete", () => "Enter = ask/confirm delete, Esc = cancel"), Match.exhaustive);
31
31
  export const buildSelectLabels = (items, selected, purpose, runtimeByProject) => items.map((item, index) => {
32
32
  const prefix = index === selected ? ">" : " ";
33
33
  const refLabel = formatRepoRef(item.repoRef);
@@ -92,7 +92,14 @@ export const renderSelectDetails = (el, purpose, item, runtimeByProject, connect
92
92
  }
93
93
  const context = buildDetailsContext(item, runtimeByProject);
94
94
  const common = commonRows(el, context);
95
- return Match.value(purpose).pipe(Match.when("Connect", () => renderConnectDetails(el, context, common, connectEnableMcpPlaywright)), Match.when("Info", () => renderInfoDetails(el, context, common)), Match.when("Down", () => [
95
+ return Match.value(purpose).pipe(Match.when("Connect", () => renderConnectDetails(el, context, common, connectEnableMcpPlaywright)), Match.when("Auth", () => [
96
+ titleRow(el, "Project auth"),
97
+ ...common,
98
+ el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`),
99
+ el(Text, { wrap: "wrap" }, `Env global: ${context.item.envGlobalPath}`),
100
+ el(Text, { wrap: "wrap" }, `Env project: ${context.item.envProjectPath}`),
101
+ el(Text, { color: "gray", wrap: "wrap" }, "Press Enter to manage labels for this project.")
102
+ ]), Match.when("Info", () => renderInfoDetails(el, context, common)), Match.when("Down", () => [
96
103
  titleRow(el, "Stop container"),
97
104
  ...common,
98
105
  el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`)
@@ -1,6 +1,7 @@
1
1
  import { Match } from "effect";
2
2
  import { Box, Text } from "ink";
3
3
  import React from "react";
4
+ import { renderLayout } from "./menu-render-layout.js";
4
5
  import { buildSelectLabels, renderSelectDetails, selectHint, selectTitle } from "./menu-render-select.js";
5
6
  import { createSteps, menuItems } from "./menu-types.js";
6
7
  // CHANGE: render menu views with Ink without JSX
@@ -14,20 +15,8 @@ import { createSteps, menuItems } from "./menu-types.js";
14
15
  // INVARIANT: menu renders all items once
15
16
  // COMPLEXITY: O(n)
16
17
  export const renderStepLabel = (step, defaults) => Match.value(step).pipe(Match.when("repoUrl", () => "Repo URL"), Match.when("repoRef", () => `Repo ref [${defaults.repoRef}]`), Match.when("outDir", () => `Output dir [${defaults.outDir}]`), Match.when("runUp", () => `Run docker compose up now? [${defaults.runUp ? "Y" : "n"}]`), Match.when("mcpPlaywright", () => `Enable Playwright MCP (Chromium sidecar)? [${defaults.enableMcpPlaywright ? "y" : "N"}]`), Match.when("force", () => `Force recreate (overwrite files + wipe volumes)? [${defaults.force ? "y" : "N"}]`), Match.exhaustive);
17
- const renderMessage = (message) => {
18
- if (!message) {
19
- return null;
20
- }
21
- return React.createElement(Box, { marginTop: 1 }, React.createElement(Text, { color: "magenta" }, message));
22
- };
23
- const renderLayout = (title, body, message) => {
24
- const el = React.createElement;
25
- const messageView = renderMessage(message);
26
- const tail = messageView ? [messageView] : [];
27
- return el(Box, { flexDirection: "column", padding: 1, borderStyle: "round" }, el(Text, { color: "cyan", bold: true }, title), ...body, ...tail);
28
- };
29
18
  const compactElements = (items) => items.filter((item) => item !== null);
30
- const renderMenuHints = (el) => el(Box, { marginTop: 1, flexDirection: "column" }, el(Text, { color: "gray" }, "Hints:"), el(Text, { color: "gray" }, " - Paste repo URL to create directly."), el(Text, { color: "gray" }, " - Aliases: create/c, select/s, info/i, status/ps, logs/l, down/d, down-all/da, delete/del, quit/q"), el(Text, { color: "gray" }, " - Use arrows and Enter to run."));
19
+ const renderMenuHints = (el) => el(Box, { marginTop: 1, flexDirection: "column" }, el(Text, { color: "gray" }, "Hints:"), el(Text, { color: "gray" }, " - Paste repo URL to create directly."), el(Text, { color: "gray" }, " - Aliases: create/c, select/s, auth/a, project-auth/pa, info/i, status/ps, logs/l, down/d, down-all/da, delete/del, quit/q"), el(Text, { color: "gray" }, " - Use arrows and Enter to run."));
31
20
  const renderMenuMessage = (el, message) => {
32
21
  if (!message || message.length === 0) {
33
22
  return null;
@@ -71,6 +60,8 @@ export const renderCreate = (label, buffer, message, stepIndex, defaults) => {
71
60
  el(Box, { marginTop: 1 }, el(Text, { color: "gray" }, "Enter = next, Esc = cancel."))
72
61
  ], message);
73
62
  };
63
+ export { renderAuthMenu, renderAuthPrompt } from "./menu-render-auth.js";
64
+ export { renderProjectAuthMenu, renderProjectAuthPrompt } from "./menu-render-project-auth.js";
74
65
  const computeListWidth = (labels) => {
75
66
  const maxLabelWidth = labels.length > 0 ? Math.max(...labels.map((label) => label.length)) : 24;
76
67
  return Math.min(Math.max(maxLabelWidth + 2, 28), 54);