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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
Key goals:
- Functional Core, Imperative Shell implementation (pure templates + typed orchestration).
- Per-project `.orch/` directory (env + local state), while still allowing shared credentials across containers.
- Shared package caches (`pnpm`/`npm`/`yarn`) across all project containers.
- Optional Playwright MCP + Chromium sidecar so Codex can do browser automation.

## Quickstart
Expand Down Expand Up @@ -63,6 +64,9 @@ Structure (simplified):
auth/
codex/ # shared Codex auth/config (when CODEX_SHARE_AUTH=1)
gh/ # GH CLI auth cache for OAuth login container
.cache/
git-mirrors/ # shared git clone mirrors
packages/ # shared pnpm/npm/yarn caches
<owner>/<repo>/
docker-compose.yml
Dockerfile
Expand Down Expand Up @@ -115,6 +119,9 @@ Common toggles:
- `DOCKER_GIT_ZSH_AUTOSUGGEST=1|0` (default: `1`)
- `MCP_PLAYWRIGHT_ISOLATED=1|0` (default: `1`)
- `MCP_PLAYWRIGHT_CDP_ENDPOINT=http://...` (override CDP endpoint if needed)
- `PNPM_STORE_DIR=/home/dev/.docker-git/.cache/packages/pnpm/store` (default shared store)
- `NPM_CONFIG_CACHE=/home/dev/.docker-git/.cache/packages/npm` (default shared cache)
- `YARN_CACHE_FOLDER=/home/dev/.docker-git/.cache/packages/yarn` (default shared cache)

## Troubleshooting

Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/docker-git/menu-render-select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ export const renderSelectDetails = (
? el(Text, { color: "yellow", wrap: "wrap" }, "Warning: project has active SSH sessions.")
: el(Text, { color: "gray", wrap: "wrap" }, "No active SSH sessions detected."),
el(Text, { wrap: "wrap" }, `Repo: ${context.item.repoUrl} (${context.refLabel})`),
el(Text, { wrap: "wrap" }, "Removes the project folder (no git history rewrite).")
el(Text, { wrap: "wrap" }, "Removes project folder and runs docker compose down -v.")
]),
Match.orElse(() => renderDefaultDetails(el, context))
)
Expand Down
2 changes: 1 addition & 1 deletion packages/app/src/docker-git/menu-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,6 @@ export const menuItems: ReadonlyArray<{ readonly id: MenuAction; readonly label:
{ id: { _tag: "Logs" }, label: "docker compose logs --tail=200" },
{ id: { _tag: "Down" }, label: "docker compose down" },
{ id: { _tag: "DownAll" }, label: "docker compose down (ALL projects)" },
{ id: { _tag: "Delete" }, label: "Delete project (remove folder)" },
{ id: { _tag: "Delete" }, label: "Delete project (folder + container)" },
{ id: { _tag: "Quit" }, label: "Quit" }
]
4 changes: 2 additions & 2 deletions packages/docker-git/src/server/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ ${codexConfigLine}

[features]
shell_snapshot = true
collab = true
multi_agent = true
apps = true

[projects."/home/dev"]
Expand All @@ -101,7 +101,7 @@ web_search = "live"

