diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index b53d5ab..688c88b 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -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 @@ -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 diff --git a/.devcontainer/local/devcontainer.json b/.devcontainer/local/devcontainer.json index 2a0b3c4..79730f8 100644 --- a/.devcontainer/local/devcontainer.json +++ b/.devcontainer/local/devcontainer.json @@ -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": {} }, diff --git a/CLAUDE.md b/CLAUDE.md index 496cb17..73a0819 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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 diff --git a/README.md b/README.md index 00c5393..fde7815 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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) @@ -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 @@ -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 diff --git a/features/src/devbase/devcontainer-feature.json b/features/src/devbase/devcontainer-feature.json index 25682d7..f6adbaa 100644 --- a/features/src/devbase/devcontainer-feature.json +++ b/features/src/devbase/devcontainer-feature.json @@ -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": [ diff --git a/features/test/devbase/node_pinned.sh b/features/test/devbase/node_pinned.sh index 3963244..493d83d 100755 --- a/features/test/devbase/node_pinned.sh +++ b/features/test/devbase/node_pinned.sh @@ -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. diff --git a/features/test/devbase/test.sh b/features/test/devbase/test.sh index 7420bae..615af4e 100644 --- a/features/test/devbase/test.sh +++ b/features/test/devbase/test.sh @@ -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