#!/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