[features]
shell_snapshot = true
collab = true
multi_agent = true
apps = true
`

Expand Down
4 changes: 4 additions & 0 deletions packages/docker-git/tests/core/templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ describe("planFiles", () => {
)
expect(entrypointSpec.contents).toContain("token=\"$GITHUB_TOKEN\"")
expect(entrypointSpec.contents).toContain("CACHE_ROOT=\"/home/dev/.docker-git/.cache/git-mirrors\"")
expect(entrypointSpec.contents).toContain("PACKAGE_CACHE_ROOT=\"/home/dev/.docker-git/.cache/packages\"")
expect(entrypointSpec.contents).toContain("npm_config_store_dir")
expect(entrypointSpec.contents).toContain("NPM_CONFIG_CACHE")
expect(entrypointSpec.contents).toContain("YARN_CACHE_FOLDER")
expect(entrypointSpec.contents).toContain("CLONE_CACHE_ARGS=\"--reference-if-able '$CACHE_REPO_DIR' --dissociate\"")
expect(entrypointSpec.contents).toContain("[clone-cache] using mirror: $CACHE_REPO_DIR")
expect(entrypointSpec.contents).toContain("git clone --progress $CLONE_CACHE_ARGS")
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 @@ -6,6 +6,7 @@ import {
renderEntrypointDockerSocket,
renderEntrypointHeader,
renderEntrypointInputRc,
renderEntrypointPackageCache,
renderEntrypointSshd,
renderEntrypointZshShell,
renderEntrypointZshUserRc
Expand All @@ -32,6 +33,7 @@ import {
export const renderEntrypoint = (config: TemplateConfig): string =>
[
renderEntrypointHeader(config),
renderEntrypointPackageCache(config),
renderEntrypointAuthorizedKeys(config),
renderEntrypointCodexHome(config),
renderEntrypointCodexSharedAuth(config),
Expand Down
25 changes: 25 additions & 0 deletions packages/lib/src/core/templates-entrypoint/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,31 @@ docker_git_upsert_ssh_env() {
chown 1000:1000 "$SSH_ENV_PATH" || true
}`

export const renderEntrypointPackageCache = (config: TemplateConfig): string =>
`# Share package manager caches across all docker-git containers
PACKAGE_CACHE_ROOT="/home/${config.sshUser}/.docker-git/.cache/packages"
PACKAGE_PNPM_STORE="\${npm_config_store_dir:-\${PNPM_STORE_DIR:-$PACKAGE_CACHE_ROOT/pnpm/store}}"
PACKAGE_NPM_CACHE="\${npm_config_cache:-\${NPM_CONFIG_CACHE:-$PACKAGE_CACHE_ROOT/npm}}"
PACKAGE_YARN_CACHE="\${YARN_CACHE_FOLDER:-$PACKAGE_CACHE_ROOT/yarn}"

mkdir -p "$PACKAGE_PNPM_STORE" "$PACKAGE_NPM_CACHE" "$PACKAGE_YARN_CACHE"
chown -R 1000:1000 "$PACKAGE_CACHE_ROOT" || true

cat <<EOF > /etc/profile.d/docker-git-package-cache.sh
export PNPM_STORE_DIR="$PACKAGE_PNPM_STORE"
export npm_config_store_dir="$PACKAGE_PNPM_STORE"
export NPM_CONFIG_CACHE="$PACKAGE_NPM_CACHE"
export npm_config_cache="$PACKAGE_NPM_CACHE"
export YARN_CACHE_FOLDER="$PACKAGE_YARN_CACHE"
EOF
chmod 0644 /etc/profile.d/docker-git-package-cache.sh

docker_git_upsert_ssh_env "PNPM_STORE_DIR" "$PACKAGE_PNPM_STORE"
docker_git_upsert_ssh_env "npm_config_store_dir" "$PACKAGE_PNPM_STORE"
docker_git_upsert_ssh_env "NPM_CONFIG_CACHE" "$PACKAGE_NPM_CACHE"
docker_git_upsert_ssh_env "npm_config_cache" "$PACKAGE_NPM_CACHE"
docker_git_upsert_ssh_env "YARN_CACHE_FOLDER" "$PACKAGE_YARN_CACHE"`

