mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-08-28 05:00:04 -04:00
feb5736dfb
Closes #5757 `DEEPAGENTS_HOME` now selects the dcode user profile and trust root at launch while preserving `~/.deepagents` as the default. --- This makes the configured home an immutable, normalized path captured before dotenv loading and shared by the client, server, reloads, and child processes. A central path snapshot separates profile data, installation-owned resources, and project configuration so cwd changes and mutable environment state cannot move the trust root. Absolute profiles also remain usable when the launch user's home cannot be resolved; optional home-based integrations are skipped in that case. Profile resolution rejects ambiguous or unsafe roots, including relative and `~user` forms, filesystem and launch-home aliases, dangling symlinks, non-directories, and unreadable or unsearchable directories. The installer applies the same validation as Python and changes ownership only for exact leaves it creates. MCP discovery now retains explicit user or project provenance. Only the exact configured user `.mcp.json` receives user-level trust, and filesystem aliases or collisions fail closed to project scope. This prevents project dotenv files, ancestor homes, checkout-contained profiles, and case or symlink aliases from self-approving project MCP servers. Install and update locks and managed ripgrep prefer installation-scoped locations, with profile-scoped fallbacks when the shared locations are unusable. A fallback ripgrep is checksum-verified and exposed through a process-private `PATH` shim so profile-controlled sibling executables never enter subprocess lookup. Runtime consumers, prompts, bundled skills, UI messages, diagnostics, and token permission hints use the effective configured paths, and failed write-probe cleanup is surfaced without repeated warnings. <details> <summary>Test plan</summary> - Added deterministic path, dotenv, cwd, reload, client/server, missing-home, and subprocess regressions. - Added root-validation and installer parity coverage, including symlink, permission, ownership, and write-probe cases. - Added MCP provenance, filesystem-identity, trust-classification, and project self-approval security regressions. - Added shared-lock, managed-ripgrep fallback, checksum, private-`PATH`-shim, and optional-ripgrep regressions. - Added prompt, bundled-skill, diagnostics, and MCP token-path consumer regressions. - Current-head validation is covered by the `deepagents-code` lint job and Python 3.12–3.14 test matrix. </details> --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
3857 lines
166 KiB
Bash
Executable File
3857 lines
166 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# Install deepagents-code.
|
||
#
|
||
# Usage:
|
||
# curl -LsSf https://langch.in/dcode | bash
|
||
# curl -LsSf https://langch.in/dcode | bash -s -- VERSION
|
||
#
|
||
# Install an exact pre-release version:
|
||
# curl -LsSf https://langch.in/dcode | DEEPAGENTS_CODE_VERSION="0.1.0rc1" bash
|
||
# curl -LsSf https://langch.in/dcode | bash -s -- 0.1.0rc1
|
||
#
|
||
# Override uv's pre-release strategy when resolving the latest version:
|
||
# curl -LsSf https://langch.in/dcode | DEEPAGENTS_CODE_PRERELEASE="allow" bash
|
||
#
|
||
# Options:
|
||
# --help, -h Show this help message and exit
|
||
# --version, -v Print installer version and exit
|
||
#
|
||
# By default, the installer uses uv's `allow` pre-release strategy so stable
|
||
# deepagents-code releases that pin a pre-release dependency can resolve.
|
||
# DEEPAGENTS_CODE_VERSION and an explicit DEEPAGENTS_CODE_PRERELEASE are mutually
|
||
# exclusive: an exact pin already selects a single version, so setting both is an
|
||
# error.
|
||
#
|
||
# Already installed?
|
||
# Safe to re-run. If a newer version exists, it asks before upgrading — or
|
||
# upgrades on its own when run unattended (cron/CI/Docker). If you're already
|
||
# on the latest, it does nothing. To skip the prompt:
|
||
# - DEEPAGENTS_CODE_YES=1 accept the upgrade
|
||
# - DEEPAGENTS_CODE_VERSION / _PRERELEASE install that exact selection
|
||
# - DEEPAGENTS_CODE_EXTRAS / _PYTHON rebuild with those options
|
||
#
|
||
# Uninstall:
|
||
# This script installs deepagents-code as a uv tool. To remove it:
|
||
# uv tool uninstall deepagents-code
|
||
# That removes the dcode/deepagents-code binary and its isolated venv.
|
||
# User config and data live in the effective DEEPAGENTS_HOME (default:
|
||
# ~/.deepagents) and are NOT removed by the uninstall above. Before
|
||
# uninstalling, run `dcode doctor` and record its exact "Data directory".
|
||
# To erase the profile too, remove only that confirmed dedicated directory;
|
||
# never recursively remove `/`, your home, a checkout, or an unresolved env
|
||
# expression. Managed support binaries live in the tool environment and are
|
||
# removed with it.
|
||
# Optionally clear uv's shared tool cache (~/.cache/uv on Linux,
|
||
# ~/Library/Caches/uv on macOS) — only if no other uv tools rely on it.
|
||
#
|
||
# Environment variables:
|
||
# DEEPAGENTS_HOME — user config and data directory (default: ~/.deepagents)
|
||
# DEEPAGENTS_CODE_EXTRAS — comma-separated pip extras, e.g. "ollama",
|
||
# "ollama,groq", or "daytona". Valid extras (see pyproject.toml for the
|
||
# authoritative list):
|
||
# Model providers: anthropic, baseten, bedrock, cohere, deepseek,
|
||
# fireworks, google-genai, groq, huggingface, ibm, litellm, mistralai,
|
||
# nvidia, ollama, openai, openrouter, perplexity, together, vertex, xai,
|
||
# all-providers
|
||
# Sandbox providers: agentcore, daytona, modal, runloop, vercel,
|
||
# all-sandboxes
|
||
# Standalone integrations: media, quickjs
|
||
# DEEPAGENTS_CODE_VERSION — exact version to install, e.g. "0.1.0rc1"
|
||
# (mutually exclusive with DEEPAGENTS_CODE_PRERELEASE)
|
||
# DEEPAGENTS_CODE_PRERELEASE — uv pre-release strategy applied when
|
||
# resolving the latest version: disallow, allow, if-necessary, explicit,
|
||
# or if-necessary-or-explicit (default: allow; explicitly setting it is
|
||
# mutually exclusive with DEEPAGENTS_CODE_VERSION)
|
||
# DEEPAGENTS_CODE_PYTHON — Python version to use (default: 3.13)
|
||
# DEEPAGENTS_CODE_YES — set to 1 to accept an available update without
|
||
# prompting (assume "yes"). Exists so automated runs that still attach a
|
||
# terminal (CI, wrapper scripts) update instead of stalling at the y/n
|
||
# prompt.
|
||
# DEEPAGENTS_CODE_SKIP_OPTIONAL — set to 1 to skip optional tool checks
|
||
# DEEPAGENTS_CODE_RIPGREP_INSTALLER — how to provision ripgrep:
|
||
# "managed" (default) eagerly installs the pinned, SHA-256-verified binary
|
||
# inside the dcode tool environment via `dcode tools install`; "system"
|
||
# keeps the interactive package-manager install (brew/apt/cargo/...),
|
||
# which only counts as success when the resulting `rg` meets the minimum
|
||
# supported version. Set DEEPAGENTS_CODE_OFFLINE=1 to skip the managed
|
||
# download entirely.
|
||
#
|
||
# Before execution, the downloaded uv bootstrap script is checked for a shell
|
||
# shebang and valid shell syntax. These structural checks help catch error
|
||
# pages and some truncated downloads, but do not verify its integrity.
|
||
# DEEPAGENTS_CODE_SKIP_XCODE_CHECK — set to 1 to bypass the macOS Xcode
|
||
# Command Line Tools preflight check
|
||
# DEEPAGENTS_CODE_NO_MODIFY_PATH — set to 1 to skip PATH setup entirely
|
||
# (no profile edits, no symlinks). The binary is still installed and
|
||
# verified; add the tool bin dir to PATH yourself, or run it by its
|
||
# absolute path. Intended for version-managed dotfiles and MDM fleets.
|
||
# DEEPAGENTS_CODE_VERBOSE — set to 1 to show uv's raw stderr (timing lines,
|
||
# unfiltered package diff), the uv installer's own output (shown only when
|
||
# uv isn't already installed), and the quiet-by-default status lines
|
||
# (optional-tool checks, post-install footer); useful when debugging. A
|
||
# fresh install otherwise hides the full list of installed dependencies.
|
||
# UV_BIN — path to uv binary (auto-detected if unset)
|
||
#
|
||
# Credits:
|
||
# Interactive mode detection, color logging, and optional tool install
|
||
# patterns adapted from hermes-agent (NousResearch/hermes-agent).
|
||
# Snap curl detection, shell-profile PATH modification, and symlink-first
|
||
# PATH setup adapted from Amp (https://ampcode.com/install.sh).
|
||
# Multi-profile shell coverage, the fish conf.d file, ZDOTDIR resolution,
|
||
# and the no-modify-PATH escape hatch adapted from muse
|
||
# (https://dev.meta.ai/install.sh).
|
||
|
||
set -euo pipefail
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# CLI flags — --help / --version short-circuit before any install work
|
||
# ---------------------------------------------------------------------------
|
||
INSTALLER_VERSION="deepagents-code installer 1.0"
|
||
|
||
print_help() {
|
||
cat <<'HELP'
|
||
Install deepagents-code.
|
||
|
||
Usage:
|
||
curl -LsSf https://langch.in/dcode | bash
|
||
curl -LsSf https://langch.in/dcode | bash -s -- [options]
|
||
curl -LsSf https://langch.in/dcode | bash -s -- VERSION
|
||
|
||
Options:
|
||
--help, -h Show this help message and exit
|
||
--version, -v Print installer version and exit
|
||
|
||
Target:
|
||
VERSION Install an exact version, e.g. 0.1.0rc1
|
||
|
||
Environment variables:
|
||
DEEPAGENTS_HOME — user config and data directory (default: ~/.deepagents)
|
||
DEEPAGENTS_CODE_EXTRAS — comma-separated pip extras, e.g. "ollama",
|
||
"ollama,groq", or "daytona". Valid extras (see pyproject.toml for the
|
||
authoritative list):
|
||
Model providers: anthropic, baseten, bedrock, cohere, deepseek,
|
||
fireworks, google-genai, groq, huggingface, ibm, litellm, mistralai,
|
||
nvidia, ollama, openai, openrouter, perplexity, together, vertex, xai,
|
||
all-providers
|
||
Sandbox providers: agentcore, daytona, modal, runloop, vercel,
|
||
all-sandboxes
|
||
Standalone integrations: media, quickjs
|
||
DEEPAGENTS_CODE_VERSION — exact version to install, e.g. "0.1.0rc1"
|
||
(mutually exclusive with DEEPAGENTS_CODE_PRERELEASE)
|
||
DEEPAGENTS_CODE_PRERELEASE — uv pre-release strategy applied when
|
||
resolving the latest version: disallow, allow, if-necessary, explicit,
|
||
or if-necessary-or-explicit (default: allow; explicitly setting it is
|
||
mutually exclusive with DEEPAGENTS_CODE_VERSION)
|
||
DEEPAGENTS_CODE_PYTHON — Python version to use (default: 3.13)
|
||
DEEPAGENTS_CODE_YES — set to 1 to accept an available update without
|
||
prompting (assume "yes")
|
||
DEEPAGENTS_CODE_SKIP_OPTIONAL — set to 1 to skip optional tool checks
|
||
DEEPAGENTS_CODE_RIPGREP_INSTALLER — how to provision ripgrep:
|
||
"managed" (default) eagerly installs the pinned, SHA-256-verified binary
|
||
inside the dcode tool environment via `dcode tools install`; "system"
|
||
keeps the interactive package-manager install (brew/apt/cargo/...). Set
|
||
DEEPAGENTS_CODE_OFFLINE=1 to skip the managed download entirely.
|
||
DEEPAGENTS_CODE_SKIP_XCODE_CHECK — set to 1 to bypass the macOS Xcode
|
||
Command Line Tools preflight check
|
||
DEEPAGENTS_CODE_NO_MODIFY_PATH — set to 1 to skip PATH setup entirely
|
||
(no profile edits, no symlinks)
|
||
DEEPAGENTS_CODE_VERBOSE — set to 1 to show uv's raw stderr and additional
|
||
status lines
|
||
UV_BIN — path to uv binary (auto-detected if unset)
|
||
|
||
For full documentation: https://docs.langchain.com/deepagents-code
|
||
HELP
|
||
}
|
||
|
||
POSITIONAL_TARGET=""
|
||
for _arg in "$@"; do
|
||
case "$_arg" in
|
||
--help|-h)
|
||
print_help
|
||
exit 0
|
||
;;
|
||
--version|-v)
|
||
printf '%s\n' "$INSTALLER_VERSION"
|
||
exit 0
|
||
;;
|
||
-*)
|
||
# Reject unknown flags (single- or double-dash) instead of silently
|
||
# ignoring them, so a typo (e.g. --verison, -V) surfaces as an error
|
||
# rather than a silent full install. Matching every dash-led token here
|
||
# also keeps such typos out of the positional-target arm below, where a
|
||
# leading dash would otherwise be reported as an "invalid version".
|
||
# log_* helpers aren't defined yet at this point, so write plainly.
|
||
printf 'Unrecognized argument: %s\n' "$_arg" >&2
|
||
printf 'Run with --help to see available options.\n' >&2
|
||
exit 2
|
||
;;
|
||
*)
|
||
if [ -n "$POSITIONAL_TARGET" ]; then
|
||
printf 'Only one target is allowed. Got both %s and %s.\n' "$POSITIONAL_TARGET" "$_arg" >&2
|
||
printf 'Run with --help to see available options.\n' >&2
|
||
exit 2
|
||
fi
|
||
# Same validation (and rationale) as the DEEPAGENTS_CODE_VERSION check
|
||
# further down: require a leading alphanumeric and a class free of shell
|
||
# metacharacters, so the value is a version, not a smuggled option, and is
|
||
# safe to interpolate into the single argv token passed to uv.
|
||
if [[ ! "$_arg" =~ ^[A-Za-z0-9][A-Za-z0-9_.!+-]*$ ]]; then
|
||
printf 'Invalid version target: %s\n' "$_arg" >&2
|
||
printf 'Use an exact version like 0.1.0rc1.\n' >&2
|
||
exit 2
|
||
fi
|
||
POSITIONAL_TARGET="$_arg"
|
||
;;
|
||
esac
|
||
done
|
||
|
||
# Registry of temporary paths to clean up on exit or interrupt. Functions that
|
||
# create them append their paths here; the signal handlers remove them all.
|
||
TEMP_FILES=()
|
||
TEMP_DIRS=()
|
||
INSTALL_LOCK_KIND=""
|
||
INSTALL_LOCK_DIR=""
|
||
INSTALL_LOCK_TOKEN=""
|
||
INSTALL_LOCK_STALE_ID=""
|
||
INSTALL_LOCK_RECLAIM_DIR=""
|
||
INSTALL_LOCK_RECLAIM_TOKEN=""
|
||
# How old a mkdir-based lock (dead/unknown holder) must be before it's treated
|
||
# as abandoned and reclaimed. 10 min comfortably exceeds a normal install.
|
||
INSTALL_LOCK_STALE_AFTER_SECS=600
|
||
register_temp() {
|
||
TEMP_FILES+=("$1")
|
||
}
|
||
register_temp_dir() {
|
||
TEMP_DIRS+=("$1")
|
||
}
|
||
cleanup_temp_files() {
|
||
for f in "${TEMP_FILES[@]:-}"; do
|
||
rm -f "$f" 2>/dev/null || true
|
||
done
|
||
}
|
||
cleanup_temp_dirs() {
|
||
for dir in "${TEMP_DIRS[@]:-}"; do
|
||
rm -rf "$dir" 2>/dev/null || true
|
||
done
|
||
}
|
||
|
||
# Keep the shell PATH the user started with. The installer may source
|
||
# ~/.local/bin/env later so it can find a freshly installed uv, but that does
|
||
# not update the parent shell that will receive the final "Run: dcode" advice.
|
||
ORIGINAL_PATH="${PATH:-}"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Colors & logging
|
||
# ---------------------------------------------------------------------------
|
||
if [ -t 1 ] || [ "${FORCE_COLOR:-}" = "1" ]; then
|
||
RED='\033[0;31m'
|
||
GREEN='\033[0;32m'
|
||
YELLOW='\033[0;33m'
|
||
CYAN='\033[0;36m'
|
||
BOLD='\033[1m'
|
||
NC='\033[0m'
|
||
else
|
||
RED='' GREEN='' YELLOW='' CYAN='' BOLD='' NC=''
|
||
fi
|
||
|
||
log_info() { printf "${CYAN}▸${NC} %s\n" "$*"; }
|
||
log_success() { printf "${GREEN}✔${NC} %s\n" "$*"; }
|
||
log_warn() { printf "${YELLOW}⚠${NC} %s\n" "$*" >&2; }
|
||
log_error() { printf "${RED}✖${NC} %s\n" "$*" >&2; }
|
||
|
||
is_linux_os() {
|
||
[ "${OS:-}" = "linux" ] || [ "$(uname -s 2>/dev/null)" = "Linux" ]
|
||
}
|
||
|
||
restore_terminal_after_signal() {
|
||
local exit_code="$1"
|
||
if [ "$exit_code" -ge 128 ] && [ -t 0 ]; then
|
||
stty sane 2>/dev/null || true
|
||
fi
|
||
}
|
||
|
||
log_signal_failure_hint() {
|
||
local exit_code="$1"
|
||
if [ "${SIGNAL_FAILURE_HINT_SHOWN:-false}" = true ]; then
|
||
return 0
|
||
fi
|
||
if [ "$exit_code" -eq 137 ] && is_linux_os; then
|
||
log_error "Installation was killed before it could finish (exit code 137). This usually means the system ran out of memory."
|
||
log_error "Free up memory or use a machine with more memory, then run this installer again."
|
||
SIGNAL_FAILURE_HINT_SHOWN=true
|
||
elif [ "$exit_code" -ge 128 ]; then
|
||
log_error "Installation was killed before it could finish (exit code ${exit_code})."
|
||
SIGNAL_FAILURE_HINT_SHOWN=true
|
||
fi
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Exit / interrupt traps — ensures the user always sees an actionable message
|
||
# on failure and temp files are cleaned up on Ctrl-C / SIGTERM.
|
||
# ---------------------------------------------------------------------------
|
||
# A live run publishes an empty log over the previous one before uv starts, so
|
||
# an abort between those two points destroys yesterday's diagnostics. The
|
||
# post-install check says so on the normal path; the handlers below cover
|
||
# Ctrl-C and any `set -e` abort, which never reach it. The flag keeps the two
|
||
# sites from both speaking on a run that reaches the post-install check and
|
||
# then exits non-zero.
|
||
LIVE_LOG_REPLACED_NOTICE_DONE=false
|
||
warn_live_log_replaced() {
|
||
[ "${LIVE_LOG_REPLACED_NOTICE_DONE:-false}" = false ] || return 0
|
||
[ "${UV_LIVE_LOG:-false}" = true ] || return 0
|
||
[ -n "${INSTALL_LOG:-}" ] || return 0
|
||
[ -f "$INSTALL_LOG" ] && [ ! -L "$INSTALL_LOG" ] || return 0
|
||
[ ! -s "$INSTALL_LOG" ] || return 0
|
||
LIVE_LOG_REPLACED_NOTICE_DONE=true
|
||
log_warn "${INSTALL_LOG_DISPLAY} is empty — any previous install log was replaced."
|
||
}
|
||
|
||
cleanup_on_signal() {
|
||
local exit_code=$?
|
||
cleanup_temp_files
|
||
cleanup_temp_dirs
|
||
if declare -F release_install_lock >/dev/null 2>&1; then
|
||
release_install_lock
|
||
fi
|
||
if [ $exit_code -ne 0 ]; then
|
||
restore_terminal_after_signal "$exit_code"
|
||
echo "" >&2
|
||
log_signal_failure_hint "$exit_code"
|
||
warn_live_log_replaced
|
||
log_error "Installation failed (exit code ${exit_code}). See errors above."
|
||
log_error "For help, visit: https://docs.langchain.com/deepagents-code"
|
||
fi
|
||
}
|
||
trap cleanup_on_signal EXIT
|
||
|
||
cleanup_on_interrupt() {
|
||
# Default to SIGINT. This runs as a signal handler, so an unbound $1 under
|
||
# `set -u` would abort partway through and let the EXIT trap print the
|
||
# contradictory "Installation failed" line that disarming it exists to
|
||
# prevent — the interrupt path must never depend on the caller's argument.
|
||
local sig="${1:-2}"
|
||
local exit_code=$((128 + sig))
|
||
# Disarm the EXIT trap first: exiting from here would otherwise also fire
|
||
# cleanup_on_signal, appending a contradictory "Installation failed" message
|
||
# after the friendly interrupt notice below. Temp files are still cleaned up
|
||
# explicitly here, so nothing leaks despite the disarm.
|
||
trap - EXIT
|
||
if [ -t 0 ]; then
|
||
stty sane 2>/dev/null || true
|
||
fi
|
||
echo "" >&2
|
||
log_warn "Installation interrupted."
|
||
warn_live_log_replaced
|
||
cleanup_temp_files
|
||
cleanup_temp_dirs
|
||
if declare -F release_install_lock >/dev/null 2>&1; then
|
||
release_install_lock
|
||
fi
|
||
# Exit with the conventional 128+signal code so callers (CI, wrappers) can
|
||
# distinguish an interrupted install from an ordinary failure.
|
||
exit "$exit_code"
|
||
}
|
||
trap 'cleanup_on_interrupt 2' INT
|
||
trap 'cleanup_on_interrupt 15' TERM
|
||
trap 'cleanup_on_interrupt 1' HUP
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Interactive mode detection
|
||
# ---------------------------------------------------------------------------
|
||
# When piped (curl | bash), stdin is not a terminal, but /dev/tty may still be
|
||
# available for prompts. IS_INTERACTIVE controls whether we ask the user
|
||
# questions; we never block a piped install on missing input.
|
||
IS_INTERACTIVE=false
|
||
if [ -t 0 ]; then
|
||
IS_INTERACTIVE=true
|
||
elif [ -r /dev/tty ]; then
|
||
# piped install but terminal is readable — can prompt via /dev/tty
|
||
IS_INTERACTIVE=true
|
||
fi
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# OS / platform detection
|
||
# ---------------------------------------------------------------------------
|
||
detect_os() {
|
||
case "$(uname -s)" in
|
||
Darwin) OS="macos" ;;
|
||
Linux)
|
||
# shellcheck disable=SC2034
|
||
# shellcheck disable=SC1091
|
||
DISTRO=$(. /etc/os-release 2>/dev/null && echo "${ID:-unknown}" || echo "unknown")
|
||
OS="linux"
|
||
;;
|
||
MINGW*|MSYS*|CYGWIN*)
|
||
OS="windows" ;;
|
||
*) OS="unknown" ;;
|
||
esac
|
||
}
|
||
detect_os
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# macOS: require Xcode Command Line Tools
|
||
# ---------------------------------------------------------------------------
|
||
# On a fresh Mac the /usr/bin shims for git, python3, etc. are stubs that pop a
|
||
# blocking GUI dialog ("...requires the command line developer tools") the first
|
||
# time they run. uv's interpreter discovery and dcode's own git usage hit those
|
||
# stubs, so fail fast here with a clear instruction instead of leaving the user
|
||
# staring at a confusing popup mid-install. `xcode-select -p` only reports the
|
||
# active developer dir — it never triggers the install dialog itself.
|
||
if [ "$OS" = "macos" ] && [ "${DEEPAGENTS_CODE_SKIP_XCODE_CHECK:-}" != "1" ] && ! xcode-select -p >/dev/null 2>&1; then
|
||
log_error "Xcode Command Line Tools are required but not installed."
|
||
log_error " Install them with: xcode-select --install"
|
||
log_error " To bypass this check, set: DEEPAGENTS_CODE_SKIP_XCODE_CHECK=1"
|
||
log_error " Then re-run this installer."
|
||
exit 1
|
||
fi
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Root / MDM support (macOS — Kandji, Jamf, etc.)
|
||
# ---------------------------------------------------------------------------
|
||
# MDM tools run scripts as root in a minimal environment where HOME may be
|
||
# unset or point to /var/root. Resolve the real console user's home so uv
|
||
# and dcode install to the right place.
|
||
if [ "$OS" = "macos" ] && { [ -z "${HOME:-}" ] || [ "$(id -u)" -eq 0 ]; }; then
|
||
CONSOLE_USER="$(stat -f '%Su' /dev/console 2>/dev/null)" || {
|
||
log_warn "Could not determine console user via /dev/console. Falling back to directory scan."
|
||
CONSOLE_USER=""
|
||
}
|
||
|
||
if [ -n "$CONSOLE_USER" ] && [ "$CONSOLE_USER" != "root" ]; then
|
||
if [ -d "/Users/$CONSOLE_USER" ]; then
|
||
HOME="/Users/$CONSOLE_USER"
|
||
else
|
||
log_warn "Console user ${CONSOLE_USER} home /Users/${CONSOLE_USER} does not exist. Falling back to directory scan."
|
||
CONSOLE_USER=""
|
||
fi
|
||
fi
|
||
|
||
# Console user is root or undetectable (MDM enrollment, single-user mode,
|
||
# headless session) — fall back to scanning /Users.
|
||
if [ -z "${CONSOLE_USER:-}" ] || [ "$CONSOLE_USER" = "root" ]; then
|
||
candidates="$(find /Users -mindepth 1 -maxdepth 1 -type d \
|
||
! -name root ! -name Shared ! -name '.*' | sort)"
|
||
count="$(echo "$candidates" | grep -c . || true)"
|
||
if [ "$count" -eq 1 ]; then
|
||
HOME="$candidates"
|
||
elif [ "$count" -gt 1 ]; then
|
||
log_error "Multiple user directories found and no console user detected."
|
||
log_error " Set HOME explicitly: HOME=/Users/yourname curl ... | bash"
|
||
exit 1
|
||
else
|
||
log_error "Could not determine user home directory. No user directories in /Users."
|
||
exit 1
|
||
fi
|
||
fi
|
||
|
||
export HOME
|
||
fi
|
||
|
||
paths_are_same_file() {
|
||
[ "$1" = "$2" ] || [ "$1" -ef "$2" ]
|
||
}
|
||
|
||
path_has_unsearchable_ancestor() {
|
||
local current="$1"
|
||
while [ "$current" != "/" ] && [ "$current" != "//" ]; do
|
||
case "$current" in
|
||
//*)
|
||
current="${current%/*}"
|
||
[ "$current" != "/" ] || current="//"
|
||
;;
|
||
*)
|
||
current="${current%/*}"
|
||
[ -n "$current" ] || current="/"
|
||
;;
|
||
esac
|
||
if [ -d "$current" ] && [ ! -x "$current" ]; then
|
||
return 0
|
||
fi
|
||
done
|
||
return 1
|
||
}
|
||
|
||
# Normalize a POSIX absolute path lexically without requiring it to exist.
|
||
# This mirrors `_paths._normalize_absolute` (i.e. `os.path.normpath`): repeated
|
||
# separators and `.` / `..` components are collapsed, but no symlink or
|
||
# filesystem lookup is performed. A leading exactly-double slash is preserved,
|
||
# because POSIX leaves `//` implementation-defined and `os.path.normpath` keeps
|
||
# it — collapsing it here would make the installer and every later launch
|
||
# disagree about which directory one DEEPAGENTS_HOME value names.
|
||
# `tests/unit_tests/test_install_script.py` asserts parity against the Python
|
||
# implementation directly, so both sides stay in step.
|
||
normalize_absolute_path() {
|
||
local rest="$1"
|
||
local normalized="/"
|
||
local root="/"
|
||
local part
|
||
case "$rest" in
|
||
//[!/]*|//) normalized="//"; root="//"; rest="${rest#//}" ;;
|
||
/*) rest="${rest#/}" ;;
|
||
*) return 1 ;;
|
||
esac
|
||
while [ -n "$rest" ]; do
|
||
part="${rest%%/*}"
|
||
if [ "$rest" = "$part" ]; then
|
||
rest=""
|
||
else
|
||
rest="${rest#*/}"
|
||
fi
|
||
case "$part" in
|
||
""|.) ;;
|
||
..)
|
||
if [ "$normalized" != "$root" ]; then
|
||
normalized="${normalized%/*}"
|
||
# Stripping the last component of a one-deep path leaves "" (root
|
||
# "/") or "/" (root "//"); both mean "back at the root".
|
||
case "$normalized" in
|
||
""|/) normalized="$root" ;;
|
||
esac
|
||
fi
|
||
;;
|
||
*)
|
||
case "$normalized" in
|
||
/|//) normalized="${normalized}${part}" ;;
|
||
*) normalized="${normalized}/${part}" ;;
|
||
esac
|
||
;;
|
||
esac
|
||
done
|
||
printf '%s' "$normalized"
|
||
}
|
||
|
||
normalize_deepagents_home() {
|
||
local raw="${DEEPAGENTS_HOME:-}"
|
||
local candidate normalized_home
|
||
case "$raw" in
|
||
"") candidate="${HOME}/.deepagents" ;;
|
||
\~/*) candidate="${HOME}/${raw#\~/}" ;;
|
||
\~*)
|
||
log_error "Invalid DEEPAGENTS_HOME: use an absolute path or a path beginning with '~/' ('~user' forms are not allowed)."
|
||
exit 1
|
||
;;
|
||
/*) candidate="$raw" ;;
|
||
*)
|
||
log_error "Invalid DEEPAGENTS_HOME '${raw}': use an absolute path or a path beginning with '~/'."
|
||
exit 1
|
||
;;
|
||
esac
|
||
if ! DEEPAGENTS_HOME="$(normalize_absolute_path "$candidate")"; then
|
||
log_error "Invalid DEEPAGENTS_HOME '${raw}': the resolved path must be absolute."
|
||
exit 1
|
||
fi
|
||
# Same degenerate-root rejections as `_paths._reject_degenerate_root`, so the
|
||
# installer fails here rather than provisioning a profile the app refuses to
|
||
# launch from.
|
||
if paths_are_same_file "$DEEPAGENTS_HOME" "/"; then
|
||
log_error "Invalid DEEPAGENTS_HOME '${raw}': the filesystem root cannot be a profile. Use a dedicated directory."
|
||
exit 1
|
||
fi
|
||
# Fail rather than compare against an unnormalized value. The app treats a
|
||
# non-absolute home as fatal (`_paths._resolve_launch_home`), so falling back
|
||
# to the raw string here would make this rejection unreliable in exactly the
|
||
# environment where the two layers must agree.
|
||
if ! normalized_home="$(normalize_absolute_path "$HOME")"; then
|
||
log_error "Cannot resolve the home directory '${HOME}': \$HOME must be an absolute path."
|
||
exit 1
|
||
fi
|
||
if paths_are_same_file "$DEEPAGENTS_HOME" "$normalized_home"; then
|
||
log_error "Invalid DEEPAGENTS_HOME '${raw}': the home directory itself cannot be a profile, because its '.env' would be loaded as trusted configuration. Use a subdirectory such as '~/.deepagents'."
|
||
exit 1
|
||
fi
|
||
if [ -L "$DEEPAGENTS_HOME" ] && [ ! -e "$DEEPAGENTS_HOME" ]; then
|
||
log_error "Invalid DEEPAGENTS_HOME '${raw}': is a symlink whose target is missing or cannot be resolved."
|
||
exit 1
|
||
fi
|
||
if [ -e "$DEEPAGENTS_HOME" ] && [ ! -d "$DEEPAGENTS_HOME" ]; then
|
||
log_error "Invalid DEEPAGENTS_HOME '${raw}': exists but is not a directory."
|
||
exit 1
|
||
fi
|
||
if [ -d "$DEEPAGENTS_HOME" ] && { [ ! -r "$DEEPAGENTS_HOME" ] || [ ! -x "$DEEPAGENTS_HOME" ]; }; then
|
||
log_error "Invalid DEEPAGENTS_HOME '${raw}': exists but cannot be read or searched. Check the permissions on it and on its parent directories."
|
||
exit 1
|
||
fi
|
||
if [ ! -e "$DEEPAGENTS_HOME" ] && path_has_unsearchable_ancestor "$DEEPAGENTS_HOME"; then
|
||
log_error "Invalid DEEPAGENTS_HOME '${raw}': cannot be inspected because a parent directory cannot be searched. Check the permissions on its parent directories."
|
||
exit 1
|
||
fi
|
||
export DEEPAGENTS_HOME
|
||
if [ -n "$raw" ]; then
|
||
log_info "Using DEEPAGENTS_HOME: ${DEEPAGENTS_HOME}"
|
||
fi
|
||
}
|
||
|
||
normalize_deepagents_home
|
||
UV_TOOL_DIR_ENV="${UV_TOOL_DIR:-}"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Ownership fix for root installs
|
||
# ---------------------------------------------------------------------------
|
||
# When running as root, files created under $HOME will be owned by root.
|
||
# Resolve the target user so we can fix ownership after install steps.
|
||
# When not root, the exact-path ownership helper is a no-op.
|
||
if [ "$(id -u)" -eq 0 ]; then
|
||
if [ "$OS" = "macos" ]; then
|
||
# Reuse CONSOLE_USER from above; fall back to basename of the
|
||
# already-resolved HOME (not a second stat call).
|
||
TARGET_USER="${CONSOLE_USER:-$(basename "$HOME")}"
|
||
[ "$TARGET_USER" = "root" ] && TARGET_USER="$(basename "$HOME")"
|
||
else
|
||
TARGET_USER="${SUDO_USER:-$(basename "$HOME")}"
|
||
fi
|
||
|
||
if [ -z "$TARGET_USER" ] || [ "$TARGET_USER" = "root" ]; then
|
||
log_warn "Could not determine non-root target user. Files under ${HOME} may remain owned by root."
|
||
log_warn " Re-run as the target user, or repair only the exact installed paths reported above."
|
||
fix_file_owner() { :; }
|
||
fix_tree_owner() { :; }
|
||
else
|
||
fix_file_owner() {
|
||
local path
|
||
for path in "$@"; do
|
||
if { [ -e "$path" ] || [ -L "$path" ]; } && ! chown -h "$TARGET_USER" "$path" 2>&1; then
|
||
log_warn "Could not fix ownership of $path for user ${TARGET_USER}."
|
||
fi
|
||
done
|
||
}
|
||
# Repair a directory tree this installer created. Chowning only the
|
||
# directory inode leaves everything inside it root-owned, which succeeds
|
||
# silently and then breaks the target user's next `uv tool upgrade` from a
|
||
# completely unrelated code path. Callers must gate this on
|
||
# `path_is_under_home`, and on a `*_PREEXISTED` flag unless the tree is one
|
||
# the installer owns outright (its own uv tool environment), so it only
|
||
# ever walks a tree this installer is responsible for under the target
|
||
# user's home. `-xdev` keeps it off other filesystems, and `-h` never
|
||
# follows a symlink out of the tree.
|
||
fix_tree_owner() {
|
||
local path
|
||
for path in "$@"; do
|
||
[ -d "$path" ] || continue
|
||
if ! find "$path" -xdev -exec chown -h "$TARGET_USER" {} + 2>&1; then
|
||
log_warn "Could not fix ownership of the tree at $path for user ${TARGET_USER}."
|
||
log_warn " Run: sudo chown -R ${TARGET_USER} $path"
|
||
fi
|
||
done
|
||
}
|
||
fi
|
||
else
|
||
fix_file_owner() { :; }
|
||
fix_tree_owner() { :; }
|
||
fi
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Prompt helper — reads from /dev/tty when stdin is piped
|
||
# ---------------------------------------------------------------------------
|
||
# prompt_yn QUESTION — three outcomes, so callers can tell a declined prompt
|
||
# apart from one that could never be shown:
|
||
# 0 — answered yes
|
||
# 1 — answered no (or empty)
|
||
# 2 — no terminal exists to ask on (non-interactive, or /dev/tty unusable)
|
||
# Plain `if prompt_yn ...` keeps working unchanged: 0 is yes, anything else is
|
||
# not-yes. Only callers that act differently on "no terminal" vs "no" check
|
||
# for 2 explicitly.
|
||
prompt_yn() {
|
||
local question="$1"
|
||
if [ "$IS_INTERACTIVE" = false ]; then
|
||
return 2
|
||
fi
|
||
local reply=""
|
||
if [ -t 0 ]; then
|
||
printf "%s [y/N] " "$question"
|
||
# `read` reports failure on a final line with no trailing newline but still
|
||
# assigns what it did read, so an answer typed before Ctrl-D must not be
|
||
# thrown away. Only an empty read is a true "no answer": EOF on a terminal
|
||
# is an interactive response, so preserve the [y/N] default and decline.
|
||
if ! read -r reply && [ -z "$reply" ]; then
|
||
log_warn "No answer — declining prompt."
|
||
return 1
|
||
fi
|
||
else
|
||
# Open the terminal before prompting, and keep that failure separate from a
|
||
# failed *read*. Conflating them is what makes a piped-stdin run (the
|
||
# documented `curl … | bash` path, where this branch always runs) treat a
|
||
# user's Ctrl-D as "nobody could answer" and proceed — the opposite of the
|
||
# printed [y/N] default, and the opposite of what the -t 0 branch does with
|
||
# the identical keystroke.
|
||
# Braces, not `exec 3<>/dev/tty 2>/dev/null`: a bare `exec` applies *every*
|
||
# redirection on it permanently, so that form would silence the script's
|
||
# own stderr for the rest of the run. A group is not a subshell, so fd 3
|
||
# still survives it.
|
||
if ! { exec 3<>/dev/tty; } 2>/dev/null; then
|
||
log_warn "Could not open /dev/tty — skipping prompt."
|
||
# 2, not 1: "nobody could answer" is not "the user said no". can_prompt
|
||
# only proves /dev/tty *could* be opened earlier, so a session that has
|
||
# since detached reaches here. Callers for whom the distinction matters
|
||
# branch on 2; those that don't still see a non-zero status and decline.
|
||
return 2
|
||
fi
|
||
printf "%s [y/N] " "$question" >&3
|
||
if ! read -r reply <&3 && [ -z "$reply" ]; then
|
||
exec 3>&-
|
||
# The terminal opened, so there was somebody to ask; an empty read means
|
||
# they answered with EOF. That is a human declining, not an unanswerable
|
||
# prompt — return 1 so callers honour it instead of proceeding.
|
||
log_warn "No answer — declining prompt."
|
||
return 1
|
||
fi
|
||
exec 3>&-
|
||
fi
|
||
if [[ "$reply" =~ ^[Yy]$ ]]; then
|
||
return 0
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
# Whether an interactive y/n prompt can actually be answered. IS_INTERACTIVE
|
||
# trusts `[ -r /dev/tty ]`, which only access-checks the device — opening it
|
||
# still fails when there is no controlling terminal (cron, systemd, some CI).
|
||
# Confirm the channel is usable so callers can fall back instead of blocking
|
||
# or silently treating an unanswerable prompt as "no".
|
||
can_prompt() {
|
||
[ "$IS_INTERACTIVE" = true ] || return 1
|
||
[ -t 0 ] && return 0
|
||
{ : < /dev/tty; } 2>/dev/null
|
||
}
|
||
|
||
path_is_under_home() {
|
||
local path="$1"
|
||
local home_real=""
|
||
local path_real=""
|
||
[ -n "${HOME:-}" ] || return 1
|
||
[ -d "$path" ] || return 1
|
||
home_real=$(cd "$HOME" 2>/dev/null && pwd -P) || return 1
|
||
path_real=$(cd "$path" 2>/dev/null && pwd -P) || return 1
|
||
case "$path_real" in
|
||
"$home_real"/*) return 0 ;;
|
||
*) return 1 ;;
|
||
esac
|
||
}
|
||
|
||
prepare_install_log_dir() {
|
||
local cache_root="$1"
|
||
local dir="${cache_root}/deepagents-code"
|
||
[ -n "$cache_root" ] || return 1
|
||
[ ! -L "$cache_root" ] || return 1
|
||
[ ! -L "$dir" ] || return 1
|
||
if [ ! -d "$cache_root" ]; then
|
||
# `-m` with `-p` only sets the mode on the deepest dir (SC2174); any parents
|
||
# -p creates keep the umask default. Create, then chmod the target itself so
|
||
# 0700 is reliably applied to cache_root.
|
||
mkdir -p "$cache_root" 2>/dev/null || return 1
|
||
chmod 700 "$cache_root" 2>/dev/null || return 1
|
||
fi
|
||
[ -d "$cache_root" ] && [ ! -L "$cache_root" ] || return 1
|
||
if [ -e "$dir" ]; then
|
||
[ -d "$dir" ] && [ ! -L "$dir" ] || return 1
|
||
else
|
||
mkdir -m 700 "$dir" 2>/dev/null || return 1
|
||
fi
|
||
if [ "$(id -u)" -eq 0 ]; then
|
||
path_is_under_home "$dir" || return 1
|
||
fi
|
||
printf '%s\n' "$dir"
|
||
}
|
||
|
||
# Render a path for pasting into a shell, quoting only when it needs it. The
|
||
# overwhelmingly common path is a plain `~/.cache/deepagents-code/install.log`,
|
||
# and rendering it bare keeps it identical to the "Full log:" pointer printed
|
||
# later in the same run — two spellings of one path read as two different
|
||
# files. A path carrying spaces or shell metacharacters (most plausibly a
|
||
# relocated XDG_CACHE_HOME) still gets single-quoted so the pasted command
|
||
# survives word splitting. Callers strip a leading `~/` first and re-attach it
|
||
# outside the quotes: a quoted tilde does not expand.
|
||
tail_hint_quote() {
|
||
local escaped
|
||
case "$1" in
|
||
*[!A-Za-z0-9._/@%+:,=-]*)
|
||
# A failed/missing `sed` yields an empty substitution, which would render
|
||
# as `tail -f ''` — a pasteable command that silently watches nothing.
|
||
# Fall back to the bare path: word-splitting is a lesser wrong than
|
||
# handing the user a command for the wrong file.
|
||
escaped=$(printf '%s' "$1" | sed "s/'/'\\\\''/g") || escaped=""
|
||
if [ -z "$escaped" ]; then
|
||
printf '%s' "$1"
|
||
else
|
||
printf "'%s'" "$escaped"
|
||
fi
|
||
;;
|
||
*) printf '%s' "$1" ;;
|
||
esac
|
||
}
|
||
|
||
# Print the live-follow command for the install log when this run streams uv's
|
||
# output to it (UV_LIVE_LOG). Root runs have no live file to follow, so for
|
||
# them the post-install "Full log:" pointer is the only reference to the log;
|
||
# log-disabled runs get neither. A live *update* prints both — a live fresh
|
||
# install prints only the pointer, per the PRE_VERSION guard below.
|
||
log_update_tail_hint() {
|
||
[ "${UV_LIVE_LOG:-false}" = true ] && [ -n "${INSTALL_LOG_DISPLAY:-}" ] || return 0
|
||
# Only an update has an "update log" to follow; a fresh install has no prior
|
||
# version and would be told to watch something it is not doing.
|
||
[ -n "${PRE_VERSION:-}" ] || return 0
|
||
case "$INSTALL_LOG_DISPLAY" in
|
||
\~/*) log_info " Update log: tail -f ~/$(tail_hint_quote "${INSTALL_LOG_DISPLAY#\~/}")" ;;
|
||
*) log_info " Update log: tail -f $(tail_hint_quote "$INSTALL_LOG_DISPLAY")" ;;
|
||
esac
|
||
}
|
||
|
||
fix_install_log_owner() {
|
||
[ -n "${INSTALL_LOG:-}" ] || return 0
|
||
[ "$(id -u)" -eq 0 ] || return 0
|
||
[ -n "${TARGET_USER:-}" ] && [ "$TARGET_USER" != "root" ] || return 0
|
||
[ -d "$install_log_dir" ] && [ ! -L "$install_log_dir" ] || return 0
|
||
# Enter the directory before validating it, then use only relative paths.
|
||
# The target user owns its parent and can rename or replace this directory;
|
||
# keeping it as the subshell's cwd pins the validated inode throughout both
|
||
# chown calls instead of resolving an attacker-swappable parent as root.
|
||
(
|
||
cd "$install_log_dir" 2>/dev/null || exit 0
|
||
path_is_under_home "." || exit 0
|
||
if ! chown -h "$TARGET_USER" "." 2>&1; then
|
||
log_warn "Could not fix ownership of $install_log_dir for user ${TARGET_USER}."
|
||
fi
|
||
if [ -f "install.log" ] && [ ! -L "install.log" ]; then
|
||
if ! chown -h "$TARGET_USER" "install.log" 2>&1; then
|
||
log_warn "Could not fix ownership of $INSTALL_LOG for user ${TARGET_USER}."
|
||
fi
|
||
fi
|
||
)
|
||
}
|
||
|
||
copy_install_log() {
|
||
# A live run already wrote INSTALL_LOG in place; there is nothing to stage.
|
||
# It still has to answer the caller's question — "is the path I am about to
|
||
# advertise still the file uv wrote?" — because the caller uses this return
|
||
# code to decide whether to print the pointer at all. Returning a bare 0
|
||
# would assert a file it never looked at. The fd pinned the inode uv wrote
|
||
# to, so only the *name* is at risk: a process able to write the cache dir
|
||
# can replace install.log after uv exits, and the pointer would then send the
|
||
# user to a file of someone else's choosing. The check rejects a symlink or a
|
||
# vanished path; it cannot detect replacement by a regular file, since the
|
||
# pinning fd is closed by now and nothing compares inode identity.
|
||
#
|
||
# Report a failure here as 2, not 1. In the staged path 1 means "the
|
||
# destination looked hostile before anything was written" — nothing was lost,
|
||
# so the caller stays quiet. Here it means uv's full stderr *was* written to
|
||
# that path and has since vanished, which is real loss and worth saying.
|
||
if [ "${UV_LIVE_LOG:-false}" = true ]; then
|
||
[ -n "${INSTALL_LOG:-}" ] || return 1
|
||
[ -f "$INSTALL_LOG" ] && [ ! -L "$INSTALL_LOG" ] || return 2
|
||
return 0
|
||
fi
|
||
[ -n "${INSTALL_LOG:-}" ] || return 1
|
||
[ -n "${install_log_dir:-}" ] || return 1
|
||
[ -d "$install_log_dir" ] && [ ! -L "$install_log_dir" ] || return 1
|
||
if [ "$(id -u)" -eq 0 ]; then
|
||
path_is_under_home "$install_log_dir" || return 1
|
||
fi
|
||
# Belt-and-braces: the publication below never follows a symlink at
|
||
# INSTALL_LOG, so this guard is not what makes publishing safe. Keep it
|
||
# anyway — it fails early and explicitly on an obviously tampered path.
|
||
[ ! -L "$INSTALL_LOG" ] || return 1
|
||
# Do not stage beneath install_log_dir: when this runs as root, its parent can
|
||
# still be user-writable and the user could replace a freshly-created staging
|
||
# directory before `cp` enters it. `/tmp` is sticky, so a root-owned 0700
|
||
# directory created there cannot be renamed or replaced by another user.
|
||
# Privileged publication pins the destination directory and uses a
|
||
# noclobber create below, which never follows a planted INSTALL_LOG symlink.
|
||
#
|
||
# `/tmp` is hardcoded rather than honouring TMPDIR on purpose: an
|
||
# attacker-controlled TMPDIR would point staging at a directory with no
|
||
# sticky bit and void the guarantee above. Do not "fix" this for
|
||
# portability.
|
||
#
|
||
# Accepted cost: /tmp is usually a separate mount, so the unprivileged
|
||
# publication below is a cross-device `mv` - copy-then-unlink, not an atomic
|
||
# rename. A concurrent reader can therefore observe a half-written
|
||
# install.log, and a `mv` that fails partway can leave a truncated one in
|
||
# place of the previous run's. Both callers treat a failed copy_install_log
|
||
# as "no log this run", and the `-s`/`-f`/`-L` rechecks around publication
|
||
# cover the resulting window; the sticky-directory property is worth more
|
||
# than the atomicity, since only root can be attacked through the staging
|
||
# path and only the log's own contents are at stake through the other.
|
||
local stage_dir staged
|
||
stage_dir=$(mktemp -d "/tmp/deepagents-code-install-log.XXXXXX" 2>/dev/null) || return 2
|
||
register_temp_dir "$stage_dir"
|
||
staged="${stage_dir}/install.log"
|
||
if ! (cd "$stage_dir" && cp "$uv_stderr" install.log) 2>/dev/null; then
|
||
rm -f "$staged" 2>/dev/null || true
|
||
rmdir "$stage_dir" 2>/dev/null || true
|
||
return 2
|
||
fi
|
||
[ -f "$staged" ] && [ ! -L "$staged" ] || {
|
||
rm -f "$staged" 2>/dev/null || true
|
||
rmdir "$stage_dir" 2>/dev/null || true
|
||
return 1
|
||
}
|
||
# Re-validate the parent immediately before publication so a directory swap
|
||
# is rejected explicitly rather than being mistaken for a write failure.
|
||
[ -d "$install_log_dir" ] && [ ! -L "$install_log_dir" ] || {
|
||
rm -f "$staged" 2>/dev/null || true
|
||
rmdir "$stage_dir" 2>/dev/null || true
|
||
return 1
|
||
}
|
||
if [ "$(id -u)" -eq 0 ]; then
|
||
path_is_under_home "$install_log_dir" || {
|
||
rm -f "$staged" 2>/dev/null || true
|
||
rmdir "$stage_dir" 2>/dev/null || true
|
||
return 1
|
||
}
|
||
fi
|
||
if [ "$(id -u)" -eq 0 ]; then
|
||
# Pin the validated directory as cwd before publishing. The target user can
|
||
# replace its path after any pathname check, so no privileged mutation may
|
||
# resolve that parent again. Noclobber makes the final create atomic: if the
|
||
# user races in a symlink, file, or directory after rm, the redirection
|
||
# fails instead of following it or treating it as a destination directory.
|
||
local publish_rc=0
|
||
(
|
||
cd "$install_log_dir" 2>/dev/null || exit 1
|
||
path_is_under_home "." || exit 1
|
||
[ ! -d "install.log" ] || exit 1
|
||
rm -f "install.log" 2>/dev/null || exit 2
|
||
set -o noclobber
|
||
if ! cat "$staged" > "install.log" 2>/dev/null; then
|
||
rm -f "install.log" 2>/dev/null || true
|
||
exit 2
|
||
fi
|
||
) || publish_rc=$?
|
||
if [ "$publish_rc" -ne 0 ]; then
|
||
rm -f "$staged" 2>/dev/null || true
|
||
rmdir "$stage_dir" 2>/dev/null || true
|
||
return "$publish_rc"
|
||
fi
|
||
rm -f "$staged" 2>/dev/null || true
|
||
else
|
||
# `mv file directory` moves the file *into* the directory and reports
|
||
# success. Reject that state rather than publishing an undiscoverable log.
|
||
[ ! -d "$INSTALL_LOG" ] || {
|
||
rm -f "$staged" 2>/dev/null || true
|
||
rmdir "$stage_dir" 2>/dev/null || true
|
||
return 1
|
||
}
|
||
if ! mv -f "$staged" "$INSTALL_LOG" 2>/dev/null; then
|
||
rm -f "$staged" 2>/dev/null || true
|
||
rmdir "$stage_dir" 2>/dev/null || true
|
||
return 2
|
||
fi
|
||
# `mv` accepts a directory destination by moving the staged file into it.
|
||
# Check the result after publication so a directory created between the
|
||
# preflight check and `mv` is reported as a failed log write. Take the
|
||
# staged copy back out of it: `mv` has already put uv's full stderr inside
|
||
# a directory this run did not create, and leaving it there is the same
|
||
# disclosure the failure paths above clean up.
|
||
#
|
||
# 2, not 1: unlike the rejections above, `mv` has already replaced the
|
||
# previous run's log. The user lost a log and gained nothing, so the caller
|
||
# should say so rather than treat it as a quiet rejected path.
|
||
[ -f "$INSTALL_LOG" ] && [ ! -L "$INSTALL_LOG" ] || {
|
||
[ ! -d "$INSTALL_LOG" ] || rm -f "${INSTALL_LOG}/install.log" 2>/dev/null || true
|
||
rmdir "$stage_dir" 2>/dev/null || true
|
||
return 2
|
||
}
|
||
fi
|
||
rmdir "$stage_dir" 2>/dev/null || true
|
||
}
|
||
|
||
# Epoch mtime of the lock directory, used as a fallback reference time when the
|
||
# started_at metadata is missing. Portable across BSD (macOS) and GNU stat.
|
||
lock_dir_mtime() {
|
||
local dir="${1:-$INSTALL_LOCK_DIR}"
|
||
local mtime
|
||
mtime="$(stat -f %m "$dir" 2>/dev/null || true)"
|
||
case "$mtime" in
|
||
''|*[!0-9]*) ;;
|
||
*) printf '%s' "$mtime"; return 0 ;;
|
||
esac
|
||
|
||
mtime="$(stat -c %Y "$dir" 2>/dev/null || true)"
|
||
case "$mtime" in
|
||
''|*[!0-9]*) printf '0' ;;
|
||
*) printf '%s' "$mtime" ;;
|
||
esac
|
||
}
|
||
|
||
# A fingerprint of the lock instance currently at the canonical path, used to
|
||
# detect whether it is still the same lock a prior check inspected (see the
|
||
# reclaim path in acquire_install_lock). Prints four newline-separated fields —
|
||
# token, pid, started_at, dir mtime — compared only by string equality, so the
|
||
# field set and order are load-bearing. Returns 1 (empty output) when the lock
|
||
# dir is gone; an unreadable field reads as empty, i.e. is treated as absent.
|
||
install_lock_identity() {
|
||
[ -d "$INSTALL_LOCK_DIR" ] || return 1
|
||
|
||
local token pid started_at mtime
|
||
token="$(cat "$INSTALL_LOCK_DIR/token" 2>/dev/null || true)"
|
||
pid="$(cat "$INSTALL_LOCK_DIR/pid" 2>/dev/null || true)"
|
||
started_at="$(cat "$INSTALL_LOCK_DIR/started_at" 2>/dev/null || true)"
|
||
mtime="$(lock_dir_mtime)"
|
||
printf '%s\n%s\n%s\n%s' "$token" "$pid" "$started_at" "$mtime"
|
||
}
|
||
|
||
# Decide whether an existing mkdir-based lock may be reclaimed. Only ever called
|
||
# in the mkdir fallback path (kernel advisory locks self-release on holder death,
|
||
# so they need no staleness heuristic). On a "stale" result, also sets the global
|
||
# INSTALL_LOCK_STALE_ID to the lock's identity fingerprint (consumed by the
|
||
# reclaim path in acquire_install_lock); clears it otherwise. Must be called in a
|
||
# condition context (`if install_lock_is_stale`) so `set -e` is suppressed for
|
||
# its body: the bare `[ -n ... ]` test near the end returns non-zero on the
|
||
# not-stale branch and would otherwise abort the script.
|
||
install_lock_is_stale() {
|
||
INSTALL_LOCK_STALE_ID=""
|
||
[ -d "$INSTALL_LOCK_DIR" ] || return 1
|
||
|
||
local pid started_at now
|
||
pid="$(cat "$INSTALL_LOCK_DIR/pid" 2>/dev/null || true)"
|
||
started_at="$(cat "$INSTALL_LOCK_DIR/started_at" 2>/dev/null || true)"
|
||
now="$(date +%s 2>/dev/null || printf '0')"
|
||
|
||
# A live owner is authoritative: never reclaim a lock whose PID is running.
|
||
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
|
||
return 1
|
||
fi
|
||
|
||
# When started_at is missing or not yet written, fall back to the lock dir's
|
||
# mtime. This covers the window between `mkdir` winning and the metadata being
|
||
# written by the new owner: treating that window as "stale" would let a racing
|
||
# installer delete a lock that was just acquired, so age it out via mtime
|
||
# instead of reclaiming it on sight.
|
||
case "$started_at" in
|
||
''|*[!0-9]*) started_at="$(lock_dir_mtime)" ;;
|
||
esac
|
||
case "$started_at" in
|
||
''|*[!0-9]*) started_at=0 ;;
|
||
esac
|
||
|
||
# Without a usable reference or current time we cannot prove the lock is old.
|
||
# Be conservative and keep waiting rather than risk deleting a live lock; a
|
||
# working host always has these, so this only guards pathological environments.
|
||
if [ "$started_at" -eq 0 ] || [ "$now" -eq 0 ]; then
|
||
return 1
|
||
fi
|
||
|
||
if [ $((now - started_at)) -ge "$INSTALL_LOCK_STALE_AFTER_SECS" ]; then
|
||
# Capture the identity for the reclaim guard. If the lock dir vanished
|
||
# between the age check and here, the fingerprint is empty; the bare test
|
||
# then returns non-zero, so `return` reports "not stale" (see header note on
|
||
# why this is safe under `set -e`).
|
||
INSTALL_LOCK_STALE_ID="$(install_lock_identity 2>/dev/null || true)"
|
||
[ -n "$INSTALL_LOCK_STALE_ID" ]
|
||
return
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
install_lock_reclaim_guard_is_stale() {
|
||
local started_at
|
||
local now
|
||
|
||
started_at="$(cat "$INSTALL_LOCK_RECLAIM_DIR/started_at" 2>/dev/null || true)"
|
||
now="$(date +%s 2>/dev/null || printf '0')"
|
||
|
||
case "$started_at" in
|
||
''|*[!0-9]*) started_at="$(lock_dir_mtime "$INSTALL_LOCK_RECLAIM_DIR")" ;;
|
||
esac
|
||
case "$started_at" in
|
||
''|*[!0-9]*) started_at=0 ;;
|
||
esac
|
||
|
||
if [ "$started_at" -eq 0 ] || [ "$now" -eq 0 ]; then
|
||
return 1
|
||
fi
|
||
|
||
[ $((now - started_at)) -ge "$INSTALL_LOCK_STALE_AFTER_SECS" ]
|
||
}
|
||
|
||
wait_for_install_lock_reclaim_guard() {
|
||
if [ ! -d "$INSTALL_LOCK_RECLAIM_DIR" ]; then
|
||
return 0
|
||
fi
|
||
|
||
if install_lock_reclaim_guard_is_stale; then
|
||
log_error "Installer lock reclaim is stuck at $INSTALL_LOCK_RECLAIM_DIR."
|
||
log_error "Remove it manually, then retry."
|
||
exit 1
|
||
fi
|
||
|
||
sleep 1
|
||
return 1
|
||
}
|
||
|
||
acquire_install_lock_reclaim_guard() {
|
||
local token
|
||
|
||
token="$$:$(date +%s 2>/dev/null || printf '0'):${RANDOM:-0}"
|
||
if ! mkdir "$INSTALL_LOCK_RECLAIM_DIR" 2>/dev/null; then
|
||
if [ -d "$INSTALL_LOCK_RECLAIM_DIR" ]; then
|
||
return 1
|
||
fi
|
||
log_error "Cannot reclaim stale installer lock at $INSTALL_LOCK_DIR."
|
||
log_error "Cannot create reclaim guard at $INSTALL_LOCK_RECLAIM_DIR."
|
||
exit 1
|
||
fi
|
||
|
||
if ! printf '%s\n' "$token" >"$INSTALL_LOCK_RECLAIM_DIR/token" 2>/dev/null; then
|
||
rm -rf "$INSTALL_LOCK_RECLAIM_DIR" 2>/dev/null || true
|
||
log_error "Cannot reclaim stale installer lock at $INSTALL_LOCK_DIR."
|
||
log_error "Cannot write reclaim guard metadata at $INSTALL_LOCK_RECLAIM_DIR."
|
||
exit 1
|
||
fi
|
||
|
||
INSTALL_LOCK_RECLAIM_TOKEN="$token"
|
||
printf '%s\n' "$$" >"$INSTALL_LOCK_RECLAIM_DIR/pid" 2>/dev/null || true
|
||
date +%s >"$INSTALL_LOCK_RECLAIM_DIR/started_at" 2>/dev/null || true
|
||
return 0
|
||
}
|
||
|
||
release_install_lock_reclaim_guard() {
|
||
# Token-guarded like release_install_lock: only drop the guard if it is still
|
||
# ours (see that function for why an unreadable token errs toward keeping it).
|
||
if [ -n "${INSTALL_LOCK_RECLAIM_TOKEN:-}" ] && \
|
||
[ "$(cat "$INSTALL_LOCK_RECLAIM_DIR/token" 2>/dev/null || true)" = "$INSTALL_LOCK_RECLAIM_TOKEN" ]; then
|
||
rm -rf "$INSTALL_LOCK_RECLAIM_DIR" 2>/dev/null || true
|
||
fi
|
||
INSTALL_LOCK_RECLAIM_TOKEN=""
|
||
}
|
||
|
||
# Compute the same installation root Python derives from `sys.prefix` (see
|
||
# `_paths._installation_paths`), which is where the install lock lives.
|
||
#
|
||
# `uv tool dir` is authoritative; the remaining branches are guesses. A guess
|
||
# that misses — uv configured through `uv.toml`, a non-default `--tool-dir`, or
|
||
# uv not yet on PATH at the first lock acquisition — yields a *different* lock
|
||
# root, so concurrent `curl | bash` runs would not serialize.
|
||
#
|
||
# Prints "<root><TAB><guessed>". The flag is returned rather than assigned to a
|
||
# global because the only caller substitutes this function, and a subshell
|
||
# assignment would be discarded — leaving the warning permanently unreachable.
|
||
resolve_installation_root() {
|
||
local tool_dir=""
|
||
local guessed=false
|
||
if [ -n "${UV_BIN:-}" ]; then
|
||
tool_dir="$("$UV_BIN" tool dir 2>/dev/null || true)"
|
||
fi
|
||
if [ -z "$tool_dir" ] && [ -n "${UV_TOOL_DIR_ENV:-}" ]; then
|
||
tool_dir="$UV_TOOL_DIR_ENV"
|
||
guessed=true
|
||
fi
|
||
if [ -z "$tool_dir" ]; then
|
||
tool_dir="${XDG_DATA_HOME:-${HOME}/.local/share}/uv/tools"
|
||
guessed=true
|
||
fi
|
||
case "$tool_dir" in
|
||
/*) ;;
|
||
*) tool_dir="$(pwd -P)/${tool_dir}" ;;
|
||
esac
|
||
printf '%s\t%s' "$(normalize_absolute_path "${tool_dir}/deepagents-code")" "$guessed"
|
||
}
|
||
|
||
# Serialize concurrent installs (racing `curl | bash` runs corrupting a shared
|
||
# uv tool dir). Use an atomic mkdir lock dir with a PID + timestamp so a crashed
|
||
# holder's lock can be aged out (see install_lock_is_stale). Avoid shell
|
||
# redirection to a lock file here. The lock is derived from the uv tool
|
||
# environment, not `DEEPAGENTS_HOME`, so profiles sharing an installation also
|
||
# share serialization.
|
||
acquire_install_lock() {
|
||
local installation_root
|
||
local installation_parent
|
||
local installation_name
|
||
local lock_root
|
||
local resolution
|
||
local guessed
|
||
# Keep this a command substitution: `set -e` must still abort if resolution
|
||
# fails. A stub that prints only the root yields no tab, which reads as
|
||
# "not guessed" below.
|
||
resolution="$(resolve_installation_root)"
|
||
installation_root="${resolution%%$'\t'*}"
|
||
guessed="${resolution#*$'\t'}"
|
||
[ "$guessed" != "$resolution" ] || guessed=false
|
||
installation_parent="${installation_root%/*}"
|
||
installation_name="${installation_root##*/}"
|
||
lock_root="${installation_parent}/.${installation_name}.deepagents-code-locks"
|
||
if [ -L "$lock_root" ]; then
|
||
log_error "Installer lock root is a symlink: $lock_root"
|
||
log_error "Remove it or choose a different uv tool directory, then retry."
|
||
exit 1
|
||
fi
|
||
if [ "${guessed:-false}" = true ]; then
|
||
log_warn "Could not ask uv for its tool directory; guessing the installer lock root."
|
||
log_warn " Concurrent installs may not serialize. Lock root: ${lock_root}"
|
||
fi
|
||
if [ ! -d "$lock_root" ]; then
|
||
if ! mkdir -p "$lock_root"; then
|
||
log_error "Could not create the installer lock directory: ${lock_root}"
|
||
log_error " This serializes concurrent installs; it is not related to DEEPAGENTS_HOME."
|
||
exit 1
|
||
fi
|
||
# `uv tool dir` can point outside $HOME (a system-wide tool dir), and this
|
||
# hands the inode to a non-root user.
|
||
if path_is_under_home "$lock_root"; then
|
||
fix_file_owner "$lock_root"
|
||
fi
|
||
fi
|
||
|
||
INSTALL_LOCK_DIR="$lock_root/install.lock.d"
|
||
INSTALL_LOCK_RECLAIM_DIR="$lock_root/install.lock.reclaim.d"
|
||
|
||
while true; do
|
||
wait_for_install_lock_reclaim_guard || continue
|
||
|
||
if mkdir "$INSTALL_LOCK_DIR" 2>/dev/null; then
|
||
break
|
||
fi
|
||
|
||
# A failed mkdir only means "another installer owns the lock" when the
|
||
# lock directory actually exists. If the root is unwritable (common with a
|
||
# system-wide, root-owned uv tool directory), waiting cannot make progress.
|
||
if [ ! -d "$INSTALL_LOCK_DIR" ]; then
|
||
log_error "Cannot create installer lock at $INSTALL_LOCK_DIR."
|
||
log_error "Check that the uv tool directory is writable, then retry."
|
||
exit 1
|
||
fi
|
||
|
||
if install_lock_is_stale; then
|
||
local _stale_id="${INSTALL_LOCK_STALE_ID:-}"
|
||
if [ -z "$_stale_id" ] || ! acquire_install_lock_reclaim_guard; then
|
||
continue
|
||
fi
|
||
if [ "$(install_lock_identity 2>/dev/null || true)" != "$_stale_id" ]; then
|
||
release_install_lock_reclaim_guard
|
||
continue
|
||
fi
|
||
|
||
log_warn "Removing stale installer lock at $INSTALL_LOCK_DIR"
|
||
# Only reclaim the exact lock instance that was inspected as stale, so this
|
||
# rename can never move a fresh owner's lock aside. Two mechanisms cover
|
||
# the window: the identity re-check above rejects a lock that was already
|
||
# replaced before we took the reclaim guard, and the guard then blocks
|
||
# peers (via wait_for_install_lock_reclaim_guard) from creating a fresh
|
||
# lock at the canonical path until this rename completes.
|
||
local _stale_reclaim="${INSTALL_LOCK_DIR}.reclaim.$$"
|
||
if mv "$INSTALL_LOCK_DIR" "$_stale_reclaim" 2>/dev/null; then
|
||
rm -rf "$_stale_reclaim" 2>/dev/null || true
|
||
release_install_lock_reclaim_guard
|
||
elif [ "$(install_lock_identity 2>/dev/null || true)" != "$_stale_id" ]; then
|
||
release_install_lock_reclaim_guard
|
||
continue
|
||
else
|
||
release_install_lock_reclaim_guard
|
||
# The stale lock can be neither renamed nor removed (typically it is
|
||
# owned by another user). Fail loudly rather than spin: `continue` skips
|
||
# the `sleep` below, so silently swallowing this error would busy-loop
|
||
# and spam the warning above forever.
|
||
log_error "Cannot reclaim stale installer lock at $INSTALL_LOCK_DIR."
|
||
log_error "Remove it manually or rerun as its owner, then retry."
|
||
exit 1
|
||
fi
|
||
continue
|
||
fi
|
||
sleep 1
|
||
done
|
||
|
||
INSTALL_LOCK_TOKEN="$$:$(date +%s 2>/dev/null || printf '0'):${RANDOM:-0}"
|
||
if ! printf '%s\n' "$INSTALL_LOCK_TOKEN" >"$INSTALL_LOCK_DIR/token" 2>/dev/null; then
|
||
rm -rf "$INSTALL_LOCK_DIR" 2>/dev/null || true
|
||
log_error "Cannot write installer lock metadata at $INSTALL_LOCK_DIR."
|
||
exit 1
|
||
fi
|
||
printf '%s\n' "$$" >"$INSTALL_LOCK_DIR/pid"
|
||
date +%s >"$INSTALL_LOCK_DIR/started_at" 2>/dev/null || true
|
||
fix_file_owner "$INSTALL_LOCK_DIR" "$INSTALL_LOCK_DIR/token" \
|
||
"$INSTALL_LOCK_DIR/pid" "$INSTALL_LOCK_DIR/started_at"
|
||
INSTALL_LOCK_KIND="mkdir"
|
||
}
|
||
|
||
release_install_lock() {
|
||
case "${INSTALL_LOCK_KIND:-}" in
|
||
mkdir)
|
||
# Remove the lock only while the on-disk token is still ours. A clean read
|
||
# that differs means a reclaimer took over the canonical path — never
|
||
# delete their live lock. An unreadable token is treated the same (skip):
|
||
# we cannot prove ownership, and erring toward a leak is safe because a
|
||
# stale lock ages out via install_lock_is_stale, whereas removing on an
|
||
# unverifiable read could delete a reclaimer's lock. Do not turn this into
|
||
# an unconditional `rm -rf`.
|
||
if [ -n "${INSTALL_LOCK_TOKEN:-}" ] && [ "$(cat "$INSTALL_LOCK_DIR/token" 2>/dev/null || true)" = "$INSTALL_LOCK_TOKEN" ]; then
|
||
rm -rf "$INSTALL_LOCK_DIR" 2>/dev/null || true
|
||
fi
|
||
;;
|
||
esac
|
||
release_install_lock_reclaim_guard
|
||
INSTALL_LOCK_KIND=""
|
||
INSTALL_LOCK_TOKEN=""
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Config
|
||
# ---------------------------------------------------------------------------
|
||
EXTRAS="${DEEPAGENTS_CODE_EXTRAS:-}"
|
||
VERSION="${DEEPAGENTS_CODE_VERSION:-}"
|
||
PRERELEASE_REQUESTED="${DEEPAGENTS_CODE_PRERELEASE:-}"
|
||
if [ -n "$POSITIONAL_TARGET" ]; then
|
||
if [ -n "$VERSION" ]; then
|
||
log_error "Do not combine a positional version with DEEPAGENTS_CODE_VERSION."
|
||
log_error "Use either the version argument, or the environment variable — not both."
|
||
exit 1
|
||
fi
|
||
VERSION="$POSITIONAL_TARGET"
|
||
fi
|
||
PRERELEASE="${PRERELEASE_REQUESTED:-allow}"
|
||
PYTHON_REQUESTED=false
|
||
if [[ -n "${DEEPAGENTS_CODE_PYTHON:-}" ]]; then
|
||
PYTHON_REQUESTED=true
|
||
fi
|
||
PYTHON_VERSION="${DEEPAGENTS_CODE_PYTHON:-3.13}"
|
||
SKIP_OPTIONAL="${DEEPAGENTS_CODE_SKIP_OPTIONAL:-0}"
|
||
VERBOSE="${DEEPAGENTS_CODE_VERBOSE:-0}"
|
||
ASSUME_YES="$(printf '%s' "${DEEPAGENTS_CODE_YES:-0}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
|
||
case "$ASSUME_YES" in
|
||
1|true|yes) ASSUME_YES="1" ;;
|
||
*) ASSUME_YES="0" ;;
|
||
esac
|
||
# How ripgrep gets provisioned: "managed" (default) eagerly fetches the
|
||
# pinned, SHA-256-verified binary inside the dcode tool environment via
|
||
# `dcode tools install`; "system" keeps the interactive package-manager path below. Any
|
||
# value other than "system" normalizes to "managed".
|
||
#
|
||
# Lowercase and strip whitespace first so this matches the `.strip().lower()`
|
||
# normalization in managed_tools.ripgrep_installer(). Without this, a value
|
||
# like "System" would parse as "managed" here but "system" in dcode, and the
|
||
# eager `dcode tools install` would skip silently while this script also
|
||
# skipped the package-manager path — leaving ripgrep unprovisioned.
|
||
RIPGREP_INSTALLER="$(printf '%s' "${DEEPAGENTS_CODE_RIPGREP_INSTALLER:-managed}" \
|
||
| tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
|
||
case "$RIPGREP_INSTALLER" in
|
||
system) RIPGREP_INSTALLER="system" ;;
|
||
*) RIPGREP_INSTALLER="managed" ;;
|
||
esac
|
||
|
||
# PyPI JSON endpoint used to discover the latest published release so we can
|
||
# tell whether an existing install is out of date before upgrading it.
|
||
PYPI_JSON_URL="https://pypi.org/pypi/deepagents-code/json"
|
||
|
||
# Base URL for a version-specific GitHub release, surfaced before the update
|
||
# prompt so users can review what changed before agreeing to upgrade. Release
|
||
# tags are `deepagents-code==X.Y.Z`; the `==` must be percent-encoded (%3D%3D)
|
||
# for the tag URL to resolve.
|
||
RELEASE_TAG_URL_BASE="https://github.com/langchain-ai/deepagents/releases/tag/deepagents-code%3D%3D"
|
||
|
||
# Validate and normalize extras: accept bare CSV, wrap in brackets for pip
|
||
if [[ -n "$EXTRAS" ]]; then
|
||
# Strip brackets if the user passed them anyway
|
||
EXTRAS="${EXTRAS#[}"
|
||
EXTRAS="${EXTRAS%]}"
|
||
if [[ ! "$EXTRAS" =~ ^[-a-zA-Z0-9,._]+$ ]]; then
|
||
log_error "DEEPAGENTS_CODE_EXTRAS must be comma-separated extra names, e.g. 'anthropic,groq' or 'daytona'"
|
||
exit 1
|
||
fi
|
||
EXTRAS="[${EXTRAS}]"
|
||
fi
|
||
|
||
# An exact pin already selects a single version, so an explicitly requested
|
||
# pre-release strategy (which only affects how a range resolves) is redundant at
|
||
# best and contradictory at worst (e.g. an rc pin with "disallow"). Reject only
|
||
# user-provided combinations; the installer's default `if-necessary` strategy is
|
||
# not forwarded when a version is pinned.
|
||
if [[ -n "$VERSION" && -n "$PRERELEASE_REQUESTED" ]]; then
|
||
log_error "DEEPAGENTS_CODE_VERSION and DEEPAGENTS_CODE_PRERELEASE are mutually exclusive."
|
||
log_error "Pin an exact version, or set a pre-release strategy — not both."
|
||
exit 1
|
||
fi
|
||
|
||
VERSION_SPEC=""
|
||
if [[ -n "$VERSION" ]]; then
|
||
# Require a leading alphanumeric so the value reads as a version rather than
|
||
# an option (e.g. "-U"); the class excludes every shell metacharacter, so the
|
||
# value is safe to interpolate into the single argv token passed to uv.
|
||
if [[ ! "$VERSION" =~ ^[A-Za-z0-9][A-Za-z0-9_.!+-]*$ ]]; then
|
||
log_error "DEEPAGENTS_CODE_VERSION must be an exact version, e.g. '0.1.0rc1'"
|
||
exit 1
|
||
fi
|
||
VERSION_SPEC="==${VERSION}"
|
||
fi
|
||
|
||
if [[ -n "$PRERELEASE" ]]; then
|
||
case "$PRERELEASE" in
|
||
disallow|allow|if-necessary|explicit|if-necessary-or-explicit)
|
||
;;
|
||
*)
|
||
log_error "Invalid DEEPAGENTS_CODE_PRERELEASE."
|
||
log_error "Use: disallow, allow, if-necessary, explicit, or if-necessary-or-explicit"
|
||
exit 1
|
||
;;
|
||
esac
|
||
fi
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# uv installation
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Detect whether `curl` is a snap package, which lacks the permissions to
|
||
# download files outside the snap sandbox. On such systems curl appears to
|
||
# work but fails on actual downloads, so callers should fall back to wget.
|
||
is_snap_curl() {
|
||
if ! command -v curl >/dev/null 2>&1; then
|
||
return 1
|
||
fi
|
||
local curl_path
|
||
curl_path=$(command -v curl 2>/dev/null) || return 1
|
||
case "$curl_path" in
|
||
*/snap/*) return 0 ;;
|
||
*) return 1 ;;
|
||
esac
|
||
}
|
||
|
||
# BusyBox wget intentionally implements only a small subset of GNU wget's
|
||
# long options. Keep the stricter redirect controls when this wget supports
|
||
# them, while still allowing minimal Linux systems to download from an HTTPS
|
||
# URL with normal TLS certificate validation.
|
||
#
|
||
# The help text is captured rather than piped straight into grep: BusyBox exits
|
||
# 1 from `--help`, and under `set -o pipefail` that failure becomes the
|
||
# pipeline's status even when grep matched, so every option would report as
|
||
# unsupported. That fails *open* — it silently drops `-S` and disables the
|
||
# redirect audit below on exactly the minimal systems this branch exists for.
|
||
# `|| true` because a nonzero `--help` is a BusyBox quirk, not "unsupported".
|
||
#
|
||
# The result is cached: this is probed once per hardening flag per attempt, and
|
||
# the defaults keep the function self-contained so it can be extracted and
|
||
# exercised on its own.
|
||
WGET_HELP_TEXT=""
|
||
WGET_HELP_CACHED=false
|
||
wget_supports_option() {
|
||
if [ "${WGET_HELP_CACHED:-false}" = false ]; then
|
||
WGET_HELP_TEXT="$(wget --help 2>&1 || true)"
|
||
WGET_HELP_CACHED=true
|
||
fi
|
||
printf '%s\n' "${WGET_HELP_TEXT:-}" | grep -Fq -- "$1"
|
||
}
|
||
|
||
# Download with wget, adding hardening options only when this wget implements
|
||
# them. `output` may be `-` to write the response to stdout.
|
||
#
|
||
# GNU wget has no real equivalent of curl's --proto-redir: `--https-only` is
|
||
# documented (and implemented) as "when in recursive mode, only HTTPS links are
|
||
# followed", so it does NOT stop a 3xx from downgrading a plain one-shot fetch
|
||
# to plaintext. We therefore pass it for what it's worth, but enforce the
|
||
# scheme ourselves: the request URL must be HTTPS, and the response headers are
|
||
# audited for a non-HTTPS Location so a redirect downgrade fails closed.
|
||
wget_download() {
|
||
local output="$1" url="$2" ua="${3:-}" quiet="${4:-false}"
|
||
case "$url" in
|
||
https://*) ;;
|
||
*)
|
||
log_warn "Refusing to download over a non-HTTPS URL: ${url}"
|
||
return 1
|
||
;;
|
||
esac
|
||
local -a args
|
||
if [ "$quiet" = "true" ]; then
|
||
args=(-qO "$output")
|
||
else
|
||
args=(-nv -O "$output")
|
||
fi
|
||
if [ -n "$ua" ] && wget_supports_option "--header"; then
|
||
args+=("--header=User-Agent: ${ua}")
|
||
fi
|
||
if wget_supports_option "--max-redirect"; then
|
||
args+=(--max-redirect=3)
|
||
fi
|
||
if wget_supports_option "--https-only"; then
|
||
args+=(--https-only)
|
||
fi
|
||
# -S echoes the response headers (including each redirect's Location) to
|
||
# stderr, which is what makes the downgrade audit below possible. BusyBox
|
||
# wget lacks it; there the audit degrades to a no-op, so BusyBox hosts keep
|
||
# working but rely on wget's own behavior for redirect handling.
|
||
local can_audit=false
|
||
if wget_supports_option "-S"; then
|
||
args+=(-S)
|
||
can_audit=true
|
||
else
|
||
# Say so rather than degrading in silence: without -S there is no
|
||
# downgrade check at all on the wget path (--https-only does not stop a
|
||
# one-shot redirect), and a control that is believed active but inert is
|
||
# worse than one the user knows is missing.
|
||
log_warn "This wget cannot report response headers (-S); cannot verify redirects stay on HTTPS."
|
||
fi
|
||
|
||
local wget_err rc=0
|
||
wget_err=$(mktemp 2>/dev/null) || return 1
|
||
register_temp "$wget_err"
|
||
wget "${args[@]}" "$url" 2>"$wget_err" || rc=$?
|
||
if [ "$can_audit" = true ] && \
|
||
grep -qiE '^[[:space:]]*Location:[[:space:]]*http:' "$wget_err"; then
|
||
log_warn "Refusing a plaintext HTTP redirect while downloading ${url}."
|
||
rm -f "$wget_err"
|
||
return 1
|
||
fi
|
||
# Forward wget's own diagnostics so the caller's stderr capture still sees
|
||
# the real failure reason; -qO callers asked for silence.
|
||
if [ "$quiet" != "true" ]; then
|
||
cat "$wget_err" >&2
|
||
fi
|
||
rm -f "$wget_err"
|
||
return "$rc"
|
||
}
|
||
|
||
# Download a URL to stdout using the first available working downloader.
|
||
# Prefers curl (unless it's a snap install, which has sandbox permission
|
||
# issues), then falls back to wget. Prints nothing and returns non-zero if no
|
||
# working downloader is available.
|
||
# The curl call pins HTTPS for the request and any redirects (--proto /
|
||
# --proto-redir) so a hostile proxy or DNS answer can't silently downgrade the
|
||
# download to plaintext, and caps redirect chains at 3 so a broken or
|
||
# malicious redirect loop fails instead of spinning. wget has no --proto-redir
|
||
# equivalent, so wget_download enforces the same property by rejecting
|
||
# non-HTTPS URLs and auditing the response headers for a downgrade redirect.
|
||
download_to_stdout() {
|
||
local url="$1" ua="${2:-deepagents-code-install}"
|
||
local attempt=1 body="" download_rc=1
|
||
while [ "$attempt" -le 3 ]; do
|
||
if command -v curl >/dev/null 2>&1 && ! is_snap_curl; then
|
||
if body=$(curl -fsSL -H "User-Agent: ${ua}" \
|
||
--proto '=https' --proto-redir '=https' --max-redirs 3 \
|
||
"$url" 2>/dev/null); then
|
||
printf '%s' "$body"
|
||
return 0
|
||
else
|
||
download_rc=$?
|
||
fi
|
||
elif command -v wget >/dev/null 2>&1; then
|
||
if body=$(wget_download - "$url" "$ua" true 2>/dev/null); then
|
||
printf '%s' "$body"
|
||
return 0
|
||
else
|
||
download_rc=$?
|
||
fi
|
||
else
|
||
return 1
|
||
fi
|
||
# 2s then 4s. A 1s/2s backoff is too short to outlast the transient it is
|
||
# meant to absorb (a captive-portal redirect settling, a CDN edge failing
|
||
# over); 6s of total waiting is still well inside a user's patience.
|
||
if [ "$attempt" -lt 3 ]; then
|
||
sleep $((attempt * 2))
|
||
fi
|
||
attempt=$((attempt + 1))
|
||
done
|
||
return "$download_rc"
|
||
}
|
||
|
||
install_uv() {
|
||
# The upstream uv installer is chatty (download progress, install paths,
|
||
# PATH-setup hints). Capture it and surface the output only when debugging
|
||
# or when the install fails — by default it's noise the user doesn't need.
|
||
# This same tempfile also captures the downloader's stderr, so a failed
|
||
# download surfaces curl/wget's own error (DNS, TLS, HTTP status) instead of
|
||
# a generic message; the installer's stdout/stderr overwrites it afterward.
|
||
local uv_install_out uv_install_rc=0
|
||
uv_install_out=$(mktemp 2>/dev/null) || {
|
||
log_error "mktemp is required to create a secure temp file."
|
||
exit 1
|
||
}
|
||
register_temp "$uv_install_out"
|
||
|
||
# Download the installer to a tempfile first instead of piping curl straight
|
||
# to sh, so we can verify the first line is a shell shebang before executing.
|
||
# A transparent proxy or captive portal returning 200 with HTML would
|
||
# otherwise pipe straight into sh with unpredictable results. curl's `-f`
|
||
# catches HTTP errors, but a 200-with-HTML response passes that check.
|
||
local uv_script
|
||
uv_script=$(mktemp 2>/dev/null) || {
|
||
log_error "mktemp is required to create a secure temp file."
|
||
exit 1
|
||
}
|
||
register_temp "$uv_script"
|
||
|
||
# Capture the downloader's stderr (2>"$uv_install_out") rather than discarding
|
||
# it: on failure it holds the actionable cause (curl: (6) Could not resolve
|
||
# host, SSL errors, HTTP status), which the failure branch below surfaces.
|
||
# curl -sS and wget -nv stay quiet on success, so this adds no noise then.
|
||
local attempt
|
||
if command -v curl >/dev/null 2>&1 && ! is_snap_curl; then
|
||
uv_install_rc=1
|
||
for attempt in 1 2 3; do
|
||
: >"$uv_install_out"
|
||
if curl -fsSL https://astral.sh/uv/install.sh -o "$uv_script" \
|
||
--proto '=https' --proto-redir '=https' --max-redirs 3 \
|
||
2>"$uv_install_out"; then
|
||
uv_install_rc=0
|
||
break
|
||
else
|
||
uv_install_rc=$?
|
||
fi
|
||
if [ "$attempt" -lt 3 ]; then
|
||
sleep $((attempt * 2))
|
||
fi
|
||
done
|
||
elif command -v wget >/dev/null 2>&1; then
|
||
uv_install_rc=1
|
||
for attempt in 1 2 3; do
|
||
: >"$uv_install_out"
|
||
if wget_download "$uv_script" https://astral.sh/uv/install.sh "" false \
|
||
2>"$uv_install_out"; then
|
||
uv_install_rc=0
|
||
break
|
||
else
|
||
uv_install_rc=$?
|
||
fi
|
||
if [ "$attempt" -lt 3 ]; then
|
||
sleep $((attempt * 2))
|
||
fi
|
||
done
|
||
elif is_snap_curl; then
|
||
rm -f "$uv_install_out" "$uv_script"
|
||
log_error "curl is installed as a snap and cannot download files due to sandbox permissions."
|
||
log_error "Please install wget, or reinstall curl with a different package manager (e.g. apt)."
|
||
exit 1
|
||
else
|
||
rm -f "$uv_install_out" "$uv_script"
|
||
log_error "curl or wget is required to install uv."
|
||
exit 1
|
||
fi
|
||
|
||
if [ "$uv_install_rc" -ne 0 ]; then
|
||
# Surface the downloader's own error (captured above) before the generic
|
||
# line, so the user sees the real cause and the downloader's exit code.
|
||
if declare -F restore_terminal_after_signal >/dev/null 2>&1; then
|
||
restore_terminal_after_signal "$uv_install_rc"
|
||
fi
|
||
cat "$uv_install_out" >&2
|
||
rm -f "$uv_install_out" "$uv_script"
|
||
if declare -F log_signal_failure_hint >/dev/null 2>&1; then
|
||
log_signal_failure_hint "$uv_install_rc"
|
||
fi
|
||
log_error "Failed to download uv installer (exit ${uv_install_rc}) from https://astral.sh/uv/install.sh"
|
||
log_error " Try again, or install uv manually: https://docs.astral.sh/uv/getting-started/installation/"
|
||
exit "$uv_install_rc"
|
||
fi
|
||
|
||
# Astral does not publish a checksum that covers uv-installer.sh itself:
|
||
# the per-release sha256.sum and dist-manifest.json only list the platform
|
||
# archives, and the archives' digests are embedded in this script (which
|
||
# pins APP_VERSION and verifies them after download). The meaningful checks
|
||
# for the script fetch are therefore the shebang and parse checks below:
|
||
# they catch a captive-portal HTML page and a truncated body, which are the
|
||
# realistic failure modes for this URL.
|
||
#
|
||
# Verify the downloaded script starts with a shell shebang before executing
|
||
# it. This catches a non-shell response — an HTML error page or JSON from a
|
||
# proxy or captive portal that returned 200 — that would otherwise fail
|
||
# unpredictably when run by sh. It only inspects the first line, so it is a
|
||
# sanity check on the response type, not an integrity guarantee: it won't
|
||
# detect a truncated or tampered body.
|
||
local uv_shebang
|
||
uv_shebang=$(head -1 "$uv_script")
|
||
if ! printf '%s' "$uv_shebang" | grep -qE '^#!.*(sh|bash)'; then
|
||
rm -f "$uv_install_out" "$uv_script"
|
||
log_error "uv installer download does not start with a shell shebang."
|
||
log_error " The URL may have returned an error page (proxy, captive portal, or outage)."
|
||
log_error " Try again, or install uv manually: https://docs.astral.sh/uv/getting-started/installation/"
|
||
exit 1
|
||
fi
|
||
|
||
# Parse-check the script before executing it. The shebang check above only
|
||
# proves the response is *meant* to be shell; a truncated download (dropped
|
||
# connection mid-body) would still pass that and then fail unpredictably
|
||
# partway through execution. A parse check reads the whole file without
|
||
# running it, so it catches any truncation that leaves unbalanced syntax (an
|
||
# open quote, `if` without `fi`, an unterminated function body). It is not a
|
||
# completeness guarantee: a body cut at a statement boundary still parses.
|
||
#
|
||
# Use the interpreter named in the script's own shebang rather than always
|
||
# `sh`. Upstream currently ships POSIX sh, but if it ever ships bash-only
|
||
# syntax, checking with dash would report a syntax error and send the user
|
||
# chasing a nonexistent truncated download. The same interpreter runs the
|
||
# script below — checking with bash and then executing with sh would let
|
||
# bash-only syntax pass the check and fail at execution instead, which is
|
||
# the confusing outcome this branch exists to avoid.
|
||
local uv_checker="sh"
|
||
case "$uv_shebang" in
|
||
*bash*) command -v bash >/dev/null 2>&1 && uv_checker="bash" ;;
|
||
esac
|
||
if ! "$uv_checker" -n "$uv_script" 2>"$uv_install_out"; then
|
||
cat "$uv_install_out" >&2
|
||
rm -f "$uv_install_out" "$uv_script"
|
||
log_error "uv installer download failed a shell syntax check — it may be truncated."
|
||
log_error " Try again, or install uv manually: https://docs.astral.sh/uv/getting-started/installation/"
|
||
exit 1
|
||
fi
|
||
|
||
"$uv_checker" "$uv_script" >"$uv_install_out" 2>&1 || uv_install_rc=$?
|
||
if [ "$VERBOSE" = "1" ] || [ "$uv_install_rc" -ne 0 ]; then
|
||
cat "$uv_install_out" >&2
|
||
fi
|
||
rm -f "$uv_install_out" "$uv_script"
|
||
if [ "$uv_install_rc" -ne 0 ]; then
|
||
if declare -F restore_terminal_after_signal >/dev/null 2>&1; then
|
||
restore_terminal_after_signal "$uv_install_rc"
|
||
fi
|
||
if declare -F log_signal_failure_hint >/dev/null 2>&1; then
|
||
log_signal_failure_hint "$uv_install_rc"
|
||
fi
|
||
log_error "uv installation failed. See errors above."
|
||
exit "$uv_install_rc"
|
||
fi
|
||
}
|
||
|
||
# Resolve uv binary: honor UV_BIN override, then PATH, the env file written by
|
||
# uv's installer, then the default install location (~/.local/bin). MDM and cron
|
||
# jobs often run with a minimal PATH, so an existing uv in ~/.local/bin must
|
||
# count as installed before we invoke the upstream installer.
|
||
resolve_uv_bin() {
|
||
if [ -n "${UV_BIN:-}" ]; then
|
||
case "$UV_BIN" in
|
||
*/*) [ -f "$UV_BIN" ] && [ -x "$UV_BIN" ] ;;
|
||
*) command -v "$UV_BIN" >/dev/null 2>&1 ;;
|
||
esac
|
||
return $?
|
||
fi
|
||
|
||
if command -v uv >/dev/null 2>&1; then
|
||
UV_BIN="uv"
|
||
return 0
|
||
fi
|
||
|
||
if [ -f "${HOME}/.local/bin/env" ]; then
|
||
set +e +u
|
||
# shellcheck source=/dev/null
|
||
. "${HOME}/.local/bin/env"
|
||
set -e -u
|
||
if command -v uv >/dev/null 2>&1; then
|
||
UV_BIN="uv"
|
||
return 0
|
||
fi
|
||
fi
|
||
|
||
if [ -x "${HOME}/.local/bin/uv" ]; then
|
||
UV_BIN="${HOME}/.local/bin/uv"
|
||
return 0
|
||
fi
|
||
|
||
return 1
|
||
}
|
||
|
||
uv_cache_candidates() {
|
||
# Single source of truth for uv's cache locations. Both the snapshot helper
|
||
# and the inline snapshot below read this list. A location added to one but
|
||
# not the other would silently leave a root-owned cache behind.
|
||
printf '%s\n' \
|
||
"${XDG_CACHE_HOME:-${HOME}/.cache}/uv" \
|
||
"${HOME}/Library/Caches/uv" \
|
||
"${XDG_DATA_HOME:-${HOME}/.local/share}/uv"
|
||
}
|
||
|
||
snapshot_missing_uv_cache_paths() {
|
||
local uv_cache_dir
|
||
UV_CACHE_CANDIDATES=""
|
||
while IFS= read -r uv_cache_dir; do
|
||
[ -n "$uv_cache_dir" ] || continue
|
||
if [ ! -e "$uv_cache_dir" ]; then
|
||
UV_CACHE_CANDIDATES="${UV_CACHE_CANDIDATES}${uv_cache_dir}"$'\n'
|
||
fi
|
||
done < <(uv_cache_candidates)
|
||
}
|
||
|
||
repair_created_uv_cache_paths() {
|
||
local uv_cache_dir
|
||
while IFS= read -r uv_cache_dir; do
|
||
[ -n "$uv_cache_dir" ] || continue
|
||
# Validate after installation, when the formerly missing path can be
|
||
# resolved without weakening path_is_under_home's symlink-safe contract.
|
||
if path_is_under_home "$uv_cache_dir"; then
|
||
fix_tree_owner "$uv_cache_dir"
|
||
fi
|
||
done <<EOF
|
||
${UV_CACHE_CANDIDATES}
|
||
EOF
|
||
}
|
||
|
||
if ! resolve_uv_bin; then
|
||
if [ -n "${UV_BIN:-}" ]; then
|
||
log_error "UV_BIN is set but does not point to an executable uv: ${UV_BIN}"
|
||
exit 1
|
||
fi
|
||
# Note which uv-owned caches this run is about to create, so a root install
|
||
# can hand them back afterwards. Without this the target user's later
|
||
# non-root `uv` invocations fail on a root-owned cache, far from here. Take
|
||
# this snapshot before acquiring the install lock: its default path lives
|
||
# below uv's data directory and can create that tree itself.
|
||
snapshot_missing_uv_cache_paths
|
||
acquire_install_lock
|
||
UV_BIN_DIR_PREEXISTED=false
|
||
if [ -d "${HOME}/.local/bin" ]; then
|
||
UV_BIN_DIR_PREEXISTED=true
|
||
fi
|
||
log_info "uv not found — installing..."
|
||
install_uv
|
||
if [ "$UV_BIN_DIR_PREEXISTED" = false ]; then
|
||
fix_file_owner "${HOME}/.local/bin"
|
||
fi
|
||
fix_file_owner "${HOME}/.local/bin/uv" "${HOME}/.local/bin/uvx" "${HOME}/.local/bin/env"
|
||
repair_created_uv_cache_paths
|
||
if ! resolve_uv_bin; then
|
||
log_error "uv not found after installation. Restart your shell or add ~/.local/bin to PATH."
|
||
exit 1
|
||
fi
|
||
fi
|
||
|
||
resolve_tool_bin_dir() {
|
||
local dir=""
|
||
if dir=$("$UV_BIN" tool dir --bin 2>/dev/null) && [ -n "$dir" ]; then
|
||
:
|
||
elif [ -n "${XDG_BIN_HOME:-}" ]; then
|
||
dir="$XDG_BIN_HOME"
|
||
elif [ -n "${XDG_DATA_HOME:-}" ]; then
|
||
dir="${XDG_DATA_HOME}/../bin"
|
||
else
|
||
dir="${HOME}/.local/bin"
|
||
fi
|
||
case "$dir" in
|
||
/*) ;;
|
||
*) dir="$(pwd -P)/${dir}" ;;
|
||
esac
|
||
printf '%s\n' "$dir"
|
||
}
|
||
|
||
TOOL_BIN_DIR="$(resolve_tool_bin_dir)"
|
||
TOOL_BIN_DIR_DISPLAY="$TOOL_BIN_DIR"
|
||
case "$TOOL_BIN_DIR" in
|
||
"$HOME"/*) TOOL_BIN_DIR_DISPLAY="~${TOOL_BIN_DIR#"$HOME"}" ;;
|
||
esac
|
||
TOOL_BIN_DIR_PREEXISTED=false
|
||
if [ -d "$TOOL_BIN_DIR" ]; then
|
||
TOOL_BIN_DIR_PREEXISTED=true
|
||
fi
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Latest-version lookup
|
||
# ---------------------------------------------------------------------------
|
||
# Print the latest published deepagents-code version from PyPI, or nothing on
|
||
# any failure (offline, transient error, missing downloader). PyPI nests the
|
||
# latest release at "info.version"; that key appears first in the response (the
|
||
# "info" object leads), so taking the first "version" match selects it without
|
||
# depending on a JSON parser. The pattern tolerates whitespace around the colon
|
||
# so a switch to pretty-printed JSON wouldn't silently break the probe.
|
||
# This relies on PyPI's current (not contractually guaranteed) key ordering; if
|
||
# it ever changed, the worst case is a wrong/empty match, which the caller
|
||
# already treats as "unknown latest" and recovers from — never a bad install.
|
||
fetch_latest_version() {
|
||
local json="" ua="deepagents-code-install"
|
||
json=$(download_to_stdout "$PYPI_JSON_URL" "$ua" 2>/dev/null) || return 0
|
||
if [ -z "$json" ]; then
|
||
return 0
|
||
fi
|
||
# `|| true` keeps a no-match (grep exit 1 under `pipefail`) from aborting the
|
||
# script; an empty result is handled by the caller as "unknown latest".
|
||
printf '%s' "$json" \
|
||
| grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]*"' \
|
||
| head -1 \
|
||
| sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/' || true
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Install deepagents-code
|
||
# ---------------------------------------------------------------------------
|
||
PACKAGE="deepagents-code${EXTRAS}${VERSION_SPEC}"
|
||
|
||
# Capture pre-install version (if any) for messaging
|
||
PRE_VERSION=""
|
||
PRE_INSTALL_IS_TOOL=false
|
||
PRE_INSTALL_ON_PATH=false
|
||
if [ "$(id -u)" -ne 0 ]; then
|
||
for candidate in dcode deepagents-code; do
|
||
if [ -x "${TOOL_BIN_DIR}/${candidate}" ]; then
|
||
PRE_VERSION=$("${TOOL_BIN_DIR}/${candidate}" -v 2>/dev/null | head -1 | awk '{print $NF}') || PRE_VERSION=""
|
||
PRE_INSTALL_IS_TOOL=true
|
||
original=$(PATH="$ORIGINAL_PATH" command -v "$candidate" 2>/dev/null || true)
|
||
if [ -n "$original" ] && \
|
||
{ [ "$original" = "${TOOL_BIN_DIR}/${candidate}" ] || [ "$original" -ef "${TOOL_BIN_DIR}/${candidate}" ]; }; then
|
||
PRE_INSTALL_ON_PATH=true
|
||
fi
|
||
break
|
||
fi
|
||
done
|
||
if [ "$PRE_INSTALL_IS_TOOL" = false ]; then
|
||
for candidate in dcode deepagents-code; do
|
||
if command -v "$candidate" >/dev/null 2>&1; then
|
||
PRE_VERSION=$("$candidate" -v 2>/dev/null | head -1 | awk '{print $NF}') || PRE_VERSION=""
|
||
break
|
||
fi
|
||
done
|
||
fi
|
||
fi
|
||
|
||
# Detect editable installs (uv tool install -e <path>) so we can tell the user
|
||
# why the environment will be rebuilt instead of upgraded in place.
|
||
IS_EDITABLE=false
|
||
EDITABLE_SRC=""
|
||
UV_TOOL_DIR=""
|
||
if UV_TOOL_DIR_RAW=$("$UV_BIN" tool dir 2>/dev/null); then
|
||
UV_TOOL_DIR="$UV_TOOL_DIR_RAW"
|
||
fi
|
||
MANAGED_BIN_DIR="${UV_TOOL_DIR:+${UV_TOOL_DIR}/deepagents-code/share/deepagents-code/bin}"
|
||
# `dcode tools install` falls back to "${DEEPAGENTS_HOME}/bin" when
|
||
# MANAGED_BIN_DIR is unwritable. Neither is snapshotted: see
|
||
# fix_managed_bin_owner for why the repair must not be gated on pre-existence.
|
||
|
||
# `uv tool install` writes into uv's caches too, so a root run leaves them
|
||
# root-owned and the target user's next non-root `uv` fails on a stale cache,
|
||
# far from here. Snapshot which caches exist now: ones this run creates can be
|
||
# handed back wholesale, while a pre-existing user cache is only reported —
|
||
# walking a tree the installer did not create is what fix_tree_owner forbids.
|
||
UV_TOOL_CACHE_NEW=""
|
||
UV_TOOL_CACHE_PREEXISTING=""
|
||
while IFS= read -r uv_cache_dir; do
|
||
[ -n "$uv_cache_dir" ] || continue
|
||
if [ -e "$uv_cache_dir" ]; then
|
||
UV_TOOL_CACHE_PREEXISTING="${UV_TOOL_CACHE_PREEXISTING}${uv_cache_dir}"$'\n'
|
||
else
|
||
UV_TOOL_CACHE_NEW="${UV_TOOL_CACHE_NEW}${uv_cache_dir}"$'\n'
|
||
fi
|
||
done < <(uv_cache_candidates)
|
||
if [ -n "$UV_TOOL_DIR" ] && [ -d "${UV_TOOL_DIR}/deepagents-code" ]; then
|
||
shopt -s nullglob
|
||
for du in "${UV_TOOL_DIR}"/deepagents-code/lib/python*/site-packages/deepagents_code-*.dist-info/direct_url.json; do
|
||
if grep -q '"editable"[[:space:]]*:[[:space:]]*true' "$du" 2>/dev/null; then
|
||
IS_EDITABLE=true
|
||
EDITABLE_SRC=$(sed -nE 's|.*"url"[[:space:]]*:[[:space:]]*"file://([^"]*)".*|\1|p' "$du" | head -1)
|
||
# Guard against malformed JSON producing a bogus path.
|
||
[ -n "$EDITABLE_SRC" ] && [ ! -d "$EDITABLE_SRC" ] && EDITABLE_SRC=""
|
||
break
|
||
fi
|
||
done
|
||
shopt -u nullglob
|
||
fi
|
||
|
||
# Read the extras the existing tool was installed with from uv's receipt. When
|
||
# the user re-runs this installer without DEEPAGENTS_CODE_EXTRAS, uv rebuilds
|
||
# the environment against bare `deepagents-code` and silently drops those
|
||
# extras' packages - warn before that happens so they can re-run with the
|
||
# extras preserved. Checked whenever the caller passed no EXTRAS and the
|
||
# existing install isn't editable, which covers plain upgrades as well as the
|
||
# same-version repair paths below (both re-run `uv tool install`).
|
||
#
|
||
# The receipt records every requirement in one array: the tool itself plus any
|
||
# supplemental `--with` packages. Only parse extras from the `deepagents-code`
|
||
# requirement - a `--with rich[jupyter]` entry must not surface as a tool
|
||
# extra, since `DEEPAGENTS_CODE_EXTRAS` installs `deepagents-code[...]` and
|
||
# cannot preserve supplemental packages.
|
||
#
|
||
# EXTRAS_UNREADABLE separates "this install has no extras" from "we could not
|
||
# tell". Both would otherwise reach the rebuild silently, and a false negative
|
||
# here costs the user the exact packages this check exists to protect - so an
|
||
# unreadable or unparseable receipt warns rather than degrading to quiet.
|
||
INSTALLED_EXTRAS=""
|
||
EXTRAS_UNREADABLE=false
|
||
receipt=""
|
||
receipt_install_dir=""
|
||
if [ -z "$EXTRAS" ] && [ "$IS_EDITABLE" = false ]; then
|
||
receipt_install_dir="${UV_TOOL_DIR:+${UV_TOOL_DIR}/deepagents-code}"
|
||
if [ -z "$UV_TOOL_DIR" ] || { [ -e "$UV_TOOL_DIR" ] && [ ! -x "$UV_TOOL_DIR" ]; }; then
|
||
# `uv tool dir` failed (a uv too old for the subcommand, a broken config),
|
||
# or its directory can't be searched. Either way we can't reach a receipt.
|
||
# Only a machine that already has an install can lose extras, so stay quiet
|
||
# when nothing is installed rather than warning every fresh run.
|
||
[ -z "$PRE_VERSION" ] || EXTRAS_UNREADABLE=true
|
||
elif [ -d "$receipt_install_dir" ]; then
|
||
receipt="${receipt_install_dir}/uv-receipt.toml"
|
||
if [ ! -x "$receipt_install_dir" ]; then
|
||
# A prior `sudo` run left the tool dir root-owned and mode 0700 (what a
|
||
# root umask of 077 produces). The receipt tests below would all report
|
||
# "absent" through an unsearchable parent, so check the directory first.
|
||
# Only search permission matters: opening a known filename inside needs
|
||
# `x`, not `r`, so a `--x` directory is still perfectly readable here.
|
||
EXTRAS_UNREADABLE=true
|
||
elif [ -L "$receipt" ]; then
|
||
# Refusing to read through a symlink matches the install-log hardening
|
||
# above, but the refusal must still be announced - staying silent here is
|
||
# indistinguishable from "no extras" to the user losing them.
|
||
EXTRAS_UNREADABLE=true
|
||
elif [ ! -f "$receipt" ]; then
|
||
# An install exists but has no receipt: a uv predating uv-receipt.toml, a
|
||
# future relocation of the file, or a partially-deleted tool dir. We
|
||
# cannot tell what extras it was built with, so say so.
|
||
EXTRAS_UNREADABLE=true
|
||
elif [ ! -r "$receipt" ]; then
|
||
# A receipt written by a previous `sudo` run and re-read as a normal user.
|
||
EXTRAS_UNREADABLE=true
|
||
else
|
||
# Narrow to the `requirements` assignment before looking for the entry. uv
|
||
# also writes an `entrypoints` array, and this package declares a console
|
||
# script literally named `deepagents-code` (see [project.scripts] in
|
||
# pyproject.toml), so an unscoped match would happily pick
|
||
# { name = "deepagents-code", install-path = "...", from = "deepagents-code" }
|
||
# - an inline table that never carries extras. Matching it would report
|
||
# "no extras" for an install that has them, defeating the whole check. The
|
||
# range runs from the requirements line to the next top-level key
|
||
# *assignment*, which covers both the single-line array uv writes today
|
||
# and a wrapped one. Note the terminator matches neither a table header
|
||
# (`[tool.options]`) nor a key containing digits, so a receipt that put
|
||
# either of those directly after `requirements` would run the range to
|
||
# EOF and let `entrypoints` back into the candidate region; that is safe
|
||
# against uv's current layout but is the thing to revisit if uv
|
||
# restructures the receipt.
|
||
if receipt_requirements=$(sed -nE \
|
||
'/^requirements = /,$ { /^requirements = /!{ /^[A-Za-z_-]+ = /q; }; p; }' \
|
||
"$receipt" 2>/dev/null); then
|
||
# Then isolate the deepagents-code inline table ([^{}] cannot cross into a
|
||
# neighbouring requirement) and read extras out of that entry alone. uv
|
||
# keeps each inline table on one line (observed through uv 0.9); if a
|
||
# future formatter wraps it, the entry match fails - handled below.
|
||
# `|| receipt_entry=""`: under `set -o pipefail` a large enough matching
|
||
# region lets `head` close the pipe while `sed` is still writing, and
|
||
# the resulting SIGPIPE (141) would abort the whole installer before uv
|
||
# ever runs - surfaced only as the EXIT trap's generic exit-code line.
|
||
# An empty value falls through to the "cannot tell" branch below, which
|
||
# is the right answer for a receipt that large or that malformed.
|
||
receipt_entry=$(printf '%s\n' "$receipt_requirements" \
|
||
| sed -nE 's/.*(\{[^{}]*name = "deepagents-code"[^{}]*\}).*/\1/p' \
|
||
| head -1) || receipt_entry=""
|
||
if [ -n "$receipt_entry" ]; then
|
||
INSTALLED_EXTRAS=$(printf '%s\n' "$receipt_entry" \
|
||
| sed -nE 's/.*extras = \[([^]]*)\].*/\1/p' \
|
||
| tr -d ' "')
|
||
# INSTALLED_EXTRAS is echoed back inside a double-quoted, ready-to-paste
|
||
# shell command below; on a shared host a less-privileged writer of the
|
||
# receipt could plant `$(...)` and have it evaluated by whoever pastes
|
||
# the suggestion. Restrict to PEP 508 extra-name characters plus commas —
|
||
# anything else means the receipt was tampered with or written by a
|
||
# foreign tool, which is the unparseable case. An empty value is the
|
||
# normal no-extras receipt (uv omits the key entirely), not tampering.
|
||
case "$INSTALLED_EXTRAS" in
|
||
*[!A-Za-z0-9_,.-]*)
|
||
EXTRAS_UNREADABLE=true
|
||
INSTALLED_EXTRAS=""
|
||
;;
|
||
esac
|
||
else
|
||
# No entry matched: either the receipt has no `requirements` section we
|
||
# recognise, or the requirement is wrapped across lines. Both are "we
|
||
# cannot tell" and never "no extras" - uv always records the tool's own
|
||
# requirement, so a miss here is a parse failure, not an empty install.
|
||
EXTRAS_UNREADABLE=true
|
||
fi
|
||
else
|
||
# The read failed after the checks above, so the receipt may have changed
|
||
# or disappeared while we were reading it. Treat that race as unreadable.
|
||
EXTRAS_UNREADABLE=true
|
||
fi
|
||
fi
|
||
else
|
||
# `uv tool dir` resolved and is searchable, but this package has no
|
||
# directory under it. When the installed dcode came from uv's tool bin dir
|
||
# there must be a receipt somewhere, so not finding it here means the tool
|
||
# dir moved between installs (UV_TOOL_DIR, a changed `tool-dir` in uv.toml,
|
||
# a relocated XDG_DATA_HOME). The old install is still on PATH and the
|
||
# rebuild will still drop its extras, so this is "couldn't tell" exactly
|
||
# like the missing-receipt branch above - not "no extras". An install that
|
||
# did not come from uv (pipx, a manual venv) has no receipt to expect, so
|
||
# stay quiet there rather than warning about a file that was never written.
|
||
[ "$PRE_INSTALL_IS_TOOL" = false ] || EXTRAS_UNREADABLE=true
|
||
fi
|
||
fi
|
||
|
||
uv_rc=0
|
||
UV_REPORTED_PACKAGE_CHANGES=false
|
||
|
||
if [ "$IS_EDITABLE" = true ]; then
|
||
pre_label="${PRE_VERSION:-(version unknown)}"
|
||
if [ -n "$EDITABLE_SRC" ]; then
|
||
log_info "deepagents-code ${pre_label} found (editable install from ${EDITABLE_SRC})."
|
||
else
|
||
log_info "deepagents-code ${pre_label} found (editable install from local source)."
|
||
fi
|
||
log_info " Replacing with a standard install from PyPI — the existing environment will be rebuilt."
|
||
elif [ -n "$PRE_VERSION" ] && [ -z "$VERSION" ] && [ -z "$PRERELEASE_REQUESTED" ]; then
|
||
# Default path with an existing install: probe PyPI and prompt before
|
||
# upgrading, rather than silently pulling the latest version every run.
|
||
# A pinned version or pre-release strategy (handled by the branches above and
|
||
# below) expresses explicit intent, so those install directly.
|
||
#
|
||
# The up-to-date check below is plain string equality, so it relies on
|
||
# PRE_VERSION (the raw `dcode -v` literal) and LATEST_VERSION (PyPI's
|
||
# PEP 440-normalized `info.version`) being identically canonical. release-please
|
||
# keeps `_version.py` to clean `X.Y.Z`, so they match today; a non-canonical
|
||
# release literal would merely re-prompt an up-to-date user, never silently
|
||
# skip a real upgrade. A shell installer can't import `packaging` to compare
|
||
# semantically the way `update_check.py` does.
|
||
log_info "dcode ${PRE_VERSION} found — checking for updates..."
|
||
# Set on the branches that deliberately move to the PyPI latest the script
|
||
# just fetched and confirmed differs from the installed version. That is the
|
||
# one path where the run can honestly call the version move an "upgrade" in
|
||
# the footer — every other version move (custom index resolving older, a
|
||
# pinned downgrade) stays neutral. See the footer far below.
|
||
UPGRADE_INTENDED=false
|
||
LATEST_VERSION=$(fetch_latest_version)
|
||
if [ -z "$LATEST_VERSION" ]; then
|
||
log_warn "Could not determine the latest version from PyPI — continuing with an upgrade attempt."
|
||
elif [ -n "$EXTRAS" ] || [ "$PYTHON_REQUESTED" = true ]; then
|
||
if [ "$LATEST_VERSION" = "$PRE_VERSION" ]; then
|
||
log_info "deepagents-code is already up to date — rebuilding with requested options."
|
||
else
|
||
log_info "Updating deepagents-code ${PRE_VERSION} → ${LATEST_VERSION} with requested options..."
|
||
UPGRADE_INTENDED=true
|
||
fi
|
||
elif [ "$LATEST_VERSION" = "$PRE_VERSION" ] && [ "$PRE_INSTALL_ON_PATH" = true ]; then
|
||
log_success "Already up to date!"
|
||
exit 0
|
||
elif [ "$LATEST_VERSION" = "$PRE_VERSION" ] && [ "$PRE_INSTALL_IS_TOOL" = true ]; then
|
||
log_info "deepagents-code ${PRE_VERSION} is current but is not selected on PATH — repairing its install."
|
||
elif [ "$LATEST_VERSION" = "$PRE_VERSION" ]; then
|
||
log_info "deepagents-code ${PRE_VERSION} is current but is outside uv's configured tool bin — installing it there."
|
||
elif [ "$ASSUME_YES" = "1" ]; then
|
||
log_info "Update available: deepagents-code ${PRE_VERSION} → ${LATEST_VERSION}"
|
||
log_info " What's new: ${RELEASE_TAG_URL_BASE}${LATEST_VERSION}"
|
||
log_info "Updating deepagents-code ${PRE_VERSION} → ${LATEST_VERSION}..."
|
||
UPGRADE_INTENDED=true
|
||
elif can_prompt; then
|
||
log_info "Update available: deepagents-code ${PRE_VERSION} → ${LATEST_VERSION}"
|
||
log_info " What's new: ${RELEASE_TAG_URL_BASE}${LATEST_VERSION}"
|
||
if prompt_yn "Install update?"; then
|
||
log_info "Updating deepagents-code ${PRE_VERSION} → ${LATEST_VERSION}..."
|
||
UPGRADE_INTENDED=true
|
||
else
|
||
update_prompt_rc=$?
|
||
if [ "$update_prompt_rc" -eq 2 ]; then
|
||
# `can_prompt` proved the terminal could be opened, but it may still
|
||
# detach before `prompt_yn` can read from it. As with no TTY at all,
|
||
# nobody declined the update, so warn and complete the install.
|
||
log_warn "Could not ask — continuing with the update."
|
||
UPGRADE_INTENDED=true
|
||
else
|
||
log_info "Keeping deepagents-code ${PRE_VERSION}. Re-run this installer anytime to update."
|
||
exit 0
|
||
fi
|
||
fi
|
||
else
|
||
# No TTY to prompt (cron, CI, Dockerfile RUN, systemd): there is no human to
|
||
# ask, and an installer's job is to make the current version present, so
|
||
# complete the upgrade rather than silently no-op. Callers that want a fixed
|
||
# version pin DEEPAGENTS_CODE_VERSION, which skips this path entirely.
|
||
log_info "Update available: deepagents-code ${PRE_VERSION} → ${LATEST_VERSION} — updating (no TTY to prompt)."
|
||
UPGRADE_INTENDED=true
|
||
fi
|
||
elif [ -n "$PRE_VERSION" ]; then
|
||
log_info "dcode ${PRE_VERSION} found — checking for updates..."
|
||
else
|
||
log_info "Installing ${PACKAGE}..."
|
||
fi
|
||
|
||
# Mirror uv's raw output to a persistent log under the XDG cache dir. A
|
||
# same-version dependency bump prints only a one-line summary and a failed
|
||
# install scrolls past, so the log preserves the full diff/errors for later.
|
||
# Prefer $XDG_CACHE_HOME, falling back to ~/.cache — XDG-style on every
|
||
# platform (like the rustup and uv installers), since this is a portable
|
||
# one-shot POSIX bootstrap. The installed app instead uses platform-native
|
||
# cache dirs (e.g. ~/Library/Caches on macOS) for its update logs, so the two
|
||
# intentionally land under different roots on macOS; see default_cache_dir()
|
||
# in deepagents_code/model_config.py.
|
||
# INSTALL_LOG is the real path used for writes; INSTALL_LOG_DISPLAY is the
|
||
# tilde-collapsed form shown to the user. Both stay empty when the dir can't
|
||
# be created, which every consumer treats as "feature disabled" so messages
|
||
# degrade cleanly.
|
||
#
|
||
# This sits *below* the update-check block on purpose. prepare_install_log_dir
|
||
# is not a path computation — it creates the cache root (0700) and the package
|
||
# subdirectory. Running it earlier made a plain "Already up to date!" re-run,
|
||
# or a declined update, create directories on a machine the run otherwise left
|
||
# completely untouched.
|
||
INSTALL_LOG=""
|
||
INSTALL_LOG_DISPLAY=""
|
||
cache_root="${XDG_CACHE_HOME:-}"
|
||
if [ "$(id -u)" -eq 0 ] && [ -n "${HOME:-}" ]; then
|
||
cache_root="${HOME}/.cache"
|
||
elif [ -z "$cache_root" ] && [ -n "${HOME:-}" ]; then
|
||
cache_root="${HOME}/.cache"
|
||
fi
|
||
if [ -n "$cache_root" ]; then
|
||
if install_log_dir=$(prepare_install_log_dir "$cache_root"); then
|
||
INSTALL_LOG="${install_log_dir}/install.log"
|
||
INSTALL_LOG_DISPLAY="$INSTALL_LOG"
|
||
if [ -n "${HOME:-}" ]; then
|
||
case "$INSTALL_LOG" in
|
||
"$HOME"/*) INSTALL_LOG_DISPLAY="~${INSTALL_LOG#"$HOME"}" ;;
|
||
esac
|
||
fi
|
||
else
|
||
# Several distinct rejections collapse into "no log this run": an
|
||
# unwritable or symlinked cache root, a non-directory in the way, a root
|
||
# run whose directory is not under $HOME. Name the path once — otherwise
|
||
# the only signal is the *absence* of the `Update log:`/`Full log:` lines,
|
||
# which is indistinguishable from a run that had nothing to report.
|
||
log_warn "Could not prepare ${cache_root}/deepagents-code — continuing without an install log."
|
||
fi
|
||
fi
|
||
|
||
# Decide where uv's stderr streams *during* the install. In the live path uv
|
||
# writes straight to INSTALL_LOG so `tail -f` shows output as it happens (the
|
||
# built-in updater does the same); copy_install_log then sees the log already
|
||
# in place and skips the staged publish. Root always takes the mktemp +
|
||
# stage-publish path: copy_install_log never resolves a user-writable parent
|
||
# as root, and streaming straight to INSTALL_LOG would follow a planted
|
||
# symlink there. An unprivileged run that cannot open its log falls back to
|
||
# the staged path too, so "unprivileged" and "live" are not synonyms.
|
||
UV_LIVE_LOG=false
|
||
# File descriptor that carries the live install log when UV_LIVE_LOG is on.
|
||
# Picked as the highest POSIX-guaranteed descriptor so it clears the script's
|
||
# own: 0-2 plus fd 3, which prompt_yn uses for /dev/tty.
|
||
UV_LIVE_LOG_FD=9
|
||
setup_live_install_log() {
|
||
[ "$(id -u)" -ne 0 ] && [ -n "$INSTALL_LOG" ] || return 0
|
||
if [ -L "$INSTALL_LOG" ]; then
|
||
# A planted symlink turns the install log off for this run entirely — not
|
||
# just the live tail. Both variables are blanked, so no consumer offers a
|
||
# pointer and nothing falls back to the staged publish. It is never
|
||
# deleted or followed.
|
||
#
|
||
# Unlike a transient hostile race, a symlink sitting here is durable state
|
||
# — most plausibly the user's own (`ln -s /dev/null` to mute logging, or a
|
||
# link onto a roomier disk). They are the only one who can undo it, so say
|
||
# so rather than leaving the feature silently off on every future run too.
|
||
log_warn "${INSTALL_LOG_DISPLAY} is a symlink — not writing an install log this run."
|
||
log_warn " Remove it to re-enable install logging."
|
||
INSTALL_LOG=""
|
||
INSTALL_LOG_DISPLAY=""
|
||
return 0
|
||
fi
|
||
# Create this run's log beside the target and rename it into place, rather
|
||
# than removing the previous log and creating in its stead. Under that older
|
||
# shape a failed create — ENOSPC, EDQUOT, a read-only remount, a sandbox
|
||
# denial, an exhausted fd table — left the user with yesterday's
|
||
# diagnostics deleted and nothing written in their place. Here the previous
|
||
# log survives until this run holds a writable file, and the rename over it
|
||
# is atomic.
|
||
local pending="${INSTALL_LOG}.new"
|
||
local umask_save
|
||
# Clear a leftover from a crashed run. `rm -f` unlinks a symlink rather than
|
||
# writing through it, so a planted one is removed, not followed.
|
||
rm -f "$pending" 2>/dev/null || true
|
||
# umask 077: `exec >` honours the ambient umask, so without this the log
|
||
# lands 0644 where the non-root staged fallback produced 0600 (`cp` from a
|
||
# 0600 mktemp file, preserved by `mv`). uv's stderr can carry credentialed
|
||
# index URLs, and the 0700 parent is not a reliable backstop —
|
||
# prepare_install_log_dir accepts a pre-existing directory at any mode.
|
||
umask_save=$(umask)
|
||
umask 077
|
||
# Noclobber so the create cannot overwrite or follow anything raced into the
|
||
# pending path. Retaining the result as an open fd pins the inode: a
|
||
# path-based `2>"$INSTALL_LOG"` would re-resolve the name when uv launches,
|
||
# letting a process that can write the cache dir swap in a symlink after the
|
||
# check. `uv_stderr` keeps the real path so the post-install readers
|
||
# (awk/cat/grep) operate on a file; the fd is only uv's write target.
|
||
set -o noclobber
|
||
if ! eval "exec $UV_LIVE_LOG_FD>\"\$pending\"" 2>/dev/null; then
|
||
set +o noclobber
|
||
umask "$umask_save"
|
||
# Not fatal — the staged mktemp path below still runs, and the previous
|
||
# log is still intact. But this is an ordinary operational failure the
|
||
# user may be able to act on, and it used to be swallowed entirely.
|
||
log_warn "Could not open a log file in ${install_log_dir} — continuing without live logging."
|
||
return 0
|
||
fi
|
||
set +o noclobber
|
||
umask "$umask_save"
|
||
# rename(2) replaces a symlink at the destination rather than writing
|
||
# through it; `-d` rejects the one case `mv` would read as "move into"
|
||
# instead of "replace".
|
||
if [ -d "$INSTALL_LOG" ] || ! mv -f "$pending" "$INSTALL_LOG" 2>/dev/null; then
|
||
eval "exec $UV_LIVE_LOG_FD>&-" 2>/dev/null || true
|
||
rm -f "$pending" 2>/dev/null || true
|
||
log_warn "Could not publish ${INSTALL_LOG_DISPLAY} — continuing without live logging."
|
||
return 0
|
||
fi
|
||
uv_stderr="$INSTALL_LOG"
|
||
UV_LIVE_LOG=true
|
||
}
|
||
|
||
# Capture uv stderr so we can:
|
||
# 1. Rewrite the cryptic "Ignoring existing environment ..." warning into
|
||
# plain English. uv emits that line when it rebuilds the tool venv
|
||
# instead of upgrading in place (e.g., Python interpreter mismatch, or
|
||
# editable↔regular install swap).
|
||
# 2. Drop uv's per-step timing lines ("Resolved N packages in...", etc.)
|
||
# download/build progress, and the trailing "Installed N executables:" line
|
||
# — we already show a concise install/update summary.
|
||
# 3. Reformat the `- pkg==X` / `+ pkg==Y` diff into an aligned
|
||
# "pkg X → Y" table under a single header.
|
||
# 4. Detect whether uv actually moved any packages (those same
|
||
# `- pkg==X` / `+ pkg==Y` lines). A same-version reinstall that still
|
||
# bumps dependencies must report differently from a true no-op, so a
|
||
# later grep over this raw capture file sets UV_REPORTED_PACKAGE_CHANGES.
|
||
# 5. Persist the raw output to a log file (see the INSTALL_LOG block above)
|
||
# so a same-version dependency bump — or a failed install — can point the
|
||
# user at the full details after the terminal scrolls away.
|
||
# Capturing to a file (vs. process substitution) ensures we see uv's full exit
|
||
# status, don't race the warning past later log lines, and can re-scan the
|
||
# raw output for (4) after the awk pass above has already reformatted it. That
|
||
# file is the mktemp scratch file in the staged path and INSTALL_LOG itself in
|
||
# the live path. Both are plain files when created; the live path is a
|
||
# user-visible location this run has just advertised, so the readers below
|
||
# check it is still readable rather than assuming it.
|
||
# Warn (and offer to back out) before *this* block would take the lock: it only
|
||
# prints a warning and asks a question - the receipt itself was read far above,
|
||
# also outside the lock - so holding the install lock across an unbounded human
|
||
# wait would block a concurrent installer (which spins silently, since it can't
|
||
# reclaim a lock whose owner is alive) for no reason. A run that had to
|
||
# bootstrap uv first already holds the lock by now (see the acquire on the
|
||
# uv-install path, which is why the acquire below is guarded), but such a run
|
||
# has no prior install and so reaches neither branch here in practice.
|
||
#
|
||
# `prompt_yn` returns 2 only when it could not ask at all - /dev/tty opened for
|
||
# can_prompt but no longer opens, e.g. a session that detached in between. That
|
||
# is not a "no": treating it as one turns a broken terminal into a silent no-op
|
||
# exit, and it would contradict the no-TTY branch above, which reasons that
|
||
# with no human to ask the installer should complete the upgrade rather than
|
||
# stall. An EOF on a terminal that *did* open is a human declining and arrives
|
||
# as 1, so it aborts through the branches below like any other "n".
|
||
#
|
||
# Both branches end an abort with "Aborted. deepagents-code was left
|
||
# unchanged." rather than a bare "Aborted.": this point is also reachable on a
|
||
# same-version PATH repair (the branches above that reinstall a current
|
||
# version), so the message names what was left undone instead of implying the
|
||
# run had nothing else to do.
|
||
extras_prompt_rc=0
|
||
if [ "$EXTRAS_UNREADABLE" = true ]; then
|
||
log_warn "Could not read ${receipt:-the uv tool receipt} to check which extras this install was built with."
|
||
log_warn " If it was built with DEEPAGENTS_CODE_EXTRAS, re-run with the same value or those packages will be removed."
|
||
# An unreadable receipt can still hide extras (e.g. one written by a prior
|
||
# sudo run), so offer the same abort prompt as the known-extras case below:
|
||
# the user is told their extras may be removed and must get the chance to
|
||
# stop before the rebuild drops them.
|
||
if [ "$ASSUME_YES" != "1" ] && can_prompt; then
|
||
prompt_yn "Continue anyway?" || extras_prompt_rc=$?
|
||
if [ "$extras_prompt_rc" -eq 2 ]; then
|
||
log_warn "Could not ask — continuing; any extras this install has will be removed."
|
||
elif [ "$extras_prompt_rc" -ne 0 ]; then
|
||
log_info "Aborted. deepagents-code was left unchanged."
|
||
exit 0
|
||
fi
|
||
fi
|
||
elif [ -n "$INSTALLED_EXTRAS" ]; then
|
||
log_warn "This install has extras that a bare re-run will remove: ${INSTALLED_EXTRAS}"
|
||
log_warn " To keep them, re-run with: DEEPAGENTS_CODE_EXTRAS=\"${INSTALLED_EXTRAS}\""
|
||
# Give an interactive user the chance to back out before uv rebuilds the
|
||
# environment and drops those packages. No TTY means nobody can answer; an
|
||
# explicit DEEPAGENTS_CODE_YES means they already did. Both keep the warning
|
||
# and proceed - a pre-answered yes is an instruction to continue, not to
|
||
# abort.
|
||
if [ "$ASSUME_YES" != "1" ] && can_prompt; then
|
||
prompt_yn "Continue anyway and remove them?" || extras_prompt_rc=$?
|
||
if [ "$extras_prompt_rc" -eq 2 ]; then
|
||
log_warn "Could not ask — continuing; the extras above will be removed."
|
||
elif [ "$extras_prompt_rc" -ne 0 ]; then
|
||
log_info "Aborted. deepagents-code was left unchanged."
|
||
exit 0
|
||
fi
|
||
fi
|
||
fi
|
||
# Take the install lock before replacing the prior diagnostic log, so a no-op,
|
||
# a declined update, or a failed lock acquisition can never erase it.
|
||
# setup_live_install_log only renames over the old log once it holds a
|
||
# writable file, so a failed create leaves the previous run's log in place.
|
||
if [ -z "$INSTALL_LOCK_KIND" ]; then
|
||
acquire_install_lock
|
||
fi
|
||
setup_live_install_log
|
||
if [ "$UV_LIVE_LOG" = false ]; then
|
||
uv_stderr=$(mktemp 2>/dev/null) || {
|
||
log_error "mktemp is required to create a secure temp file."
|
||
exit 1
|
||
}
|
||
register_temp "$uv_stderr"
|
||
else
|
||
log_update_tail_hint
|
||
fi
|
||
# In live-log mode uv's stderr goes to the descriptor opened by
|
||
# setup_live_install_log, not to a re-opened pathname; see the rationale there.
|
||
if [ "$UV_LIVE_LOG" = true ]; then
|
||
if [[ -z "$VERSION" ]]; then
|
||
"$UV_BIN" tool install -U --python "$PYTHON_VERSION" \
|
||
--prerelease "$PRERELEASE" "$PACKAGE" 2>&$UV_LIVE_LOG_FD || uv_rc=$?
|
||
else
|
||
"$UV_BIN" tool install -U --python "$PYTHON_VERSION" "$PACKAGE" \
|
||
2>&$UV_LIVE_LOG_FD || uv_rc=$?
|
||
fi
|
||
# Close the write end now that uv has exited. Everything downstream — the
|
||
# awk/cat/grep passes, `dcode -v`, the ripgrep install — would otherwise
|
||
# inherit a writable handle on the log for the rest of the run. Matches the
|
||
# convention prompt_yn already follows for its own descriptor. Say so if the
|
||
# close fails: that inherited handle is exactly what this line exists to
|
||
# prevent, and silently not preventing it is worse than a noisy run.
|
||
if ! eval "exec $UV_LIVE_LOG_FD>&-" 2>/dev/null; then
|
||
log_warn "Could not close the install-log descriptor — later steps may inherit it."
|
||
fi
|
||
elif [[ -z "$VERSION" ]]; then
|
||
"$UV_BIN" tool install -U --python "$PYTHON_VERSION" \
|
||
--prerelease "$PRERELEASE" "$PACKAGE" 2>"$uv_stderr" || uv_rc=$?
|
||
else
|
||
"$UV_BIN" tool install -U --python "$PYTHON_VERSION" "$PACKAGE" \
|
||
2>"$uv_stderr" || uv_rc=$?
|
||
fi
|
||
# In the live path `uv_stderr` is the cache log this run just told the user to
|
||
# `tail -f`, so it can disappear under a cache cleaner or a tidy-up between
|
||
# uv exiting and these readers. Under `set -e` an unguarded `awk`/`cat` on a
|
||
# missing file would abort the whole install and report it as an install
|
||
# failure, which is both wrong and unexplained.
|
||
if [ ! -r "$uv_stderr" ]; then
|
||
log_warn "The captured uv output at ${INSTALL_LOG_DISPLAY:-$uv_stderr} is no longer readable."
|
||
elif [ "$VERBOSE" != "1" ] && command -v awk >/dev/null 2>&1; then
|
||
awk '
|
||
/^Ignoring existing environment/ {
|
||
print "⚠ Existing environment uses a different Python — rebuilding from scratch (this is normal)."
|
||
next
|
||
}
|
||
/^Resolved( [0-9]+ packages?)? in / { next }
|
||
/^Prepared [0-9]+ packages?( |$)/ { next }
|
||
/^Uninstalled [0-9]+ packages? in / { next }
|
||
/^Installed [0-9]+ packages? in / { next }
|
||
/^Audited( [0-9]+ packages?)? in / { next }
|
||
/^Checked( [0-9]+ packages?)? in / { next }
|
||
/^[[:space:]]*Downloading / { next }
|
||
/^[[:space:]]*Downloaded / { next }
|
||
/^[[:space:]]*Building / { next }
|
||
/^[[:space:]]*Built / { next }
|
||
/^Installed [0-9]+ executables?:/ { next }
|
||
/^ - / {
|
||
s = $0; sub(/^ - /, "", s); n = index(s, "==")
|
||
if (n > 0) {
|
||
pkg = substr(s, 1, n - 1); ver = substr(s, n + 2)
|
||
removed[pkg] = ver
|
||
if (!(pkg in seen)) { seen[pkg] = 1; order[++cnt] = pkg }
|
||
}
|
||
next
|
||
}
|
||
/^ \+ / {
|
||
s = $0; sub(/^ \+ /, "", s); n = index(s, "==")
|
||
if (n > 0) {
|
||
pkg = substr(s, 1, n - 1); ver = substr(s, n + 2)
|
||
added[pkg] = ver
|
||
if (!(pkg in seen)) { seen[pkg] = 1; order[++cnt] = pkg }
|
||
}
|
||
next
|
||
}
|
||
{ print }
|
||
END {
|
||
if (cnt == 0) exit
|
||
any_removed = 0
|
||
for (i = 1; i <= cnt; i++) {
|
||
if (order[i] in removed) any_removed = 1
|
||
}
|
||
if (!any_removed) {
|
||
# No upgrades or removals — every touched package is a brand-new
|
||
# addition (a fresh install, or new extras pulled into an existing
|
||
# env). Listing the full transitive set is noise; verbose mode keeps
|
||
# the output available for debugging.
|
||
exit
|
||
}
|
||
maxw = 0
|
||
for (i = 1; i <= cnt; i++) {
|
||
p = order[i]
|
||
if (length(p) > maxw) maxw = length(p)
|
||
}
|
||
# Upgrades touch only a handful of packages, so the diff stays compact and
|
||
# genuinely useful — keep printing it. "(new)" disambiguates added rows
|
||
# from upgraded/removed ones within this mixed list.
|
||
print "Updated packages:"
|
||
for (i = 1; i <= cnt; i++) {
|
||
p = order[i]
|
||
pad = ""
|
||
for (j = length(p); j < maxw; j++) pad = pad " "
|
||
if ((p in removed) && (p in added)) {
|
||
printf " %s%s %s → %s\n", p, pad, removed[p], added[p]
|
||
} else if (p in added) {
|
||
printf " %s%s %s (new)\n", p, pad, added[p]
|
||
} else {
|
||
printf " %s%s %s (removed)\n", p, pad, removed[p]
|
||
}
|
||
}
|
||
}
|
||
' "$uv_stderr" >&2
|
||
else
|
||
cat "$uv_stderr" >&2
|
||
fi
|
||
if grep -Eq '^[[:space:]]+[-+][[:space:]]+[^=]+==' "$uv_stderr" 2>/dev/null; then
|
||
UV_REPORTED_PACKAGE_CHANGES=true
|
||
fi
|
||
if [ -n "$INSTALL_LOG" ]; then
|
||
# Return code 2 means the publish failed for an ordinary operational reason
|
||
# (no space, a cache dir left root-owned by an earlier sudo run) rather than
|
||
# because the path looked hostile. Say so: on a `curl | bash` run with no
|
||
# scrollback the log is the only way back to uv's output, and silently not
|
||
# having one is indistinguishable from a clean run. Rejected-path failures
|
||
# (return 1) stay quiet — the user cannot act on them and the noise would be
|
||
# alarming.
|
||
log_copy_rc=0
|
||
copy_install_log || log_copy_rc=$?
|
||
if [ "$log_copy_rc" -ne 0 ]; then
|
||
if [ "$log_copy_rc" -eq 2 ]; then
|
||
log_warn "Could not write the install log to ${INSTALL_LOG_DISPLAY} — continuing without it."
|
||
fi
|
||
INSTALL_LOG=""
|
||
INSTALL_LOG_DISPLAY=""
|
||
fi
|
||
fi
|
||
# Live-log runs left uv's output in INSTALL_LOG, which must survive.
|
||
[ "$UV_LIVE_LOG" = true ] || rm -f "$uv_stderr"
|
||
# A live run replaced the previous log before uv started, so an empty one here
|
||
# means yesterday's diagnostics are gone and nothing replaced them. uv exited
|
||
# without writing to stderr — a clean no-op reinstall, or a run cut short
|
||
# before its first byte. Both `Full log:` pointers are gated on `-s`, so
|
||
# without this the loss is completely silent. (The staged path can also publish
|
||
# an empty log over a good one; it simply has no equivalent warning, since
|
||
# nothing there distinguishes "uv said nothing" from "uv was never run".)
|
||
if [ "$UV_LIVE_LOG" = true ] && [ -n "$INSTALL_LOG" ] && [ ! -s "$INSTALL_LOG" ]; then
|
||
log_warn "uv wrote no output — ${INSTALL_LOG_DISPLAY} is empty (any previous log was replaced)."
|
||
LIVE_LOG_REPLACED_NOTICE_DONE=true
|
||
fi
|
||
if [ "$uv_rc" -ne 0 ]; then
|
||
restore_terminal_after_signal "$uv_rc"
|
||
log_signal_failure_hint "$uv_rc"
|
||
log_error "Failed to install ${PACKAGE}. See errors above."
|
||
# The log holds uv's full stderr — written directly in the live path, copied
|
||
# just above in the staged one — so point the user at it: non-verbose mode
|
||
# trims uv's lines from the terminal
|
||
# and piped `curl | bash` runs lose scrollback. Require a non-empty file for
|
||
# the same reason the success path does: uv killed by a signal before writing
|
||
# anything leaves a zero-byte log, and sending a user whose install just
|
||
# failed to an empty file is a dead end.
|
||
if [ -n "$INSTALL_LOG" ] && [ -s "$INSTALL_LOG" ]; then
|
||
log_error "Full log: ${INSTALL_LOG_DISPLAY}"
|
||
fi
|
||
log_error "Common fixes: check your network, try a different Python version (DEEPAGENTS_CODE_PYTHON=3.12), or install manually."
|
||
exit "$uv_rc"
|
||
fi
|
||
if path_is_under_home "$TOOL_BIN_DIR"; then
|
||
if [ "$TOOL_BIN_DIR_PREEXISTED" = false ]; then
|
||
fix_file_owner "$TOOL_BIN_DIR"
|
||
fi
|
||
fix_file_owner "${TOOL_BIN_DIR}/dcode" "${TOOL_BIN_DIR}/deepagents-code"
|
||
fi
|
||
# Repair on every root run, not only a first install. `uv tool install` has
|
||
# just written into this tree as root, so gating on "did this run create
|
||
# it?" would leave the common `sudo` upgrade path root-owned — exactly
|
||
# the breakage fix_tree_owner exists to prevent. The tree is dcode's own tool
|
||
# environment, so the installer owns it whether or not this run created it.
|
||
if [ -n "$UV_TOOL_DIR" ] && path_is_under_home "${UV_TOOL_DIR}/deepagents-code"; then
|
||
fix_tree_owner "${UV_TOOL_DIR}/deepagents-code"
|
||
fi
|
||
while IFS= read -r uv_cache_dir; do
|
||
[ -n "$uv_cache_dir" ] || continue
|
||
if path_is_under_home "$uv_cache_dir"; then
|
||
fix_tree_owner "$uv_cache_dir"
|
||
fi
|
||
done <<EOF
|
||
${UV_TOOL_CACHE_NEW}
|
||
EOF
|
||
if [ "$(id -u)" -eq 0 ]; then
|
||
while IFS= read -r uv_cache_dir; do
|
||
[ -n "$uv_cache_dir" ] || continue
|
||
[ -d "$uv_cache_dir" ] || continue
|
||
log_warn "Installed as root using the pre-existing uv cache at ${uv_cache_dir}."
|
||
log_warn " Some entries are now root-owned. Run: sudo chown -R ${TARGET_USER} ${uv_cache_dir}"
|
||
done <<EOF
|
||
${UV_TOOL_CACHE_PREEXISTING}
|
||
EOF
|
||
fi
|
||
# Restore ownership for the log path without recursively chowning a cache path
|
||
# that could have been swapped after creation.
|
||
fix_install_log_owner
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# PATH setup — make dcode immediately findable in a new shell
|
||
# ---------------------------------------------------------------------------
|
||
# After `uv tool install`, dcode lands in uv's configured tool bin directory.
|
||
# If that directory is not already in PATH, expose the installed binary through
|
||
# one of the user's conventional bin directories.
|
||
#
|
||
# Strategy (symlink-first adapted from Amp's installer,
|
||
# https://ampcode.com/install.sh; multi-profile coverage and the fish conf.d
|
||
# file adapted from muse's installer, https://dev.meta.ai/install.sh):
|
||
# 1. If a common bin dir (~/.local/bin, ~/bin, ~/.bin) is already in PATH,
|
||
# create a symlink there — no profile modification needed.
|
||
# 2. Otherwise, create ~/.local/bin, symlink dcode there, then add
|
||
# ~/.local/bin to the user's shell startup files. Every zsh/bash profile
|
||
# that already exists gets the entry (plus the primary one for the
|
||
# current shell), so dcode resolves even if the user later switches
|
||
# shells; fish gets a standalone conf.d/deepagents-code.fish file, which
|
||
# fish auto-sources, instead of a config.fish edit. Prompt interactively
|
||
# before writing; auto-add in non-interactive mode (CI, cron, piped
|
||
# install).
|
||
# 3. Skip the whole thing if the binary was already on PATH or uv's env file
|
||
# exposes a binary installed under ~/.local/bin.
|
||
# DEEPAGENTS_CODE_NO_MODIFY_PATH=1 skips steps 1–2 entirely, for users with
|
||
# version-managed dotfiles or MDM-managed PATH.
|
||
|
||
# Check if a directory was in PATH before the installer sourced any env files.
|
||
dir_in_original_path() {
|
||
local check_dir="$1"
|
||
[ -d "$check_dir" ] || return 1
|
||
check_dir=$(cd "$check_dir" 2>/dev/null && pwd) || return 1
|
||
case ":${ORIGINAL_PATH:-}:" in
|
||
*":$check_dir:"*) return 0 ;;
|
||
*) return 1 ;;
|
||
esac
|
||
}
|
||
|
||
# Try to symlink the dcode binary into a directory already in PATH. Tries
|
||
# ~/.local/bin, ~/bin, and ~/.bin in order. Returns 0 on success.
|
||
try_symlink_in_path() {
|
||
local binary_name="$1"
|
||
local binary_path="$2"
|
||
local preferred_dirs=("$HOME/.local/bin" "$HOME/bin" "$HOME/.bin")
|
||
local dir symlink_path
|
||
for dir in "${preferred_dirs[@]}"; do
|
||
if dir_in_original_path "$dir"; then
|
||
mkdir -p "$dir" 2>/dev/null || continue
|
||
symlink_path="$dir/$binary_name"
|
||
if paths_are_same_file "$binary_path" "$symlink_path"; then
|
||
return 0
|
||
fi
|
||
if [ -e "$symlink_path" ] && [ ! -L "$symlink_path" ]; then
|
||
continue
|
||
fi
|
||
# Remove existing symlink if it points elsewhere or is stale
|
||
if [ -L "$symlink_path" ]; then
|
||
rm -f "$symlink_path"
|
||
fi
|
||
if ln -s "$binary_path" "$symlink_path" 2>/dev/null; then
|
||
fix_file_owner "$symlink_path" 2>/dev/null || true
|
||
return 0
|
||
fi
|
||
fi
|
||
done
|
||
return 1
|
||
}
|
||
|
||
# Detect the user's shell and return the profile file + PATH export statement.
|
||
# Sets SHELL_PROFILE, PATH_EXPORT, and DETECTED_SHELL as globals.
|
||
#
|
||
# For fish, SHELL_PROFILE names config.fish only so the reload hint and the
|
||
# "add this yourself" message have something to point at. The PATH entry itself
|
||
# is never written there: ensure_path_setup writes a standalone
|
||
# conf.d/deepagents-code.fish (see fish_conf_file) and never adds config.fish
|
||
# as a write candidate.
|
||
detect_shell_profile() {
|
||
local default_shell="bash"
|
||
if [ "$OS" = "macos" ]; then
|
||
default_shell="zsh"
|
||
fi
|
||
local shell_name
|
||
shell_name=$(basename "${SHELL:-$default_shell}" 2>/dev/null) || shell_name="$default_shell"
|
||
# Callers must reuse this rather than re-deriving from $SHELL: an empty
|
||
# SHELL would otherwise resolve differently here and there.
|
||
DETECTED_SHELL="${shell_name:-$default_shell}"
|
||
shell_name="$DETECTED_SHELL"
|
||
SHELL_PROFILE=""
|
||
PATH_EXPORT=""
|
||
case "$shell_name" in
|
||
zsh)
|
||
local zdotdir
|
||
zdotdir="$(resolve_zdotdir)"
|
||
SHELL_PROFILE="${zdotdir:-$HOME}/.zshrc"
|
||
# shellcheck disable=SC2016 # single-quoted so $HOME/$PATH expand at profile source time, not here
|
||
PATH_EXPORT='export PATH="$HOME/.local/bin:$PATH"'
|
||
;;
|
||
bash)
|
||
if [ "$OS" = "macos" ]; then
|
||
if [ -f "$HOME/.bash_profile" ]; then
|
||
SHELL_PROFILE="$HOME/.bash_profile"
|
||
elif [ -f "$HOME/.bashrc" ]; then
|
||
SHELL_PROFILE="$HOME/.bashrc"
|
||
else
|
||
SHELL_PROFILE="$HOME/.bash_profile"
|
||
fi
|
||
else
|
||
if [ -f "$HOME/.bashrc" ]; then
|
||
SHELL_PROFILE="$HOME/.bashrc"
|
||
elif [ -f "$HOME/.bash_profile" ]; then
|
||
SHELL_PROFILE="$HOME/.bash_profile"
|
||
else
|
||
SHELL_PROFILE="$HOME/.bashrc"
|
||
fi
|
||
fi
|
||
# shellcheck disable=SC2016 # single-quoted so $HOME/$PATH expand at profile source time, not here
|
||
PATH_EXPORT='export PATH="$HOME/.local/bin:$PATH"'
|
||
;;
|
||
fish)
|
||
# Reload hint sources config.fish; the PATH entry itself is written to
|
||
# conf.d/deepagents-code.fish by ensure_path_setup. Honor XDG_CONFIG_HOME
|
||
# here too, or the hint names a file the user doesn't have.
|
||
SHELL_PROFILE="${XDG_CONFIG_HOME:-$HOME/.config}/fish/config.fish"
|
||
# shellcheck disable=SC2016 # single-quoted so $HOME expands at profile source time, not here
|
||
PATH_EXPORT='fish_add_path "$HOME/.local/bin"'
|
||
;;
|
||
*)
|
||
# Unknown shell — don't modify any profile.
|
||
;;
|
||
esac
|
||
}
|
||
|
||
# The fish PATH entry lives in its own conf.d file, which fish auto-sources at
|
||
# startup. A standalone file keeps the installer from mutating the user's main
|
||
# config.fish and makes the entry trivially removable.
|
||
fish_conf_file() {
|
||
printf '%s\n' "${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d/deepagents-code.fish"
|
||
}
|
||
|
||
# shellcheck disable=SC2016 # single-quoted so $PATH stays literal for the fish file
|
||
FISH_PATH_EXPORT='contains -- "$HOME/.local/bin" $PATH; or set -gx PATH "$HOME/.local/bin" $PATH'
|
||
|
||
# The POSIX export line, used for every startup file that isn't fish's conf.d
|
||
# file. Kept separate from PATH_EXPORT because PATH_EXPORT tracks the *current
|
||
# shell* (and is empty for unknown shells), while the line written to a given
|
||
# file has to match *that file's* syntax — a fish user can still have a
|
||
# ~/.zshrc candidate, which must not receive fish syntax.
|
||
# shellcheck disable=SC2016 # single-quoted so $HOME/$PATH expand at profile source time
|
||
POSIX_PATH_EXPORT='export PATH="$HOME/.local/bin:$PATH"'
|
||
|
||
# Check if ~/.local/bin is already referenced in the shell profile's PATH
|
||
# config. Matches non-commented lines containing .local/bin in a PATH
|
||
# assignment or fish_add_path. Returns 0 if already present.
|
||
#
|
||
# The alternation also recognizes the un-normalized ~/.local/share/../bin
|
||
# spelling (share/.. collapses to .local, so it resolves to the same directory
|
||
# as ~/.local/bin). Some tools write that spelling into a profile from
|
||
# $XDG_DATA_HOME/../bin; without this we'd fail to see ~/.local/bin as already
|
||
# on PATH and append a duplicate entry. This covers the common alias only — it
|
||
# is not a full path normalizer, so other exotic spellings still slip through.
|
||
local_bin_in_profile() {
|
||
local profile="$1"
|
||
[ -f "$profile" ] || return 1
|
||
grep -v '^[[:space:]]*#' "$profile" 2>/dev/null | grep -qE 'PATH=.*(\.local/bin|\.local/share/\.\./bin)' \
|
||
|| grep -v '^[[:space:]]*#' "$profile" 2>/dev/null | grep -qE '(fish_add_path|set[[:space:]].*PATH).*(\.local/bin|\.local/share/\.\./bin)'
|
||
}
|
||
|
||
# Net block-nesting change contributed by one shell line, into
|
||
# SHELL_BLOCK_DELTA (a global: returning it via $(...) would fork per line).
|
||
# Separators that can abut a keyword are flattened to spaces first, so a
|
||
# self-contained `if [ x ]; then y; fi` nets zero rather than opening a block
|
||
# that never closes. This is a line-oriented approximation, not a shell parser:
|
||
# it does not know about quoting or heredocs. It only has to be right about the
|
||
# ordinary conditional wrapper in a hand-written ~/.zshenv.
|
||
SHELL_BLOCK_DELTA=0
|
||
shell_block_delta() {
|
||
local haystack="$1" word
|
||
SHELL_BLOCK_DELTA=0
|
||
haystack="${haystack//;/ }"
|
||
haystack="${haystack//&/ }"
|
||
haystack="${haystack//|/ }"
|
||
haystack="${haystack//(/ }"
|
||
haystack="${haystack//)/ }"
|
||
# Unquoted expansion below is deliberate (word splitting); disable globbing so
|
||
# a `*` in the line can't expand against the cwd.
|
||
set -f
|
||
for word in $haystack; do
|
||
case "$word" in
|
||
if|case|while|for|until) SHELL_BLOCK_DELTA=$((SHELL_BLOCK_DELTA + 1)) ;;
|
||
fi|esac|done) SHELL_BLOCK_DELTA=$((SHELL_BLOCK_DELTA - 1)) ;;
|
||
esac
|
||
done
|
||
set +f
|
||
}
|
||
|
||
# Resolve the directory holding zsh's .zshrc. ZDOTDIR, when set, relocates all
|
||
# of zsh's dotfiles; users who keep ~/.zshrc elsewhere (e.g.
|
||
# ~/.config/zsh/.zshrc) would otherwise get a stray new ~/.zshrc that zsh never
|
||
# reads, plus no PATH entry in the file zsh does read.
|
||
# Checks $ZDOTDIR itself first, then parses a ZDOTDIR assignment out of
|
||
# ~/.zshenv (the one file zsh always reads from $HOME, and the canonical place
|
||
# to set ZDOTDIR). Prints the resolved directory, or nothing when unresolved.
|
||
resolve_zdotdir() {
|
||
local line value="" depth=0 scan
|
||
if [ -n "${ZDOTDIR:-}" ]; then
|
||
printf '%s\n' "$ZDOTDIR"
|
||
return 0
|
||
fi
|
||
[ -n "${HOME:-}" ] || return 0
|
||
# `-r`, not just `-f`: a root-owned or 0600 .zshenv belonging to another user
|
||
# passes `-f` but the redirect below then fails, leaving `value` empty and
|
||
# silently sending the zshrc to ~/.zshrc — the exact misplacement this
|
||
# function exists to prevent. Say so instead of guessing.
|
||
if [ -f "$HOME/.zshenv" ] && [ ! -r "$HOME/.zshenv" ]; then
|
||
log_warn "Cannot read ~/.zshenv to check for a ZDOTDIR setting; assuming zsh reads ~/.zshrc."
|
||
return 0
|
||
fi
|
||
[ -f "$HOME/.zshenv" ] || return 0
|
||
while IFS= read -r line || [ -n "$line" ]; do
|
||
scan="${line%%#*}"
|
||
# Only a top-level assignment is authoritative. The common portable-dotfiles
|
||
# idiom guards the relocation:
|
||
#
|
||
# if [ -d "$HOME/.config/zsh" ]; then export ZDOTDIR="$HOME/.config/zsh"; fi
|
||
#
|
||
# While that directory is absent the guard is false and zsh reads ~/.zshrc.
|
||
# Honoring the assignment anyway would make the caller create the directory,
|
||
# which flips the guard true and strands every later shell in a config dir
|
||
# holding nothing but our PATH block. Assignments nested in any block are
|
||
# therefore skipped; the one-line `[ -d x ] && export ZDOTDIR=y` form is
|
||
# already skipped because such a line doesn't begin with the assignment.
|
||
if [ "$depth" -eq 0 ]; then
|
||
# Strip leading whitespace, then an optional leading `export`.
|
||
while [[ "$line" == [[:space:]]* ]]; do line="${line#?}"; done
|
||
if [[ "$line" == export[[:space:]]* ]]; then
|
||
line="${line#export}"
|
||
while [[ "$line" == [[:space:]]* ]]; do line="${line#?}"; done
|
||
fi
|
||
if [[ "$line" == ZDOTDIR=* ]]; then
|
||
value="${line#ZDOTDIR=}"
|
||
fi
|
||
fi
|
||
shell_block_delta "$scan"
|
||
depth=$((depth + SHELL_BLOCK_DELTA))
|
||
# A stray closer (or a construct this line-oriented scan misreads) must not
|
||
# drive the depth negative and re-arm the top-level branch inside a block.
|
||
[ "$depth" -lt 0 ] && depth=0
|
||
done <"$HOME/.zshenv"
|
||
# Strip surrounding quotes, or a trailing `;`, `# comment`, or whitespace for
|
||
# the unquoted form.
|
||
case "$value" in
|
||
\"*)
|
||
value="${value#\"}"
|
||
value="${value%%\"*}"
|
||
;;
|
||
\'*)
|
||
value="${value#\'}"
|
||
value="${value%%\'*}"
|
||
;;
|
||
*)
|
||
value="${value%%;*}"
|
||
value="${value%%#*}"
|
||
value="${value%%[[:space:]]*}"
|
||
;;
|
||
esac
|
||
# Expand the ~ and $HOME spellings people actually write in .zshenv.
|
||
case "$value" in
|
||
\~) value="$HOME" ;;
|
||
\~/*) value="$HOME/${value#\~/}" ;;
|
||
"\$HOME") value="$HOME" ;;
|
||
"\${HOME}") value="$HOME" ;;
|
||
"\$HOME"/*) value="$HOME/${value#\$HOME/}" ;;
|
||
"\${HOME}"/*) value="$HOME/${value#\$\{HOME\}/}" ;;
|
||
esac
|
||
# Any absolute path is accepted, whether or not it exists yet. A ZDOTDIR=
|
||
# line in ~/.zshenv tells zsh "look here" — and zsh looks only there — so a
|
||
# valid absolute ZDOTDIR is authoritative for where the zshrc must go even
|
||
# when the directory hasn't been created. An existence check would have the
|
||
# failure mode backwards: on a fresh setup with `ZDOTDIR=$HOME/.config/zsh`,
|
||
# rejecting the value writes ~/.zshrc, a file zsh never reads once ZDOTDIR
|
||
# is set, so `dcode` stays off PATH. The caller creates a missing candidate
|
||
# directory before writing, which is safe only because guarded assignments
|
||
# were skipped above — creating a directory an `if [ -d ... ]` is waiting on
|
||
# would silently activate a ZDOTDIR the user has not opted into yet.
|
||
# Remaining limit: the loop takes the last top-level ZDOTDIR= assignment, so
|
||
# a value set from a sourced file or a shell function is still missed. That
|
||
# errs toward ~/.zshrc, which is where zsh looks when the assignment never
|
||
# runs, and ~/.zshrc is added alongside a ZDOTDIR zshrc whenever it exists.
|
||
case "$value" in
|
||
/*) printf '%s\n' "$value" ;;
|
||
esac
|
||
return 0
|
||
}
|
||
|
||
managed_path_block_present() {
|
||
local profile="$1"
|
||
[ -f "$profile" ] || return 1
|
||
grep -F '# >>> deepagents-code installer >>>' "$profile" >/dev/null 2>&1
|
||
}
|
||
|
||
managed_path_block_has_line() {
|
||
local profile="$1" path_export="$2"
|
||
[ -f "$profile" ] || return 1
|
||
grep -F "$path_export" "$profile" >/dev/null 2>&1
|
||
}
|
||
|
||
append_managed_path_block() {
|
||
local profile="$1" path_export="$2"
|
||
{
|
||
echo ""
|
||
echo "# >>> deepagents-code installer >>>"
|
||
echo "$path_export"
|
||
echo "# <<< deepagents-code installer <<<"
|
||
} >>"$profile"
|
||
}
|
||
|
||
# Resolve a symlink to the final non-symlink path in the chain. The result is
|
||
# not canonicalized (a relative hop leaves `..` segments in place) and is not
|
||
# guaranteed to exist or to be a regular file — nothing here checks that. It is
|
||
# absolute only if the input was.
|
||
# Read-only; used to pick the directory an atomic temp-file rewrite must live
|
||
# in. Chains (a -> b -> c) must be followed to the end: resolving one hop and
|
||
# `mv`-ing over it would replace an intermediate link with a regular file, so
|
||
# the real dotfile-manager source stays stale and the next restow recreates the
|
||
# link, reverting the PATH entry. The hop count is capped to bound symlink
|
||
# loops (ELOOP), which we can't detect portably; 40 matches Linux's
|
||
# MAXSYMLINKS, and macOS stops at 32, so any chain a kernel would follow fits.
|
||
resolve_link_target() {
|
||
local link="$1" target hops=0
|
||
while [ -L "$link" ]; do
|
||
hops=$((hops + 1))
|
||
if [ "$hops" -gt 40 ]; then
|
||
return 1
|
||
fi
|
||
target=$(readlink "$link") || return 1
|
||
case "$target" in
|
||
/*) link="$target" ;;
|
||
*) link="$(dirname "$link")/$target" ;;
|
||
esac
|
||
done
|
||
printf '%s\n' "$link"
|
||
}
|
||
|
||
rewrite_managed_path_block() {
|
||
local profile="$1" path_export="$2" tmp_profile mode
|
||
if [ -L "$profile" ]; then
|
||
# Dotfile managers (chezmoi, stow, nix home-manager) symlink startup files
|
||
# into a repo. Replacing the link with a regular file means the next
|
||
# `apply`/`restow` silently reverts the PATH entry — after we printed a
|
||
# success message. Write the chain's final target instead, through the same
|
||
# atomic temp-file + mv used below. Writing the target in place would
|
||
# truncate it first, so an interrupted install (we trap INT/TERM/HUP) could
|
||
# leave the dotfile-manager source empty or partial. Resolving the target
|
||
# first puts the temp file in the target's directory, keeping the mv atomic.
|
||
local link_target
|
||
if link_target=$(resolve_link_target "$profile"); then
|
||
log_warn "$profile is a symlink — editing its target instead of replacing the link."
|
||
profile="$link_target"
|
||
else
|
||
log_warn "Could not resolve the symlink target of $profile; skipping."
|
||
return 1
|
||
fi
|
||
fi
|
||
tmp_profile=$(mktemp "$(dirname "$profile")/.deepagents-code-profile.XXXXXX") || return 1
|
||
register_temp "$tmp_profile"
|
||
|
||
awk -v begin="# >>> deepagents-code installer >>>" \
|
||
-v end="# <<< deepagents-code installer <<<" \
|
||
-v line="$path_export" '
|
||
BEGIN { in_block = 0; replaced = 0 }
|
||
$0 == begin {
|
||
# Emit the replacement only for the first marker: any accidental
|
||
# duplicate managed blocks are collapsed into this single one.
|
||
if (!replaced) {
|
||
print begin
|
||
print line
|
||
print end
|
||
replaced = 1
|
||
}
|
||
in_block = 1
|
||
next
|
||
}
|
||
in_block {
|
||
if ($0 == end) {
|
||
in_block = 0
|
||
}
|
||
next
|
||
}
|
||
{ print }
|
||
END {
|
||
# A begin marker with no matching end marker means the block is malformed.
|
||
# Fail so the caller keeps the original profile (the mv below is skipped)
|
||
# rather than writing back a truncated file.
|
||
if (in_block != 0) {
|
||
exit 1
|
||
}
|
||
}
|
||
' "$profile" >"$tmp_profile" || return 1
|
||
# mktemp created tmp_profile as 0600; carry over the profile's real perms so
|
||
# an in-place rewrite doesn't silently tighten (e.g.) a 0644 ~/.zshrc.
|
||
mode=$(stat -f '%OLp' "$profile" 2>/dev/null || stat -c '%a' "$profile" 2>/dev/null || true)
|
||
if [ -n "$mode" ]; then
|
||
# Not fatal — the rewrite is still correct — but the user's file quietly
|
||
# becoming 0600 is the kind of change they should hear about.
|
||
chmod "$mode" "$tmp_profile" 2>/dev/null \
|
||
|| log_warn "Could not restore mode ${mode} on $(tilde_display "$profile"); it may now be more restrictive."
|
||
fi
|
||
mv "$tmp_profile" "$profile" || return 1
|
||
# `profile` is the final target when the caller supplied a symlink. The
|
||
# caller still fixes ownership on the original symlink, so also restore the
|
||
# target's ownership after this root-created atomic replacement.
|
||
fix_file_owner "$profile"
|
||
# Explicit: without this the function's status is fix_file_owner's, so the
|
||
# day an ownership helper starts propagating chown failures, a rewrite that
|
||
# fully succeeded would report failure.
|
||
return 0
|
||
}
|
||
|
||
# Ensure dcode is on PATH for new shell sessions. Creates symlinks and/or
|
||
# modifies shell startup files as needed. Only acts when the binary verified
|
||
# but isn't already on the user's original PATH.
|
||
# Returns: 0 = nothing more to say — PATH is fixed for the current shell (a
|
||
# symlink in an on-PATH dir), or setup was deliberately skipped
|
||
# and this function already printed the exact guidance,
|
||
# 1 = failure (a specific warning was already printed),
|
||
# 2 = no changes needed, or changes were written to startup files,
|
||
# but the current shell still must be reloaded or sourced before
|
||
# dcode will resolve,
|
||
# 3 = root install to a custom bin; PATH changes are left to MDM policy,
|
||
# 4 = the user declined the prompt; nothing was written.
|
||
# rc=0, 3 and 4 suppress the caller's reload hint. Any path that edits a
|
||
# startup file must return 2 (or 1) — a written file never helps the shell that
|
||
# is running right now — and any path that writes nothing must not return 2,
|
||
# or the user is told to restart a shell for a change that was never made.
|
||
ensure_path_setup() {
|
||
local binary_name="$1"
|
||
local binary_path="$2"
|
||
|
||
# Escape hatch for version-managed dotfiles (chezmoi, nix home-manager) and
|
||
# MDM fleets: install and verify the binary, but never touch startup files
|
||
# or create symlinks. The user adds the bin dir to PATH themselves.
|
||
#
|
||
# Accept the same *truthy* spellings as DEEPAGENTS_CODE_YES. The falsy
|
||
# handling deliberately differs: ASSUME_YES maps anything unrecognized to
|
||
# off, while this maps it to on. The opt-out exists to protect managed
|
||
# dotfiles, so an unparsed value must fail toward leaving files alone rather
|
||
# than editing them. Don't unify the two parsers.
|
||
local no_modify_path
|
||
no_modify_path="$(printf '%s' "${DEEPAGENTS_CODE_NO_MODIFY_PATH:-0}" \
|
||
| tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')"
|
||
case "$no_modify_path" in
|
||
0|false|no|'') no_modify_path="0" ;;
|
||
1|true|yes) no_modify_path="1" ;;
|
||
*)
|
||
log_warn "Unrecognized DEEPAGENTS_CODE_NO_MODIFY_PATH value — treating it as 1 (leaving startup files alone)."
|
||
no_modify_path="1"
|
||
;;
|
||
esac
|
||
if [ "$no_modify_path" = "1" ]; then
|
||
log_info "Skipping PATH setup (DEEPAGENTS_CODE_NO_MODIFY_PATH is set)."
|
||
log_info " ${binary_name} is at ${binary_path} — add its directory to PATH yourself, e.g.:"
|
||
log_info " export PATH=\"${binary_path%/*}:\$PATH\""
|
||
# rc=0 so the caller stays quiet: the generic reload hint would name
|
||
# ~/.local/bin, contradicting the exact directory printed just above.
|
||
return 0
|
||
fi
|
||
|
||
# uv's env file only exposes binaries that are actually under ~/.local/bin.
|
||
# A custom uv tool bin still needs a symlink or its own PATH entry.
|
||
local binary_dir="${binary_path%/*}"
|
||
if [ -f "$HOME/.local/bin/env" ] && \
|
||
paths_are_same_file "$binary_dir" "$HOME/.local/bin"; then
|
||
return 2
|
||
fi
|
||
if [ "$(id -u)" -eq 0 ] && ! paths_are_same_file "$binary_dir" "$HOME/.local/bin"; then
|
||
log_warn "${binary_name} installed to ${TOOL_BIN_DIR_DISPLAY}, which is not on the target user's PATH."
|
||
log_warn " Add that directory through the user's shell configuration or MDM policy."
|
||
return 3
|
||
fi
|
||
|
||
# Step 1: try symlinking into a dir already in PATH (no profile change).
|
||
if try_symlink_in_path "$binary_name" "$binary_path"; then
|
||
if [ "$VERBOSE" = "1" ]; then
|
||
log_success "Created symlink in PATH for ${binary_name}."
|
||
fi
|
||
return 0
|
||
fi
|
||
|
||
# Step 2: create ~/.local/bin, symlink there, then add to shell profiles.
|
||
local local_bin_preexisted=false
|
||
if [ -d "$HOME/.local/bin" ]; then
|
||
local_bin_preexisted=true
|
||
fi
|
||
mkdir -p "$HOME/.local/bin" 2>/dev/null || {
|
||
log_warn "Could not create ~/.local/bin."
|
||
return 1
|
||
}
|
||
if [ "$local_bin_preexisted" = false ]; then
|
||
fix_file_owner "$HOME/.local/bin"
|
||
fi
|
||
local symlink_path="$HOME/.local/bin/$binary_name"
|
||
if ! paths_are_same_file "$binary_path" "$symlink_path"; then
|
||
if [ -e "$symlink_path" ] && [ ! -L "$symlink_path" ]; then
|
||
log_warn "Refusing to replace existing file at ${symlink_path}."
|
||
return 1
|
||
fi
|
||
if [ -L "$symlink_path" ]; then
|
||
rm -f "$symlink_path"
|
||
fi
|
||
if ! ln -s "$binary_path" "$symlink_path" 2>/dev/null; then
|
||
log_warn "Could not create symlink at ${symlink_path}."
|
||
return 1
|
||
fi
|
||
fix_file_owner "$symlink_path"
|
||
fi
|
||
|
||
# Step 3: detect the current shell, then write the PATH entry into every
|
||
# relevant startup file that exists — plus the primary file for the current
|
||
# shell — so dcode resolves no matter which shell the user opens next.
|
||
detect_shell_profile
|
||
# Reuse detect_shell_profile's result rather than re-deriving it from $SHELL,
|
||
# so the two can't disagree about which shell this is: any second derivation
|
||
# needs its own fallback for an empty $SHELL, and a mismatch silently drops
|
||
# the primary profile detect_shell_profile just chose.
|
||
local shell_name="$DETECTED_SHELL"
|
||
local zdotdir
|
||
zdotdir="$(resolve_zdotdir)"
|
||
|
||
# Build the candidate profile list (deduped):
|
||
# - the current shell's primary file, even if it doesn't exist yet
|
||
# (zsh and bash only; fish is covered by its conf.d file below)
|
||
# - the zshrc at the resolved ZDOTDIR, plus ~/.zshrc when both exist
|
||
# (ZDOTDIR wins for the shell itself; ~/.zshrc covers stale copies)
|
||
# - whenever any bash file exists, or the shell is bash: .bashrc (on macOS
|
||
# only if it already exists), plus the login file (.bash_profile
|
||
# preferred, else .bash_login, else ~/.profile, which is what a bash
|
||
# login shell falls back to reading)
|
||
# - fish: conf.d/deepagents-code.fish, when fish is the shell or a fish
|
||
# config dir exists
|
||
# - any other shell: ~/.profile, in addition to whichever of the above
|
||
# matched on file existence
|
||
local candidates=() seen=""
|
||
add_candidate() {
|
||
case ":$seen:" in
|
||
*":$1:"*) ;;
|
||
*)
|
||
seen="$seen:$1"
|
||
candidates+=("$1")
|
||
;;
|
||
esac
|
||
}
|
||
has_candidate() {
|
||
case ":$seen:" in
|
||
*":$1:"*) return 0 ;;
|
||
esac
|
||
return 1
|
||
}
|
||
case "$shell_name" in
|
||
zsh)
|
||
# detect_shell_profile resolves ZDOTDIR too, so SHELL_PROFILE is already
|
||
# the same "${zdotdir:-$HOME}/.zshrc" the zsh branch below adds, and
|
||
# add_candidate dedupes. Adding it here is therefore redundant either
|
||
# way; the guard just makes the single source of that path obvious.
|
||
if [ -z "$zdotdir" ] || [ "$zdotdir" = "$HOME" ]; then
|
||
[ -n "$SHELL_PROFILE" ] && add_candidate "$SHELL_PROFILE"
|
||
fi
|
||
;;
|
||
bash)
|
||
[ -n "$SHELL_PROFILE" ] && add_candidate "$SHELL_PROFILE"
|
||
;;
|
||
# fish is covered solely by its conf.d file below — never edit config.fish.
|
||
esac
|
||
if [ "$shell_name" = "zsh" ] || [ -f "${zdotdir:-$HOME}/.zshrc" ]; then
|
||
add_candidate "${zdotdir:-$HOME}/.zshrc"
|
||
# ~/.zshrc is added alongside a ZDOTDIR zshrc only when it already exists.
|
||
# When ZDOTDIR is already set in the *environment*, zsh reads
|
||
# ${ZDOTDIR}/.zshenv rather than ~/.zshenv, so zsh's live ZDOTDIR can
|
||
# differ from whatever resolve_zdotdir parsed out of ~/.zshenv — and on a
|
||
# machine set up that way, ~/.zshrc may not exist at all.
|
||
if [ -n "$zdotdir" ] && [ "$zdotdir" != "$HOME" ] && [ -f "$HOME/.zshrc" ]; then
|
||
add_candidate "$HOME/.zshrc"
|
||
fi
|
||
fi
|
||
if [ "$shell_name" = "bash" ] || [ -f "$HOME/.bashrc" ] || \
|
||
[ -f "$HOME/.bash_profile" ] || [ -f "$HOME/.bash_login" ]; then
|
||
# .bashrc is sourced for interactive non-login shells (the default on
|
||
# Linux); macOS Terminal runs login shells instead, so a .bashrc created
|
||
# here wouldn't be read by the default Terminal session — skip creating
|
||
# one there. (A non-login bash started by hand on macOS does read it,
|
||
# which is why an existing .bashrc is still updated.)
|
||
if [ -f "$HOME/.bashrc" ] || [ "$OS" != "macos" ]; then
|
||
add_candidate "$HOME/.bashrc"
|
||
fi
|
||
# Test existence *or* already-queued: the bash branch above may have queued
|
||
# a not-yet-created ~/.bash_profile as the shell's primary file. Looking
|
||
# only at the filesystem would miss that, fall through to ~/.profile, and
|
||
# create both — after which bash reads the .bash_profile we just made and
|
||
# never the .profile, leaving a stray file behind.
|
||
if [ -f "$HOME/.bash_profile" ] || has_candidate "$HOME/.bash_profile"; then
|
||
add_candidate "$HOME/.bash_profile"
|
||
elif [ -f "$HOME/.bash_login" ] || has_candidate "$HOME/.bash_login"; then
|
||
add_candidate "$HOME/.bash_login"
|
||
else
|
||
# bash files exist but no login file. A bash login shell reads the first
|
||
# of .bash_profile / .bash_login / .profile that exists, so with neither
|
||
# of the first two present, ~/.profile is the file it will read — and
|
||
# ~/.bashrc alone leaves login shells (macOS Terminal, `bash -l`, ssh)
|
||
# without the entry. add_candidate dedupes if it's already listed.
|
||
add_candidate "$HOME/.profile"
|
||
fi
|
||
fi
|
||
if [ "$shell_name" = "fish" ] || [ -d "${XDG_CONFIG_HOME:-$HOME/.config}/fish" ]; then
|
||
add_candidate "$(fish_conf_file)"
|
||
fi
|
||
case "$shell_name" in
|
||
zsh|bash|fish) ;;
|
||
*) add_candidate "$HOME/.profile" ;;
|
||
esac
|
||
|
||
# Defensive only: every branch above adds at least one candidate (unknown
|
||
# shells fall through to ~/.profile), so this should be unreachable.
|
||
if [ ${#candidates[@]} -eq 0 ]; then
|
||
log_warn "${binary_name} installed to ~/.local/bin but no startup file could be selected."
|
||
log_warn " Add ~/.local/bin to your PATH manually."
|
||
return 1
|
||
fi
|
||
|
||
# Collapse $HOME prefix to ~ for tidier display paths. Done with a case +
|
||
# prefix strip rather than "${1/#$HOME/\~}": bash 3.2 (still the default
|
||
# /bin/bash on macOS, and this script is run as `curl … | bash`) does not
|
||
# unescape the \~ in a pattern-substitution replacement, so that form
|
||
# renders literally as "\~/.zshrc".
|
||
tilde_display() {
|
||
case "$1" in
|
||
"$HOME"/*) printf '~%s\n' "${1#"$HOME"}" ;;
|
||
"$HOME") printf '~\n' ;;
|
||
*) printf '%s\n' "$1" ;;
|
||
esac
|
||
}
|
||
|
||
# A candidate is satisfied when it already has our managed block with the
|
||
# right line, or (legacy installs, manual entries) any ~/.local/bin PATH
|
||
# reference at all.
|
||
profile_satisfied() {
|
||
local profile="$1" export_line="$2"
|
||
if managed_path_block_present "$profile"; then
|
||
managed_path_block_has_line "$profile" "$export_line"
|
||
return $?
|
||
fi
|
||
local_bin_in_profile "$profile"
|
||
}
|
||
|
||
# The line to write is a property of the *target file*, never of the current
|
||
# shell. Using PATH_EXPORT here would write fish syntax (`fish_add_path …`)
|
||
# into a fish user's ~/.zshrc — which is a candidate whenever that file
|
||
# exists — so every zsh session would fail with `command not found:
|
||
# fish_add_path` and never get ~/.local/bin. Only the fish conf.d file gets
|
||
# fish syntax; everything else (.zshrc, .bashrc, .bash_profile, .profile) is
|
||
# POSIX-compatible.
|
||
export_line_for() {
|
||
if [ "$1" = "$(fish_conf_file)" ]; then
|
||
printf '%s' "$FISH_PATH_EXPORT"
|
||
else
|
||
printf '%s' "$POSIX_PATH_EXPORT"
|
||
fi
|
||
}
|
||
|
||
# Partition candidates into satisfied vs needing the entry.
|
||
local pending=() satisfied=()
|
||
local profile export_line
|
||
for profile in "${candidates[@]}"; do
|
||
export_line="$(export_line_for "$profile")"
|
||
if profile_satisfied "$profile" "$export_line"; then
|
||
satisfied+=("$profile")
|
||
else
|
||
pending+=("$profile")
|
||
fi
|
||
done
|
||
|
||
if [ ${#pending[@]} -eq 0 ]; then
|
||
if [ "$VERBOSE" = "1" ]; then
|
||
for profile in "${satisfied[@]}"; do
|
||
log_info "~/.local/bin already configured in $(tilde_display "$profile")."
|
||
done
|
||
fi
|
||
# Nothing to write, but the current shell may still lack ~/.local/bin on
|
||
# PATH (stale shell). Return 2 so the caller emits a reload/source hint.
|
||
return 2
|
||
fi
|
||
|
||
# Prompt interactively (once, covering all files), or auto-add when
|
||
# non-interactive.
|
||
# Name every file the answer covers. Consent for one file must not be taken
|
||
# as consent to edit three: a macOS zsh user with a leftover ~/.bashrc and
|
||
# ~/.bash_profile would otherwise be asked only about ~/.zshrc.
|
||
local should_add=true prompt_targets
|
||
prompt_targets="$(tilde_display "${pending[0]}")"
|
||
# `pending` is guaranteed non-empty above, so "${pending[@]:1}" is safe even
|
||
# on the bash 3.2 macOS still ships (an empty *slice* of a non-empty array is
|
||
# fine there; only expanding a wholly empty array trips `set -u`). The length
|
||
# check just skips a pointless loop.
|
||
if [ ${#pending[@]} -gt 1 ]; then
|
||
local extra
|
||
for extra in "${pending[@]:1}"; do
|
||
prompt_targets="${prompt_targets}, $(tilde_display "$extra")"
|
||
done
|
||
fi
|
||
if [ "$IS_INTERACTIVE" = true ] && can_prompt; then
|
||
# Only an explicit "no" (rc=1) declines; rc=2 (no usable terminal) leaves
|
||
# the auto-add default in place, matching the non-interactive behavior.
|
||
# `|| prompt_rc=$?` keeps "no" from tripping `set -e`.
|
||
prompt_rc=0
|
||
prompt_yn "Add ~/.local/bin to your PATH in ${prompt_targets}?" || prompt_rc=$?
|
||
if [ "$prompt_rc" -eq 1 ]; then
|
||
should_add=false
|
||
fi
|
||
fi
|
||
if [ "$should_add" = false ]; then
|
||
log_info "Skipped modifying shell startup files."
|
||
# PATH_EXPORT is empty for unknown shells; fall back to the POSIX line so
|
||
# this never renders as a bare "add to PATH:" with nothing after it.
|
||
log_info " To use ${binary_name}, add to PATH: ${PATH_EXPORT:-$POSIX_PATH_EXPORT}"
|
||
# rc=4, not 2: nothing was written, so the caller's "Restart your shell"
|
||
# hint would be actively wrong — a restart cannot pick up an edit that was
|
||
# never made. The exact line to run was just printed above.
|
||
return 4
|
||
fi
|
||
|
||
local updated=0 failed=0
|
||
for profile in "${pending[@]}"; do
|
||
export_line="$(export_line_for "$profile")"
|
||
# Create the file (and parents) if missing. Keep the real error text —
|
||
# "Permission denied", "Read-only file system" and "Not a directory" call
|
||
# for completely different fixes, and this warning is the user's only
|
||
# signal that a startup file was skipped.
|
||
if [ ! -f "$profile" ]; then
|
||
local mk_err=""
|
||
if ! mk_err=$(mkdir -p "$(dirname "$profile")" 2>&1); then
|
||
log_warn "Could not create the directory for $(tilde_display "$profile"): ${mk_err}"
|
||
failed=$((failed + 1))
|
||
continue
|
||
fi
|
||
if ! mk_err=$(touch "$profile" 2>&1); then
|
||
log_warn "Could not create $(tilde_display "$profile"): ${mk_err}"
|
||
failed=$((failed + 1))
|
||
continue
|
||
fi
|
||
fi
|
||
# Check for an existing managed block first, including for fish: a fish
|
||
# conf.d file that already has our block (with a stale line) must be
|
||
# rewritten, not appended to, or every export-line change leaves another
|
||
# dead block behind. The standalone-file argument only means our block
|
||
# can't collide with *user* config — it can still collide with our own.
|
||
if managed_path_block_present "$profile"; then
|
||
# Our block exists with a stale line — rewrite it in place.
|
||
if rewrite_managed_path_block "$profile" "$export_line"; then
|
||
fix_file_owner "$profile"
|
||
log_success "Updated deepagents-code PATH block in $(tilde_display "$profile")."
|
||
updated=$((updated + 1))
|
||
else
|
||
log_warn "Could not update deepagents-code PATH block in $(tilde_display "$profile")."
|
||
# Deliberately hedged: rewrite_managed_path_block also returns 1 for an
|
||
# unresolvable symlink, a mktemp failure and a failed mv, each of which
|
||
# already printed its own reason just above. Asserting the marker cause
|
||
# outright would contradict those.
|
||
log_warn " One cause is a stray '# >>> deepagents-code installer >>>' line with no matching '<<<' line."
|
||
failed=$((failed + 1))
|
||
fi
|
||
elif append_managed_path_block "$profile" "$export_line"; then
|
||
fix_file_owner "$profile"
|
||
log_success "Added ~/.local/bin to PATH in $(tilde_display "$profile")."
|
||
updated=$((updated + 1))
|
||
else
|
||
log_warn "Could not update $(tilde_display "$profile")."
|
||
failed=$((failed + 1))
|
||
fi
|
||
done
|
||
|
||
# Never return 0 here. rc=0 tells the caller "PATH is already fixed for the
|
||
# running shell", which is only true for the symlink path above — a file we
|
||
# just edited does nothing for the shell that is currently running, so the
|
||
# user still needs the reload hint. A failure on any candidate returns 1
|
||
# rather than being masked by a success elsewhere: the file that failed may
|
||
# be the only one this user's shell actually reads.
|
||
if [ "$failed" -gt 0 ]; then
|
||
return 1
|
||
fi
|
||
return 2
|
||
}
|
||
|
||
classify_shadowing_command() {
|
||
local path="$1"
|
||
case "$path" in
|
||
/opt/homebrew/*|/usr/local/*)
|
||
if [ "$OS" = "macos" ]; then
|
||
printf 'Homebrew-managed'
|
||
return 0
|
||
fi
|
||
;;
|
||
*pipx*)
|
||
printf 'pipx-managed'
|
||
return 0
|
||
;;
|
||
*/.local/bin/*)
|
||
printf 'user-local'
|
||
return 0
|
||
;;
|
||
esac
|
||
printf 'existing'
|
||
}
|
||
|
||
detect_shadowing_install() {
|
||
local candidate expected original manager
|
||
for candidate in dcode deepagents-code; do
|
||
expected="${TOOL_BIN_DIR}/${candidate}"
|
||
[ -x "$expected" ] || continue
|
||
original=$(PATH="$ORIGINAL_PATH" command -v "$candidate" 2>/dev/null || true)
|
||
[ -n "$original" ] || continue
|
||
# Same file reached via a different PATH spelling (e.g. ~/.local/share/../bin
|
||
# vs ~/.local/bin) is not a shadowing install. Keep the string equality as a
|
||
# fast path, but also accept `-ef` (true when both paths are the same device
|
||
# and inode, resolving `..` and symlinks) so aliases don't trigger a false
|
||
# "existing install" warning. A genuinely different binary has a distinct
|
||
# inode and still fails both checks, so only the false positive is skipped.
|
||
{ [ "$original" = "$expected" ] || [ "$original" -ef "$expected" ]; } && continue
|
||
manager=$(classify_shadowing_command "$original")
|
||
log_warn "Detected ${manager} ${candidate} at ${original}."
|
||
log_warn "PATH order may run that binary instead of the uv tool at ${expected}."
|
||
log_warn "Restart your shell after this installer updates PATH, or remove the older install."
|
||
done
|
||
}
|
||
|
||
DCODE_BIN=""
|
||
DCODE_NAME=""
|
||
# Tracks whether the binary would have resolved via the user's original PATH,
|
||
# not the installer-mutated PATH.
|
||
DCODE_ON_PATH=false
|
||
for candidate in dcode deepagents-code; do
|
||
if [ -x "${TOOL_BIN_DIR}/${candidate}" ]; then
|
||
DCODE_BIN="${TOOL_BIN_DIR}/${candidate}"
|
||
DCODE_NAME="$candidate"
|
||
original=$(PATH="$ORIGINAL_PATH" command -v "$candidate" 2>/dev/null || true)
|
||
if [ -n "$original" ] && \
|
||
{ [ "$original" = "$DCODE_BIN" ] || [ "$original" -ef "$DCODE_BIN" ]; }; then
|
||
DCODE_ON_PATH=true
|
||
fi
|
||
break
|
||
fi
|
||
done
|
||
if [ -z "$DCODE_BIN" ]; then
|
||
for candidate in dcode deepagents-code; do
|
||
if resolved=$(command -v "$candidate" 2>/dev/null) && [ -n "$resolved" ]; then
|
||
DCODE_BIN="$resolved"
|
||
DCODE_NAME="$candidate"
|
||
original=$(PATH="$ORIGINAL_PATH" command -v "$candidate" 2>/dev/null || true)
|
||
if [ -n "$original" ] && \
|
||
{ [ "$original" = "$DCODE_BIN" ] || [ "$original" -ef "$DCODE_BIN" ]; }; then
|
||
DCODE_ON_PATH=true
|
||
fi
|
||
break
|
||
fi
|
||
done
|
||
fi
|
||
|
||
# Collapse $HOME prefix to ~ for a tidier display path. Used in user-facing
|
||
# log lines only; DCODE_BIN keeps the absolute path for any exec needs.
|
||
DCODE_BIN_DISPLAY="$DCODE_BIN"
|
||
if [ -n "$DCODE_BIN" ] && [ -n "${HOME:-}" ]; then
|
||
case "$DCODE_BIN" in
|
||
"$HOME"/*) DCODE_BIN_DISPLAY="~${DCODE_BIN#"$HOME"}" ;;
|
||
esac
|
||
fi
|
||
|
||
detect_shadowing_install
|
||
|
||
NEW_VERSION=""
|
||
VERIFY_OK=false
|
||
VERIFY_OUTPUT=""
|
||
if [ -n "$DCODE_BIN" ]; then
|
||
if VERIFY_OUTPUT=$("$DCODE_BIN" -v 2>&1); then
|
||
NEW_VERSION=$(printf '%s\n' "$VERIFY_OUTPUT" | head -1 | awk '{print $NF}') || NEW_VERSION=""
|
||
VERIFY_OK=true
|
||
fi
|
||
fi
|
||
|
||
if [ "$IS_EDITABLE" = true ]; then
|
||
log_success "deepagents-code${NEW_VERSION:+ ${NEW_VERSION}} reinstalled from PyPI."
|
||
elif [ -z "$PRE_VERSION" ]; then
|
||
log_success "deepagents-code${NEW_VERSION:+ ${NEW_VERSION}} installed."
|
||
elif [ -n "$NEW_VERSION" ] && [ "$PRE_VERSION" = "$NEW_VERSION" ]; then
|
||
# Same app version, but uv may have refreshed transitive deps (security or
|
||
# compat bumps). The final status line is the user-facing summary, so a flat
|
||
# "already up to date" would contradict the package diff printed just above
|
||
# (and, in non-verbose mode where an addition-only diff is suppressed, hide
|
||
# the dep move entirely). UV_REPORTED_PACKAGE_CHANGES (set far above) is the
|
||
# signal that the reinstall actually moved packages.
|
||
if [ "$UV_REPORTED_PACKAGE_CHANGES" = true ]; then
|
||
# No log pointer here: the "Full log:" line below prints it whenever a log
|
||
# was successfully published *and* uv wrote something. This branch
|
||
# guarantees the second half (UV_REPORTED_PACKAGE_CHANGES is only true
|
||
# because a grep matched `- pkg==` / `+ pkg==` lines in the captured
|
||
# stderr), but publication can still have failed - copy_install_log clears
|
||
# INSTALL_LOG on failure - and then there is no log to point at from either
|
||
# site. Leaving the pointer to that one site also keeps this line short;
|
||
# a live run already showed the path once in the `Update log:` hint.
|
||
log_success "deepagents-code ${NEW_VERSION} was already up to date; dependencies were updated."
|
||
else
|
||
log_success "deepagents-code ${NEW_VERSION} already up to date."
|
||
fi
|
||
elif [ -n "$NEW_VERSION" ]; then
|
||
log_success "deepagents-code updated: ${PRE_VERSION} → ${NEW_VERSION}."
|
||
else
|
||
log_success "deepagents-code installed."
|
||
fi
|
||
# The log captured uv's full stderr (dependency diff, warnings, rebuild
|
||
# notice). Point at it after any successful install so users can inspect what
|
||
# changed - non-verbose mode trims those lines from the terminal, and a
|
||
# non-TTY run (CI, `curl | bash` under a pipe) has no scrollback to go back
|
||
# to. The failure path prints its own pointer and exits before reaching here.
|
||
# Test on the file rather than the display name: uv writes nothing to stderr
|
||
# on a clean no-op reinstall, and pointing at an empty log is a dead end.
|
||
if [ -n "$INSTALL_LOG_DISPLAY" ] && [ -s "$INSTALL_LOG" ]; then
|
||
log_info "Full log: ${INSTALL_LOG_DISPLAY}"
|
||
fi
|
||
|
||
if [ "$VERBOSE" = "1" ] && [ -n "$DCODE_BIN_DISPLAY" ]; then
|
||
printf " Location: %s\n" "$DCODE_BIN_DISPLAY"
|
||
fi
|
||
|
||
if [ "$VERIFY_OK" = true ]; then
|
||
# The prior log_success already named the installed/updated version, so the
|
||
# "Verified" line is redundant for the common case — gate it behind VERBOSE.
|
||
# The empty-output warning stays unconditional: it signals a broken install.
|
||
if [ -z "$NEW_VERSION" ] || [ "$PRE_VERSION" != "$NEW_VERSION" ] || [ "$IS_EDITABLE" = true ]; then
|
||
VERIFY_FIRST=$(printf '%s\n' "$VERIFY_OUTPUT" | head -1)
|
||
if [ -z "$VERIFY_FIRST" ]; then
|
||
log_warn "${DCODE_NAME} -v exited 0 but produced no output; installation may be incomplete."
|
||
elif [ "$VERBOSE" = "1" ]; then
|
||
log_success "Verified: ${DCODE_NAME} ${VERIFY_FIRST}"
|
||
fi
|
||
fi
|
||
elif [ -n "$DCODE_BIN" ]; then
|
||
log_warn "${DCODE_NAME} binary found but '${DCODE_NAME} -v' failed:"
|
||
log_warn " ${VERIFY_OUTPUT}"
|
||
log_warn "The installation may be broken. Try running: ${DCODE_NAME} -v"
|
||
else
|
||
log_warn "dcode (or deepagents-code) command not found in ${TOOL_BIN_DIR_DISPLAY} or PATH. Restart your shell or run:"
|
||
log_warn " source ~/.zshrc # (or ~/.bashrc)"
|
||
fi
|
||
|
||
# The binary verified via its absolute path but isn't on the current shell's
|
||
# PATH (typical right after a fresh `uv tool install`): typing `dcode` won't
|
||
# work until the shell picks up ~/.local/bin. Instead of just telling the user
|
||
# to restart their shell, try to fix the PATH now — symlink into an existing
|
||
# PATH dir, or add ~/.local/bin to the shell profile — so the binary is
|
||
# immediately usable in a new terminal without manual configuration.
|
||
if [ "$VERIFY_OK" = true ] && [ "$DCODE_ON_PATH" = false ] && [ -n "$DCODE_BIN" ]; then
|
||
path_setup_rc=0
|
||
ensure_path_setup "$DCODE_NAME" "$DCODE_BIN" || path_setup_rc=$?
|
||
if [ "$path_setup_rc" -ne 0 ] && [ "$path_setup_rc" -ne 3 ] && \
|
||
[ "$path_setup_rc" -ne 4 ]; then
|
||
# rc=1: ensure_path_setup already printed a specific warning; keep the
|
||
# fallback in warning styling since profile setup genuinely failed.
|
||
# rc=2: startup files were written, or no change was needed — either way
|
||
# the *running* shell still lacks ~/.local/bin on PATH. Render the hint
|
||
# as a friendly next step (new terminals already work), not a warning.
|
||
# rc=0/3/4 are silent here: PATH is already fixed, MDM owns it, or the user
|
||
# declined and was already given the one line that would help.
|
||
if [ "$path_setup_rc" -eq 2 ]; then
|
||
log_info "To use ${DCODE_NAME} in this shell, run:"
|
||
if [ -f "${HOME}/.local/bin/env" ]; then
|
||
printf " ${CYAN}>${NC} source ~/.local/bin/env\n"
|
||
else
|
||
printf " ${CYAN}>${NC} export PATH=\"\$HOME/.local/bin:\$PATH\"\n"
|
||
fi
|
||
else
|
||
log_warn " Restart your shell, or run:"
|
||
if [ -f "${HOME}/.local/bin/env" ]; then
|
||
log_warn " source ~/.local/bin/env"
|
||
else
|
||
log_warn " export PATH=\"\$HOME/.local/bin:\$PATH\""
|
||
fi
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Optional tools — ripgrep
|
||
# ---------------------------------------------------------------------------
|
||
# Oldest ripgrep the agent's grep tool is expected to work with. The managed
|
||
# installer pins a newer upstream release; this floor only guards the system
|
||
# path (brew/apt/...) so an ancient distro package doesn't install
|
||
# "successfully" and then behave differently at runtime.
|
||
MIN_RIPGREP_VERSION="12.0.0"
|
||
|
||
# version_at_least HAVE WANT — dotted numeric compare (12.0.0 >= 11.0.0) over
|
||
# the first three components only. The installer runs before Python exists, so
|
||
# this stays in pure shell. It is not a PEP 440 comparator: either side
|
||
# carrying a non-numeric component (a prerelease suffix, an empty string) is
|
||
# rejected outright rather than compared, so callers holding package versions
|
||
# get `false` instead of a bogus ordering.
|
||
version_at_least() {
|
||
local have="$1" want="$2" IFS_save="$IFS"
|
||
case "$have" in ''|*[!0-9.]*) return 1 ;; esac
|
||
case "$want" in ''|*[!0-9.]*) return 1 ;; esac
|
||
IFS=.
|
||
# shellcheck disable=SC2086 # word-splitting on '.' is the point
|
||
set -- $have
|
||
IFS="$IFS_save"
|
||
local have_major="${1:-0}" have_minor="${2:-0}" have_patch="${3:-0}"
|
||
IFS=.
|
||
# shellcheck disable=SC2086
|
||
set -- $want
|
||
IFS="$IFS_save"
|
||
local want_major="${1:-0}" want_minor="${2:-0}" want_patch="${3:-0}"
|
||
[ "$have_major" -gt "$want_major" ] && return 0
|
||
[ "$have_major" -lt "$want_major" ] && return 1
|
||
[ "$have_minor" -gt "$want_minor" ] && return 0
|
||
[ "$have_minor" -lt "$want_minor" ] && return 1
|
||
[ "$have_patch" -ge "$want_patch" ]
|
||
}
|
||
|
||
# version_older_than_have IS MIN — true when IS is present but below MIN.
|
||
version_too_old() {
|
||
local is="$1" min="$2"
|
||
[ -n "$is" ] || return 1
|
||
! version_at_least "$is" "$min"
|
||
}
|
||
|
||
# Print the version of `rg` on PATH, or nothing when it can't be determined.
|
||
installed_rg_version() {
|
||
rg --version 2>/dev/null | head -1 | awk '{print $2}'
|
||
}
|
||
|
||
|
||
# Pre-check: verify sudo is usable before running sudo commands.
|
||
# Returns 0 if sudo is available (cached or passwordless), 1 otherwise.
|
||
check_sudo() {
|
||
if ! command -v sudo >/dev/null 2>&1; then
|
||
return 1
|
||
fi
|
||
# -v -n: validate cached credentials, non-interactive (no password prompt)
|
||
if sudo -v -n 2>/dev/null; then
|
||
return 0
|
||
fi
|
||
# Interactive: warn and let sudo prompt normally
|
||
if [ "$IS_INTERACTIVE" = true ]; then
|
||
log_warn "sudo may prompt for your password."
|
||
return 0
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
# True when the `rg` on PATH is present and at least MIN_RIPGREP_VERSION.
|
||
# An install that produced a too-old binary does not count as success: an
|
||
# ancient distro package would otherwise satisfy `command -v rg` while behaving
|
||
# differently from what the grep tool expects.
|
||
installed_rg_is_acceptable() {
|
||
local ver
|
||
command -v rg >/dev/null 2>&1 || return 1
|
||
if ! ver=$(installed_rg_version) || [ -z "$ver" ]; then
|
||
return 1
|
||
fi
|
||
if version_too_old "$ver" "$MIN_RIPGREP_VERSION"; then
|
||
log_warn "ripgrep ${ver} is older than the supported minimum (${MIN_RIPGREP_VERSION})."
|
||
return 1
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
install_ripgrep_via_pkg() {
|
||
case "$OS" in
|
||
macos)
|
||
if command -v brew >/dev/null 2>&1; then
|
||
log_info "Installing ripgrep via Homebrew (this may take a moment)..."
|
||
if HOMEBREW_NO_AUTO_UPDATE=1 brew install ripgrep; then
|
||
installed_rg_is_acceptable && return 0
|
||
fi
|
||
fi
|
||
if command -v port >/dev/null 2>&1 && check_sudo; then
|
||
log_info "Installing ripgrep via MacPorts..."
|
||
if sudo port install ripgrep; then
|
||
installed_rg_is_acceptable && return 0
|
||
fi
|
||
fi
|
||
;;
|
||
linux)
|
||
if command -v apt-get >/dev/null 2>&1 && check_sudo; then
|
||
log_info "Installing ripgrep via apt-get..."
|
||
if sudo apt-get install -y ripgrep; then
|
||
installed_rg_is_acceptable && return 0
|
||
fi
|
||
elif command -v dnf >/dev/null 2>&1 && check_sudo; then
|
||
log_info "Installing ripgrep via dnf..."
|
||
if sudo dnf install -y ripgrep; then
|
||
installed_rg_is_acceptable && return 0
|
||
fi
|
||
elif command -v pacman >/dev/null 2>&1 && check_sudo; then
|
||
log_info "Installing ripgrep via pacman..."
|
||
if sudo pacman -S --noconfirm ripgrep; then
|
||
installed_rg_is_acceptable && return 0
|
||
fi
|
||
elif command -v zypper >/dev/null 2>&1 && check_sudo; then
|
||
log_info "Installing ripgrep via zypper..."
|
||
if sudo zypper install -y ripgrep; then
|
||
installed_rg_is_acceptable && return 0
|
||
fi
|
||
elif command -v apk >/dev/null 2>&1 && check_sudo; then
|
||
log_info "Installing ripgrep via apk..."
|
||
if sudo apk add ripgrep; then
|
||
installed_rg_is_acceptable && return 0
|
||
fi
|
||
elif command -v nix-env >/dev/null 2>&1; then
|
||
log_info "Installing ripgrep via nix..."
|
||
if nix-env -iA nixpkgs.ripgrep; then
|
||
installed_rg_is_acceptable && return 0
|
||
fi
|
||
fi
|
||
;;
|
||
esac
|
||
return 1
|
||
}
|
||
|
||
install_ripgrep_via_cargo() {
|
||
if command -v cargo >/dev/null 2>&1; then
|
||
log_info "Installing ripgrep via cargo (no sudo needed)..."
|
||
if cargo install ripgrep; then
|
||
fix_file_owner "${HOME}/.cargo/bin/rg"
|
||
installed_rg_is_acceptable && return 0
|
||
log_warn "cargo install succeeded but rg not found in PATH or too old."
|
||
fi
|
||
fi
|
||
return 1
|
||
}
|
||
|
||
ripgrep_manual_hint() {
|
||
log_warn "ripgrep is not installed; the grep tool will use a slower fallback."
|
||
case "$OS" in
|
||
macos) log_warn " Install: brew install ripgrep" ;;
|
||
*) log_warn " Install: https://github.com/BurntSushi/ripgrep#installation" ;;
|
||
esac
|
||
}
|
||
|
||
ripgrep_managed_failed() {
|
||
log_warn "Managed ripgrep setup did not complete; the grep tool will use a slower fallback."
|
||
ripgrep_manual_hint
|
||
}
|
||
|
||
# Hand back whichever managed bin directory `dcode tools install` just wrote
|
||
# to. It prefers the installation-scoped MANAGED_BIN_DIR and falls back to the
|
||
# profile-scoped one when that is unwritable, so a root install must repair
|
||
# both or the fallback stays root-owned inside the user's home. Each is gated
|
||
# on `path_is_under_home` as fix_tree_owner requires: MANAGED_BIN_DIR derives
|
||
# from `uv tool dir`, which the user can point outside $HOME.
|
||
#
|
||
# Repair on every root run, not only a first install — the same reason
|
||
# `fix_tree_owner "${UV_TOOL_DIR}/deepagents-code"` is ungated. `dcode tools
|
||
# install` has just written into these trees as root, and on an upgrade they
|
||
# already exist, so a `*_PREEXISTED` gate would skip the common `sudo` path and
|
||
# leave the freshly written `rg` root-owned. Both hold only the managed binary
|
||
# the installer put there, so they are trees this installer owns outright.
|
||
fix_managed_bin_owner() {
|
||
if [ -n "$MANAGED_BIN_DIR" ] && path_is_under_home "$MANAGED_BIN_DIR"; then
|
||
fix_tree_owner "$MANAGED_BIN_DIR"
|
||
fi
|
||
if [ -n "${DEEPAGENTS_HOME:-}" ] && path_is_under_home "${DEEPAGENTS_HOME}/bin"; then
|
||
fix_tree_owner "${DEEPAGENTS_HOME}/bin"
|
||
fi
|
||
}
|
||
|
||
if [ "$SKIP_OPTIONAL" != "1" ]; then
|
||
if [ "$RIPGREP_INSTALLER" = "managed" ] && [ "$VERIFY_OK" = true ] && [ -n "$DCODE_BIN" ]; then
|
||
# Eager, non-prompting managed install through the freshly installed binary
|
||
# — the same pinned, SHA-256-verified path dcode uses on first run
|
||
# (downloads inside the dcode tool environment). Doing it here removes the
|
||
# first-run download latency. The binary reuses a system `rg` already on
|
||
# PATH and honors DEEPAGENTS_CODE_OFFLINE and
|
||
# DEEPAGENTS_CODE_RIPGREP_INSTALLER=system. Routine output stays behind
|
||
# verbose mode because most users do not need ripgrep setup details.
|
||
if [ "$VERBOSE" = "1" ]; then
|
||
echo ""
|
||
log_info "Setting up ripgrep..."
|
||
if "$DCODE_BIN" tools install; then
|
||
fix_managed_bin_owner
|
||
else
|
||
ripgrep_managed_failed
|
||
fi
|
||
else
|
||
# Quiet path: capture setup output and surface it only on failure, so a
|
||
# broken install stays debuggable without noise in the common case.
|
||
if ripgrep_setup_out=$(mktemp 2>/dev/null); then
|
||
register_temp "$ripgrep_setup_out"
|
||
if "$DCODE_BIN" tools install >"$ripgrep_setup_out" 2>&1; then
|
||
fix_managed_bin_owner
|
||
else
|
||
echo ""
|
||
cat "$ripgrep_setup_out" >&2 2>/dev/null || true
|
||
ripgrep_managed_failed
|
||
fi
|
||
rm -f "$ripgrep_setup_out"
|
||
else
|
||
log_warn "Could not create a secure temp file; skipping managed ripgrep setup."
|
||
ripgrep_managed_failed
|
||
fi
|
||
fi
|
||
elif command -v rg >/dev/null 2>&1; then
|
||
if ! rg_version=$(installed_rg_version) || [ -z "$rg_version" ]; then
|
||
echo ""
|
||
log_warn "Could not determine the version of ripgrep on PATH."
|
||
ripgrep_manual_hint
|
||
elif version_too_old "$rg_version" "$MIN_RIPGREP_VERSION"; then
|
||
echo ""
|
||
log_warn "ripgrep ${rg_version} found, but the minimum supported is ${MIN_RIPGREP_VERSION} — the grep tool may misbehave."
|
||
ripgrep_manual_hint
|
||
elif [ "$VERBOSE" = "1" ]; then
|
||
echo ""
|
||
log_info "Checking optional tools..."
|
||
log_success "ripgrep ${rg_version} found"
|
||
fi
|
||
else
|
||
echo ""
|
||
log_warn "ripgrep not found — recommended for faster file search."
|
||
|
||
installed=false
|
||
if prompt_yn " Install ripgrep?"; then
|
||
if install_ripgrep_via_pkg; then
|
||
installed=true
|
||
elif install_ripgrep_via_cargo; then
|
||
installed=true
|
||
fi
|
||
|
||
if [ "$installed" = true ]; then
|
||
log_success "ripgrep installed."
|
||
else
|
||
log_error "Automatic install failed."
|
||
ripgrep_manual_hint
|
||
fi
|
||
else
|
||
ripgrep_manual_hint
|
||
fi
|
||
fi
|
||
fi
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Done — footer wording depends on what changed. All named branches also
|
||
# require a non-editable install (an editable one always falls through to the
|
||
# catch-all, even when its reinstall moved dependencies):
|
||
# - same app version + dependency changes → "Dependencies updated."
|
||
# - already up to date → "Already installed."
|
||
# - deliberate move to the PyPI latest → "Upgraded."
|
||
# - any other unpinned version move → "Version changed."
|
||
# - everything else → "Setup complete."
|
||
#
|
||
# The last branch is a catch-all, not an enumerated set. It covers a fresh
|
||
# install and an editable→PyPI swap. The version-move branches split on
|
||
# UPGRADE_INTENDED (set in the update-check branch above): the script can only
|
||
# claim "Upgraded." when it deliberately moved to the PyPI latest it had just
|
||
# fetched, confirmed differed from the installed version, and landed on a
|
||
# version that is not numerically older. version_at_least is `>=` over three
|
||
# dotted integer components, so it rules out a downgrade rather than proving a
|
||
# strict increase — two textually different versions that tie over those three
|
||
# components (0.2 → 0.2.0) also reach this branch. A non-numeric version on
|
||
# either side fails the check outright and falls through to the neutral
|
||
# branch. Any other move stays neutral because uv honors custom
|
||
# indexes and configuration whose newest available package can be older than
|
||
# the installed version. Two other known downgrade paths remain in the
|
||
# catch-all branch:
|
||
# - a *pinned* version (VERSION set): `bash -s -- 0.1.0` over an installed
|
||
# 0.2.0 is a downgrade.
|
||
# - an explicit DEEPAGENTS_CODE_PRERELEASE (PRERELEASE_REQUESTED set): with
|
||
# `disallow` over an installed 0.3.0rc1, uv resolves to the latest *stable*,
|
||
# which can be older than what's there.
|
||
# It also covers an empty NEW_VERSION — the post-install `dcode -v` probe
|
||
# failed, was never run because DCODE_BIN didn't resolve, or exited 0 while
|
||
# printing nothing.
|
||
# ---------------------------------------------------------------------------
|
||
if [ "$IS_EDITABLE" = false ] && [ -n "$PRE_VERSION" ] && [ -n "$NEW_VERSION" ] \
|
||
&& [ "$PRE_VERSION" = "$NEW_VERSION" ] && [ "$UV_REPORTED_PACKAGE_CHANGES" = true ]; then
|
||
footer_msg="Dependencies updated."
|
||
elif [ "$IS_EDITABLE" = false ] && [ -n "$PRE_VERSION" ] && [ -n "$NEW_VERSION" ] \
|
||
&& [ "$PRE_VERSION" = "$NEW_VERSION" ]; then
|
||
footer_msg="Already installed."
|
||
elif [ "$IS_EDITABLE" = false ] && [ -z "$VERSION" ] && [ -z "$PRERELEASE_REQUESTED" ] \
|
||
&& [ -n "$PRE_VERSION" ] && [ -n "$NEW_VERSION" ] && [ "$PRE_VERSION" != "$NEW_VERSION" ] \
|
||
&& [ "${UPGRADE_INTENDED:-false}" = true ] && [ -n "${LATEST_VERSION:-}" ] \
|
||
&& [ "$NEW_VERSION" = "$LATEST_VERSION" ] && version_at_least "$NEW_VERSION" "$PRE_VERSION"; then
|
||
footer_msg="Upgraded."
|
||
elif [ "$IS_EDITABLE" = false ] && [ -z "$VERSION" ] && [ -z "$PRERELEASE_REQUESTED" ] \
|
||
&& [ -n "$PRE_VERSION" ] && [ -n "$NEW_VERSION" ] && [ "$PRE_VERSION" != "$NEW_VERSION" ]; then
|
||
footer_msg="Version changed."
|
||
else
|
||
footer_msg="Setup complete."
|
||
fi
|
||
echo ""
|
||
# shellcheck disable=SC2059
|
||
printf "${GREEN}✔${NC} %s Run: ${BOLD}dcode${NC}\n" "$footer_msg"
|
||
echo " Docs: https://docs.langchain.com/deepagents-code"
|