Skip to content
 
 

Repository files navigation

Devcontainer Kit

Reusable .devcontainer/ toolkit for projects that want the normal VS Code Dev Containers workflow with the current Codegeist/planner development toolchain.

Purpose

This repository is a reusable devcontainer kit that can be added to other repositories at .devcontainer/, either as a Git subtree or as a Git submodule. The current Dockerfile intentionally carries the full Codegeist/planner-style toolchain, including Docker CE, Node 24, VS Code, GitHub CLI, Maven, GraalVM, Hugo, Nix, OpenCode tooling, Repomix, and related CLI tools.

The consuming project should use the standard VS Code flow:

  1. Clone the consuming repository.
  2. Open the repository folder in VS Code.
  3. Run Dev Containers: Reopen in Container.

The kit should not require a root-level launcher such as start.sh for normal VS Code usage. It should also not open VS Code from its own scripts. VS Code and the Dev Containers extension own the container lifecycle.

The devcontainer user follows the host $USER: remoteUser and containerUser both use ${localEnv:USER}. initialize.sh writes matching generated Docker build arguments and numeric runtime user values so the user exists in the image and bind-mounted files use the host UID for both the numeric user and group.

The same configuration can be smoke-tested without opening VS Code by running the Dev Containers CLI against the repository root:

npx --yes @devcontainers/cli up --workspace-folder <repo-root>

VS Code opens the container workspace at /workspace. Without BRANCH, the Compose runtime mounts the repository root there. With BRANCH, initializeCommand creates or reuses the matching Git worktree and docker-compose.yml mounts that worktree at /workspace while still mounting the repository root at its host path for linked-worktree Git metadata.

Quick Start For Consuming Repos

Use one of the two installation modes below to place this kit at .devcontainer/ in a consuming repository. After installing it, add these local files to the consuming repository's .gitignore:

/.local.env
/compose.local.yml
/.worktrees/

Open the consuming repository root in VS Code and run Dev Containers: Reopen in Container:

code .

To select a managed Git worktree at /workspace, still start VS Code from the consuming repository root and set BRANCH:

BRANCH=develop0 code .

The first start creates local generated files when missing:

  • root .local.env
  • root compose.local.yml
  • .devcontainer/.gen.env
  • .devcontainer/compose.local.gen.yml
  • .worktrees/<branch> when BRANCH is set

Do not edit .devcontainer/.gen.env or .devcontainer/compose.local.gen.yml; they are regenerated by initialize.sh. Put manual overrides in root .local.env and compose.local.yml instead.

Develop This Kit

Clone this repository with submodules initialized because .opencode/ is a shared OpenCode workspace submodule:

git clone --recurse-submodules <this-repo-url>
cd <repo>

If the repository was already cloned without submodules, initialize them later:

git submodule update --init --recursive

Run the local test suite from this repository root:

task tests-run

Open the current Git root with the real VS Code entrypoint:

task code-open
task code-open -- develop0

BRANCH=develop0 task code-open is still accepted for shell-driven runs. When a branch is passed as a task argument, the command writes .devcontainer/.env so VS Code and Docker Compose keep the branch even if an existing VS Code process handles the code request.

Run the fixture-backed reality test when you need to exercise the same command against a temporary consuming repository:

task code-open-test
task code-open-test -- develop0

The reality test intentionally leaves its temporary fixture and VS Code-started container in place because VS Code is opened against that fixture.

OpenCode Workspace

This repository includes .opencode/ as a Git submodule pointing to https://github.com/codegeist-ai/codegeist-agent-kit. It provides shared OpenCode commands, rules, and skills used while maintaining this kit.

Keep it initialized for development work:

git submodule update --init --recursive .opencode

The submodule is not part of the consuming .devcontainer/ runtime contract; it is repository-local AI workflow support for this kit.

Git Subtree Setup

Use a Git subtree when a project should vendor this kit into .devcontainer/ without making consumers initialize a submodule. The consuming repository stores the kit files directly in its history, while still allowing updates from the upstream kit repository.

Add the kit to a consuming repository:

