Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 52 additions & 21 deletions packages/lib/src/core/command-builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ const resolveNames = (
})

type PathConfig = {
readonly dockerGitPath: string
readonly authorizedKeysPath: string
readonly envGlobalPath: string
readonly envProjectPath: string
Expand All @@ -113,43 +114,72 @@ type PathConfig = {
readonly outDir: string
}

type DefaultPathConfig = {
readonly dockerGitPath: string
readonly authorizedKeysPath: string
readonly envGlobalPath: string
readonly envProjectPath: string
readonly codexAuthPath: string
}

const resolveNormalizedSecretsRoot = (value: string | undefined): string | undefined => {
const trimmed = value?.trim() ?? ""
return trimmed.length === 0 ? undefined : normalizeSecretsRoot(trimmed)
}

const buildDefaultPathConfig = (
normalizedSecretsRoot: string | undefined,
projectSlug: string
): DefaultPathConfig =>
normalizedSecretsRoot === undefined
? {
dockerGitPath: defaultTemplateConfig.dockerGitPath,
authorizedKeysPath: defaultTemplateConfig.authorizedKeysPath,
envGlobalPath: defaultTemplateConfig.envGlobalPath,
envProjectPath: defaultTemplateConfig.envProjectPath,
codexAuthPath: defaultTemplateConfig.codexAuthPath
}
: {
dockerGitPath: normalizedSecretsRoot,
authorizedKeysPath: `${normalizedSecretsRoot}/authorized_keys`,
envGlobalPath: `${normalizedSecretsRoot}/global.env`,
envProjectPath: `${normalizedSecretsRoot}/${projectSlug}.env`,
codexAuthPath: `${normalizedSecretsRoot}/codex`
}

const resolvePaths = (
raw: RawOptions,
projectSlug: string,
repoPath: string
): Either.Either<PathConfig, ParseError> =>
Either.gen(function*(_) {
const secretsRoot = raw.secretsRoot?.trim()
const normalizedSecretsRoot = secretsRoot === undefined || secretsRoot.length === 0
? undefined
: normalizeSecretsRoot(secretsRoot)
const defaultAuthorizedKeysPath = normalizedSecretsRoot === undefined
? defaultTemplateConfig.authorizedKeysPath
: `${normalizedSecretsRoot}/authorized_keys`
const defaultEnvGlobalPath = normalizedSecretsRoot === undefined
? defaultTemplateConfig.envGlobalPath
: `${normalizedSecretsRoot}/global.env`
const defaultEnvProjectPath = normalizedSecretsRoot === undefined
? defaultTemplateConfig.envProjectPath
: `${normalizedSecretsRoot}/${projectSlug}.env`
const defaultCodexAuthPath = normalizedSecretsRoot === undefined
? defaultTemplateConfig.codexAuthPath
: `${normalizedSecretsRoot}/codex`
const normalizedSecretsRoot = resolveNormalizedSecretsRoot(raw.secretsRoot)
const defaults = buildDefaultPathConfig(normalizedSecretsRoot, projectSlug)
const dockerGitPath = defaults.dockerGitPath
const authorizedKeysPath = yield* _(
nonEmpty("--authorized-keys", raw.authorizedKeysPath, defaultAuthorizedKeysPath)
nonEmpty("--authorized-keys", raw.authorizedKeysPath, defaults.authorizedKeysPath)
)
const envGlobalPath = yield* _(nonEmpty("--env-global", raw.envGlobalPath, defaultEnvGlobalPath))
const envGlobalPath = yield* _(nonEmpty("--env-global", raw.envGlobalPath, defaults.envGlobalPath))
const envProjectPath = yield* _(
nonEmpty("--env-project", raw.envProjectPath, defaultEnvProjectPath)
nonEmpty("--env-project", raw.envProjectPath, defaults.envProjectPath)
)
const codexAuthPath = yield* _(
nonEmpty("--codex-auth", raw.codexAuthPath, defaultCodexAuthPath)
nonEmpty("--codex-auth", raw.codexAuthPath, defaults.codexAuthPath)
)
const codexSharedAuthPath = codexAuthPath
const codexHome = yield* _(nonEmpty("--codex-home", raw.codexHome, defaultTemplateConfig.codexHome))
const outDir = yield* _(nonEmpty("--out-dir", raw.outDir, `.docker-git/${repoPath}`))

return { authorizedKeysPath, envGlobalPath, envProjectPath, codexAuthPath, codexSharedAuthPath, codexHome, outDir }
return {
dockerGitPath,
authorizedKeysPath,
envGlobalPath,
envProjectPath,
codexAuthPath,
codexSharedAuthPath,
codexHome,
outDir
}
})

// CHANGE: build a typed create command from raw options (CLI or API)
Expand Down Expand Up @@ -190,6 +220,7 @@ export const buildCreateCommand = (
repoRef: repo.repoRef,
targetDir: repo.targetDir,
volumeName: names.volumeName,
dockerGitPath: paths.dockerGitPath,
authorizedKeysPath: paths.authorizedKeysPath,
envGlobalPath: paths.envGlobalPath,
envProjectPath: paths.envProjectPath,
Expand Down
2 changes: 2 additions & 0 deletions packages/lib/src/core/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface TemplateConfig {
readonly forkRepoUrl?: string
readonly targetDir: string
readonly volumeName: string
readonly dockerGitPath: string
readonly authorizedKeysPath: string
readonly envGlobalPath: string
readonly envProjectPath: string
Expand Down Expand Up @@ -194,6 +195,7 @@ export const defaultTemplateConfig = {
repoRef: "main",
targetDir: "/home/dev/app",
volumeName: "dev_home",
dockerGitPath: "./.docker-git",
authorizedKeysPath: "./.docker-git/authorized_keys",
envGlobalPath: "./.docker-git/.orch/env/global.env",
envProjectPath: "./.orch/env/project.env",
Expand Down
2 changes: 2 additions & 0 deletions packages/lib/src/core/templates-entrypoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
renderEntrypointMcpPlaywright
} from "./templates-entrypoint/codex.js"
import { renderEntrypointGitConfig, renderEntrypointGitHooks } from "./templates-entrypoint/git.js"
import { renderEntrypointDockerGitBootstrap } from "./templates-entrypoint/nested-docker-git.js"
import { renderEntrypointBackgroundTasks } from "./templates-entrypoint/tasks.js"
import {
renderEntrypointBashCompletion,
Expand All @@ -32,6 +33,7 @@ export const renderEntrypoint = (config: TemplateConfig): string =>
renderEntrypointAuthorizedKeys(config),
renderEntrypointCodexHome(config),
renderEntrypointCodexSharedAuth(config),
renderEntrypointDockerGitBootstrap(config),
renderEntrypointMcpPlaywright(config),
renderEntrypointZshShell(config),
renderEntrypointZshUserRc(config),
Expand Down
96 changes: 96 additions & 0 deletions packages/lib/src/core/templates-entrypoint/nested-docker-git.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { TemplateConfig } from "../domain.js"

const entrypointDockerGitBootstrapTemplate = String.raw`# Bootstrap ~/.docker-git for nested docker-git usage inside this container.
DOCKER_GIT_HOME="/home/__SSH_USER__/.docker-git"
DOCKER_GIT_AUTH_DIR="$DOCKER_GIT_HOME/.orch/auth/codex"
DOCKER_GIT_ENV_DIR="$DOCKER_GIT_HOME/.orch/env"
DOCKER_GIT_ENV_GLOBAL="$DOCKER_GIT_ENV_DIR/global.env"
DOCKER_GIT_ENV_PROJECT="$DOCKER_GIT_ENV_DIR/project.env"
DOCKER_GIT_AUTH_KEYS="$DOCKER_GIT_HOME/authorized_keys"

mkdir -p "$DOCKER_GIT_AUTH_DIR" "$DOCKER_GIT_ENV_DIR" "$DOCKER_GIT_HOME/.orch/auth/gh"

if [[ -f "/home/__SSH_USER__/.ssh/authorized_keys" ]]; then
cp "/home/__SSH_USER__/.ssh/authorized_keys" "$DOCKER_GIT_AUTH_KEYS"
elif [[ -f /authorized_keys ]]; then
cp /authorized_keys "$DOCKER_GIT_AUTH_KEYS"
fi
if [[ -f "$DOCKER_GIT_AUTH_KEYS" ]]; then
chmod 600 "$DOCKER_GIT_AUTH_KEYS" || true
fi

if [[ ! -f "$DOCKER_GIT_ENV_GLOBAL" ]]; then
cat <<'EOF' > "$DOCKER_GIT_ENV_GLOBAL"
# docker-git env
# KEY=value
EOF
fi
if [[ ! -f "$DOCKER_GIT_ENV_PROJECT" ]]; then
cat <<'EOF' > "$DOCKER_GIT_ENV_PROJECT"
# docker-git project env defaults
CODEX_SHARE_AUTH=1
CODEX_AUTO_UPDATE=1
DOCKER_GIT_ZSH_AUTOSUGGEST=1
DOCKER_GIT_ZSH_AUTOSUGGEST_STYLE=fg=8,italic
DOCKER_GIT_ZSH_AUTOSUGGEST_STRATEGY=history completion
MCP_PLAYWRIGHT_ISOLATED=1
EOF
fi

upsert_env_var() {
local file="$1"
local key="$2"
local value="$3"
local tmp
tmp="$(mktemp)"
awk -v key="$key" 'index($0, key "=") != 1 { print }' "$file" > "$tmp"
printf "%s=%s\n" "$key" "$value" >> "$tmp"
mv "$tmp" "$file"
}

copy_if_distinct_file() {
local source="$1"
local target="$2"
if [[ ! -f "$source" ]]; then
return 1
fi
local source_real=""
local target_real=""
source_real="$(readlink -f "$source" 2>/dev/null || true)"
target_real="$(readlink -f "$target" 2>/dev/null || true)"
if [[ -n "$source_real" && -n "$target_real" && "$source_real" == "$target_real" ]]; then
return 0
fi
cp "$source" "$target"
return 0
}

if [[ -n "$GH_TOKEN" ]]; then
upsert_env_var "$DOCKER_GIT_ENV_GLOBAL" "GH_TOKEN" "$GH_TOKEN"
fi
if [[ -n "$GITHUB_TOKEN" ]]; then
upsert_env_var "$DOCKER_GIT_ENV_GLOBAL" "GITHUB_TOKEN" "$GITHUB_TOKEN"
elif [[ -n "$GH_TOKEN" ]]; then
upsert_env_var "$DOCKER_GIT_ENV_GLOBAL" "GITHUB_TOKEN" "$GH_TOKEN"
fi

SOURCE_CODEX_CONFIG="__CODEX_HOME__/config.toml"
copy_if_distinct_file "$SOURCE_CODEX_CONFIG" "$DOCKER_GIT_AUTH_DIR/config.toml" || true

SOURCE_SHARED_AUTH="__CODEX_HOME__-shared/auth.json"
SOURCE_LOCAL_AUTH="__CODEX_HOME__/auth.json"
if [[ -f "$SOURCE_SHARED_AUTH" ]]; then
copy_if_distinct_file "$SOURCE_SHARED_AUTH" "$DOCKER_GIT_AUTH_DIR/auth.json" || true
elif [[ -f "$SOURCE_LOCAL_AUTH" ]]; then
copy_if_distinct_file "$SOURCE_LOCAL_AUTH" "$DOCKER_GIT_AUTH_DIR/auth.json" || true
fi
if [[ -f "$DOCKER_GIT_AUTH_DIR/auth.json" ]]; then
chmod 600 "$DOCKER_GIT_AUTH_DIR/auth.json" || true
fi

chown -R 1000:1000 "$DOCKER_GIT_HOME" || true`

export const renderEntrypointDockerGitBootstrap = (config: TemplateConfig): string =>
entrypointDockerGitBootstrapTemplate
.replaceAll("__SSH_USER__", config.sshUser)
.replaceAll("__CODEX_HOME__", config.codexHome)
1 change: 1 addition & 0 deletions packages/lib/src/core/templates/docker-compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ ${maybePlaywrightEnv}${maybeDependsOn} env_file:
- "127.0.0.1:${config.sshPort}:22"
volumes:
- ${config.volumeName}:/home/${config.sshUser}
- ${config.dockerGitPath}:/home/${config.sshUser}/.docker-git
- ${config.authorizedKeysPath}:/authorized_keys:ro
- ${config.codexAuthPath}:${config.codexHome}
- ${config.codexSharedAuthPath}:${config.codexHome}-shared
Expand Down
2 changes: 1 addition & 1 deletion packages/lib/src/core/templates/dockerfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ ENV NVM_DIR=/usr/local/nvm

RUN apt-get update && apt-get install -y --no-install-recommends \
openssh-server git gh ca-certificates curl unzip bsdutils sudo \
make docker.io docker-compose bash-completion zsh zsh-autosuggestions xauth \
make docker.io docker-compose-v2 bash-completion zsh zsh-autosuggestions xauth \
ncurses-term \
&& rm -rf /var/lib/apt/lists/*

Expand Down
3 changes: 3 additions & 0 deletions packages/lib/src/shell/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ const TemplateConfigSchema = Schema.Struct({
repoRef: Schema.String,
targetDir: Schema.String,
volumeName: Schema.String,
dockerGitPath: Schema.optionalWith(Schema.String, {
default: () => defaultTemplateConfig.dockerGitPath
}),
authorizedKeysPath: Schema.String,
envGlobalPath: Schema.optionalWith(Schema.String, {
default: () => defaultTemplateConfig.envGlobalPath
Expand Down
1 change: 1 addition & 0 deletions packages/lib/src/usecases/actions/create-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const makeCreateContext = (path: Path.Path, baseDir: string): CreateContext => {

const resolveRootedConfig = (command: CreateCommand, ctx: CreateContext): CreateCommand["config"] => ({
...command.config,
dockerGitPath: ctx.resolveRootPath(command.config.dockerGitPath),
authorizedKeysPath: ctx.resolveRootPath(command.config.authorizedKeysPath),
envGlobalPath: ctx.resolveRootPath(command.config.envGlobalPath),
envProjectPath: ctx.resolveRootPath(command.config.envProjectPath),
Expand Down
2 changes: 2 additions & 0 deletions packages/lib/src/usecases/actions/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const buildProjectConfigs = (

const globalConfig = {
...resolvedConfig,
dockerGitPath: resolvePathFromBase(path, baseDir, resolvedConfig.dockerGitPath),
authorizedKeysPath: resolvePathFromBase(path, baseDir, resolvedConfig.authorizedKeysPath),
envGlobalPath: resolvePathFromBase(path, baseDir, resolvedConfig.envGlobalPath),
envProjectPath: resolvePathFromBase(path, baseDir, resolvedConfig.envProjectPath),
Expand All @@ -52,6 +53,7 @@ export const buildProjectConfigs = (
}
const projectConfig = {
...resolvedConfig,
dockerGitPath: relativeFromOutDir(globalConfig.dockerGitPath),
authorizedKeysPath: relativeFromOutDir(globalConfig.authorizedKeysPath),
envGlobalPath: "./.orch/env/global.env",
envProjectPath: path.isAbsolute(resolvedConfig.envProjectPath)
Expand Down
29 changes: 20 additions & 9 deletions packages/lib/src/usecases/state-normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,25 +21,35 @@ const isLegacyDockerGitRelativePath = (value: string): boolean => {
const shouldNormalizePath = (path: Path.Path, value: string): boolean =>
path.isAbsolute(value) || isLegacyDockerGitRelativePath(value)

const withFallback = (value: string, fallback: string): string =>
value.length > 0 ? value : fallback

const pathFieldsForNormalization = (template: TemplateConfig): ReadonlyArray<string> => [
template.dockerGitPath,
template.authorizedKeysPath,
template.envGlobalPath,
template.envProjectPath,
template.codexAuthPath,
template.codexSharedAuthPath
]

const hasLegacyTemplatePaths = (path: Path.Path, template: TemplateConfig): boolean =>
pathFieldsForNormalization(template).some((value) => shouldNormalizePath(path, value))

const normalizeTemplateConfig = (
path: Path.Path,
projectsRoot: string,
projectDir: string,
template: TemplateConfig
): TemplateConfig | null => {
const needs = shouldNormalizePath(path, template.authorizedKeysPath) ||
shouldNormalizePath(path, template.envGlobalPath) ||
shouldNormalizePath(path, template.envProjectPath) ||
shouldNormalizePath(path, template.codexAuthPath) ||
shouldNormalizePath(path, template.codexSharedAuthPath)

if (!needs) {
if (!hasLegacyTemplatePaths(path, template)) {
return null
}

// The state repo is shared across machines, so never persist absolute host paths in tracked files.
const authorizedKeysAbs = path.join(projectsRoot, "authorized_keys")
const authorizedKeysRel = toPosixPath(path.relative(projectDir, authorizedKeysAbs))
const dockerGitRel = toPosixPath(path.relative(projectDir, projectsRoot))

const envGlobalPath = "./.orch/env/global.env"
const envProjectPath = "./.orch/env/project.env"
Expand All @@ -49,11 +59,12 @@ const normalizeTemplateConfig = (

return {
...template,
authorizedKeysPath: authorizedKeysRel.length > 0 ? authorizedKeysRel : "./authorized_keys",
dockerGitPath: withFallback(dockerGitRel, "./.docker-git"),
authorizedKeysPath: withFallback(authorizedKeysRel, "./authorized_keys"),
envGlobalPath,
envProjectPath,
codexAuthPath,
codexSharedAuthPath: codexSharedRel.length > 0 ? codexSharedRel : "./.orch/auth/codex"
codexSharedAuthPath: withFallback(codexSharedRel, "./.orch/auth/codex")
}
}

Expand Down
8 changes: 8 additions & 0 deletions packages/lib/tests/usecases/prepare-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const makeGlobalConfig = (root: string, path: Path.Path): TemplateConfig => ({
repoRef: "main",
targetDir: "/home/dev/org/repo",
volumeName: "dg-test-home",
dockerGitPath: path.join(root, ".docker-git"),
authorizedKeysPath: path.join(root, "authorized_keys"),
envGlobalPath: path.join(root, ".orch/env/global.env"),
envProjectPath: path.join(root, ".orch/env/project.env"),
Expand All @@ -54,6 +55,7 @@ const makeProjectConfig = (
repoRef: "main",
targetDir: "/home/dev/org/repo",
volumeName: "dg-test-home",
dockerGitPath: path.join(outDir, ".docker-git"),
authorizedKeysPath: path.join(outDir, "authorized_keys"),
envGlobalPath: path.join(outDir, ".orch/env/global.env"),
envProjectPath: path.join(outDir, ".orch/env/project.env"),
Expand Down Expand Up @@ -99,7 +101,13 @@ describe("prepareProjectFiles", () => {
})
)

const dockerfile = yield* _(fs.readFileString(path.join(outDir, "Dockerfile")))
const entrypoint = yield* _(fs.readFileString(path.join(outDir, "entrypoint.sh")))
const composeBefore = yield* _(fs.readFileString(path.join(outDir, "docker-compose.yml")))
expect(dockerfile).toContain("docker-compose-v2")
expect(entrypoint).toContain('DOCKER_GIT_HOME="/home/dev/.docker-git"')
expect(entrypoint).toContain('SOURCE_SHARED_AUTH="/home/dev/.codex-shared/auth.json"')
expect(composeBefore).toContain(":/home/dev/.docker-git")
expect(composeBefore).not.toContain("dg-test-browser")

yield* _(
Expand Down