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.
This repo is the source of the @lovrozagar/crossdeps npm package (packages/core). 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.
- 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
- Repository layout
- Develop
- Lint and format
- CI
- Releases
- Changelog
- License
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
| Need | Detail |
|---|---|
| Bun | CLI is packages/core/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/.
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)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-pmA larger real catalog (24 deps) lives in examples/consumer/crossdeps.config.ts.
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.
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.mjs
There 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.tsThe 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.
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.
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" }],
}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-apt
It is not a failure.
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.
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.
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 snapshot
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, windows
exit 1.
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.
- 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.
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.
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.
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>.
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 → null
commandExists:
- 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.
| 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:) |
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.
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.
This is the entire public surface (packages/core/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 }const OS_TARGETS = ["linux-apt", "linux-dnf", "linux-pacman", "macos", "windows"] as const
type OsTarget = (typeof OS_TARGETS)[number]See defineConfig. Returns the same object with packageJsonPath defaulted.
interpolate("{{name}}@{{version}} ({{major}}) {{arch}}", "node", "22.12.0")
// "node@22.12.0 (22) arm64" or "… amd64"
interpolate("{{major}}", "rust", "stable")
// "stable"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("node", {/* no check */})
// "node --version"
resolveCheckCommand("atlas", { check: { command: "{{name}} version {{version}}" }, version: "1.2.3" /* … */ })
// "atlas version 1.2.3"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("macos") // "macos"
parseOsTarget("freebsd") // throws Error("Unknown OS target: freebsd. Expected one of: …")detectOsFromPlatform("darwin") // "macos"
detectOsFromPlatform("win32") // "windows"
detectOsFromPlatform("linux", "linux-dnf") // "linux-dnf"
detectOsFromPlatform("linux") // linux-apt / linux-dnf / linux-pacman / linux-apt defaultdetectOsFromPlatform(process.platform, override ?? process.env.CROSSDEPS_OS).
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"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("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("sh") // "/usr/bin/sh"
whichBinary("crossdeps-not-real") // nulljq: {
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.
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.
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.
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.
"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 ~/.zshrcflux: {
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: flux@2.7.11 — not available on linux-apt. Install and check succeed.
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.
// 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.
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)){
"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.
| 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. |
packages/core published package (@lovrozagar/crossdeps)
examples/consumer a real consumer: defineConfig + the 24-dep catalog
e2e/app isolated fixtures + CLI / catalog tests
e2e/docker Linux distro matrix (apt / dnf / pacman)
e2e/catalog-install.ts real install of examples/consumer on this machine
examples/ is what a user project looks like. It is not a test runner. e2e/ is how this repo proves the package: fixture configs, bun test, Docker, and the catalog-install script. Tests import @lovrozagar/crossdeps over workspace:* the same way a published consumer would.
packages/core is the only published workspace.
Requires Bun 1.3+.
bun install
bun run test # core unit tests
bun run test:consumers # e2e-app + catalog dry-run
bun run test:e2e:docker # OS matrix in Docker
bun run test:catalog-install # real catalog install on this machine
bun run typecheck
bun run typecheck:consumers
bun run lint # oxlint
bun run fmt # oxfmt
bun run fmt:checkDocker Linux services set CROSSDEPS_REAL_INSTALL=1 so e2e actually runs download/install commands. That variable is not a CLI flag and is ignored by the published package.
e2e/catalog-install.ts installs one catalog dep at a time. Override the per-dep timeout with CROSSDEPS_INSTALL_TIMEOUT_MS (default 180000).
Docker cannot boot macOS or Windows. Those kernels are covered by GitHub Actions. Locally you can still resolve another OS's command strings with --os / CROSSDEPS_OS.
Lint and format with oxlint and oxfmt. Config is .oxlintrc.json and .oxfmtrc.jsonc.
GitHub Actions (.github/workflows/ci.yml) on main and pull requests:
| Job | What it runs |
|---|---|
test |
lint + format check (Linux) + unit + consumer tests on Ubuntu, macOS, Windows |
docker |
apt / dnf / pacman install-path tests |
catalog-install |
real crossdeps install of the 24-dep catalog on those three OSes |
The published package is @lovrozagar/crossdeps. GitHub Releases match that version. Pushing a tag vX.Y.Z (same as packages/core/package.json version) runs .github/workflows/release.yml: unit + typecheck + consumer tests, npm publish via trusted publishing, GitHub Packages, then a GitHub Release.
There is no NPM_TOKEN. npm authenticates with GitHub OIDC. Configure the trusted publisher once on the package (not on honey):
npmjs.com/package/@lovrozagar/crossdeps → Settings → Trusted Publisher → GitHub Actions
| Field | Value |
|---|---|
| Publisher | GitHub Actions |
| Organization or user | lovrozagar |
| Repository | crossdeps |
| Workflow filename | release.yml |
| Environment name | empty |
| Allowed actions | Allow npm publish |
Publishing access on that page: Require two-factor authentication or a granular access token with bypass 2fa enabled. Trusted publishers still work with that option.
Ship a version:
# 1. set packages/core/package.json version to X.Y.Z and update CHANGELOG
git add packages/core/package.json packages/core/CHANGELOG.md
git commit -m "chore: release X.Y.Z"
git push origin main
# 2. tag the same version — this starts the Release workflow
git tag vX.Y.Z
git push origin vX.Y.ZYou can also run the Release workflow by hand (workflow_dispatch) and pass the existing tag (v0.1.0). The tag must match packages/core/package.json. If that version is already on npm, publish is skipped and the GitHub Release is still created.
Do not put an npm token in repo secrets. The classic 2FA-bypass token path is being restricted; OIDC is the publish path.
See packages/core/CHANGELOG.md.
MIT