install.sh
Rama main · réplica de lectura del repositorio original.
#!/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)" || true
case "$out" in
*[Pp]ermission\ denied*)
say "Warning: Docker is installed but your user may not use its socket (permission denied)."
say " Run: sudo \"\$(command -v self-host)\" (the Project stays yours; ~/.local/bin is not on sudo's PATH)."
say " Or use rootless Docker (no root; some Apps lose low ports, host networking or devices)."
say " The docker group works but gives root-equivalent access without a password: sudo usermod -aG docker \$USER"
;;
esac
}
main() {
command -v curl >/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 "$@"