fix: harden installer PATH/bootstrap wiring + warm update-check cache

Addresses reviewer feedback on PR #2954:
- install.sh: fail loudly if uv bootstrap download is empty/failed and verify
  uv is on PATH afterwards (no more masked "uv: command not found").
- install.sh (bash): also wire the applicable login profile
  (.bash_profile/.bash_login/.profile) so login shells find praisonai.
- install.sh (fish): guard fish_add_path with a POSIX-safe fallback for
  older fish versions.
- self_manage/app: schedule a detached, non-blocking background refresh of the
  update-check cache on start-up so the "update available" hint is actually
  populated (never performs network I/O in the caller, never blocks/raises).

Co-authored-by: Mervin Praison <[email protected]>
This commit is contained in:
praisonai-triage-agent[bot]andMervin Praison committed 2026-07-13 09:02:49 +00:00
1 parent a6ed37645c
commit 8abfb92573
3 files changed
+140 -22

No files matched your search

+83 -21
View File
@@ -34,31 +34,71 @@ detect_platform() {
info "Detected platform: ${os} ${arch}"
}
# Add a directory to PATH in the user's shell rc file, idempotently.
# Append an export line to a single rc file, idempotently. Returns 0 on success.
_append_path_line() {
rc="$1"
line="$2"
[ -n "${rc}" ] || return 1
if [ -f "${rc}" ] && grep -Fq "${bindir}" "${rc}" 2>/dev/null; then
return 0
fi
printf '\n# Added by PraisonAI installer\n%s\n' "${line}" >>"${rc}" 2>/dev/null
}
wire_path_dispatch() {
bindir="$1"
shell_name="$(basename "${SHELL:-sh}")"
updated=""
case "${shell_name}" in
zsh)
line="export PATH=\"${bindir}:\$PATH\""
_append_path_line "${HOME}/.zshrc" "${line}" && updated="${HOME}/.zshrc"
;;
fish)
mkdir -p "${HOME}/.config/fish" 2>/dev/null || true
rc="${HOME}/.config/fish/config.fish"
# `fish_add_path` is unavailable on older fish; guard it and provide a
# POSIX-safe fallback so PATH is wired regardless of fish version.
line="if type -q fish_add_path; fish_add_path ${bindir}; else; set -gx PATH ${bindir} \$PATH; end"
_append_path_line "${rc}" "${line}" && updated="${rc}"
;;
bash)
# Interactive non-login shells read ~/.bashrc; login shells read the
# first existing of ~/.bash_profile, ~/.bash_login, ~/.profile. Wire the
# interactive rc and whichever login profile applies so both work.
line="export PATH=\"${bindir}:\$PATH\""
_append_path_line "${HOME}/.bashrc" "${line}" && updated="${HOME}/.bashrc"
if [ -f "${HOME}/.bash_profile" ]; then
login_rc="${HOME}/.bash_profile"
elif [ -f "${HOME}/.bash_login" ]; then
login_rc="${HOME}/.bash_login"
else
login_rc="${HOME}/.profile"
fi
_append_path_line "${login_rc}" "${line}" \
&& updated="${updated:+${updated} }${login_rc}"
;;
*)
line="export PATH=\"${bindir}:\$PATH\""
_append_path_line "${HOME}/.profile" "${line}" && updated="${HOME}/.profile"
;;
esac
if [ -n "${updated}" ]; then
info "Added ${bindir} to PATH in ${updated} (restart your shell to apply)."
else
warn "Could not update your shell profile; add ${bindir} to your PATH manually."
fi
}
# Add a directory to PATH in the user's shell rc file(s), idempotently.
wire_path() {
bindir="$1"
case ":${PATH}:" in
*":${bindir}:"*) return 0 ;;
esac
shell_name="$(basename "${SHELL:-sh}")"
case "${shell_name}" in
zsh) rc="${HOME}/.zshrc"; line="export PATH=\"${bindir}:\$PATH\"" ;;
fish)
rc="${HOME}/.config/fish/config.fish"
line="fish_add_path ${bindir}"
mkdir -p "${HOME}/.config/fish" 2>/dev/null || true
;;
bash) rc="${HOME}/.bashrc"; line="export PATH=\"${bindir}:\$PATH\"" ;;
*) rc="${HOME}/.profile"; line="export PATH=\"${bindir}:\$PATH\"" ;;
esac
if [ -f "${rc}" ] && grep -Fq "${bindir}" "${rc}" 2>/dev/null; then
return 0
fi
printf '\n# Added by PraisonAI installer\n%s\n' "${line}" >>"${rc}" 2>/dev/null \
&& info "Added ${bindir} to PATH in ${rc} (restart your shell or 'source ${rc}')." \
|| warn "Could not update ${rc}; add ${bindir} to your PATH manually."
wire_path_dispatch "${bindir}"
}
spec() {
@@ -88,7 +128,21 @@ bootstrap_uv() {
else
info "No uv/pipx found; bootstrapping uv..."
fi
curl -fsSL https://astral.sh/uv/install.sh | sh
# Download the installer to a file first so a failed/empty download (which a
# piped `curl | sh` can silently swallow) surfaces here with a useful message
# instead of a later confusing `uv: command not found`.
tmp_installer="$(mktemp 2>/dev/null || echo "${TMPDIR:-/tmp}/uv-install.$$.sh")"
if ! curl -fsSL https://astral.sh/uv/install.sh -o "${tmp_installer}" \
|| [ ! -s "${tmp_installer}" ]; then
rm -f "${tmp_installer}" 2>/dev/null || true
err "Failed to download the uv bootstrap installer (network/DNS issue?)."
err "Install uv or pipx manually, then re-run this installer:"
err " https://docs.astral.sh/uv/getting-started/installation/"
exit 1
fi
sh "${tmp_installer}"
rm -f "${tmp_installer}" 2>/dev/null || true
# uv installs to ~/.local/bin or ~/.cargo/bin depending on platform.
if [ -x "${HOME}/.local/bin/uv" ]; then
PATH="${HOME}/.local/bin:${PATH}"
@@ -96,6 +150,14 @@ bootstrap_uv() {
PATH="${HOME}/.cargo/bin:${PATH}"
fi
export PATH
# Verify the bootstrap actually produced a working `uv`; otherwise fail loudly
# instead of proceeding to `uv tool install` with no uv on PATH.
if ! has uv; then
err "uv bootstrap completed but 'uv' is not on PATH."
err "Open a new shell or install uv/pipx manually, then re-run this installer."
exit 1
fi
}
main() {
+7 -1
View File
@@ -795,11 +795,17 @@ def main_callback(
# raises (only reads a time-boxed cache, never performs network I/O here).
if mode == OutputMode.TEXT and not state.quiet:
try:
from .features.self_manage import read_cached_hint
from .features.self_manage import (
maybe_schedule_update_check,
read_cached_hint,
)
hint = read_cached_hint()
if hint:
typer.echo(hint, err=True)
# Warm the cache in a detached child for the next invocation. Never
# performs network I/O here and never blocks or raises.
maybe_schedule_update_check()
except Exception:
pass
@@ -258,3 +258,53 @@ def refresh_update_cache(timeout: float = 5.0) -> None:
except Exception:
# A read-only home or unwritable state dir must never break the CLI.
return
def _cache_is_fresh() -> bool:
"""Return True when the update cache exists and is within its TTL."""
try:
path = _update_cache_path()
if not path.exists():
return False
data = json.loads(path.read_text(encoding="utf-8"))
checked_at = float(data.get("checked_at", 0))
return (time.time() - checked_at) <= _UPDATE_CHECK_TTL_SECONDS
except Exception:
return False
def maybe_schedule_update_check() -> None:
"""Warm the update cache in a fully detached background process.
Called once per CLI start. Performs **no** network I/O in the caller and
never blocks or raises: if the cache is missing/stale it spawns a
short-lived detached child that refreshes it for the *next* invocation. If
the cache is already fresh, or checks are disabled, this is a no-op.
"""
if update_check_disabled():
return
try:
if _cache_is_fresh():
return
# Detached, output-suppressed child so the parent CLI never waits on it.
code = (
"from praisonai_code.cli.features.self_manage import "
"refresh_update_cache; refresh_update_cache()"
)
kwargs = {
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
"stdin": subprocess.DEVNULL,
}
if os.name == "posix":
kwargs["start_new_session"] = True
else: # pragma: no cover - Windows-only detach flag
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) | getattr(
subprocess, "DETACHED_PROCESS", 0
)
if creationflags:
kwargs["creationflags"] = creationflags
subprocess.Popen([sys.executable, "-c", code], **kwargs)
except Exception:
# Best-effort only: a spawn failure must never break start-up.
return