#!/usr/bin/env bash # Install Panama's searchable launcher actions as one narrow directory link. # Keeping the runtime path stable lets Vicinae watch it while every command # remains authored and reviewed in the Panama repository. set -euo pipefail _collect_vicinae_inputs() { local extension="$1" output="$2" [[ -d "$extension" && ! -L "$extension" \ && -f "$extension/package.json" && ! -L "$extension/package.json" \ && -f "$extension/package-lock.json" && ! -L "$extension/package-lock.json" ]] \ || return 1 # Everything authored below the extension affects its build. npm's # dependency tree is the sole exception and is reproduced from the lock. find "$extension" -mindepth 1 \ \( -path "$extension/node_modules" -prune \) -o \ ! -type d -print0 >"$output" || return 1 LC_ALL=C sort -z -o "$output" "$output" || return 1 } _write_vicinae_manifest() { local extension="$1" inputs="$2" output="$3" local input relative digest : >"$output" || return 1 while IFS= read -r -d '' input; do [[ -f "$input" && ! -L "$input" && -r "$input" ]] || return 1 relative="${input#"$extension"/}" [[ "$relative" != "$input" && -n "$relative" ]] || return 1 digest="$(sha256sum -- "$input" | awk '{ print $1 }')" || return 1 [[ "$digest" =~ ^[0-9a-f]{64}$ ]] || return 1 printf '%s\0%s\0' "$relative" "$digest" >>"$output" || return 1 done <"$inputs" } _vicinae_extension_digest() ( local extension="${1%/}" work="" trap '[[ -z "$work" ]] || rm -rf -- "$work"' EXIT trap 'exit 130' INT trap 'exit 143' TERM work="$(mktemp -u -d -t panama-vicinae-digest.XXXXXX)" || exit 1 if ! mkdir -m 700 -- "$work"; then work="" exit 1 fi _collect_vicinae_inputs "$extension" "$work/inputs.before" || exit 1 _write_vicinae_manifest \ "$extension" "$work/inputs.before" "$work/manifest.before" || exit 1 _collect_vicinae_inputs "$extension" "$work/inputs.after" || exit 1 _write_vicinae_manifest \ "$extension" "$work/inputs.after" "$work/manifest.after" || exit 1 cmp -s -- "$work/inputs.before" "$work/inputs.after" || exit 1 cmp -s -- "$work/manifest.before" "$work/manifest.after" || exit 1 sha256sum -- "$work/manifest.before" | awk '{ print $1 }' ) _record_vicinae_digest() ( local built="$1" digest="$2" receipt temporary="" trap '[[ -z "$temporary" ]] || rm -f -- "$temporary"' EXIT trap 'exit 130' INT trap 'exit 143' TERM [[ -d "$built" && ! -L "$built" ]] || exit 1 receipt="$built/.panama-source-sha256" temporary="$(mktemp -u "$built/.panama-source-sha256.XXXXXX")" || exit 1 umask 077 if ! (set -o noclobber; : >"$temporary") 2>/dev/null; then temporary="" exit 1 fi printf '%s\n' "$digest" >"$temporary" || exit 1 mv -f -- "$temporary" "$receipt" || exit 1 temporary="" ) panama_path="${PANAMA_PATH:-$HOME/.local/share/Panama}" vicinae_data_dir="${VICINAE_DATA_DIR:-$HOME/.local/share/vicinae}" source_dir="$panama_path/config/local/share/vicinae/scripts" scripts_dir="$vicinae_data_dir/scripts" target_dir="$scripts_dir/panama" backup_root="$vicinae_data_dir/backups" backup_dir="$backup_root/panama.pre-panama" legacy_backup="$scripts_dir/panama.pre-panama" [[ -d "$source_dir" ]] || { printf 'Panama command source is missing: %s\n' "$source_dir" >&2 exit 1 } mkdir -p "$scripts_dir" "$backup_root" # Older Panama builds preserved this directory alongside searchable commands. # Move it out before reloading so those scripts cannot appear twice. if [[ -e "$legacy_backup" ]]; then if [[ -e "$backup_dir" ]]; then printf 'Refusing to move %s; backup already exists at %s\n' \ "$legacy_backup" "$backup_dir" >&2 exit 1 fi mv "$legacy_backup" "$backup_dir" fi if [[ -L "$target_dir" ]]; then rm "$target_dir" elif [[ -e "$target_dir" ]]; then if [[ -e "$backup_dir" ]]; then printf 'Refusing to replace %s; backup already exists at %s\n' \ "$target_dir" "$backup_dir" >&2 exit 1 fi mv "$target_dir" "$backup_dir" fi ln -s "$source_dir" "$target_dir" # ── Extensions ─────────────────────────────────────────────────────────────── # # Script commands are a file and a shebang, so they are linked. Extensions are # not: they are TypeScript that has to be compiled, and `vici build` writes its # output straight into Vicinae's data directory rather than leaving a bundle to # link. So the source lives in this repository and the build is what installs # it. # # Never fatal, and never a reason to fail a stage. A build wants npm and the # network, and neither is guaranteed at this point in an install -- npm arrives # with nvm earlier in install-packages, which can itself be skipped. An # extension that did not build is a launcher missing one command, not a desktop # that failed to install. extensions_source="$panama_path/config/local/share/vicinae/extensions" # npm comes from nvm, and nvm is a shell function in a file that only an # interactive shell sources. A stage is not one, so without this npm is simply # absent and the build below is skipped -- silently, which is the whole problem. # # This machine hid the bug: /usr/sbin/npm existed, but only because Claude # Desktop depends on nodejs and dragged it in. A machine without that would have # installed the launcher and quietly not built its search extension. if [[ -s /etc/profile.d/nvm.sh ]] && ! command -v npm >/dev/null 2>&1; then # nvm's script reads unset variables, which `set -u` treats as fatal. set +u # shellcheck source=/dev/null source /etc/profile.d/nvm.sh set -u fi if [[ -d "$extensions_source" ]] && command -v npm >/dev/null 2>&1; then for extension in "$extensions_source"/*/; do [[ -f "$extension/package.json" ]] || continue name="$(basename "$extension")" # Skip only when a prior successful build records the digest of both # manifests and every source byte. Directory mtimes do not change when # an existing source file is edited. built="$vicinae_data_dir/extensions/$name" receipt="$built/.panama-source-sha256" if ! source_digest="$(_vicinae_extension_digest "$extension")"; then printf 'Vicinae extension %s inputs could not be verified; skipping\n' \ "$name" >&2 continue fi if [[ -f "$receipt" && ! -L "$receipt" ]] \ && cmp -s <(printf '%s\n' "$source_digest") "$receipt"; then printf 'Vicinae extension %s is already built\n' "$name" continue fi printf 'Building Vicinae extension %s\n' "$name" if ! (cd "$extension" && npm ci --silent >/dev/null 2>&1 \ && npm run build >/dev/null 2>&1); then printf 'Vicinae extension %s did not build; skipping\n' "$name" >&2 continue fi if ! final_digest="$(_vicinae_extension_digest "$extension")" \ || [[ "$final_digest" != "$source_digest" ]]; then printf 'Vicinae extension %s changed while building; receipt withheld\n' \ "$name" >&2 continue fi if ! _record_vicinae_digest "$built" "$source_digest"; then printf 'Vicinae extension %s receipt could not be recorded\n' "$name" >&2 fi done elif [[ -d "$extensions_source" ]]; then printf 'npm is not available, so Vicinae extensions were not built\n' >&2 fi # The server also rescans periodically, but an explicit reload makes a setup # run deterministic. If Vicinae is not active yet, its startup scan is enough. if command -v vicinae >/dev/null 2>&1 && vicinae ping >/dev/null 2>&1; then vicinae cmd launch core:reload-scripts >/dev/null 2>&1 || true fi