From 2eec933fa0c41a46abd86e355a047d14192e2758 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Sun, 4 Jan 2026 15:22:38 -0800 Subject: [PATCH 1/3] refactor(images): remove systemd and dind image variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Current behavior: Three container image variants were published: base, systemd (with init system), and dind (with Docker-in-Docker). The systemd and dind variants added complexity to CI/CD, release management, and documentation. New behavior: Only the base image variant is published. Removed: - images/systemd/ and images/dind/ directories - CI workflow matrix entries for systemd/dind - Release-please configuration for systemd/dind components - Docker Bake targets for systemd/dind - Documentation references to systemd/dind variants 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .github/workflows/images.yml | 14 +---- .github/workflows/security-scan.yml | 2 +- .release-please-manifest.json | 4 +- RELEASE.md | 25 +++----- docker-bake.hcl | 25 +------- docs/docs/reference/configuration.md | 2 +- docs/docs/reference/images/overview.md | 85 ++++++-------------------- images/dind/.trivyignore | 23 ------- images/dind/CHANGELOG.md | 3 - images/dind/Dockerfile | 59 ------------------ images/systemd/.trivyignore | 22 ------- images/systemd/CHANGELOG.md | 3 - images/systemd/Dockerfile | 47 -------------- internal/config/config.go | 1 - justfile | 12 +--- release-please-config.json | 14 ----- 16 files changed, 35 insertions(+), 306 deletions(-) delete mode 100644 images/dind/.trivyignore delete mode 100644 images/dind/CHANGELOG.md delete mode 100644 images/dind/Dockerfile delete mode 100644 images/systemd/.trivyignore delete mode 100644 images/systemd/CHANGELOG.md delete mode 100644 images/systemd/Dockerfile diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index 36e21bb..809e6d0 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -15,8 +15,6 @@ on: - '.github/workflows/images.yml' tags: - 'images/base/v*' - - 'images/systemd/v*' - - 'images/dind/v*' env: REGISTRY: ghcr.io @@ -28,8 +26,6 @@ jobs: matrix: dockerfile: - images/base/Dockerfile - - images/systemd/Dockerfile - - images/dind/Dockerfile steps: - name: Checkout repository uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 @@ -61,7 +57,7 @@ jobs: echo "Building $VARIANT with version $VERSION" else # Build all variants on push to master or PR - echo 'variants=["base", "systemd", "dind"]' >> $GITHUB_OUTPUT + echo 'variants=["base"]' >> $GITHUB_OUTPUT echo "is_release=false" >> $GITHUB_OUTPUT echo "version=latest" >> $GITHUB_OUTPUT echo "Building all variants with latest tag" @@ -80,8 +76,6 @@ jobs: variant: ${{ fromJson(needs.prepare.outputs.variants) }} outputs: base-digest: ${{ steps.digest.outputs.base }} - systemd-digest: ${{ steps.digest.outputs.systemd }} - dind-digest: ${{ steps.digest.outputs.dind }} steps: - name: Checkout repository @@ -146,11 +140,7 @@ jobs: id: digest run: | # Get the digest from the build job output - case "${{ matrix.variant }}" in - base) echo "value=${{ needs.build.outputs.base-digest }}" >> $GITHUB_OUTPUT ;; - systemd) echo "value=${{ needs.build.outputs.systemd-digest }}" >> $GITHUB_OUTPUT ;; - dind) echo "value=${{ needs.build.outputs.dind-digest }}" >> $GITHUB_OUTPUT ;; - esac + echo "value=${{ needs.build.outputs.base-digest }}" >> $GITHUB_OUTPUT - name: Log in to Container Registry uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3 diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 06b9477..2cdcafe 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -20,7 +20,7 @@ jobs: strategy: fail-fast: false matrix: - variant: [base, systemd, dind] + variant: [base] steps: - name: Checkout repository diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 18cd96f..0840c23 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,6 +1,4 @@ { ".": "0.0.0", - "images/base": "0.0.0", - "images/systemd": "0.0.0", - "images/dind": "0.0.0" + "images/base": "0.0.0" } diff --git a/RELEASE.md b/RELEASE.md index bd35320..783dabc 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -4,14 +4,12 @@ This document describes the release process for Headjack, covering the CLI and c ## Overview -Headjack uses [release-please](https://github.com/googleapis/release-please) to automate releases. The system manages four independent components: +Headjack uses [release-please](https://github.com/googleapis/release-please) to automate releases. The system manages two independent components: | Component | Tag Format | Changelog | |-----------|------------|-----------| | CLI | `v1.0.0` | `CHANGELOG.md` | | base image | `images/base/v1.0.0` | `images/base/CHANGELOG.md` | -| systemd image | `images/systemd/v1.0.0` | `images/systemd/CHANGELOG.md` | -| dind image | `images/dind/v1.0.0` | `images/dind/CHANGELOG.md` | ## Release Flow @@ -101,7 +99,6 @@ git commit -m "feat: add instance list command" # Image changes (touches files in images/) git commit -m "feat(images/base): add ripgrep to base image" -git commit -m "fix(images/dind): update Docker CE version" ``` ## CLI Releases @@ -146,21 +143,19 @@ Triggers on `v*` tags and runs GoReleaser with: Container images are built and published when `images/*/v*` tags are pushed. -### Image Variants +### Image Variant | Variant | Base | Features | |---------|------|----------| | `base` | Ubuntu 24.04 | Dev tools, agent CLIs, version managers | -| `systemd` | `base` | systemd init system | -| `dind` | `systemd` | Docker-in-Docker support | ### Image Tags Each release creates two tags: ``` -ghcr.io/gilmanlab/headjack:base # Latest -ghcr.io/gilmanlab/headjack:base-v1.0.0 # Versioned +ghcr.io/gilmanlab/headjack:base # Latest +ghcr.io/gilmanlab/headjack:base-v1.0.0 # Versioned ``` ### Build Features @@ -174,8 +169,6 @@ ghcr.io/gilmanlab/headjack:base-v1.0.0 # Versioned Triggers on: - `images/base/v*` tags -- `images/systemd/v*` tags -- `images/dind/v*` tags Jobs: 1. **lint**: Validates Dockerfiles with hadolint @@ -194,9 +187,7 @@ Defines components, release types, and changelog configuration: "separate-pull-requests": true, "packages": { ".": { "component": "cli", "include-component-in-tag": false }, - "images/base": { "component": "images/base", "include-component-in-tag": true }, - "images/systemd": { "component": "images/systemd", "include-component-in-tag": true }, - "images/dind": { "component": "images/dind", "include-component-in-tag": true } + "images/base": { "component": "images/base", "include-component-in-tag": true } } } ``` @@ -208,9 +199,7 @@ Tracks current versions for each component: ```json { ".": "1.0.0", - "images/base": "1.0.0", - "images/systemd": "1.0.0", - "images/dind": "1.0.0" + "images/base": "1.0.0" } ``` @@ -273,7 +262,7 @@ cosign verify-attestation ghcr.io/gilmanlab/headjack:base \ Release-please uses file paths to attribute commits. Ensure your changes are in the correct directory: - CLI: Root Go files (`*.go`, `internal/`, `cmd/`) -- Images: `images/base/`, `images/systemd/`, `images/dind/` +- Images: `images/base/` ### Release PR Has Wrong Version diff --git a/docker-bake.hcl b/docker-bake.hcl index e8a2581..b9d82c2 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -5,8 +5,7 @@ # docker buildx bake base # Build only base image # docker buildx bake --push # Build and push all images # -# The images have dependencies: base -> systemd -> dind -# Bake automatically builds dependencies first. +# The base image provides all required functionality. variable "REGISTRY" { default = "ghcr.io" @@ -22,7 +21,7 @@ variable "TAG" { # Target group to build all images group "default" { - targets = ["base", "systemd", "dind"] + targets = ["base"] } target "base" { @@ -31,23 +30,3 @@ target "base" { tags = ["${REGISTRY}/${REPOSITORY}:base", "${REGISTRY}/${REPOSITORY}:base-${TAG}"] platforms = ["linux/amd64", "linux/arm64"] } - -target "systemd" { - context = "images/systemd" - dockerfile = "Dockerfile" - tags = ["${REGISTRY}/${REPOSITORY}:systemd", "${REGISTRY}/${REPOSITORY}:systemd-${TAG}"] - platforms = ["linux/amd64", "linux/arm64"] - contexts = { - base = "target:base" - } -} - -target "dind" { - context = "images/dind" - dockerfile = "Dockerfile" - tags = ["${REGISTRY}/${REPOSITORY}:dind", "${REGISTRY}/${REPOSITORY}:dind-${TAG}"] - platforms = ["linux/amd64", "linux/arm64"] - contexts = { - systemd = "target:systemd" - } -} diff --git a/docs/docs/reference/configuration.md b/docs/docs/reference/configuration.md index 4e315a8..596ccfe 100644 --- a/docs/docs/reference/configuration.md +++ b/docs/docs/reference/configuration.md @@ -36,7 +36,7 @@ Default values applied when creating new instances. | Key | Type | Default | Description | |-----|------|---------|-------------| | `default.agent` | string | `""` (empty) | Default agent to use. Valid values: `claude`, `gemini`, `codex`. Empty means no default. | -| `default.base_image` | string | `ghcr.io/gilmanlab/headjack:base` | Container image to use for instances. Available variants: `:base` (minimal), `:systemd` (with init), `:dind` (with Docker). | +| `default.base_image` | string | `ghcr.io/gilmanlab/headjack:base` | Container image to use for instances. | ### agents diff --git a/docs/docs/reference/images/overview.md b/docs/docs/reference/images/overview.md index c2470bd..88bda98 100644 --- a/docs/docs/reference/images/overview.md +++ b/docs/docs/reference/images/overview.md @@ -1,16 +1,16 @@ --- sidebar_position: 1 title: Overview -description: Headjack container image variants +description: Headjack container image --- -# Container Images Overview +# Container Image Overview -Headjack provides pre-built container images for running isolated CLI-based LLM coding agents. All images are based on Ubuntu 24.04 LTS and include development tools, agent CLIs, and language runtime managers. +Headjack provides a pre-built container image for running isolated CLI-based LLM coding agents. The image is based on Ubuntu 24.04 LTS and includes development tools, agent CLIs, and language runtime managers. ## Registry -All images are published to the GitHub Container Registry: +The image is published to the GitHub Container Registry: ``` ghcr.io/gilmanlab/headjack @@ -21,79 +21,36 @@ ghcr.io/gilmanlab/headjack Images follow a consistent naming pattern: ``` -ghcr.io/gilmanlab/headjack: -ghcr.io/gilmanlab/headjack:- +ghcr.io/gilmanlab/headjack:base +ghcr.io/gilmanlab/headjack:base- ``` Examples: - `ghcr.io/gilmanlab/headjack:base` - Latest base image - `ghcr.io/gilmanlab/headjack:base-v1.0.0` - Base image version 1.0.0 -- `ghcr.io/gilmanlab/headjack:dind` - Latest Docker-in-Docker image -## Image Variants +## Features -The images form an inheritance hierarchy. Each variant builds on the previous one: +The base image includes: -``` -base --> systemd --> dind -``` - -### Comparison Table - -| Feature | base | systemd | dind | -|---------|------|---------|------| -| Ubuntu 24.04 LTS | Yes | Yes | Yes | -| Agent CLIs (Claude, Gemini, Codex) | Yes | Yes | Yes | -| Version managers (pyenv, nodenv, goenv, rustup) | Yes | Yes | Yes | -| Development tools (git, gh, vim, ripgrep, etc.) | Yes | Yes | Yes | -| Terminal multiplexer (tmux) | Yes | Yes | Yes | -| systemd init system | No | Yes | Yes | -| Docker CE | No | No | Yes | -| Docker Compose plugin | No | No | Yes | -| Docker Buildx plugin | No | No | Yes | -| Multi-architecture support (amd64, arm64) | Yes | Yes | Yes | - -### Image Sizes - -| Variant | Approximate Size | -|---------|-----------------| -| `base` | ~600 MB | -| `systemd` | ~620 MB | -| `dind` | ~1.0 GB | +| Feature | Included | +|---------|----------| +| Ubuntu 24.04 LTS | Yes | +| Agent CLIs (Claude, Gemini, Codex) | Yes | +| Version managers (pyenv, nodenv, goenv, rustup) | Yes | +| Development tools (git, gh, vim, ripgrep, etc.) | Yes | +| Terminal multiplexer (tmux) | Yes | +| Multi-architecture support (amd64, arm64) | Yes | -## Choosing an Image - -### Use `base` when: -- Running simple agent workflows that do not require background services -- Minimizing image size is a priority -- No systemd or Docker functionality is needed - -### Use `systemd` when: -- Your workflow requires running background services managed by systemd -- You need a proper init system for signal handling and process management -- You are running services that expect systemd to be available - -### Use `dind` when: -- Your workflow requires building or running Docker containers -- You need to test containerized applications -- Your agent needs to execute Docker commands (e.g., `docker build`, `docker compose`) - -## Pulling Images +## Pulling the Image ```bash -# Pull the base image docker pull ghcr.io/gilmanlab/headjack:base - -# Pull the systemd image -docker pull ghcr.io/gilmanlab/headjack:systemd - -# Pull the Docker-in-Docker image -docker pull ghcr.io/gilmanlab/headjack:dind ``` ## Security -All images are: +The image is: - **Signed** with Cosign using keyless signing (Sigstore) - **Attested** with SBOM (Software Bill of Materials) in SPDX format - **Scanned** for vulnerabilities using Trivy @@ -106,10 +63,8 @@ cosign verify ghcr.io/gilmanlab/headjack:base \ --certificate-oidc-issuer='https://token.actions.githubusercontent.com' ``` -## Dockerfiles +## Dockerfile -For complete image specifications, see the Dockerfiles in the repository: +For the complete image specification, see the Dockerfile in the repository: - [Base Dockerfile](https://github.com/GilmanLab/headjack/blob/master/images/base/Dockerfile) -- [Systemd Dockerfile](https://github.com/GilmanLab/headjack/blob/master/images/systemd/Dockerfile) -- [Docker-in-Docker Dockerfile](https://github.com/GilmanLab/headjack/blob/master/images/dind/Dockerfile) diff --git a/images/dind/.trivyignore b/images/dind/.trivyignore deleted file mode 100644 index e335f67..0000000 --- a/images/dind/.trivyignore +++ /dev/null @@ -1,23 +0,0 @@ -# Trivy Ignore File for DinD (Docker-in-Docker) Image -# See: https://aquasecurity.github.io/trivy/latest/docs/configuration/filtering/ -# -# This image inherits from systemd (which inherits from base), -# so includes the same CVE suppressions. - -# glob CVE - The glob package bundled with npm has a command injection vulnerability. -# We attempt to patch this in the Dockerfile, but npm versions vary across architectures -# and the bundled glob version may differ. This is a transitive dependency that can only -# be fully fixed by npm maintainers updating their bundled dependencies. -CVE-2025-64756 - -# GitHub CLI (gh) false positives -# The gh CLI binary v2.83.2 contains the fix for CVE-2024-52308 (fixed in v2.62.0), -# but Trivy reports a Go pseudo-version from the internal build info rather than -# the actual release version. -CVE-2024-52308 - -# The sigstore/timestamp-authority CVE is a transitive Go dependency embedded in -# the gh CLI binary. This can only be fixed by the gh CLI maintainers updating -# their dependencies. The vulnerability is a DoS via excessive OID or Content-Type, -# which has limited impact in the context of a dev container CLI tool. -CVE-2025-66564 diff --git a/images/dind/CHANGELOG.md b/images/dind/CHANGELOG.md deleted file mode 100644 index 9dfc5b9..0000000 --- a/images/dind/CHANGELOG.md +++ /dev/null @@ -1,3 +0,0 @@ -# Changelog - -All notable changes to the dind image will be documented in this file. diff --git a/images/dind/Dockerfile b/images/dind/Dockerfile deleted file mode 100644 index 5ff01d0..0000000 --- a/images/dind/Dockerfile +++ /dev/null @@ -1,59 +0,0 @@ -# Headjack DinD (Docker-in-Docker) Image -# -# Extends the systemd image with Docker CE for Docker-in-Docker support. -# Use this when your workflows require Docker commands inside the container. -# See docs/designs/base-image.md for full specification. - -# hadolint ignore=DL3006 -FROM systemd - -SHELL ["/bin/bash", "-o", "pipefail", "-c"] - -ARG DOCKER_GPG_FINGERPRINT=9DC858229FC7DD38854AE2D88D81803C0EBFCD88 -ARG USERNAME=developer - -# ============================================================================= -# iptables-legacy Workaround -# Required for Docker-in-Docker in certain container environments -# ============================================================================= - -# hadolint ignore=DL3008 -RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - iptables \ - && rm -rf /var/lib/apt/lists/* \ - && update-alternatives --set iptables /usr/sbin/iptables-legacy \ - && update-alternatives --set ip6tables /usr/sbin/ip6tables-legacy - -# ============================================================================= -# Docker CE -# ============================================================================= - -# hadolint ignore=DL3008 -RUN keyring=/usr/share/keyrings/docker-archive-keyring.gpg && \ - tmpdir=$(mktemp -d) && \ - curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /tmp/docker.gpg && \ - GNUPGHOME="${tmpdir}" gpg --batch --show-keys --with-colons /tmp/docker.gpg | \ - awk -F: '/^fpr:/ {print $10; exit}' | grep -qx "${DOCKER_GPG_FINGERPRINT}" && \ - gpg --dearmor -o "${keyring}" /tmp/docker.gpg && \ - echo "deb [arch=$(dpkg --print-architecture) signed-by=${keyring}] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list && \ - apt-get update && \ - DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - docker-ce \ - docker-ce-cli \ - containerd.io \ - docker-buildx-plugin \ - docker-compose-plugin \ - && rm -rf /var/lib/apt/lists/* /tmp/docker.gpg "${tmpdir}" - -# ============================================================================= -# Docker Configuration -# ============================================================================= - -# Docker-in-Docker requires a real filesystem for overlayfs snapshots. -VOLUME ["/var/lib/docker", "/var/lib/containerd"] - -# Add developer user to docker group and enable Docker service -RUN usermod -aG docker "$USERNAME" && \ - systemctl enable docker - -# STOPSIGNAL, VOLUME, and CMD are inherited from systemd image diff --git a/images/systemd/.trivyignore b/images/systemd/.trivyignore deleted file mode 100644 index 131639c..0000000 --- a/images/systemd/.trivyignore +++ /dev/null @@ -1,22 +0,0 @@ -# Trivy Ignore File for Systemd Image -# See: https://aquasecurity.github.io/trivy/latest/docs/configuration/filtering/ -# -# This image inherits from base, so includes the same CVE suppressions. - -# glob CVE - The glob package bundled with npm has a command injection vulnerability. -# We attempt to patch this in the Dockerfile, but npm versions vary across architectures -# and the bundled glob version may differ. This is a transitive dependency that can only -# be fully fixed by npm maintainers updating their bundled dependencies. -CVE-2025-64756 - -# GitHub CLI (gh) false positives -# The gh CLI binary v2.83.2 contains the fix for CVE-2024-52308 (fixed in v2.62.0), -# but Trivy reports a Go pseudo-version from the internal build info rather than -# the actual release version. -CVE-2024-52308 - -# The sigstore/timestamp-authority CVE is a transitive Go dependency embedded in -# the gh CLI binary. This can only be fixed by the gh CLI maintainers updating -# their dependencies. The vulnerability is a DoS via excessive OID or Content-Type, -# which has limited impact in the context of a dev container CLI tool. -CVE-2025-66564 diff --git a/images/systemd/CHANGELOG.md b/images/systemd/CHANGELOG.md deleted file mode 100644 index ba3d078..0000000 --- a/images/systemd/CHANGELOG.md +++ /dev/null @@ -1,3 +0,0 @@ -# Changelog - -All notable changes to the systemd image will be documented in this file. diff --git a/images/systemd/Dockerfile b/images/systemd/Dockerfile deleted file mode 100644 index 5241826..0000000 --- a/images/systemd/Dockerfile +++ /dev/null @@ -1,47 +0,0 @@ -# Headjack systemd Image -# -# Extends the base image with systemd init system support. -# Use this for multi-service environments that need a proper init system. -# For Docker-in-Docker support, use the dind variant instead. -# See docs/designs/base-image.md for full specification. - -# hadolint ignore=DL3006 -FROM base - -SHELL ["/bin/bash", "-o", "pipefail", "-c"] - -# Switch to root - systemd requires root privileges as PID 1 -# hadolint ignore=DL3002 -USER root - -# ============================================================================= -# Install systemd -# ============================================================================= - -# hadolint ignore=DL3008 -RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - systemd \ - systemd-sysv \ - && rm -rf /var/lib/apt/lists/* - -# ============================================================================= -# systemd Configuration -# ============================================================================= - -# Clean up systemd services that don't make sense in a container -RUN rm -f /lib/systemd/system/multi-user.target.wants/* \ - /etc/systemd/system/*.wants/* \ - /lib/systemd/system/local-fs.target.wants/* \ - /lib/systemd/system/sockets.target.wants/*udev* \ - /lib/systemd/system/sockets.target.wants/*initctl* \ - /lib/systemd/system/sysinit.target.wants/systemd-tmpfiles-setup* \ - /lib/systemd/system/systemd-update-utmp* - -# Use systemd as init -STOPSIGNAL SIGRTMIN+3 - -# Volume for cgroups (required for systemd) -VOLUME ["/sys/fs/cgroup"] - -# systemd as init system -CMD ["/lib/systemd/systemd"] diff --git a/internal/config/config.go b/internal/config/config.go index 9789bc8..7a49a7b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,7 +22,6 @@ const ( ) // DefaultBaseImage is the default container image. -// Available variants: :base (minimal), :systemd (+ init), :dind (+ Docker) const DefaultBaseImage = "ghcr.io/gilmanlab/headjack:base" // Sentinel errors for configuration operations. diff --git a/justfile b/justfile index c659df5..dafcbd5 100644 --- a/justfile +++ b/justfile @@ -34,22 +34,12 @@ test-coverage: build-base: docker build -t headjack:base images/base -# Build systemd image locally (depends on base) -build-systemd: build-base - docker build -t headjack:systemd --build-arg BASE_IMAGE=headjack:base images/systemd - -# Build dind image locally (depends on systemd) -build-dind: build-systemd - docker build -t headjack:dind --build-arg BASE_IMAGE=headjack:systemd images/dind - # Build all container images -build-images: build-dind +build-images: build-base # Lint all Dockerfiles lint-dockerfiles: hadolint images/base/Dockerfile - hadolint images/systemd/Dockerfile - hadolint images/dind/Dockerfile # ============================================================================= # Integration Tests diff --git a/release-please-config.json b/release-please-config.json index cb57dc0..5d52acc 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -29,20 +29,6 @@ "changelog-path": "CHANGELOG.md", "include-component-in-tag": true, "tag-separator": "/" - }, - "images/systemd": { - "release-type": "simple", - "component": "images/systemd", - "changelog-path": "CHANGELOG.md", - "include-component-in-tag": true, - "tag-separator": "/" - }, - "images/dind": { - "release-type": "simple", - "component": "images/dind", - "changelog-path": "CHANGELOG.md", - "include-component-in-tag": true, - "tag-separator": "/" } } } From 96f851d97b51a084a824f9930d1dbf578835e89a Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Sun, 4 Jan 2026 16:53:04 -0800 Subject: [PATCH 2/3] refactor(cli): make devcontainers the default container mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Current behavior: Base container images were the default, with devcontainer support as an optional add-on. The --base flag and default.base_image config were the primary way to specify container environments. New behavior: Devcontainers are now the primary and default method. When running hjk run: 1. If --image is specified, use that image (bypasses devcontainer) 2. If devcontainer.json exists, use devcontainer mode automatically 3. If default.base_image is configured, use that as fallback 4. Otherwise, error with guidance on configuration options Key changes: - Rename --base flag to --image (clearer semantics) - Make default.base_image optional (defaults to empty) - Add warning when devcontainer CLI is not found - Error when no devcontainer.json and no image configured - Remove hjk recreate command (use hjk rm + hjk run instead) - Update all documentation to reflect devcontainer-first approach 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../decisions/adr-006-oci-customization.md | 9 +- docs/docs/explanation/authentication.md | 6 +- docs/docs/explanation/image-customization.md | 25 +++-- docs/docs/how-to/authenticate.md | 2 +- docs/docs/how-to/build-custom-image.md | 28 ++--- docs/docs/how-to/manage-sessions.md | 8 +- docs/docs/how-to/troubleshoot-auth.md | 7 +- docs/docs/reference/cli/recreate.md | 66 ----------- docs/docs/reference/cli/rm.md | 2 +- docs/docs/reference/cli/run.md | 16 +-- docs/docs/reference/cli/stop.md | 1 - docs/docs/reference/configuration.md | 15 ++- docs/docs/reference/environment.md | 4 +- docs/docs/tutorials/custom-image.md | 10 +- docs/sidebars.ts | 1 - internal/cmd/helpers.go | 4 +- internal/cmd/recreate.go | 62 ----------- internal/cmd/run.go | 105 ++++++++++++------ internal/config/config.go | 7 +- internal/config/config_test.go | 10 +- internal/instance/manager.go | 50 --------- internal/instance/manager_test.go | 69 ------------ 22 files changed, 158 insertions(+), 349 deletions(-) delete mode 100644 docs/docs/reference/cli/recreate.md delete mode 100644 internal/cmd/recreate.go diff --git a/docs/docs/decisions/adr-006-oci-customization.md b/docs/docs/decisions/adr-006-oci-customization.md index e3dd32a..c94011b 100644 --- a/docs/docs/decisions/adr-006-oci-customization.md +++ b/docs/docs/decisions/adr-006-oci-customization.md @@ -41,7 +41,7 @@ This adds complexity: **Pure OCI image approach** - Ship a default base image with opinionated tooling -- Users override with `--base ` or `--base ` +- Users override with `--image ` or `--image ` - All customization delegated to standard OCI tooling This approach: @@ -56,9 +56,10 @@ Use **OCI images exclusively** for environment customization. No first-class sup The customization model is: -1. **Default**: Headjack ships a base image (`ghcr.io/headjack/base:latest`) with opinionated tooling -2. **Image override**: Users specify an alternative image via `--base ` -3. **Dockerfile override**: Users specify a Dockerfile via `--base ` +1. **Devcontainer (default)**: If a `devcontainer.json` exists, use it automatically +2. **Image override**: Users specify an alternative image via `--image ` +3. **Dockerfile override**: Users specify a Dockerfile via `--image ` +4. **Fallback**: If no devcontainer and no `--image`, use configured `default.base_image` When a Dockerfile path is provided (detected by filename ending in `Dockerfile` or `Containerfile`), Headjack runs `container build` before launching the instance. Layer caching is handled by the container runtime. diff --git a/docs/docs/explanation/authentication.md b/docs/docs/explanation/authentication.md index aa0b736..9906afa 100644 --- a/docs/docs/explanation/authentication.md +++ b/docs/docs/explanation/authentication.md @@ -277,7 +277,7 @@ Currently, Headjack stores one set of credentials per agent type. Multi-account ### Container Filesystem Persistence -Once credentials are written inside a container, they persist until the container is recreated. A `hjk recreate` is needed to rotate credentials if they change on the host. +Once credentials are written inside a container, they persist until the container is recreated. Use `hjk rm` followed by `hjk run` to recreate an instance if credentials change on the host. ### OAuth Token Expiry @@ -299,7 +299,7 @@ The token may have expired: ```bash hjk auth claude # Re-capture fresh credential -hjk recreate # Recreate container with new credentials +hjk rm && hjk run # Recreate instance with new credentials ``` ### Claude onboarding prompt @@ -312,7 +312,7 @@ To switch authentication methods, simply run `hjk auth` again and select the oth ```bash hjk auth claude # Select option 2 for API key -hjk recreate # Recreate to use new credential type +hjk rm && hjk run # Recreate instance with new credential type ``` ## Why Not SSH Agent Forwarding? diff --git a/docs/docs/explanation/image-customization.md b/docs/docs/explanation/image-customization.md index 5e756f6..e053229 100644 --- a/docs/docs/explanation/image-customization.md +++ b/docs/docs/explanation/image-customization.md @@ -208,17 +208,26 @@ Each instance uses a full container image. There's no Nix-style deduplication wh Custom images must be built before use. For complex images, this can take minutes. Pre-building and pushing to a registry mitigates this. -## Image Variants +## Devcontainers vs Custom Images -The base image comes in variants for different use cases: +Headjack supports two approaches to environment customization: -| Variant | Features | Use Case | -|---------|----------|----------| -| `base` | Agent CLIs, version managers | Most development work | -| `systemd` | Adds systemd support | Projects requiring services | -| `dind` | Adds Docker-in-Docker | Testing Docker workflows | +### Devcontainers (Recommended) -Each variant extends the previous, adding capabilities at the cost of image size. +If your repository contains a `devcontainer.json`, Headjack uses it automatically. This is the preferred approach because: + +- Configuration lives with the code +- Standard format understood by VS Code, GitHub Codespaces, and other tools +- Supports Dev Container Features for modular customization +- Team members get consistent environments automatically + +### Custom Images + +Build a custom OCI image when: + +- Your repository doesn't have a devcontainer configuration +- You need to share the same image across multiple repositories +- You want faster startup (pre-built vs building at runtime) ## Best Practices diff --git a/docs/docs/how-to/authenticate.md b/docs/docs/how-to/authenticate.md index f56020f..0e78e3b 100644 --- a/docs/docs/how-to/authenticate.md +++ b/docs/docs/how-to/authenticate.md @@ -112,7 +112,7 @@ To switch between subscription and API key: ```bash hjk auth claude # Select the other option -hjk recreate my-feature # Recreate container with new credentials +hjk rm my-feature && hjk run my-feature # Recreate instance with new credentials ``` ## Notes diff --git a/docs/docs/how-to/build-custom-image.md b/docs/docs/how-to/build-custom-image.md index f87220e..fe61a84 100644 --- a/docs/docs/how-to/build-custom-image.md +++ b/docs/docs/how-to/build-custom-image.md @@ -6,22 +6,14 @@ description: Build a custom container image with project dependencies and use it # Build and Use Custom Images -Create a custom image with your project's dependencies pre-installed for faster container startup, or use one of the official Headjack image variants. +Create a custom image with your project's dependencies pre-installed for faster container startup. -## Use an official variant - -Headjack provides three image variants: - -```bash -# Base image (default) - minimal with agent CLIs -hjk run feat/auth --base ghcr.io/gilmanlab/headjack:base - -# Systemd variant - includes init system -hjk run feat/auth --base ghcr.io/gilmanlab/headjack:systemd - -# Docker-in-Docker variant - includes Docker daemon -hjk run feat/auth --base ghcr.io/gilmanlab/headjack:dind -``` +:::tip Prefer Devcontainers +If your repository has a `devcontainer.json`, Headjack uses it automatically. You only need a custom image when: +- Your repository doesn't have a devcontainer configuration +- You want to share a pre-built image across multiple repositories +- You need faster startup than devcontainer building provides +::: ## Build a custom image @@ -114,16 +106,16 @@ docker push ghcr.io/your-org/my-custom-headjack:latest ### Override for a single run -Use the `--base` flag: +Use the `--image` flag: ```bash -hjk run feat/auth --base my-registry.io/my-custom-headjack:latest +hjk run feat/auth --image my-registry.io/my-custom-headjack:latest ``` Combine with `--agent`: ```bash -hjk run feat/auth --base my-registry.io/my-custom-headjack:latest --agent claude "Implement the feature" +hjk run feat/auth --image my-registry.io/my-custom-headjack:latest --agent claude "Implement the feature" ``` ### Set as permanent default diff --git a/docs/docs/how-to/manage-sessions.md b/docs/docs/how-to/manage-sessions.md index c9ef2d0..493db92 100644 --- a/docs/docs/how-to/manage-sessions.md +++ b/docs/docs/how-to/manage-sessions.md @@ -153,12 +153,16 @@ hjk logs feat/auth happy-panda --full # complete log hjk run feat/auth --agent claude --name jwt-implementation "Implement JWT" ``` -### Custom base image +### Custom container image ```bash -hjk run feat/auth --agent claude --base my-registry.io/custom-image:latest +hjk run feat/auth --agent claude --image my-registry.io/custom-image:latest ``` +:::note +Using `--image` bypasses devcontainer detection. If your repository has a `devcontainer.json`, it will be used automatically without needing `--image`. +::: + ## Troubleshooting **"no sessions exist"** - No sessions are running. Start one with `hjk run`. diff --git a/docs/docs/how-to/troubleshoot-auth.md b/docs/docs/how-to/troubleshoot-auth.md index d736ed0..f84666c 100644 --- a/docs/docs/how-to/troubleshoot-auth.md +++ b/docs/docs/how-to/troubleshoot-auth.md @@ -120,10 +120,11 @@ hjk auth codex hjk auth # Select option 2 and enter your API key ``` -After re-authenticating, recreate your instance: +After re-authenticating, remove and recreate your instance to apply the new credentials: ```bash -hjk recreate my-feature +hjk rm my-feature +hjk run my-feature ``` ## Keychain Access Issues @@ -209,7 +210,7 @@ To switch authentication methods: ```bash hjk auth claude # Select the other option when prompted -hjk recreate my-feature # Apply new credentials to existing instance +hjk rm my-feature && hjk run my-feature # Recreate instance with new credentials ``` ## Related diff --git a/docs/docs/reference/cli/recreate.md b/docs/docs/reference/cli/recreate.md deleted file mode 100644 index cace46a..0000000 --- a/docs/docs/reference/cli/recreate.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -sidebar_position: 8 -title: hjk recreate -description: Recreate an instance container ---- - -# hjk recreate - -Recreate the container for an instance while preserving the worktree. - -## Synopsis - -```bash -hjk recreate [flags] -``` - -## Description - -Recreates the container for an instance. This command: - -1. Stops and deletes the existing container -2. Creates a new container with the same worktree - -Useful when the container environment is corrupted or needs a fresh state. The worktree (and all git-tracked and untracked files) is preserved. - -## Arguments - -| Argument | Description | -|----------|-------------| -| `branch` | Git branch name of the instance to recreate (required) | - -## Flags - -| Flag | Type | Default | Description | -|------|------|---------|-------------| -| `--base` | string | | Use a different base image for the new container | - -## Examples - -```bash -# Recreate with same image -hjk recreate feat/auth - -# Recreate with new image -hjk recreate feat/auth --base my-registry.io/new-image:v2 -``` - -## Use Cases - -- **Corrupted container**: When a container's environment becomes corrupted or unstable -- **Image update**: When you want to use a newer version of the base image -- **Clean slate**: When you want to reset the container state without losing code changes -- **Configuration change**: When you need to apply new container configuration - -## Behavior - -- All running sessions in the container are terminated -- The container is deleted and a new one is created -- The git worktree directory is preserved and remounted -- A new instance ID is generated for the new container - -## See Also - -- [hjk stop](stop.md) - Stop without recreating -- [hjk rm](rm.md) - Remove instance entirely -- [hjk run](run.md) - Create a new session after recreating diff --git a/docs/docs/reference/cli/rm.md b/docs/docs/reference/cli/rm.md index afeec80..82bad4d 100644 --- a/docs/docs/reference/cli/rm.md +++ b/docs/docs/reference/cli/rm.md @@ -63,4 +63,4 @@ Type `y` or `yes` to confirm, or any other input (including Enter) to cancel. - [hjk stop](stop.md) - Stop without removing - [hjk ps](ps.md) - List instances -- [hjk recreate](recreate.md) - Recreate container without removing worktree +- [hjk run](run.md) - Create a new instance diff --git a/docs/docs/reference/cli/run.md b/docs/docs/reference/cli/run.md index 06302bc..2505bcd 100644 --- a/docs/docs/reference/cli/run.md +++ b/docs/docs/reference/cli/run.md @@ -16,10 +16,10 @@ hjk run [prompt] [flags] ## Description -Creates a new session within an instance for the specified branch. If no instance exists for the branch, one is created first by: +Creates a new session within an instance for the specified branch. If no instance exists for the branch, one is created first. The container environment is determined by: -- Creating a git worktree at the configured location -- Spawning a new container with the worktree mounted +1. **Devcontainer (default)**: If the repository contains a `devcontainer.json`, it is used to build and run the container environment automatically. +2. **Base image**: Use `--image` to specify a container image directly, bypassing devcontainer detection. A new session is always created within the instance. If `--agent` is specified, the agent is started with an optional prompt. Otherwise, the default shell is started. @@ -40,16 +40,16 @@ If an instance exists but is stopped, it is automatically restarted before creat |------|-------|------|---------|-------------| | `--agent` | | string | | Start the specified agent instead of a shell. Valid values: `claude`, `gemini`, `codex`. If specified without a value, uses the configured `default.agent`. | | `--name` | | string | | Override the auto-generated session name | -| `--base` | | string | | Override the default base image | +| `--image` | | string | | Use a container image instead of devcontainer | | `--detached` | `-d` | bool | `false` | Create session but do not attach (run in background) | ## Examples ```bash -# Create instance with shell session +# Auto-detect devcontainer.json (recommended) hjk run feat/auth -# Create instance with Claude agent +# Start Claude agent in devcontainer hjk run feat/auth --agent claude "Implement JWT authentication" # Create additional session in existing instance @@ -62,8 +62,8 @@ hjk run feat/auth --name debug-shell hjk run feat/auth --agent claude -d "Refactor the auth module" hjk run feat/auth --agent claude -d "Write tests for auth module" -# Use a custom base image -hjk run feat/auth --base my-registry.io/custom-image:latest +# Use a specific container image (bypasses devcontainer) +hjk run feat/auth --image my-registry.io/custom-image:latest # Use default agent from config hjk run feat/auth --agent diff --git a/docs/docs/reference/cli/stop.md b/docs/docs/reference/cli/stop.md index f5b8f92..e0775a6 100644 --- a/docs/docs/reference/cli/stop.md +++ b/docs/docs/reference/cli/stop.md @@ -47,4 +47,3 @@ When an instance is stopped: - [hjk run](run.md) - Restart a stopped instance - [hjk rm](rm.md) - Remove an instance entirely - [hjk ps](ps.md) - List instances and their status -- [hjk recreate](recreate.md) - Recreate the container diff --git a/docs/docs/reference/configuration.md b/docs/docs/reference/configuration.md index 596ccfe..517951c 100644 --- a/docs/docs/reference/configuration.md +++ b/docs/docs/reference/configuration.md @@ -36,7 +36,7 @@ Default values applied when creating new instances. | Key | Type | Default | Description | |-----|------|---------|-------------| | `default.agent` | string | `""` (empty) | Default agent to use. Valid values: `claude`, `gemini`, `codex`. Empty means no default. | -| `default.base_image` | string | `ghcr.io/gilmanlab/headjack:base` | Container image to use for instances. | +| `default.base_image` | string | `""` (empty) | Fallback container image when no devcontainer is found. If empty and no devcontainer.json exists, `hjk run` will error with guidance. | ### agents @@ -74,7 +74,7 @@ A complete configuration file with all options: ```yaml default: agent: claude - base_image: ghcr.io/gilmanlab/headjack:base + base_image: "" # Empty by default; set if you want a fallback when no devcontainer exists agents: claude: @@ -144,8 +144,17 @@ The following environment variables override their corresponding configuration k Headjack validates configuration values when loading and setting them: - `default.agent` must be one of: `claude`, `gemini`, `codex` (or empty) -- `default.base_image` is required and cannot be empty +- `default.base_image` is optional; if empty, a devcontainer.json must exist in the repository - `runtime.name` must be one of: `podman`, `docker` - All storage paths are required Invalid values will result in an error message describing the validation failure. + +## Devcontainer Priority + +When running `hjk run`, Headjack determines the container environment as follows: + +1. If `--image` is specified, use that image (bypasses devcontainer detection) +2. If a `devcontainer.json` exists in the repository, use devcontainer mode +3. If `default.base_image` is configured, use that image +4. Otherwise, error with guidance on how to configure the environment diff --git a/docs/docs/reference/environment.md b/docs/docs/reference/environment.md index 1ab05d4..67c6a82 100644 --- a/docs/docs/reference/environment.md +++ b/docs/docs/reference/environment.md @@ -15,7 +15,7 @@ These environment variables override values in the configuration file. They foll | Variable | Type | Description | Overrides | |----------|------|-------------|-----------| | `HEADJACK_DEFAULT_AGENT` | string | Default agent for new instances | `default.agent` | -| `HEADJACK_BASE_IMAGE` | string | Default container image | `default.base_image` | +| `HEADJACK_BASE_IMAGE` | string | Fallback container image when no devcontainer exists | `default.base_image` | | `HEADJACK_MULTIPLEXER` | string | Terminal multiplexer | `default.multiplexer` | | `HEADJACK_WORKTREE_DIR` | string | Worktree storage directory | `storage.worktrees` | @@ -25,7 +25,7 @@ These environment variables override values in the configuration file. They foll # Use Claude as the default agent export HEADJACK_DEFAULT_AGENT=claude -# Use a custom container image +# Set a fallback container image (used when no devcontainer.json exists) export HEADJACK_BASE_IMAGE=myregistry.com/myimage:latest # Override worktree directory diff --git a/docs/docs/tutorials/custom-image.md b/docs/docs/tutorials/custom-image.md index 0c855e8..9a36407 100644 --- a/docs/docs/tutorials/custom-image.md +++ b/docs/docs/tutorials/custom-image.md @@ -226,17 +226,21 @@ Type `exit` to leave the container. ## Step 9: Use the Image with Headjack -Now use your custom image with Headjack. Specify it with the `--base` flag: +Now use your custom image with Headjack. Specify it with the `--image` flag: ```bash -hjk run feat/new-feature --base my-app-headjack:latest --agent claude "Add user authentication using PostgreSQL sessions" +hjk run feat/new-feature --image my-app-headjack:latest --agent claude "Add user authentication using PostgreSQL sessions" ``` The agent starts immediately with all dependencies available. No waiting for Python or Node.js installation. +:::note +Using `--image` bypasses devcontainer detection. If your repository has a `devcontainer.json`, you typically don't need a custom image—just run `hjk run feat/new-feature` and the devcontainer will be used automatically. +::: + ## Step 10: Set as Default -To avoid specifying `--base` every time, set your image as the default: +To avoid specifying `--image` every time, set your image as the default: ```bash hjk config default.base_image my-app-headjack:latest diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 85fb3c9..392b70a 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -98,7 +98,6 @@ const sidebars: SidebarsConfig = { 'reference/cli/stop', 'reference/cli/kill', 'reference/cli/rm', - 'reference/cli/recreate', 'reference/cli/auth', 'reference/cli/config', 'reference/cli/version', diff --git a/internal/cmd/helpers.go b/internal/cmd/helpers.go index eb8c7aa..72c9280 100644 --- a/internal/cmd/helpers.go +++ b/internal/cmd/helpers.go @@ -39,10 +39,10 @@ func resolveBaseImage(ctx context.Context, override string) string { if override != "" { return override } - if cfg := ConfigFromContext(ctx); cfg != nil && cfg.Default.BaseImage != "" { + if cfg := ConfigFromContext(ctx); cfg != nil { return cfg.Default.BaseImage } - return config.DefaultBaseImage + return "" } func getInstanceByBranch(ctx context.Context, mgr *instance.Manager, branch, notFoundMsg string) (*instance.Instance, error) { diff --git a/internal/cmd/recreate.go b/internal/cmd/recreate.go deleted file mode 100644 index 57a4a34..0000000 --- a/internal/cmd/recreate.go +++ /dev/null @@ -1,62 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/spf13/cobra" -) - -var recreateCmd = &cobra.Command{ - Use: "recreate ", - Short: "Recreate an instance's container without losing worktree state", - Long: `Recreate the container for an instance while preserving the worktree. - -This command: -- Stops and deletes the existing container -- Creates a new container with the same worktree - -Useful when the container environment is corrupted or needs a fresh state. -The worktree (and all git-tracked and untracked files) is preserved.`, - Example: ` # Recreate with same image - headjack recreate feat/auth - - # Recreate with new image - headjack recreate feat/auth --base my-registry.io/new-image:v2`, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - branch := args[0] - - mgr, err := requireManager(cmd.Context()) - if err != nil { - return err - } - - inst, err := getInstanceByBranch(cmd.Context(), mgr, branch, "no instance found for branch %q") - if err != nil { - return err - } - - // Determine image to use (precedence: flag > config) - // Config already has defaults set via Viper, so just use it - imageOverride, err := cmd.Flags().GetString("base") - if err != nil { - return fmt.Errorf("get base flag: %w", err) - } - image := resolveBaseImage(cmd.Context(), imageOverride) - - // Recreate the instance - newInst, err := mgr.Recreate(cmd.Context(), inst.ID, image) - if err != nil { - return fmt.Errorf("recreate instance: %w", err) - } - - fmt.Printf("Recreated instance %s for branch %s with image %s\n", newInst.ID, newInst.Branch, image) - return nil - }, -} - -func init() { - rootCmd.AddCommand(recreateCmd) - - recreateCmd.Flags().String("base", "", "use a different base image") -} diff --git a/internal/cmd/run.go b/internal/cmd/run.go index b6ef93b..ddb1e47 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -3,6 +3,7 @@ package cmd import ( "errors" "fmt" + "os/exec" "github.com/spf13/cobra" @@ -22,9 +23,13 @@ var runCmd = &cobra.Command{ Short: "Create a new session (and instance if needed), then attach", Long: `Create a new session within an instance for the specified branch. -If no instance exists for the branch, one is created first: - - Creates a git worktree at the configured location - - Spawns a new container with the worktree mounted +If no instance exists for the branch, one is created first. The container +environment is determined by: + + 1. Devcontainer (default): If the repository contains a devcontainer.json, + it is used to build and run the container environment automatically. + 2. Base image: Use --image to specify a container image directly, bypassing + devcontainer detection. A new session is always created within the instance. If --agent is specified, the agent is started (with an optional prompt). Otherwise, the default shell @@ -32,10 +37,10 @@ is started. Unless --detached is specified, the terminal attaches to the session. All session output is captured to a log file regardless of attached/detached mode.`, - Example: ` # New instance with shell session + Example: ` # Auto-detect devcontainer.json (recommended) headjack run feat/auth - # New instance with Claude agent + # Start Claude agent in devcontainer headjack run feat/auth --agent claude "Implement JWT authentication" # Additional session in existing instance @@ -48,8 +53,8 @@ All session output is captured to a log file regardless of attached/detached mod headjack run feat/auth --agent claude -d "Refactor the auth module" headjack run feat/auth --agent claude -d "Write tests for auth module" - # Use a custom base image - headjack run feat/auth --base my-registry.io/custom-image:latest`, + # Use a specific container image (bypasses devcontainer) + headjack run feat/auth --image my-registry.io/custom-image:latest`, Args: cobra.RangeArgs(1, 2), RunE: runRunCmd, } @@ -57,7 +62,7 @@ All session output is captured to a log file regardless of attached/detached mod // runFlags holds parsed flags for the run command. type runFlags struct { image string - imageExplicit bool // true if --base was explicitly passed + imageExplicit bool // true if --image was explicitly passed agent string sessionName string detached bool @@ -65,11 +70,11 @@ type runFlags struct { // parseRunFlags extracts and validates flags from the command. func parseRunFlags(cmd *cobra.Command) (*runFlags, error) { - image, err := cmd.Flags().GetString("base") + image, err := cmd.Flags().GetString("image") if err != nil { - return nil, fmt.Errorf("get base flag: %w", err) + return nil, fmt.Errorf("get image flag: %w", err) } - imageExplicit := cmd.Flags().Changed("base") + imageExplicit := cmd.Flags().Changed("image") agent, err := cmd.Flags().GetString("agent") if err != nil { @@ -291,7 +296,10 @@ func getOrCreateInstance(cmd *cobra.Command, mgr *instance.Manager, repoPath, br } // Build create config - detect devcontainer mode if applicable - createCfg := buildCreateConfig(cmd, repoPath, branch, image, imageExplicit) + createCfg, err := buildCreateConfig(cmd, repoPath, branch, image, imageExplicit) + if err != nil { + return nil, err + } // Create new instance inst, err = mgr.Create(cmd.Context(), repoPath, createCfg) @@ -303,44 +311,75 @@ func getOrCreateInstance(cmd *cobra.Command, mgr *instance.Manager, repoPath, br return inst, nil } +// devcontainerCLI is the name of the devcontainer CLI binary. +const devcontainerCLI = "devcontainer" + // buildCreateConfig builds the instance creation config, detecting devcontainer mode if applicable. // Devcontainer mode is used when: -// - No --base flag was explicitly passed (imageExplicit is false) +// - No --image flag was explicitly passed (imageExplicit is false) // - A devcontainer.json exists in the repo -func buildCreateConfig(cmd *cobra.Command, repoPath, branch, image string, imageExplicit bool) instance.CreateConfig { +// - The devcontainer CLI is available +// +// Returns an error if no devcontainer.json is found and no image is configured. +func buildCreateConfig(cmd *cobra.Command, repoPath, branch, image string, imageExplicit bool) (instance.CreateConfig, error) { cfg := instance.CreateConfig{ Branch: branch, Image: image, } + // Always check if devcontainer CLI is available and warn if not + devcontainerAvailable := isDevcontainerCLIAvailable() + if !devcontainerAvailable { + fmt.Println("Warning: devcontainer CLI not found in PATH") + fmt.Println(" Install with: npm install -g @devcontainers/cli") + fmt.Println(" See: https://github.com/devcontainers/cli") + } + // If image was explicitly passed, use vanilla mode if imageExplicit { - return cfg + return cfg, nil } // Check for devcontainer.json - if !devcontainer.HasConfig(repoPath) { - return cfg - } + hasDevcontainer := devcontainer.HasConfig(repoPath) - // Create devcontainer runtime wrapping the underlying runtime - runtimeName := runtimeNameDocker - if appCfg := ConfigFromContext(cmd.Context()); appCfg != nil && appCfg.Runtime.Name != "" { - runtimeName = appCfg.Runtime.Name - } - dcRuntime := createDevcontainerRuntime(cmd, runtimeName) - if dcRuntime == nil { - // Fall back to vanilla mode if we can't create the devcontainer runtime - return cfg + if hasDevcontainer { + if !devcontainerAvailable { + // Devcontainer exists but CLI not available - error + return cfg, errors.New("devcontainer.json found but devcontainer CLI is not installed") + } + + // Create devcontainer runtime wrapping the underlying runtime + runtimeName := runtimeNameDocker + if appCfg := ConfigFromContext(cmd.Context()); appCfg != nil && appCfg.Runtime.Name != "" { + runtimeName = appCfg.Runtime.Name + } + dcRuntime := createDevcontainerRuntime(cmd, runtimeName) + if dcRuntime == nil { + return cfg, errors.New("failed to create devcontainer runtime") + } + + fmt.Println("Detected devcontainer.json, using devcontainer mode") + + cfg.WorkspaceFolder = repoPath + cfg.Runtime = dcRuntime + cfg.Image = "" // Not needed in devcontainer mode + + return cfg, nil } - fmt.Println("Detected devcontainer.json, using devcontainer mode") + // No devcontainer.json - need an image + if image == "" { + return cfg, errors.New("no devcontainer.json found and no image configured\n\nTo fix this, either:\n 1. Add a devcontainer.json to your repository\n 2. Use --image to specify a container image\n 3. Set default.base_image in your config") + } - cfg.WorkspaceFolder = repoPath - cfg.Runtime = dcRuntime - cfg.Image = "" // Not needed in devcontainer mode + return cfg, nil +} - return cfg +// isDevcontainerCLIAvailable checks if the devcontainer CLI is in PATH. +func isDevcontainerCLIAvailable() bool { + _, err := exec.LookPath(devcontainerCLI) + return err == nil } // createDevcontainerRuntime creates a DevcontainerRuntime wrapping the appropriate underlying runtime. @@ -402,7 +441,7 @@ func init() { runCmd.Flags().String("agent", "", "start an agent (claude, gemini, codex, or 'default' for configured default)") runCmd.Flags().String("name", "", "override auto-generated session name") - runCmd.Flags().String("base", "", "override the default base image") + runCmd.Flags().String("image", "", "use a container image instead of devcontainer") runCmd.Flags().BoolP("detached", "d", false, "create session but don't attach (run in background)") agentFlag := runCmd.Flags().Lookup("agent") diff --git a/internal/config/config.go b/internal/config/config.go index 7a49a7b..36d09a0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,7 +21,8 @@ const ( DefaultDataDir = ".local/share/headjack" ) -// DefaultBaseImage is the default container image. +// DefaultBaseImage is the fallback container image when no devcontainer is found. +// This is only used when explicitly requested or when config has no base_image set. const DefaultBaseImage = "ghcr.io/gilmanlab/headjack:base" // Sentinel errors for configuration operations. @@ -62,7 +63,7 @@ type Config struct { // DefaultConfig holds default values for new instances. type DefaultConfig struct { Agent string `mapstructure:"agent" validate:"omitempty,oneof=claude gemini codex"` - BaseImage string `mapstructure:"base_image" validate:"required"` + BaseImage string `mapstructure:"base_image"` } // AgentConfig holds agent-specific configuration. @@ -140,7 +141,7 @@ func NewLoader() (*Loader, error) { // setDefaults sets all default configuration values using Viper. func (l *Loader) setDefaults() { l.v.SetDefault("default.agent", "") - l.v.SetDefault("default.base_image", DefaultBaseImage) + l.v.SetDefault("default.base_image", "") l.v.SetDefault("storage.worktrees", "~/.local/share/headjack/git") l.v.SetDefault("storage.catalog", "~/.local/share/headjack/catalog.json") l.v.SetDefault("storage.logs", "~/.local/share/headjack/logs") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e634347..8fd0b4f 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -21,7 +21,7 @@ func TestLoader_Load_CreatesDefaultIfMissing(t *testing.T) { // Check defaults assert.Empty(t, cfg.Default.Agent) - assert.Equal(t, DefaultBaseImage, cfg.Default.BaseImage) + assert.Empty(t, cfg.Default.BaseImage) assert.Contains(t, cfg.Storage.Worktrees, "headjack") assert.Contains(t, cfg.Storage.Catalog, "catalog.json") assert.Contains(t, cfg.Storage.Logs, "logs") @@ -117,7 +117,7 @@ func TestLoader_Get(t *testing.T) { t.Run("valid key returns value", func(t *testing.T) { val, err := loader.Get("default.base_image") require.NoError(t, err) - assert.Equal(t, DefaultBaseImage, val) + assert.Empty(t, val) }) t.Run("invalid key returns error", func(t *testing.T) { @@ -199,14 +199,12 @@ func TestConfig_Validate(t *testing.T) { require.Error(t, err) }) - t.Run("missing required base_image", func(t *testing.T) { + t.Run("valid config without base_image", func(t *testing.T) { cfg := &Config{ Default: DefaultConfig{Agent: ""}, Storage: StorageConfig{Worktrees: "/tmp/worktrees", Catalog: "/tmp/catalog.json", Logs: "/tmp/logs"}, } - err := cfg.Validate() - require.Error(t, err) - assert.Contains(t, err.Error(), "BaseImage") + assert.NoError(t, cfg.Validate()) }) } diff --git a/internal/instance/manager.go b/internal/instance/manager.go index 128b22a..8fcd5fa 100644 --- a/internal/instance/manager.go +++ b/internal/instance/manager.go @@ -555,56 +555,6 @@ func (m *Manager) Remove(ctx context.Context, id string) error { return nil } -// Recreate removes the container and creates a new one with the specified image. -func (m *Manager) Recreate(ctx context.Context, id, image string) (*Instance, error) { - entry, err := m.catalog.Get(ctx, id) - if err != nil { - if errors.Is(err, catalog.ErrNotFound) { - return nil, ErrNotFound - } - return nil, fmt.Errorf("get catalog entry: %w", err) - } - - if shutdownErr := m.shutdownContainer(ctx, entry, shutdownContainerOpts{RemoveContainer: true}); shutdownErr != nil { - return nil, shutdownErr - } - - // Create new container - containerName := m.containerName(entry.RepoID, entry.Branch) - c, err := m.runtime.Run(ctx, &container.RunConfig{ - Name: containerName, - Image: image, - Mounts: []container.Mount{ - {Source: entry.Worktree, Target: "/workspace", ReadOnly: false}, - }, - Flags: flags.ToArgs(m.configFlags), - }) - if err != nil { - entry.Status = catalog.StatusError - _ = m.catalog.Update(ctx, entry) //nolint:errcheck // best-effort status update - return nil, fmt.Errorf("create container: %w", err) - } - - // Update catalog - entry.ContainerID = c.ID - entry.Status = catalog.StatusRunning - if err := m.catalog.Update(ctx, entry); err != nil { - return nil, fmt.Errorf("update catalog entry: %w", err) - } - - return &Instance{ - ID: entry.ID, - Repo: entry.Repo, - RepoID: entry.RepoID, - Branch: entry.Branch, - Worktree: entry.Worktree, - ContainerID: c.ID, - Container: c, - CreatedAt: entry.CreatedAt, - Status: StatusRunning, - }, nil -} - // Attach executes a command in an instance's container. // If the instance is stopped, it will be started first. func (m *Manager) Attach(ctx context.Context, id string, cfg AttachConfig) error { diff --git a/internal/instance/manager_test.go b/internal/instance/manager_test.go index 670a3f5..81133c9 100644 --- a/internal/instance/manager_test.go +++ b/internal/instance/manager_test.go @@ -488,75 +488,6 @@ func TestManager_Remove(t *testing.T) { }) } -func TestManager_Recreate(t *testing.T) { - ctx := context.Background() - - t.Run("recreates container with new image", func(t *testing.T) { - store := &catalogmocks.StoreMock{ - GetFunc: func(ctx context.Context, id string) (*catalog.Entry, error) { - return &catalog.Entry{ - ID: "abc123", - RepoID: testRepoID, - Branch: "main", - Worktree: "/data/git/myrepo/main", - ContainerID: "old-container", - CreatedAt: time.Now(), - Status: catalog.StatusRunning, - }, nil - }, - UpdateFunc: func(ctx context.Context, entry *catalog.Entry) error { - return nil - }, - } - runtime := &containermocks.RuntimeMock{ - StopFunc: func(ctx context.Context, id string) error { - return nil - }, - RemoveFunc: func(ctx context.Context, id string) error { - return nil - }, - RunFunc: func(ctx context.Context, cfg *container.RunConfig) (*container.Container, error) { - return &container.Container{ - ID: "new-container", - Name: cfg.Name, - Image: cfg.Image, - Status: container.StatusRunning, - }, nil - }, - } - - mgr := NewManager(store, runtime, nil, nil, ManagerConfig{}) - - inst, err := mgr.Recreate(ctx, "abc123", "newimage:v2") - - require.NoError(t, err) - assert.Equal(t, "new-container", inst.ContainerID) - assert.Equal(t, StatusRunning, inst.Status) - - // Verify old container was removed - require.Len(t, runtime.StopCalls(), 1) - require.Len(t, runtime.RemoveCalls(), 1) - - // Verify new container was created with new image - require.Len(t, runtime.RunCalls(), 1) - assert.Equal(t, "newimage:v2", runtime.RunCalls()[0].Cfg.Image) - }) - - t.Run("returns ErrNotFound for missing instance", func(t *testing.T) { - store := &catalogmocks.StoreMock{ - GetFunc: func(ctx context.Context, id string) (*catalog.Entry, error) { - return nil, catalog.ErrNotFound - }, - } - - mgr := NewManager(store, nil, nil, nil, ManagerConfig{}) - - _, err := mgr.Recreate(ctx, "nonexistent", "image") - - assert.ErrorIs(t, err, ErrNotFound) - }) -} - func TestManager_Attach(t *testing.T) { ctx := context.Background() From 5d315b9dea8ba8cb061cf4315ca0e145badddb4b Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Sun, 4 Jan 2026 17:10:36 -0800 Subject: [PATCH 3/3] test(integration): remove recreate command test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Current behavior: Integration tests included a test for the hjk recreate command. New behavior: The recreate command test is removed since the command was deleted. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- integration/testdata/scripts/recreate.txtar | 41 --------------------- 1 file changed, 41 deletions(-) delete mode 100644 integration/testdata/scripts/recreate.txtar diff --git a/integration/testdata/scripts/recreate.txtar b/integration/testdata/scripts/recreate.txtar deleted file mode 100644 index 581f49b..0000000 --- a/integration/testdata/scripts/recreate.txtar +++ /dev/null @@ -1,41 +0,0 @@ -# Test hjk recreate command -# This test requires a container runtime - -# Create a test git repository with unique name -exec git init testrepo-recreate -cd testrepo-recreate -exec git config user.email 'test@example.com' -exec git config user.name 'Test User' -exec git commit --allow-empty -m 'initial commit' -exec git branch test/recreate-instance - -# Create an instance -exec hjk run test/recreate-instance -d -stdout 'Created' -! stderr . - -# Wait for container to be running -wait_running test/recreate-instance - -# Verify instance is running -exec hjk ps -stdout 'test/recreate-instance' -stdout 'running' -! stderr . - -# Recreate the instance -exec hjk recreate test/recreate-instance -stdout 'Recreated instance' -stdout 'test/recreate-instance' -! stderr . - -# Verify instance is still running after recreate -exec hjk ps -stdout 'test/recreate-instance' -stdout 'running' -! stderr . - -# Clean up -exec hjk rm test/recreate-instance --force -stdout 'Removed' -! stderr .