#!/usr/bin/env bash # Install the self-host CLI. # # curl -fsSL https://self-host.fyi/install | bash # # Installs to ~/.local/bin, or /usr/local/bin when run as root. # Environment: # SELF_HOST_VERSION install this version (1.2.0 or v1.2.0) instead of the latest # SELF_HOST_INSTALL_DIR install here instead of the default directory # SELF_HOST_GITHUB_TOKEN GitHub token to lift the anonymous API rate limit; defaults to `gh auth token` when gh is logged in # SELF_HOST_RELEASE_REPO owner/name of the repository that publishes releases (default below) # SELF_HOST_RELEASES_URL, SELF_HOST_WEB_URL alternative API and github.com roots (https, or http for localhost) # SELF_HOST_SKIP_SKILL_PROMPT=1 never offer to add the skill to an AI agent # SELF_HOST_TTY file to ask the skill question on instead of /dev/tty (tests) # # Everything runs inside main, which is the last line: a truncated download # defines functions at most and installs nothing. set +x set -euo pipefail # The one place the repository name is written. After a GitHub rename the old # name keeps redirecting, so changing it is cosmetic. DEFAULT_REPO="ajnart/self-host" REPO="" TOKEN="" TOKEN_IMPLICIT=0 API_URL="" WEB_URL="" HTTPS_ONLY=1 tmp="" staging="" configure() { REPO="${SELF_HOST_RELEASE_REPO:-$DEFAULT_REPO}" [[ "$REPO" =~ ^[A-Za-z0-9_-][A-Za-z0-9_.-]*/[A-Za-z0-9_.-]+$ && "$REPO" != *..* && "$REPO" != */. ]] || die "SELF_HOST_RELEASE_REPO must look like owner/name, not '${REPO}'" # Optional GitHub token for a higher API rate limit. Tokens stay out of process arguments. TOKEN="${SELF_HOST_GITHUB_TOKEN:-}" if [ -z "$TOKEN" ] && [ -z "${SELF_HOST_RELEASES_URL:-}" ] && command -v gh >/dev/null 2>&1; then TOKEN="$(gh auth token 2>/dev/null || true)" if [ -n "$TOKEN" ]; then TOKEN_IMPLICIT=1; fi fi case "$TOKEN" in *[!a-zA-Z0-9_.-]*) die "invalid token format" ;; esac API_URL="${SELF_HOST_RELEASES_URL:-https://api.github.com/repos/${REPO}}" WEB_URL="${SELF_HOST_WEB_URL:-https://github.com/${REPO}}" local url for url in "$API_URL" "$WEB_URL"; do case "$url" in https://*) ;; http://localhost | http://localhost[:/]* | http://127.0.0.1 | http://127.0.0.1[:/]*) HTTPS_ONLY=0 ;; *) die "SELF_HOST_RELEASES_URL and SELF_HOST_WEB_URL must be https URLs (http is allowed only for localhost)" ;; esac done } cleanup() { if [ -n "$tmp" ]; then rm -rf "$tmp" fi if [ -n "$staging" ]; then rm -f "$staging" fi } say() { printf '%s\n' "$*"; } die() { printf 'install.sh: %s\n' "$*" >&2 exit 1 } need() { command -v "$1" >/dev/null 2>&1 || die "$1 is required but was not found" } detect_os() { case "$(uname -s)" in Linux) echo linux ;; Darwin) echo darwin ;; MINGW* | MSYS* | CYGWIN*) die "native Windows is unsupported; install WSL, then run this script inside it" ;; *) die "unsupported operating system $(uname -s); self-host supports Linux and macOS" ;; esac } detect_arch() { case "$(uname -m)" in x86_64 | amd64) echo amd64 ;; aarch64 | arm64) echo arm64 ;; *) die "unsupported CPU architecture $(uname -m)" ;; esac } install_dir() { if [ -n "${SELF_HOST_INSTALL_DIR:-}" ]; then echo "$SELF_HOST_INSTALL_DIR" elif [ "$(id -u)" -eq 0 ]; then echo /usr/local/bin else echo "${HOME}/.local/bin" fi } # Parse only scalar fields of JSON objects; nested assets cannot become tags. # This keeps the installer independent of jq, Python, Node and source checkout. json_values() { env LC_ALL=C awk -v wanted="${1:-}" -v mode="${2:-tags}" ' function fail() { invalid=1; exit 1 } function ws() { while (pos<=len && ch[pos] ~ /[[:space:]]/) pos++ } function string_value( value,c,escape) { if (ch[pos++] != "\"") fail() value="" while (pos<=len) { c=ch[pos++] if (c == "\"") return value if (c == "\\") { escape=ch[pos++] if (escape == "u") { c=ch[pos] ch[pos+1] ch[pos+2] ch[pos+3] if (pos+3>len || c ~ /[^0-9a-fA-F]/) fail() pos+=4; value=value "\\u" c } else if (escape ~ /^["\\\/bfnrt]$/) value=value escape else fail() } else { if (c ~ /[[:cntrl:]]/) fail() value=value c } } fail() } function cli_release(tag, ver,i,n,platforms) { ver=tag if (!sub(/^new-cli-v/, "", ver)) sub(/^v/, "", ver) if (!sums) return 0 n=split("linux_amd64 linux_arm64 darwin_amd64 darwin_arm64", platforms, " ") for (i=1; i<=n; i++) if (index(names, "\nself-host_" ver "_" platforms[i] ".tar.gz\n")) return 1 return 0 } function object_value( field,value,tag) { pos++; depth++; ws() if (ch[pos] != "}") { while (1) { ws(); field=string_value(); ws() if (ch[pos++] != ":") fail() value=parse_value(); values[depth,field]=value; ws() if (ch[pos] == "}") break if (ch[pos++] != ",") fail() } } pos++ tag=values[depth,"tag_name"] if (depth==2) { if (values[depth,"name"]=="checksums.txt") sums=1 names=names "\n" values[depth,"name"] "\n" if (mode=="asset" && values[depth,"name"]==wanted) output=output values[depth,"id"] "\n" if (mode=="assetname" && values[depth,"name"]==wanted) output=output wanted "\n" } if (depth==1 && tag!="") { count++ if (mode=="tag" && values[depth,"draft"]!="true" && values[depth,"prerelease"]!="true") output=output tag "\n" if (mode=="tags" && values[depth,"draft"]!="true" && values[depth,"prerelease"]!="true" && cli_release(tag)) output=output tag "\n" } if (depth==1) { sums=0; names="" } for (field in values) if (index(field,depth SUBSEP)==1) delete values[field] depth-- return "" } function array_value() { pos++; ws() if (ch[pos] != "]") { while (1) { parse_value(); ws() if (ch[pos] == "]") break if (ch[pos++] != ",") fail() } } pos++; return "" } function parse_value( c,value) { ws(); c=ch[pos] if (c == "{") return object_value() if (c == "[") return array_value() if (c == "\"") return string_value() value="" while (pos<=len && !index("[],} \t\n\r\f\v", ch[pos])) value=value ch[pos++] if (value !~ /^(true|false|null)$/ && value !~ /^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$/) fail() return value } { input=input $0 "\n" } END { if (invalid) exit 1 len=split(input, ch, "") pos=1; ws() if (mode=="tags" && ch[pos]!="[") fail() if ((mode=="asset" || mode=="assetname" || mode=="tag") && ch[pos]!="{") fail() parse_value(); ws() if (pos<=len || invalid) exit 1 printf "%s",output if (mode=="tags") print "#" count+0 }' } # with_token is true while requests carry the token: not set, or dropped after # GitHub refused an implicitly adopted gh token (the flag file survives the # subshells that capture responses). with_token() { [ -n "$TOKEN" ] && [ ! -e "${tmp}/anonymous" ] } api_fetch() { local proto=(--proto '=https' --proto-redir '=https' --tlsv1.2) wget_proto=(--https-only) if [ "$HTTPS_ONLY" != 1 ]; then proto=(--proto '=http,https' --proto-redir '=http,https') wget_proto=(--max-redirect=10) elif ! wget --help 2>&1 | grep -q -- --https-only; then wget_proto=(--max-redirect=10) fi if command -v curl >/dev/null 2>&1; then if with_token; then printf 'header = "Authorization: Bearer %s"\n' "$TOKEN" | curl --config - "${proto[@]}" -fsSL -H "Accept: $2" "$1" else curl "${proto[@]}" -fsSL -H "Accept: $2" "$1" fi elif with_token; then (umask 077 && printf 'header = Authorization: Bearer %s\n' "$TOKEN" > "${tmp}/wgetrc") WGETRC="${tmp}/wgetrc" wget "${wget_proto[@]}" -nv -O- --header="Accept: $2" "$1" else wget "${wget_proto[@]}" -nv -O- --header="Accept: $2" "$1" fi } # http_error is true when the last failed request was refused with one of the # given HTTP statuses (curl, GNU wget and busybox all print the number). http_error() { local status for status in "$@"; do grep -Eq "(^|[^0-9])${status}([^0-9]|\$)" "${tmp}/error" 2>/dev/null && return 0 done return 1 } api_get() { local status=0 api_fetch "$@" 2>"${tmp}/error" || status=$? if [ "$status" -ne 0 ] && [ "$TOKEN_IMPLICIT" = 1 ] && with_token && http_error 401 403; then : > "${tmp}/anonymous" status=0 api_fetch "$@" 2>"${tmp}/error" || status=$? fi if [ "$status" -ne 0 ]; then cat "${tmp}/error" >&2; fi return "$status" } valid_version() { [[ "$1" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] } # A CLI release is tagged vX.Y.Z or new-cli-vX.Y.Z and carries the self-host # archives and checksums.txt (json_values filters on that), which keeps the # legacy skill-pack releases out whatever their version. latest_tag() { local body listing page count line candidates="" tag version per_page=100 for page in {1..10}; do body="$(api_get "${API_URL}/releases?per_page=${per_page}&page=${page}" application/vnd.github+json)" || die "could not look up CLI releases from ${API_URL} (a 403 means GitHub's rate limit of 60 anonymous requests an hour, or a token it refused: wait, or set SELF_HOST_GITHUB_TOKEN)" listing="$(printf '%s' "$body" | json_values)" || die "invalid release-list JSON" count=0 while IFS= read -r line; do case "$line" in "#"*) count="${line#"#"}"; continue ;; new-cli-v*) tag="$line"; version="${tag#new-cli-v}" ;; v*) tag="$line"; version="${tag#v}" ;; *) continue ;; esac if valid_version "$version"; then candidates="${candidates}${version} ${tag}"$'\n'; fi done <<< "$listing" if [ "$count" -lt "$per_page" ]; then tag="$(printf '%s' "$candidates" | sed '/^$/d' | sort -t . -k1,1n -k2,2n -k3,3n | tail -n 1 | cut -d ' ' -f 2)" [ -n "$tag" ] || die "no CLI release is published (a vX.Y.Z tag with the self-host archives and checksums.txt)" printf '%s\n' "$tag" return fi done die "CLI release lookup exceeded 1000 releases" } sha256_of() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d ' ' -f 1 elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d ' ' -f 1 else die "sha256sum or shasum is required to verify the download" fi } # path_help says exactly how to put dir on PATH for the person's login shell. path_help() { local dir="$1" shown="$1" file line if [ "$dir" = "${HOME:-}/.local/bin" ]; then shown='$HOME/.local/bin'; fi case "$(basename "${SHELL:-}")" in fish) say "Warning: ${dir} is not on your PATH. Add it for fish, then open a new terminal:" say " fish_add_path ${shown}" return ;; zsh) file='~/.zshrc' ;; bash) if [ "$(uname -s)" = Darwin ]; then file='~/.bash_profile'; else file='~/.bashrc'; fi ;; *) file="" ;; esac line="export PATH=\"${shown}:\$PATH\"" if [ -n "$file" ]; then say "Warning: ${dir} is not on your PATH. Add it, then open a new terminal:" say " echo '${line}' >> ${file}" else say "Warning: ${dir} is not on your PATH. Add this line to your shell's startup file, then open a new terminal:" say " ${line}" fi } # resolve_release sets tag, version and metadata for the first candidate tag # that is a published CLI release listing the archive and checksums.txt. resolve_release() { local archive="$1" candidate found="" listed shift for candidate in "$@"; do metadata="$(api_get "${API_URL}/releases/tags/${candidate}" application/vnd.github+json 2>/dev/null)" || continue [ "$(printf '%s' "$metadata" | json_values "" tag)" = "$candidate" ] || die "release metadata does not match requested stable CLI tag" listed="$(printf '%s' "$metadata" | json_values "$archive" assetname)" || listed="" [ -n "$listed" ] || { found="$candidate"; continue; } tag="$candidate" return done if [ -n "$found" ]; then die "release ${found} is not a CLI release: it does not list ${archive}" fi die "could not read release metadata for $* from ${API_URL} (404 means no such release; 403 means GitHub's rate limit of 60 anonymous requests an hour, or a token it refused: wait, or set SELF_HOST_GITHUB_TOKEN)" } download_failed() { if http_error 403; then die "could not download $1 from $2 (a 403 means GitHub's rate limit; wait, or set SELF_HOST_GITHUB_TOKEN)" fi die "could not download $1 from $2" } skill_hint() { say "To make the self-host skill discoverable by your AI agents, run:" say " self-host skill install" say "or paste this into your AI:" say " Install the self-host skill from github.com/${REPO} and help me set up my own home server." } # offer_skill asks over the terminal, never over stdin (which is the download # when piped), and only when a terminal can be opened. Without one, or with no # answer within a minute (a scheduled task can have a terminal and nobody at # it), it prints the hint. A failure here never fails an install that already # succeeded. offer_skill() { local tty="${SELF_HOST_TTY:-/dev/tty}" answer="" installed_binary="$1" if [ -z "${SELF_HOST_SKIP_SKILL_PROMPT:-}" ] && { exec 3<"$tty"; } 2>/dev/null; then printf 'Add the self-host skill to your AI agent too? y/n ' read -r -t 60 answer <&3 || answer="" say case "$answer" in y | Y | yes | YES | Yes) say "Running: ${installed_binary} skill install" if "$installed_binary" skill install <&3; then exec 3<&- return fi say "That did not finish." ;; esac exec 3<&- fi skill_hint } # docker_socket_warning only reads: it never runs sudo or changes groups, and # a failing probe never fails the install. docker_socket_warning() { local out out="$(docker info 2>&1 /dev/null 2>&1 || command -v wget >/dev/null 2>&1 || die "curl or wget is required but neither was found" need tar need uname configure local os arch tag version archive dir expected actual metadata asset_id name members os="$(detect_os)" arch="$(detect_arch)" case "${os}_${arch}" in linux_amd64 | linux_arm64 | darwin_amd64 | darwin_arm64) ;; *) die "no release is built for ${os}/${arch}" ;; esac if [ -n "${SELF_HOST_VERSION:-}" ]; then case "$SELF_HOST_VERSION" in new-cli-v*) version="${SELF_HOST_VERSION#new-cli-v}" ;; v*) version="${SELF_HOST_VERSION#v}" ;; *) version="$SELF_HOST_VERSION" ;; esac valid_version "$version" || die "expected a stable X.Y.Z version, vX.Y.Z or new-cli-vX.Y.Z tag" archive="self-host_${version}_${os}_${arch}.tar.gz" tmp="$(mktemp -d)" trap cleanup EXIT case "$SELF_HOST_VERSION" in new-cli-v*) resolve_release "$archive" "new-cli-v${version}" ;; *) resolve_release "$archive" "v${version}" "new-cli-v${version}" ;; esac else tmp="$(mktemp -d)" trap cleanup EXIT tag="$(latest_tag)" case "$tag" in new-cli-v*) version="${tag#new-cli-v}" ;; *) version="${tag#v}" ;; esac archive="self-host_${version}_${os}_${arch}.tar.gz" resolve_release "$archive" "$tag" fi dir="$(install_dir)" say "Downloading self-host ${version} for ${os}/${arch}" for name in "$archive" checksums.txt; do if with_token; then asset_id="$(printf '%s' "$metadata" | json_values "$name" asset)" [[ "$asset_id" =~ ^[1-9][0-9]*$ ]] || die "release ${tag} does not list ${name}" api_get "${API_URL}/releases/assets/${asset_id}" application/octet-stream > "${tmp}/${name}" || download_failed "$name" "${API_URL}/releases/assets/${asset_id}" else api_get "${WEB_URL}/releases/download/${tag}/${name}" application/octet-stream > "${tmp}/${name}" || download_failed "$name" "${WEB_URL}/releases/download/${tag}/${name}" fi [ -s "${tmp}/${name}" ] || die "downloaded ${name} is empty; nothing was installed" done expected="$(awk -v name="$archive" '$2 == name || $2 == "*" name { count++; digest=$1 } END { if (count == 1) print digest }' "${tmp}/checksums.txt")" [[ "$expected" =~ ^[a-fA-F0-9]{64}$ ]] || die "checksums.txt must list exactly one valid SHA256 for ${archive}" expected="$(printf '%s' "$expected" | tr 'A-F' 'a-f')" actual="$(sha256_of "${tmp}/${archive}")" if [ "$expected" != "$actual" ]; then die "checksum mismatch for ${archive}: expected ${expected}, got ${actual}; nothing was installed" fi # A checksum-valid archive must still contain one ordinary binary, never a link. members="$(tar -tvzf "${tmp}/${archive}" self-host)" || die "cannot inspect self-host in ${archive}" printf '%s\n' "$members" | awk '{ count++; if (substr($0, 1, 1) != "-") invalid=1 } END { exit (count != 1 || invalid) }' || die "${archive} must contain exactly one regular self-host binary; links are unsupported" tar -xzf "${tmp}/${archive}" -C "$tmp" self-host || die "${archive} does not contain a self-host binary" [ -f "${tmp}/self-host" ] && [ ! -L "${tmp}/self-host" ] || die "${archive} does not contain a regular self-host binary" mkdir -p "$dir" || die "cannot create ${dir}" [ -w "$dir" ] || die "cannot write to ${dir}; rerun with sudo or set SELF_HOST_INSTALL_DIR" # Install next to the target and rename, so a running self-host is replaced atomically. staging="$(mktemp "${dir}/.self-host.new.XXXXXX")" || die "cannot create an install staging file in ${dir}" cp "${tmp}/self-host" "$staging" || die "cannot write ${staging}" chmod 0755 "$staging" || die "cannot make ${staging} executable" mv -f "$staging" "${dir}/self-host" || die "cannot replace ${dir}/self-host" staging="" say "Installed ${dir}/self-host (${version}, checksum verified)" case ":${PATH}:" in *":${dir}:"*) ;; *) path_help "$dir" ;; esac if command -v docker >/dev/null 2>&1; then docker_socket_warning else say "Warning: Docker was not found. Install Docker with the Compose v2 plugin before deploying; self-host doctor checks it." fi say say "Next: run self-host to choose Apps, review the plan and approve installation." say "For an AI agent: read self-host skill and get approval for the plan before making changes." say offer_skill "${dir}/self-host" } main "$@"