Skip to content

Repository files navigation

@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.

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.

Table of contents

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 dependsOn install 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 dependsOn targets when you install a single name
  • support JSON/YAML/TOML config (modules only: .ts / .js / .mjs)
  • expose env.ts / exec.ts helpers as public API

Requirements

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/.

Install

npm install -D @lovrozagar/crossdeps
# or
bun add -D @lovrozagar/crossdeps

The 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-pm

A larger real catalog (24 deps) lives in examples/consumer/crossdeps.config.ts.

Agent contract

Follow these rules. They are the actual runtime, not suggestions.

  1. Config files must be named crossdeps.config.ts, crossdeps.config.js, or crossdeps.config.mjs in cwd, or passed with --config <path>.
  2. The file must export an object with a deps field. Default export is preferred. A named export also works if it has deps.
  3. defineConfig is an identity helper that types the object and defaults packageJsonPath to "package.json". You can export a plain object instead.
  4. OS targets are exactly: macos, linux-apt, linux-dnf, linux-pacman, windows.
  5. Install command resolution: per-OS string wins; else os.all; false means unavailable (overrides all); omitted key with no all means unavailable.
  6. {{name}}, {{version}}, {{major}}, {{arch}} are interpolated in os commands and check.command. {{arch}} is arm64 only when process.arch === "arm64"; every other arch is amd64.
  7. 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.-]*).
  8. install (no name) topologically sorts by dependsOn. install <name> installs only that name and does not walk dependsOn.
  9. install skips when a detected version matches the pin (latest or substring either way). Mismatch without --upgrade skips, prints the resolved binary path, and hints crossdeps install <name> --upgrade. --upgrade re-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-run never skips.
  10. Failed required deps fail install (exit 1). Failed optional deps are counted as skipped.
  11. Unavailable on this OS is not a failure.
  12. check / check <name> snapshot PATH once from a new interactive TTY (bash: $SHELL -ic with stdin /dev/null, not login-only -lc; zsh: $SHELL -lic; Windows: powershell.exe with 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. --here uses this process PATH. Spawn failure or empty PATH → this process PATH and one warning (do not crash). install / --upgrade always use this process PATH.
  13. check (all): missing required → exit 1. Version mismatch → warning, exit 0. Missing optional → warning, exit 0.
  14. check <name>: missing → exit 1 even if the dep is optional. Unavailable → exit 0. Any detected version → exit 0 (no match check).
  15. sync-pm only reads deps.bun. Skips if bun is absent or version is "latest". Rewrites package.json by string replace, relative to the config file directory.
  16. env writes ~/.zshrc or ~/.bashrc on Unix, and Documents/PowerShell/Microsoft.PowerShell_profile.ps1 on Windows. Detected paths are written unexpanded ($HOME/..., not the resolved path).
  17. On Windows, commands matching PowerShell markers run in powershell.exe. Everything else runs in cmd.exe. On Unix, commands run in /bin/bash.
  18. --os <target> sets CROSSDEPS_OS for the process. Invalid targets exit 1.
  19. Unknown CLI command → print usage, exit 1. No command → print usage, exit 0.
  20. Public library surface is only what @lovrozagar/crossdeps re-exports from index.ts. Do not import ./env.ts, ./exec.ts, or ./path.ts from the package.
  21. Circular dependsOn logs 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:

  1. --config <path> (resolved with path.resolve(cwd, path)). Missing flag value → exit 1. File does not exist → exit 1.
  2. First existing of:
    • crossdeps.config.ts
    • crossdeps.config.js
    • crossdeps.config.mjs
  3. 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.ts

Export shape

The loader import()s the file and accepts:

  1. export default defineConfig({ deps, packageJsonPath? })
  2. export default { deps, packageJsonPath? }
  3. Any named export whose value is an object with a deps field (first such value in Object.values order)
// 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-apt

It 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 / armamd64
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:

  1. If detect is a non-empty array: first existing expanded path → return the unexpanded detect string. Else if fallback is set → return fallback. Else skip this var (log, do not write).
  2. Else return value or null.
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 environment

PowerShell 7:

# android-sdk environment (managed by crossdeps)
$env:ANDROID_HOME = "$HOME/Library/Android/sdk"
$env:Path += ";$ANDROID_HOME/platform-tools"
# end android-sdk environment

Target 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 snapshot

Invocation 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-run

Unknown command:

bunx crossdeps foo
# prints USAGE
# Unknown command: foo
# exit 1

No 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.

install

All deps (crossdeps install):

  1. Detect OS.
  2. Print every dep with [required] / [optional] and mark those with no command as (not available on <os>).
  3. Sort with sortByDependencies (dependsOn first).
  4. For each dep, run the single-dep steps below.
  5. Print summary: Installed / Skipped / Unavailable / Failed.
  6. Exit 1 if Failed > 0.

One dep (crossdeps install node):

  1. Unknown name → Unknown dependency: node plus Available: …, exit 1.
  2. Run the single-dep steps. Do not install dependsOn first.
  3. Exit 1 only if that dep failed. Unavailable and skipped exit 0.

Single-dep steps

  1. Resolve the install command for the detected OS. None → log unavailable, return.
  2. If --dry-run → print dry-run: <command>, return installed (no check, no exec, no env write).
  3. If version detection returns a string that matches the pin → print Already installed (<ver>), skipping, return skipped.
  4. If a version is detected but does not match, and --upgrade is off → print Already installed (<ver>), skipping (expected <pin>) plus the resolved binary path and run: crossdeps install <name> --upgrade, return skipped.
  5. Run the command. Success → print Installed successfully, then if env is non-empty run the env writer. Re-check the version; if PATH still does not match, print a shadow warning. Return installed.
  6. 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 -ic with stdin /dev/null; zsh: $SHELL -lic; Windows: powershell.exe with 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.version
  • config.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 env

Walks 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-pm

Only 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 → null

commandExists:

  1. Strip one pair of surrounding quotes from the token.
  2. If that path existsSync, true.
  3. Else run command -v <token> (Unix) or where <token> (Windows).

Implications:

  • Check command echo android-studio finds echo on PATH, prints android-studio, regex misses, result is not installed. The check output must contain something like 1.2 / 22.12.0 / 1.0.0-beta.
  • Check command "/usr/local/bin/node" --version works because commandExists accepts an existing path.
  • A tool that prints only stable or ok is 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 (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 }

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")
// "node@22.12.0 (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")
// null

resolveCheckCommand(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 dependsOn depth-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 default

detectOs(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") // false

versionsMatch(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") // true

whichBinary(command, platform?)

whichBinary("sh") // "/usr/bin/sh"
whichBinary("crossdeps-not-real") // null

Worked 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: nodenpmclaude.

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).
  • check counts any detected version as OK.
  • If this were bun, sync-pm would 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 ~/.zshrc

Unavailable 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: flux@2.7.11 — not available on linux-apt. Install and check succeed.

Dry-run on another OS command set

bunx crossdeps install --os windows --dry-run

Prints 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.ts

sync-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-pm

sync-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.

Repository layout

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.

Develop

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:check

Docker 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

Lint and format with oxlint and oxfmt. Config is .oxlintrc.json and .oxfmtrc.jsonc.

CI

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

Releases

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.Z

You 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.

Changelog

See packages/core/CHANGELOG.md.

License

MIT

About

Cross OS system dependency manager for teams. npm i -D @lovrozagar/crossdeps

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages