From 0fbe12e1a5b32deaa53f8d98f1dcdd04fa0065f0 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:43:13 +0100 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20versioned=20Node=20runtimes=20?= =?UTF-8?q?=E2=80=94=20add=20node22/npm22/npx22=20and=20generalize=20cb=20?= =?UTF-8?q?expose=20(RM-11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add built-in `node22`, `npm22`, `npx22` profiles using `node:22-slim` and the `node22` state group, keeping the same logical volume names as the existing `node24` family so state isolation is provided by `state_group`. - Generalize `cb expose` to accept any npm-shaped stateful profile already in the registry (e.g. `npm` or `npm22`); exposed tools inherit the source profile's image, state group and env/volume configuration. - Update tests: registry count (16), node22 profile assertions, volume-name identity, `AppendMissingDefaultTools` pre-RM-11 upgrade, `Expose` guard clauses, and lockfile distinct `node:22-slim` / `node:24-slim` entries. - Update README, docs/security-model.md and docs/architecture.md. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 25 ++++++++++---- docs/architecture.md | 3 +- docs/security-model.md | 2 +- internal/cli/cli.go | 16 ++++----- internal/cli/cli_test.go | 32 ++++++++++++++++++ internal/lockfile/lockfile_test.go | 40 ++++++++++++++++++++++ internal/registry/file_test.go | 48 +++++++++++++++++++++++++++ internal/registry/registry.go | 34 +++++++++++++++++++ internal/registry/registry_test.go | 42 +++++++++++++++++++++-- internal/registry/state_group_test.go | 35 +++++++++++++++++++ main.go | 2 +- 11 files changed, 257 insertions(+), 22 deletions(-) create mode 100644 internal/cli/cli_test.go create mode 100644 internal/registry/state_group_test.go diff --git a/README.md b/README.md index 5dcdde6..b8d69e8 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,7 @@ cb lock | `python`, `python3` | `python:3.13-slim` | python (project `/venv` in a named volume) | | `pip`, `pip3` | `python:3.13-slim` | python | | `node`, `npm`, `npx` | `node:24-slim` | stateful (`node24` state group) | +| `node22`, `npm22`, `npx22` | `node:22-slim` | stateful (`node22` state group) | | `go`, `gofmt` | `golang:1.24` | stateful (`go124` state group) | | `jq` | `ghcr.io/jqlang/jq:latest` | stateless | | `yq` | `mikefarah/yq:latest` | stateless | @@ -196,7 +197,11 @@ dependencies live in a project-scoped named volume mounted at the project's — contents live in the volume). There's a shared npm cache and a persistent npm global prefix. Projects are mounted with their **real basename** (`D:\TEMP\node-demo-3` → `/workspace/node-demo-3`) because tools like -`npm init` derive metadata from it. +`npm init` derive metadata from it. Node 24 is the default runtime, but it is +not a guarantee that every npm package is ABI-compatible with it. For packages +whose native addons need a different Node ABI, `node22`/`npm22`/`npx22` are a +second, independent Node-major runtime with their own `node22` state group, +fully isolating project `node_modules`, the npm cache and the npm global prefix. ## Dynamic npm CLI exposure @@ -206,11 +211,15 @@ cb expose npm cowsay "hello from a container" ``` -`cb expose npm` discovers binaries in the persistent npm global prefix, adds -registry profiles for them, and creates Windows shims — `cowsay.exe` appears on -PATH without Node ever touching the host. `cb unexpose cowsay` removes the shim -and profile without deleting the underlying npm state. Registry mutations are -validated and written atomically; a failed validation refuses the update. +`cb expose` takes the name of any npm-shaped stateful profile already in the +registry (`npm`, `npm22`, ...) and discovers binaries in that profile's +persistent npm global prefix. It adds registry profiles for them that inherit +the source profile's image and `state_group`, and creates Windows shims — +`cowsay.exe` appears on PATH without Node ever touching the host. To expose a +binary installed under the Node 22 runtime, use `cb expose npm22 `. +`cb unexpose cowsay` removes the shim and profile without deleting the +underlying npm state. Registry mutations are validated and written atomically; +a failed validation refuses the update. ## Image locking and explicit updates @@ -229,7 +238,9 @@ Runtime behavior is fail-closed: **fails** and asks for `cb update TOOL` or `cb lock`. Tools sharing an image share one lock entry (`node`, `npm`, `npx` and all -npm-exposed tools ride the single `node:24-slim` entry). +npm-exposed tools ride the single `node:24-slim` entry). The Node 22 runtime +family (`node22`, `npm22`, `npx22` and anything exposed from `npm22`) ride a +separate `node:22-slim` lock entry. ## State inspection and garbage collection diff --git a/docs/architecture.md b/docs/architecture.md index fc0bd9c..cac7c3e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -133,7 +133,8 @@ Runtime resolution is fail-closed: lockfile present + configured image missing from it = refuse to run and say exactly which command fixes it. Tools sharing an image share one entry, so `node`, `npm`, `npx` and every npm-exposed tool update together — by design: they are the same runtime and diverging them -would create unrepresentable states. +would create unrepresentable states. The Node 22 family (`node22`, `npm22`, +`npx22` and anything exposed from `npm22`) is a separate `node:22-slim` entry. ## Atomic writes diff --git a/docs/security-model.md b/docs/security-model.md index 716a509..a2c2257 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -81,7 +81,7 @@ readable, and dangerous to let others edit. change; review `cb update` diffs (old → new digest) when refreshing. - Prefer specific tags (`python:3.13-slim`) over `latest` in profiles you care about; the lockfile pins either way, but intent stays readable. -- Audit `cb expose npm` output — every exposed binary is a new command on your +- Audit `cb expose` output — every exposed binary is a new command on your PATH. - Before pasting `cb inspect` / registry snippets into issues, strip credentials: env allowlists tell attackers what's worth stealing, and diff --git a/internal/cli/cli.go b/internal/cli/cli.go index b147f2d..97d0466 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -140,7 +140,7 @@ func discoverNPMGlobalBins(t registry.Tool) ([]string, error) { } } if globalVol == "" { - return nil, errors.New("npm profile has no npm-global shared volume") + return nil, fmt.Errorf("tool %q has no npm-global shared volume", t.Name) } script := `if [ -d /cb/npm-global/bin ]; then for f in /cb/npm-global/bin/*; do [ -e "$f" ] || continue; basename "$f"; done; fi` mount, err := dockerrun.MountSpec("volume", globalVol, "/cb/npm-global") @@ -170,16 +170,14 @@ func discoverNPMGlobalBins(t registry.Tool) ([]string, error) { func Expose(reg registry.Registry, cfgPath string, args []string) error { if len(args) == 0 { - return errors.New("usage: cb expose npm [BINARY ...]") + return errors.New("usage: cb expose TOOL [BINARY ...] (TOOL is an npm-shaped stateful profile already in the registry, e.g. npm or npm22)") } - if strings.ToLower(args[0]) != "npm" { - return errors.New("v0.8 supports only: cb expose npm [BINARY ...]") - } - npm, ok := reg.Tools["npm"] + sourceName := strings.ToLower(args[0]) + source, ok := reg.Tools[sourceName] if !ok { - return errors.New("npm tool is not configured") + return fmt.Errorf("tool %q not found; cb expose exposes global binaries from an npm-shaped profile already in the registry", sourceName) } - bins, err := discoverNPMGlobalBins(npm) + bins, err := discoverNPMGlobalBins(source) if err != nil { return err } @@ -217,7 +215,7 @@ func Expose(reg registry.Registry, cfgPath string, args []string) error { fmt.Printf("skip %-16s already exists in registry\n", name) continue } - section := fmt.Sprintf("\n# Exposed from npm global prefix by cb expose npm\n[tools.%s]\nimage = %s\nprovider = \"stateful\"\ncommand = [%s]\nstate_group = %s\nshared_volumes = %s\nenv_set = %s\nenv_prefixes = %s\nenv_names = %s\n", name, toml.Quote(npm.Image), toml.Quote("/cb/npm-global/bin/"+name), toml.Quote(npm.StateGroup), toml.Array(npm.SharedVolumes), toml.Array(npm.EnvSet), toml.Array(npm.EnvPrefixes), toml.Array(npm.EnvNames)) + section := fmt.Sprintf("\n# Exposed from %s global prefix by cb expose %s\n[tools.%s]\nimage = %s\nprovider = \"stateful\"\ncommand = [%s]\nstate_group = %s\nshared_volumes = %s\nenv_set = %s\nenv_prefixes = %s\nenv_names = %s\n", sourceName, sourceName, name, toml.Quote(source.Image), toml.Quote("/cb/npm-global/bin/"+name), toml.Quote(source.StateGroup), toml.Array(source.SharedVolumes), toml.Array(source.EnvSet), toml.Array(source.EnvPrefixes), toml.Array(source.EnvNames)) add.WriteString(section) added++ fmt.Printf("exposed %-16s /cb/npm-global/bin/%s\n", name, name) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..35b62c5 --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,32 @@ +package cli + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/AviBackToBlack/container-bin/internal/registry" +) + +// These tests cover Expose guard paths that need no Docker daemon. +// The Docker-dependent discovery path (discoverNPMGlobalBins onward) remains +// untested here because it requires a real Docker daemon and a populated +// npm-global volume. + +func TestExposeRequiresSourceTool(t *testing.T) { + reg := registry.Default() + if err := Expose(reg, filepath.Join(t.TempDir(), "container-bin.toml"), nil); err == nil { + t.Fatal("expected usage error for empty args") + } else if !strings.Contains(err.Error(), "usage: cb expose TOOL") { + t.Fatalf("unexpected error message: %v", err) + } +} + +func TestExposeRejectsUnknownSource(t *testing.T) { + reg := registry.Default() + if err := Expose(reg, filepath.Join(t.TempDir(), "container-bin.toml"), []string{"notarealtool"}); err == nil { + t.Fatal("expected not-found error") + } else if !strings.Contains(err.Error(), `tool "notarealtool" not found`) { + t.Fatalf("unexpected error message: %v", err) + } +} diff --git a/internal/lockfile/lockfile_test.go b/internal/lockfile/lockfile_test.go index ec41ca6..fa561ec 100644 --- a/internal/lockfile/lockfile_test.go +++ b/internal/lockfile/lockfile_test.go @@ -58,6 +58,46 @@ digest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa } } +func TestConfiguredImagesIncludesNode22(t *testing.T) { + reg := registry.Default() + got := ConfiguredImages(reg) + want := map[string]bool{ + "node:24-slim": true, + "node:22-slim": true, + } + for image := range want { + if !containsString(got, image) { + t.Fatalf("ConfiguredImages missing %q; got %v", image, got) + } + } + // node:22-slim and node:24-slim are distinct configured references, so they + // must produce distinct lock entries. + node24Idx, node22Idx := -1, -1 + for i, image := range got { + if image == "node:24-slim" { + node24Idx = i + } + if image == "node:22-slim" { + node22Idx = i + } + } + if node24Idx == -1 || node22Idx == -1 { + t.Fatalf("missing node images in %v", got) + } + if node24Idx == node22Idx { + t.Fatal("node:24-slim and node:22-slim collapsed into the same entry") + } +} + +func containsString(xs []string, s string) bool { + for _, x := range xs { + if x == s { + return true + } + } + return false +} + func TestConfiguredImagesDeduplicatesSharedImage(t *testing.T) { reg := registry.Registry{Tools: map[string]registry.Tool{ "node": {Image: "node:24-slim"}, diff --git a/internal/registry/file_test.go b/internal/registry/file_test.go index 6cf2afc..4079a7e 100644 --- a/internal/registry/file_test.go +++ b/internal/registry/file_test.go @@ -88,6 +88,54 @@ provider = "stateless" } } +// A pre-RM-11 registry (all built-in tools except the node22 trio) should be +// upgraded to include node22/npm22/npx22, while existing sections are left +// untouched. +func TestAppendMissingDefaultToolsUpgradesPreRM11(t *testing.T) { + sections := DefaultToolSections() + preRM11 := []string{"python", "python3", "pip", "pip3", "jq", "yq", "terraform", "ffmpeg", "node", "npm", "npx", "go", "gofmt"} + var b strings.Builder + b.WriteString("schema_version = 1\n") + for _, name := range preRM11 { + b.WriteString(sections[name]) + } + b.WriteString("\n[tools.jq2]\nimage = \"ghcr.io/jqlang/jq:latest\"\nprovider = \"stateless\"\n") + + dir := t.TempDir() + path := dir + "/container-bin.toml" + if err := os.WriteFile(path, []byte(b.String()), 0644); err != nil { + t.Fatal(err) + } + if err := AppendMissingDefaultTools(path, "dev"); err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + reg, err := ParseTOML(string(data)) + if err != nil { + t.Fatal(err) + } + if _, ok := reg.Tools["jq2"]; !ok { + t.Fatal("custom jq2 was lost") + } + for _, name := range []string{"node22", "npm22", "npx22"} { + if _, ok := reg.Tools[name]; !ok { + t.Fatalf("missing upgraded tool %q", name) + } + } + if reg.Tools["node"].Image != "node:24-slim" { + t.Fatalf("existing node profile was modified: %q", reg.Tools["node"].Image) + } + if reg.Tools["node22"].Image != "node:22-slim" { + t.Fatalf("bad node22 image: %q", reg.Tools["node22"].Image) + } + if !strings.Contains(string(data), "# Added by container-bin dev") { + t.Fatal("upgrade comment missing") + } +} + func TestValidateRegistryBackup(t *testing.T) { dir := t.TempDir() good := filepath.Join(dir, "good.bak") diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 42731dd..2d645c7 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -142,6 +142,40 @@ env_set = ["NPM_CONFIG_PREFIX=/cb/npm-global"] env_prefixes = ["NPM_CONFIG_"] env_names = ["NODE_ENV", "NODE_OPTIONS", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"] +[tools.node22] +image = "node:22-slim" +provider = "stateful" +command = ["node"] +state_group = "node22" +project_markers = ["package.json", "package-lock.json", "npm-shrinkwrap.json", ".git"] +project_volumes = ["node-modules:/workspace/node_modules"] +shared_volumes = ["npm-cache:/root/.npm"] +env_names = ["NODE_ENV", "NODE_OPTIONS", "NPM_CONFIG_REGISTRY", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"] + +[tools.npm22] +image = "node:22-slim" +provider = "stateful" +command = ["npm"] +state_group = "node22" +project_markers = ["package.json", "package-lock.json", "npm-shrinkwrap.json", ".git"] +project_volumes = ["node-modules:/workspace/node_modules"] +shared_volumes = ["npm-cache:/root/.npm", "npm-global:/cb/npm-global"] +env_set = ["NPM_CONFIG_PREFIX=/cb/npm-global"] +env_prefixes = ["NPM_CONFIG_"] +env_names = ["NODE_ENV", "NODE_OPTIONS", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"] + +[tools.npx22] +image = "node:22-slim" +provider = "stateful" +command = ["npx"] +state_group = "node22" +project_markers = ["package.json", "package-lock.json", "npm-shrinkwrap.json", ".git"] +project_volumes = ["node-modules:/workspace/node_modules"] +shared_volumes = ["npm-cache:/root/.npm", "npm-global:/cb/npm-global"] +env_set = ["NPM_CONFIG_PREFIX=/cb/npm-global"] +env_prefixes = ["NPM_CONFIG_"] +env_names = ["NODE_ENV", "NODE_OPTIONS", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"] + [tools.go] image = "golang:1.24" provider = "stateful" diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index ac397eb..cfecd6f 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -24,8 +24,8 @@ func TestParseDefaultRegistry(t *testing.T) { if err != nil { t.Fatal(err) } - if len(reg.Tools) != 13 { - t.Fatalf("expected 13 tools, got %d", len(reg.Tools)) + if len(reg.Tools) != 16 { + t.Fatalf("expected 16 tools, got %d", len(reg.Tools)) } jq := reg.Tools["jq"] if jq.Provider != "stateless" || jq.Image != "ghcr.io/jqlang/jq:latest" { @@ -120,7 +120,7 @@ func TestDefaultRegistryHasV06Tools(t *testing.T) { func TestDefaultToolSections(t *testing.T) { sections := DefaultToolSections() - for _, name := range []string{"python", "yq", "terraform", "ffmpeg", "node", "npm", "npx", "go", "gofmt"} { + for _, name := range []string{"python", "yq", "terraform", "ffmpeg", "node", "node22", "npm", "npm22", "npx", "npx22", "go", "gofmt"} { if !strings.Contains(sections[name], "[tools."+name+"]") { t.Fatalf("bad section for %s: %q", name, sections[name]) } @@ -382,3 +382,39 @@ func TestGoProfilesDeclareNoForcedPathSemantics(t *testing.T) { t.Fatalf("gofmt must not declare forced path semantics: %+v", gofmtTool) } } + +// Node 24 is the default runtime, but it is not a guarantee that every npm +// package is ABI-compatible with it. Node 22 is the supported LTS alternative, +// with fully isolated state even though the logical volume names are identical. +func TestNode22ProfilesParseAndVolumes(t *testing.T) { + reg, err := ParseTOML(DefaultTOML) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"node22", "npm22", "npx22"} { + tool, ok := reg.Tools[name] + if !ok { + t.Fatalf("missing default tool %q", name) + } + if tool.Image != "node:22-slim" { + t.Fatalf("%s image = %q, want node:22-slim", name, tool.Image) + } + if tool.StateGroup != "node22" { + t.Fatalf("%s state_group = %q, want node22", name, tool.StateGroup) + } + } + pairs := []struct{ old, new string }{ + {"node", "node22"}, + {"npm", "npm22"}, + {"npx", "npx22"}, + } + for _, p := range pairs { + oldTool, newTool := reg.Tools[p.old], reg.Tools[p.new] + if !reflect.DeepEqual(oldTool.ProjectVolumes, newTool.ProjectVolumes) { + t.Fatalf("%s and %s project_volumes differ: %#v vs %#v", p.old, p.new, oldTool.ProjectVolumes, newTool.ProjectVolumes) + } + if !reflect.DeepEqual(oldTool.SharedVolumes, newTool.SharedVolumes) { + t.Fatalf("%s and %s shared_volumes differ: %#v vs %#v", p.old, p.new, oldTool.SharedVolumes, newTool.SharedVolumes) + } + } +} diff --git a/internal/registry/state_group_test.go b/internal/registry/state_group_test.go new file mode 100644 index 0000000..1b008f2 --- /dev/null +++ b/internal/registry/state_group_test.go @@ -0,0 +1,35 @@ +package registry_test + +import ( + "testing" + + "github.com/AviBackToBlack/container-bin/internal/pathmap" + "github.com/AviBackToBlack/container-bin/internal/registry" +) + +// The registry uses the same logical volume names (node-modules, npm-cache, +// npm-global) for both the node24 and node22 runtimes. The actual Docker +// volume IDs differ because the state_group is part of the name; this test +// proves that two groups with the same logical name cannot collide. +func TestNodeStateGroupsHaveDistinctSharedVolumeIDs(t *testing.T) { + node24 := pathmap.StatefulSharedVolumeID("node24", "npm-cache") + node22 := pathmap.StatefulSharedVolumeID("node22", "npm-cache") + if node24 == node22 { + t.Fatalf("node24 and node22 shared volume IDs collided: %q", node24) + } +} + +// DefaultTOML now contains both the node24 and node22 runtime families. +// This is a quick smoke check that the package default parses correctly +// from an external test package as well. +func TestDefaultRegistryContainsBothNodeRuntimes(t *testing.T) { + reg, err := registry.ParseTOML(registry.DefaultTOML) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"node", "npm", "npx", "node22", "npm22", "npx22"} { + if _, ok := reg.Tools[name]; !ok { + t.Fatalf("missing default tool %q", name) + } + } +} diff --git a/main.go b/main.go index 54f103f..deba75b 100644 --- a/main.go +++ b/main.go @@ -191,7 +191,7 @@ Commands: cb state list container-bin Docker volumes and mark current/shared state cb inspect show a tool profile plus resolved project/state information cb gc dry-run cleanup; supports --orphans for labeled missing projects - cb expose expose binaries from a managed global tool store (npm) + cb expose expose binaries from a managed npm-shaped global tool store (npm, npm22, ...) cb unexpose remove dynamically exposed tool profiles/shims cb uninstall remove custom tool profiles/shims cb lock create/check immutable image digest lockfile From 6e96f3ca8b52cf0c016b5d5160873c008ffdaa37 Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:49:22 +0100 Subject: [PATCH 2/4] test: cover Expose's non-npm-shaped source tool guard (RM-11 GLM finding) GLM-5.2's round-1 verify flagged that cb expose terraform (a registry tool that exists but has no npm-global shared volume) was the one daemon-free guard clause left untested, and that the new %q-formatted error could in principle render as tool "" if Tool.Name weren't populated. Pins both. Co-Authored-By: Claude Sonnet 5 --- internal/cli/cli_test.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 35b62c5..21f59cb 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -30,3 +30,22 @@ func TestExposeRejectsUnknownSource(t *testing.T) { t.Fatalf("unexpected error message: %v", err) } } + +// terraform exists in the registry but is not npm-shaped (stateless, no +// npm-global shared volume), so Expose must still fail closed here without +// touching Docker — discoverNPMGlobalBins's shared-volume check fires before +// any docker invocation. This also pins that the new %q-formatted error +// carries the source tool's actual name rather than an empty string. +func TestExposeRejectsNonNpmShapedTool(t *testing.T) { + reg := registry.Default() + err := Expose(reg, filepath.Join(t.TempDir(), "container-bin.toml"), []string{"terraform"}) + if err == nil { + t.Fatal("expected error for non-npm-shaped source tool") + } + if !strings.Contains(err.Error(), "no npm-global shared volume") { + t.Fatalf("unexpected error message: %v", err) + } + if !strings.Contains(err.Error(), `"terraform"`) { + t.Fatalf("error message does not name the source tool: %v", err) + } +} From df44349f131edc54c3fe2efcad837a7139ed244c Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:04:47 +0100 Subject: [PATCH 3/4] fix(cli): address Devin and Copilot review findings for cb expose (RM-11) - discoverNPMGlobalBins now resolves the discovery image through lockfile.RuntimeImageForTool, matching every other execution path. - Expose rejects non-stateful source tools before any Docker run. - Shared-volume matching is by container destination (/cb/npm-global) rather than hardcoded logical name. - Existing-tool skip message now includes the existing tool's state_group. - Extracted renderExposedToolSection and added TestRenderExposedToolSection to prove exposed sections inherit the source tool's Node identity. - README notes that node22/npm22/npx22 are added on upgrade but must be locked before use. - Added a deferred-scoping comment for node22 self-test steps in diag.go. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- README.md | 2 +- internal/cli/cli.go | 35 ++++++++++++++++++++++------ internal/cli/cli_test.go | 50 +++++++++++++++++++++++++++++++++++----- internal/diag/diag.go | 1 + 4 files changed, 74 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index b8d69e8..ac4111a 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,7 @@ npm global prefix. Projects are mounted with their **real basename** not a guarantee that every npm package is ABI-compatible with it. For packages whose native addons need a different Node ABI, `node22`/`npm22`/`npx22` are a second, independent Node-major runtime with their own `node22` state group, -fully isolating project `node_modules`, the npm cache and the npm global prefix. +fully isolating project `node_modules`, the npm cache and the npm global prefix; upgrading an existing installation adds these profiles automatically, but they are not yet locked, so run `cb lock` or `cb update --all` before using them. ## Dynamic npm CLI exposure diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 97d0466..9e0ca65 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -128,26 +128,31 @@ func Env(reg registry.Registry) error { } func discoverNPMGlobalBins(t registry.Tool) ([]string, error) { - globalVol := "" + var globalVol, logicalName string for _, spec := range t.SharedVolumes { - logical, _, err := registry.ParseVolumeBinding(spec) + logical, dst, err := registry.ParseVolumeBinding(spec) if err != nil { return nil, err } - if logical == "npm-global" { - globalVol = pathmap.StatefulSharedVolumeID(t.StateGroup, logical) + if dst == "/cb/npm-global" { + logicalName = logical + globalVol = pathmap.StatefulSharedVolumeID(t.StateGroup, logicalName) break } } if globalVol == "" { return nil, fmt.Errorf("tool %q has no npm-global shared volume", t.Name) } + image, err := lockfile.RuntimeImageForTool(t) + if err != nil { + return nil, err + } script := `if [ -d /cb/npm-global/bin ]; then for f in /cb/npm-global/bin/*; do [ -e "$f" ] || continue; basename "$f"; done; fi` mount, err := dockerrun.MountSpec("volume", globalVol, "/cb/npm-global") if err != nil { return nil, err } - cmd := exec.Command("docker", "run", "--rm", "--mount", mount, t.Image, "sh", "-lc", script) + cmd := exec.Command("docker", "run", "--rm", "--mount", mount, image, "sh", "-lc", script) out, err := cmd.Output() if err != nil { return nil, fmt.Errorf("inspect npm global bin: %w", err) @@ -168,6 +173,19 @@ func discoverNPMGlobalBins(t registry.Tool) ([]string, error) { return bins, nil } +func renderExposedToolSection(sourceName string, source registry.Tool, name string) string { + return fmt.Sprintf("\n# Exposed from %s global prefix by cb expose %s\n[tools.%s]\nimage = %s\nprovider = \"stateful\"\ncommand = [%s]\nstate_group = %s\nshared_volumes = %s\nenv_set = %s\nenv_prefixes = %s\nenv_names = %s\n", + sourceName, sourceName, name, + toml.Quote(source.Image), + toml.Quote("/cb/npm-global/bin/"+name), + toml.Quote(source.StateGroup), + toml.Array(source.SharedVolumes), + toml.Array(source.EnvSet), + toml.Array(source.EnvPrefixes), + toml.Array(source.EnvNames), + ) +} + func Expose(reg registry.Registry, cfgPath string, args []string) error { if len(args) == 0 { return errors.New("usage: cb expose TOOL [BINARY ...] (TOOL is an npm-shaped stateful profile already in the registry, e.g. npm or npm22)") @@ -177,6 +195,9 @@ func Expose(reg registry.Registry, cfgPath string, args []string) error { if !ok { return fmt.Errorf("tool %q not found; cb expose exposes global binaries from an npm-shaped profile already in the registry", sourceName) } + if source.Provider != "stateful" { + return fmt.Errorf("tool %q is not a stateful npm-shaped profile", sourceName) + } bins, err := discoverNPMGlobalBins(source) if err != nil { return err @@ -212,10 +233,10 @@ func Expose(reg registry.Registry, cfgPath string, args []string) error { added := 0 for _, name := range selected { if _, exists := reg.Tools[name]; exists { - fmt.Printf("skip %-16s already exists in registry\n", name) + fmt.Printf("skip %-16s already exists in registry (state_group=%s)\n", name, reg.Tools[name].StateGroup) continue } - section := fmt.Sprintf("\n# Exposed from %s global prefix by cb expose %s\n[tools.%s]\nimage = %s\nprovider = \"stateful\"\ncommand = [%s]\nstate_group = %s\nshared_volumes = %s\nenv_set = %s\nenv_prefixes = %s\nenv_names = %s\n", sourceName, sourceName, name, toml.Quote(source.Image), toml.Quote("/cb/npm-global/bin/"+name), toml.Quote(source.StateGroup), toml.Array(source.SharedVolumes), toml.Array(source.EnvSet), toml.Array(source.EnvPrefixes), toml.Array(source.EnvNames)) + section := renderExposedToolSection(sourceName, source, name) add.WriteString(section) added++ fmt.Printf("exposed %-16s /cb/npm-global/bin/%s\n", name, name) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 21f59cb..a00397d 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -2,6 +2,7 @@ package cli import ( "path/filepath" + "reflect" "strings" "testing" @@ -31,21 +32,58 @@ func TestExposeRejectsUnknownSource(t *testing.T) { } } -// terraform exists in the registry but is not npm-shaped (stateless, no -// npm-global shared volume), so Expose must still fail closed here without -// touching Docker — discoverNPMGlobalBins's shared-volume check fires before -// any docker invocation. This also pins that the new %q-formatted error -// carries the source tool's actual name rather than an empty string. +// terraform exists in the registry but is not npm-shaped (stateless), so +// Expose must fail closed here before any docker invocation. This also pins +// that the new %q-formatted error carries the source tool's actual name rather +// than an empty string. func TestExposeRejectsNonNpmShapedTool(t *testing.T) { reg := registry.Default() err := Expose(reg, filepath.Join(t.TempDir(), "container-bin.toml"), []string{"terraform"}) if err == nil { t.Fatal("expected error for non-npm-shaped source tool") } - if !strings.Contains(err.Error(), "no npm-global shared volume") { + if !strings.Contains(err.Error(), "is not a stateful npm-shaped profile") { t.Fatalf("unexpected error message: %v", err) } if !strings.Contains(err.Error(), `"terraform"`) { t.Fatalf("error message does not name the source tool: %v", err) } } + +// TestRenderExposedToolSection proves that the TOML section generated by +// cb expose inherits the source tool's identity (image, state_group, +// shared_volumes and environment settings), not a hardcoded npm default. +// It uses npm22 from the default registry so the parsed tool carries the +// Node 22 runtime identity. +func TestRenderExposedToolSection(t *testing.T) { + reg := registry.Default() + source, ok := reg.Tools["npm22"] + if !ok { + t.Fatal("npm22 not in default registry") + } + + const binary = "cowsay" + section := renderExposedToolSection("npm22", source, binary) + parsed, err := registry.ParseTOML("schema_version = 1\n" + section) + if err != nil { + t.Fatalf("rendered section invalid: %v", err) + } + + got, ok := parsed.Tools[binary] + if !ok { + t.Fatal("parsed registry missing exposed tool") + } + if got.Image != "node:22-slim" { + t.Errorf("image = %q, want %q", got.Image, "node:22-slim") + } + if got.StateGroup != "node22" { + t.Errorf("state_group = %q, want %q", got.StateGroup, "node22") + } + if !reflect.DeepEqual(got.SharedVolumes, source.SharedVolumes) { + t.Errorf("shared_volumes = %v, want %v", got.SharedVolumes, source.SharedVolumes) + } + wantCommand := []string{"/cb/npm-global/bin/" + binary} + if !reflect.DeepEqual(got.Command, wantCommand) { + t.Errorf("command = %v, want %v", got.Command, wantCommand) + } +} diff --git a/internal/diag/diag.go b/internal/diag/diag.go index 678c4d4..c4802a4 100644 --- a/internal/diag/diag.go +++ b/internal/diag/diag.go @@ -505,6 +505,7 @@ type selfTestStep struct { // populated in this slice's order, so a step whose depID has not been // inserted yet would see a zero-value selfTestCheck instead of the real // dependency's status. TestSelfTestStepsDependencyOrder pins this invariant. +// Node 22 self-test steps are deliberately deferred to a separate scoping pass. var selfTestSteps = []selfTestStep{ {id: "python-image-local", tool: "python", depID: "docker", errFunc: func(o toolSelfTestOutcome) *string { return o.ImageLocalErr }}, {id: "python-persist-write", tool: "python", depID: "python-image-local", errFunc: func(o toolSelfTestOutcome) *string { return o.PersistWriteErr }}, From 67a0c7b53eb99b6b02abec58abf594a98bd5250a Mon Sep 17 00:00:00 2001 From: AviBackToBlack <54722547+AviBackToBlack@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:23:28 +0100 Subject: [PATCH 4/4] docs: note exposed profiles can't share a binary name across runtimes (RM-11 Devin finding) Co-Authored-By: Claude Sonnet 5 --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index ac4111a..84ed4f6 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,9 @@ persistent npm global prefix. It adds registry profiles for them that inherit the source profile's image and `state_group`, and creates Windows shims — `cowsay.exe` appears on PATH without Node ever touching the host. To expose a binary installed under the Node 22 runtime, use `cb expose npm22 `. +Exposed profiles are keyed by binary name only, so a binary already exposed +from one runtime cannot also be exposed from the other under the same name — +`cb unexpose` it first if you need to switch which runtime backs it. `cb unexpose cowsay` removes the shim and profile without deleting the underlying npm state. Registry mutations are validated and written atomically; a failed validation refuses the update.