@lovrozagar/crossdeps
v0.2.3
Published
Cross-platform system dependency manager. Define deps in a config file, run crossdeps install.
Maintainers
Readme
@lovrozagar/crossdeps
Define system dependencies in a TypeScript (or JS) config file. Install, check, and wire their environment on macOS, Linux (apt / dnf / pacman), and Windows with one CLI.
The published package ships TypeScript source. The CLI shebang is #!/usr/bin/env bun, so Bun is required to run the CLI.
This README is the full usage contract. If you are an agent, read it end to end before writing a config or invoking the CLI. Every public command, flag, config field, export, and runtime rule is here with an example.
Source and the 24-dep catalog: github.com/lovrozagar/crossdeps.
Table of contents
- What this is
- Requirements
- Install
- Quick start
- Agent contract
- Config file
- CLI
- OS detection
- How install commands are executed
- Library API
- Worked examples
- package.json integration
- Gotchas
- Changelog
- License
What this is
crossdeps is a system-binary installer, not an npm/bun package installer.
You write crossdeps.config.ts listing tools such as node, bun, docker, adb. Each entry has:
- a version string
- per-OS install shell commands
- an optional version-check command
- optional
dependsOninstall order - optional env-var blocks written into the user shell profile
Then:
bunx crossdeps install # install missing deps for this OS
bunx crossdeps install --upgrade # also re-run installers on version mismatch
bunx crossdeps check # report installed vs expected
bunx crossdeps env # write env blocks for deps that define env
bunx crossdeps sync-pm # write package.json "packageManager": "bun@<version>"It does not:
- install npm/bun workspace packages
- rewrite PATH so a newly installed binary wins over brew/nvm/fnm (it warns)
- install
dependsOntargets when you install a single name - support JSON/YAML/TOML config (modules only:
.ts/.js/.mjs) - expose
env.ts/exec.tshelpers as public API
Requirements
| Need | Detail |
| ------------- | ------------------------------------------------------------------------------------------------------------------ |
| Bun | CLI is src/cli.ts with #!/usr/bin/env bun. npx crossdeps / bunx crossdeps only work if bun is on PATH. |
| Config module | Loaded with dynamic import(). Must be valid ESM that Bun can import. |
| Privileges | Install commands run as-is. If a command uses sudo / choco / brew, the machine must allow that. |
| Network | Most catalog commands download installers. Offline machines will fail those commands. |
The library API (defineConfig, detectOs, …) can be imported from TypeScript that resolves .ts exports (Bun, or a bundler). There is no compiled dist/.
Install
npm install -D @lovrozagar/crossdeps
# or
bun add -D @lovrozagar/crossdepsThe bin name is crossdeps. After install:
bunx crossdeps
# prints usage and exits 0 (no command)Quick start
Create crossdeps.config.ts in the project root (the directory you will run the CLI from):
import { defineConfig } from "@lovrozagar/crossdeps"
export default defineConfig({
packageJsonPath: "package.json",
deps: {
node: {
description: "JavaScript runtime",
required: true,
version: "22.12.0",
os: {
macos:
"curl -fsSL https://nodejs.org/dist/v{{version}}/node-v{{version}}-darwin-{{arch}}.tar.gz | sudo tar -xz -C /usr/local --strip-components=1",
"linux-apt":
"curl -fsSL https://nodejs.org/dist/v{{version}}/node-v{{version}}-linux-x64.tar.gz | sudo tar -xz -C /usr/local --strip-components=1",
windows: "choco install nodejs --version={{version}}",
},
},
bun: {
description: "JavaScript runtime and package manager",
required: true,
version: "1.3.11",
os: {
all: 'curl -fsSL https://bun.sh/install | bash -s "bun-v{{version}}"',
windows: 'powershell -c "irm bun.sh/install.ps1|iex" && bun upgrade --to {{version}}',
},
},
},
})bunx crossdeps install
bunx crossdeps install --upgrade
bunx crossdeps check
bunx crossdeps install --dry-run
bunx crossdeps sync-pmAgent contract
Follow these rules. They are the actual runtime, not suggestions.
- Config files must be named
crossdeps.config.ts,crossdeps.config.js, orcrossdeps.config.mjsincwd, or passed with--config <path>. - The file must export an object with a
depsfield. Default export is preferred. A named export also works if it hasdeps. defineConfigis an identity helper that types the object and defaultspackageJsonPathto"package.json". You can export a plain object instead.- OS targets are exactly:
macos,linux-apt,linux-dnf,linux-pacman,windows. - Install command resolution: per-OS string wins; else
os.all;falsemeans unavailable (overridesall); omitted key with noallmeans unavailable. {{name}},{{version}},{{major}},{{arch}}are interpolated inoscommands andcheck.command.{{arch}}isarm64only whenprocess.arch === "arm64"; every other arch isamd64.- Default check command is
{{name}} --version. A dep is "installed" only if the check binary exists and stdout/stderr of the check command matches(\d+\.\d+[\w.-]*). install(no name) topologically sorts bydependsOn.install <name>installs only that name and does not walkdependsOn.installskips when a detected version matches the pin (latestor substring either way). Mismatch without--upgradeskips, prints the resolved binary path, and hintscrossdeps install <name> --upgrade.--upgradere-runs the install command on mismatch only. After that command succeeds, if PATH still reports a non-matching version, print a shadow warning (do not fail).--dry-runnever skips.- Failed required deps fail
install(exit 1). Failed optional deps are counted as skipped. - Unavailable on this OS is not a failure.
check/check <name>snapshot PATH once from a new interactive TTY (bash:$SHELL -icwith stdin/dev/null, not login-only-lc; zsh:$SHELL -lic; Windows:powershell.exewith profile,$env:Path). The spawn env is a keep-list (HOME/SHELL/locale/TERM) plus a stock PATH — not the caller's PATH or leftover toolchain exports.--hereuses this process PATH. Spawn failure or empty PATH → this process PATH and one warning (do not crash).install/--upgradealways use this process PATH.check(all): missing required → exit 1. Version mismatch → warning, exit 0. Missing optional → warning, exit 0.check <name>: missing → exit 1 even if the dep is optional. Unavailable → exit 0. Any detected version → exit 0 (no match check).sync-pmonly readsdeps.bun. Skips ifbunis absent orversionis"latest". Rewritespackage.jsonby string replace, relative to the config file directory.envwrites~/.zshrcor~/.bashrcon Unix, andDocuments/PowerShell/Microsoft.PowerShell_profile.ps1on Windows. Detected paths are written unexpanded ($HOME/..., not the resolved path).- On Windows, commands matching PowerShell markers run in
powershell.exe. Everything else runs incmd.exe. On Unix, commands run in/bin/bash. --os <target>setsCROSSDEPS_OSfor the process. Invalid targets exit 1.- Unknown CLI command → print usage, exit 1. No command → print usage, exit 0.
- Public library surface is only what
@lovrozagar/crossdepsre-exports fromindex.ts. Do not import./env.ts,./exec.ts, or./path.tsfrom the package. - Circular
dependsOnlogs a warning and still installs every node once.
Config file
Discovery
The CLI looks at process.cwd(), not the config file's parent, to find a convention name.
Search order:
--config <path>(resolved withpath.resolve(cwd, path)). Missing flag value → exit 1. File does not exist → exit 1.- First existing of:
crossdeps.config.tscrossdeps.config.jscrossdeps.config.mjs
- None found → exit 1:
No crossdeps config found. Create one of: crossdeps.config.ts, crossdeps.config.js, crossdeps.config.mjsThere is no crossdeps.config.json. There is no recursive walk up parent directories.
# convention name in cwd
bunx crossdeps install
# explicit path (any filename, must exist)
bunx crossdeps install --config ./tooling/deps.ts
bunx crossdeps check --config /abs/path/crossdeps.config.tsExport shape
The loader import()s the file and accepts:
export default defineConfig({ deps, packageJsonPath? })export default { deps, packageJsonPath? }- Any named export whose value is an object with a
depsfield (first such value inObject.valuesorder)
// preferred
export default defineConfig({ deps: {/* ... */} })
// also valid
export const config = defineConfig({ deps: {/* ... */} })
// also valid (no defineConfig)
export default {
deps: {
jq: {
description: "JSON processor",
required: false,
version: "1.8.1",
os: { all: "echo install jq" },
},
},
}If nothing exported has deps:
Config file must export an object with a `deps` field (use defineConfig)and the process exits 1.
defineConfig
function defineConfig(options: { packageJsonPath?: string; deps: Record<string, SystemDepConfig> }): CrossdepsConfig| Field | Type | Default | Meaning |
| ----------------- | --------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------- |
| deps | Record<string, SystemDepConfig> | required | Map of dep key → config. The key is {{name}}. |
| packageJsonPath | string | "package.json" | Path used by sync-pm and the check packageManager probe. Relative to the config file's directory, not cwd. |
import { defineConfig } from "@lovrozagar/crossdeps"
export default defineConfig({
packageJsonPath: "./apps/web/package.json",
deps: {
bun: {
description: "JavaScript runtime and package manager",
required: true,
version: "1.3.11",
os: { all: 'curl -fsSL https://bun.sh/install | bash -s "bun-v{{version}}"' },
},
},
})Calling defineConfig does not validate commands or OS keys. It returns { deps, packageJsonPath } with the default filled in.
SystemDepConfig
interface SystemDepConfig {
description: string
required: boolean
version: string
os: OsCommands
check?: { command: string }
dependsOn?: string[]
env?: EnvVar[]
}| Field | Required | Example | Meaning |
| ------------- | -------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| description | yes | "JSON processor" | Printed by install / check. Not used for logic. |
| required | yes | true | If true, a failed install of this dep increments Failed and exits 1. If false, a failed install is counted as Skipped. Unused by single-target check (missing always exits 1). |
| version | yes | "1.8.1" or "latest" | Interpolated as {{version}}. "latest" makes check treat any detected version as OK, and makes sync-pm skip. |
| os | yes | { all: "brew install jq" } | Per-OS install commands. See OsCommands. |
| check | no | { command: "atlas version" } | Version-check command. Default: "{{name}} --version". Interpolates the same templates as os. |
| dependsOn | no | ["node", "npm"] | Keys that must be installed before this one when running install with no name. Unknown keys are ignored. |
| env | no | [{ key: "FOO", value: "bar" }] | Written by install (after a successful install) and by crossdeps env. |
Every field together:
stripe: {
description: "Stripe CLI",
required: true,
version: "1.21.0",
dependsOn: ["brew"],
check: { command: "stripe version" },
os: {
macos: "brew install stripe/stripe-cli/stripe",
"linux-apt": "curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg && sudo apt-get install -y stripe",
windows: "choco install stripe-cli --version={{version}}",
"linux-dnf": false,
"linux-pacman": false,
},
env: [{ key: "STRIPE_CLI_TELEMETRY_OPTOUT", value: "1" }],
}OsCommands
type OsTarget = "linux-apt" | "linux-dnf" | "linux-pacman" | "macos" | "windows"
type OsCommands = Partial<Record<OsTarget, string | false>> & { all?: string }OS_TARGETS (exported) is ["linux-apt", "linux-dnf", "linux-pacman", "macos", "windows"].
Resolution for a given target:
| os[target] | os.all | Result |
| ------------ | -------- | ------------------------- |
| string | anything | that string, interpolated |
| false | anything | unavailable (null) |
| omitted | string | os.all, interpolated |
| omitted | omitted | unavailable (null) |
// same command everywhere
os: { all: "npm install -g {{name}}@{{version}}" }
// same everywhere except Windows
os: {
all: 'curl -fsSL https://bun.sh/install | bash -s "bun-v{{version}}"',
windows: 'powershell -c "irm bun.sh/install.ps1|iex" && bun upgrade --to {{version}}',
}
// Linux only; macOS/Windows unavailable
os: {
"linux-apt": "sudo apt-get install -y {{name}}",
"linux-dnf": "sudo dnf install -y {{name}}",
"linux-pacman": "sudo pacman -S --noconfirm {{name}}",
}
// available on Unix, explicitly not on Windows (overrides all)
os: {
all: "cargo install {{name}} --version {{version}} --locked",
windows: false,
}Unavailable is printed as:
name@version — not available on linux-aptIt is not a failure.
Template variables
Replaced globally (replace(/\{\{name\}\}/g, …)) in every os command and in check.command.
| Token | Source | Example input | Example output |
| ------------- | ---------------------------------------------------------------------- | --------------------------------------------------------- | -------------- |
| {{name}} | dep key | key stripe-cli | stripe-cli |
| {{version}} | config.version as-is | "22.12.0" | 22.12.0 |
| {{major}} | first . segment of version, or the whole string if there is no . | "22.12.0" → 22; "stable" → stable |
| {{arch}} | process.arch === "arm64" ? "arm64" : "amd64" | Apple Silicon → arm64; x64 / ia32 / arm → amd64 |
os: {
macos:
"curl -fsSL https://nodejs.org/dist/v{{version}}/node-v{{version}}-darwin-{{arch}}.tar.gz -o /tmp/node.tgz",
}
check: { command: "{{name}}-{{major}} --version" }There are no other tokens. {{os}}, {{home}}, and env vars are not interpolated here. Put $HOME / %USERPROFILE% in the shell command itself if you need them at install time.
EnvVar
interface EnvVar {
key: string
value?: string
appendToPath?: boolean
detect?: string[]
fallback?: string
}| Field | Meaning |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| key | Variable name (ANDROID_HOME, PATH, …). For appendToPath: true the written line still uses PATH/$env:Path; key is only for logging. |
| value | Used when detect is absent or empty. Written as-is (not expanded by crossdeps). |
| detect | Candidate paths. First path that exists after expansion wins. The original unexpanded string is what gets written. |
| fallback | Used when every detect path is missing. Written as-is. |
| appendToPath | If true, append to PATH instead of export KEY=value. |
Path expansion for detect existence checks only substitutes:
| Token | Becomes |
| ---------------- | ---------------------------------- |
| $HOME | os.homedir() |
| %USERPROFILE% | os.homedir() (case-insensitive) |
| %HOME% | os.homedir() (case-insensitive) |
| $ANDROID_HOME | process.env.ANDROID_HOME or "" |
| %ANDROID_HOME% | process.env.ANDROID_HOME or "" |
No other $VAR / %VAR% tokens are expanded.
Resolution order per EnvVar:
- If
detectis a non-empty array: first existing expanded path → return the unexpanded detect string. Else iffallbackis set → returnfallback. Else skip this var (log, do not write). - Else return
valueornull.
env: [
{
key: "ANDROID_HOME",
detect: ["$HOME/Android/sdk", "$HOME/Library/Android/sdk", "/usr/lib/android-sdk"],
fallback: "$HOME/Android/sdk",
},
{ key: "PATH", appendToPath: true, value: "$ANDROID_HOME/platform-tools" },
{ key: "PATH", appendToPath: true, value: "$ANDROID_HOME/emulator" },
]If $HOME/Library/Android/sdk exists, the profile gets export ANDROID_HOME="$HOME/Library/Android/sdk" (the detect string), not the resolved /Users/you/Library/Android/sdk.
Written blocks are wrapped in markers and replaced on the next run:
# android-sdk environment (managed by crossdeps)
export ANDROID_HOME="$HOME/Library/Android/sdk"
export PATH="$PATH:$ANDROID_HOME/platform-tools"
# end android-sdk environmentPowerShell 7:
# android-sdk environment (managed by crossdeps)
$env:ANDROID_HOME = "$HOME/Library/Android/sdk"
$env:Path += ";$ANDROID_HOME/platform-tools"
# end android-sdk environmentTarget files:
| Platform | File |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| Windows | {homedir}/Documents/PowerShell/Microsoft.PowerShell_profile.ps1 (PowerShell 7, not Windows PowerShell 5 WindowsPowerShell) |
| Unix and process.env.SHELL contains zsh | {homedir}/.zshrc |
| Other Unix | {homedir}/.bashrc |
After writing, the CLI prints:
- Unix:
Run: source ~/.bashrc(or.zshrc) - Windows:
Restart PowerShell or run: . $PROFILE
install writes env only after a successful install of that dep (not on skip, not on dry-run). crossdeps env writes every dep that has env, regardless of install state.
CLI
Usage: crossdeps <command> [args]
Commands:
install Install all deps (auto-detect OS)
install <name> Install single dep
check Check all deps
check <name> Check single dep
env Setup environment variables
sync-pm Sync packageManager field in package.json
Flags:
--config <path> Config file (default: crossdeps.config.ts in cwd)
--os <target> Force OS target (or set CROSSDEPS_OS)
--dry-run Print install commands without running them
--upgrade Re-run install when the detected version does not match
--here check: use this process PATH instead of an interactive-shell snapshotInvocation and flags
bunx crossdeps <command> [name] [--config <path>] [--os <target>] [--dry-run] [--upgrade] [--here]Flags may appear before or after the command. Each flag is stripped once (first occurrence).
| Flag / env | Applies to | Behavior |
| ----------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| --config <path> | all commands | Required path argument. Must exist. |
| --os <target> | all commands | Must be one of OS_TARGETS. Sets process.env.CROSSDEPS_OS. Missing value or unknown target → exit 1. |
| CROSSDEPS_OS | all commands | Same as --os when --os is not passed. |
| --dry-run | install only | Prints dry-run: <command> and counts the dep as installed. Silently ignored by check / env / sync-pm. |
| --upgrade | install only | Re-run the install command when the detected version does not match the pin. Matching versions still skip. Ignored by check / env / sync-pm. |
| --here | check only | Use this process PATH instead of an interactive-shell snapshot. Ignored by install / env / sync-pm. |
bunx crossdeps install
bunx crossdeps install node
bunx crossdeps install --upgrade
bunx crossdeps install node --upgrade
bunx crossdeps install --dry-run
bunx crossdeps install node --os windows --dry-run
bunx crossdeps --config ./deps.ts --os linux-dnf check bun
bunx crossdeps check --here
bunx crossdeps check node --here
CROSSDEPS_OS=macos bunx crossdeps install --dry-runUnknown command:
bunx crossdeps foo
# prints USAGE
# Unknown command: foo
# exit 1No command:
bunx crossdeps
# prints USAGE
# exit 0--os without a value:
bunx crossdeps install --os
# --os requires a target argument
# exit 1--os freebsd:
Unknown OS target: freebsd. Expected one of: linux-apt, linux-dnf, linux-pacman, macos, windowsexit 1.
install
All deps (crossdeps install):
- Detect OS.
- Print every dep with
[required]/[optional]and mark those with no command as(not available on <os>). - Sort with
sortByDependencies(dependsOnfirst). - For each dep, run the single-dep steps below.
- Print summary: Installed / Skipped / Unavailable / Failed.
- Exit 1 if Failed > 0.
One dep (crossdeps install node):
- Unknown name →
Unknown dependency: nodeplusAvailable: …, exit 1. - Run the single-dep steps. Do not install
dependsOnfirst. - Exit 1 only if that dep failed. Unavailable and skipped exit 0.
Single-dep steps
- Resolve the install command for the detected OS. None → log unavailable, return.
- If
--dry-run→ printdry-run: <command>, return installed (no check, no exec, no env write). - If version detection returns a string that matches the pin → print
Already installed (<ver>), skipping, return skipped. - If a version is detected but does not match, and
--upgradeis off → printAlready installed (<ver>), skipping (expected <pin>)plus the resolved binary path andrun: crossdeps install <name> --upgrade, return skipped. - Run the command. Success → print
Installed successfully, then ifenvis non-empty run the env writer. Re-check the version; if PATH still does not match, print a shadow warning. Return installed. - Failure → print
Installation failed. Required → failed. Optional → skipped.
Matching uses the same table as check. --upgrade does not re-run a dep whose detected version already matches.
check
PATH for version probes is snapshotted once per check invocation:
- Default: interactive TTY PATH (bash:
$SHELL -icwith stdin/dev/null; zsh:$SHELL -lic; Windows:powershell.exewith profile,$env:Path). Spawn env is a keep-list (HOME/SHELL/locale/TERM) plus a stock PATH, not the caller's PATH or leftover toolchain exports. --here: this process PATH.- Spawn failure or empty PATH: this process PATH, one warning, do not crash.
install does not use this snapshot.
All deps (crossdeps check):
For each dep in Object.entries order (not topo-sorted):
| Situation | Line | Counter |
| -------------------------------- | ------------------------------------------------------- | -------- |
| No install command on this OS | - [required] name — not available on <os> | none |
| Check found no version, required | x [required] name — not installed (expected <ver>) | Missing |
| Check found no version, optional | x [optional] name — not installed (expected <ver>) | Mismatch |
| Version matches (see below) | v [required] name@<installed> | OK |
| Version does not match | ~ [required] name@<installed> (expected <ver>) <path> | Mismatch |
A version matches if any of these is true:
config.version === "latest"installed === config.versionconfig.version.includes(installed)installed.includes(config.version)
Substring either way is intentional so 1.0.1 matches v1.0.1-1c2aa24-canary and the reverse.
Then, if deps.bun exists, check also probes package.json at resolve(configDir, packageJsonPath):
| deps.bun.version | packageManager | Result |
| ------------------ | ----------------------- | -------- |
| "latest" | starts with bun@ | OK line |
| "latest" | anything else / missing | Mismatch |
| other | exactly bun@<version> | OK line |
| other | anything else / missing | Mismatch |
Missing package.json throws (fatal, exit 1).
Summary:
OK: N Mismatch: N Missing: N- Missing > 0 →
Required dependencies missing — run: crossdeps install, exit 1. - Mismatch > 0 →
Version mismatches found — run: crossdeps install --upgrade, exit 0. - Else →
All system dependencies OK, exit 0.
One dep (crossdeps check bun):
| Situation | Output | Exit |
| ---------------------- | ----------------------------------------- | ---- |
| Unknown name | Unknown dependency: … | 1 |
| Unavailable on this OS | bun — not available on <os> | 0 |
| No version detected | bun — not installed (expected <ver>) | 1 |
| Any version detected | bun@<installed> (expected <ver>) <path> | 0 |
Single-target check does not apply the match table. Any parsed version is success, even if it disagrees with config.version. Single-target check does not honor required: false for the missing case.
env
bunx crossdeps envWalks every dep that has a non-empty env array and writes/replaces that tool's managed block. Does not install anything.
If no dep has env:
No dependencies with environment variables configured.exit 0.
sync-pm
bunx crossdeps sync-pmOnly uses deps.bun.
| Condition | Behavior | Exit |
| ---------------------------------------------------- | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| No deps.bun | Skipping packageManager sync — bun not in config | 0 |
| deps.bun.version === "latest" | Skipping packageManager sync — bun version is "latest" | 0 |
| File already has "packageManager": "bun@<version>" | packageManager already correct: bun@<version> | 0 |
| File has a different packageManager string | String-replaces "packageManager": "<current>" with "packageManager": "bun@<version>" | 0 |
| File has no packageManager | Injects ,\n\t"packageManager": "bun@<version>" immediately after "name": "<pkg.name>" | 0 |
| Read/parse/write throws | Failed to sync packageManager: + error | 0 from cmdSyncPm (the function returns false; main does not exit 1) |
packageJsonPath is resolved from the directory that contains the config file.
This is a string edit, not a JSON rewrite. It expects the current value to appear exactly as "packageManager": "<current>". Unusual formatting (single quotes, extra spaces) will not match; the inject-after-name path only runs when packageManager is missing from the parsed object.
sync-pm never writes a node@ packageManager. Only bun@<version>.
Version detection
Used by install (skip if present, this process PATH) and check (interactive TTY PATH snapshot, or this process PATH with --here).
checkCmd = resolveCheckCommand(name, config)
= interpolate(config.check?.command ?? "{{name}} --version", name, version)
binary = first space-separated token of checkCmd
if binary is set and commandExists(binary) is false → not installed (null)
run checkCmd via execSync
stdout+stderr must match /(\d+\.\d+[\w.-]*)/
first match → that string
no match, non-zero exit, or throw → nullcommandExists:
- Strip one pair of surrounding quotes from the token.
- If that path
existsSync, true. - Else run
command -v <token>(Unix) orwhere <token>(Windows).
Implications:
- Check command
echo android-studiofindsechoon PATH, printsandroid-studio, regex misses, result is not installed. The check output must contain something like1.2/22.12.0/1.0.0-beta. - Check command
"/usr/local/bin/node" --versionworks becausecommandExistsaccepts an existing path. - A tool that prints only
stableorokis treated as missing.
Exit codes
| Situation | Exit |
| ----------------------------------------------------- | ---------------------------------------------------------- |
| crossdeps with no command | 0 |
| Unknown command | 1 |
| --config / --os missing value or bad --os | 1 |
| Config file missing or no deps export | 1 |
| install / check unknown dep name | 1 |
| install required dep command failed | 1 |
| install optional dep command failed | 0 (counted skipped) |
| install dep unavailable | 0 |
| install --dry-run | 0 |
| check all, required missing | 1 |
| check all, only version / packageManager mismatches | 0 |
| check <name> missing (even optional) | 1 |
| check <name> unavailable | 0 |
| env / sync-pm | 0 (sync-pm file errors are logged, not turned into exit 1) |
| Uncaught exception | 1 (Fatal error:) |
OS detection
detectOs((override = process.env.CROSSDEPS_OS))| Input | Result |
| ---------------------------------------- | ------------------------------- |
| override / CROSSDEPS_OS / --os set | parseOsTarget(value) or throw |
| process.platform === "darwin" | macos |
| process.platform === "win32" | windows |
| Linux and apt-get on PATH | linux-apt |
| Linux and dnf on PATH | linux-dnf |
| Linux and pacman on PATH | linux-pacman |
| Linux and none of those | linux-apt (default) |
--os is implemented by assigning process.env.CROSSDEPS_OS before any detect call.
# force the Windows command set while sitting on Linux (does not boot Windows)
bunx crossdeps install --os windows --dry-run--os / CROSSDEPS_OS do not change process.platform. Env file paths and the PowerShell-vs-cmd router still follow the real kernel. Use --os to select which os.* command string is resolved, not to emulate another OS's shell.
How install commands are executed
Unix (linux-*, and also when you force --os windows from Linux):
execSync(command, { shell: "/bin/bash", stdio: "inherit" })Multi-line scripts and && / pipes work because the shell is bash.
Windows, if the command matches any PowerShell marker:
powershell.exe -NoProfile -NonInteractive -Command <command>Markers (any one is enough):
| Marker | Example that trips it |
| ------------------- | ---------------------------------------------------------------- |
| \birm\b | irm bun.sh/install.ps1 \| iex |
| \biex\b | same |
| $env: | Invoke-WebRequest … -OutFile "$env:LOCALAPPDATA\bin\atlas.exe" |
| Invoke-WebRequest | official Windows binary downloads |
| Invoke-Expression | |
| New-Item | New-Item -ItemType Directory -Force … |
| Test-Path | |
| Out-Null | … \| Out-Null |
| Get-Command | |
| $LASTEXITCODE | |
Windows, otherwise:
execSync(command, { shell: process.env.ComSpec || "cmd.exe", stdio: "inherit" })so choco install nginx || choco install nginx and bun -e "…" keep working. || is invalid in PowerShell; do not add $env: / Out-Null to a cmd-oriented command or it will be routed to PowerShell and break.
stdio: "inherit" means the user sees the installer output live.
Library API
This is the entire public surface (src/index.ts):
export type { CrossdepsConfig, EnvVar, OsCommands, OsTarget, SystemDepConfig }
export { defineConfig, interpolate, OS_TARGETS, resolveCheckCommand, resolveOsCommand, versionsMatch }
export { sortByDependencies }
export { commandExists, commandLookup, detectOs, detectOsFromPlatform, parseOsTarget, whichBinary }OS_TARGETS
const OS_TARGETS = ["linux-apt", "linux-dnf", "linux-pacman", "macos", "windows"] as const
type OsTarget = (typeof OS_TARGETS)[number]defineConfig(options)
See defineConfig. Returns the same object with packageJsonPath defaulted.
interpolate(template, name, version)
interpolate("{{name}}@{{version}} ({{major}}) {{arch}}", "node", "22.12.0")
// "[email protected] (22) arm64" or "… amd64"
interpolate("{{major}}", "rust", "stable")
// "stable"resolveOsCommand(name, config, target)
resolveOsCommand(
"git",
{
description: "git",
required: true,
version: "2.39.5",
os: { all: "echo all", "linux-apt": "apt install git={{version}}" },
},
"linux-apt",
)
// "apt install git=2.39.5"
resolveOsCommand("flux", {/* os: { all: "…", windows: false } */}, "windows")
// nullresolveCheckCommand(name, config)
resolveCheckCommand("node", {/* no check */})
// "node --version"
resolveCheckCommand("atlas", { check: { command: "{{name}} version {{version}}" }, version: "1.2.3" /* … */ })
// "atlas version 1.2.3"sortByDependencies(entries)
sortByDependencies([
["npm", { dependsOn: ["node"] /* … */ }],
["node", {/* … */}],
])
// [["node", …], ["npm", …]]- Walks
dependsOndepth-first. - Names not present in the input set are ignored.
- Cycles:
console.warn("Circular dependency detected involving: <name>"), then both nodes still appear once.
parseOsTarget(value)
parseOsTarget("macos") // "macos"
parseOsTarget("freebsd") // throws Error("Unknown OS target: freebsd. Expected one of: …")detectOsFromPlatform(platform, override?)
detectOsFromPlatform("darwin") // "macos"
detectOsFromPlatform("win32") // "windows"
detectOsFromPlatform("linux", "linux-dnf") // "linux-dnf"
detectOsFromPlatform("linux") // linux-apt / linux-dnf / linux-pacman / linux-apt defaultdetectOs(override?)
detectOsFromPlatform(process.platform, override ?? process.env.CROSSDEPS_OS).
commandLookup(command, platform = process.platform)
commandLookup("bun", "win32") // "where bun >nul 2>&1"
commandLookup("bun", "linux") // "command -v bun >/dev/null 2>&1"
commandLookup("bun", "darwin") // "command -v bun >/dev/null 2>&1"commandExists(command)
true if the (optionally quoted) path exists on disk, or if commandLookup succeeds.
commandExists("sh") // true on Unix
commandExists("/usr/local/bin/node")
commandExists('"/usr/local/bin/node"')
commandExists("crossdeps-not-real") // falseversionsMatch(installed, expected)
versionsMatch("24.19.0", "24.19.0") // true
versionsMatch("1.0.1", "v1.0.1-canary") // true
versionsMatch("25.5.0", "24.19.0") // false
versionsMatch("1.3.11", "latest") // truewhichBinary(command, platform?)
whichBinary("sh") // "/usr/bin/sh"
whichBinary("crossdeps-not-real") // nullWorked examples
Optional dep
jq: {
description: "JSON processor",
required: false,
version: "1.8.1",
os: {
macos: "brew install jq",
"linux-apt": "sudo apt-get install -y jq",
windows: "choco install jq --version={{version}}",
},
}If the command fails, install continues. check reports a mismatch, exit 0. check jq with jq missing still exits 1.
Custom check command
atlas: {
description: "Database schema management tool",
required: true,
version: "v1.0.1-1c2aa24-canary",
check: { command: "atlas version" },
os: {
all: "curl -sSf https://atlasgo.sh | sh -s -- --version {{version}}",
windows:
'New-Item -ItemType Directory -Force "$env:LOCALAPPDATA\\bin" | Out-Null; Invoke-WebRequest -UseBasicParsing https://release.ariga.io/atlas/atlas-windows-amd64-latest.exe -OutFile "$env:LOCALAPPDATA\\bin\\atlas.exe"',
},
}Default atlas --version would be wrong. check.command must still print a digits.digits token or install will never skip.
dependsOn
node: { /* … */ version: "22.12.0", os: { all: "…" } },
npm: {
description: "npm CLI",
required: true,
version: "10.9.0",
dependsOn: ["node"],
os: { all: "npm install -g npm@{{version}}" },
},
claude: {
description: "Claude Code CLI",
required: true,
version: "latest",
dependsOn: ["npm"],
os: { all: "npm install -g @anthropic-ai/claude-code@{{version}}" },
},crossdeps install order: node → npm → claude.
crossdeps install claude runs only the claude command. Install node and npm first, or run the full install.
version: "latest"
claude: {
description: "Claude Code CLI",
required: true,
version: "latest",
os: { all: "npm install -g @anthropic-ai/claude-code@{{version}}" },
}- Install command becomes
…@latest. - If any version is already detected, install skips (will not refresh to a newer latest).
checkcounts any detected version as OK.- If this were
bun,sync-pmwould skip.
Env detect + PATH append
"android-sdk": {
description: "Android SDK",
required: true,
version: "35.0.0",
check: { command: "adb --version" },
os: { macos: "brew install --cask android-commandlinetools" },
env: [
{
key: "ANDROID_HOME",
detect: ["$HOME/Library/Android/sdk", "$HOME/Android/sdk"],
fallback: "$HOME/Library/Android/sdk",
},
{ key: "PATH", appendToPath: true, value: "$ANDROID_HOME/platform-tools" },
],
}bunx crossdeps env
# writes ~/.zshrc or ~/.bashrc
source ~/.zshrcUnavailable on one OS
flux: {
description: "InfluxDB CLI",
required: false,
version: "2.7.11",
os: {
macos: "brew install influxdb",
windows: 'Invoke-WebRequest … -OutFile "$env:LOCALAPPDATA\\bin\\flux.exe"',
"linux-apt": false,
"linux-dnf": false,
"linux-pacman": false,
},
}On Ubuntu: [email protected] — not available on linux-apt. Install and check succeed.
Dry-run on another OS command set
bunx crossdeps install --os windows --dry-runPrints the Windows command strings. Does not run them. Does not write env. Does not skip already-installed deps.
Named export and custom config path
// tooling/system-deps.ts
import { defineConfig } from "@lovrozagar/crossdeps"
export const systemDeps = defineConfig({
packageJsonPath: "../package.json",
deps: { bun: {/* … */} },
})bunx crossdeps install --config ./tooling/system-deps.tssync-pm will edit tooling/../package.json.
Library-only use (no CLI)
import {
defineConfig,
detectOs,
interpolate,
resolveCheckCommand,
resolveOsCommand,
sortByDependencies,
} from "@lovrozagar/crossdeps"
const config = defineConfig({
deps: {
jq: {
description: "JSON processor",
required: false,
version: "1.8.1",
os: { all: "brew install jq" },
},
},
})
const os = detectOs()
const command = resolveOsCommand("jq", config.deps.jq, os)
const check = resolveCheckCommand("jq", config.deps.jq)
const order = sortByDependencies(Object.entries(config.deps))package.json integration
{
"scripts": {
"setup:deps": "crossdeps install",
"setup:deps:upgrade": "crossdeps install --upgrade",
"setup:deps:check": "crossdeps check",
"setup:deps:env": "crossdeps env",
"setup:deps:sync-pm": "crossdeps sync-pm"
}
}Onboarding:
npm install # or bun install
npm run setup:deps
npm run setup:deps:env
npm run setup:deps:sync-pmsync-pm keeps Corepack / package-manager pinning aligned with deps.bun.version.
Gotchas
| Trap | What actually happens |
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| install <name> of a dep with dependsOn | Dependents are not installed. |
| Tool already installed at the wrong version | install skips. install --upgrade re-runs the command. If PATH still shows the old binary (brew/nvm/fnm), that is a shadow warning, not a failed install. |
| Agent/IDE PATH vs a new terminal | check uses an interactive TTY PATH snapshot with a keep-list spawn env (stock PATH, no leftover toolchain exports). check --here uses this process PATH. install always uses this process PATH. |
| --dry-run to "see what would skip" | Dry-run never checks installed versions. Everything with a command is "installed". |
| Check command that prints no N.N | Treated as not installed. Install will run every time. |
| check <optional-dep> when missing | Exit 1. required: false only changes all-deps install / check. |
| {{arch}} on x64 | amd64, not x64. |
| Linux without apt/dnf/pacman | Detected as linux-apt. |
| os.windows uses \|\| plus $env: | Routed to PowerShell; \|\| is wrong. Keep cmd syntax and PowerShell syntax in separate commands. |
| env.detect writes the resolved path | No. It writes the template ($HOME/...). |
| Extra env tokens in detect ($XDG_DATA_HOME) | Not expanded. Existence check looks for a literal $XDG_DATA_HOME/… path. |
| sync-pm for node | Not implemented. Only deps.bun. |
| packageJsonPath relative to cwd | No. Relative to the config file directory. |
| Import @lovrozagar/crossdeps/env | Not exported. Use the CLI or copy the idea. |
| JSON config | Not supported. |
| Walking parent dirs for config | Not supported. Run from the directory that contains the file, or pass --config. |
Releases
GitHub Releases match npm versions. Pushing a tag vX.Y.Z (same as this package.json version) runs the repo .github/workflows/release.yml: test, npm publish via trusted publishing, GitHub Packages, then a GitHub Release.
Configure the trusted publisher once on this package (Settings → Trusted Publisher → GitHub Actions): repository lovrozagar/crossdeps, workflow release.yml, no environment, allow npm publish. Do not put an npm token in GitHub secrets.
Changelog
See CHANGELOG.md.
License
MIT
