diff --git a/packages/lib/src/core/templates-entrypoint/claude.ts b/packages/lib/src/core/templates-entrypoint/claude.ts index 358c3717..b98e7e7d 100644 --- a/packages/lib/src/core/templates-entrypoint/claude.ts +++ b/packages/lib/src/core/templates-entrypoint/claude.ts @@ -1,11 +1,9 @@ import type { TemplateConfig } from "../domain.js" const claudeAuthRootContainerPath = (sshUser: string): string => `/home/${sshUser}/.docker-git/.orch/auth/claude` -const claudeHomeContainerPath = (sshUser: string): string => `/home/${sshUser}/.claude` -const renderClaudeAuthConfig = (config: TemplateConfig): string => - String - .raw`# Claude Code: expose CLAUDE_CONFIG_DIR for SSH sessions (OAuth cache lives under ~/.docker-git/.orch/auth/claude) +const claudeAuthConfigTemplate = String + .raw`# Claude Code: expose CLAUDE_CONFIG_DIR for SSH sessions (OAuth cache lives under ~/.docker-git/.orch/auth/claude) CLAUDE_LABEL_RAW="$CLAUDE_AUTH_LABEL" if [[ -z "$CLAUDE_LABEL_RAW" ]]; then CLAUDE_LABEL_RAW="default" @@ -18,7 +16,7 @@ if [[ -z "$CLAUDE_LABEL_NORM" ]]; then CLAUDE_LABEL_NORM="default" fi -CLAUDE_AUTH_ROOT="${claudeAuthRootContainerPath(config.sshUser)}" +CLAUDE_AUTH_ROOT="__CLAUDE_AUTH_ROOT__" CLAUDE_CONFIG_DIR="$CLAUDE_AUTH_ROOT/$CLAUDE_LABEL_NORM" # Backward compatibility: if default auth is stored directly under claude root, reuse it. @@ -33,8 +31,8 @@ fi export CLAUDE_CONFIG_DIR mkdir -p "$CLAUDE_CONFIG_DIR" || true -CLAUDE_HOME_DIR="${claudeHomeContainerPath(config.sshUser)}" -CLAUDE_HOME_JSON="/home/${config.sshUser}/.claude.json" +CLAUDE_HOME_DIR="__CLAUDE_HOME_DIR__" +CLAUDE_HOME_JSON="__CLAUDE_HOME_JSON__" mkdir -p "$CLAUDE_HOME_DIR" || true docker_git_link_claude_file() { @@ -86,6 +84,12 @@ docker_git_refresh_claude_oauth_token() { docker_git_refresh_claude_oauth_token` +const renderClaudeAuthConfig = (config: TemplateConfig): string => + claudeAuthConfigTemplate + .replaceAll("__CLAUDE_AUTH_ROOT__", claudeAuthRootContainerPath(config.sshUser)) + .replaceAll("__CLAUDE_HOME_DIR__", `/home/${config.sshUser}/.claude`) + .replaceAll("__CLAUDE_HOME_JSON__", `/home/${config.sshUser}/.claude.json`) + const renderClaudeCliInstall = (): string => String.raw`# Claude Code: ensure CLI command exists (non-blocking startup self-heal) docker_git_ensure_claude_cli() { @@ -186,7 +190,8 @@ NODE docker_git_sync_claude_playwright_mcp chown 1000:1000 "$CLAUDE_SETTINGS_FILE" 2>/dev/null || true` -const entrypointClaudeGlobalPromptTemplate = String.raw`# Claude Code: managed global memory (CLAUDE.md is auto-loaded by Claude Code) +const entrypointClaudeGlobalPromptTemplate = String + .raw`# Claude Code: managed global memory (CLAUDE.md is auto-loaded by Claude Code) CLAUDE_GLOBAL_PROMPT_FILE="/home/__SSH_USER__/.claude/CLAUDE.md" CLAUDE_AUTO_SYSTEM_PROMPT="${"$"}{CLAUDE_AUTO_SYSTEM_PROMPT:-1}" CLAUDE_WORKSPACE_CONTEXT="Контекст workspace: repository" diff --git a/packages/lib/src/shell/docker-auth.ts b/packages/lib/src/shell/docker-auth.ts index 5690d8e5..61fd3b46 100644 --- a/packages/lib/src/shell/docker-auth.ts +++ b/packages/lib/src/shell/docker-auth.ts @@ -20,13 +20,18 @@ export type DockerAuthSpec = { readonly interactive: boolean } -const resolveDefaultDockerUser = (): string | null => { - const getUid = (process as { readonly getuid?: () => number }).getuid - const getGid = (process as { readonly getgid?: () => number }).getgid +export const resolveDefaultDockerUser = (): string | null => { + const getUid = Reflect.get(process, "getuid") + const getGid = Reflect.get(process, "getgid") if (typeof getUid !== "function" || typeof getGid !== "function") { return null } - return `${getUid()}:${getGid()}` + const uid = getUid.call(process) + const gid = getGid.call(process) + if (typeof uid !== "number" || typeof gid !== "number") { + return null + } + return `${uid}:${gid}` } const appendEnvArgs = (base: Array, env: string | ReadonlyArray) => { diff --git a/packages/lib/src/usecases/actions/prepare-files.ts b/packages/lib/src/usecases/actions/prepare-files.ts index 5d4a4958..51514fc3 100644 --- a/packages/lib/src/usecases/actions/prepare-files.ts +++ b/packages/lib/src/usecases/actions/prepare-files.ts @@ -172,11 +172,10 @@ export const migrateProjectOrchLayout = ( globalConfig: CreateCommand["config"], resolveRootPath: (value: string) => string ): Effect.Effect => - migrateLegacyOrchLayout( - baseDir, - globalConfig.envGlobalPath, - globalConfig.envProjectPath, - globalConfig.codexAuthPath, - resolveRootPath(".docker-git/.orch/auth/gh"), - resolveRootPath(".docker-git/.orch/auth/claude") - ) + migrateLegacyOrchLayout(baseDir, { + envGlobalPath: globalConfig.envGlobalPath, + envProjectPath: globalConfig.envProjectPath, + codexAuthPath: globalConfig.codexAuthPath, + ghAuthPath: resolveRootPath(".docker-git/.orch/auth/gh"), + claudeAuthPath: resolveRootPath(".docker-git/.orch/auth/claude") + }) diff --git a/packages/lib/src/usecases/auth-claude-oauth.ts b/packages/lib/src/usecases/auth-claude-oauth.ts index a0332ace..6bd1dc51 100644 --- a/packages/lib/src/usecases/auth-claude-oauth.ts +++ b/packages/lib/src/usecases/auth-claude-oauth.ts @@ -6,6 +6,7 @@ import * as Fiber from "effect/Fiber" import type * as Scope from "effect/Scope" import * as Stream from "effect/Stream" +import { resolveDefaultDockerUser } from "../shell/docker-auth.js" import { AuthError, CommandFailedError } from "../shell/errors.js" const oauthTokenEnvKey = "DOCKER_GIT_CLAUDE_OAUTH_TOKEN" @@ -142,10 +143,9 @@ const buildDockerSetupTokenSpec = ( const buildDockerSetupTokenArgs = (spec: DockerSetupTokenSpec): ReadonlyArray => { const base: Array = ["run", "--rm", "-i", "-t", "-v", `${spec.hostPath}:${spec.containerPath}`] - const getUid = (process as { readonly getuid?: () => number }).getuid - const getGid = (process as { readonly getgid?: () => number }).getgid - if (typeof getUid === "function" && typeof getGid === "function") { - base.push("--user", `${getUid()}:${getGid()}`) + const dockerUser = resolveDefaultDockerUser() + if (dockerUser !== null) { + base.push("--user", dockerUser) } for (const entry of spec.env) { const trimmed = entry.trim() diff --git a/packages/lib/src/usecases/auth-claude.ts b/packages/lib/src/usecases/auth-claude.ts index 96890c7f..a144e384 100644 --- a/packages/lib/src/usecases/auth-claude.ts +++ b/packages/lib/src/usecases/auth-claude.ts @@ -102,14 +102,13 @@ const buildClaudeAuthEnv = ( const ensureClaudeOrchLayout = ( cwd: string ): Effect.Effect => - migrateLegacyOrchLayout( - cwd, - defaultTemplateConfig.envGlobalPath, - defaultTemplateConfig.envProjectPath, - defaultTemplateConfig.codexAuthPath, - ".docker-git/.orch/auth/gh", - ".docker-git/.orch/auth/claude" - ) + migrateLegacyOrchLayout(cwd, { + envGlobalPath: defaultTemplateConfig.envGlobalPath, + envProjectPath: defaultTemplateConfig.envProjectPath, + codexAuthPath: defaultTemplateConfig.codexAuthPath, + ghAuthPath: ".docker-git/.orch/auth/gh", + claudeAuthPath: ".docker-git/.orch/auth/claude" + }) const renderClaudeDockerfile = (): string => String.raw`FROM ubuntu:24.04 diff --git a/packages/lib/src/usecases/auth-codex.ts b/packages/lib/src/usecases/auth-codex.ts index 139ddf2d..041f1cf5 100644 --- a/packages/lib/src/usecases/auth-codex.ts +++ b/packages/lib/src/usecases/auth-codex.ts @@ -31,14 +31,13 @@ const ensureCodexOrchLayout = ( cwd: string, codexAuthPath: string ): Effect.Effect => - migrateLegacyOrchLayout( - cwd, - defaultTemplateConfig.envGlobalPath, - defaultTemplateConfig.envProjectPath, + migrateLegacyOrchLayout(cwd, { + envGlobalPath: defaultTemplateConfig.envGlobalPath, + envProjectPath: defaultTemplateConfig.envProjectPath, codexAuthPath, - ".docker-git/.orch/auth/gh", - ".docker-git/.orch/auth/claude" - ) + ghAuthPath: ".docker-git/.orch/auth/gh", + claudeAuthPath: ".docker-git/.orch/auth/claude" + }) const renderCodexDockerfile = (): string => String.raw`FROM ubuntu:24.04 diff --git a/packages/lib/src/usecases/auth-github.ts b/packages/lib/src/usecases/auth-github.ts index f88d3ef3..82305d82 100644 --- a/packages/lib/src/usecases/auth-github.ts +++ b/packages/lib/src/usecases/auth-github.ts @@ -38,14 +38,13 @@ const ensureGithubOrchLayout = ( cwd: string, envGlobalPath: string ): Effect.Effect => - migrateLegacyOrchLayout( - cwd, + migrateLegacyOrchLayout(cwd, { envGlobalPath, - defaultTemplateConfig.envProjectPath, - defaultTemplateConfig.codexAuthPath, - ghAuthRoot, - ".docker-git/.orch/auth/claude" - ) + envProjectPath: defaultTemplateConfig.envProjectPath, + codexAuthPath: defaultTemplateConfig.codexAuthPath, + ghAuthPath: ghAuthRoot, + claudeAuthPath: ".docker-git/.orch/auth/claude" + }) const normalizeGithubLabel = (value: string | null): string => { const trimmed = value?.trim() ?? "" diff --git a/packages/lib/src/usecases/auth-sync-helpers.ts b/packages/lib/src/usecases/auth-sync-helpers.ts new file mode 100644 index 00000000..c1c7ae74 --- /dev/null +++ b/packages/lib/src/usecases/auth-sync-helpers.ts @@ -0,0 +1,140 @@ +import type { PlatformError } from "@effect/platform/Error" +import * as ParseResult from "@effect/schema/ParseResult" +import * as Schema from "@effect/schema/Schema" +import { Effect, Either } from "effect" + +type CopyDecision = "skip" | "copy" +type JsonPrimitive = boolean | number | string | null +type JsonValue = JsonPrimitive | JsonRecord | ReadonlyArray +type JsonRecord = Readonly<{ [key: string]: JsonValue }> + +const JsonValueSchema: Schema.Schema = Schema.suspend(() => + Schema.Union( + Schema.Null, + Schema.Boolean, + Schema.String, + Schema.JsonNumber, + Schema.Array(JsonValueSchema), + Schema.Record({ key: Schema.String, value: JsonValueSchema }) + ) +) + +const JsonRecordSchema: Schema.Schema = Schema.Record({ + key: Schema.String, + value: JsonValueSchema +}) + +const JsonRecordFromStringSchema = Schema.parseJson(JsonRecordSchema) +const defaultEnvContents = "# docker-git env\n# KEY=value\n" +const codexConfigMarker = "# docker-git codex config" + +// CHANGE: enable web search tool in default Codex config (top-level) +// WHY: avoid deprecated legacy flags and keep config minimal +// QUOTE(ТЗ): "да убери легаси" +// REF: user-request-2026-02-05-remove-legacy-web-search +// SOURCE: n/a +// FORMAT THEOREM: ∀c: config(c) -> web_search(c)="live" +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: default config stays deterministic +// COMPLEXITY: O(1) +export const defaultCodexConfig = [ + "# docker-git codex config", + "model = \"gpt-5.3-codex\"", + "model_reasoning_effort = \"xhigh\"", + "personality = \"pragmatic\"", + "", + "approval_policy = \"never\"", + "sandbox_mode = \"danger-full-access\"", + "web_search = \"live\"", + "", + "[features]", + "shell_snapshot = true", + "multi_agent = true", + "apps = true", + "shell_tool = true" +].join("\n") + +export const resolvePathFromBase = ( + path: { + readonly isAbsolute: (targetPath: string) => boolean + readonly resolve: (...parts: ReadonlyArray) => string + }, + baseDir: string, + targetPath: string +): string => path.isAbsolute(targetPath) ? targetPath : path.resolve(baseDir, targetPath) + +const isPermissionDeniedSystemError = (error: PlatformError): boolean => + error._tag === "SystemError" && error.reason === "PermissionDenied" + +export const skipCodexConfigPermissionDenied = ( + configPath: string, + error: PlatformError +): Effect.Effect => + isPermissionDeniedSystemError(error) + ? Effect.logWarning( + `Skipped Codex config sync at ${configPath}: permission denied (${error.description ?? "no details"}).` + ) + : Effect.fail(error) + +const normalizeConfigText = (text: string): string => + text + .replaceAll("\r\n", "\n") + .trim() + +export const shouldRewriteDockerGitCodexConfig = (existing: string): boolean => { + const normalized = normalizeConfigText(existing) + if (normalized.length === 0) { + return true + } + if (!normalized.startsWith(codexConfigMarker)) { + return false + } + return normalized !== normalizeConfigText(defaultCodexConfig) +} + +export const shouldCopyEnv = (sourceText: string, targetText: string): CopyDecision => { + if (sourceText.trim().length === 0) { + return "skip" + } + if (targetText.trim().length === 0) { + return "copy" + } + if (targetText.trim() === defaultEnvContents.trim() && sourceText.trim() !== defaultEnvContents.trim()) { + return "copy" + } + return "skip" +} + +export const parseJsonRecord = (text: string): Effect.Effect => + Either.match(ParseResult.decodeUnknownEither(JsonRecordFromStringSchema)(text), { + onLeft: () => Effect.succeed(null), + onRight: (record) => Effect.succeed(record) + }) + +export const hasClaudeOauthAccount = (record: JsonRecord | null): boolean => + record !== null && typeof record["oauthAccount"] === "object" && record["oauthAccount"] !== null + +export const hasClaudeCredentials = (record: JsonRecord | null): boolean => + record !== null && typeof record["claudeAiOauth"] === "object" && record["claudeAiOauth"] !== null + +export const isGithubTokenKey = (key: string): boolean => + key === "GITHUB_TOKEN" || key === "GH_TOKEN" || key.startsWith("GITHUB_TOKEN__") + +export type AuthPaths = { + readonly envGlobalPath: string + readonly envProjectPath: string + readonly codexAuthPath: string +} + +export type AuthSyncSpec = { + readonly sourceBase: string + readonly targetBase: string + readonly source: AuthPaths + readonly target: AuthPaths +} + +export type LegacyOrchPaths = AuthPaths & { + readonly ghAuthPath: string + readonly claudeAuthPath: string +} diff --git a/packages/lib/src/usecases/auth-sync.ts b/packages/lib/src/usecases/auth-sync.ts index 328f5c33..8808f398 100644 --- a/packages/lib/src/usecases/auth-sync.ts +++ b/packages/lib/src/usecases/auth-sync.ts @@ -4,108 +4,22 @@ import type * as Path from "@effect/platform/Path" import { Effect } from "effect" import { copyCodexFile, copyDirIfEmpty } from "./auth-copy.js" +import { + type AuthSyncSpec, + defaultCodexConfig, + hasClaudeCredentials, + hasClaudeOauthAccount, + isGithubTokenKey, + type LegacyOrchPaths, + parseJsonRecord, + resolvePathFromBase, + shouldCopyEnv, + shouldRewriteDockerGitCodexConfig, + skipCodexConfigPermissionDenied +} from "./auth-sync-helpers.js" import { parseEnvEntries, removeEnvKey, upsertEnvKey } from "./env-file.js" import { withFsPathContext } from "./runtime.js" -type CopyDecision = "skip" | "copy" -type JsonRecord = Readonly> - -const defaultEnvContents = "# docker-git env\n# KEY=value\n" -// CHANGE: enable web search tool in default Codex config (top-level) -// WHY: avoid deprecated legacy flags and keep config minimal -// QUOTE(ТЗ): "да убери легаси" -// REF: user-request-2026-02-05-remove-legacy-web-search -// SOURCE: n/a -// FORMAT THEOREM: ∀c: config(c) -> web_search(c)="live" -// PURITY: CORE -// EFFECT: n/a -// INVARIANT: default config stays deterministic -// COMPLEXITY: O(1) -const defaultCodexConfig = [ - "# docker-git codex config", - "model = \"gpt-5.3-codex\"", - "model_reasoning_effort = \"xhigh\"", - "personality = \"pragmatic\"", - "", - "approval_policy = \"never\"", - "sandbox_mode = \"danger-full-access\"", - "web_search = \"live\"", - "", - "[features]", - "shell_snapshot = true", - "multi_agent = true", - "apps = true", - "shell_tool = true" -].join("\n") - -const resolvePathFromBase = (path: Path.Path, baseDir: string, targetPath: string): string => - path.isAbsolute(targetPath) ? targetPath : path.resolve(baseDir, targetPath) - -const isPermissionDeniedSystemError = (error: PlatformError): boolean => - error._tag === "SystemError" && error.reason === "PermissionDenied" - -const skipCodexConfigPermissionDenied = ( - configPath: string, - error: PlatformError -): Effect.Effect => - isPermissionDeniedSystemError(error) - ? Effect.logWarning( - `Skipped Codex config sync at ${configPath}: permission denied (${error.description ?? "no details"}).` - ) - : Effect.fail(error) - -const codexConfigMarker = "# docker-git codex config" - -const normalizeConfigText = (text: string): string => - text - .replaceAll("\r\n", "\n") - .trim() - -const shouldRewriteDockerGitCodexConfig = (existing: string): boolean => { - const normalized = normalizeConfigText(existing) - if (normalized.length === 0) { - return true - } - if (!normalized.startsWith(codexConfigMarker)) { - return false - } - return normalized !== normalizeConfigText(defaultCodexConfig) -} - -const shouldCopyEnv = (sourceText: string, targetText: string): CopyDecision => { - if (sourceText.trim().length === 0) { - return "skip" - } - if (targetText.trim().length === 0) { - return "copy" - } - if (targetText.trim() === defaultEnvContents.trim() && sourceText.trim() !== defaultEnvContents.trim()) { - return "copy" - } - return "skip" -} - -const parseJsonRecord = (text: string): JsonRecord | null => { - try { - const parsed: unknown = JSON.parse(text) - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { - return null - } - return parsed as JsonRecord - } catch { - return null - } -} - -const hasClaudeOauthAccount = (record: JsonRecord | null): boolean => - record !== null && typeof record["oauthAccount"] === "object" && record["oauthAccount"] !== null - -const hasClaudeCredentials = (record: JsonRecord | null): boolean => - record !== null && typeof record["claudeAiOauth"] === "object" && record["claudeAiOauth"] !== null - -const isGithubTokenKey = (key: string): boolean => - key === "GITHUB_TOKEN" || key === "GH_TOKEN" || key.startsWith("GITHUB_TOKEN__") - // CHANGE: synchronize GitHub auth keys between env files // WHY: avoid stale per-project tokens that cause clone auth failures after token rotation // QUOTE(ТЗ): n/a @@ -196,94 +110,88 @@ const copyFileIfNeeded = ( }) ) -const syncClaudeHomeJson = ( +type ClaudeJsonSyncSpec = { + readonly sourcePath: string + readonly targetPath: string + readonly hasRequiredData: (record: Parameters[0]) => boolean + readonly onWrite: (targetPath: string) => Effect.Effect + readonly seedLabel: string + readonly updateLabel: string +} + +const syncClaudeJsonFile = ( fs: FileSystem.FileSystem, path: Path.Path, - sourcePath: string, - targetPath: string + spec: ClaudeJsonSyncSpec ): Effect.Effect => Effect.gen(function*(_) { - const sourceExists = yield* _(fs.exists(sourcePath)) + const sourceExists = yield* _(fs.exists(spec.sourcePath)) if (!sourceExists) { return } - const sourceInfo = yield* _(fs.stat(sourcePath)) + const sourceInfo = yield* _(fs.stat(spec.sourcePath)) if (sourceInfo.type !== "File") { return } - const sourceText = yield* _(fs.readFileString(sourcePath)) - const sourceJson = parseJsonRecord(sourceText) - const sourceHasOauth = hasClaudeOauthAccount(sourceJson) + const sourceText = yield* _(fs.readFileString(spec.sourcePath)) + const sourceJson = yield* _(parseJsonRecord(sourceText)) + if (!spec.hasRequiredData(sourceJson)) { + return + } - const targetExists = yield* _(fs.exists(targetPath)) + const targetExists = yield* _(fs.exists(spec.targetPath)) if (!targetExists) { - yield* _(fs.makeDirectory(path.dirname(targetPath), { recursive: true })) - yield* _(fs.copyFile(sourcePath, targetPath)) - yield* _(Effect.log(`Seeded Claude auth file from ${sourcePath} to ${targetPath}`)) + yield* _(fs.makeDirectory(path.dirname(spec.targetPath), { recursive: true })) + yield* _(fs.copyFile(spec.sourcePath, spec.targetPath)) + yield* _(spec.onWrite(spec.targetPath)) + yield* _(Effect.log(`Seeded ${spec.seedLabel} from ${spec.sourcePath} to ${spec.targetPath}`)) return } - const targetInfo = yield* _(fs.stat(targetPath)) + const targetInfo = yield* _(fs.stat(spec.targetPath)) if (targetInfo.type !== "File") { return } - const targetText = yield* _(fs.readFileString(targetPath), Effect.orElseSucceed(() => "")) - const targetJson = parseJsonRecord(targetText) - const targetHasOauth = hasClaudeOauthAccount(targetJson) - - if (sourceHasOauth && !targetHasOauth) { - yield* _(fs.writeFileString(targetPath, sourceText)) - yield* _(Effect.log(`Updated Claude auth file from ${sourcePath} to ${targetPath}`)) + const targetText = yield* _(fs.readFileString(spec.targetPath), Effect.orElseSucceed(() => "")) + const targetJson = yield* _(parseJsonRecord(targetText)) + if (!spec.hasRequiredData(targetJson)) { + yield* _(fs.writeFileString(spec.targetPath, sourceText)) + yield* _(spec.onWrite(spec.targetPath)) + yield* _(Effect.log(`Updated ${spec.updateLabel} from ${spec.sourcePath} to ${spec.targetPath}`)) } }) -const syncClaudeCredentialsJson = ( +const syncClaudeHomeJson = ( fs: FileSystem.FileSystem, path: Path.Path, sourcePath: string, targetPath: string ): Effect.Effect => - Effect.gen(function*(_) { - const sourceExists = yield* _(fs.exists(sourcePath)) - if (!sourceExists) { - return - } - - const sourceInfo = yield* _(fs.stat(sourcePath)) - if (sourceInfo.type !== "File") { - return - } - - const sourceText = yield* _(fs.readFileString(sourcePath)) - const sourceJson = parseJsonRecord(sourceText) - if (!hasClaudeCredentials(sourceJson)) { - return - } - - const targetExists = yield* _(fs.exists(targetPath)) - if (!targetExists) { - yield* _(fs.makeDirectory(path.dirname(targetPath), { recursive: true })) - yield* _(fs.copyFile(sourcePath, targetPath)) - yield* _(fs.chmod(targetPath, 0o600), Effect.orElseSucceed(() => void 0)) - yield* _(Effect.log(`Seeded Claude credentials from ${sourcePath} to ${targetPath}`)) - return - } - - const targetInfo = yield* _(fs.stat(targetPath)) - if (targetInfo.type !== "File") { - return - } + syncClaudeJsonFile(fs, path, { + sourcePath, + targetPath, + hasRequiredData: hasClaudeOauthAccount, + onWrite: () => Effect.void, + seedLabel: "Claude auth file", + updateLabel: "Claude auth file" + }) - const targetText = yield* _(fs.readFileString(targetPath), Effect.orElseSucceed(() => "")) - const targetJson = parseJsonRecord(targetText) - if (!hasClaudeCredentials(targetJson)) { - yield* _(fs.writeFileString(targetPath, sourceText)) - yield* _(fs.chmod(targetPath, 0o600), Effect.orElseSucceed(() => void 0)) - yield* _(Effect.log(`Updated Claude credentials from ${sourcePath} to ${targetPath}`)) - } +const syncClaudeCredentialsJson = ( + fs: FileSystem.FileSystem, + path: Path.Path, + sourcePath: string, + targetPath: string +): Effect.Effect => + syncClaudeJsonFile(fs, path, { + sourcePath, + targetPath, + hasRequiredData: hasClaudeCredentials, + onWrite: (pathToChmod) => fs.chmod(pathToChmod, 0o600).pipe(Effect.orElseSucceed(() => void 0)), + seedLabel: "Claude credentials", + updateLabel: "Claude credentials" }) // CHANGE: seed docker-git Claude auth store from host-level Claude files @@ -356,25 +264,15 @@ export const ensureCodexConfigFile = ( }) yield* _( writeConfig.pipe( - Effect.catchAll((error) => skipCodexConfigPermissionDenied(configPath, error)) + Effect.matchEffect({ + onFailure: (error) => skipCodexConfigPermissionDenied(configPath, error), + onSuccess: () => Effect.void + }) ) ) }) ) -type AuthPaths = { - readonly envGlobalPath: string - readonly envProjectPath: string - readonly codexAuthPath: string -} - -export type AuthSyncSpec = { - readonly sourceBase: string - readonly targetBase: string - readonly source: AuthPaths - readonly target: AuthPaths -} - export const syncAuthArtifacts = ( spec: AuthSyncSpec ): Effect.Effect => @@ -418,11 +316,7 @@ export const syncAuthArtifacts = ( export const migrateLegacyOrchLayout = ( baseDir: string, - envGlobalPath: string, - envProjectPath: string, - codexAuthPath: string, - ghAuthPath: string, - claudeAuthPath: string + paths: LegacyOrchPaths ): Effect.Effect => withFsPathContext(({ fs, path }) => Effect.gen(function*(_) { @@ -442,11 +336,11 @@ export const migrateLegacyOrchLayout = ( const legacyGh = path.join(legacyRoot, "auth", "gh") const legacyClaude = path.join(legacyRoot, "auth", "claude") - const resolvedEnvGlobal = resolvePathFromBase(path, baseDir, envGlobalPath) - const resolvedEnvProject = resolvePathFromBase(path, baseDir, envProjectPath) - const resolvedCodex = resolvePathFromBase(path, baseDir, codexAuthPath) - const resolvedGh = resolvePathFromBase(path, baseDir, ghAuthPath) - const resolvedClaude = resolvePathFromBase(path, baseDir, claudeAuthPath) + const resolvedEnvGlobal = resolvePathFromBase(path, baseDir, paths.envGlobalPath) + const resolvedEnvProject = resolvePathFromBase(path, baseDir, paths.envProjectPath) + const resolvedCodex = resolvePathFromBase(path, baseDir, paths.codexAuthPath) + const resolvedGh = resolvePathFromBase(path, baseDir, paths.ghAuthPath) + const resolvedClaude = resolvePathFromBase(path, baseDir, paths.claudeAuthPath) yield* _(copyFileIfNeeded(legacyEnvGlobal, resolvedEnvGlobal)) yield* _(copyFileIfNeeded(legacyEnvProject, resolvedEnvProject)) diff --git a/packages/lib/tests/usecases/auth-sync.test.ts b/packages/lib/tests/usecases/auth-sync.test.ts index d097aeb5..0fc38914 100644 --- a/packages/lib/tests/usecases/auth-sync.test.ts +++ b/packages/lib/tests/usecases/auth-sync.test.ts @@ -110,14 +110,13 @@ describe("syncGithubAuthKeys", () => { yield* _(fs.writeFileString(legacyTokenPath, expectedToken)) yield* _( - migrateLegacyOrchLayout( - root, - ".docker-git/.orch/env/global.env", - ".orch/env/project.env", - ".docker-git/.orch/auth/codex", - ".docker-git/.orch/auth/gh", - ".docker-git/.orch/auth/claude" - ) + migrateLegacyOrchLayout(root, { + envGlobalPath: ".docker-git/.orch/env/global.env", + envProjectPath: ".orch/env/project.env", + codexAuthPath: ".docker-git/.orch/auth/codex", + ghAuthPath: ".docker-git/.orch/auth/gh", + claudeAuthPath: ".docker-git/.orch/auth/claude" + }) ) const migratedTokenPath = path.join(