#!/bin/bash # Copyright © Cloud Linux GmbH & Cloud Linux Software, Inc 2010-2026 All Rights Reserved # # Licensed under CLOUD LINUX LICENSE AGREEMENT # http://cloudlinux.com/docs/LICENSE.TXT # # User-side half of the bun install-time delivery, invoked through # cagefs_enter_user by clselector/bun_delivery_hook.py. Normal delivery has # three phases; explicit migration uses isolated resolve/rebuild variants: # # prepare canonicalize active lockfile inputs; conflicts refuse # prepare-classic preserve divergent legacy prefix inputs for plain npm # resolve bun install --lockfile-only -- the account's own registries # and .npmrc; no package code runs # rebuild npm rebuild -- lifecycle scripts and node-gyp, # as the user, venv node on PATH # # The venv's activate must be sourced first: without it the venv node is # not on PATH and .bin shims that exec a bare `node` die inside the cage. set -u phase="${1:-}" app_root="${2:-}" # Overridable so the phase can be driven against a stub in tests. No # privilege implication: this half already runs as the account. BUN="${CL_BUN_BIN:-/opt/cl-bun/bun}" # Refuse before creating/changing any application inputs. Preparation, # rebuild and cleanup do not use Bun and must remain available without it. if [[ "$phase" == resolve || "$phase" == migration-resolve ]]; then if [[ ! -f "$BUN" || ! -x "$BUN" ]]; then echo 'Shared node_modules Store requires a usable cl-bun engine. Ask the administrator to run cl-node-modules-storage enable to restore it, or cl-node-modules-storage disable to use classic npm.' >&2 exit 1 fi fi CL_PYTHON=/opt/cloudlinux/venv/bin/python3 SHRINKWRAP_UNSUPPORTED='npm-shrinkwrap.json is not supported by Shared node_modules Store; use classic npm' PACKAGE_LOCK_V1_UNSUPPORTED='package-lock.json lockfileVersion 1 is not supported by Shared node_modules Store; run classic npm install first' NO_DEPENDENCIES='the application has no dependencies to share' # Separates the fatal entries of .bun-buildable from the tolerated ones. A # valid npm name no tree carries, so an older phase reading a newer file # rebuilds the whole list fatally, exactly as it did before. BUILDABLE_OPTIONAL_SECTION='cl-bun-optional-names' MIGRATION_CLEANER="${MIGRATION_CLEANER:-/usr/share/l.v.e-manager/utils/cloudlinux_bun_migration_cleanup.py}" copy_regular_nofollow() { /opt/cloudlinux/venv/bin/python3 - "$1" "$2" "$3" <<'PY' import os import errno import stat import sys source, target, label = sys.argv[1:] source_fd = target_fd = None created = False try: try: source_fd = os.open(source, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW) except OSError as error: if error.errno == errno.ELOOP: raise OSError('%s is not a regular file' % label) raise if not stat.S_ISREG(os.fstat(source_fd).st_mode): raise OSError('%s is not a regular file' % label) target_fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) created = True while True: chunk = os.read(source_fd, 1024 * 1024) if not chunk: break offset = 0 while offset < len(chunk): offset += os.write(target_fd, chunk[offset:]) except OSError as error: sys.stderr.write('migration %s\n' % error) if created: try: os.unlink(target) except OSError: pass sys.exit(1) finally: if target_fd is not None: os.close(target_fd) if source_fd is not None: os.close(source_fd) PY } package_lock_state() { "$CL_PYTHON" - "$1" <<'PY' import errno import json import os import stat import sys path = sys.argv[1] maximum = 4 * 1024 * 1024 def no_duplicates(pairs): result = {} for key, value in pairs: if key in result: raise ValueError('duplicate JSON key') result[key] = value return result fd = None try: try: fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW) except OSError as error: if error.errno == errno.ENOENT: sys.exit(0) raise info = os.fstat(fd) if not stat.S_ISREG(info.st_mode) or info.st_size > maximum: raise ValueError('package-lock.json is not a bounded regular file') chunks = [] remaining = maximum + 1 while remaining: chunk = os.read(fd, min(1024 * 1024, remaining)) if not chunk: break chunks.append(chunk) remaining -= len(chunk) data = b''.join(chunks) if len(data) > maximum: raise ValueError('package-lock.json is too large') lock = json.loads(data.decode('utf-8'), object_pairs_hook=no_duplicates) if not isinstance(lock, dict): raise ValueError('package-lock.json is not an object') version = lock.get('lockfileVersion') if type(version) is not int or version not in (1, 2, 3): raise ValueError('unsupported package-lock.json lockfileVersion') sys.exit(3 if version == 1 else 0) except (OSError, UnicodeError, ValueError, RecursionError) as error: sys.stderr.write('cannot inspect package-lock.json: %s\n' % error) sys.exit(2) finally: if fd is not None: os.close(fd) PY } unsupported_manifest_field() { # Print the first package.json field the Store cannot deliver, and exit 3. # The root validator refuses the same fields, but bun resolves first, on a # scratch directory that has none of the account's patch files: the account # was told to "run bun patch" for a feature that would have been refused # anyway. Read the manifest bounded, with duplicate keys rejected, the way # package_lock_state reads the lock. A symlinked manifest is named as the # token `symlink`: bun would follow it, but the privileged phase never # does, so it is classic npm territory. A manifest that cannot even be # opened is the root validator's business, not this pre-check's. "$CL_PYTHON" - "$1" <<'PY' import json import os import stat import sys path = sys.argv[1] maximum = 4 * 1024 * 1024 unsupported = ('patchedDependencies', 'workspaces') def no_duplicates(pairs): result = {} for key, value in pairs: if key in result: raise ValueError('duplicate JSON key') result[key] = value return result fd = None try: # The privileged phase reads this same file through a component-wise # O_NOFOLLOW walk (B4) and refuses a link; letting a linked manifest # through here only moved that failure one phase later. Name it now. try: if stat.S_ISLNK(os.lstat(path).st_mode): sys.stdout.write('symlink') sys.exit(3) except OSError: pass try: fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK) except OSError: # No opinion: nothing was read, so nothing is refused here. sys.exit(0) info = os.fstat(fd) if not stat.S_ISREG(info.st_mode) or info.st_size > maximum: raise ValueError('package.json is not a bounded regular file') chunks = [] remaining = maximum + 1 while remaining: chunk = os.read(fd, min(1024 * 1024, remaining)) if not chunk: break chunks.append(chunk) remaining -= len(chunk) data = b''.join(chunks) if len(data) > maximum: raise ValueError('package.json is too large') manifest = json.loads(data.decode('utf-8'), object_pairs_hook=no_duplicates) if not isinstance(manifest, dict): raise ValueError('package.json is not an object') for field in unsupported: if field in manifest: sys.stdout.write(field) sys.exit(3) sys.exit(0) except (OSError, UnicodeError, ValueError, RecursionError) as error: sys.stderr.write('cannot inspect package.json: %s\n' % error) sys.exit(2) finally: if fd is not None: os.close(fd) PY } # The administrator migration must resolve without changing the active # application symlink, venv package.json, lockfile, or cache. Its only # account-side write is a transaction-named scratch directory below HOME. # Handle it before the normal venv setup, which deliberately maintains those # live links for ordinary delivery. if [ "$phase" = "migration-resolve" ] || [ "$phase" = "migration-clean" ]; then transaction="${4:-}" [ -n "$app_root" ] || { echo "missing application root" >&2; exit 2; } [[ "$transaction" =~ ^\.cl-bun-migration-[0-9a-f]{32}$ ]] || { echo "invalid migration transaction" >&2; exit 2; } scratch_root="$HOME/.cl-bun-migration" scratch="$scratch_root/$transaction" if [ "$phase" = "migration-clean" ]; then exec "$MIGRATION_CLEANER" "$HOME" "$transaction" exit $? fi umask 077 mkdir -p -- "$scratch_root" || exit 1 [ ! -e "$scratch" ] || { echo "migration scratch exists" >&2; exit 1; } mkdir -- "$scratch" || exit 1 copy_regular_nofollow "$HOME/$app_root/package.json" \ "$scratch/package.json" package.json || exit 1 # Bun resolves the existing npm/yarn pins when they are present. Keep a # root-side migration semantically equivalent to the application's next # install without modifying any live application input. Symlinks are not # copied: migration must not follow a caller-controlled detour out of the # application directory. for lock_input in package-lock.json npm-shrinkwrap.json yarn.lock; do app_lock="$HOME/$app_root/$lock_input" venv_lock="$HOME/nodevenv/$app_root/$3/lib/$lock_input" app_present=0 venv_present=0 if [ -e "$app_lock" ] || [ -L "$app_lock" ]; then [ -f "$app_lock" ] && [ ! -L "$app_lock" ] || { echo "migration lock input is not a regular file: $lock_input" >&2; exit 1; } app_present=1 fi if [ -L "$venv_lock" ]; then # Normal prepare makes this exact absolute alias, including a dangling # one when npm has not yet written a lock. It is only another name for # the app-root input, never a second migration source. [ "$(readlink "$venv_lock")" = "$app_lock" ] || { echo "migration venv lock input is not a regular file: $lock_input" >&2; exit 1; } elif [ -e "$venv_lock" ]; then [ -f "$venv_lock" ] || { echo "migration venv lock input is not a regular file: $lock_input" >&2; exit 1; } venv_present=1 fi if [ "$app_present" -eq 1 ] && [ "$venv_present" -eq 1 ]; then cmp -s -- "$app_lock" "$venv_lock" || { conflict="migration lock input conflicts with active venv: $lock_input" printf '%s\n' "$conflict" > "$scratch/diagnostic" echo "$conflict" >&2 exit 1 } fi if [ "$lock_input" = npm-shrinkwrap.json ] \ && { [ "$app_present" -eq 1 ] || [ "$venv_present" -eq 1 ]; }; then printf '%s\n' "$SHRINKWRAP_UNSUPPORTED" > "$scratch/diagnostic" echo "$SHRINKWRAP_UNSUPPORTED" >&2 exit 1 fi if [ "$app_present" -eq 1 ]; then copy_regular_nofollow "$app_lock" "$scratch/$lock_input" \ "$lock_input" || exit 1 elif [ "$venv_present" -eq 1 ]; then copy_regular_nofollow "$venv_lock" "$scratch/$lock_input" \ "$lock_input" || exit 1 fi done package_lock_state "$scratch/package-lock.json" package_lock_status=$? if [ "$package_lock_status" -eq 3 ]; then printf '%s\n' "$PACKAGE_LOCK_V1_UNSUPPORTED" > "$scratch/diagnostic" echo "$PACKAGE_LOCK_V1_UNSUPPORTED" >&2 exit 3 elif [ "$package_lock_status" -ne 0 ]; then exit 1 fi cd "$scratch" || exit 1 "$BUN" install --lockfile-only --no-progress \ --cache-dir="$scratch/.bun-cache" 2>&1 | tail -c 2000 > "$scratch/diagnostic" resolve_status="${PIPESTATUS[0]}" # Zero packages means bun wrote no lockfile, so there is nothing for the # privileged phase to validate. Migration is an explicit conversion, not # an install, so it takes its own typed refusal here rather than the # classic-npm route the install path takes. if [ "$resolve_status" -eq 0 ] && [ ! -f bun.lock ]; then printf '%s\n' "$NO_DEPENDENCIES" > "$scratch/diagnostic" echo "$NO_DEPENDENCIES" >&2 exit 3 fi exit "$resolve_status" fi dedupe_list() { # Reads `dedupe_input`, writes `dedupe_output`, order preserving. # # `printf … | sort -u` would have to come back through a process # substitution, and the cage has no /dev/fd: there the redirect fails, # `mapfile` never runs, and the array keeps what it already held. Both # lists here are populated before this point, so the effect was a # redundant per-instance loop rather than a missing one -- but the # construct cannot work in a cage at all, which is why it is banned # outright rather than worked around. # A here-string would only trade that for a writable TMPDIR. Bash alone # needs neither. Order is first-occurrence, not `sort -u`'s: root sorts # the file by path and this runs after stripping to install names, which # reorders. Nothing depends on it -- one `npm rebuild` takes the required # names as arguments, and the optional ones drive an independent loop. # # Quadratic in the number of buildable packages, which the tree walk # already bounds and which is a handful in practice. local candidate seen duplicate dedupe_output=() for candidate in ${dedupe_input[@]+"${dedupe_input[@]}"}; do duplicate=0 for seen in ${dedupe_output[@]+"${dedupe_output[@]}"}; do [ "$seen" = "$candidate" ] || continue duplicate=1 break done [ "$duplicate" -eq 0 ] && dedupe_output+=("$candidate") done # Without this the function returns 1 whenever the *last* element was a # duplicate, which no caller checks today and one would eventually. return 0 } drop_optional_package() { # npm parity: an optional dependency whose install hook fails is removed # and the install carries on. The package is a private copy -- the root # phase detaches every buildable package before hand-over -- so this # removes no inode the shared store still owns. # # Returns non-zero if the directory survives: leaving an unbuilt native # package behind while reporting success would commit a tree the account # cannot use. local path="$1" install_name parent container target shim walked component \ rest ancestors # `.bin` belongs to the `node_modules` container, never to a scope # directory inside it, so the container is what is left once the whole # install name -- scope included -- is taken off the end. install_name="${path##*/node_modules/}" parent="${path%"$install_name"}" parent="${parent%/}" container="$lib/node_modules" [ -n "$parent" ] && container="$lib/node_modules/$parent" # `rm -rf` resolves intermediate components, so a symlinked ancestor # would delete outside the tree. Root's walk is O_NOFOLLOW at every # component and can never emit such a path -- this is defence in depth, # and what the "re-check rather than trust the file" claim beside the # caller needs to be true. The leaf is not checked: `rm` unlinks a # symlink there and leaves its target alone. # Every component except the final one, so a scoped leaf's `@scope` # directory is walked too: it sits between the container and the leaf, # and `$parent` -- the path minus the *whole* install name -- skips it. # The leaf itself is deliberately excluded: `rm` unlinks a symlink there # and leaves its target, which is what npm would do. # # Parameter expansion rather than word splitting on IFS: an unquoted # `$ancestors` would also be glob-expanded, and a package may # legitimately be called `a*b`. ancestors="${path%/*}" [ "$ancestors" = "$path" ] && ancestors="" walked="$lib/node_modules" # The walk's own starting point counts: a symlinked `node_modules` takes # every removal outside the venv however clean the components below it. if [ -L "$walked" ]; then echo "Shared node_modules Store: refusing to remove optional" \ "dependency $path through a symbolic link at node_modules" >&2 return 1 fi rest="$ancestors" while [ -n "$rest" ]; do component="${rest%%/*}" if [ "$component" = "$rest" ]; then rest=""; else rest="${rest#*/}"; fi [ -n "$component" ] || continue walked="$walked/$component" if [ -L "$walked" ]; then echo "Shared node_modules Store: refusing to remove optional" \ "dependency $path through the symbolic link $component" >&2 return 1 fi done rm -rf -- "$lib/node_modules/$path" # `-e` follows the link, so a dangling symlink left at that path would # read as removed while node still resolves it. `-L` catches that. if [ -e "$lib/node_modules/$path" ] || [ -L "$lib/node_modules/$path" ]; then echo "Shared node_modules Store: cannot remove optional dependency" \ "$path after its build failed" >&2 return 1 fi # Only this package's own shims go. Another dangling link in the same # .bin is the account's business: we were asked to drop one package. if [ -d "$container/.bin" ]; then for shim in "$container/.bin"/*; do [ -L "$shim" ] || continue target="$(readlink -- "$shim")" || continue # `../lmdb/../other/cli.js` opens with the package's own prefix and # then leaves it, so anything that walks back out is not ours. case "$target" in *"/../"*|*/..) continue ;; esac case "$target" in "../$install_name"|"../$install_name"/*) rm -f -- "$shim" ;; esac done fi return 0 } migration_rebuild=0 migration_scratch="" classic_prepare=0 classic_conflict=0 if [ "$phase" = "migration-rebuild" ]; then transaction="${4:-}" [[ "$transaction" =~ ^\.cl-bun-migration-[0-9a-f]{32}$ ]] || { echo "invalid migration transaction" >&2; exit 2; } migration_scratch="$HOME/.cl-bun-migration/$transaction" [ -d "$migration_scratch" ] || { echo "migration scratch missing" >&2; exit 1; } migration_rebuild=1 phase=rebuild elif [ "$phase" = "prepare-classic" ]; then # Existing applications can legitimately have a Git-managed lock in the # application root and a different lock written by the historical # prefix-based npm path. Plain npm must remain available after upgrade, # while Shared Store delivery continues to reject that ambiguity. classic_prepare=1 phase=prepare fi # npm invokes --script-shell as ` -c `. Re-entering this # script only for that form lets npm suppress automatic companion hooks while # the application command itself sees its normal npm configuration. if [ "$phase" = "-c" ]; then unset npm_config_ignore_scripts exec "${CL_BUN_NPM_SCRIPT_SHELL:-/bin/sh}" "$@" fi major="${3:-}" [ -n "$phase" ] && [ -n "$app_root" ] || { echo "usage: $0 [node-major]" >&2; exit 2; } DELIVERY_CLIENT=/usr/share/l.v.e-manager/utils/cloudlinux_bun_client.py # The delivery request needs neither a venv nor bun -- it is one sentence to # a socket -- so it is answered before the interpreter discovery below, # which would otherwise fail on an application the account has not built. if [ "$phase" = "deliver" ]; then [ -x "$CL_PYTHON" ] || { echo "python runtime unavailable" >&2; exit 2; } exec "$CL_PYTHON" "$DELIVERY_CLIENT" "$app_root" fi # Which venv this install belongs to. A Node.js version change leaves the # old `/` beside the new one, so a listing of the directory # has two answers and the lexically first is not the current one. The # answer comes from whoever already knows: npm_wrapper's environment # (`CL_VIRTUAL_ENV`, set by set_env_vars for exactly this application) or # the panel hook's argument (the selector's recorded major). Only without # either does the listing decide, and only when it has one entry. venv_base="$HOME/nodevenv/$app_root" if [ -n "${CL_VIRTUAL_ENV:-}" ] && [ -d "$CL_VIRTUAL_ENV" ]; then venv="$CL_VIRTUAL_ENV" elif [ -n "$major" ]; then venv="$venv_base/$major" else # A glob loop rather than mapfile over a process substitution: the cage # has no /dev/fd, and an empty array is fatal under `set -u` on bash 4.2. found="" count=0 for candidate in "$venv_base"/[0-9]*; do [ -d "$candidate" ] || continue found="$found ${candidate##*/}" count=$((count + 1)) venv="$candidate" done if [ "$count" -ne 1 ]; then echo "cannot tell which venv of $app_root to use: found${found:- none}" >&2 exit 1 fi fi [ -d "$venv" ] || { echo "no venv for $app_root at $venv" >&2; exit 1; } lib="$venv/lib" prepare_app_lockfile() { local name="$1" mode="$2" app_lock lib_lock app_kind lib_kind app_lock="$HOME/$app_root/$name" lib_lock="$lib/$name" if [[ -L "$app_lock" && "$classic_prepare" -eq 1 ]]; then # A linked lockfile is the account's own layout and plain npm reads it # as it always has. Keep the whole historical layout, like the # divergent-copies case below: refusing here left such an application # unable to `npm install` at all, even after `disable`. classic_conflict=1 return 0 elif [[ -L "$app_lock" || ( -e "$app_lock" && ! -f "$app_lock" ) ]]; then echo "refusing unexpected app lockfile: $app_lock" >&2 return 1 elif [[ -f "$app_lock" ]]; then app_kind=regular else app_kind=absent fi if [[ -L "$lib_lock" ]]; then if [[ "$(readlink "$lib_lock")" == "$app_lock" ]]; then lib_kind=linked else echo "refusing unexpected venv lockfile link: $lib_lock" >&2 return 1 fi elif [[ -e "$lib_lock" && ! -f "$lib_lock" ]]; then echo "refusing unexpected venv lockfile: $lib_lock" >&2 return 1 elif [[ -f "$lib_lock" ]]; then lib_kind=regular else lib_kind=absent fi case "$app_kind:$lib_kind" in absent:regular) [ "$mode" = preflight ] && return 0 mv -- "$lib_lock" "$app_lock" || return 1 ;; regular:regular) if ! cmp -s -- "$app_lock" "$lib_lock"; then if [ "$classic_prepare" -eq 1 ]; then classic_conflict=1 return 0 fi echo "conflicting lock files: $app_lock and $lib_lock" >&2 return 1 fi [ "$mode" = preflight ] && return 0 rm -f -- "$lib_lock" || return 1 ;; regular:absent) [ "$mode" = preflight ] && return 0 ;; absent:absent) return 0 ;; regular:linked) return 0 ;; absent:linked) [ "$mode" = preflight ] && return 0 # Older wrappers created this exact dangling alias. npm 10 refuses # to open it, so let npm materialize a regular prefix lock and # canonicalize that file after the command succeeds. rm -f -- "$lib_lock" || return 1 return 0 ;; *) echo "refusing unexpected lockfile state: $app_lock $lib_lock" >&2 return 1 ;; esac ln -s -- "$app_lock" "$lib_lock" } prepare_app_lockfiles() { local lockfile for lockfile in package-lock.json npm-shrinkwrap.json yarn.lock; do prepare_app_lockfile "$lockfile" preflight || return 1 done # Preserve the complete historical lockfile layout when any pair is # ambiguous. This keeps classic npm compatible without partly # canonicalizing unrelated inputs before the command runs. [ "$classic_conflict" -eq 1 ] && return 0 for lockfile in package-lock.json npm-shrinkwrap.json yarn.lock; do prepare_app_lockfile "$lockfile" apply || return 1 done } # The selector's activate script references unset variables, which is # fatal under `set -u` -- relax it for the duration of the source only. set +u # shellcheck disable=SC1090 source "$venv/bin/activate" 2>/dev/null || true set -u # Both package managers work with the venv as their directory while the # manifest lives in the application root, so the venv has to point at it. # npm_wrapper does this before every npm run, and a freshly created # application has only bin/ -- no lib/, no link. Measured without this on a # new account: `cd /lib` fails, resolve exits 1, the hook falls back # to plain npm, and that npm run creates both -- so delivery never covered # an account's *first* install, the one install the shared cache is most # likely to serve in full. Idempotent, and done here rather than root-side # on purpose: this phase already runs as the account, so linking inside its # own home stays an ordinary unprivileged operation. if [ "$migration_rebuild" -eq 0 ]; then # Classic npm mutations call `prepare` under their app lock. It needs a # place for lockfile aliases, but must not touch the application's active # node_modules tree (update/uninstall/rebuild operate on that tree). mkdir -p "$lib" || { echo "cannot create $lib" >&2; exit 1; } if [ "$phase" = resolve ]; then # Engine incapability like a linked package.json, and the same route: # delivery canonicalises these names and must not follow or replace the # account's link, so name it before preparation refuses it outright. for linked_lock in package-lock.json npm-shrinkwrap.json yarn.lock; do if [ -L "$HOME/$app_root/$linked_lock" ]; then printf '%s\n' 'cl-bun:unsupported-manifest symlink' echo "Shared node_modules Store: $linked_lock is a symbolic link," \ "which is not supported; using classic npm" >&2 exit 3 fi done fi prepare_app_lockfiles || exit 1 if [ "$phase" = prepare ]; then exit 0 fi mkdir -p "$lib/node_modules" || { echo "cannot create $lib/node_modules" >&2; exit 1; } ln -sf "$HOME/$app_root/package.json" "$lib/package.json" || { echo "cannot link package.json into $lib" >&2; exit 1; } # Node resolves modules from /node_modules, while the tree we # deliver lives in the venv. npm_wrapper links the two on every install -- # and the panel path, the only entry point this feature supports, never # reaches the wrapper. Without this a delivered application cannot load a # single dependency: measured on an application installed only through the # delivery, `require('express')` fails with MODULE_NOT_FOUND while the tree # sits complete in the venv. app_node_modules="$HOME/$app_root/node_modules" if [ -L "$app_node_modules" ]; then [ "$(readlink "$app_node_modules")" = "$lib/node_modules" ] || { echo "$app_node_modules exists and is not the selected venv link" >&2; exit 1; } elif [ -e "$app_node_modules" ]; then # Same rule npm_wrapper enforces: a real directory there is the account's # own tree and must not be silently replaced. Refusing drops the install # onto the ordinary npm path, which reports the same condition. echo "$app_node_modules exists and is not a symlink" >&2; exit 1 fi if [ ! -e "$app_node_modules" ] && [ ! -L "$app_node_modules" ]; then ln -s "$lib/node_modules" "$app_node_modules" || { echo "cannot link node_modules into $HOME/$app_root" >&2; exit 1; } fi fi cd "$lib" || exit 1 migration_command() { if [ "$migration_rebuild" -eq 1 ]; then "$@" 2>&1 | tail -c 2000 > "$migration_scratch/diagnostic" return "${PIPESTATUS[0]}" fi "$@" >/dev/null 2>&1 } rebuild_env_state="$lib/.cl-bun-rebuild-env" is_rebuild_env_name() { case "$1" in npm_config_build_from_source|npm_config_arch|npm_config_platform|\ npm_config_target|npm_config_runtime|npm_config_nodedir|npm_config_devdir|\ npm_config_jobs|npm_config_python|npm_config_node_gyp|\ NPM_CONFIG_BUILD_FROM_SOURCE|NPM_CONFIG_ARCH|NPM_CONFIG_PLATFORM|\ NPM_CONFIG_TARGET|NPM_CONFIG_RUNTIME|NPM_CONFIG_NODEDIR|NPM_CONFIG_DEVDIR|\ NPM_CONFIG_JOBS|NPM_CONFIG_PYTHON|NPM_CONFIG_NODE_GYP|\ CC|CXX|CPP|AR|LD|MAKE|PYTHON|NODE_GYP_FORCE_PYTHON) return 0 ;; esac return 1 } save_rebuild_environment() { local name temporary temporary="$rebuild_env_state.$$" (umask 077; set -o noclobber; : > "$temporary") || return 1 for name in $(compgen -e); do if is_rebuild_env_name "$name"; then printf '%s=%s\0' "$name" "${!name}" >> "$temporary" || { rm -f -- "$temporary" return 1 } fi done if [ -e "$rebuild_env_state" ] || [ -L "$rebuild_env_state" ]; then [ -f "$rebuild_env_state" ] && [ ! -L "$rebuild_env_state" ] || { rm -f -- "$temporary" return 1 } fi mv -f -- "$temporary" "$rebuild_env_state" } restore_rebuild_environment() { local entry name [ -f "$rebuild_env_state" ] && [ ! -L "$rebuild_env_state" ] || return 0 trap 'rm -f -- "$rebuild_env_state"' EXIT while IFS= read -r -d '' entry; do [[ "$entry" == *=* ]] || continue name="${entry%%=*}" is_rebuild_env_name "$name" || continue export "$entry" done < "$rebuild_env_state" } case "$phase" in resolve) if [ -f "$HOME/$app_root/npm-shrinkwrap.json" ] \ && [ ! -L "$HOME/$app_root/npm-shrinkwrap.json" ]; then # Engine incapability like a v1 lockfile or an unsupported manifest # field, and the same route: exit 3 with its own sentinel and classic # npm installs. A published CLI pins its tree this way, and refusing # left such an application unable to install at all -- while being # told to use the npm that never ran. Migration keeps its own typed # refusal, which is an explicit conversion, not an install. printf '%s\n' 'cl-bun:unsupported-npm-shrinkwrap' echo "Shared node_modules Store: npm-shrinkwrap.json is not" \ "supported; using classic npm" >&2 exit 3 fi package_lock_state "$HOME/$app_root/package-lock.json" package_lock_status=$? if [ "$package_lock_status" -eq 3 ]; then printf '%s\n' 'cl-bun:legacy-package-lock-v1' exit 3 elif [ "$package_lock_status" -ne 0 ]; then exit 1 fi # Name the refusal here rather than letting bun fail first on a manifest # feature the root validator would refuse anyway. A manifest that could # not be read names no field and stays an ordinary failure. unsupported_field="$(unsupported_manifest_field "$HOME/$app_root/package.json")" unsupported_status=$? if [ "$unsupported_status" -eq 3 ]; then # Engine incapability, not a delivery failure: npm workspaces is # mainstream and a monorepo root's bare `npm install` is exactly what # the wrapper routes here. Take the `lockfileVersion 1` route -- exit # 3, named by its own sentinel, and classic npm installs. printf '%s %s\n' 'cl-bun:unsupported-manifest' "$unsupported_field" if [ "$unsupported_field" = symlink ]; then echo "Shared node_modules Store: package.json is a symbolic link," \ "which privileged delivery does not follow; using classic npm" >&2 else echo "Shared node_modules Store: package.json field" \ "$unsupported_field is not supported; using classic npm" >&2 fi exit 3 elif [ "$unsupported_status" -ne 0 ]; then exit 1 fi # The privileged delivery starts rebuild in a fresh account cage while # retaining the old tree for rollback. Preserve only non-secret native # build controls from the originating npm environment across that phase # boundary; registry credentials and arbitrary account variables never # enter this file. save_rebuild_environment || exit 1 # Scratch state stays inside the venv, not in the account's dotfiles: # the shared cache is deliberately unreachable from the cage. # # Not `exec`: bun's own diagnosis (a registry error, a typo, an auth # failure) is the only thing that tells the account why resolution # failed, so the output is captured -- bounded by `tail` -- and echoed # on failure. This half already runs as the account, so echoing its own # bun output leaks no privileged state. stdout stays clean because the # wrapper and the hook parse it for the phase sentinels above. resolve_output="$(set -o pipefail "$BUN" install --lockfile-only --no-progress \ --cache-dir="$lib/.bun-cache" 2>&1 | tail -c 2000)" resolve_status=$? if [ "$resolve_status" -ne 0 ]; then # Same reasoning as the no-dependencies route below: no exit that # leaves this install to classic npm may leave native build settings # in the venv for a rebuild that never comes. rm -f -- "$rebuild_env_state" echo 'Shared node_modules Store: dependency resolution failed:' >&2 [ -n "$resolve_output" ] && printf '%s\n' "$resolve_output" >&2 exit "$resolve_status" fi # Bun writes no lockfile at all when the manifest resolves to zero # packages, and deletes one it wrote earlier ("No packages! Deleted # empty lockfile"). There is then nothing for the privileged phase to # read, and it used to fail the install on the missing file -- which is # what the panel's "Run NPM Install" does on a freshly created # application. Same route as the other inputs this engine cannot serve: # exit 3 with its own sentinel, and classic npm performs the no-op # install. A tree delivered before losing its last dependency is # detached by the wrapper's classic path, as on every other exit 3. if [ ! -f bun.lock ]; then # The other three sentinels exit before `save_rebuild_environment` # and so leave nothing behind. This one cannot: an empty resolution # is what reveals the case, and that is only knowable once bun has # run. So it clears the file it already wrote, rather than leaving # native-build settings in the venv for a rebuild this route never # reaches -- classic npm installs from here. rm -f -- "$rebuild_env_state" printf '%s\n' 'cl-bun:no-dependencies' echo "Shared node_modules Store: the application has no dependencies" \ "to share; using classic npm" >&2 exit 3 fi exit 0 ;; rebuild) # This phase already runs inside npm_wrapper's app lock. Use the real # alt-node npm directly so a manual `npm rebuild` can be protected by the # wrapper without the internal lifecycle work trying to lock/unshare its # own freshly delivered tree again. NPM="${CL_NODEHOME:-}/usr/bin/npm" [ -x "$NPM" ] || { echo "node npm unavailable at $NPM" >&2; exit 1; } if [ "$migration_rebuild" -eq 0 ]; then restore_rebuild_environment fi # npm parity: the npm path runs dependency lifecycle scripts # unconditionally, and here they run as the user. The root phase # leaves the short list of packages that actually carry an install # hook; rebuilding the whole tree instead is the dominant cost of the # flow. An absent list means "unknown", so rebuild everything. if [ -f "$lib/.bun-buildable" ]; then mapfile -t entries < "$lib/.bun-buildable" # Everything before the section header must rebuild; everything after # it is an optional dependency, which npm itself drops rather than # failing the install when its hook fails. A file written before the # section existed has no header, so every entry stays required. pkgs=() optional_paths=() in_optional=0 # `${a[@]+"${a[@]}"}`: bash 4.2 (CL7) treats "${a[@]}" of an empty # array as unbound, and this script runs under `set -u`. `entries` is # empty for an empty manifest and `optional_paths` whenever there is # no optional section, which is the ordinary case. for entry in ${entries[@]+"${entries[@]}"}; do if [ "$in_optional" -eq 0 ] && [ "$entry" = "$BUILDABLE_OPTIONAL_SECTION" ]; then in_optional=1 continue fi if [ "$in_optional" -eq 1 ]; then # Root produced these paths from its own walk, but this phase # deletes by them, so re-check them here rather than trust the # file: relative, and no parent traversal. case "$entry" in ''|/*|*/../*|../*|*/..) echo "unsafe buildable entry: $entry" >&2; exit 1 ;; ..) echo "unsafe buildable entry: $entry" >&2; exit 1 ;; esac optional_paths+=("$entry") else pkgs+=("$entry") fi done if [ "${#pkgs[@]}" -ne 0 ]; then # The root phase records paths for private copies. npm 6 does not # rebuild directory arguments; names select every installed version. # Keep @scope/name intact. All buildable instances are private now. for i in "${!pkgs[@]}"; do pkgs[i]="${pkgs[i]##*/node_modules/}" done dedupe_input=(${pkgs[@]+"${pkgs[@]}"}) dedupe_list pkgs=(${dedupe_output[@]+"${dedupe_output[@]}"}) migration_command "$NPM" rebuild -- "${pkgs[@]}" || exit $? fi # Required first, then the tolerated names. npm builds one dependency # graph in its own order; this cannot, because a required failure has # to stop the transaction before anything optional is removed. A # package that needs an optional sibling built first would therefore # differ from npm -- none is known, and npm does not promise that # order either. # Grouped by install name, and one `npm rebuild` per name: a name # selector is name-wide -- it rebuilds every instance carrying it -- # so a failure cannot be attributed to one instance. npm 8 and 10 do # accept a `./path` spec that would select just one, but npm 6 does # not, and this has to behave the same on every major we deliver to. # Root only tolerates a name when *every* buildable instance of it is # optional (`_required_buildable`), so dropping all of them removes # nothing npm would have kept, and never leaves a broken instance # behind. One rebuild per name also keeps a single failure from # hiding every later optional package. optional_names=() for optional_path in ${optional_paths[@]+"${optional_paths[@]}"}; do optional_names+=("${optional_path##*/node_modules/}") done dedupe_input=(${optional_names[@]+"${optional_names[@]}"}) dedupe_list optional_names=(${dedupe_output[@]+"${dedupe_output[@]}"}) for optional_name in ${optional_names[@]+"${optional_names[@]}"}; do migration_command "$NPM" rebuild -- "$optional_name" && continue for optional_path in ${optional_paths[@]+"${optional_paths[@]}"}; do [ "${optional_path##*/node_modules/}" = "$optional_name" ] || continue drop_optional_package "$optional_path" || exit 1 done echo "Shared node_modules Store: optional dependency $optional_name" \ "failed to build and was removed; the application runs" \ "without it" >&2 done else migration_command "$NPM" rebuild || exit $? fi # `npm rebuild` replays dependency hooks but not this application's # install lifecycle. Only invoke declared lifecycle events so a pure-JS # application keeps the no-npm shortcut. JSON parsing is done by the # venv node already required for npm; the emitted names come from this # fixed list, not from the manifest. lifecycle_events=$(node -e ' const fs = require("fs"); const events = ["preinstall", "install", "postinstall", "prepublish", "preprepare", "prepare", "postprepare"]; const manifest = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); const scripts = manifest.scripts; if (scripts && typeof scripts === "object" && !Array.isArray(scripts)) { for (const event of events) { if (typeof scripts[event] === "string") console.log(event); } } ' "$lib/package.json") || exit $? [ -n "$lifecycle_events" ] || exit 0 # `--ignore-scripts` makes npm run only the explicitly requested event, # rather than also running pre/post companions. npm passes # that config into the script environment, though, so re-enter this # script through its documented --script-shell hook to clear it before # the application command starts. Preserve a user-configured script-shell # (or npm's /bin/sh default) for the command itself. lifecycle_shell=$("$NPM" config get script-shell 2>/dev/null) || exit $? case "$lifecycle_shell" in ''|null|undefined) lifecycle_shell=/bin/sh ;; esac for lifecycle_script in $lifecycle_events; do if [ "$migration_rebuild" -eq 1 ]; then migration_command env CL_BUN_NPM_SCRIPT_SHELL="$lifecycle_shell" \ "$NPM" run --ignore-scripts --script-shell "$0" \ "$lifecycle_script" --if-present || exit $? else CL_BUN_NPM_SCRIPT_SHELL="$lifecycle_shell" \ "$NPM" run --ignore-scripts --script-shell "$0" \ "$lifecycle_script" --if-present >/dev/null 2>&1 || exit $? fi done ;; *) echo "unknown phase: $phase" >&2; exit 2 ;; esac