@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.
Files changed (65) hide show
  1. package/.package.json.release.bak +1 -1
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +12 -7
  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 +11 -4
  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 +3 -1
  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 +12 -2
  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 +70 -3
  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
@@ -1,5 +1,5 @@
1
1
  import { Either, Match } from "effect";
2
- import { defaultTemplateConfig } from "@effect-template/lib/core/domain";
2
+ import {} from "@effect-template/lib/core/domain";
3
3
  import { parseRawOptions } from "./parser-options.js";
4
4
  const missingArgument = (name) => ({
5
5
  _tag: "MissingRequiredOption",
@@ -14,20 +14,27 @@ const normalizeLabel = (value) => {
14
14
  const trimmed = value?.trim() ?? "";
15
15
  return trimmed.length === 0 ? null : trimmed;
16
16
  };
17
+ const defaultEnvGlobalPath = ".docker-git/.orch/env/global.env";
18
+ const defaultCodexAuthPath = ".docker-git/.orch/auth/codex";
19
+ const defaultClaudeAuthPath = ".docker-git/.orch/auth/claude";
17
20
  const resolveAuthOptions = (raw) => ({
18
- envGlobalPath: raw.envGlobalPath ?? defaultTemplateConfig.envGlobalPath,
19
- codexAuthPath: raw.codexAuthPath ?? defaultTemplateConfig.codexAuthPath,
21
+ envGlobalPath: raw.envGlobalPath ?? defaultEnvGlobalPath,
22
+ codexAuthPath: raw.codexAuthPath ?? defaultCodexAuthPath,
23
+ claudeAuthPath: defaultClaudeAuthPath,
20
24
  label: normalizeLabel(raw.label),
21
25
  token: normalizeLabel(raw.token),
22
- scopes: normalizeLabel(raw.scopes)
26
+ scopes: normalizeLabel(raw.scopes),
27
+ authWeb: raw.authWeb === true
23
28
  });
24
- const buildGithubCommand = (action, options) => Match.value(action).pipe(Match.when("login", () => Either.right({
25
- _tag: "AuthGithubLogin",
26
- label: options.label,
27
- token: options.token,
28
- scopes: options.scopes,
29
- envGlobalPath: options.envGlobalPath
30
- })), Match.when("status", () => Either.right({
29
+ const buildGithubCommand = (action, options) => Match.value(action).pipe(Match.when("login", () => options.authWeb && options.token !== null
30
+ ? Either.left(invalidArgument("--token", "cannot be combined with --web"))
31
+ : Either.right({
32
+ _tag: "AuthGithubLogin",
33
+ label: options.label,
34
+ token: options.authWeb ? null : options.token,
35
+ scopes: options.scopes,
36
+ envGlobalPath: options.envGlobalPath
37
+ })), Match.when("status", () => Either.right({
31
38
  _tag: "AuthGithubStatus",
32
39
  envGlobalPath: options.envGlobalPath
33
40
  })), Match.when("logout", () => Either.right({
@@ -48,7 +55,20 @@ const buildCodexCommand = (action, options) => Match.value(action).pipe(Match.wh
48
55
  label: options.label,
49
56
  codexAuthPath: options.codexAuthPath
50
57
  })), Match.orElse(() => Either.left(invalidArgument("auth action", `unknown action '${action}'`))));
51
- const buildAuthCommand = (provider, action, options) => Match.value(provider).pipe(Match.when("github", () => buildGithubCommand(action, options)), Match.when("gh", () => buildGithubCommand(action, options)), Match.when("codex", () => buildCodexCommand(action, options)), Match.orElse(() => Either.left(invalidArgument("auth provider", `unknown provider '${provider}'`))));
58
+ const buildClaudeCommand = (action, options) => Match.value(action).pipe(Match.when("login", () => Either.right({
59
+ _tag: "AuthClaudeLogin",
60
+ label: options.label,
61
+ claudeAuthPath: options.claudeAuthPath
62
+ })), Match.when("status", () => Either.right({
63
+ _tag: "AuthClaudeStatus",
64
+ label: options.label,
65
+ claudeAuthPath: options.claudeAuthPath
66
+ })), Match.when("logout", () => Either.right({
67
+ _tag: "AuthClaudeLogout",
68
+ label: options.label,
69
+ claudeAuthPath: options.claudeAuthPath
70
+ })), Match.orElse(() => Either.left(invalidArgument("auth action", `unknown action '${action}'`))));
71
+ const buildAuthCommand = (provider, action, options) => Match.value(provider).pipe(Match.when("github", () => buildGithubCommand(action, options)), Match.when("gh", () => buildGithubCommand(action, options)), Match.when("codex", () => buildCodexCommand(action, options)), Match.when("claude", () => buildClaudeCommand(action, options)), Match.when("cc", () => buildClaudeCommand(action, options)), Match.orElse(() => Either.left(invalidArgument("auth provider", `unknown provider '${provider}'`))));
52
72
  // CHANGE: parse docker-git auth subcommands
53
73
  // WHY: keep auth flows in the same typed CLI parser
54
74
  // QUOTE(ТЗ): "система авторизации"
@@ -16,7 +16,7 @@ const helpCommand = { _tag: "Help", message: usageText };
16
16
  const menuCommand = { _tag: "Menu" };
17
17
  const statusCommand = { _tag: "Status" };
18
18
  const downAllCommand = { _tag: "DownAll" };
19
- const parseCreate = (args) => Either.flatMap(parseRawOptions(args), buildCreateCommand);
19
+ const parseCreate = (args) => Either.flatMap(parseRawOptions(args), (raw) => buildCreateCommand(raw));
20
20
  // CHANGE: parse CLI arguments into a typed command
21
21
  // WHY: enforce deterministic, pure parsing before any effects run
22
22
  // QUOTE(ТЗ): "Надо написать CLI команду с помощью которой мы будем создавать докер образы"
@@ -25,7 +25,7 @@ Commands:
25
25
  sessions List/kill/log container terminal processes
26
26
  ps, status Show docker compose status for all docker-git projects
27
27
  down-all Stop all docker-git containers (docker compose down)
28
- auth Manage GitHub/Codex auth for docker-git
28
+ auth Manage GitHub/Codex/Claude Code auth for docker-git
29
29
  state Manage docker-git state directory via git (sync across machines)
30
30
 
31
31
  Options:
@@ -37,7 +37,6 @@ Options:
37
37
  --container-name <name> Docker container name (default: dg-<repo>)
38
38
  --service-name <name> Compose service name (default: dg-<repo>)
39
39
  --volume-name <name> Docker volume name (default: dg-<repo>-home)
40
- --secrets-root <path> Host root for shared secrets (default: n/a)
41
40
  --authorized-keys <path> Host path to authorized_keys (default: <projectsRoot>/authorized_keys)
42
41
  --env-global <path> Host path to shared env file (default: <projectsRoot>/.orch/env/global.env)
43
42
  --env-project <path> Host path to project env file (default: ./.orch/env/project.env)
@@ -69,6 +68,7 @@ Container runtime env (set via .orch/env/project.env):
69
68
  Auth providers:
70
69
  github, gh GitHub CLI auth (tokens saved to env file)
71
70
  codex Codex CLI auth (stored under .orch/auth/codex)
71
+ claude, cc Claude Code CLI auth (OAuth cache stored under .orch/auth/claude)
72
72
 
73
73
  Auth actions:
74
74
  login Run login flow and store credentials
@@ -77,7 +77,8 @@ Auth actions:
77
77
 
78
78
  Auth options:
79
79
  --label <label> Account label (default: default)
80
- --token <token> GitHub token override (login only)
80
+ --token <token> GitHub token override (login only; useful for non-interactive/CI)
81
+ --web Force OAuth web flow (login only; ignores --token)
81
82
  --scopes <scopes> GitHub scopes (login only, default: repo,workflow,read:org)
82
83
  --env-global <path> Env file path for GitHub tokens (default: <projectsRoot>/.orch/env/global.env)
83
84
  --codex-auth <path> Codex auth root path (default: <projectsRoot>/.orch/auth/codex)
@@ -5,9 +5,10 @@ import { renderError } from "@effect-template/lib/usecases/errors";
5
5
  import { downAllDockerGitProjects, listProjectItems, listProjectStatus, listRunningProjectItems } from "@effect-template/lib/usecases/projects";
6
6
  import { runDockerComposeUpWithPortCheck } from "@effect-template/lib/usecases/projects-up";
7
7
  import { Effect, Match, pipe } from "effect";
8
+ import { openAuthMenu } from "./menu-auth.js";
8
9
  import { startCreateView } from "./menu-create.js";
9
10
  import { loadSelectView } from "./menu-select-load.js";
10
- import { resumeTui, suspendTui } from "./menu-shared.js";
11
+ import { withSuspendedTui, writeErrorAndPause } from "./menu-shared.js";
11
12
  import {} from "./menu-types.js";
12
13
  // CHANGE: keep menu actions and input parsing in a dedicated module
13
14
  // WHY: reduce cognitive complexity in the TUI entry
@@ -24,15 +25,12 @@ const continueOutcome = (state) => ({
24
25
  state
25
26
  });
26
27
  const quitOutcome = { _tag: "Quit" };
27
- const actionLabel = (action) => Match.value(action).pipe(Match.when({ _tag: "Up" }, () => "docker compose up"), Match.when({ _tag: "Status" }, () => "docker compose ps"), Match.when({ _tag: "Logs" }, () => "docker compose logs"), Match.when({ _tag: "Down" }, () => "docker compose down"), Match.when({ _tag: "DownAll" }, () => "docker compose down (all projects)"), Match.orElse(() => "action"));
28
+ const actionLabel = (action) => Match.value(action).pipe(Match.when({ _tag: "Auth" }, () => "Auth profiles"), Match.when({ _tag: "ProjectAuth" }, () => "Project auth"), Match.when({ _tag: "Up" }, () => "docker compose up"), Match.when({ _tag: "Status" }, () => "docker compose ps"), Match.when({ _tag: "Logs" }, () => "docker compose logs"), Match.when({ _tag: "Down" }, () => "docker compose down"), Match.when({ _tag: "DownAll" }, () => "docker compose down (all projects)"), Match.orElse(() => "action"));
28
29
  const runWithSuspendedTui = (effect, context, label) => {
29
30
  context.runner.runEffect(pipe(Effect.sync(() => {
30
31
  context.setMessage(`${label}...`);
31
- suspendTui();
32
- }), Effect.zipRight(effect), Effect.tap(() => Effect.sync(() => {
32
+ }), Effect.zipRight(withSuspendedTui(effect, { onError: (error) => writeErrorAndPause(renderError(error)) })), Effect.tap(() => Effect.sync(() => {
33
33
  context.setMessage(`${label} finished.`);
34
- })), Effect.ensuring(Effect.sync(() => {
35
- resumeTui();
36
34
  })), Effect.asVoid));
37
35
  };
38
36
  const requireActiveProject = (context) => {
@@ -51,7 +49,7 @@ const withProjectConfig = (state, setMessage, f) => pipe(readProjectConfig(state
51
49
  : Effect.fail(error),
52
50
  onSuccess: (config) => pipe(f(config), Effect.as(continueOutcome(state)))
53
51
  }));
54
- const handleMenuAction = (state, setMessage, action) => Match.value(action).pipe(Match.when({ _tag: "Quit" }, () => Effect.succeed(quitOutcome)), Match.when({ _tag: "Create" }, () => Effect.succeed(continueOutcome(state))), Match.when({ _tag: "Select" }, () => Effect.succeed(continueOutcome(state))), Match.when({ _tag: "Info" }, () => Effect.succeed(continueOutcome(state))), Match.when({ _tag: "Delete" }, () => Effect.succeed(continueOutcome(state))), Match.when({ _tag: "Up" }, () => withProjectConfig(state, setMessage, () => runDockerComposeUpWithPortCheck(state.activeDir ?? state.cwd).pipe(Effect.asVoid))), Match.when({ _tag: "Status" }, () => withProjectConfig(state, setMessage, () => runDockerComposePs(state.activeDir ?? state.cwd))), Match.when({ _tag: "Logs" }, () => withProjectConfig(state, setMessage, () => runDockerComposeLogs(state.activeDir ?? state.cwd))), Match.when({ _tag: "Down" }, () => withProjectConfig(state, setMessage, () => runDockerComposeDown(state.activeDir ?? state.cwd))), Match.when({ _tag: "DownAll" }, () => pipe(downAllDockerGitProjects, Effect.as(continueOutcome(state)))), Match.exhaustive);
52
+ const handleMenuAction = (state, setMessage, action) => Match.value(action).pipe(Match.when({ _tag: "Quit" }, () => Effect.succeed(quitOutcome)), Match.when({ _tag: "Create" }, () => Effect.succeed(continueOutcome(state))), Match.when({ _tag: "Select" }, () => Effect.succeed(continueOutcome(state))), Match.when({ _tag: "Auth" }, () => Effect.succeed(continueOutcome(state))), Match.when({ _tag: "ProjectAuth" }, () => Effect.succeed(continueOutcome(state))), Match.when({ _tag: "Info" }, () => Effect.succeed(continueOutcome(state))), Match.when({ _tag: "Delete" }, () => Effect.succeed(continueOutcome(state))), Match.when({ _tag: "Up" }, () => withProjectConfig(state, setMessage, () => runDockerComposeUpWithPortCheck(state.activeDir ?? state.cwd).pipe(Effect.asVoid))), Match.when({ _tag: "Status" }, () => withProjectConfig(state, setMessage, () => runDockerComposePs(state.activeDir ?? state.cwd))), Match.when({ _tag: "Logs" }, () => withProjectConfig(state, setMessage, () => runDockerComposeLogs(state.activeDir ?? state.cwd))), Match.when({ _tag: "Down" }, () => withProjectConfig(state, setMessage, () => runDockerComposeDown(state.activeDir ?? state.cwd))), Match.when({ _tag: "DownAll" }, () => pipe(downAllDockerGitProjects, Effect.as(continueOutcome(state)))), Match.exhaustive);
55
53
  const runCreateAction = (context) => {
56
54
  startCreateView(context.setView, context.setMessage);
57
55
  };
@@ -59,6 +57,20 @@ const runSelectAction = (context) => {
59
57
  context.setMessage(null);
60
58
  context.runner.runEffect(loadSelectView(listProjectItems, "Connect", context));
61
59
  };
60
+ const runAuthProfilesAction = (context) => {
61
+ context.setMessage(null);
62
+ openAuthMenu({
63
+ state: context.state,
64
+ runner: context.runner,
65
+ setView: context.setView,
66
+ setMessage: context.setMessage,
67
+ setActiveDir: context.setActiveDir
68
+ });
69
+ };
70
+ const runProjectAuthAction = (context) => {
71
+ context.setMessage(null);
72
+ context.runner.runEffect(loadSelectView(listProjectItems, "Auth", context));
73
+ };
62
74
  const runDownAllAction = (context) => {
63
75
  context.setMessage(null);
64
76
  runWithSuspendedTui(downAllDockerGitProjects, context, "Stopping all docker-git containers");
@@ -99,6 +111,10 @@ export const handleMenuActionSelection = (action, context) => {
99
111
  runCreateAction(context);
100
112
  }), Match.when({ _tag: "Select" }, () => {
101
113
  runSelectAction(context);
114
+ }), Match.when({ _tag: "Auth" }, () => {
115
+ runAuthProfilesAction(context);
116
+ }), Match.when({ _tag: "ProjectAuth" }, () => {
117
+ runProjectAuthAction(context);
102
118
  }), Match.when({ _tag: "Info" }, () => {
103
119
  runInfoAction(context);
104
120
  }), Match.when({ _tag: "Delete" }, () => {
@@ -0,0 +1,90 @@
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 { ensureEnvFile, parseEnvEntries, readEnvText, upsertEnvKey } from "@effect-template/lib/usecases/env-file";
5
+ import {} from "@effect-template/lib/usecases/errors";
6
+ import { defaultProjectsRoot } from "@effect-template/lib/usecases/menu-helpers";
7
+ import { autoSyncState } from "@effect-template/lib/usecases/state-repo";
8
+ import { countAuthAccountDirectories } from "./menu-auth-helpers.js";
9
+ import { buildLabeledEnvKey, countKeyEntries, normalizeLabel } from "./menu-labeled-env.js";
10
+ const authMenuItems = [
11
+ { action: "GithubOauth", label: "GitHub: login via OAuth (web)" },
12
+ { action: "GithubRemove", label: "GitHub: remove token" },
13
+ { action: "GitSet", label: "Git: add/update credentials" },
14
+ { action: "GitRemove", label: "Git: remove credentials" },
15
+ { action: "ClaudeOauth", label: "Claude Code: login via OAuth (web)" },
16
+ { action: "ClaudeLogout", label: "Claude Code: logout (clear cache)" },
17
+ { action: "Refresh", label: "Refresh snapshot" },
18
+ { action: "Back", label: "Back to main menu" }
19
+ ];
20
+ const flowSteps = {
21
+ GithubOauth: [
22
+ { key: "label", label: "Label (empty = default)", required: false, secret: false }
23
+ ],
24
+ GithubRemove: [
25
+ { key: "label", label: "Label to remove (empty = default)", required: false, secret: false }
26
+ ],
27
+ GitSet: [
28
+ { key: "label", label: "Label (empty = default)", required: false, secret: false },
29
+ { key: "token", label: "Git auth token", required: true, secret: true },
30
+ { key: "user", label: "Git auth user (empty = x-access-token)", required: false, secret: false }
31
+ ],
32
+ GitRemove: [
33
+ { key: "label", label: "Label to remove (empty = default)", required: false, secret: false }
34
+ ],
35
+ ClaudeOauth: [
36
+ { key: "label", label: "Label (empty = default)", required: false, secret: false }
37
+ ],
38
+ ClaudeLogout: [
39
+ { key: "label", label: "Label to logout (empty = default)", required: false, secret: false }
40
+ ]
41
+ };
42
+ const flowTitle = (flow) => Match.value(flow).pipe(Match.when("GithubOauth", () => "GitHub OAuth"), Match.when("GithubRemove", () => "GitHub remove"), Match.when("GitSet", () => "Git credentials"), Match.when("GitRemove", () => "Git remove"), Match.when("ClaudeOauth", () => "Claude Code OAuth"), Match.when("ClaudeLogout", () => "Claude Code logout"), Match.exhaustive);
43
+ export const successMessage = (flow, label) => Match.value(flow).pipe(Match.when("GithubOauth", () => `Saved GitHub token (${label}).`), Match.when("GithubRemove", () => `Removed GitHub token (${label}).`), Match.when("GitSet", () => `Saved Git credentials (${label}).`), Match.when("GitRemove", () => `Removed Git credentials (${label}).`), Match.when("ClaudeOauth", () => `Saved Claude Code login (${label}).`), Match.when("ClaudeLogout", () => `Logged out Claude Code (${label}).`), Match.exhaustive);
44
+ const buildGlobalEnvPath = (cwd) => `${defaultProjectsRoot(cwd)}/.orch/env/global.env`;
45
+ const buildClaudeAuthPath = (cwd) => `${defaultProjectsRoot(cwd)}/.orch/auth/claude`;
46
+ const loadAuthEnvText = (cwd) => Effect.gen(function* (_) {
47
+ const fs = yield* _(FileSystem.FileSystem);
48
+ const path = yield* _(Path.Path);
49
+ const globalEnvPath = buildGlobalEnvPath(cwd);
50
+ const claudeAuthPath = buildClaudeAuthPath(cwd);
51
+ yield* _(ensureEnvFile(fs, path, globalEnvPath));
52
+ const envText = yield* _(readEnvText(fs, globalEnvPath));
53
+ return { fs, path, globalEnvPath, claudeAuthPath, envText };
54
+ });
55
+ export const readAuthSnapshot = (cwd) => pipe(loadAuthEnvText(cwd), Effect.flatMap(({ claudeAuthPath, envText, fs, globalEnvPath, path }) => pipe(countAuthAccountDirectories(fs, path, claudeAuthPath), Effect.map((claudeAuthEntries) => ({
56
+ globalEnvPath,
57
+ claudeAuthPath,
58
+ totalEntries: parseEnvEntries(envText).filter((entry) => entry.value.trim().length > 0).length,
59
+ githubTokenEntries: countKeyEntries(envText, "GITHUB_TOKEN"),
60
+ gitTokenEntries: countKeyEntries(envText, "GIT_AUTH_TOKEN"),
61
+ gitUserEntries: countKeyEntries(envText, "GIT_AUTH_USER"),
62
+ claudeAuthEntries
63
+ })))));
64
+ export const writeAuthFlow = (cwd, flow, values) => pipe(loadAuthEnvText(cwd), Effect.flatMap(({ envText, fs, globalEnvPath }) => {
65
+ const label = values["label"] ?? "";
66
+ const canonicalLabel = (() => {
67
+ const normalized = normalizeLabel(label);
68
+ return normalized.length === 0 || normalized === "DEFAULT" ? "default" : normalized;
69
+ })();
70
+ const token = (values["token"] ?? "").trim();
71
+ const user = (values["user"] ?? "").trim();
72
+ const nextText = Match.value(flow).pipe(Match.when("GithubRemove", () => upsertEnvKey(envText, buildLabeledEnvKey("GITHUB_TOKEN", label), "")), Match.when("GitSet", () => {
73
+ const withToken = upsertEnvKey(envText, buildLabeledEnvKey("GIT_AUTH_TOKEN", label), token);
74
+ const resolvedUser = user.length > 0 ? user : "x-access-token";
75
+ return upsertEnvKey(withToken, buildLabeledEnvKey("GIT_AUTH_USER", label), resolvedUser);
76
+ }), Match.when("GitRemove", () => {
77
+ const withoutToken = upsertEnvKey(envText, buildLabeledEnvKey("GIT_AUTH_TOKEN", label), "");
78
+ return upsertEnvKey(withoutToken, buildLabeledEnvKey("GIT_AUTH_USER", label), "");
79
+ }), Match.exhaustive);
80
+ const syncMessage = Match.value(flow).pipe(Match.when("GithubRemove", () => `chore(state): auth gh logout ${canonicalLabel}`), Match.when("GitSet", () => `chore(state): auth git ${canonicalLabel}`), Match.when("GitRemove", () => `chore(state): auth git logout ${canonicalLabel}`), Match.exhaustive);
81
+ return pipe(fs.writeFileString(globalEnvPath, nextText), Effect.zipRight(autoSyncState(syncMessage)));
82
+ }), Effect.asVoid);
83
+ export const authViewTitle = (flow) => flowTitle(flow);
84
+ export const authViewSteps = (flow) => flowSteps[flow];
85
+ export const authMenuLabels = () => authMenuItems.map((item) => item.label);
86
+ export const authMenuActionByIndex = (index) => {
87
+ const item = authMenuItems[index];
88
+ return item ? item.action : null;
89
+ };
90
+ export const authMenuSize = () => authMenuItems.length;
@@ -0,0 +1,20 @@
1
+ import { Effect } from "effect";
2
+ export const countAuthAccountDirectories = (fs, path, root) => Effect.gen(function* (_) {
3
+ const exists = yield* _(fs.exists(root));
4
+ if (!exists) {
5
+ return 0;
6
+ }
7
+ const entries = yield* _(fs.readDirectory(root));
8
+ let count = 0;
9
+ for (const entry of entries) {
10
+ if (entry === ".image") {
11
+ continue;
12
+ }
13
+ const fullPath = path.join(root, entry);
14
+ const info = yield* _(fs.stat(fullPath));
15
+ if (info.type === "Directory") {
16
+ count += 1;
17
+ }
18
+ }
19
+ return count;
20
+ });
@@ -0,0 +1,159 @@
1
+ import { Effect, Match, pipe } from "effect";
2
+ import { authClaudeLogin, authClaudeLogout, authGithubLogin, claudeAuthRoot } from "@effect-template/lib/usecases/auth";
3
+ import { renderError } from "@effect-template/lib/usecases/errors";
4
+ import { authMenuActionByIndex, authMenuSize, authViewSteps, readAuthSnapshot, successMessage, writeAuthFlow } from "./menu-auth-data.js";
5
+ import { nextBufferValue } from "./menu-buffer-input.js";
6
+ import { handleMenuNumberInput, submitPromptStep } from "./menu-input-utils.js";
7
+ import { pauseOnError, resetToMenu, resumeSshWithSkipInputs, withSuspendedTui } from "./menu-shared.js";
8
+ const defaultLabel = (value) => {
9
+ const trimmed = value.trim();
10
+ return trimmed.length > 0 ? trimmed : "default";
11
+ };
12
+ const startAuthMenuWithSnapshot = (snapshot, context) => {
13
+ context.setView({ _tag: "AuthMenu", selected: 0, snapshot });
14
+ context.setMessage(null);
15
+ };
16
+ const startAuthPrompt = (snapshot, flow, context) => {
17
+ context.setView({
18
+ _tag: "AuthPrompt",
19
+ flow,
20
+ step: 0,
21
+ buffer: "",
22
+ values: {},
23
+ snapshot
24
+ });
25
+ context.setMessage(null);
26
+ };
27
+ const resolveLabelOption = (values) => {
28
+ const labelValue = (values["label"] ?? "").trim();
29
+ return labelValue.length > 0 ? labelValue : null;
30
+ };
31
+ const resolveAuthPromptEffect = (view, cwd, values) => {
32
+ const labelOption = resolveLabelOption(values);
33
+ return Match.value(view.flow).pipe(Match.when("GithubOauth", () => authGithubLogin({
34
+ _tag: "AuthGithubLogin",
35
+ label: labelOption,
36
+ token: null,
37
+ scopes: null,
38
+ envGlobalPath: view.snapshot.globalEnvPath
39
+ })), Match.when("ClaudeOauth", () => authClaudeLogin({
40
+ _tag: "AuthClaudeLogin",
41
+ label: labelOption,
42
+ claudeAuthPath: claudeAuthRoot
43
+ })), Match.when("ClaudeLogout", () => authClaudeLogout({
44
+ _tag: "AuthClaudeLogout",
45
+ label: labelOption,
46
+ claudeAuthPath: claudeAuthRoot
47
+ })), Match.when("GithubRemove", (flow) => writeAuthFlow(cwd, flow, values)), Match.when("GitSet", (flow) => writeAuthFlow(cwd, flow, values)), Match.when("GitRemove", (flow) => writeAuthFlow(cwd, flow, values)), Match.exhaustive);
48
+ };
49
+ const runAuthPromptEffect = (effect, view, label, context, options) => {
50
+ const withOptionalSuspension = options.suspendTui
51
+ ? withSuspendedTui(effect, {
52
+ onError: pauseOnError(renderError),
53
+ onResume: resumeSshWithSkipInputs(context)
54
+ })
55
+ : effect;
56
+ context.setSshActive(options.suspendTui);
57
+ context.runner.runEffect(pipe(withOptionalSuspension, Effect.zipRight(readAuthSnapshot(context.state.cwd)), Effect.tap((snapshot) => Effect.sync(() => {
58
+ startAuthMenuWithSnapshot(snapshot, context);
59
+ context.setMessage(successMessage(view.flow, label));
60
+ })), Effect.asVoid));
61
+ };
62
+ const loadAuthMenuView = (cwd, context) => pipe(readAuthSnapshot(cwd), Effect.tap((snapshot) => Effect.sync(() => {
63
+ startAuthMenuWithSnapshot(snapshot, context);
64
+ })), Effect.asVoid);
65
+ const runAuthAction = (action, view, context) => {
66
+ if (action === "Back") {
67
+ resetToMenu(context);
68
+ return;
69
+ }
70
+ if (action === "Refresh") {
71
+ context.runner.runEffect(loadAuthMenuView(context.state.cwd, context));
72
+ return;
73
+ }
74
+ startAuthPrompt(view.snapshot, action, context);
75
+ };
76
+ const submitAuthPrompt = (view, context) => {
77
+ const steps = authViewSteps(view.flow);
78
+ submitPromptStep(view, steps, context, () => {
79
+ startAuthMenuWithSnapshot(view.snapshot, context);
80
+ }, (nextValues) => {
81
+ const label = defaultLabel(nextValues["label"] ?? "");
82
+ const effect = resolveAuthPromptEffect(view, context.state.cwd, nextValues);
83
+ runAuthPromptEffect(effect, view, label, context, {
84
+ suspendTui: view.flow === "GithubOauth" || view.flow === "ClaudeOauth" || view.flow === "ClaudeLogout"
85
+ });
86
+ });
87
+ };
88
+ const setAuthMenuSelection = (view, selected, context) => {
89
+ context.setView({
90
+ ...view,
91
+ selected
92
+ });
93
+ };
94
+ const shiftAuthMenuSelection = (view, delta, context) => {
95
+ const menuSize = authMenuSize();
96
+ const selected = (view.selected + delta + menuSize) % menuSize;
97
+ setAuthMenuSelection(view, selected, context);
98
+ };
99
+ const runAuthMenuSelection = (selected, view, context) => {
100
+ const action = authMenuActionByIndex(selected);
101
+ if (action === null) {
102
+ return;
103
+ }
104
+ runAuthAction(action, view, context);
105
+ };
106
+ const handleAuthMenuNumberInput = (input, view, context) => {
107
+ handleMenuNumberInput(input, context, authMenuActionByIndex, (action) => {
108
+ runAuthAction(action, view, context);
109
+ });
110
+ };
111
+ const handleAuthMenuInput = (input, key, view, context) => {
112
+ if (key.escape) {
113
+ resetToMenu(context);
114
+ return;
115
+ }
116
+ if (key.upArrow) {
117
+ shiftAuthMenuSelection(view, -1, context);
118
+ return;
119
+ }
120
+ if (key.downArrow) {
121
+ shiftAuthMenuSelection(view, 1, context);
122
+ return;
123
+ }
124
+ if (key.return) {
125
+ runAuthMenuSelection(view.selected, view, context);
126
+ return;
127
+ }
128
+ handleAuthMenuNumberInput(input, view, context);
129
+ };
130
+ const handleAuthPromptInput = (input, key, view, context) => {
131
+ if (key.escape) {
132
+ startAuthMenuWithSnapshot(view.snapshot, context);
133
+ return;
134
+ }
135
+ if (key.return) {
136
+ submitAuthPrompt(view, context);
137
+ return;
138
+ }
139
+ setAuthPromptBuffer({ input, key, view, context });
140
+ };
141
+ const setAuthPromptBuffer = (args) => {
142
+ const { context, input, key, view } = args;
143
+ const nextBuffer = nextBufferValue(input, key, view.buffer);
144
+ if (nextBuffer === null) {
145
+ return;
146
+ }
147
+ context.setView({ ...view, buffer: nextBuffer });
148
+ };
149
+ export const openAuthMenu = (context) => {
150
+ context.setMessage("Loading auth profiles...");
151
+ context.runner.runEffect(loadAuthMenuView(context.state.cwd, context));
152
+ };
153
+ export const handleAuthInput = (input, key, view, context) => {
154
+ if (view._tag === "AuthMenu") {
155
+ handleAuthMenuInput(input, key, view, context);
156
+ return;
157
+ }
158
+ handleAuthPromptInput(input, key, view, context);
159
+ };
@@ -0,0 +1,9 @@
1
+ export const nextBufferValue = (input, key, buffer) => {
2
+ if (key.backspace || key.delete) {
3
+ return buffer.slice(0, -1);
4
+ }
5
+ if (input.length > 0) {
6
+ return buffer + input;
7
+ }
8
+ return null;
9
+ };
@@ -5,10 +5,11 @@ import * as Path from "@effect/platform/Path";
5
5
  import { Effect, Either, Match, pipe } from "effect";
6
6
  import { parseArgs } from "./cli/parser.js";
7
7
  import { formatParseError, usageText } from "./cli/usage.js";
8
+ import { nextBufferValue } from "./menu-buffer-input.js";
8
9
  import { resetToMenu } from "./menu-shared.js";
9
10
  import { createSteps } from "./menu-types.js";
10
11
  export const buildCreateArgs = (input) => {
11
- const args = ["create", "--repo-url", input.repoUrl, "--secrets-root", input.secretsRoot];
12
+ const args = ["create", "--repo-url", input.repoUrl];
12
13
  if (input.repoRef.length > 0) {
13
14
  args.push("--repo-ref", input.repoRef);
14
15
  }
@@ -61,13 +62,11 @@ const resolveDefaultOutDir = (cwd, repoUrl) => {
61
62
  export const resolveCreateInputs = (cwd, values) => {
62
63
  const repoUrl = values.repoUrl ?? "";
63
64
  const resolvedRepoRef = repoUrl.length > 0 ? resolveRepoInput(repoUrl).repoRef : undefined;
64
- const secretsRoot = values.secretsRoot ?? joinPath(defaultProjectsRoot(cwd), "secrets");
65
65
  const outDir = values.outDir ?? (repoUrl.length > 0 ? resolveDefaultOutDir(cwd, repoUrl) : "");
66
66
  return {
67
67
  repoUrl,
68
68
  repoRef: values.repoRef ?? resolvedRepoRef ?? "main",
69
69
  outDir,
70
- secretsRoot,
71
70
  runUp: values.runUp !== false,
72
71
  enableMcpPlaywright: values.enableMcpPlaywright === true,
73
72
  force: values.force === true,
@@ -193,11 +192,8 @@ export const handleCreateInput = (input, key, view, context) => {
193
192
  handleCreateReturn({ ...context, view });
194
193
  return;
195
194
  }
196
- if (key.backspace || key.delete) {
197
- context.setView({ ...view, buffer: view.buffer.slice(0, -1) });
198
- return;
199
- }
200
- if (input.length > 0) {
201
- context.setView({ ...view, buffer: view.buffer + input });
195
+ const nextBuffer = nextBufferValue(input, key, view.buffer);
196
+ if (nextBuffer !== null) {
197
+ context.setView({ ...view, buffer: nextBuffer });
202
198
  }
203
199
  };
@@ -1,5 +1,7 @@
1
+ import { handleAuthInput } from "./menu-auth.js";
1
2
  import { handleCreateInput } from "./menu-create.js";
2
3
  import { handleMenuInput } from "./menu-menu.js";
4
+ import { handleProjectAuthInput } from "./menu-project-auth.js";
3
5
  import { handleSelectInput } from "./menu-select.js";
4
6
  const activateInput = (input, key, context) => {
5
7
  if (context.inputStage === "active") {
@@ -26,36 +28,51 @@ const shouldHandleMenuInput = (input, key, context) => {
26
28
  }
27
29
  return activation.allowProcessing;
28
30
  };
29
- export const handleUserInput = (input, key, context) => {
30
- if (context.busy || context.sshActive) {
31
- return;
32
- }
33
- if (context.view._tag === "Menu") {
34
- if (!shouldHandleMenuInput(input, key, context)) {
35
- return;
36
- }
37
- handleMenuInput(input, key, {
38
- selected: context.selected,
39
- setSelected: context.setSelected,
40
- state: context.state,
41
- runner: context.runner,
42
- exit: context.exit,
43
- setView: context.setView,
44
- setMessage: context.setMessage
45
- });
46
- return;
47
- }
48
- if (context.view._tag === "Create") {
49
- handleCreateInput(input, key, context.view, {
50
- state: context.state,
51
- setView: context.setView,
52
- setMessage: context.setMessage,
53
- runner: context.runner,
54
- setActiveDir: context.setActiveDir
55
- });
31
+ const handleMenuViewInput = (input, key, context) => {
32
+ if (!shouldHandleMenuInput(input, key, context)) {
56
33
  return;
57
34
  }
58
- handleSelectInput(input, key, context.view, {
35
+ handleMenuInput(input, key, {
36
+ selected: context.selected,
37
+ setSelected: context.setSelected,
38
+ state: context.state,
39
+ runner: context.runner,
40
+ exit: context.exit,
41
+ setView: context.setView,
42
+ setMessage: context.setMessage,
43
+ setActiveDir: context.setActiveDir
44
+ });
45
+ };
46
+ const handleCreateViewInput = (input, key, view, context) => {
47
+ handleCreateInput(input, key, view, {
48
+ state: context.state,
49
+ setView: context.setView,
50
+ setMessage: context.setMessage,
51
+ runner: context.runner,
52
+ setActiveDir: context.setActiveDir
53
+ });
54
+ };
55
+ const handleAuthViewInput = (input, key, view, context) => {
56
+ handleAuthInput(input, key, view, {
57
+ state: context.state,
58
+ setView: context.setView,
59
+ setMessage: context.setMessage,
60
+ setActiveDir: context.setActiveDir,
61
+ runner: context.runner,
62
+ setSshActive: context.setSshActive,
63
+ setSkipInputs: context.setSkipInputs
64
+ });
65
+ };
66
+ const handleProjectAuthViewInput = (input, key, view, context) => {
67
+ handleProjectAuthInput(input, key, view, {
68
+ runner: context.runner,
69
+ setView: context.setView,
70
+ setMessage: context.setMessage,
71
+ setActiveDir: context.setActiveDir
72
+ });
73
+ };
74
+ const handleSelectViewInput = (input, key, view, context) => {
75
+ handleSelectInput(input, key, view, {
59
76
  setView: context.setView,
60
77
  setMessage: context.setMessage,
61
78
  setActiveDir: context.setActiveDir,
@@ -65,3 +82,28 @@ export const handleUserInput = (input, key, context) => {
65
82
  setSkipInputs: context.setSkipInputs
66
83
  });
67
84
  };
85
+ const handleActiveViewInput = (input, key, view, context) => {
86
+ if (view._tag === "Create") {
87
+ handleCreateViewInput(input, key, view, context);
88
+ return;
89
+ }
90
+ if (view._tag === "AuthMenu" || view._tag === "AuthPrompt") {
91
+ handleAuthViewInput(input, key, view, context);
92
+ return;
93
+ }
94
+ if (view._tag === "ProjectAuthMenu" || view._tag === "ProjectAuthPrompt") {
95
+ handleProjectAuthViewInput(input, key, view, context);
96
+ return;
97
+ }
98
+ handleSelectViewInput(input, key, view, context);
99
+ };
100
+ export const handleUserInput = (input, key, context) => {
101
+ if (context.busy || context.sshActive) {
102
+ return;
103
+ }
104
+ if (context.view._tag === "Menu") {
105
+ handleMenuViewInput(input, key, context);
106
+ return;
107
+ }
108
+ handleActiveViewInput(input, key, context.view, context);
109
+ };