#!/usr/bin/env bash # Sync the `packageManager` field of every first-party package.json to the # system pnpm version. # # Only `pnpm` and `node` are required (plus registry access to resolve the # integrity hash, unless --field is given). # # Run it from the repo root with the log redirected to a file (never pipe # tool output through filters). set -euo pipefail usage() { cat <<'EOF' Usage: scripts/sync-pnpm-version [options] [version] Stamp every first-party package.json's `packageManager` field with the system pnpm version (default: `pnpm --version`). Options: --field FIELD Use FIELD verbatim, skip the registry lookup. For tests and offline use. --check Verify all fields match; do not write. Exit 1 on drift. --install After stamping, run `pnpm install` in each workspace root to refresh lockfile metadata. --root DIR Scan DIR instead of the repo root. For tests. -h, --help Show this help. external/ (vendored trees with their own lifecycles), .opencode/, .pnpm-store/, bundles/ and docker build-context copies are never stamped. EOF } ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" FIELD="" CHECK=0 INSTALL=0 VERSION="" while [[ $# -gt 0 ]]; do case "$1" in --field) FIELD="$2"; shift 2 ;; --field=*) FIELD="${1#--field=}"; shift ;; --check) CHECK=1; shift ;; --install) INSTALL=1; shift ;; --root) ROOT="$2"; shift 2 ;; --root=*) ROOT="${1#--root=}"; shift ;; -h | --help) usage; exit 0 ;; -*) { echo "error: unknown option: $1" >&2 usage >&2 exit 64 } ;; *) if [[ -n "$VERSION" ]]; then echo "error: only one version argument allowed" >&2 usage >&2 exit 64 fi VERSION="$1"; shift ;; esac done if [[ -z "$FIELD" ]]; then if [[ -z "$VERSION" ]]; then VERSION="$(pnpm --version | tr -d '[:space:]')" fi if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-.+)?$ ]]; then echo "error: invalid pnpm version: $VERSION" >&2 exit 64 fi # Same value `npm view pnpm@ dist.integrity` returns; pnpm is the one # tool guaranteed to exist everywhere, so no npm needed. integrity="$(pnpm view "pnpm@${VERSION}" dist.integrity | tr -d '[:space:]')" b64="${integrity#sha512-}" if ! [[ "$b64" =~ ^[A-Za-z0-9+/=]+$ ]]; then echo "error: unexpected integrity for pnpm@${VERSION}: ${integrity}" >&2 exit 1 fi # node ships with every pnpm, so no python3 needed for base64 -> hex. hex="$(node -p "Buffer.from('${b64}','base64').toString('hex')")" FIELD="pnpm@${VERSION}+sha512.${hex}" fi cd "$ROOT" # Same exclusions as scripts/clean-node-modules, plus build-context copies # (bundles/, docker/images/bundle-*) which builds regenerate from the # stamped sources. mapfile -t files < <( find . \ \( -name .git -o -name node_modules -o -name .pnpm-store \ -o -name external -o -name .opencode -o -name bundles \ -o -name .angular \) -type d -prune \ -o -path './docker/images/bundle-*' -prune \ -o -name package.json -type f -print | sort ) if [[ ${#files[@]} -eq 0 ]]; then echo "error: no package.json files found under $ROOT" >&2 exit 1 fi # Stamp one file with node: replaces only the packageManager value when the # key exists (indent, key order and the rest stay byte-identical), else # inserts the key after "name" using the file's own indent. Prints # "updated " / "unchanged " / "missing " / "mismatch ". stamp_one() { node -e ' const fs = require("fs"); const [file, field, check] = process.argv.slice(1); const raw = fs.readFileSync(file, "utf8"); const data = JSON.parse(raw); const rel = file.replace(/^\.\//, ""); if (data.packageManager === field) { console.log(`unchanged ${rel}`); } else if (check === "1") { console.log(`${data.packageManager === undefined ? "missing" : "mismatch"} ${rel}`); process.exit(3); } else { let out; const valueRe = /("packageManager"\s*:\s*")[^"]*(")/; if (valueRe.test(raw)) { out = raw.replace(valueRe, `$1${field}$2`); } else { const anchor = raw.match(/^(\s*)"(name|version)"\s*:\s*"[^"]*",\s*$/m); if (!anchor) { console.error(`error: cannot find insertion point in ${rel}`); process.exit(1); } out = raw.replace( anchor[0], `${anchor[0]}\n${anchor[1]}"packageManager": "${field}",` ); } JSON.parse(out); // refuse to write invalid JSON fs.writeFileSync(file, out); console.log(`updated ${rel}`); } ' "$1" "$FIELD" "$CHECK" } failures=0 updated=0 unchanged=0 for file in "${files[@]}"; do if [[ "$CHECK" -eq 1 ]]; then if stamp_one "$file"; then unchanged=$((unchanged + 1)) else rc=$? if [[ "$rc" -eq 3 ]]; then failures=$((failures + 1)) else exit "$rc" fi fi else if [[ "$(stamp_one "$file")" == updated* ]]; then updated=$((updated + 1)) else unchanged=$((unchanged + 1)) fi fi done if [[ "$CHECK" -eq 1 ]]; then if [[ "$failures" -gt 0 ]]; then echo "check failed: ${failures} file(s) differ from ${FIELD}" >&2 exit 1 fi echo "all ${unchanged} package.json files match ${FIELD}" exit 0 fi echo "stamped ${FIELD}: ${updated} updated, ${unchanged} unchanged" if [[ "$INSTALL" -eq 1 ]]; then # The 11 pnpm workspaces: repo root plus one per module. Members resolve # through their parent workspace lockfile, so no per-member install. for dir in . backend common docs exporter frontend library mcp media-processor plugins render-wasm; do echo "--- pnpm install in ${dir}" (cd "$dir" && pnpm install) done fi