Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227#!/usr/bin/env bash# Sets up a Debian or Ubuntu machine as a didbot dev box: build packages,# Docker, Node, rustup with this checkout's pinned toolchain, sccache, Claude# Code, atgc and the didbot agent-host binaries. The AWS box runs it at first# boot (infra/devbox/templates/user_data.sh.tftpl); a home box runs it from a# clone. Safe to rerun, which is also how the tools are updated.## It makes no login and holds no secret. docs/devbox.md has the human steps# that follow it.set -euo pipefail
usage() { cat <<'EOF'sudo scripts/devbox-bootstrap.sh [--user NAME] [--pds HOST] [--tailscale] [--firewall] [--reboot-at HH:MM] [--node-major N] [--sccache-size-gb N]
--user who the tools are for (default: the user running sudo)--pds DIDBOT_PDS for didbot-agentd (default: pds.did.bot)--tailscale install Tailscale and start tailscaled, without joining--firewall drop inbound traffic except on tailscale0--reboot-at let unattended-upgrades reboot at this hour--node-major Node.js major version (default: 24)--sccache-size-gb sccache's local disk cache size (default: 50)EOF exit "${1:-2}"}
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"TARGET_USER="${SUDO_USER:-}"DIDBOT_PDS=pds.did.botTAILSCALE=0FIREWALL=0REBOOT_AT=""NODE_MAJOR=24SCCACHE_SIZE_GB=50
while [ $# -gt 0 ]; do case "$1" in --user) TARGET_USER="$2"; shift 2 ;; --pds) DIDBOT_PDS="$2"; shift 2 ;; --tailscale) TAILSCALE=1; shift ;; --firewall) FIREWALL=1; shift ;; --reboot-at) REBOOT_AT="$2"; shift 2 ;; --node-major) NODE_MAJOR="$2"; shift 2 ;; --sccache-size-gb) SCCACHE_SIZE_GB="$2"; shift 2 ;; -h | --help) usage 0 ;; *) echo "unknown argument: $1" >&2; usage ;; esacdone
say() { printf '\n== %s\n' "$*"; }die() { echo "devbox-bootstrap: $*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "run it as root, with sudo"[ -n "$TARGET_USER" ] || die "name the user the tools are for with --user"TARGET_HOME="$(getent passwd "$TARGET_USER" | cut -d: -f6)"[ -n "$TARGET_HOME" ] || die "no user named $TARGET_USER"[ -f "$REPO_DIR/rust-toolchain.toml" ] || die "run it from a didbot checkout"[[ "$REBOOT_AT" =~ ^([01][0-9]|2[0-3]):[0-5][0-9]$ || -z "$REBOOT_AT" ]] \ || die "--reboot-at takes HH:MM"[[ "$SCCACHE_SIZE_GB" =~ ^[0-9]+$ ]] || die "--sccache-size-gb takes a whole number"
# shellcheck source=/dev/null. /etc/os-releasecase "${ID:-}" in ubuntu | debian) ;; *) die "this script supports Ubuntu and Debian; /etc/os-release says ${ID:-nothing}" ;;esacARCH="$(dpkg --print-architecture)"TOOLCHAIN="$(sed -n 's/^channel = "\(.*\)"/\1/p' "$REPO_DIR/rust-toolchain.toml")"[ -n "$TOOLCHAIN" ] || die "rust-toolchain.toml names no channel"
export DEBIAN_FRONTEND=noninteractive
as_user() { runuser -u "$TARGET_USER" -- env -C "$TARGET_HOME" HOME="$TARGET_HOME" \ PATH="$TARGET_HOME/.cargo/bin:$TARGET_HOME/.local/bin:/usr/local/bin:/usr/bin:/bin" \ CARGO_BUILD_JOBS="$(nproc)" "$@"}
# One apt source, written only when it differs, so a rerun does not rewrite# what an unchanged source already says.apt_source() { local name="$1" key_url="$2" key_file="$3" line="$4" install -d -m 755 /etc/apt/keyrings [ -s "$key_file" ] || curl -fsSL "$key_url" -o "$key_file" chmod 644 "$key_file" if [ "$(cat "/etc/apt/sources.list.d/$name.list" 2>/dev/null)" != "$line" ]; then echo "$line" > "/etc/apt/sources.list.d/$name.list" fi}
say "packages"apt-get updateapt-get install -y build-essential pkg-config cmake clang libssl-dev \ git tmux curl ca-certificates jq unzip python3 unattended-upgrades
say "apt sources: Docker, Node $NODE_MAJOR"apt_source docker "https://download.docker.com/linux/$ID/gpg" /etc/apt/keyrings/docker.asc \ "deb [arch=$ARCH signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$ID $VERSION_CODENAME stable"apt_source nodesource https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key /etc/apt/keyrings/nodesource.asc \ "deb [arch=$ARCH signed-by=/etc/apt/keyrings/nodesource.asc] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main"if [ "$TAILSCALE" = 1 ]; then apt_source tailscale "https://pkgs.tailscale.com/stable/$ID/$VERSION_CODENAME.noarmor.gpg" \ /usr/share/keyrings/tailscale-archive-keyring.gpg \ "deb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/stable/$ID $VERSION_CODENAME main"fiapt-get updateapt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin nodejs# The docker group can start a root container, so this user is as good as# root on this machine. A dev box has one user, and that user builds images.usermod -aG docker "$TARGET_USER"systemctl enable --now docker
if [ "$TAILSCALE" = 1 ]; then # Installed and started, never joined: `tailscale up` is the owner's step. say "tailscale" apt-get install -y tailscale systemctl enable --now tailscaledfi
if [ "$FIREWALL" = 1 ]; then # Drops everything inbound except over the tailnet. Run it after `tailscale # up` works, from a shell that is not on the LAN's ssh. say "firewall" ip link show tailscale0 >/dev/null 2>&1 || die "--firewall needs tailscale0; run tailscale up first" apt-get install -y ufw ufw default deny incoming ufw default allow outgoing ufw allow in on tailscale0 ufw --force enablefi
if [ -n "$REBOOT_AT" ]; then say "unattended-upgrades reboots at $REBOOT_AT" cat > /etc/apt/apt.conf.d/52devbox-reboot <<EOFUnattended-Upgrade::Automatic-Reboot "true";Unattended-Upgrade::Automatic-Reboot-Time "$REBOOT_AT";EOFfi
say "rustup, Rust $TOOLCHAIN"if [ -x "$TARGET_HOME/.cargo/bin/rustup" ]; then as_user rustup toolchain install "$TOOLCHAIN" --profile minimal -c clippy -c rustfmt as_user rustup default "$TOOLCHAIN"else as_user bash -c "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ | sh -s -- -y --profile minimal --default-toolchain '$TOOLCHAIN' -c clippy -c rustfmt"fias_user rustup target add --toolchain "$TOOLCHAIN" wasm32-unknown-unknown
# `cargo install` skips a crate whose installed version is current, so a rerun# only builds what moved.say "sccache, atgc"as_user cargo install --locked sccacheas_user cargo install --locked --git https://tangled.org/permadeath.com/atgc atgc
say "didbot-dispatch, didbot-agentd"# From this checkout, so the binaries match the tree the bootstrap came from.as_user env -C "$REPO_DIR" cargo install --locked --path crates/didbot-dispatchas_user env -C "$REPO_DIR" cargo install --locked --path crates/didbot-agentd
say "sccache as cargo's compiler wrapper, ${SCCACHE_SIZE_GB} GB on local disk"as_user mkdir -p "$TARGET_HOME/.config/sccache" "$TARGET_HOME/.cache/sccache"as_user tee "$TARGET_HOME/.config/sccache/config" >/dev/null <<EOF[cache.disk]dir = "$TARGET_HOME/.cache/sccache"size = $((SCCACHE_SIZE_GB * 1024 * 1024 * 1024))EOFCARGO_CONFIG="$TARGET_HOME/.cargo/config.toml"if ! [ -e "$CARGO_CONFIG" ]; then as_user tee "$CARGO_CONFIG" >/dev/null <<'EOF'[build]rustc-wrapper = "sccache"EOFelif ! grep -q 'rustc-wrapper' "$CARGO_CONFIG"; then echo "left $CARGO_CONFIG alone; add rustc-wrapper = \"sccache\" under [build] to use the cache" >&2fi
# The checkout's .cargo/config.toml holds builds to two jobs for a laptop# shared with other work. A dev box gives every job to its builds.say "shell environment"ENV_FILE="$TARGET_HOME/.config/devbox/env.sh"as_user mkdir -p "$(dirname "$ENV_FILE")"as_user tee "$ENV_FILE" >/dev/null <<'EOF'export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"CARGO_BUILD_JOBS="$(nproc)"export CARGO_BUILD_JOBSEOFfor rc in .profile .bashrc; do line=". \"\$HOME/.config/devbox/env.sh\"" as_user touch "$TARGET_HOME/$rc" grep -qxF "$line" "$TARGET_HOME/$rc" || echo "$line" >> "$TARGET_HOME/$rc"done
say "Claude Code"if ! [ -x "$TARGET_HOME/.local/bin/claude" ]; then as_user bash -c 'curl -fsSL https://claude.ai/install.sh | bash'fi
say "didbot-agentd user unit, not started"as_user mkdir -p "$TARGET_HOME/.config/didbot" "$TARGET_HOME/.config/systemd/user"if ! [ -e "$TARGET_HOME/.config/didbot/agentd.env" ]; then echo "DIDBOT_PDS=$DIDBOT_PDS" | as_user tee "$TARGET_HOME/.config/didbot/agentd.env" >/dev/nullfias_user cp "$REPO_DIR/crates/didbot-agentd/didbot-agentd.service" "$TARGET_HOME/.config/systemd/user/"# The user's systemd starts at boot rather than at first login, so the daemon# and agents in tmux outlive the ssh session that started them.loginctl enable-linger "$TARGET_USER"
say "done"cat <<EOFThe tools are installed for $TARGET_USER. The logins are yours, on this box,as $TARGET_USER; docs/devbox.md walks through them: claude then /login atgc auth login atgc key create or: atgc key add <pubkey> --name <box> didbot register host <name> --server $DIDBOT_PDS claude plugin marketplace add https://tangled.org/permadeath.com/didbot-claude claude plugin install didbot@didbot systemctl --user enable --now didbot-agentdEOFif [ "$TAILSCALE" = 1 ]; then echo " sudo tailscale up --ssh --advertise-tags=tag:devbox"fi