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
28 changes: 21 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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; 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

Expand All @@ -206,11 +211,18 @@ 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 <binary>`.
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.

## Image locking and explicit updates

Expand All @@ -229,7 +241,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

Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/security-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
49 changes: 34 additions & 15 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, errors.New("npm profile has no npm-global shared volume")
return nil, fmt.Errorf("tool %q has no npm-global shared volume", t.Name)
Comment thread
AviBackToBlack marked this conversation as resolved.
}
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)
Expand All @@ -168,18 +173,32 @@ 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),
)
}
Comment thread
AviBackToBlack marked this conversation as resolved.

func Expose(reg registry.Registry, cfgPath string, args []string) error {
if len(args) == 0 {
return errors.New("usage: cb expose npm [BINARY ...]")
}
if strings.ToLower(args[0]) != "npm" {
return errors.New("v0.8 supports only: 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)")
}
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)
}
Comment thread
AviBackToBlack marked this conversation as resolved.
if source.Provider != "stateful" {
return fmt.Errorf("tool %q is not a stateful npm-shaped profile", sourceName)
}
Comment thread
AviBackToBlack marked this conversation as resolved.
bins, err := discoverNPMGlobalBins(npm)
bins, err := discoverNPMGlobalBins(source)
if err != nil {
return err
}
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -214,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 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 := renderExposedToolSection(sourceName, source, name)
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
add.WriteString(section)
added++
fmt.Printf("exposed %-16s /cb/npm-global/bin/%s\n", name, name)
Expand Down
89 changes: 89 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package cli

import (
"path/filepath"
"reflect"
"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)
}
}

// 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(), "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)
}
}
1 change: 1 addition & 0 deletions internal/diag/diag.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }},
Expand Down
40 changes: 40 additions & 0 deletions internal/lockfile/lockfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
48 changes: 48 additions & 0 deletions internal/registry/file_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading