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
16 changes: 16 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
.git
.github
.serena
.DS_Store
.idea
.vscode

# Local build artifacts
why
why.exe
tests/test_why_core
tests/test_why_core.exe
nimcache/

# Editor/OS noise
Thumbs.db
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,14 @@ jobs:

- name: Run Tests
run: nimble test

e2e:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4

- name: Run E2E Tests
run: ./tests/e2e/run.sh
env:
E2E_JOBS: 2
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ why
why.exe
tests/test_why_core
tests/test_why_core.exe
tests/e2e/why-linux-amd64

# Nimble files
nimble.develop
Expand All @@ -11,4 +12,7 @@ nimble.paths
nimbledeps

# LLM utilities
repomix-output.xml
repomix-output.xml

# Serena
.serena/
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
**"Why is this command here? Who installed it?"**

`why` is a CLI tool that identifies the installation source (provider) of a command.
It resolves symlinks, checks path patterns, and queries system package managers to tell you if a command is managed by Homebrew, apt, npm, Mise, Flatpak, or simply a system file.
It resolves symlinks, checks path patterns, and queries system package managers to tell you if a command is managed by Homebrew, apt, npm, Mise, Flatpak, asdf, SDKMAN!, or simply a system file.

## Features

- Provider Identification: Instantly detects if a binary is from Homebrew, apt/dpkg, yum/rpm, npm, pip, Cargo, Go, etc.
- Provider Identification: Instantly detects if a binary is from Homebrew, apt/dpkg, yum/rpm, zypper/rpm, apk, pacman, Nix, MacPorts, npm, pip, Cargo, Go, etc.
- Symlink Resolution: Traces the "Origin Path" (where your shell finds it) to the "Real Path" (where the binary actually lives).
- System Package Manager Integration: Automatically queries `dpkg` or `rpm` for system files to identify the package name.
- System Package Manager Integration: Automatically queries `dpkg`, `rpm`/`zypper`, `apk`, `pacman`, or Portage tools for system files to identify the package name.

## Installation

Expand Down Expand Up @@ -77,8 +77,8 @@ Real Path: /var/lib/flatpak/app/com.valvesoftware.Steam/current/active/export/

`why` currently supports detection for:

- Package Managers: Homebrew, apt (Debian/Ubuntu), yum/rpm (RHEL/CentOS), Snap, Flatpak
- Version Managers: Mise, Volta
- Package Managers: Homebrew, apt (Debian/Ubuntu), yum/rpm (RHEL/CentOS), zypper/rpm (SUSE), apk (Alpine), pacman (Arch), Portage (Gentoo), MacPorts, Nix, Snap, Flatpak, Scoop, Chocolatey, winget
- Version Managers: Mise, Volta, asdf, SDKMAN!, nvm, fnm, pyenv, rbenv, rvm, Rustup, Conda
- Language Managers: npm (Global), pip/pipx (Python), Cargo (Rust), Go
- System: Standard system paths (`/usr/bin`, etc.)

Expand Down
163 changes: 146 additions & 17 deletions src/why_core.nim
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ proc defaultRules*(homeDir: string): seq[ProviderRule] =
"/opt/homebrew", "/usr/local/cellar",
"/home/linuxbrew/.linuxbrew", "/.linuxbrew/cellar", "/.linuxbrew/caskroom"
]),
ProviderRule(name: "MacPorts", kind: mkStartsWith, patterns: @[
"/opt/local/"
]),
ProviderRule(name: "Nix", kind: mkStartsWith, patterns: @[
"/nix/store", "/run/current-system/sw", "/nix/var/nix/profiles"
]),
ProviderRule(name: "Flatpak", kind: mkStartsWith, patterns: @[
"/var/lib/flatpak/exports/bin",
homeDir / ".local/share/flatpak/exports/bin"
Expand All @@ -63,6 +69,42 @@ proc defaultRules*(homeDir: string): seq[ProviderRule] =
ProviderRule(name: "Snap", kind: mkContains, patterns: @[
"/snap/", "snap/bin"
]),
ProviderRule(name: "SDKMAN!", kind: mkContains, patterns: @[
".sdkman"
]),
ProviderRule(name: "Volta", kind: mkContains, patterns: @[
".volta"
]),
ProviderRule(name: "nvm", kind: mkContains, patterns: @[
".nvm"
]),
ProviderRule(name: "fnm", kind: mkContains, patterns: @[
".fnm", ".local/share/fnm", "fnm_multishells"
]),
ProviderRule(name: "pyenv", kind: mkContains, patterns: @[
".pyenv", "pyenv/shims"
]),
ProviderRule(name: "rbenv", kind: mkContains, patterns: @[
".rbenv", "rbenv/shims"
]),
ProviderRule(name: "rvm", kind: mkContains, patterns: @[
".rvm", "/usr/local/rvm"
]),
ProviderRule(name: "Rustup", kind: mkContains, patterns: @[
".rustup", "rustup/toolchains"
]),
ProviderRule(name: "Conda", kind: mkContains, patterns: @[
".conda", "/miniconda", "/anaconda", "/mambaforge", "/miniforge"
]),
ProviderRule(name: "Scoop", kind: mkContains, patterns: @[
"scoop/shims", "scoop/apps"
]),
ProviderRule(name: "Chocolatey", kind: mkContains, patterns: @[
"chocolatey/bin", "chocolatey/lib"
]),
ProviderRule(name: "winget", kind: mkContains, patterns: @[
"WindowsApps", "Microsoft/WindowsApps"
]),
ProviderRule(name: "Cargo", kind: mkContains, patterns: @[
".cargo/bin"
]),
Expand All @@ -73,9 +115,6 @@ proc defaultRules*(homeDir: string): seq[ProviderRule] =
"site-packages", "dist-packages", "/pipx/", ".local/bin/pipx",
"/bin/pip", "/bin/pip3"
]),
ProviderRule(name: "Volta", kind: mkContains, patterns: @[
".volta"
]),
ProviderRule(name: "Go", kind: mkContains, patterns: @[
"go/bin"
]),
Expand Down Expand Up @@ -120,36 +159,126 @@ proc resolveSymlinkChain*(path: string, ctx: WhyCtx): string =
return current

