run.sh for the KCD2 Dice Solver

How one script turns "clone and pray" into "clone and run" for a Vite/React app.

Background

kcd2_dice_solver is a small Vite + React + TypeScript app: you tell it which dice and badges you own in Kingdom Come: Deliverance II, and it searches every loadout your inventory allows to recommend the best one. It's a normal Node project — a package.json with scripts for dev, build, test, and lint, run through pnpm.

Until now, running it meant reading the README and typing two commands by hand:

pnpm install
pnpm dev

That's fine if you already have the right Node version and pnpm installed and you remember the exact commands. It's not fine as a "just run it" entry point — which is what this change adds: a run.sh that does the checking and installing for you, then starts the dev server.

Intuition

The interesting part of run.sh isn't the two commands above — it's everything that has to happen before them so those two commands are safe to run unconditionally. The script has to answer two questions about the machine it's running on:

Is Node installed, and is it ≥ v22?
no load nvm, install/switch to the LTS release
yes leave it alone
Is pnpm on the PATH?
no enable Corepack, activate the latest pnpm
yes leave it alone

Only once both are true does it do the boring part: pnpm install, then pnpm dev. The project's own package.json already states the requirement ("engines": {"node": ">=22", "pnpm": ">=10"}) — the script's job is to make that requirement true rather than just documented.

Play with the simulator below: pick a starting machine state and step through exactly what run.sh would print and do.
Node on this machine
pnpm on this machine
 

Code

Guarding Node's version

ensure_node only reaches for nvm when it has to. If node resolves and its major version already clears the bar, it returns immediately — no network call, no reinstall, no surprise version switch on a machine that's already fine.

ensure_node() {
    if command -v node >/dev/null 2>&1; then
        local major
        major="$(node --version | sed -E 's/^v([0-9]+).*/\1/')"
        if (( major >= NODE_MIN_MAJOR )); then
            return
        fi
        echo "Node $(node --version) is older than required v${NODE_MIN_MAJOR}; installing a newer version via nvm..."
    else
        echo "Node not found; installing via nvm..."
    fi

    if [[ ! -s "${NVM_DIR}/nvm.sh" ]]; then
        echo "Error: nvm not found at ${NVM_DIR}/nvm.sh; install nvm first." >&2
        exit 1
    fi
    source "${NVM_DIR}/nvm.sh"
    nvm install --lts
    nvm use --lts
}

Two things worth noticing: it fails with a clear error rather than trying to bootstrap nvm itself (installing a version manager is a bigger, riskier step than the script should take silently), and it treats "too old" and "missing" as the same recovery path — both end up at nvm install --lts.

Guarding pnpm

ensure_pnpm is the same shape, one level simpler: pnpm ships inside Node via Corepack, so there's no version manager to bootstrap — just corepack enable followed by pulling the latest pnpm release.

ensure_pnpm() {
    if command -v pnpm >/dev/null 2>&1; then
        return
    fi
    echo "pnpm not found; enabling via corepack..."
    corepack enable
    corepack prepare pnpm@latest --activate
}

Loading nvm before checking, not just before installing

One subtlety lives in main, not in ensure_node: nvm.sh is sourced before ensure_node is even called.

main() {
    if [[ -s "${NVM_DIR}/nvm.sh" ]]; then
        source "${NVM_DIR}/nvm.sh"
    fi

    ensure_node
    ensure_pnpm

    cd "$SCRIPT_DIR"
    echo "Installing dependencies..."
    pnpm install

    echo "Starting dev server (http://localhost:5174)..."
    exec pnpm dev
}

A fresh, non-interactive shell (like the one a script runs in) doesn't source ~/.bashrc, so nvm's shims for switching Node versions wouldn't otherwise be in scope at all. Sourcing it up front means that if a later run needs nvm use --lts inside ensure_node, the function to call already exists.

The last line, exec pnpm dev, replaces the shell process with pnpm instead of running it as a child. That means Ctrl-C in the terminal goes straight to Vite's dev server rather than being caught by an intermediate run.sh process — one less layer between you and the thing you're trying to stop.

FunctionChecksRecovery
ensure_nodenode present and ≥ v22nvm install --lts + nvm use --lts
ensure_pnpmpnpm presentcorepack enable + corepack prepare pnpm@latest --activate

Quiz