Skip to content
Open
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
27 changes: 23 additions & 4 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
# Everything is therefore installed from plain RUN layers here. Tool versions
# live in mise.toml (installed system-wide with mise); only the few things mise
# cannot manage (apt packages, nsc, Playwright) are installed directly.
#
# The build context is the REPOSITORY ROOT (devcontainer.json: build.context
# "..", CI: `docker build -f .devcontainer/Dockerfile .`) so the in-container
# utilities in utilities/ can be compiled here. COPY paths are repo-relative.
FROM mcr.microsoft.com/devcontainers/typescript-node:26-trixie

USER root
Expand Down Expand Up @@ -47,8 +51,8 @@ RUN mkdir -p /workspaces && chown node:node /workspaces
# ---------------------------------------------------------------------------
# tmux configuration for both `node` (default) and `root` (used by `coder ssh`).
# ---------------------------------------------------------------------------
COPY tmux.conf /home/node/.tmux.conf
COPY tmux.conf /root/.tmux.conf
COPY .devcontainer/tmux.conf /home/node/.tmux.conf
COPY .devcontainer/tmux.conf /root/.tmux.conf
RUN chown node:node /home/node/.tmux.conf && chown root:root /root/.tmux.conf

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -88,7 +92,7 @@ ENV MISE_DATA_DIR=/usr/local/share/mise \
RUSTUP_HOME=/usr/local/rustup \
CARGO_HOME=/usr/local/cargo
ENV PATH=${MISE_DATA_DIR}/shims:${GOPATH}/bin:${CARGO_HOME}/bin:${PATH}
COPY mise.toml /etc/mise/config.toml
COPY .devcontainer/mise.toml /etc/mise/config.toml
RUN mkdir -p "${MISE_DATA_DIR}" "${GOPATH}" "${RUSTUP_HOME}" "${CARGO_HOME}" \
&& chown -R node:node "${MISE_DATA_DIR}" "${GOPATH}" "${RUSTUP_HOME}" "${CARGO_HOME}"

Expand All @@ -99,7 +103,7 @@ RUN mkdir -p "${MISE_DATA_DIR}" "${GOPATH}" "${RUSTUP_HOME}" "${CARGO_HOME}" \
# (CI's init script is non-login and never noticed). Export the tool dirs and
# mise variables from a profile.d snippet and source it from the interactive rc
# files too, exactly as the devcontainer features used to do.
COPY 10-devcontainer-tools.sh /etc/profile.d/10-devcontainer-tools.sh
COPY .devcontainer/10-devcontainer-tools.sh /etc/profile.d/10-devcontainer-tools.sh
RUN chmod 0644 /etc/profile.d/10-devcontainer-tools.sh \
&& printf '\n. /etc/profile.d/10-devcontainer-tools.sh\n' >> /etc/bash.bashrc \
&& printf '\n. /etc/profile.d/10-devcontainer-tools.sh\n' >> /etc/zsh/zshrc
Expand All @@ -114,6 +118,21 @@ RUN MISE_YES=1 mise install --jobs 8 \
&& if [ -n "$missing" ]; then echo "mise tools missing after install:" "$missing" >&2; exit 1; fi \
&& rm -rf /home/node/.cache/mise