git remote add devcontainer-kit <kit-repo-url>
git fetch devcontainer-kit
git subtree add --prefix=.devcontainer devcontainer-kit <branch> --squash

Replace <kit-repo-url> with this repository URL and <branch> with the kit branch to consume, for example main.

Update the vendored kit later:

git fetch devcontainer-kit
git subtree pull --prefix=.devcontainer devcontainer-kit <branch> --squash

Commit the subtree add or pull like any other source change in the consuming repository.

If the consuming repository already has a .devcontainer/ directory, move or remove that directory first. git subtree add expects the target prefix to be absent or empty.

The consuming repository should ignore local files generated next to the subtree:

/.local.env
/compose.local.yml
/.worktrees/

The kit also writes generated runtime files inside .devcontainer/: .gen.env and compose.local.gen.yml. They are ignored by the kit's own .gitignore and should not be edited manually.

initializeCommand is not a bootstrap mechanism for downloading the kit. If .devcontainer/devcontainer.json is not present in the checkout, VS Code cannot discover or run the devcontainer configuration at all.

Git Submodule Setup

Use a Git submodule when the consuming repository should keep the kit as a separate repository mounted at .devcontainer/.

Add the kit to a consuming repository:

git submodule add <kit-repo-url> .devcontainer
git commit -m "chore(devcontainer): add shared kit submodule"

Clone or update consuming repositories with submodules initialized:

git submodule update --init --recursive

The same generated root files must still be ignored by the consuming repository:

/.local.env
/compose.local.yml
/.worktrees/

The .devcontainer/.gen.env and .devcontainer/compose.local.gen.yml files are generated by the kit inside the submodule checkout and intentionally ignored there.

The test suite includes a real submodule-consuming fixture that starts BRANCH=dev0 through devcontainer up, verifies /workspace, nested Docker, and a commit/fast-forward merge flow from inside the container.

Normal VS Code Workflow

The primary entrypoint is devcontainer.json.

The intended flow is:

VS Code opens repository
Dev Containers extension reads .devcontainer/devcontainer.json
initializeCommand creates local compose/env files when missing
Docker Compose builds and starts the workspace service
VS Code attaches to the workspace service

This means the kit should avoid old launcher-style behavior in the normal VS Code path:

  • no code --new-window from repository scripts
  • no recursive reopen-in-container behavior
  • no root start.sh dependency
  • no project-specific assumptions such as CODEGEIST_*

initializeCommand Contract

initializeCommand is the only standard devcontainer lifecycle hook that runs on the host before the container exists. It is useful for preparing local files that Docker Compose or the container runtime will consume.

Recommended shape:

{
  "initializeCommand": ".devcontainer/initialize.sh"
}

The initializer must be:

  • idempotent
  • non-interactive
  • fast enough for repeated starts
  • safe when run multiple times in one VS Code session
  • limited to host-side preparation

It must not:

  • open VS Code
  • start long-running foreground processes
  • start or remove the devcontainer project
  • delete user data or running containers
  • assume it runs only once
  • rely on exporting variables back into VS Code

It may:

  • create root .local.env from .devcontainer/.local.env.example
  • create root compose.local.yml from .devcontainer/compose.local.yml.example
  • write .devcontainer/.gen.env and .devcontainer/compose.local.gen.yml generated runtime values such as the container hostname and numeric runtime user
  • update generated local env values when their content changed
  • compute host UID
  • compute a stable project name
  • compute the host short name
  • create local cache/config directories
  • validate required host tools and print clear errors

Environment Model

initializeCommand cannot export environment variables back into the already running VS Code process. Values produced by initialize.sh should therefore be written to files, not expected to appear as ${localEnv:...} values.

Preferred pattern:

  1. initialize.sh writes root .local.env.
  2. initialize.sh writes root compose.local.yml.
  3. initialize.sh writes .devcontainer/.gen.env.
  4. initialize.sh writes .devcontainer/compose.local.gen.yml.
  5. .devcontainer/docker-compose.yml reads .gen.env and ../.local.env with env_file.
  6. devcontainer.json includes compose.local.gen.yml and ../compose.local.yml.
  7. .devcontainer/docker-compose.yml owns the workspace and parent Git mounts.
  8. devcontainer.json uses ${localEnv:USER} for remoteUser and containerUser.
  9. Container-side tools read normal environment variables from Compose.

