From 6b83ec236a43b067c4bb125552b087c94d2b565b Mon Sep 17 00:00:00 2001 From: stevenzg Date: Sat, 18 Jul 2026 17:40:28 +1200 Subject: [PATCH 1/2] feat(deps): verify a requirement by command with `check:` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tool is normally detected by looking for its name as an executable on PATH. That cannot see a dependency which is not a program — a shared library, a font, a kernel module — so such a requirement could not be expressed at all. Add a `check:` field: when set, its shell command is run (via the OS shell, so pipes work) and a zero exit means the dependency is present, bypassing PATH lookup. MinVersion does not apply (there is no version string); pair `check:` with a package:/install: provider so `--install` can still install it. The Starlark `tool()` builtin gains a `check` argument. Motivating case: Electron's Chromium needs system libraries (libnspr4, libnss3, libasound2) that are not executables — `check: "ldconfig -p | grep -q ..."` lets an environment declare and auto-install them on a minimal Linux/WSL box. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 6 ++++++ internal/config/config.go | 9 +++++++++ internal/deps/deps.go | 27 +++++++++++++++++++++++++-- internal/deps/deps_test.go | 25 +++++++++++++++++++++++++ internal/starcfg/starcfg.go | 7 ++++--- 5 files changed, 69 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e1885a..7600e0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ development history from before the open-sourcing is not carried over. `tarjan up ` skips toolchains it won't use. `tarjan doctor ` scopes the same way, and the Starlark `tool()` builtin gains a `services` argument. +- Command-verified requirements: a `requires` tool may set `check:` — a shell + command whose zero exit means "present" — instead of being detected as an + executable on `PATH`. This lets a requirement be something `PATH` cannot see, + such as a shared library (`check: "ldconfig -p | grep -q libnspr4.so"`); + pair it with a `package:`/`install:` provider so `--install` can supply it. + The Starlark `tool()` builtin gains a `check` argument. - Initial public release of tarjan: spin up a complete local development environment for a whole product from a single config file (`tarjan.yaml` / `tarjan.star`). diff --git a/internal/config/config.go b/internal/config/config.go index a26ce5e..0861211 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -153,6 +153,15 @@ type Tool struct { Package PackageSpec `yaml:"package"` // Optional tools only produce a warning when missing, not an error. Optional bool `yaml:"optional"` + // Check overrides how the tool's presence is verified. Normally a tool is + // detected by looking for its Name as an executable on PATH; when Check is + // set, this shell command is run instead and a zero exit means "present". + // That lets a requirement be something PATH cannot see — a shared library, a + // font, a kernel module — e.g. a library probed with + // `check: "ldconfig -p | grep -q libnspr4.so"`. MinVersion is not applied to + // a Check-verified tool (there is no version string to parse); pair Check + // with a package:/install: provider so --install can still install it. + Check string `yaml:"check"` // Services scopes the tool to the services that actually need it: with none // it is a baseline tool, always checked; with one or more it is checked only // when at least one of those services is in the run's selection. This is what diff --git a/internal/deps/deps.go b/internal/deps/deps.go index 35ffeb9..d93736b 100644 --- a/internal/deps/deps.go +++ b/internal/deps/deps.go @@ -141,6 +141,15 @@ func Check(tools []config.Tool, opts Options) error { // evaluate probes a tool, returning its path, detected version, whether it was // found on PATH, and whether it is present and meets any minimum version. func evaluate(t config.Tool) (path, version string, found, ok bool) { + // A Check command replaces PATH lookup: it verifies things PATH cannot see + // (a shared library, a font). Exit 0 means present; there is no path or + // version to report, and MinVersion does not apply. + if t.Check != "" { + if runCheck(t.Check) { + return "", "", true, true + } + return "", "", false, false + } path, err := exec.LookPath(t.Name) if err != nil { return "", "", false, false @@ -155,11 +164,25 @@ func evaluate(t config.Tool) (path, version string, found, ok bool) { return path, version, true, true } +// runCheck runs a tool's Check command through the OS shell (so pipes and shell +// syntax work) with the same bounded timeout as a version probe, and reports +// whether it exited 0 — the signal that the dependency is present. +func runCheck(command string) bool { + ctx, cancel := context.WithTimeout(context.Background(), probeTimeout) + defer cancel() + name, args := shellx.Command(command) + return exec.CommandContext(ctx, name, args...).Run() == nil +} + func report(t config.Tool, path, version string) { - if version != "" { + switch { + case version != "" && path != "": ui.Step("%s %s (%s)", t.Name, version, path) - } else { + case path != "": ui.Step("%s (%s)", t.Name, path) + default: + // A Check-verified tool has no path or version to show. + ui.Step("%s", t.Name) } } diff --git a/internal/deps/deps_test.go b/internal/deps/deps_test.go index 23fd154..d6db022 100644 --- a/internal/deps/deps_test.go +++ b/internal/deps/deps_test.go @@ -54,6 +54,31 @@ func TestVersionAtLeast(t *testing.T) { } } +func TestCheckVerifiesWithoutPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("uses a POSIX check command") + } + // A tool whose name is not on PATH is still satisfied when its Check exits 0, + // so a requirement can be something PATH cannot see (e.g. a shared library). + present := config.Tool{Name: "some-lib", Check: "true"} + if err := Check([]config.Tool{present}, Options{}); err != nil { + t.Fatalf("check exiting 0 should satisfy the tool: %v", err) + } + + // A failing Check marks the tool missing — and without --install that is an + // error for a required tool. + absent := config.Tool{Name: "some-lib", Check: "false"} + if err := Check([]config.Tool{absent}, Options{}); err == nil { + t.Fatal("check exiting non-zero should fail a required tool") + } + + // The same failing tool only warns when optional. + absentOpt := config.Tool{Name: "some-lib", Check: "false", Optional: true} + if err := Check([]config.Tool{absentOpt}, Options{}); err != nil { + t.Fatalf("optional tool with a failing check should not error: %v", err) + } +} + func TestAutoInstall(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("uses a POSIX install script") diff --git a/internal/starcfg/starcfg.go b/internal/starcfg/starcfg.go index 5142f58..6fd35fc 100644 --- a/internal/starcfg/starcfg.go +++ b/internal/starcfg/starcfg.go @@ -119,13 +119,14 @@ func bRepo(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs } func bTool(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { - var name, minVersion, versionCommand, installHint, mise string + var name, minVersion, versionCommand, installHint, mise, check string var optional bool var install, pkg, services starlark.Value if err := starlark.UnpackArgs("tool", args, kwargs, "name", &name, "min_version?", &minVersion, "version_command?", &versionCommand, "install?", &install, "mise?", &mise, "package?", &pkg, - "install_hint?", &installHint, "optional?", &optional, "services?", &services); err != nil { + "install_hint?", &installHint, "optional?", &optional, "services?", &services, + "check?", &check); err != nil { return nil, err } spec, err := toInstall("install", install) @@ -143,7 +144,7 @@ func bTool(_ *starlark.Thread, _ *starlark.Builtin, args starlark.Tuple, kwargs return wrapped{"tool", config.Tool{ Name: name, MinVersion: minVersion, VersionCommand: versionCommand, Install: spec, Mise: mise, Package: pkgSpec, - InstallHint: installHint, Optional: optional, Services: svc, + InstallHint: installHint, Optional: optional, Services: svc, Check: check, }}, nil } From fad000e798c469ec203844a80f0b4f4c3cf0baba Mon Sep 17 00:00:00 2001 From: stevenzg Date: Sat, 18 Jul 2026 18:01:06 +1200 Subject: [PATCH 2/2] feat(deps): verify a package-managed requirement via the package manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tarjan could install a system package (`package:` + `--install`) but had no way to verify one: presence was probed only with `exec.LookPath`, which finds an executable. A shared library is not an executable, so a package-only requirement was installed and then immediately reported unsatisfied — an install-but-never-verify asymmetry. When a tool is not found on PATH and declares a `package:`, ask the host package manager whether that package is installed (`dpkg -s`, `rpm -q`, `pacman -Q`, `apk info -e`, `brew list`) before deciding it is missing. The query is the manager's own tool (dpkg, not apt-get), needs no root, and is conservative — no query, no query tool on PATH, or a non-zero exit all read as "not present" — so a false positive never masks a genuinely missing dependency. Detection order is now: explicit `check:` → executable on PATH (the only path yielding a version for MinVersion) → installed `package:`. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 18 ++++-- internal/deps/deps.go | 30 ++++++---- internal/deps/install.go | 63 ++++++++++++++++---- internal/deps/install_test.go | 105 ++++++++++++++++++++++++++++++++++ 4 files changed, 190 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7600e0f..17600ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,12 +17,18 @@ development history from before the open-sourcing is not carried over. `tarjan up ` skips toolchains it won't use. `tarjan doctor ` scopes the same way, and the Starlark `tool()` builtin gains a `services` argument. -- Command-verified requirements: a `requires` tool may set `check:` — a shell - command whose zero exit means "present" — instead of being detected as an - executable on `PATH`. This lets a requirement be something `PATH` cannot see, - such as a shared library (`check: "ldconfig -p | grep -q libnspr4.so"`); - pair it with a `package:`/`install:` provider so `--install` can supply it. - The Starlark `tool()` builtin gains a `check` argument. +- Verify requirements that are not executables on `PATH`, closing the gap where + a `requires` tool could be *installed* by `--install` yet never *verified*, so + it was reported unsatisfied forever. Two ways, most explicit first: + - `package:` now doubles as verification. When a tool is not on `PATH`, the + host package manager is asked whether the declared package is installed + (`dpkg -s`, `rpm -q`, `pacman -Q`, `apk info -e`, `brew list`) — so a shared + library declared only for `--install` is now detected as present without a + hand-written probe. + - `check:` is the general escape hatch: a shell command whose zero exit means + "present", for anything the package managers can't express (a font, a + kernel module, an OS-gated probe). The Starlark `tool()` builtin gains a + `check` argument. - Initial public release of tarjan: spin up a complete local development environment for a whole product from a single config file (`tarjan.yaml` / `tarjan.star`). diff --git a/internal/deps/deps.go b/internal/deps/deps.go index d93736b..d18ca69 100644 --- a/internal/deps/deps.go +++ b/internal/deps/deps.go @@ -150,18 +150,28 @@ func evaluate(t config.Tool) (path, version string, found, ok bool) { } return "", "", false, false } - path, err := exec.LookPath(t.Name) - if err != nil { - return "", "", false, false - } - version = probeVersion(t) - if t.MinVersion != "" && version != "" { - atLeast, comparable := versionAtLeast(version, t.MinVersion) - if comparable && !atLeast { - return path, version, true, false + // An executable on PATH is the common case, and the only one that yields a + // version to gate on MinVersion. + if path, err := exec.LookPath(t.Name); err == nil { + version = probeVersion(t) + if t.MinVersion != "" && version != "" { + atLeast, comparable := versionAtLeast(version, t.MinVersion) + if comparable && !atLeast { + return path, version, true, false + } } + return path, version, true, true + } + // Not on PATH — but a declared system package may still be installed and + // satisfy the requirement even though it is not an executable (a shared + // library). Ask the package manager. This removes the asymmetry where a + // package: could be installed via --install yet never verified, so the tool + // was reported unsatisfied forever. No version is available this way, so + // MinVersion is not applied. + if !t.Package.IsZero() && packageInstalled(t.Package) { + return "", "", true, true } - return path, version, true, true + return "", "", false, false } // runCheck runs a tool's Check command through the OS shell (so pipes and shell diff --git a/internal/deps/install.go b/internal/deps/install.go index 04cf492..05e7951 100644 --- a/internal/deps/install.go +++ b/internal/deps/install.go @@ -299,15 +299,22 @@ func isExec(p string) bool { // --- system package managers ---------------------------------------------- -// pkgManager describes how to install a named package with one system package -// manager. The install command is held as a structured argv (subcommand args, -// with the package name appended last) rather than a format string, so the +// pkgManager describes how to install and query a named package with one system +// package manager. The install command is held as a structured argv (subcommand +// args, with the package name appended last) rather than a format string, so the // package name is never interpreted by a shell. type pkgManager struct { name string // key used in a per-manager package: map (apt/brew/dnf/…) bin string // executable probed on PATH to detect the manager sudo bool // whether the install needs root via sudo args []string // install subcommand args; the package name is appended + // queryBin/queryArgs run a "is this package installed?" check that exits 0 + // when present — the mechanism that lets a package: double as verification + // for a dependency PATH cannot see (a shared library). The query is a + // separate tool from the installer (apt-get installs, dpkg queries), and + // needs no root. Empty queryBin means this manager cannot verify presence. + queryBin string + queryArgs []string } // argv returns the executable and argument vector to install pkg — with the @@ -327,13 +334,26 @@ func (m pkgManager) installCmd(pkg string) string { return bin + " " + strings.Join(args, " ") } +// queryArgv returns the executable and argument vector that checks whether pkg +// is installed (exit 0 = present), or ("", nil) when this manager has no query. +// The package name is its own final argument, never spliced into a command. +func (m pkgManager) queryArgv(pkg string) (string, []string) { + if m.queryBin == "" { + return "", nil + } + return m.queryBin, append(append([]string{}, m.queryArgs...), pkg) +} + // pkgManagers lists the supported managers for a GOOS, in detection order — the // first one found on PATH wins. func pkgManagers(goos string) []pkgManager { switch goos { case "darwin": - return []pkgManager{{name: "brew", bin: "brew", args: []string{"install"}}} + return []pkgManager{{name: "brew", bin: "brew", args: []string{"install"}, queryBin: "brew", queryArgs: []string{"list", "--versions"}}} case "windows": + // winget/choco/scoop install; presence-query is left unset — Windows + // binaries ship self-contained, so the library-verification case this + // powers does not arise there. return []pkgManager{ {name: "winget", bin: "winget", args: []string{"install", "-e", "--id"}}, {name: "choco", bin: "choco", args: []string{"install", "-y"}}, @@ -341,12 +361,12 @@ func pkgManagers(goos string) []pkgManager { } default: // linux and other unixes return []pkgManager{ - {name: "apt", bin: "apt-get", sudo: true, args: []string{"install", "-y"}}, - {name: "dnf", bin: "dnf", sudo: true, args: []string{"install", "-y"}}, - {name: "yum", bin: "yum", sudo: true, args: []string{"install", "-y"}}, - {name: "pacman", bin: "pacman", sudo: true, args: []string{"-S", "--noconfirm"}}, - {name: "zypper", bin: "zypper", sudo: true, args: []string{"install", "-y"}}, - {name: "apk", bin: "apk", sudo: true, args: []string{"add"}}, + {name: "apt", bin: "apt-get", sudo: true, args: []string{"install", "-y"}, queryBin: "dpkg", queryArgs: []string{"-s"}}, + {name: "dnf", bin: "dnf", sudo: true, args: []string{"install", "-y"}, queryBin: "rpm", queryArgs: []string{"-q"}}, + {name: "yum", bin: "yum", sudo: true, args: []string{"install", "-y"}, queryBin: "rpm", queryArgs: []string{"-q"}}, + {name: "pacman", bin: "pacman", sudo: true, args: []string{"-S", "--noconfirm"}, queryBin: "pacman", queryArgs: []string{"-Q"}}, + {name: "zypper", bin: "zypper", sudo: true, args: []string{"install", "-y"}, queryBin: "rpm", queryArgs: []string{"-q"}}, + {name: "apk", bin: "apk", sudo: true, args: []string{"add"}, queryBin: "apk", queryArgs: []string{"info", "-e"}}, } } } @@ -366,3 +386,26 @@ func resolvePackage(spec config.PackageSpec) (*pkgManager, string) { } return nil, "" } + +// packageInstalled reports whether the package a spec names is installed +// according to the host package manager — the check that lets a package: verify +// a dependency PATH cannot see. It resolves the same manager `--install` would +// use, then runs that manager's presence query. It is conservative: a manager +// with no query, no query tool on PATH, or a non-zero exit all read as "not +// present", so a false positive never masks a genuinely missing dependency. +func packageInstalled(spec config.PackageSpec) bool { + mgr, pkg := resolvePackage(spec) + if mgr == nil { + return false + } + bin, args := mgr.queryArgv(pkg) + if bin == "" { + return false + } + if _, err := exec.LookPath(bin); err != nil { + return false + } + ctx, cancel := context.WithTimeout(context.Background(), probeTimeout) + defer cancel() + return exec.CommandContext(ctx, bin, args...).Run() == nil +} diff --git a/internal/deps/install_test.go b/internal/deps/install_test.go index d6c5262..cd1b8ce 100644 --- a/internal/deps/install_test.go +++ b/internal/deps/install_test.go @@ -132,6 +132,100 @@ func TestResolvePackageDetectsManager(t *testing.T) { } } +// TestPkgManagerQueryArgv checks each manager's presence query is the right tool +// and argv (the package name as its own final argument, never shell-spliced), +// and that the Windows managers declare no query. +func TestPkgManagerQueryArgv(t *testing.T) { + cases := map[string]struct { + goos, mgr, pkg, wantBin string + wantArgs []string + }{ + "apt": {"linux", "apt", "libnspr4", "dpkg", []string{"-s", "libnspr4"}}, + "dnf": {"linux", "dnf", "nspr", "rpm", []string{"-q", "nspr"}}, + "pacman": {"linux", "pacman", "nss", "pacman", []string{"-Q", "nss"}}, + "apk": {"linux", "apk", "nss", "apk", []string{"info", "-e", "nss"}}, + "brew": {"darwin", "brew", "nss", "brew", []string{"list", "--versions", "nss"}}, + } + for name, c := range cases { + var m pkgManager + for _, cand := range pkgManagers(c.goos) { + if cand.name == c.mgr { + m = cand + } + } + if m.name == "" { + t.Errorf("%s: manager %q not listed for %s", name, c.mgr, c.goos) + continue + } + bin, args := m.queryArgv(c.pkg) + if bin != c.wantBin { + t.Errorf("%s: queryArgv bin = %q, want %q", name, bin, c.wantBin) + } + if strings.Join(args, " ") != strings.Join(c.wantArgs, " ") { + t.Errorf("%s: queryArgv args = %v, want %v", name, args, c.wantArgs) + } + } + for _, m := range pkgManagers("windows") { + if bin, _ := m.queryArgv("x"); bin != "" { + t.Errorf("windows manager %q should declare no query, got %q", m.name, bin) + } + } +} + +// TestPackageInstalledQueriesManager fabricates apt-get (the manager) and dpkg +// (its query) on PATH and checks packageInstalled reads the query's exit status +// — and is conservative when the query tool is absent. Linux-only because the +// candidate manager list is keyed on the host OS. +func TestPackageInstalledQueriesManager(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("uses apt/dpkg fakes") + } + bin := t.TempDir() + writeFakeExec(t, filepath.Join(bin, "apt-get")) // manager detected on PATH + t.Setenv("PATH", bin) + spec := config.NewPackage("", map[string]string{"apt": "libnspr4"}) + + writeFakeExecCode(t, filepath.Join(bin, "dpkg"), 0) // installed + if !packageInstalled(spec) { + t.Fatal("dpkg exit 0 should read as installed") + } + writeFakeExecCode(t, filepath.Join(bin, "dpkg"), 1) // not installed + if packageInstalled(spec) { + t.Fatal("dpkg exit 1 should read as not installed") + } + if err := os.Remove(filepath.Join(bin, "dpkg")); err != nil { + t.Fatalf("remove dpkg: %v", err) + } + if packageInstalled(spec) { + t.Fatal("no query tool on PATH should read as not installed (conservative)") + } +} + +// TestCheckSatisfiedByInstalledPackage is the end-to-end payoff: a requirement +// that is not an executable on PATH (a shared library) is satisfied when its +// declared package is installed, closing the install-but-never-verify gap. +func TestCheckSatisfiedByInstalledPackage(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("uses apt/dpkg fakes") + } + bin := t.TempDir() + writeFakeExec(t, filepath.Join(bin, "apt-get")) + writeFakeExecCode(t, filepath.Join(bin, "dpkg"), 0) // package reports installed + t.Setenv("PATH", bin) + + // libnspr4 is not on PATH (it is a library), but its apt package is present. + tool := config.Tool{Name: "libnspr4", Package: config.NewPackage("", map[string]string{"apt": "libnspr4"})} + if err := Check([]config.Tool{tool}, Options{}); err != nil { + t.Fatalf("an installed package should satisfy a non-executable requirement: %v", err) + } + + // When the package is not installed, the requirement is unmet. + writeFakeExecCode(t, filepath.Join(bin, "dpkg"), 1) + if err := Check([]config.Tool{tool}, Options{}); err == nil { + t.Fatal("a package that is not installed should leave the requirement unmet") + } +} + // TestDescribeShowsProviderCommand checks the --install-less error names the // exact command a provider would run (mise here, which needs no host state). func TestDescribeShowsProviderCommand(t *testing.T) { @@ -285,3 +379,14 @@ func writeFakeExec(t *testing.T, path string) { t.Fatalf("write fake exec: %v", err) } } + +// writeFakeExecCode writes a fake executable that exits with the given status — +// used to stand in for a package-manager query (dpkg -s) that reports a package +// present (0) or absent (non-zero). +func writeFakeExecCode(t *testing.T, path string, code int) { + t.Helper() + script := fmt.Sprintf("#!/bin/sh\nexit %d\n", code) + if err := os.WriteFile(path, []byte(script), 0o755); err != nil { + t.Fatalf("write fake exec: %v", err) + } +}