#!/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'
DEPENDENCY_FILTER_UNSUPPORTED='migration cannot determine a supported npm dependency filter; use classic npm'
MANIFEST_UNSUPPORTED='manifest feature %s is not supported by Shared node_modules Store; use classic npm'
# 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() {
  # Exit 0 for a lockfile bun can read (or none), 3 for version 1, 4 over a
  # bound of this check (which stdout names), 5 for one that lacks an entry
  # a package requires (named on stdout), 2 for anything unreadable.
  "$CL_PYTHON" - "$1" <<'PY'
import errno
import json
import os
import stat
import sys

path = sys.argv[1]
maximum = 4 * 1024 * 1024
# Real trees nest about 10 node_modules levels, and a 2,021-entry
# application takes about 10,000 lookups; root bounds its own comparison
# of the same chains (MAX_RESOLUTION_STEPS) at 500,000.
MAX_NESTING = 64
MAX_LOOKUPS = 500000


class BeyondBound(Exception):
    """The check would cost more than a real lockfile needs."""

def no_duplicates(pairs):
    result = {}
    for key, value in pairs:
        if key in result:
            raise ValueError('duplicate JSON key')
        result[key] = value
    return result

def names(field):
    return [name for name in field if isinstance(name, str)] if isinstance(field, dict) else []

def missing_entry(packages):
    # A dependency an entry requires that no `node_modules/` entry provides,
    # looked up as npm resolves it: from the entry's own node_modules up to
    # the root's. bun's migration drops such an edge silently and root
    # installs the tree without it (a hand-resolved merge, a pruned file);
    # npm install repairs the file and npm ci refuses it. What npm leaves
    # out on purpose is not looked for: optional and peer dependencies, a
    # link's (its target is keyed by its own path), a bundled or an
    # extraneous package's. Each lookup is one probe of a (parent path,
    # name) pair, never a key built from the path: building them made the
    # check grow with the names times the square of the depth (51 s at
    # depth 800), while the account holds its application lock.
    provided = set()
    for path in packages:
        if path.count('node_modules/') > MAX_NESTING:
            raise BeyondBound('it nests packages more than %d node_modules deep'
                              % MAX_NESTING)
        cut = path.rfind('/node_modules/')
        if cut >= 0:
            provided.add((path[:cut], path[cut + len('/node_modules/'):]))
        elif path.startswith('node_modules/'):
            provided.add(('', path[len('node_modules/'):]))
    budget = MAX_LOOKUPS
    for path, entry in packages.items():
        if not isinstance(entry, dict) or not (path == '' or path.startswith('node_modules/')):
            continue
        if entry.get('link') or entry.get('inBundle') or entry.get('extraneous'):
            continue
        required = names(entry.get('dependencies'))
        if path == '':
            required += names(entry.get('devDependencies'))
        skipped = set(names(entry.get('optionalDependencies'))) | set(names(entry.get('peerDependencies')))
        bases = [path]
        while bases[-1]:
            cut = bases[-1].rfind('/node_modules/')
            bases.append(bases[-1][:cut] if cut >= 0 else '')
        for name in required:
            if name in skipped:
                continue
            budget -= len(bases)
            if budget < 0:
                raise BeyondBound('its dependencies take more than %d lookups to check'
                                  % MAX_LOOKUPS)
            for base in bases:
                if (base, name) in provided:
                    break
            else:
                return name, path or 'package.json'
    return None

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):
        raise ValueError('package-lock.json is not a regular file')
    if info.st_size > maximum:
        # An engine limit, like root's lockfile_too_large: classic npm.
        raise BeyondBound('it is larger than 4 MB')
    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 BeyondBound('it is larger than 4 MB')
    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')
    if version == 1:
        sys.exit(3)
    if isinstance(lock.get('packages'), dict):
        missing = missing_entry(lock['packages'])
        if missing is not None:
            print('package-lock.json has no entry for %s, which %s requires; '
                  'npm install writes it' % missing)
            sys.exit(5)
    sys.exit(0)
except BeyondBound as error:
    print('cannot inspect package-lock.json: %s' % error)
    sys.exit(4)
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
}

fill_missing_integrity() {
  # fill_missing_integrity <previous bun.lock> <bun.lock>: an application
  # the Store installed without a package-lock.json has a tree without npm
  # metadata, so the package-lock.json npm writes for a maintenance command
  # has no integrity or resolved URL for what is already installed, and bun
  # migrates those entries with an empty integrity -- which root refuses as
  # content it cannot verify. The previous bun.lock is bun's resolution of
  # exactly those packages from an allowed registry: an entry with an empty
  # integrity takes integrity (and an empty resolved URL its URL) from the
  # previous entry of the same name@version. Nothing else changes. Both
  # files are bun's, one entry per line; the result is still validated by
  # root like any bun.lock.
  "$CL_PYTHON" - "$1" "$2" <<'PY'
import os
import re
import stat
import sys

maximum = 4 * 1024 * 1024
STRING = r'"(?:[^"\\]|\\.)*"'
ENTRY = re.compile(r'^(\s*%s: \[)(%s), (%s), (.*), (%s)\](,?)$'
                   % (STRING, STRING, STRING, STRING))
INTEGRITY = re.compile(r'"sha512-[A-Za-z0-9+/]+={0,2}"\Z')

def read(path):
    fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW)
    try:
        info = os.fstat(fd)
        if not stat.S_ISREG(info.st_mode) or info.st_size > maximum:
            raise ValueError('%s is not a bounded regular file' % path)
        chunks = []
        remaining = maximum + 1
        while remaining:
            chunk = os.read(fd, min(1024 * 1024, remaining))
            if not chunk:
                break
            chunks.append(chunk)
            remaining -= len(chunk)
        return b''.join(chunks).decode('utf-8')
    finally:
        os.close(fd)

try:
    previous = {}
    for line in read(sys.argv[1]).splitlines():
        match = ENTRY.match(line)
        if match and INTEGRITY.match(match.group(5)):
            previous.setdefault(match.group(2), (match.group(3), match.group(5)))
    lines = read(sys.argv[2]).split('\n')
    changed = False
    for index, line in enumerate(lines):
        match = ENTRY.match(line)
        if not match or match.group(5) != '""' or match.group(2) not in previous:
            continue
        resolved, integrity = previous[match.group(2)]
        if match.group(3) != '""':
            resolved = match.group(3)
        lines[index] = '%s%s, %s, %s, %s]%s' % (
            match.group(1), match.group(2), resolved, match.group(4),
            integrity, match.group(6))
        changed = True
    if changed:
        temporary = '%s.%d' % (sys.argv[2], os.getpid())
        with open(temporary, 'x') as handle:
            handle.write('\n'.join(lines))
        os.replace(temporary, sys.argv[2])
except (OSError, UnicodeError, ValueError) as error:
    sys.stderr.write('Shared node_modules Store: cannot read bun.lock: %s\n' % error)
    sys.exit(1)
PY
}

put_back_previous_bun_lock() {
  # The bun.lock a resolve set aside (.cl-bun-previous.lock) goes back as
  # the venv bun.lock: a resolve that resolved nothing, failed, or was
  # stopped before it finished (found at the next one's entry) leaves the
  # previous resolution -- the pin source of an application without a
  # lockfile, the integrity proof of one with a package-lock.json. A link
  # there is not ours and only goes.
  if [ -f .cl-bun-previous.lock ] && [ ! -L .cl-bun-previous.lock ]; then
    mv -f -- .cl-bun-previous.lock bun.lock || return 1
  fi
  rm -f -- .cl-bun-previous.lock
}

unproven_integrity() {
  # unproven_integrity <package-lock.json> <previous bun.lock, or empty>:
  # exit 5 when a registry entry of the lockfile has no integrity at all and
  # the previous bun.lock -- bun's own resolution from an allowed registry,
  # what fill_missing_integrity copies from -- does not prove its
  # name@version with sha512. Links, bundled, git and file entries are
  # root's to refuse with their own reason; anything unreadable is left to
  # bun and root.
  "$CL_PYTHON" - "$1" "$2" <<'PY'
import json
import os
import re
import stat
import sys

maximum = 4 * 1024 * 1024
STRING = r'"(?:[^"\\]|\\.)*"'
ENTRY = re.compile(r'^\s*%s: \[(%s), (%s), .*, (%s)\],?$' % (STRING, STRING, STRING, STRING))
INTEGRITY = re.compile(r'sha512-[A-Za-z0-9+/]+={0,2}\Z')
URL = re.compile(r'https?://')


def read(path):
    fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW)
    try:
        info = os.fstat(fd)
        if not stat.S_ISREG(info.st_mode) or info.st_size > maximum:
            sys.exit(0)
        chunks = []
        size = 0
        while size <= maximum:
            chunk = os.read(fd, 1024 * 1024)
            if not chunk:
                break
            chunks.append(chunk)
            size += len(chunk)
        return b''.join(chunks).decode('utf-8')
    finally:
        os.close(fd)


try:
    packages = json.loads(read(sys.argv[1])).get('packages')
    if not isinstance(packages, dict):
        sys.exit(0)
    proven = set()
    if sys.argv[2]:
        for line in read(sys.argv[2]).splitlines():
            match = ENTRY.match(line)
            if match:
                spec, _url, integrity = (json.loads(group) for group in match.groups())
                if INTEGRITY.match(integrity):
                    proven.add(spec)
    for path, entry in packages.items():
        if (not isinstance(entry, dict) or 'node_modules/' not in path
                or entry.get('integrity') or entry.get('link') or entry.get('inBundle')
                or not isinstance(entry.get('version'), str)):
            continue
        resolved = entry.get('resolved')
        if resolved is not None and not (isinstance(resolved, str) and URL.match(resolved)):
            continue
        name = entry.get('name', path.rsplit('node_modules/', 1)[1])
        if '%s@%s' % (name, entry['version']) not in proven:
            sys.exit(5)
except (OSError, UnicodeError, ValueError, AttributeError, TypeError):
    sys.exit(0)
sys.exit(0)
PY
}

ci_has_dependencies() {
  # Whether the application has anything to install: package-lock.json lists
  # a package, or package.json declares a dependency. Unreadable counts as
  # yes -- it must never read as "no dependencies".
  "$CL_PYTHON" - "$HOME/$1/package-lock.json" "$HOME/$1/package.json" <<'PY'
import json
import os
import stat
import sys

maximum = 4 * 1024 * 1024

def load(path):
    fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW)
    try:
        info = os.fstat(fd)
        if not stat.S_ISREG(info.st_mode) or info.st_size > maximum:
            raise ValueError('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)
        return json.loads(b''.join(chunks).decode('utf-8'))
    finally:
        os.close(fd)

try:
    lock = load(sys.argv[1])
    manifest = load(sys.argv[2])
except (OSError, UnicodeError, ValueError, RecursionError):
    sys.exit(0)
packages = lock.get('packages') if isinstance(lock, dict) else None
if not isinstance(packages, dict) or any(key != '' for key in packages):
    sys.exit(0)
if not isinstance(manifest, dict):
    sys.exit(0)
for field in ('dependencies', 'devDependencies', 'optionalDependencies',
              'peerDependencies'):
    value = manifest.get(field)
    if value is not None and (not isinstance(value, dict) or value):
        sys.exit(0)
sys.exit(1)
PY
}

lockfile_bun_reads() {
  # lockfile_bun_reads <lockfile> pnpm|berry: exit 0 when bun 1.4.4 migrates
  # it in that format, 1 otherwise (absent or unreadable too). pnpm: a
  # lockfileVersion of 7 or later -- bun refuses older ones (pnpm 6 to 8)
  # before reading anything else (src/install/pnpm.rs) and resolves
  # package.json afresh. berry: a yarn.lock Yarn 2+ wrote, by the test bun
  # uses (src/install/yarn.rs). Both mark the head of the file, so only that
  # is read; a link is followed, as the account's own.
  [ -f "$1" ] || return 1
  "$CL_PYTHON" - "$1" "$2" <<'PY'
import os
import stat
import sys

path, kind = sys.argv[1:]
try:
    fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
    try:
        if not stat.S_ISREG(os.fstat(fd).st_mode):
            sys.exit(1)
        chunks = []
        remaining = 1024 * 1024
        while remaining:
            chunk = os.read(fd, remaining)
            if not chunk:
                break
            chunks.append(chunk)
            remaining -= len(chunk)
    finally:
        os.close(fd)
except OSError:
    sys.exit(1)
data = b''.join(chunks)
if kind == 'berry':
    berry = data.startswith(b'__metadata:') or b'\n__metadata:' in data
    at = data.find(b'# yarn lockfile v1')
    # Yarn reads a file as v1 only with the marker among its first comments.
    v1 = at >= 0 and (not berry or all(
        line in (b'', b'\r') or line.startswith(b'#') for line in data[:at].split(b'\n')))
    sys.exit(0 if berry and not v1 else 1)
for line in data.split(b'\n'):
    if line.startswith(b'lockfileVersion:'):
        version = line[len(b'lockfileVersion:'):].strip().strip(b'\'"')
        try:
            sys.exit(0 if float(version.split(b'.')[0]) >= 7 else 1)
        except ValueError:
            sys.exit(1)
sys.exit(1)
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.
  # A top-level packageExtensions bun 1.4.4 applies and records in bun.lock,
  # which root refuses as an unsupported lockfile field -- a failed install,
  # not classic npm -- so it is named here, and the root validator names it
  # too, for a manifest that changed between the phases.
  # venv lib's package.json is a link to the account's own file, and bun
  # writes it when it migrates a pnpm-lock.yaml it reads ($2 = yes: it copies
  # these pnpm settings to the top level) or a Yarn Berry yarn.lock ($3 =
  # yes: it rewrites the Yarn-only `patch:`, `portal:` and `link:` ranges,
  # and an `npm:` that no `name@range` follows): with that lockfile beside
  # it, such a manifest is named too. A project patch either lock records
  # while package.json no longer names it is named as well: bun looks for
  # the patch file in venv lib, which has none, and fails the resolve
  # (src/install/patch_install.rs). Deliberately by the lockfile alone, also
  # beside a package-lock.json: bun tries the next lockfile when one fails
  # to migrate, and a package-lock.json package_lock_state accepts can still
  # fail bun's migration (a non-boolean field, a registry that cannot fill a
  # missing integrity), so a leftover yarn.lock or pnpm-lock.yaml may be the
  # one bun migrates.
  "$CL_PYTHON" - "$1" "${2:-no}" "${3:-no}" <<'PY'
import json
import os
import stat
import sys
from urllib.parse import unquote_to_bytes

path, pnpm_lock, berry_lock = sys.argv[1:]
maximum = 4 * 1024 * 1024
unsupported = ('patchedDependencies', 'workspaces', 'packageExtensions')
pnpm_settings = ('overrides', 'onlyBuiltDependencies', 'packageExtensions',
                 'patchedDependencies')
yarn_groups = ('dependencies', 'devDependencies', 'optionalDependencies',
               'peerDependencies', 'resolutions')

def yarn_only(spec):
    for protocol in ('patch', 'portal', 'link'):
        if spec.startswith(protocol + ':'):
            return protocol
    if spec.startswith('npm:'):
        # bun keeps `npm:` only before `name@range` or `@scope/name@range`,
        # an alias; `npm:^1.2.3` and Yarn's tag form `npm:name` it rewrites.
        rest = spec[len('npm:'):]
        if rest.find('@', 1 if rest.startswith('@') else 0) < 0:
            return 'npm'
    return None

class LockTooLarge(Exception):
    pass

def lock_lines(name):
    # The lock beside the manifest, read as the manifest is: a regular
    # file of at most `maximum`, opened without blocking. lockfile_bun_reads
    # looked at it a moment ago; a FIFO or device swapped in since would
    # otherwise stall this resolve, and with it the hook waiting on it.
    # One past the bound is an engine limit, like a package-lock.json past
    # its bounds: classic npm, not a failed resolve.
    fd = os.open(os.path.join(os.path.dirname(path), name), os.O_RDONLY | os.O_NONBLOCK)
    try:
        info = os.fstat(fd)
        if not stat.S_ISREG(info.st_mode):
            raise ValueError(name + ' is not a regular file')
        if info.st_size > maximum:
            raise LockTooLarge(name)
        chunks = []
        remaining = maximum + 1
        while remaining:
            chunk = os.read(fd, min(1024 * 1024, remaining))
            if not chunk:
                break
            chunks.append(chunk)
            remaining -= len(chunk)
    finally:
        os.close(fd)
    data = b''.join(chunks)
    if len(data) > maximum:
        raise LockTooLarge(name)
    for line in data.split(b'\n'):
        yield line.rstrip(b'\r')

def berry_project_patch():
    # `resolution: "name@patch:<locator>#<source>::<params>"`, the source
    # `&`-separated files; only Yarn's own `builtin<...>` needs none
    # (src/install/yarn_berry.rs, project_patch_paths).
    for line in lock_lines('yarn.lock'):
        text = line.strip()
        if not text.startswith(b'resolution:') or b'@patch:' not in text:
            continue
        source = text.split(b'@patch:', 1)[1].partition(b'#')[2]
        source = unquote_to_bytes(source.split(b'::', 1)[0].rstrip(b'"'))
        for one in source.split(b'&'):
            if one.startswith(b'optional!'):
                one = one[len(b'optional!'):]
            if one and b'builtin<' not in one:
                return True
    return False

def pnpm_project_patch():
    # A top-level `patchedDependencies:` whose entries carry their `path`
    # (pnpm 9); pnpm 10 records the hash alone and bun takes the path from
    # package.json (src/install/pnpm.rs).
    section = False
    for line in lock_lines('pnpm-lock.yaml'):
        if line[:1] not in (b' ', b''):
            section = line.rstrip() == b'patchedDependencies:'
        elif section and line.strip().startswith(b'path:'):
            return True
    return False

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)
    pnpm = manifest.get('pnpm')
    if pnpm_lock == 'yes':
        for setting in pnpm_settings:
            if isinstance(pnpm, dict) and setting in pnpm:
                sys.stdout.write('pnpm.' + setting)
                sys.exit(3)
        if pnpm_project_patch():
            sys.stdout.write('pnpm.patchedDependencies')
            sys.exit(3)
    if berry_lock == 'yes':
        for group in yarn_groups:
            ranges = manifest.get(group)
            for spec in (ranges.values() if isinstance(ranges, dict) else ()):
                protocol = yarn_only(spec) if isinstance(spec, str) else None
                if protocol:
                    sys.stdout.write(protocol + '-protocol')
                    sys.exit(3)
        if berry_project_patch():
            sys.stdout.write('patch-protocol')
            sys.exit(3)
    sys.exit(0)
except LockTooLarge:
    sys.stdout.write('lockfile-too-large')
    sys.exit(3)
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
}

# Both install and migration ask the selected npm to resolve its own
# configuration precedence. The caller chooses the project prefix.
effective_dependency_mode() (
  set -o pipefail
  local prefix="$1" arg
  shift
  local -a mode_args mode_options
  mode_args=()
  mode_options=(--prefix="$prefix" --json=true)
  for arg in "$@"; do
    if [ "$arg" = -- ] && [ "${#mode_options[@]}" -ne 0 ]; then
      mode_args+=(${mode_options[@]+"${mode_options[@]}"})
      mode_options=()
    fi
    mode_args+=("$arg")
  done
  mode_args+=(${mode_options[@]+"${mode_options[@]}"})
  "${CL_NODEHOME}/usr/bin/npm" config list ${mode_args[@]+"${mode_args[@]}"} | "$CL_PYTHON" -c '
import json, sys
try:
    config = json.load(sys.stdin)
    omit = config.get("omit")
    if omit and config.get("legacy-peer-deps") is True:
        # Bun still follows peers that this npm configuration ignores.
        print("unsupported")
    elif isinstance(omit, list) and all(value == "dev" for value in omit):
        print("dev" if omit else "all")
    elif ("omit" not in config and type(config.get("production")) is bool
          and config.get("only") is None and config.get("optional") is True
          and config.get("dev") is False and config.get("also") not in ("dev", "development")):
        # npm 6 resolves NODE_ENV, npmrc and --production into this boolean.
        # Other legacy filters stay with classic npm.
        print("dev" if config["production"] else "all")
    else:
        print("unsupported")
except (ValueError, AttributeError):
    sys.exit(1)
'
)

copy_migration_npmrc() {
  # Mirror expose_app_npmrc's file choice without changing the live venv.
  # This runs as the account: its config links may be followed, unlike the
  # lock inputs root later reads. Copy only a bounded regular file.
  "$CL_PYTHON" - "$HOME/$app_root/.npmrc" "$venv/lib/.npmrc" \
      "$scratch/.npmrc" "${CL_NODEHOME}/usr/bin/npm" <<'PY'
import os
import re
import stat
import subprocess
import sys

app, venv, target, npm = sys.argv[1:]
fd = None
try:
    use_app = os.path.isfile(app) and (
        not os.path.lexists(venv) or (os.path.islink(venv) and (
            not os.path.exists(venv) or os.readlink(venv) == app)))
    source = app if use_app else venv
    try:
        fd = os.open(source, os.O_RDONLY | os.O_NONBLOCK)
    except FileNotFoundError:
        sys.exit(0)
    info = os.fstat(fd)
    maximum = 4 * 1024 * 1024
    if not stat.S_ISREG(info.st_mode) or info.st_size > maximum:
        raise ValueError('migration .npmrc must be a regular file of at most 4 MiB')
    with os.fdopen(fd, 'rb') as handle:
        fd = None
        data = handle.read(maximum + 1)
    if len(data) > maximum:
        raise ValueError('migration .npmrc exceeds 4 MiB')
    # The wrapper withholds only an app config it would expose itself.
    # An account's independent venv config always keeps npm's own behaviour.
    if use_app and any(name not in os.environb for name in re.findall(rb'\$\{([^}\n]+)\}', data)):
        version = subprocess.run([npm, '--version'], stdout=subprocess.PIPE,
                                 stderr=subprocess.DEVNULL)
        major = version.stdout.strip().split(b'.')[0]
        if version.returncode == 0 and major.isdigit() and int(major) < 7:
            sys.stderr.write('npm 6 cannot load app .npmrc with an unset variable; '
                             'migration runs without it, as npm_wrapper does\n')
            sys.exit(0)
    fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
    with os.fdopen(fd, 'wb') as handle:
        fd = None
        handle.write(data)
except (OSError, ValueError) as error:
    sys.stderr.write('cannot copy migration .npmrc: %s\n' % error)
    sys.exit(1)
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 Bun cache.
# Handle it before the normal venv setup, which maintains those live links.
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/pnpm 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 pnpm-lock.yaml; do
    app_lock="$HOME/$app_root/$lock_input"
    venv_lock="$HOME/nodevenv/$app_root/$3/lib/$lock_input"
    # A pnpm lock bun refuses (pnpm 6 to 8) is no input, as for an install.
    if [ "$lock_input" = pnpm-lock.yaml ] && ! lockfile_bun_reads "$app_lock" pnpm \
       && ! lockfile_bun_reads "$venv_lock" pnpm; then
      continue
    fi
    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_missing="$(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" -eq 5 ] || [ "$package_lock_status" -eq 4 ]; then
    printf '%s\n' "$package_lock_missing" | tee "$scratch/diagnostic" >&2
    exit 1
  elif [ "$package_lock_status" -ne 0 ]; then
    exit 1
  fi
  # The install path's manifest pre-check, on the scratch copies. What bun
  # writes back while migrating a pnpm-lock.yaml or a Berry yarn.lock lands
  # in the scratch package.json, never the account's -- but root reads only
  # the scratch bun.lock and delivers it against the live manifest, so the
  # migration would succeed for a manifest the account does not have; and
  # a project patch the lock records fails on the scratch, which has no
  # patch file. Refuse before bun runs, with the token the install path
  # reports, which root turns into `ineligible`.
  pnpm_lock=no
  lockfile_bun_reads "$scratch/pnpm-lock.yaml" pnpm && pnpm_lock=yes
  berry_lock=no
  lockfile_bun_reads "$scratch/yarn.lock" berry && berry_lock=yes
  unsupported_field="$(unsupported_manifest_field "$scratch/package.json" \
                       "$pnpm_lock" "$berry_lock")"
  unsupported_status=$?
  if [ "$unsupported_status" -eq 3 ]; then
    # shellcheck disable=SC2059
    printf "$MANIFEST_UNSUPPORTED\n" "$unsupported_field" | tee "$scratch/diagnostic" >&2
    exit 3
  elif [ "$unsupported_status" -ne 0 ]; then
    exit 1
  fi
  venv="$HOME/nodevenv/$app_root/$3"
  # The root caller selected this venv. Load the same application settings
  # as the panel, but query a copied npm configuration: dry-run must not
  # create lib/ or .npmrc aliases in the live venv.
  set +u
  source "$venv/bin/activate" 2>/dev/null || true
  set -u
  if [ -x "$venv/bin/set_env_vars.py" ]; then
    export CL_APP_ROOT="$app_root"
    eval "$("$venv/bin/set_env_vars.py" nodejs)"
  fi
  copy_migration_npmrc 2> "$scratch/diagnostic"
  npmrc_status=$?
  [ ! -s "$scratch/diagnostic" ] || cat "$scratch/diagnostic" >&2
  [ "$npmrc_status" -eq 0 ] || exit "$npmrc_status"
  cd "$scratch" || exit 1
  mode="$(effective_dependency_mode "$scratch" 2>/dev/null)" || mode=unsupported
  case "$mode" in
    all|dev) printf '%s\n' "$mode" > "$scratch/dependency-mode" || exit 1 ;;
    *)
      printf '%s\n' "$DEPENDENCY_FILTER_UNSUPPORTED" > "$scratch/diagnostic"
      echo "$DEPENDENCY_FILTER_UNSUPPORTED" >&2
      exit 3
      ;;
  esac
  "$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 "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 "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 "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_omit_dev="${CL_BUN_OMIT_DEV:-0}"
  [[ "$migration_omit_dev" == 0 || "$migration_omit_dev" == 1 ]] || exit 2
  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
elif [ "$phase" = "app-scripts" ]; then
  # The application's own install lifecycle after npm_wrapper's classic
  # install (S3): the lockfiles keep classic npm's layout.
  classic_prepare=1
fi

# npm invokes --script-shell as `<shell> -c <command>`. 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 <prepare|prepare-classic|resolve|deliver|rebuild|app-scripts> <app-root> [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 `<venv_base>/<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
    elif [[ ! -e "$lib_lock" ]] && [[ "$classic_prepare" -eq 1 \
        || "$(readlink "$lib_lock")" == /*/"$name" ]]; then
      # Moving the application (`set --new-app-root`) or the home directory
      # (a WHM rename, /home2, a restore) carries the venv's canonical alias
      # over verbatim, naming the old application root. Dangling, it holds
      # no pins and npm 10 cannot open it: it is dropped like the dangling
      # alias below -- by classic npm whatever it names, by the Store's
      # resolve only when it is such an alias (an absolute path to a
      # lockfile of this name). Only the link goes; no target is followed.
      lib_kind=stale
    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
      # Without a manifest it is no application's pins: what a failed
      # bare `npm install` leaves at its prefix (`"name": "lib"`).
      [ -f "$HOME/$app_root/package.json" ] || 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|absent:stale)
      [ "$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
      ;;
    regular:stale)
      [ "$mode" = preflight ] && return 0
      rm -f -- "$lib_lock" || return 1
      ;;
    *)
      echo "refusing unexpected lockfile state: $app_lock $lib_lock" >&2
      return 1
      ;;
  esac
  ln -s -- "$app_lock" "$lib_lock"
}

retire_classic_yarn_lock() {
  # npm 7+ rewrites a yarn.lock it finds at its prefix in the Yarn v1
  # format -- through a link, the application root's own (measured, npm
  # 10.9.8) -- so a Yarn 2+ file there was replaced by every classic or
  # maintenance command once delivery's preparation (or a pre-upgrade
  # wrapper) had linked it into venv lib. Classic npm gets no yarn.lock:
  # that link goes, as does a venv copy equal to the application's (npm
  # rewrote it, and delivery's preparation then refused the pair). A
  # different copy -- the historical layout -- and a link elsewhere stay.
  local app_lock="$HOME/$app_root/yarn.lock" lib_lock="$lib/yarn.lock" target
  if [[ -L "$lib_lock" ]]; then
    target="$(readlink "$lib_lock")"
    if [[ "$target" == "$app_lock" || ( ! -e "$lib_lock" && "$target" == /*/yarn.lock ) ]]; then
      rm -f -- "$lib_lock" || return 1
    fi
  elif [[ -f "$lib_lock" && -f "$app_lock" && ! -L "$app_lock" ]] \
      && cmp -s -- "$app_lock" "$lib_lock"; then
    rm -f -- "$lib_lock" || return 1
  fi
  return 0
}

prepare_app_lockfiles() {
  local lockfile lockfiles="package-lock.json npm-shrinkwrap.json yarn.lock"
  # Bun reads yarn.lock; npm does not, and npm 7+ rewrites one it finds
  # through venv lib in the Yarn v1 format -- a Yarn 2+ file in the
  # application root would be replaced. Classic npm gets none
  # (retire_classic_yarn_lock). pnpm-lock.yaml is Bun's alone too; npm
  # neither reads nor writes it, so a link delivery made can stay. One bun
  # refuses to migrate (pnpm 6 to 8) is no Bun input at all, as before --
  # unless venv lib holds one bun reads (the account ran pnpm there): that
  # copy is prepared like any lock input, moved to the application root
  # where the pre-check reads it, or refused beside a different one.
  if [ "$classic_prepare" -eq 1 ]; then
    lockfiles="package-lock.json npm-shrinkwrap.json"
  elif lockfile_bun_reads "$HOME/$app_root/pnpm-lock.yaml" pnpm \
       || lockfile_bun_reads "$lib/pnpm-lock.yaml" pnpm; then
    lockfiles="$lockfiles pnpm-lock.yaml"
  fi
  for lockfile in $lockfiles; do
    prepare_app_lockfile "$lockfile" preflight || return 1
  done
  if [ "$classic_prepare" -eq 1 ]; then
    retire_classic_yarn_lock || return 1
  fi
  # 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 $lockfiles; 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

# The direct panel hook has no npm_wrapper parent to load application
# settings (including app_mode -> NODE_ENV). Reuse the wrapper's helper.
if [[ ( "$migration_rebuild" -eq 1 || "$phase" == dependency-mode || ( "$phase" == resolve \
      && -z "${CL_BUN_OMIT_DEV:-}" ) ) && -x "$venv/bin/set_env_vars.py" ]]; then
  export CL_APP_ROOT="$app_root"
  eval "$("$venv/bin/set_env_vars.py" nodejs)"
fi

if [ "$phase" = dependency-mode ]; then
  # Ask this venv's npm: it owns config precedence, list-valued omit/include,
  # production aliases and NODE_ENV defaults. No package code or npm writes.
  mkdir -p "$lib" || exit 1
  if [ "${CL_BUN_NPMRC_READY:-0}" != 1 ] \
      && [ -f "$HOME/$app_root/.npmrc" ] && [ ! -e "$lib/.npmrc" ]; then
    [ ! -L "$lib/.npmrc" ] || rm -- "$lib/.npmrc" || exit 1
    ln -s -- "$HOME/$app_root/.npmrc" "$lib/.npmrc" || exit 1
  fi
  cd "$HOME/$app_root" || exit 1
  effective_dependency_mode "$lib" "${@:4}"
  exit $?
fi

# 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 <venv>/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 pnpm-lock.yaml; do
      if [ "$linked_lock" = pnpm-lock.yaml ] \
         && ! lockfile_bun_reads "$HOME/$app_root/$linked_lock" pnpm; then
        continue
      fi
      if [ -L "$HOME/$app_root/$linked_lock" ]; then
        printf '%s\n' 'cl-bun:unsupported-manifest symlink'
        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 <app_root>/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
    app_node_modules_target=$(readlink "$app_node_modules")
    if [ "$app_node_modules_target" != "$lib/node_modules" ]; then
      case "$app_node_modules_target" in
        "$HOME/nodevenv/"*|/*/nodevenv/*/lib/node_modules|/*/nodevenv/*/lib/node_modules/)
          # The Selector's own link to another venv path -- what
          # `set --new-app-root`, a WHM username rename, a move to /home2
          # or a restore onto another home base carries over verbatim.
          # npm_wrapper re-points it on its classic path; the same rule
          # here, under the application lock this phase runs in, keeps a
          # Store install of such an application from refusing.
          ln -sfn -- "$lib/node_modules" "$app_node_modules" || {
            echo "cannot re-point $app_node_modules" >&2; exit 1; }
          ;;
        *)
          echo "$app_node_modules exists and is not the selected venv link" >&2
          printf '%s\n' 'cl-bun:node-modules-not-selector-link'
          exit 1
          ;;
      esac
    fi
  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. stdout names the refusal
  # so the root hook can give the Selector's recovery; npm_wrapper checks
  # the link itself before it routes a bare install here.
    echo "$app_node_modules exists and is not a symlink" >&2
    printf '%s\n' 'cl-bun:node-modules-not-selector-link'
    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|NODE_ENV|CL_BUN_OMIT_DEV)
      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"
}

run_application_lifecycle() {
  # The application's own preinstall/install/postinstall/prepublish/
  # preprepare/prepare/postprepare, each declared one once, after the
  # dependencies were rebuilt. They run on every delivery -- a maintenance
  # one too (CLOS-7898): the delivery swaps in a whole new tree, so what
  # they wrote into node_modules (`prisma generate`'s `.prisma/client`,
  # files a `prepare` generates) is gone unless they run again. `quiet` keeps
  # their output out of the delivery (root reports the failure).
  local quiet="${1:-}" lifecycle_events lifecycle_shell lifecycle_script
  # Only declared events run, 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") || return $?
  [ -n "$lifecycle_events" ] || return 0
  # `--ignore-scripts` makes npm run only the explicitly requested event,
  # rather than also running pre<event>/post<event> 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) || return $?
  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 || return $?
    elif [ "$quiet" = quiet ]; then
      CL_BUN_NPM_SCRIPT_SHELL="$lifecycle_shell" \
      "$NPM" run --ignore-scripts --script-shell "$0" \
          "$lifecycle_script" --if-present >/dev/null 2>&1 || return $?
    else
      CL_BUN_NPM_SCRIPT_SHELL="$lifecycle_shell" \
      "$NPM" run --ignore-scripts --script-shell "$0" \
          "$lifecycle_script" --if-present >/dev/null || return $?
    fi
  done
}

case "$phase" in
  resolve)
    if [ -z "${CL_BUN_OMIT_DEV:-}" ]; then
      # The direct panel hook has no wrapper to pass the account's mode.
      dependency_mode="$("$0" dependency-mode "$app_root" "${venv##*/}")" \
        || dependency_mode=unsupported
      case "$dependency_mode" in
        all) export CL_BUN_OMIT_DEV=0 ;;
        dev) export CL_BUN_OMIT_DEV=1 ;;
        *)
          printf '%s\n' 'cl-bun:unsupported-manifest dependency-filter'
          exit 3
          ;;
      esac
    fi
    # A set-aside copy at entry means the last resolve was stopped (a
    # signal, the hook's timeout) before it finished, and no delivery used
    # what it wrote: put the previous resolution back -- the pin source of
    # an application without a lockfile, the integrity proof of one with a
    # package-lock.json.
    put_back_previous_bun_lock || exit 1
    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'
      exit 3
    fi
    package_lock_state "$HOME/$app_root/package-lock.json" >/dev/null
    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" -eq 5 ]; then
      # A lockfile without an entry a package requires: classic npm
      # install writes it; npm ci refuses it with its own error.
      printf '%s\n' 'cl-bun:unsupported-manifest missing-lockfile-entry'
      exit 3
    elif [ "$package_lock_status" -eq 4 ]; then
      # Over the 4 MB this phase reads, or nested or interlinked past what
      # its check walks: an engine limit, and classic npm.
      printf '%s\n' 'cl-bun:package-lock-too-large'
      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.
    pnpm_lock=no
    lockfile_bun_reads "$HOME/$app_root/pnpm-lock.yaml" pnpm && pnpm_lock=yes
    berry_lock=no
    lockfile_bun_reads "$HOME/$app_root/yarn.lock" berry && berry_lock=yes
    unsupported_field="$(unsupported_manifest_field "$HOME/$app_root/package.json" \
                         "$pnpm_lock" "$berry_lock")"
    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"
      exit 3
    elif [ "$unsupported_status" -ne 0 ]; then
      exit 1
    fi
    # `npm ci` (npm_wrapper's CL_BUN_COMMAND) installs package-lock.json and
    # nothing else; without one there is nothing exact to resolve. Every
    # route runs the same non-frozen resolve: bun's frozen migration of
    # package-lock.json refused every application with a root install or
    # prepare script (bun 1.4.0) and wrote no bun.lock at all (the 1.4.4
    # canary). A package.json that drifted from the lockfile is then
    # resolved, and root's exactness check -- the package set, integrity,
    # and package.json against the manifest package-lock.json recorded --
    # refuses it. Any failure here leaves the verdict to classic `npm ci`.
    if [ "${CL_BUN_COMMAND:-}" = ci ]; then
      [ -f "$HOME/$app_root/package-lock.json" ] || exit 1
    fi
    # bun prefers a bun.lock it finds over package-lock.json, yarn.lock and
    # pnpm-lock.yaml (measured, bun 1.4.0), and the venv one is the previous
    # resolution: after a `git pull` that bumped pins in the application's
    # lockfile, a bare install or "Run NPM Install" re-delivered the old
    # versions. So with any of them every route resolves the application's
    # lockfile alone -- one bun refuses to migrate is resolved afresh from
    # package.json, as without a lockfile; without one, bun.lock is the
    # record of the pins and stays, as it does beside a pnpm-lock.yaml bun
    # does not read. A resolve that fails puts it back, and for a
    # package-lock.json the previous resolution still proves the integrity
    # of what is installed (fill_missing_integrity, below).
    if [ -f "$HOME/$app_root/package-lock.json" ] || [ -f "$HOME/$app_root/yarn.lock" ] \
       || [ "$pnpm_lock" = yes ]; then
      rm -f -- .cl-bun-previous.lock || exit 1
      if [ -f bun.lock ] && [ ! -L bun.lock ]; then
        cp -p -- bun.lock .cl-bun-previous.lock || exit 1
      fi
      rm -f -- bun.lock || exit 1
    fi
    if [ -f "$HOME/$app_root/package-lock.json" ]; then
      # Registry entries without integrity that the previous resolution does
      # not prove either: root refuses them as content it cannot verify, and
      # the account then read "remove such a dependency". Classic npm, with
      # its own hint, before bun runs.
      previous_proof=''
      [ ! -f .cl-bun-previous.lock ] || previous_proof=.cl-bun-previous.lock
      unproven_integrity "$HOME/$app_root/package-lock.json" "$previous_proof"
      if [ "$?" -eq 5 ]; then
        put_back_previous_bun_lock || exit 1
        printf '%s\n' 'cl-bun:unsupported-manifest missing-integrity'
        exit 3
      fi
    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"
      # A failed `ci` resolve goes to classic `npm ci`, whose own error is
      # the one the account should read. The delivery of the lockfile a
      # maintenance command or a bare install with options wrote
      # (npm_wrapper's CL_BUN_ORIGIN `maintenance` or `install`, CLOS-7898)
      # fails instead, so its account reads bun's.
      if [ "${CL_BUN_COMMAND:-}" != ci ] || [ -n "${CL_BUN_ORIGIN:-}" ]; then
        echo 'Shared node_modules Store: dependency resolution failed:' >&2
        [ -n "$resolve_output" ] && printf '%s\n' "$resolve_output" >&2
      fi
      put_back_previous_bun_lock
      exit "$resolve_status"
    fi
    if [ -f .cl-bun-previous.lock ] && [ -f bun.lock ]; then
      if [ -f "$HOME/$app_root/package-lock.json" ] \
         && ! fill_missing_integrity .cl-bun-previous.lock bun.lock; then
        rm -f -- "$rebuild_env_state"
        put_back_previous_bun_lock
        exit 1
      fi
      rm -f -- .cl-bun-previous.lock
    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"
      # Nothing was resolved, so the bun.lock set aside is still the record
      # of what is installed: it goes back, whichever verdict follows.
      put_back_previous_bun_lock || exit 1
      # A resolve of package-lock.json that writes no bun.lock while the
      # application has something to install is a failed resolve, never "no
      # dependencies" -- what the 1.4.4 canary's frozen migration did; no
      # measured build does it unfrozen, and the check costs nothing.
      if [ "${CL_BUN_COMMAND:-}" = ci ] && ci_has_dependencies "$app_root"; then
        if [ -n "${CL_BUN_ORIGIN:-}" ]; then
          echo 'Shared node_modules Store: dependency resolution failed:' \
               'bun wrote no bun.lock for this package-lock.json' >&2
          [ -n "$resolve_output" ] && printf '%s\n' "$resolve_output" >&2
        fi
        exit 1
      fi
      printf '%s\n' 'cl-bun:no-dependencies'
      exit 3
    fi
    [ "${CL_BUN_OMIT_DEV:-0}" != 1 ] || printf '%s\n' 'cl-bun:omit-dev'
    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
    else
      # Keep the mode selected for this candidate, even if application
      # settings changed while root prepared it. Never restore a stale
      # ordinary-install environment file during migration.
      export CL_BUN_OMIT_DEV="$migration_omit_dev"
    fi
    [ "${CL_BUN_OMIT_DEV:-0}" != 1 ] || export NODE_ENV=production
    # 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 "optional dependency $optional_name failed to build and was" \
             "removed; the application runs without it" >&2
      done
    else
      migration_command "$NPM" rebuild || exit $?
    fi

    run_application_lifecycle quiet || exit $?
    ;;
  app-scripts)
    # npm_wrapper's classic install after a Store refusal (S3) ran with
    # --ignore-scripts and rebuilt the dependencies itself; the application's
    # own lifecycle runs here, once, as on every delivery. Its errors reach
    # the account.
    NPM="${CL_NODEHOME:-}/usr/bin/npm"
    [ -x "$NPM" ] || { echo "node npm unavailable at $NPM" >&2; exit 1; }
    run_application_lifecycle || exit $?
    ;;
  *)
    echo "unknown phase: $phase" >&2; exit 2
    ;;
esac
