#!/usr/bin/env sh # visit-portal installer — POSIX sh. macOS · Linux · Git Bash on Windows. # No sudo. No silent shell-rc edits. No silent network calls. Confirms before acting. # Non-TTY stdin (e.g. curl|sh) requires VISITPORTAL_ASSUME_YES=1. # # Release pinning: REPO_REF and REPO_TARBALL_SHA256 must be updated each release. # Helper: scripts/compute-install-sha.sh v0.1.8 prints both values to paste here. set -eu VERSION="0.1.8" REPO_URL="https://github.com/0motionguy/portal" REPO_REF="v0.1.8" REPO_TARBALL_SHA256="9f0a9f4d626bfbc12ddc6b4ebfd56f19a2cfc6201516ffbfb40264c45ea468a7" INSTALL_DIR="${VISITPORTAL_HOME:-$HOME/.visitportal}" BIN_DIR="$INSTALL_DIR/bin" SHIM="$BIN_DIR/visit-portal" say() { printf '%s\n' "$*"; } die() { printf 'error: %s\n' "$*" >&2; exit 1; } have() { command -v "$1" >/dev/null 2>&1; } run() { say " \$ $*"; "$@"; } MODE="install"; FROM_LOCAL=""; DRY_RUN=0 while [ $# -gt 0 ]; do case "$1" in --uninstall) MODE="uninstall"; shift ;; --from-local) FROM_LOCAL="${2:-}"; [ -n "$FROM_LOCAL" ] || die "--from-local needs a path"; shift 2 ;; --dry-run) DRY_RUN=1; shift ;; -h|--help) MODE="help"; shift ;; *) die "unknown argument: $1" ;; esac done # Refuse root / sudo — installer only ever writes to $HOME. if [ "${EUID:-$(id -u 2>/dev/null || echo 0)}" = "0" ]; then die "do not run this as root / with sudo. Installer writes only to \$HOME." fi if [ "$MODE" = "help" ]; then cat <] [--uninstall] [--dry-run] ENV: VISITPORTAL_HOME override install dir (default ~/.visitportal) VISITPORTAL_ASSUME_YES set to 1 to skip y/N prompt (required for non-TTY stdin) EOF exit 0 fi # Plan — print before doing anything. say "" say "visit-portal installer v$VERSION" say " install dir: $INSTALL_DIR" say " shim target: $SHIM" if [ "$MODE" = "uninstall" ]; then say " mode: UNINSTALL (will remove $INSTALL_DIR)" elif [ -n "$FROM_LOCAL" ]; then say " mode: install from local path: $FROM_LOCAL" else say " mode: clone from $REPO_URL @ $REPO_REF" fi [ "$DRY_RUN" -eq 1 ] && say " dry-run: yes (no changes will be written)" say "" # Confirm. Non-TTY requires env opt-in. if [ "${VISITPORTAL_ASSUME_YES:-0}" = "1" ]; then say "VISITPORTAL_ASSUME_YES=1 set; proceeding without prompt." elif [ ! -t 0 ]; then die "stdin is not a TTY (piped install). Set VISITPORTAL_ASSUME_YES=1 to proceed." else printf "continue? [y/N] "; read -r ans case "$ans" in [yY]|[yY][eE][sS]) : ;; *) die "aborted by user." ;; esac fi # Uninstall path. if [ "$MODE" = "uninstall" ]; then [ -d "$INSTALL_DIR" ] || { say "nothing to remove at $INSTALL_DIR"; exit 0; } [ "$DRY_RUN" -eq 1 ] && { say "would run: rm -rf $INSTALL_DIR"; exit 0; } run rm -rf "$INSTALL_DIR" say "removed $INSTALL_DIR" say "reminder: if you added $BIN_DIR to your PATH manually, remove that line too." exit 0 fi # Preflight deps. The install path downloads a tarball and verifies its SHA256 # against REPO_TARBALL_SHA256 before extracting — so we need curl + tar + a # sha256 tool but NOT git. have curl || die "curl is required but not installed." have tar || die "tar is required but not installed." have pnpm || die "pnpm is required (npm install -g pnpm@10)." have node || die "node >=22 is required." # Pick a sha256 tool: GNU/Linux has sha256sum, macOS has shasum -a 256. if have sha256sum; then SHA256="sha256sum" elif have shasum; then SHA256="shasum -a 256" else die "sha256sum or shasum is required for tarball verification." fi [ "$DRY_RUN" -eq 1 ] && { say "dry-run: would create $INSTALL_DIR and write shim to $SHIM"; exit 0; } run mkdir -p "$BIN_DIR" if [ -n "$FROM_LOCAL" ]; then [ -d "$FROM_LOCAL/packages/cli" ] || die "no packages/cli at $FROM_LOCAL — not a Portal checkout." say "copying $FROM_LOCAL -> $INSTALL_DIR/repo (excluding node_modules)" run mkdir -p "$INSTALL_DIR/repo" ( cd "$FROM_LOCAL" && tar --exclude='node_modules' --exclude='.git' -cf - . ) \ | ( cd "$INSTALL_DIR/repo" && tar -xf - ) else # Download the pinned tarball, verify SHA256, then extract. Never fall back # to git — the whole point of the pin is to make the installed source # auditable against a fixed hash. tarball_url="${REPO_URL}/archive/refs/tags/${REPO_REF}.tar.gz" tarball_file="$INSTALL_DIR/repo.tar.gz" say "will fetch: $tarball_url" run curl -fsSL "$tarball_url" -o "$tarball_file" actual_sha="$($SHA256 "$tarball_file" | awk '{print $1}')" if [ -z "$REPO_TARBALL_SHA256" ]; then die "REPO_TARBALL_SHA256 is empty — refusing to install unverified tarball. Check $REPO_URL/releases/tag/$REPO_REF." fi if [ "$actual_sha" != "$REPO_TARBALL_SHA256" ]; then die "tarball SHA256 mismatch. Expected $REPO_TARBALL_SHA256, got $actual_sha." fi say "tarball SHA256 verified: $actual_sha" # Extract to a fresh repo dir so a previously-installed version can't leak. run rm -rf "$INSTALL_DIR/repo" run mkdir -p "$INSTALL_DIR/repo" run tar -xzf "$tarball_file" -C "$INSTALL_DIR/repo" --strip-components=1 run rm -f "$tarball_file" fi say "installing dependencies (pnpm install --frozen-lockfile)" ( cd "$INSTALL_DIR/repo" && pnpm install --frozen-lockfile >/dev/null ) cat > "$SHIM" <<'SHIM_EOF' #!/usr/bin/env sh # visit-portal shim — points at the CLI inside $VISITPORTAL_HOME/repo. set -eu REPO="${VISITPORTAL_HOME:-$HOME/.visitportal}/repo" [ -d "$REPO" ] || { echo "visit-portal: repo missing at $REPO; reinstall." >&2; exit 1; } exec pnpm --silent --dir "$REPO" --filter @visitportal/cli exec tsx src/cli.ts "$@" SHIM_EOF chmod +x "$SHIM" say "" say "installed." say " cli: $SHIM" say " source: $INSTALL_DIR/repo" say "" say "add this to your shell rc to put visit-portal on PATH:" say " export PATH=\"\$PATH:$BIN_DIR\"" say "" say "try it: $SHIM --help" say "uninstall: sh install --uninstall"