proc detectProviderByPath*(originPath, realPath: string, rules: seq[ProviderRule]): string =
let checkPaths = @[realPath, originPath]
let normalizedReal = realPath.replace('\\', '/')
let normalizedOrigin = originPath.replace('\\', '/')
let checkPaths = @[normalizedReal, normalizedOrigin]

for rule in rules:
for path in checkPaths:
if path.len == 0:
continue

for pattern in rule.patterns:
let normalizedPattern = pattern.replace('\\', '/')
case rule.kind
of mkContains:
if pattern in path:
if normalizedPattern in path:
return rule.name
of mkStartsWith:
if path.startsWith(pattern):
if path.startsWith(normalizedPattern):
return rule.name

return "Unknown"

type
PkgManagerStrategy = object
name: string
cmd: proc(path: string, ctx: WhyCtx): string {.nimcall.}

proc checkPkgManagerDpkg(path: string, ctx: WhyCtx): string =
if ctx.findExe("dpkg").len == 0:
return ""
let (outp, exitCode) = ctx.execCmd("dpkg -S " & quoteShell(path))
if exitCode != 0:
return ""
let parts = outp.split(":")
if parts.len == 0:
return ""
return "apt/dpkg (" & parts[0].strip() & ")"

proc checkPkgManagerRpm(path: string, ctx: WhyCtx): string =
let hasRpm = ctx.findExe("rpm").len > 0
let hasZypper = ctx.findExe("zypper").len > 0
if not hasRpm:
return ""
let (outp, exitCode) = ctx.execCmd("rpm -qf " & quoteShell(path))
if exitCode != 0:
return ""
if hasZypper:
return "zypper/rpm (" & outp.strip() & ")"
return "yum/rpm (" & outp.strip() & ")"

proc checkPkgManagerApk(path: string, ctx: WhyCtx): string =
if ctx.findExe("apk").len == 0:
return ""
let (outp, exitCode) = ctx.execCmd("apk info -W " & quoteShell(path))
if exitCode != 0:
return ""
let lines = outp.splitLines()
if lines.len == 0:
return ""
let pkg = lines[0].strip()
if pkg.len == 0:
return ""
return "apk (" & pkg & ")"

proc checkPkgManagerPacman(path: string, ctx: WhyCtx): string =
if ctx.findExe("pacman").len == 0:
return ""
let (outp, exitCode) = ctx.execCmd("pacman -Qo " & quoteShell(path))
if exitCode != 0:
return ""
let trimmed = outp.strip()
let marker = " is owned by "
if trimmed.contains(marker):
let parts = trimmed.split(marker)
if parts.len > 1:
return "pacman (" & parts[1].strip() & ")"
if trimmed.len > 0:
return "pacman (" & trimmed & ")"
return ""

proc checkPkgManagerPortageQfile(path: string, ctx: WhyCtx): string =
if ctx.findExe("qfile").len == 0:
return ""
let (outp, exitCode) = ctx.execCmd("qfile -qv " & quoteShell(path))
if exitCode != 0:
return ""
let lines = outp.splitLines()
if lines.len == 0:
return ""
let tokens = lines[0].splitWhitespace()
if tokens.len == 0:
return ""
return "portage (" & tokens[0].strip() & ")"

proc checkPkgManagerPortageEquery(path: string, ctx: WhyCtx): string =
if ctx.findExe("equery").len == 0:
return ""
let (outp, exitCode) = ctx.execCmd("equery b " & quoteShell(path))
if exitCode != 0:
return ""
for line in outp.splitLines():
let idx = line.find(" (")
if idx > 0:
let pkg = line[0..<idx].strip()
if pkg.len > 0 and not pkg.startsWith("*"):
return "portage (" & pkg & ")"
return ""

proc checkSystemPackageManager*(path: string, ctx: WhyCtx): string =
if ctx.findExe("dpkg").len > 0:
let (outp, exitCode) = ctx.execCmd("dpkg -S " & quoteShell(path))
if exitCode == 0:
let parts = outp.split(":")
if parts.len > 0:
return "apt/dpkg (" & parts[0].strip() & ")"

if ctx.findExe("rpm").len > 0:
let (outp, exitCode) = ctx.execCmd("rpm -qf " & quoteShell(path))
if exitCode == 0:
return "yum/rpm (" & outp.strip() & ")"
let checks = @[
PkgManagerStrategy(name: "dpkg", cmd: checkPkgManagerDpkg),
PkgManagerStrategy(name: "rpm", cmd: checkPkgManagerRpm),
PkgManagerStrategy(name: "apk", cmd: checkPkgManagerApk),
PkgManagerStrategy(name: "pacman", cmd: checkPkgManagerPacman),
PkgManagerStrategy(name: "qfile", cmd: checkPkgManagerPortageQfile),
PkgManagerStrategy(name: "equery", cmd: checkPkgManagerPortageEquery),
]
for check in checks:
let detected = check.cmd(path, ctx)
if detected.len > 0:
return detected

return ""

Expand Down
8 changes: 8 additions & 0 deletions tests/e2e/Builder.Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
FROM nimlang/nim:2.2.4-ubuntu-regular
WORKDIR /work
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY . .
RUN nimble build -d:release \
&& install -m 0755 /work/why /usr/local/bin/why
31 changes: 31 additions & 0 deletions tests/e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# E2E Tests (Docker)

These tests run `why` inside distro containers to exercise real package-manager lookups.

## Run

```bash
./tests/e2e/run.sh
```

Run a subset:

```bash
./tests/e2e/run.sh ubuntu alpine
```

## Coverage

- ubuntu: apt/dpkg + path-based providers (asdf/SDKMAN!/nvm/fnm/pyenv/rbenv/rvm/rustup/conda/mise/volta/macports/nix)
- fedora: yum/rpm
- opensuse: zypper/rpm
- alpine: apk
- arch: pacman
- gentoo: portage (qfile)

## Notes

- The `why` binary is built once via `tests/e2e/Builder.Dockerfile` and copied into each distro image.
- Alpine uses a glibc compatibility layer (`gcompat`/`libc6-compat`) to run the Ubuntu-built binary.
- Gentoo coverage is provided via a stage3 image with portage-utils (qfile).
- Windows-only providers (Scoop/Chocolatey/winget) are covered by unit tests.
6 changes: 6 additions & 0 deletions tests/e2e/alpine/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
FROM alpine:3.20
RUN apk add --no-cache bash ca-certificates gcompat libc6-compat libstdc++
COPY tests/e2e/why-linux-amd64 /usr/local/bin/why
COPY tests/e2e/common.sh /test-common.sh
COPY tests/e2e/alpine/test.sh /test.sh
ENTRYPOINT ["/bin/bash", "/test.sh"]
10 changes: 10 additions & 0 deletions tests/e2e/alpine/test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail

WHY=/usr/local/bin/why
source /test-common.sh

out=$($WHY ls)
assert_contains "$out" "Provider: apk ("

echo "alpine e2e OK"
7 changes: 7 additions & 0 deletions tests/e2e/arch/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
FROM archlinux:base
RUN pacman -Syu --noconfirm bash ca-certificates coreutils \
&& pacman -Scc --noconfirm
COPY tests/e2e/why-linux-amd64 /usr/local/bin/why
COPY tests/e2e/common.sh /test-common.sh
COPY tests/e2e/arch/test.sh /test.sh
ENTRYPOINT ["/bin/bash", "/test.sh"]
10 changes: 10 additions & 0 deletions tests/e2e/arch/test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail

WHY=/usr/local/bin/why
source /test-common.sh

out=$($WHY ls)
assert_contains "$out" "Provider: pacman ("

echo "arch e2e OK"
13 changes: 13 additions & 0 deletions tests/e2e/common.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail

assert_contains() {
local haystack="$1"
local needle="$2"
if ! grep -qF "$needle" <<<"$haystack"; then
echo "Expected output to contain: $needle" >&2
echo "Actual output:" >&2
echo "$haystack" >&2
exit 1
fi
}
7 changes: 7 additions & 0 deletions tests/e2e/fedora/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
FROM fedora:40
RUN dnf -y install bash ca-certificates coreutils \
&& dnf clean all
COPY tests/e2e/why-linux-amd64 /usr/local/bin/why
COPY tests/e2e/common.sh /test-common.sh
COPY tests/e2e/fedora/test.sh /test.sh
ENTRYPOINT ["/bin/bash", "/test.sh"]
10 changes: 10 additions & 0 deletions tests/e2e/fedora/test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
set -euo pipefail

WHY=/usr/local/bin/why
source /test-common.sh

out=$($WHY ls)
assert_contains "$out" "Provider: yum/rpm ("

echo "fedora e2e OK"
Loading