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
20 changes: 14 additions & 6 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@
// looks right — a symptom that points at git rather than at this line.
//
// ~/.claude is a directory either way, so its `mkdir` is about ownership rather than
// shape: the one Docker creates is owned by root, and claude-code — which devbase
// pulls in via `dependsOn` — then cannot write its own config in the container. It has
// to stay in step with the .claude mount below; dropping it while the mount remained
// is exactly what the .ssh cleanup did.
// shape: the one Docker creates is owned by root, and Claude Code then cannot write its
// own config or session state in the container. Still needed after 2.0.0 dropped the
// claude-code CLI from devbase's `dependsOn` — the VS Code extension writes to the same
// directory, and the RTK hook is read from the settings.json inside it. It has to stay
// in step with the .claude mount below; dropping it while the mount remained is exactly
// what the .ssh cleanup did.
//
// ~/.zsh_history needs the same `touch` as ~/.gitconfig and for the same reason: it is
// bound as a single file since RSRMID-3052, and a missing source becomes a root-owned
Expand All @@ -42,12 +44,18 @@
// and you are back to pushing a branch to find out whether a change works.
"ghcr.io/devcontainers/features/docker-in-docker:2": {},

// node, github-cli and claude-code are deliberately NOT listed: devbase declares all
// three in `dependsOn` and pulls them in itself — node at lts, which is the Node the
// node and github-cli are deliberately NOT listed: devbase declares both in
// `dependsOn` and pulls them in itself — node at lts, which is the Node the
// devcontainer CLI and prettier run on. Leaving them out is what makes this container
// a check of that guarantee rather than a config that would still have `gh` and a Node
// of its own if the dependency silently stopped resolving.
//
// claude-code used to be a third. 2.0.0 dropped it (RSRMID-3053): the VS Code
// extension runs its own bundled runtime, so the npm global was a redundant second
// copy that could not even update itself. This frame still references `devbase:1`
// below, so it keeps the CLI until that reference moves to `:2` — after which there is
// deliberately no `claude` on PATH here.
//
// Every removal from this list is a bet that the *published* Feature already provides
// it, because this list resolves devbase:1 from the registry. Dropping github-cli once
// on the strength of a dependsOn that existed only in the working tree is how every
Expand Down
9 changes: 6 additions & 3 deletions .devcontainer/local/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,12 @@
"features": {
// Needed to run `pnpm features:test` from inside this container.
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
// node, github-cli and claude-code come from devbase's `dependsOn` — see the default
// config. Here they exercise it straight from the working-tree copy, which is why this
// list can shrink without waiting for a release the way the default one must.
// node and github-cli come from devbase's `dependsOn` — see the default config. Here
// they exercise it straight from the working-tree copy, which is why this list can
// shrink without waiting for a release the way the default one must. That also means
// this config is where the claude-code removal takes effect first: unlike the default
// frame, it has no published `:1` to keep the CLI alive, so a container built here has
// no `claude` on PATH as soon as the working tree drops it.
"./devbase": {}
},