# ---------------------------------------------------------------------------
# In-container utilities (utilities/): Go TUIs such as `bootstrap`, which walks
# through logins and identity on a fresh container. Built with the mise-managed
# Go as `node` so the module cache lands in node-owned /go; the binaries are
# then installed system-wide. See utilities/AGENTS.md for the pattern.
# ---------------------------------------------------------------------------
COPY --chown=node:node utilities /usr/local/src/devcontainer-utilities
RUN cd /usr/local/src/devcontainer-utilities \
&& go build -trimpath -ldflags="-s -w" -o /tmp/devcontainer-utilities-bin/ ./cmd/... \
&& go clean -cache \
&& rm -rf /home/node/.cache/go-build
USER root
RUN install -m 0755 /tmp/devcontainer-utilities-bin/* /usr/local/bin/ \
&& rm -rf /tmp/devcontainer-utilities-bin

# Stay on the non-root `node` user. envbuilder (used by Coder) picks its target
# user from the last `USER` directive when `containerUser`/`remoteUser` are not
# applied from image metadata, so the intended user must be explicit here to
Expand Down
8 changes: 6 additions & 2 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
// image. See the Dockerfile header and README for details.
{
"name": "bmorton/devcontainer",
// Build context is the repo root so the Dockerfile can compile utilities/.
"build": {
"dockerfile": "Dockerfile"
"dockerfile": "Dockerfile",
"context": ".."
},

// Connect as the `node` user. Set this explicitly rather than relying on the
Expand All @@ -29,7 +31,9 @@
// Use 'postCreateCommand' to run commands after the container is created.
// The sentinel file lets CI verify that the lifecycle actually ran to
// completion (both the devcontainer CLI and envbuilder honor this hook).
"postCreateCommand": "kubectl version --client 2>/dev/null || true; touch \"$HOME/.devcontainer-postcreate-done\"",
// `bootstrap --check` leaves a status report (logins etc.) in the creation
// log; it never fails the lifecycle.
"postCreateCommand": "kubectl version --client 2>/dev/null || true; bootstrap --check || true; touch \"$HOME/.devcontainer-postcreate-done\"",

// Start sshd (port 2222) on every container start; this replaces the entrypoint
// the sshd devcontainer feature used to install. Never fails the start.
Expand Down
8 changes: 8 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# The Docker build context is the repository root (devcontainer.json sets
# build.context to ".."), so the Dockerfile can COPY both .devcontainer/ and
# utilities/. Exclude everything else to keep the context small and cache keys
# stable.
*
!.devcontainer/
!utilities/
utilities/bin/
7 changes: 7 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,10 @@ updates:
schedule:
interval: "weekly"
open-pull-requests-limit: 5

# Go dependencies of the in-container utilities
- package-ecosystem: "gomod"
directory: "/utilities"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
32 changes: 31 additions & 1 deletion .github/workflows/build-containers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,36 @@ permissions:
contents: read

jobs:
utilities:
name: Test utilities (Go)
runs-on: namespace-profile-devcontainer
timeout-minutes: 15
defaults:
run:
working-directory: utilities

steps:
- name: Checkout code
uses: actions/checkout@v7

- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: utilities/go.mod
cache-dependency-path: utilities/go.sum

- name: gofmt
run: test -z "$(gofmt -l .)" || { echo "gofmt needed:"; gofmt -l .; exit 1; }

- name: go vet
run: go vet ./...

- name: go test
run: go test ./...

- name: go build
run: go build -trimpath -o bin/ ./cmd/...

build-dockerfile:
name: Build Dockerfile
runs-on: namespace-profile-devcontainer
Expand All @@ -23,7 +53,7 @@ jobs:
- name: Build Dockerfile
run: |
echo "Building Dockerfile..."
docker build -t devcontainer-dockerfile .devcontainer/
docker build -f .devcontainer/Dockerfile -t devcontainer-dockerfile .
echo "Dockerfile build completed successfully"

build-envbuilder:
Expand Down
48 changes: 48 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# bmorton/devcontainer — guidance for coding agents

Personal devcontainer image used from VS Code and, mainly, Coder (built with
envbuilder). The README explains the "why"; this file is the operating manual.

## Non-negotiables

- **No devcontainer `features:`.** envbuilder's cache probe cannot reproduce
feature layers, so features force a full rebuild on every workspace start.
Everything is installed from `.devcontainer/Dockerfile`.
- **Versioned tools go in `.devcontainer/mise.toml`,** pinned exactly. Check
`mise registry <name>` first (note: the registry's `nsc` is NATS, not
Namespace). Only apt packages, nsc and Playwright are installed directly.
- **Nothing important lives under `/home/node` in the image.** Coder may persist
the home directory, which would shadow it. Tools go to `/usr/local/share/mise`,
`GOPATH=/go`, `CARGO_HOME`/`RUSTUP_HOME` under `/usr/local`, all node-owned.
- **PATH additions must be made twice:** `ENV PATH` for non-login processes
and `.devcontainer/10-devcontainer-tools.sh` (profile.d, sourced from
bash.bashrc and zsh/zshrc) for login shells. Debian's `/etc/profile` resets
PATH and the base image only restores its own.
- **Keep the tmux key bindings.** Everything else in `.devcontainer/tmux.conf`
may be modernised; test on both the current tmux and the mise-installed one.

## Validating changes

There is no Docker inside the container. The PR workflow `Build Containers`
is the test: plain `docker build`, `devcontainer up` + exec, and an envbuilder
build that runs `scripts/verify-devcontainer.sh` as the init script. Extend
that script whenever you add something the image must guarantee (a binary, a
PATH entry, a config file). On merge, `Cache Devcontainer` republishes the
prebuilt image; Coder workspaces pick it up on next start.

## Repo map

- `.devcontainer/` — Dockerfile, devcontainer.json (build context is the repo
root), mise.toml, tmux.conf, profile.d snippet.
- `utilities/` — Go module of in-container TUIs (`bootstrap`); has its own
AGENTS.md and STYLEGUIDE.md.
- `scripts/` — verify script (runs inside the built container), local
envbuilder reproduction, cache publishing.
- `.github/workflows/` — PR builds, weekly `mise upgrade --bump` PR, cache publish.

## Habits

- Dependency bumps: mise.toml via the weekly workflow (or `mise upgrade --bump`
in `.devcontainer/`); base image and actions via Dependabot.
- Prefer small PRs with the CI run linked; write down the lesson in the
README or this file when a Coder-only failure is found.
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,19 @@ a version for that project; `mise use` / `mise install` work as usual.
are system-wide and owned by `node`, so `go install` and `cargo install` work
without sudo and survive a persisted home directory.

## In-container utilities (`utilities/`)

A Go module of small terminal tools that are compiled into the image and
installed to `/usr/local/bin`. They share one look (bright cyberpunk accents,
see [`utilities/STYLEGUIDE.md`](utilities/STYLEGUIDE.md)) and one code pattern
(`cmd/<tool>` → `internal/<tool>`, see [`utilities/AGENTS.md`](utilities/AGENTS.md)).

| Command | What it does |
| --- | --- |
| `bootstrap` | Steps through the setup an image can't bake in: git identity, GitHub CLI login and credential helper, Claude Code login, and optionally Copilot, opencode, pi, Namespace and Azure. `enter` runs the fix for the selected item (interactive logins run in your terminal, then get re-checked). `bootstrap --check` prints the same report non-interactively and exits 1 while required items are outstanding; `postCreateCommand` runs it so the report is in the creation log. |

Run `bootstrap` first thing in a new workspace.

## VS Code extensions

- eamodio.gitlens
Expand Down Expand Up @@ -180,6 +193,7 @@ When you bump the envbuilder version, update it in both

`scripts/verify-devcontainer.sh` runs inside the built container (CI uses it as
envbuilder's init script) and asserts: user is `node`, tmux starts with the
shipped config, the lifecycle completed, `/workspaces` is writable, and every
mise-pinned tool is installed and runnable from a plain `PATH` lookup.
shipped config, the lifecycle completed, `/workspaces` is writable, every
mise-pinned tool and utility is runnable from a plain `PATH` lookup, and the
same holds in bash and zsh login shells.
`scripts/test-envbuilder.sh` reproduces the Coder build locally with Docker.
2 changes: 1 addition & 1 deletion scripts/verify-devcontainer.sh
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ if command -v mise >/dev/null 2>&1; then
for entry in "go:version" "ruby:--version" "cargo:--version" "kubectl:version --client" \
"helm:version" "gh:--version" "az:version" "claude:--version" \
"opencode:--version" "copilot:--version" "pi:--version" "witr:--version" \
"tmux:-V" "gopls:version" "golangci-lint:--version"; do
"tmux:-V" "gopls:version" "golangci-lint:--version" "bootstrap:--version"; do
tool="${entry%%:*}"
args="${entry#*:}"
# shellcheck disable=SC2086 # intentional word splitting of $args
Expand Down
1 change: 1 addition & 0 deletions utilities/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
bin/
77 changes: 77 additions & 0 deletions utilities/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# utilities/ — guidance for coding agents

Go module `github.com/bmorton/devcontainer/utilities`: small terminal tools that
ship inside the devcontainer image (built by `.devcontainer/Dockerfile`,
installed to `/usr/local/bin`). The first is `bootstrap`. Read
`STYLEGUIDE.md` before touching any UI.

## Layout and the pattern to copy

```
cmd/<tool>/main.go thin: flags → internal/<tool>.Run(Options) → os.Exit
internal/<tool>/ all logic; exported Run(Options) int, RunCheck(...)
internal/<tool>/model.go Bubble Tea model (Init/Update/View) only
internal/ui/theme/ shared palette, styles, glyphs, huh theme (the only place hex colors live)
internal/sh/ command runner interface + FakeRunner for tests
```

Adding a utility:

1. `cmd/<name>/main.go` with `--check`/`--version` flags, mirroring `cmd/bootstrap`.
2. `internal/<name>/` with `run.go` (TTY detection, program start), `model.go`,
and the domain logic in plain functions that take `context.Context` and an
injectable `Env`/runner so they are unit-testable without a shell.
3. Register the binary nowhere: the Dockerfile builds `./cmd/...` and installs
every binary. Add it to the shim check list in `scripts/verify-devcontainer.sh`
and to the README table.

## Libraries

- Bubble Tea **v2**: `charm.land/bubbletea/v2`. `View()` returns `tea.View`
(`tea.NewView(s)`, set `AltScreen`, `WindowTitle`), keys arrive as
`tea.KeyPressMsg` (match on `msg.String()` such as `"ctrl+c"`, `"enter"`),
interactive subprocesses go through `tea.ExecProcess`.
- Lip Gloss v2 `charm.land/lipgloss/v2`, Bubbles v2 `charm.land/bubbles/v2/...`
(spinner, progress), Huh v2 `charm.land/huh/v2` with `theme.Huh()`.
- Do not import `github.com/charmbracelet/...` v1 paths; they are a different API.
- Stdlib first. Avoid new dependencies unless they remove meaningful code.

## Conventions

- `gofmt`, `go vet`, tests green: `mise run lint && mise run test` (or the raw
`go` commands). CI runs the same on every PR.
- Checks: concurrent, each with its own timeout, never writing to the terminal.
Return a `Result{Status, Summary, Detail}`; summaries are short lowercase
fragments, details may hold commands. Prefer parsing `--json` output over
scraping text; when text is unavoidable, keep the regex next to the step and
cover it with a test using `sh.FakeRunner`.
- Fixes: run the tool's own interactive command (`gh auth login`) rather than
re-implementing a flow. Re-check afterwards; if that is impossible, set
`Unverifiable` and say so.
- Never read or print secrets. Checking that a credential exists is fine;
echoing tokens, keys or auth files is not.
- Keep `cmd/` free of logic so `internal/` stays testable and reusable.
- Exit codes: 0 ready, 1 required work outstanding, 2 usage error.

## Testing

- Unit tests for every step/check with `sh.FakeRunner` (see `steps_test.go`).
- Headless model tests drive `Update` with `tea.WindowSizeMsg`, result
messages and `tea.KeyPressMsg{Code: 'j', Text: "j"}`, then assert on
`View().Content` (see `model_test.go`). No real terminal is needed.
- For a visual check, run the binary inside a detached tmux session and
`tmux capture-pane -p`; that is how screenshots in PRs are produced.

## Build and run

```sh
cd utilities
mise run build # ./bin/<tool>
mise run test
mise run lint
go run ./cmd/bootstrap # TUI
go run ./cmd/bootstrap --check
```

The image builds with the mise-managed Go version pinned in
`.devcontainer/mise.toml`; keep `go.mod`'s `go` directive at or below it.
1 change: 1 addition & 0 deletions utilities/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
Loading