Example Compose shape:

services:
  workspace:
    env_file:
      - path: .gen.env
        required: false
      - path: ../.local.env
        required: false

Avoid generating values for devcontainer.json to read through ${localEnv:...}. The Dev Containers extension may have captured host environment variables before initializeCommand writes anything, and clients may need a restart to pick up changed host environment.

Lifecycle Constraints

Official Dev Container lifecycle behavior relevant to this kit:

  • initializeCommand runs on the host machine.
  • It can run during container creation and on later starts.
  • It may run more than once in a session.
  • String commands run through /bin/sh.
  • Array commands execute directly without a shell.
  • If a lifecycle command fails, later lifecycle commands are skipped.

Implementation consequence: initialize.sh must fail only for real blockers and must produce clear diagnostics. Best-effort setup should not make the whole container unusable unless the missing state is required.

CLI And IDE Differences

VS Code and the devcontainer CLI do not behave identically in every path. Known problem areas from upstream discussions include:

  • devcontainer build may not run initializeCommand like VS Code does.
  • CLI rebuild ordering around existing containers can differ from VS Code.
  • initializeCommand has been observed to run on reopen, not only first create.

Tests should therefore cover the behavior the kit relies on directly. A build test alone is not enough to prove host initialization behavior.

Test Strategy

The kit should be tested as close to the real workflow as possible with the Dev Containers CLI. Prefer devcontainer up over only validating files or running docker compose directly, because devcontainer up exercises the same configuration model that VS Code uses.

Primary smoke command:

npx --yes @devcontainers/cli up --workspace-folder <fixture-repo>

If the CLI is installed globally, the shorter form is equivalent:

devcontainer up --workspace-folder <fixture-repo>

The smoke fixture should look like a real consuming repository:

  • repository root contains project files
  • .devcontainer/ contains this kit
  • VS Code/devcontainer configuration is read from .devcontainer/devcontainer.json
  • initializeCommand runs through the devcontainer lifecycle, not by calling the initializer directly as the only assertion

Recommended test layers:

  • fast contract tests for generated files and static configuration
  • devcontainer read-configuration for schema/config resolution
  • devcontainer up for the real lifecycle, including initializeCommand
  • devcontainer exec or docker exec for observable checks inside the running workspace service

The tests should verify at least:

  • root .local.env is created or preserved by initializeCommand
  • .devcontainer/.gen.env and .devcontainer/compose.local.gen.yml are regenerated by initializeCommand
  • the generated container hostname matches host, repo, and branch context
  • the generated runtime user and group match the host UID
  • repeated devcontainer up runs stay safe and idempotent
  • no VS Code window is opened by kit scripts
  • no project-specific names such as CODEGEIST_* are required
  • the workspace service starts and accepts a basic command
  • generated local files are not accidentally tracked

Use direct docker compose tests only for focused Compose behavior that the CLI does not expose clearly. Do not treat a plain Compose test as a substitute for a full devcontainer smoke test.

File Layout

Expected target layout when consumed as a subtree at .devcontainer/:

.local.env              # generated, ignored by the consuming repo
compose.local.yml       # generated, ignored by the consuming repo
.devcontainer/
  .gen.env              # generated, ignored by the kit
  compose.local.gen.yml # generated, ignored by the kit
  devcontainer.json
  docker-compose.yml
  Dockerfile
  entrypoint.sh
  initialize.sh
  .local.env.example
  compose.local.yml.example
  tests/

Roles:

  • devcontainer.json is the VS Code entrypoint.
  • initialize.sh performs host-side setup for initializeCommand.
  • docker-compose.yml defines the workspace runtime and the root/worktree bind mounts.
  • Dockerfile builds the workspace image.
  • entrypoint.sh runs inside the container.
  • .local.env.example documents root .local.env values.
  • compose.local.yml.example is copied to root compose.local.yml.
  • .gen.env exposes generated runtime values to the container.
  • compose.local.gen.yml sets generated Compose-only values such as hostname.
  • tests/ verifies host initialization and container configuration contracts.

