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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion .devcontainer/devcontainer-lock.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,24 @@
{
"features": {}
"features": {
"ghcr.io/synergy-shock/devcontainer-features/claude:2": {
"version": "2.1.145",
"resolved": "ghcr.io/synergy-shock/devcontainer-features/claude@sha256:4ad94850485d53ec0a7ccf1fbef5e48286dde8be1811bdb926434637085e0c4e",
"integrity": "sha256:4ad94850485d53ec0a7ccf1fbef5e48286dde8be1811bdb926434637085e0c4e"
},
"ghcr.io/synergy-shock/devcontainer-features/gitbutler:0": {
"version": "0.1.0",
"resolved": "ghcr.io/synergy-shock/devcontainer-features/gitbutler@sha256:28e79b97e473e948f1d4ff8d35690ef1191f1f0bf2bb3f0df398bfb507b16ae2",
"integrity": "sha256:28e79b97e473e948f1d4ff8d35690ef1191f1f0bf2bb3f0df398bfb507b16ae2"
},
"ghcr.io/synergy-shock/devcontainer-features/pnpm:11": {
"version": "11.0.0",
"resolved": "ghcr.io/synergy-shock/devcontainer-features/pnpm@sha256:9ad82ac4ab3b0cf1d8422adbfd09ce61b12d99acd21d5083718ee55c87d9a2f9",
"integrity": "sha256:9ad82ac4ab3b0cf1d8422adbfd09ce61b12d99acd21d5083718ee55c87d9a2f9"
},
"ghcr.io/synergy-shock/devcontainer-features/rtk:0": {
"version": "0.1.0",
"resolved": "ghcr.io/synergy-shock/devcontainer-features/rtk@sha256:69779045064cdc8a6d92461949d15974d0e19bc78d0f2b791969d5d719f392d4",
"integrity": "sha256:69779045064cdc8a6d92461949d15974d0e19bc78d0f2b791969d5d719f392d4"
}
}
}
24 changes: 12 additions & 12 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
{
"name": "Devcontainer Features",
"image": "mcr.microsoft.com/devcontainers/typescript-node:24-trixie",
"image": "mcr.microsoft.com/devcontainers/base:trixie",
"features": {
"../src/pnpm": {},
"../src/gitbutler": {},
// "../src/opencode": {},
"../src/rtk": {},
"../src/claude": {},
"ghcr.io/devcontainers/features/common-utils:2": {},
"ghcr.io/devcontainers/features/git:1": {},
"ghcr.io/devcontainers/features/git-lfs:1": {},
"ghcr.io/devcontainers/features/node:2": {
"version": "24",
"npmVersion": "latest",
"pnpmVersion": "latest",
},
"ghcr.io/synergy-shock/devcontainer-features/claude:2": {},
"ghcr.io/synergy-shock/devcontainer-features/rtk:0": {},
"ghcr.io/synergy-shock/devcontainer-features/gitbutler:0": {},
},
"mounts": [
"source=pnpm-store,target=/home/node/.pnpm-store,type=volume",
"source=${localEnv:HOME}/.npmrc,target=/home/node/.npmrc,type=bind,readonly",
// "source=${localEnv:HOME}/.config/opencode,target=/home/node/.config/opencode,type=bind",
// "source=${localEnv:HOME}/.local/share/opencode,target=/home/node/.local/share/opencode,type=bind",
"source=${localEnv:HOME}/.claude,target=/home/node/.claude,type=bind",
"source=${localEnv:HOME}/.claude.json,target=/home/node/.claude.json,type=bind",
"source=/Applications/1Password.app/Contents/MacOS/op-ssh-sign,target=/op-ssh-sign,type=bind,readonly",
Expand All @@ -22,8 +24,6 @@
"runArgs": [],
"containerEnv": {
"SSH_AUTH_SOCK": "/agent.sock",
"PNPM_HOME": "/home/node/.pnpm-store",
"PNPM_STORE_DIR": "/home/node/.pnpm-store",
},
"forwardPorts": [],
"remoteUser": "node",
Expand Down
26 changes: 17 additions & 9 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,6 @@ jobs:
fail-fast: false
matrix:
include:
# pnpm requires Node — only typescript-node:24 has it.
- feature: pnpm
base-image: mcr.microsoft.com/devcontainers/typescript-node:24-trixie
- feature: pnpm
base-image: mcr.microsoft.com/devcontainers/typescript-node:24-bookworm
- feature: pnpm
base-image: mcr.microsoft.com/devcontainers/typescript-node:24-bullseye

# claude / gitbutler / opencode are Node-less — test on the bare base
# across all current Debian + Ubuntu LTS releases.
- feature: claude
Expand Down Expand Up @@ -61,6 +53,22 @@ jobs:
base-image: mcr.microsoft.com/devcontainers/base:jammy
- feature: opencode
base-image: mcr.microsoft.com/devcontainers/base:focal

# node uses the official Linux prebuilt tarball; Node 24 LTS requires
# glibc >= 2.28, which is satisfied by all six base images (focal and
# bullseye both ship glibc 2.31).
- feature: node
base-image: mcr.microsoft.com/devcontainers/base:trixie
- feature: node
base-image: mcr.microsoft.com/devcontainers/base:bookworm
- feature: node
base-image: mcr.microsoft.com/devcontainers/base:bullseye
- feature: node
base-image: mcr.microsoft.com/devcontainers/base:noble
- feature: node
base-image: mcr.microsoft.com/devcontainers/base:jammy
- feature: node
base-image: mcr.microsoft.com/devcontainers/base:focal
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
Expand Down Expand Up @@ -95,7 +103,7 @@ jobs:
strategy:
fail-fast: false
matrix:
feature: [claude, gitbutler, opencode, pnpm]
feature: [claude, gitbutler, opencode, node]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FEATURES ?= claude,gitbutler,opencode,pnpm
FEATURES ?= claude,gitbutler,opencode,rtk
BASE_IMAGE ?= mcr.microsoft.com/devcontainers/typescript-node:24-trixie
BASE_PATH ?= ./src
OCI_REGISTRY ?= ghcr.io
Expand Down
72 changes: 33 additions & 39 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ A collection of [Dev Container Features](https://containers.dev/features) publis
|----|------|-------------|-----------|
| [`claude`](./src/claude) | Claude Code | Installs the Claude Code CLI (`@anthropic-ai/claude-code`). | `ghcr.io/synergy-shock/devcontainer-features/claude` |
| [`gitbutler`](./src/gitbutler) | GitButler CLI | Installs the GitButler CLI (`but`). | `ghcr.io/synergy-shock/devcontainer-features/gitbutler` |
| [`node`](./src/node) | Node.js | Installs Node.js from the official nodejs.org prebuilt binaries, with optional `npm` and `pnpm` pinning. NVM-free. | `ghcr.io/synergy-shock/devcontainer-features/node` |
| [`opencode`](./src/opencode) | OpenCode | Installs the OpenCode AI CLI. | `ghcr.io/synergy-shock/devcontainer-features/opencode` |
| [`pnpm`](./src/pnpm) | pnpm | Installs pnpm globally and configures the store and npm prefix. | `ghcr.io/synergy-shock/devcontainer-features/pnpm` |
| [`rtk`](./src/rtk) | rtk (Rust Token Killer) | Installs the `rtk` CLI proxy. Pairs with `claude` and/or `opencode`. | `ghcr.io/synergy-shock/devcontainer-features/rtk` |

## Usage
Expand All @@ -18,9 +18,8 @@ Reference a feature from any `devcontainer.json` by its GHCR URL and version tag

```jsonc
{
"image": "mcr.microsoft.com/devcontainers/typescript-node:24-trixie",
"image": "mcr.microsoft.com/devcontainers/base:trixie",
"features": {
"ghcr.io/synergy-shock/devcontainer-features/pnpm:11": {},
"ghcr.io/synergy-shock/devcontainer-features/claude:2": {},
"ghcr.io/synergy-shock/devcontainer-features/opencode:1": {},
"ghcr.io/synergy-shock/devcontainer-features/rtk:0": {},
Expand All @@ -31,56 +30,49 @@ Reference a feature from any `devcontainer.json` by its GHCR URL and version tag

Version tags follow the `MAJOR`, `MAJOR.MINOR`, and `MAJOR.MINOR.PATCH` aliases that `devcontainers/action` publishes automatically.

## Wiring host state into the container
## Recommended pairings

The features install binaries; the `mounts` and `containerEnv` in this repo's own [`.devcontainer/devcontainer.json`](./.devcontainer/devcontainer.json) are a worked example of how to thread host credentials, caches, and services through so those binaries have something to talk to.
These features are intentionally narrow — one upstream CLI each. For everything else (a shell, language runtimes, git, GitHub auth), reach for **Microsoft's official base images** and the **`devcontainers/features`** catalog before writing your own. Both are first-party, versioned, and reviewed.

### Conventions worth copying
**Start from an official base image.** Pick the one that already ships the runtime you need so you don't reinstall it as a feature:

- **Bind host config, not container config.** Treat the container as disposable. Anything you would lose on rebuild (auth tokens, AI session state, shell history for the tools you care about) should live on the host and be mounted in.
- **Named volumes for caches.** Bind mounts share the host filesystem's permissions and inode layout, which is fine for config but slow and fragile for large package caches. Use a Docker named volume (like `pnpm-store` below) for anything write-heavy.
- **`readonly` for credentials you only need to read.** `.npmrc` and the 1Password signing helper are mounted readonly so a misbehaving tool inside the container cannot corrupt host state.
- **Pair every socket bind with an env var.** A forwarded socket with no `SSH_AUTH_SOCK` (or equivalent) pointing at it is just a file.
- [`mcr.microsoft.com/devcontainers/base:<distro>`](https://github.com/devcontainers/images/tree/main/src/base-debian) — minimal Debian/Ubuntu with `vscode` user, sudo, common utilities. The right default for our `claude` / `gitbutler` / `opencode` / `rtk` features, none of which need Node.
- [`mcr.microsoft.com/devcontainers/typescript-node:<node>-<distro>`](https://github.com/devcontainers/images/tree/main/src/typescript-node) — base + Node.js + `pnpm` and `yarn` via corepack, ships with a `node` user. Use this when you actually need a JS toolchain; it removes the need for any pnpm feature.

### Forward PNPM configuration to all devcontainers
**Layer official features for shared tooling.** From [`ghcr.io/devcontainers/features`](https://github.com/devcontainers/features/tree/main/src):

Mount the host's `~/.npmrc` readonly so the container inherits your registry, auth tokens, and pnpm settings without copying secrets into the image. `PNPM_HOME` and `PNPM_STORE_DIR` keep the store path predictable across rebuilds.
- [`node:1`](https://github.com/devcontainers/features/tree/main/src/node) — install Node + npm, and pnpm via its `pnpmVersion` option (and yarn via `installYarnUsingApt`). NVM-based. Use it if you want NVM's multi-version management; reach for this repo's [`node`](./src/node) feature instead if you want a single, NVM-free Node install (official `nodejs.org` prebuilt binaries straight into `/usr/local`).
- [`common-utils:2`](https://github.com/devcontainers/features/tree/main/src/common-utils) — sudo, curl/wget, useful shells, the canonical non-root user setup. Add it when you start from a non-`devcontainers/base` image. **Required before** this repo's `node` feature.
- [`git:1`](https://github.com/devcontainers/features/tree/main/src/git) — newer git than what's in older distros.
- [`git-lfs:1`](https://github.com/devcontainers/features/tree/main/src/git-lfs) — Git LFS, if your repo uses it.
- [`github-cli:1`](https://github.com/devcontainers/features/tree/main/src/github-cli) — `gh` for PR/issue workflows and `gh auth` against the host.

A typical Node-flavored stack ends up looking like:

```jsonc
{
"image": "mcr.microsoft.com/devcontainers/typescript-node:24-trixie",
"features": {
"ghcr.io/synergy-shock/devcontainer-features/pnpm:11": {}
},
"mounts": [
"source=${localEnv:HOME}/.npmrc,target=/home/node/.npmrc,type=bind,readonly",
],
"containerEnv": {
"PNPM_HOME": "/home/node/.pnpm-store",
"PNPM_STORE_DIR": "/home/node/.pnpm-store"
"ghcr.io/devcontainers/features/github-cli:1": {},
"ghcr.io/devcontainers/features/git-lfs:1": {},
"ghcr.io/synergy-shock/devcontainer-features/claude:2": {},
"ghcr.io/synergy-shock/devcontainer-features/gitbutler:0": {}
}
}
```

### Unify PNPM cache across all devcontainers
If you don't need Node at all, drop the image down to `mcr.microsoft.com/devcontainers/base:trixie` and skip the `node` feature entirely.

Back the pnpm store with a Docker named volume so every devcontainer on the host shares one cache — first install in a fresh container is fast, and disk usage stops scaling with the number of projects.
## Wiring host state into the container

```jsonc
{
"image": "mcr.microsoft.com/devcontainers/typescript-node:24-trixie",
"features": {
"ghcr.io/synergy-shock/devcontainer-features/pnpm:11": {}
},
"mounts": [
"source=pnpm-store,target=/home/node/.pnpm-store,type=volume",
],
"containerEnv": {
"PNPM_HOME": "/home/node/.pnpm-store",
"PNPM_STORE_DIR": "/home/node/.pnpm-store"
}
}
```
The features install binaries; the `mounts` and `containerEnv` in this repo's own [`.devcontainer/devcontainer.json`](./.devcontainer/devcontainer.json) are a worked example of how to thread host credentials, caches, and services through so those binaries have something to talk to.

### Conventions worth copying

- **Bind host config, not container config.** Treat the container as disposable. Anything you would lose on rebuild (auth tokens, AI session state, shell history for the tools you care about) should live on the host and be mounted in.
- **Named volumes for caches.** Bind mounts share the host filesystem's permissions and inode layout, which is fine for config but slow and fragile for large package caches. Use a Docker named volume for anything write-heavy (a shared pnpm/yarn store, build caches, Docker layer caches inside the container).
- **`readonly` for credentials you only need to read.** `.npmrc` and the 1Password signing helper are mounted readonly so a misbehaving tool inside the container cannot corrupt host state.
- **Pair every socket bind with an env var.** A forwarded socket with no `SSH_AUTH_SOCK` (or equivalent) pointing at it is just a file.

### 1Password SSH agent and commit signing (macOS)

Expand Down Expand Up @@ -115,8 +107,10 @@ Then inside the container verify the binaries you touched:
```bash
claude --version
but --version
node --version
opencode --version
pnpm --version
rtk --version
```

## Testing
Expand All @@ -131,8 +125,8 @@ Then from the repo root:

```bash
# Autogenerated checks for a single feature
devcontainer features test -f pnpm \
-i mcr.microsoft.com/devcontainers/typescript-node:24-trixie \
devcontainer features test -f claude \
-i mcr.microsoft.com/devcontainers/base:trixie \
-p .

# Custom scenarios for a single feature
Expand Down
7 changes: 1 addition & 6 deletions src/claude/devcontainer-feature.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,5 @@
"proposals": ["latest"]
}
},
"containerEnv": {
"NPM_CONFIG_PREFIX": "/usr/local/share/npm-global",
"PNPM_HOME": "/usr/local/share/pnpm",
"PNPM_STORE_DIR": "/usr/local/share/pnpm-store",
"PATH": "/usr/local/share/npm-global/bin:/usr/local/share/pnpm:/usr/local/bin:${PATH}"
}
"containerEnv": {}
}
2 changes: 1 addition & 1 deletion src/gitbutler/devcontainer-feature.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"id": "gitbutler",
"version": "0.1.0",
"version": "0.1.1",
"name": "GitButler CLI",
"description": "Installs the GitButler CLI (but).",
"documentationURL": "https://github.com/Synergy-Shock/devcontainer-features/tree/main/src/gitbutler",
Expand Down
30 changes: 30 additions & 0 deletions src/node/NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
## OS support