export const renderEntrypointAuthorizedKeys = (config: TemplateConfig): string =>
`# 1) Authorized keys are mounted from host at /authorized_keys
mkdir -p /home/${config.sshUser}/.ssh
Expand Down
2 changes: 1 addition & 1 deletion packages/lib/src/core/templates-entrypoint/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ web_search = "live"

[features]
shell_snapshot = true
collab = true
multi_agent = true
apps = true
shell_tool = true
EOF
Expand Down
2 changes: 1 addition & 1 deletion packages/lib/src/usecases/auth-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const defaultCodexConfig = [
"",
"[features]",
"shell_snapshot = true",
"collab = true",
"multi_agent = true",
"apps = true",
"shell_tool = true"
].join("\n")
Expand Down
61 changes: 49 additions & 12 deletions packages/lib/src/usecases/projects-delete.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { CommandExecutor } from "@effect/platform/CommandExecutor"
import type * as CommandExecutor from "@effect/platform/CommandExecutor"
import type { PlatformError } from "@effect/platform/Error"
import * as FileSystem from "@effect/platform/FileSystem"
import * as Path from "@effect/platform/Path"
import { Effect } from "effect"
import { deriveRepoPathParts } from "../core/domain.js"
import { runDockerComposeDown } from "../shell/docker.js"
import type { DockerCommandError } from "../shell/errors.js"
import { runCommandWithExitCodes } from "../shell/command-runner.js"
import { runDockerComposeDownVolumes } from "../shell/docker.js"
import { CommandFailedError, type DockerCommandError } from "../shell/errors.js"
import { renderError } from "./errors.js"
import { defaultProjectsRoot } from "./menu-helpers.js"
import type { ProjectItem } from "./projects-core.js"
Expand All @@ -25,19 +26,52 @@ const isWithinProjectsRoot = (path: Path.Path, root: string, target: string): bo
return true
}

const removeContainerByName = (
cwd: string,
containerName: string
): Effect.Effect<void, never, CommandExecutor.CommandExecutor> =>
runCommandWithExitCodes(
{
cwd,
command: "docker",
args: ["rm", "-f", containerName]
},
[0],
(exitCode) => new CommandFailedError({ command: `docker rm -f ${containerName}`, exitCode })
).pipe(
Effect.matchEffect({
onFailure: (error) =>
Effect.logWarning(`docker rm -f fallback failed for ${containerName}: ${renderError(error)}`),
onSuccess: () => Effect.log(`Removed container: ${containerName}`)
}),
Effect.asVoid
)

const removeContainersFallback = (
item: ProjectItem
): Effect.Effect<void, never, CommandExecutor.CommandExecutor> =>
Effect.gen(function*(_) {
yield* _(removeContainerByName(item.projectDir, item.containerName))
yield* _(removeContainerByName(item.projectDir, `${item.containerName}-browser`))
})

// CHANGE: delete a docker-git project directory (state) selected in the TUI
// WHY: allow removing unwanted projects without rewriting git history (just delete the folder)
// QUOTE(ТЗ): "Сделай возможность так же удалять мусорный для меня контейнер... Не нужно чистить гит историю. Пусть просто папку с ним удалит"
// REF: user-request-2026-02-09-delete-project
// SOURCE: n/a
// FORMAT THEOREM: forall p: delete(p) -> !exists(projectDir(p))
// FORMAT THEOREM: forall p: delete(p) -> !exists(projectDir(p)) && !container_exists(p)
// PURITY: SHELL
// EFFECT: Effect<void, PlatformError | DockerCommandError, FileSystem | Path | CommandExecutor>
// INVARIANT: never deletes paths outside the projects root
// COMPLEXITY: O(docker + fs)
export const deleteDockerGitProject = (
item: ProjectItem
): Effect.Effect<void, PlatformError | DockerCommandError, FileSystem.FileSystem | Path.Path | CommandExecutor> =>
): Effect.Effect<
void,
PlatformError | DockerCommandError,
FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor
> =>
Effect.gen(function*(_) {
const fs = yield* _(FileSystem.FileSystem)
const path = yield* _(Path.Path)
Expand All @@ -56,14 +90,17 @@ export const deleteDockerGitProject = (
return
}

// Best-effort: stop the container if possible before removing the compose dir.
// Best-effort: remove compose containers and volumes before deleting the project folder.
yield* _(
runDockerComposeDown(targetDir).pipe(
Effect.catchTag(
"DockerCommandError",
(error: DockerCommandError) =>
Effect.logWarning(`docker compose down failed before delete: ${renderError(error)}`)
)
runDockerComposeDownVolumes(targetDir).pipe(
Effect.matchEffect({
onFailure: (error) =>
Effect.gen(function*(_) {
yield* _(Effect.logWarning(`docker compose down -v failed before delete: ${renderError(error)}`))
yield* _(removeContainersFallback(item))
}),
onSuccess: () => Effect.void
})
)
)

Expand Down
23 changes: 22 additions & 1 deletion packages/lib/src/usecases/state-repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ type StateRepoEnv = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandE

const resolveStateRoot = (path: Path.Path, cwd: string): string => path.resolve(defaultProjectsRoot(cwd))

const managedRepositoryCachePaths: ReadonlyArray<string> = [".cache/git-mirrors", ".cache/packages"]

const ensureStateIgnoreAndUntrackCaches = (
fs: FileSystem.FileSystem,
path: Path.Path,
root: string
): Effect.Effect<void, CommandFailedError | PlatformError, StateRepoEnv> =>
Effect.gen(function*(_) {
yield* _(ensureStateGitignore(fs, path, root))
// Best-effort idempotent cleanup: keep cache artifacts out of git history.
yield* _(git(root, ["rm", "-r", "--cached", "--ignore-unmatch", ...managedRepositoryCachePaths], gitBaseEnv))
}).pipe(Effect.asVoid)

export const statePath: Effect.Effect<void, PlatformError, Path.Path> = Effect.gen(function*(_) {
const path = yield* _(Path.Path)
const cwd = process.cwd()
Expand All @@ -48,6 +61,8 @@ export const stateSync = (
)
}

yield* _(ensureStateIgnoreAndUntrackCaches(fs, path, root))

const originUrlExit = yield* _(gitExitCode(root, ["remote", "get-url", "origin"], gitBaseEnv))
if (originUrlExit !== successExitCode) {
yield* _(Effect.logWarning(`State dir has no origin remote: ${root}`))
Expand Down Expand Up @@ -269,11 +284,17 @@ export const statePush = Effect.gen(function*(_) {

export const stateCommit = (
message: string
): Effect.Effect<void, CommandFailedError | PlatformError, Path.Path | CommandExecutor.CommandExecutor> =>
): Effect.Effect<
void,
CommandFailedError | PlatformError,
FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor
> =>
Effect.gen(function*(_) {
const fs = yield* _(FileSystem.FileSystem)
const path = yield* _(Path.Path)
const root = resolveStateRoot(path, process.cwd())

yield* _(ensureStateIgnoreAndUntrackCaches(fs, path, root))
yield* _(git(root, ["add", "-A"], gitBaseEnv))
const diffExit = yield* _(gitExitCode(root, ["diff", "--cached", "--quiet"], gitBaseEnv))

Expand Down
7 changes: 4 additions & 3 deletions packages/lib/src/usecases/state-repo/gitignore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,16 @@ const volatileCodexIgnorePatterns: ReadonlyArray<string> = [
]

const repositoryCacheIgnorePatterns: ReadonlyArray<string> = [
".cache/git-mirrors/"
".cache/git-mirrors/",
".cache/packages/"
]

const defaultStateGitignore = [
stateGitignoreMarker,
"# NOTE: this repo intentionally tracks EVERYTHING under the state dir, including .orch/env and .orch/auth.",
"# Keep the remote private; treat it as sensitive infrastructure state.",
"",
"# Shared git mirrors cache (do not commit)",
"# Shared repository caches (do not commit)",
...repositoryCacheIgnorePatterns,
"",
"# Volatile Codex artifacts (do not commit)",
Expand Down Expand Up @@ -58,7 +59,7 @@ const appendManagedBlocks = (
): string => {
const blocks = [
missing.repositoryCache.length > 0
? `# Shared git mirrors cache (do not commit)\n${missing.repositoryCache.join("\n")}`
? `# Shared repository caches (do not commit)\n${missing.repositoryCache.join("\n")}`
: "",
missing.volatileCodex.length > 0
? `# Volatile Codex artifacts (do not commit)\n${missing.volatileCodex.join("\n")}`
Expand Down
Loading