Expand Down
22 changes: 13 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,17 @@ environment.
sequences a feature the consumer already listed and does nothing when they did not, so
it suits the language runtimes — devbase must not install those, only run after them. A
new language runtime that post-create depends on goes there. `dependsOn` actually pulls
the feature in, and carries `github-cli` (the credential helper is useless without
`gh`) and `claude-code`. Adding to `dependsOn` puts software in every consumer's image
with no option to decline, so it is a `feat(devbase)` and needs a test asserting the
binary — never list the same feature in both. Check what a dependency drags in before
adding one: `claude-code` installs its own Node when it finds none, and that fallback is
**EOL 18 from nodesource** — which is why `node` is a dependency too, and why removing it
would silently put every repository that does not list its own on a dead runtime.
the feature in, and carries `github-cli` (the credential helper is useless without `gh`)
and `node` (devbase's own pnpm, commitizen and npm-floor steps need npm). Adding to
`dependsOn` puts software in every consumer's image with no option to decline, so it is a
`feat(devbase)` and needs a test asserting the binary — never list the same feature in
both. **Removing** one is a major, and needs a test asserting the binary is _absent_, or
nothing distinguishes a removal from one that quietly came back through another feature's
own `dependsOn`. Check what a dependency drags in before adding one, and what its removal
takes with it: `claude-code` was dropped in 2.0.0 (RSRMID-3053) because the VS Code
extension runs its own bundled runtime, and that also retired one of the two reasons
`node` is here — `claude-code` installed **EOL Node 18 from nodesource** when it found no
Node. The other reason stands, so `node` stayed.
- **`node` is in `dependsOn` at `lts`, and that makes devbase the owner of the Node
version.** It rests on one CLI behaviour: two instances of a feature with _identical_
options deduplicate, differing ones do not — both install and the dependency-expanded
Expand Down Expand Up @@ -345,8 +349,8 @@ Opus decides, Sonnet implements. Definitions live in `.claude/agents/`.
## Do NOT

- Add a language runtime to `devbase` — runtimes come from the devcontainers language
features, and this Feature installs none directly (a Node does arrive transitively via
the `claude-code` dependency; that is a known side effect, not licence to add more)
features, and `node` is the sole exception, declared in `dependsOn` because devbase's own
npm-dependent steps need it. That is one deliberate exception, not licence to add more
- Hand-edit `version` in `devcontainer-feature.json` — semantic-release owns it
- File a behaviour change under a non-releasing commit type, which silently ships nothing
- Repoint this repository's devcontainer **away from** the published coordinate — the
Expand Down
82 changes: 69 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ Add one entry to your repository's `.devcontainer/devcontainer.json`:
}
```

Only the second line is strictly required. `devbase` declares `node` (at `lts`),
`github-cli` and `claude-code` in `dependsOn`, so it installs all three itself, before
itself. The `node` entry above is kept because it deduplicates with `devbase`'s own — same
feature, same options, one install — and it documents the runtime at the point people look
for it. Other runtimes (php, go, python, java) are genuinely yours: `devbase` installs
none of those and only orders itself after them.
Only the second line is strictly required. `devbase` declares `node` (at `lts`) and
`github-cli` in `dependsOn`, so it installs both itself, before itself. The `node` entry
above is kept because it deduplicates with `devbase`'s own — same feature, same options,
one install — and it documents the runtime at the point people look for it. Other runtimes
(php, go, python, java) are genuinely yours: `devbase` installs none of those and only
orders itself after them.

Rebuild the container. That is the whole integration for a repository whose defaults
are fine.
Expand All @@ -45,6 +45,7 @@ instead — the frames come with this entry already in place.
- [Migrating a repository that already has a devcontainer](#migrating-a-repository-that-already-has-a-devcontainer)
- [Developing a Feature](#developing-a-feature)
- [Publishing](#publishing)
- [Migrating to 2.0.0](#migrating-to-200)
- [Keeping consumers up to date](#keeping-consumers-up-to-date)
- [Troubleshooting](#troubleshooting)

Expand All @@ -55,14 +56,26 @@ language runtime of its own** — php, go, python and java stay in each reposito
list, and `devbase` declares `installsAfter` for them so its setup steps run once those
runtimes exist. Node is the exception, and a deliberate one: see below.

Three features it does _not_ leave to the consumer, declared in `dependsOn` rather than
Two features it does _not_ leave to the consumer, declared in `dependsOn` rather than
`installsAfter`: **`github-cli`**, because the `gh` credential helper below is useless
without `gh`; **`claude-code`**, because it is on every one of our machines anyway; and
**`node` at `lts`**, because `devbase`'s own pnpm and commitizen steps need npm, and
because `claude-code` installs **Node 18 from nodesource** — EOL since April 2025 — when it
cannot find a Node of its own. `installsAfter` is only a hint: it orders a feature the
consumer already listed and does nothing when they did not, so it could never have carried
these three.
without `gh`; and **`node` at `lts`**, because `devbase`'s own pnpm, commitizen and
npm-floor steps need npm. `installsAfter` is only a hint: it orders a feature the consumer
already listed and does nothing when they did not, so it could never have carried either.

> **`claude-code` was a third, until 2.0.0 removed it.** It was there "because it is on
> every one of our machines anyway", and that turned out to be the whole of the reason —
> nothing in this Feature, and nothing in any consuming repository's scripts or workflows,
> ever called the `claude` CLI. Meanwhile the VS Code extension ships and runs its own
> runtime (`resources/native-binary/claude`, inside the extension directory), so the
> npm-installed global was a second copy of the same thing — and, being installed as root
> into a global npm tree the node feature makes user-owned, a copy that could not update
> itself: `claude update` failed until someone chowned it by hand. See
> [Migrating to 2.0.0](#migrating-to-200).
>
> It also used to be half the reason `node` is a dependency: `claude-code` installs
> **Node 18 from nodesource** — EOL since April 2025 — when it cannot find a Node of its
> own. That reason retires with it. The other half stands unchanged, which is why `node`
> stays.

> **On the Node pin.** Keep `"ghcr.io/devcontainers/features/node:2": { "version": "lts" }`
> in your `devcontainer.json` if it is already there — identical options deduplicate, so it
Expand Down Expand Up @@ -512,6 +525,49 @@ private, and a private Feature fails every consumer's build with a `401`. Set it
this repository's _Packages_ → the `devbase` package → _Package settings_ → _Change
visibility_.

## Migrating to 2.0.0

**What changed:** `devbase` no longer declares
`ghcr.io/anthropics/devcontainer-features/claude-code:1` in `dependsOn`, so a container
built from `2.x` has no `claude` on `PATH` unless the repository asks for it. (RSRMID-3053)

**Why:** the Claude Code VS Code extension ships and runs its own runtime, at
`resources/native-binary/claude` inside the extension directory — verified by process
list in a running container, not inferred. The npm-installed global was therefore a second
copy of the same thing, and one that could not update itself: the `claude-code` feature
installs as root into a global npm tree the node feature makes `vscode:nvm`, so the package
directory lands root-owned at mode 755 and `claude update` fails for the remote user until
someone runs `sudo chown -R vscode:nvm "$(npm root -g)/@anthropic-ai"` by hand.

**What you have to do.** Because consumers pin `devbase:1`, `2.0.0` reaches nobody on a
rebuild — the upgrade is deliberate, in two steps:

1. Change the reference in `.devcontainer/devcontainer.json` from `devbase:1` to
`devbase:2`.
2. If the repository commits a `devcontainer-lock.json`, re-resolve it. Neither
`devcontainer upgrade` nor Dependabot will do this for you — see
[Keeping consumers up to date](#keeping-consumers-up-to-date) for why, and for the
commands.

**What you do _not_ lose.** `gh`, `node` at `lts`, `pnpm` and the RTK binary are all
unchanged, as are the `anthropic.claude-code` and `anthropic.claude-vscode` VS Code
extensions — which are now the only Claude Code in the container. RTK in particular still
matters: its `PreToolUse` hook lives in the bind-mounted, host-shared
`~/.claude/settings.json` and fires under the extension's runtime exactly as it did under
the CLI's, so a container without the binary still exits 127 on every Bash call.

**If you actually want the CLI in a container**, list the feature yourself:

```jsonc
"features": {
"ghcr.io/anthropics/devcontainer-features/claude-code:1": {},
"ghcr.io/centralnicgroup-opensource/rtldev-middleware-devcontainer-features/devbase:2": {}
}
```

That is a deliberate, per-repository choice rather than something every image carries. Note
it brings the ownership problem above back with it.

## Keeping consumers up to date

**A consumer with no `devcontainer-lock.json` needs nothing.** Every rebuild re-resolves
Expand Down
1 change: 0 additions & 1 deletion features/src/devbase/devcontainer-feature.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,6 @@
"ghcr.io/devcontainers/features/node:2": {
"version": "lts"
},
"ghcr.io/anthropics/devcontainer-features/claude-code:1": {},
"ghcr.io/devcontainers/features/github-cli:1": {}
},
"installsAfter": [
Expand Down
12 changes: 7 additions & 5 deletions features/test/devbase/node_pinned.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@ check "devbase's lts wins over a repository's own Node pin" bash -lc '
node -v
! node -v | grep -q "^v22\."'

# claude-code installs its own Node only when it cannot find one, and that fallback is
# Node 18 from nodesource — EOL. With node in dependsOn it can never run, and this is what
# would catch it starting to: the apt source exists if and only if that installer ran.
check "claude-code added no Node of its own" bash -c \
'! test -e /etc/apt/sources.list.d/nodesource.list'
# The nodesource guard that used to live here is gone with its subject. It existed because
# claude-code installed Node 18 from nodesource — EOL — when it could not find a Node, and
# it caught that installer starting to run. 2.0.0 dropped claude-code from dependsOn, so
# nothing in the Feature can reach nodesource any more and the check could no longer fail.
# A check that cannot fail is worse than no check: it reports SUCCESS and sends the next
# reader somewhere else. `node` stays in dependsOn on its own merits — devbase's pnpm,
# commitizen and npm-floor steps need npm — which the check above is what actually proves.

# devbase's npm-dependent steps are the reason the dependency exists, so assert they
# completed rather than reporting a missing toolchain.
Expand Down
23 changes: 16 additions & 7 deletions features/test/devbase/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -252,14 +252,23 @@ check "banner reports the OS" bash -c 'cd /tmp && devbase-env-info | grep -qi "u
check "config.env parses as shell" sh -n /usr/local/share/devbase/config.env

# --- dependsOn -----------------------------------------------------------------
# Neither of these is listed by this test's feature set, so their presence is entirely
# the manifest's `dependsOn` doing its job. Asserting the binaries rather than the
# manifest is the point: `installsAfter` would parse identically and install neither,
# and the resulting container looks fine until the credential helper needs `gh`.
# gh is not listed by this test's feature set, so its presence is entirely the manifest's
# `dependsOn` doing its job. Asserting the binary rather than the manifest is the point:
# `installsAfter` would parse identically and install nothing, and the resulting container
# looks fine until the credential helper needs `gh`.
check "dependsOn installed the gh CLI" command -v gh
# -l so a PATH addition made in the login profile is picked up the way a real shell
# would pick it up.
check "dependsOn installed the claude CLI" bash -lc 'command -v claude'

# The mirror image, and the reason it is asserted rather than simply dropped: devbase used
# to pull the Claude Code CLI in through `dependsOn`, and 2.0.0 stopped. The VS Code
# extension ships and runs its own runtime — `resources/native-binary/claude` inside the
# extension directory — so the npm-installed global CLI was a second, redundant copy, and
# being installed as root inside a user-owned global npm tree it could not even update
# itself (`claude update` failed until someone chowned it by hand).
#
# A removal with no assertion is indistinguishable from a removal that quietly came back
# through a transitive dependency, which is exactly what `dependsOn` does. -l so a PATH
# addition made in the login profile is picked up the way a real shell would pick it up.
check "devbase installs no Claude CLI of its own" bash -lc '! command -v claude'

# --- setup.sh steps, asserted as effects -------------------------------------
# These steps are what the Feature is for, and until now only the *recording* of their
Expand Down