The generated root files intentionally live one directory above .devcontainer/. This keeps machine-local state out of the vendored subtree so many consuming projects can share the same .devcontainer kit without modifying those subtree files.

The generated .gen.env and compose.local.gen.yml files intentionally live in .devcontainer/ because they are kit-owned dynamic runtime state. They are rewritten by initialize.sh; users should put manual overrides in root .local.env and compose.local.yml instead.

launch.sh is not part of the required normal VS Code workflow. If a launcher is kept for compatibility or manual convenience, it should be documented as optional and must not be required by devcontainer.json.

Git Worktrees

The kit provides BRANCH for worktree selection through the Dev Containers lifecycle. VS Code is still launched from the repository root, but the container workspace is /workspace; BRANCH changes which checkout is mounted there.

Use it from a consuming repository where this kit is vendored at .devcontainer/:

BRANCH=develop0 code .

During initializeCommand, initialize.sh creates or reuses .worktrees/<branch>, initializes any consuming-repository submodules when the repository defines them, creates root .local.env from .devcontainer/.local.env.example when missing, and links the worktree root .local.env back to the main root file. Without BRANCH, /workspace is the repository root. With BRANCH, /workspace is the selected worktree.

When BRANCH is set, docker-compose.yml uses the same BRANCH value to mount ../.worktrees/<branch> at /workspace. The second bind mount always mounts the repository root at ${PWD}; start VS Code or Dev Containers CLI from the repository root so that linked-worktree Git metadata resolves inside the container. compose.local.yml remains available for local overrides, but it does not own the workspace or parent Git mounts.

Changing BRANCH after a container already exists does not automatically remount the running container. Rebuild or remove the existing devcontainer first, then start VS Code again with the new BRANCH value.

VS Code Reality Test

Use code-open as the real editor entrypoint from a consuming repository root. It refuses to run outside Git, from a Git subdirectory, or without .devcontainer/devcontainer.json.

task code-open
task code-open -- develop0

Use the manual reality test when you want to verify that same entrypoint through a temporary consuming repository. It creates a temporary Git repository, copies this kit into .devcontainer/, and then invokes the real code-open task against that fixture.

task code-open-test

To verify branch selection through the normal root-start flow:

task code-open-test -- develop0

BRANCH=develop0 task code-open-test is still accepted when an environment variable is more convenient. The task-argument form records BRANCH in the workspace .devcontainer/.env, which makes branch selection stable when code . is forwarded to an already running VS Code process.

The temporary fixture is intentionally left on disk because VS Code is opened against it.

Local Generated Files

Generated or machine-local files should not be committed.

Typical examples:

  • .local.env
  • .devcontainer/.env
  • compose.local.yml
  • generated runtime metadata
  • editor state
  • tool caches

If a value must affect the runtime, write it to a local env file or another documented generated file that Compose reads explicitly.

Design Rules

The kit should remain reusable across repositories, while currently preserving the copied Codegeist/planner image contents.

Avoid:

  • product-specific names
  • repository-specific paths
  • assumptions about root start.sh
  • hard-coded nested dependency names outside .devcontainer itself
  • committed root .env, .local.env, or compose.local.yml
  • automatic VS Code window management

Prefer:

  • stable Dev Container spec properties
  • static devcontainer.json where possible
  • local generated env files for host-dependent values
  • explicit optional extension points for consuming repositories
  • tests that exercise the exact documented contract

Roadmap Notes

  • Docker-in-Docker is enabled by default because it is part of the current tested smoke path. A future variant may make it opt-in if consuming projects need a lighter image.
  • SSH helper behavior is intentionally left to consuming repositories for now.
  • The base image currently keeps the copied Codegeist/planner toolchain intact; future work can split generic tools from project-specific features when there is a concrete consumer need.

About

Shared reproducible devcontainer source and generated consumer releases for Codegeist projects

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages