#!/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 `<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-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
    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 <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; 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 <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
    [ "$(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<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) || 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
