clawdie-iso/build.sh

401 lines
16 KiB
Bash
Executable file

#!/bin/sh
# clawdie-iso build script (UNIFIED)
# Produces a bootable FreeBSD memstick image with Clawdie-AI pre-bundled.
# All packages are fetched and bundled for fully offline installation.
#
# Unified ISO: Single image works on VPS, baremetal, and cloud
# Runtime detection handles configuration (display, GPU, etc.)
#
# Usage:
# ./build.sh # full build (fetch + assemble)
# ./build.sh --fetch-only # fetch packages/memstick only (no root needed)
# ./build.sh --skip-fetch # assemble only (use cached packages)
# ./build.sh --clawdie-version 1.0.2 # pin Clawdie-AI version
#
# Requirements (run on FreeBSD host):
# pkg install curl # for fetching
# pkg install node24 npm-node24 # to bundle clawdie-ai node_modules for offline firstboot
# pkg install (root) # for step 5-6 (mdconfig, mount)
#
# The tmp/packages/ directory produced here is dual-purpose:
# 1. Bundled into the ISO for offline installation
# 2. Used to seed zroot/pkg-cache on the installed system for offline jail provisioning
set -e
SCRIPT_DIR="$(dirname "$(realpath "$0")")"
TMP_DIR="${SCRIPT_DIR}/tmp"
PKG_LIST_DIR="${SCRIPT_DIR}/packages"
PKG_REPO_DIR="${TMP_DIR}/packages"
NPM_GLOBALS_DIR="${TMP_DIR}/npm-globals"
CACHE_DIR="${TMP_DIR}/cache"
OUTPUT_DIR="${TMP_DIR}/output"
. "${SCRIPT_DIR}/build.cfg"
# --- argument parsing ---
SKIP_FETCH=0
FETCH_ONLY=0
while [ "$#" -gt 0 ]; do
case "$1" in
--clawdie-version) CLAWDIE_VERSION="$2"; shift 2 ;;
--skip-fetch) SKIP_FETCH=1; shift ;;
--fetch-only) FETCH_ONLY=1; shift ;;
--assistant-name) ASSISTANT_NAME="$2"; shift 2 ;;
--domain) AGENT_DOMAIN="$2"; shift 2 ;;
--tz) TZ="$2"; shift 2 ;;
--ssh-key) SSH_PUBLIC_KEY="$2"; shift 2 ;;
--root-password) ROOT_PASSWORD="$2"; shift 2 ;;
--clawdie-password) CLAWDIE_USER_PASSWORD="$2"; shift 2 ;;
*) echo "Unknown arg: $1"; exit 1 ;;
esac
done
# Tailscale warning (recommended but optional)
if [ "${FEATURE_TAILSCALE:-YES}" = "YES" ] && [ -z "${TAILSCALE_AUTHKEY:-}" ]; then
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "⚠️ WARNING: Tailscale auth key not set"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Tailscale provides secure remote access without exposing SSH."
echo "Without it, your instance will have public SSH on port 22."
echo ""
echo "Recommended: Generate a key and rebuild"
echo " https://login.tailscale.com/admin/settings/keys"
echo " export TAILSCALE_AUTHKEY='tskey-auth-...'"
echo " ./build.sh"
echo ""
echo "Continuing without Tailscale (SSH will be publicly accessible)..."
echo ""
export FEATURE_TAILSCALE="NO"
fi
echo "==> clawdie-iso build"
echo " FreeBSD : ${FREEBSD_VERSION} ${FREEBSD_ARCH}"
echo " Clawdie : v${CLAWDIE_VERSION}"
echo " Desktop : ${DEFAULT_DESKTOP}"
echo " Pkg : ${DEFAULT_PKG_BRANCH}"
echo " GPU : ${GPU_DRIVER:-auto-detect}"
echo " Target : ${TARGET:-baremetal}"
echo ""
# Set IMAGE_NAME with VARIANT (vps or GPU_DRIVER or baremetal) (after arg parsing, before it's used)
IMAGE_NAME="clawdie-iso-unified-$(LC_TIME=C date +%d.%b.%Y | tr 'A-Z' 'a-z').img"
mkdir -p "$TMP_DIR"
# --- helper: read package lists into a single deduplicated list ---
pkg_list_all() {
# Unified ISO: include all packages for maximum flexibility
# No target-specific logic - runtime detection handles configuration
echo "==> Unified ISO: including all packages (host + jails + desktop + GPU drivers)" >&2
cat \
"${PKG_LIST_DIR}/pkg-list-host.txt" \
"${PKG_LIST_DIR}/pkg-list-jails.txt" \
"${PKG_LIST_DIR}/pkg-list-desktop-base.txt" \
"${PKG_LIST_DIR}/pkg-list-lumina.txt" \
"${PKG_LIST_DIR}/pkg-list-nvidia-all.txt" \
| grep -v '^#' | grep -v '^$' | sort -u
}
# --- step 1: fetch FreeBSD memstick ---
MEMSTICK="${CACHE_DIR}/FreeBSD-${FREEBSD_VERSION}-${FREEBSD_ARCH}-memstick.img"
if [ "$SKIP_FETCH" -eq 0 ] || [ ! -f "$MEMSTICK" ]; then
echo "==> [1/7] Fetching FreeBSD memstick..."
mkdir -p "$CACHE_DIR"
curl -L --progress-bar -o "$MEMSTICK" "$FREEBSD_MEMSTICK_URL"
curl -L -o "${MEMSTICK}.SHA256" "$FREEBSD_MEMSTICK_SHA256_URL"
sha256 -c "${MEMSTICK}.SHA256" || { echo "ERROR: checksum mismatch on memstick"; exit 1; }
else
echo "==> [1/7] FreeBSD memstick cached."
fi
# --- step 2: fetch all packages (no root needed) ---
if [ "$SKIP_FETCH" -eq 0 ]; then
echo "==> [2/7] Fetching packages to tmp/packages/..."
mkdir -p "$PKG_REPO_DIR"
# Set pkg repo to configured branch before fetching
# Use temporary user-level config to avoid requiring root
PKG_CONFIG_DIR=$(mktemp -d "${TMP_DIR}/pkg-repo.XXXXXX")
trap "rm -rf $PKG_CONFIG_DIR" EXIT
# Avoid calling pkg config (needs privilege) — hardcode ABI and use cache
ABI="FreeBSD:15:amd64"
PKG_CACHE_HOME="${HOME}/.pkg-cache"
mkdir -p "$PKG_CONFIG_DIR" "$PKG_CACHE_HOME"
cat > "$PKG_CONFIG_DIR/FreeBSD.conf" <<EOF
FreeBSD: {
url: "pkg+https://pkg.FreeBSD.org/${ABI}/${DEFAULT_PKG_BRANCH}",
mirror_type: "srv",
enabled: yes
}
EOF
PKGS=$(pkg_list_all)
PKG_COUNT=$(echo "$PKGS" | wc -l | tr -d ' ')
echo " Fetching ${PKG_COUNT} packages (with dependencies)..."
# pkg fetch downloads .pkg files and all dependencies to tmp/packages/
# Use PKG_REPOS_DIR and PKG_CACHEDIR to minimize privilege requirements
export PKG_REPOS_DIR="$PKG_CONFIG_DIR"
export PKG_CACHEDIR="$PKG_CACHE_HOME"
# Try unprivileged fetch first
if ! echo "$PKGS" | xargs pkg fetch --yes --dependencies --output "$PKG_REPO_DIR" 2>/tmp/pkg-fetch-error.log; then
# If privilege error, offer to re-run with sudo
if grep -q "Insufficient privileges" /tmp/pkg-fetch-error.log 2>/dev/null; then
echo " ⚠️ pkg fetch requires elevated privileges."
echo " Re-running with sudo..."
sudo sh -c "export PKG_REPOS_DIR='$PKG_CONFIG_DIR' PKG_CACHEDIR='$PKG_CACHE_HOME'; echo '$PKGS' | xargs pkg fetch --yes --dependencies --output '$PKG_REPO_DIR'" || {
echo " ERROR: sudo pkg fetch failed"
exit 1
}
else
cat /tmp/pkg-fetch-error.log
exit 1
fi
fi
echo " Fetch complete."
# Fetch npm-global CLI tarballs (claude-code, gemini-cli, pi)
echo "==> [2b/7] Fetching npm-global CLI tarballs..."
OUT_DIR="$NPM_GLOBALS_DIR" "${SCRIPT_DIR}/scripts/fetch-npm-globals.sh" || {
echo " ERROR: fetch-npm-globals.sh failed"
exit 1
}
else
echo "==> [2/7] Skipping package fetch."
fi
# --- step 3: generate offline pkg repo metadata ---
echo "==> [3/7] Generating offline pkg repo metadata..."
if [ -d "$PKG_REPO_DIR/All" ]; then
# pkg repo may also need privilege — try unprivileged first
if ! pkg repo "$PKG_REPO_DIR" 2>/tmp/pkg-repo-error.log; then
if grep -q "Insufficient privileges" /tmp/pkg-repo-error.log 2>/dev/null; then
echo " ⚠️ pkg repo requires elevated privileges. Re-running with sudo..."
sudo pkg repo "$PKG_REPO_DIR" || {
echo " ERROR: sudo pkg repo failed"
exit 1
}
else
cat /tmp/pkg-repo-error.log
exit 1
fi
fi
echo " Repo metadata written to tmp/packages/"
else
echo " WARN: tmp/packages/All/ not found — run without --skip-fetch first"
fi
# --- step 4: fetch + prepare Clawdie-AI tarball (offline-ready) ---
# Resolve "latest" to the most recent Codeberg tag
if [ "$CLAWDIE_VERSION" = "latest" ] || [ -z "$CLAWDIE_VERSION" ]; then
echo "==> [4/7] Resolving latest Clawdie-AI version..."
CLAWDIE_VERSION=$(curl -s "https://codeberg.org/api/v1/repos/Clawdie/Clawdie-AI/releases?limit=1" \
| grep -o '"tag_name":"[^"]*"' | head -1 | cut -d'"' -f4 | sed 's/^v//')
echo " Resolved: v${CLAWDIE_VERSION}"
fi
CLAWDIE_TARBALL="${CACHE_DIR}/clawdie-ai-v${CLAWDIE_VERSION}.tar.gz"
if [ "$SKIP_FETCH" -eq 0 ] || [ ! -f "$CLAWDIE_TARBALL" ]; then
echo "==> [4/7] Fetching Clawdie-AI v${CLAWDIE_VERSION}..."
mkdir -p "$CACHE_DIR"
curl -L --progress-bar -o "$CLAWDIE_TARBALL" \
"https://codeberg.org/Clawdie/Clawdie-AI/archive/v${CLAWDIE_VERSION}.tar.gz"
else
echo "==> [4/7] Clawdie-AI v${CLAWDIE_VERSION} cached."
fi
# Build an ISO-ready tarball that includes node_modules for offline firstboot.
CLAWDIE_TARBALL_ISO="${CACHE_DIR}/clawdie-ai-v${CLAWDIE_VERSION}-iso.tar.gz"
if [ "$SKIP_FETCH" -eq 0 ] || [ ! -f "$CLAWDIE_TARBALL_ISO" ]; then
echo "==> [4/7] Preparing Clawdie-AI offline tarball (node_modules)..."
if ! command -v npm >/dev/null 2>&1; then
echo "ERROR: npm not found on build host."
echo "Install Node.js + npm on the ISO build machine, then rerun build.sh."
echo "Example (FreeBSD): sudo pkg install -y node24 npm-node24"
exit 1
fi
BUILD_AI_DIR="${CACHE_DIR}/clawdie-ai-build"
STAGE_AI_DIR="${CACHE_DIR}/clawdie-ai-stage"
rm -rf "$BUILD_AI_DIR"
rm -rf "$STAGE_AI_DIR"
mkdir -p "$BUILD_AI_DIR"
mkdir -p "$STAGE_AI_DIR"
tar -xzf "$CLAWDIE_TARBALL" -C "$BUILD_AI_DIR"
AI_SRC_DIR="$(find "$BUILD_AI_DIR" -mindepth 1 -maxdepth 1 -type d | head -n 1)"
if [ -z "${AI_SRC_DIR:-}" ]; then
echo "ERROR: Could not locate extracted Clawdie-AI directory."
exit 1
fi
(
cd "$AI_SRC_DIR"
npm ci --no-audit --no-fund
)
mv "$AI_SRC_DIR" "${STAGE_AI_DIR}/Clawdie-AI"
tar -czf "$CLAWDIE_TARBALL_ISO" -C "$STAGE_AI_DIR" Clawdie-AI
else
echo "==> [4/7] Clawdie-AI offline tarball cached."
fi
# Exit here if --fetch-only (CI package pre-fetch step, no root required)
if [ "$FETCH_ONLY" -eq 1 ]; then
echo ""
echo "==> Fetch complete. Run ./build.sh --skip-fetch to assemble ISO."
exit 0
fi
# --- step 5: prepare working image (requires root) ---
echo "==> [5/7] Preparing working image (${IMAGE_SIZE} for offline packages)..."
if [ "$(id -u)" -ne 0 ]; then
echo "ERROR: steps 5-7 require root (mdconfig/mount)"
exit 1
fi
WORK_IMG="${CACHE_DIR}/work.img"
# Create image with configured size for offline packages
if [ ! -f "$WORK_IMG" ]; then
echo " Creating ${IMAGE_SIZE} image..."
truncate -s "${IMAGE_SIZE}" "$WORK_IMG"
# Attach to mdconfig and partition
MD=$(mdconfig -a -t vnode -f "$WORK_IMG")
echo " Attached as /dev/${MD}"
# Initialize MBR partition table
gpart create -s MBR /dev/${MD}
gpart add -t freebsd /dev/${MD}
# Create BSD label (partition a) in the FreeBSD slice
gpart create -s BSD /dev/${MD}s1
gpart add -t freebsd-ufs /dev/${MD}s1
# Create UFS filesystem on partition a
newfs -U /dev/${MD}s1a
# Mount the new filesystem
MOUNT_POINT="${CACHE_DIR}/mnt"
mkdir -p "$MOUNT_POINT"
mount /dev/${MD}s1a "$MOUNT_POINT"
echo " Mounted /dev/${MD}s1a at ${MOUNT_POINT}"
# Mount memstick read-only to extract base system
MEMSTICK_MNT="${CACHE_DIR}/memstick-src"
mkdir -p "$MEMSTICK_MNT"
MD_SRC=$(mdconfig -a -t vnode -f "$MEMSTICK")
mount -r -t ufs /dev/${MD_SRC}s2a "$MEMSTICK_MNT"
echo " Copying base system from memstick..."
# Copy all files from memstick to new image (excluding package cache)
tar -C "$MEMSTICK_MNT" -cf - . | tar -C "$MOUNT_POINT" -xf -
# Cleanup memstick mount
umount "$MEMSTICK_MNT"
mdconfig -d -u ${MD_SRC}
rm -rf "$MEMSTICK_MNT"
# Store MD device for later cleanup
echo "$MD" > "${CACHE_DIR}/.md_device"
else
# Reattach existing image (but extract base system if empty)
MD=$(mdconfig -a -t vnode -f "$WORK_IMG")
echo " Reattached as /dev/${MD}"
MOUNT_POINT="${CACHE_DIR}/mnt"
mkdir -p "$MOUNT_POINT"
mount /dev/${MD}s1a "$MOUNT_POINT"
echo " Mounted /dev/${MD}s1a at ${MOUNT_POINT}"
# Check if base system is missing and extract if needed
if [ ! -d "$MOUNT_POINT/etc" ]; then
echo " Extracting base system from memstick..."
MEMSTICK_MNT="${CACHE_DIR}/memstick-src"
mkdir -p "$MEMSTICK_MNT"
MD_SRC=$(mdconfig -a -t vnode -f "$MEMSTICK")
mount -r -t ufs /dev/${MD_SRC}s2a "$MEMSTICK_MNT"
tar -C "$MEMSTICK_MNT" -cf - . | tar -C "$MOUNT_POINT" -xf -
umount "$MEMSTICK_MNT"
mdconfig -d -u ${MD_SRC}
rm -rf "$MEMSTICK_MNT"
fi
# Store MD device for later cleanup
echo "$MD" > "${CACHE_DIR}/.md_device"
fi
# --- step 6: inject payload ---
echo "==> [6/7] Injecting payload..."
# Create share directory on USB
USB_SHARE="${MOUNT_POINT}/usr/local/share/clawdie-iso"
mkdir -p "$USB_SHARE"
# Copy payload
cp "${SCRIPT_DIR}/installerconfig" "${MOUNT_POINT}/etc/installerconfig"
cp -r "${SCRIPT_DIR}/firstboot" "${USB_SHARE}/"
cp -r "${PKG_REPO_DIR}" "${USB_SHARE}/"
if [ -d "$NPM_GLOBALS_DIR" ]; then
cp -r "${NPM_GLOBALS_DIR}" "${USB_SHARE}/"
echo " Bundled npm-globals: $(ls "${NPM_GLOBALS_DIR}"/*.tgz 2>/dev/null | wc -l | tr -d ' ') tarballs"
fi
cp "${CLAWDIE_TARBALL_ISO}" "${USB_SHARE}/clawdie-ai.tar.gz"
cp "${SCRIPT_DIR}/build.cfg" "${USB_SHARE}/"
# Bake runtime vars so firstboot reads the right target config
{
echo "TARGET=\"${TARGET:-baremetal}\""
[ -n "${GPU_DRIVER:-}" ] && echo "GPU_DRIVER=\"${GPU_DRIVER}\""
[ -n "${ASSISTANT_NAME:-}" ] && echo "ASSISTANT_NAME=\"${ASSISTANT_NAME}\""
[ -n "${AGENT_GENDER:-}" ] && echo "AGENT_GENDER=\"${AGENT_GENDER}\""
[ -n "${AGENT_DOMAIN:-}" ] && echo "AGENT_DOMAIN=\"${AGENT_DOMAIN}\""
[ -n "${TZ:-}" ] && echo "TZ=\"${TZ}\""
[ -n "${PI_TUI_PROVIDER:-}" ] && echo "PI_TUI_PROVIDER=\"${PI_TUI_PROVIDER}\""
[ -n "${PI_TUI_MODEL:-}" ] && echo "PI_TUI_MODEL=\"${PI_TUI_MODEL}\""
[ -n "${ZAI_API_KEY:-}" ] && echo "ZAI_API_KEY=\"${ZAI_API_KEY}\""
[ -n "${OPENROUTER_API_KEY:-}" ] && echo "OPENROUTER_API_KEY=\"${OPENROUTER_API_KEY}\""
[ -n "${ANTHROPIC_API_KEY:-}" ] && echo "ANTHROPIC_API_KEY=\"${ANTHROPIC_API_KEY}\""
[ -n "${EMBED_BASE_URL:-}" ] && echo "EMBED_BASE_URL=\"${EMBED_BASE_URL}\""
[ -n "${EMBED_MODEL:-}" ] && echo "EMBED_MODEL=\"${EMBED_MODEL}\""
[ -n "${TELEGRAM_BOT_TOKEN:-}" ] && echo "TELEGRAM_BOT_TOKEN=\"${TELEGRAM_BOT_TOKEN}\""
[ -n "${TELEGRAM_ADMIN_IDS:-}" ] && echo "TELEGRAM_ADMIN_IDS=\"${TELEGRAM_ADMIN_IDS}\""
[ -n "${FEATURE_TELEGRAM:-}" ] && echo "FEATURE_TELEGRAM=\"${FEATURE_TELEGRAM}\""
[ -n "${SSH_PUBLIC_KEY:-}" ] && echo "SSH_PUBLIC_KEY=\"${SSH_PUBLIC_KEY}\""
[ -n "${ROOT_PASSWORD:-}" ] && echo "ROOT_PASSWORD=\"${ROOT_PASSWORD}\""
[ -n "${CLAWDIE_USER_PASSWORD:-}" ] && echo "CLAWDIE_USER_PASSWORD=\"${CLAWDIE_USER_PASSWORD}\""
} >> "${USB_SHARE}/build.cfg"
echo " Payload injected."
# Unmount and detach
umount "$MOUNT_POINT"
if [ -f "${CACHE_DIR}/.md_device" ]; then
MD=$(cat "${CACHE_DIR}/.md_device")
mdconfig -d -u "$MD"
rm "${CACHE_DIR}/.md_device"
fi
# --- step 7: write output ---
echo "==> [7/7] Writing output image..."
mkdir -p "$OUTPUT_DIR"
cp "$WORK_IMG" "${OUTPUT_DIR}/${IMAGE_NAME}"
sync
echo ""
echo " Done : ${OUTPUT_DIR}/${IMAGE_NAME}"
echo " Size : $(du -sh "${OUTPUT_DIR}/${IMAGE_NAME}" | cut -f1)"
echo ""
echo " Write to USB:"
echo " dd if=${IMAGE_NAME} of=/dev/daX bs=1M status=progress"