Debian/Ubuntu-based images. The feature does **not** call `apt-get` itself — it expects `curl`, `jq`, and `tar` to already be installed. The expected provider is [`ghcr.io/devcontainers/features/common-utils:2`](https://github.com/devcontainers/features/tree/main/src/common-utils), which must be listed **before** the `node` feature in your `devcontainer.json`. If any required command is missing, the install script aborts with a clear error pointing at `common-utils:2`.

## Implementation details

- **Node.js** is the official Linux prebuilt tarball from `https://nodejs.org/dist/`, extracted into `/usr/local` with `tar --strip-components=1 --exclude=CHANGELOG.md --exclude=LICENSE --exclude=README.md` so that `node`, `npm`, and `npx` land directly under `/usr/local/bin` — already on every shell's `PATH`. No NVM, no NodeSource apt repo, no shell-rc edits.
- **Version resolution** is done against the official `https://nodejs.org/dist/index.json` index. Accepted forms for the `version` option: `lts`, `latest`/`current`, `lts/<codename>` (e.g. `lts/krypton`), `<major>` (e.g. `24`), `<major>.<minor>`, or an exact version with or without a leading `v`.
- **npm** is pinned with `npm install -g npm@<version>` into the Node tarball's `/usr/local` prefix, overwriting `/usr/local/bin/npm`. The default `latest` always installs the newest published npm.
- **pnpm** is installed with `npm install -g pnpm@<version>` (no `get.pnpm.io` script, no Corepack indirection). It lands in `NPM_CONFIG_PREFIX=/usr/local/share/npm-global` — a dedicated dir for npm globals so user-installed packages don't mix with the Node tarball's `/usr/local/bin` — and is symlinked at `/usr/local/bin/pnpm` so it's reachable even before `/etc/profile.d/node.sh` is sourced. The install script writes `/etc/profile.d/node.sh` exporting `NPM_CONFIG_PREFIX`, `PNPM_HOME=/usr/local/share/pnpm` (where pnpm itself stores globally-installed packages on later `pnpm add -g …`), and prepending both `${NPM_CONFIG_PREFIX}/bin` and `${PNPM_HOME}` to `PATH`. `/etc/profile` is sourced by login shells, and `common-utils:2` (a prerequisite) also wires `/etc/bash.bashrc` to source `/etc/profile.d/*.sh`, so the binaries are reachable from both login and interactive non-login shells.

## Feature ordering

Add `common-utils` **before** `node`, since this feature checks its dependencies up front:

```jsonc
{
"image": "mcr.microsoft.com/devcontainers/base:trixie",
"features": {
"ghcr.io/devcontainers/features/common-utils:2": {},
"ghcr.io/synergy-shock/devcontainer-features/node:0": {
"version": "lts",
"npmVersion": "latest",
"pnpmVersion": "latest"
}
}
}
```

If you already start from `mcr.microsoft.com/devcontainers/base:*`, `common-utils` essentials are baked in; the explicit feature line is still the safest way to guarantee `curl` / `jq` / `tar` regardless of the base image.
Loading