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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ development history from before the open-sourcing is not carried over.
`tarjan up <service>` skips toolchains it won't use. `tarjan doctor
<service...>` scopes the same way, and the Starlark `tool()` builtin gains a
`services` 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`).
Expand Down
9 changes: 9 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 43 additions & 10 deletions internal/deps/deps.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,25 +141,58 @@ 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) {
path, err := exec.LookPath(t.Name)
if err != nil {
// 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
}
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
}
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 "", "", false, false
}

// 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)
}
}

Expand Down
25 changes: 25 additions & 0 deletions internal/deps/deps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
63 changes: 53 additions & 10 deletions internal/deps/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -327,26 +334,39 @@ 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"}},
{name: "scoop", bin: "scoop", args: []string{"install"}},
}
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"}},
}
}
}
Expand All @@ -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
}
105 changes: 105 additions & 0 deletions internal/deps/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
}
}
7 changes: 4 additions & 3 deletions internal/starcfg/starcfg.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}

Expand Down