clawdie-iso/build.sh
Sam & Claude 579a8ccd74 build: document Go+Rust build-host toolchains + toolchain-aware preflight
Go (builds the zot agent) and Rust (builds the Colibri release binaries) are
required on the build host to produce the binaries build.sh stages, but were
undocumented. Add them to REQUIREMENTS.md (build-host only, not the image), and
make the binary-missing preflights note when the matching toolchain (go/cargo)
isn't installed so that case surfaces up front instead of later.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 12:27:17 +02:00

2308 lines
100 KiB
Bash
Executable file

#!/bin/sh
# clawdie-iso operator USB build script
# Produces a bootable FreeBSD XFCE operator USB image with Clawdie-AI pre-bundled.
# All packages are fetched and bundled for fully offline installation.
#
# 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 --skip-memstick-fetch # fetch packages, reuse cached FreeBSD memstick
# ./build.sh --live-default-password # set live clawdie password to quindecim
# ./build.sh --clawdie-password ... # set an explicit live clawdie password
# ./build.sh --clawdie-version 1.0.2 # pin Clawdie-AI release tag v1.0.2
# ./build.sh --clawdie-ref main # bundle a branch/tag/commit ref
#
# 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")")"
# Make build host command lookup independent from the invoking user's login PATH.
PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:${PATH:-}"
export PATH
NPM_CONFIG_UPDATE_NOTIFIER=false
export NPM_CONFIG_UPDATE_NOTIFIER
TMP_DIR="${SCRIPT_DIR}/tmp"
NPM_CONFIG_GLOBALCONFIG="${TMP_DIR}/npm-globalconfig"
NPM_CONFIG_USERCONFIG="${TMP_DIR}/npm-userconfig"
export NPM_CONFIG_GLOBALCONFIG NPM_CONFIG_USERCONFIG
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"
LIVE_SESSION_DIR="${SCRIPT_DIR}/live/operator-session"
mkdir -p "$TMP_DIR"
. "${SCRIPT_DIR}/build.cfg"
BUILD_HOST_USER=""
BUILD_HOST_HOME=""
if [ "$(id -u)" -eq 0 ] && [ -n "${SUDO_USER:-}" ] && [ "${SUDO_USER}" != "root" ]; then
BUILD_HOST_USER="$SUDO_USER"
BUILD_HOST_HOME="$(getent passwd "$BUILD_HOST_USER" | cut -d: -f6)"
fi
if [ "$(id -u)" -eq 0 ] && [ -z "${BUILD_HOST_USER}" ]; then
echo "WARN: build is running as root without SUDO_USER; npm stages cannot drop to a non-root build user."
fi
# --- argument parsing ---
SKIP_FETCH=0
SKIP_MEMSTICK_FETCH=0
FETCH_ONLY=0
LIVE_DEFAULT_PASSWORD=0
while [ "$#" -gt 0 ]; do
case "$1" in
--clawdie-version) CLAWDIE_VERSION="$2"; CLAWDIE_REF="v$2"; shift 2 ;;
--clawdie-ref) CLAWDIE_REF="$2"; CLAWDIE_VERSION="$2"; shift 2 ;;
--skip-fetch) SKIP_FETCH=1; shift ;;
--skip-memstick-fetch) SKIP_MEMSTICK_FETCH=1; shift ;;
--fetch-only) FETCH_ONLY=1; shift ;;
--live-default-password) LIVE_DEFAULT_PASSWORD=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 ;;
--tailscale-auth-key) TAILSCALE_AUTHKEY="$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
if [ "$LIVE_DEFAULT_PASSWORD" -eq 1 ] && [ -z "${CLAWDIE_USER_PASSWORD:-}" ]; then
CLAWDIE_USER_PASSWORD="quindecim"
fi
TAILSCALE_AUTH_KEY_BAKED=false
if [ -n "${TAILSCALE_AUTHKEY:-}" ]; then
TAILSCALE_AUTH_KEY_BAKED=true
fi
LIVE_SSH_PUBKEY_FP=""
if [ -n "${SSH_PUBLIC_KEY:-}" ]; then
_ssh_fp_tmp=$(mktemp "${TMP_DIR}/live-ssh-pubkey.XXXXXX")
printf '%s\n' "${SSH_PUBLIC_KEY}" > "${_ssh_fp_tmp}"
LIVE_SSH_PUBKEY_FP=$(ssh-keygen -lf "${_ssh_fp_tmp}" 2>/dev/null | awk '{print $2}')
rm -f "${_ssh_fp_tmp}"
if [ -z "${LIVE_SSH_PUBKEY_FP:-}" ]; then
echo "ERROR: could not derive fingerprint from --ssh-key input"
exit 1
fi
fi
if [ "${BUILD_CHANNEL}" = "release" ]; then
case "${CLAWDIE_REF}" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*)
echo "ERROR: release builds must pin a Clawdie-AI tag with --clawdie-version X.Y.Z"
echo " Current Clawdie ref: ${CLAWDIE_REF}"
exit 1
;;
esac
fi
# ISO version tracks the zot release built upon — no separately invented number.
# "auto" resolves from the zot checkout (git describe); else the pinned ZOT_VERSION.
_iso_zot_repo="${ZOT_REPO:-${SCRIPT_DIR}/../zot}"
case "${_iso_zot_repo}" in /*) ;; *) _iso_zot_repo="${SCRIPT_DIR}/${_iso_zot_repo}" ;; esac
ZOT_RESOLVED_VERSION=""
ZOT_RESOLVED_COMMIT=""
if [ -d "${_iso_zot_repo}/.git" ]; then
ZOT_RESOLVED_VERSION="$(git -C "${_iso_zot_repo}" describe --tags --always 2>/dev/null || true)"
ZOT_RESOLVED_COMMIT="$(git -C "${_iso_zot_repo}" rev-parse --short=12 HEAD 2>/dev/null || true)"
fi
[ -n "${ZOT_RESOLVED_VERSION}" ] || ZOT_RESOLVED_VERSION="${ZOT_VERSION}"
if [ -z "${ISO_VERSION:-}" ] || [ "${ISO_VERSION}" = "auto" ]; then
ISO_VERSION="${ZOT_RESOLVED_VERSION#v}"
fi
echo "==> clawdie-iso build"
echo " ISO : ${ISO_VERSION}-${BUILD_CHANNEL} (tracks zot ${ZOT_RESOLVED_VERSION})"
echo " FreeBSD : ${FREEBSD_VERSION} ${FREEBSD_ARCH}"
echo " Clawdie : ${CLAWDIE_REF}"
echo " Desktop : ${DEFAULT_DESKTOP}"
echo " Pkg : ${DEFAULT_PKG_BRANCH}"
echo " GPU : ${GPU_DRIVER:-auto-detect}"
echo " NVIDIA universal : ${NVIDIA_UNIVERSAL:-NO}"
echo " Target : ${TARGET:-baremetal}"
echo " Colibri : ${FEATURE_COLIBRI:-NO} (agent ${ZOT_VERSION:-} / stage ${COLIBRI_STAGE_AGENT:-YES})"
echo ""
# Name the output: clawdie-<freebsd-codename>-<version>.img, where the version
# tracks zot (see ISO_VERSION above). Per-build provenance — date, clawdie-iso
# commit, zot commit — lives in build-manifest.json, not the filename.
BUILD_FREEBSD_MAJOR="${FREEBSD_RELEASE_SERIES%%.*}"
BUILD_FREEBSD_STAMP="$(printf '%s' "${FREEBSD_RELEASE_SERIES}" | tr 'A-Z' 'a-z')"
case "${BUILD_FREEBSD_MAJOR}" in
15) BUILD_FREEBSD_CODENAME="quindecim" ;;
*) BUILD_FREEBSD_CODENAME="fbsd${BUILD_FREEBSD_STAMP}" ;;
esac
IMAGE_NAME="clawdie-${BUILD_FREEBSD_CODENAME}-${ISO_VERSION}.img"
: > "$NPM_CONFIG_GLOBALCONFIG"
: > "$NPM_CONFIG_USERCONFIG"
# --- helper: read package lists into a single deduplicated list ---
pkg_list_all() {
# Operator USB: include host, jail, desktop, GPU, and disk-install extras
# for offline use. Runtime detection handles configuration.
echo "==> Operator USB: including packages (host + jails + XFCE desktop + GPU drivers + disk extras)" >&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-xfce.txt" \
"${PKG_LIST_DIR}/pkg-list-xfce-theming.txt" \
"${PKG_LIST_DIR}/pkg-list-live-operator.txt" \
"${PKG_LIST_DIR}/pkg-list-disk-install-extras.txt" \
"${PKG_LIST_DIR}/pkg-list-nvidia-all.txt" \
| grep -v '^#' | grep -v '^$' | sort -u
}
pkg_list_live_installer() {
cat \
"${PKG_LIST_DIR}/pkg-list-desktop-base.txt" \
"${PKG_LIST_DIR}/pkg-list-xfce.txt" \
"${PKG_LIST_DIR}/pkg-list-xfce-theming.txt" \
"${PKG_LIST_DIR}/pkg-list-live-operator.txt" \
| grep -v '^#' \
| grep -v '^$' \
| sort -u
}
pkg_list_live_nvidia() {
case "${GPU_DRIVER:-}" in
nvidia-390)
cat "${PKG_LIST_DIR}/pkg-list-nvidia-390.txt"
;;
nvidia-470)
cat "${PKG_LIST_DIR}/pkg-list-nvidia-470.txt"
;;
nvidia-590)
cat "${PKG_LIST_DIR}/pkg-list-nvidia-590.txt"
;;
*)
return 0
;;
esac | grep -v '^#' | grep -v '^$' | sort -u
}
set_config_line() {
_file="$1"
_assignment="$2"
_name=$(echo "$_assignment" | cut -d= -f1)
mkdir -p "$(dirname "$_file")"
touch "$_file"
if grep -q "^${_name}=" "$_file" 2>/dev/null; then
sed -i '' "s|^${_name}=.*|${_assignment}|" "$_file"
else
echo "$_assignment" >> "$_file"
fi
}
append_rc_list_values() {
_file="$1"
_name="$2"
shift 2
mkdir -p "$(dirname "$_file")"
touch "$_file"
_current=""
if grep -q "^${_name}=" "$_file" 2>/dev/null; then
_current=$(sed -n "s/^${_name}=\"\\(.*\\)\"/\\1/p" "$_file" | head -1)
fi
_merged="$_current"
for _value in "$@"; do
if [ -z "$_merged" ]; then
_merged="$_value"
continue
fi
case " ${_merged} " in
*" ${_value} "*) ;;
*)
_merged="${_merged} ${_value}"
;;
esac
done
set_config_line "$_file" "${_name}=\"${_merged}\""
}
ensure_fstab_line() {
_file="$1"
_pattern="$2"
_line="$3"
mkdir -p "$(dirname "$_file")"
touch "$_file"
if ! grep -Eq "$_pattern" "$_file" 2>/dev/null; then
echo "$_line" >> "$_file"
fi
}
set_fstab_line() {
_file="$1"
_pattern="$2"
_line="$3"
mkdir -p "$(dirname "$_file")"
touch "$_file"
_tmp="${_file}.tmp.$$"
awk -v pattern="$_pattern" -v line="$_line" '
BEGIN { replaced = 0 }
$0 ~ pattern {
if (!replaced) {
print line
replaced = 1
}
next
}
{ print }
END {
if (!replaced) {
print line
}
}
' "$_file" > "$_tmp"
mv "$_tmp" "$_file"
}
pkg_archive_for() {
_pkg_name="$1"
# Match exact package names only. A loose `${name}-*.pkg` pattern lets a
# missing package such as `xfce` resolve to `xfce-icons-elementary`, which
# silently builds an image without the requested desktop session.
{
find "${PKG_REPO_DIR}/All" -type f -name "${_pkg_name}-[0-9]*.pkg" 2>/dev/null
find "${PKG_REPO_DIR}" -maxdepth 2 -type f -name "${_pkg_name}-[0-9]*.pkg" 2>/dev/null
} | sort -u | tail -1
}
resolve_colibri_paths() {
_resolved_colibri_repo="${COLIBRI_REPO:-/home/clawdie/ai/colibri}"
case "${_resolved_colibri_repo}" in
/*) ;;
*) _resolved_colibri_repo="${SCRIPT_DIR}/${_resolved_colibri_repo}" ;;
esac
if [ -n "${COLIBRI_ARTIFACT_DIR:-}" ]; then
_resolved_colibri_artifact_dir="${COLIBRI_ARTIFACT_DIR}"
case "${_resolved_colibri_artifact_dir}" in
/*) ;;
*) _resolved_colibri_artifact_dir="${SCRIPT_DIR}/${_resolved_colibri_artifact_dir}" ;;
esac
else
_resolved_colibri_artifact_dir="${_resolved_colibri_repo}/target/release"
fi
}
resolve_clawdie_ai_repo() {
_resolved_clawdie_ai_repo="${CLAWDIE_AI_REPO:-/home/clawdie/ai/clawdie-ai}"
case "${_resolved_clawdie_ai_repo}" in
/*) ;;
*) _resolved_clawdie_ai_repo="${SCRIPT_DIR}/${_resolved_clawdie_ai_repo}" ;;
esac
}
preflight_colibri_artifacts() {
[ "${FEATURE_COLIBRI:-NO}" = "YES" ] || return 0
[ "${FETCH_ONLY:-0}" -eq 0 ] || return 0
resolve_colibri_paths
_colibri_rc="${_resolved_colibri_repo}/packaging/freebsd/colibri_daemon.in"
_colibri_newsyslog="${_resolved_colibri_repo}/packaging/freebsd/newsyslog-colibri.conf"
if [ ! -f "${_colibri_rc}" ]; then
echo "ERROR: Colibri rc.d source missing: ${_colibri_rc}"
echo " Set COLIBRI_REPO=/path/to/colibri or FEATURE_COLIBRI=NO."
exit 1
fi
if ! grep -q '^command="/usr/sbin/daemon"' "${_colibri_rc}"; then
echo "ERROR: Colibri rc.d source does not supervise with daemon(8): ${_colibri_rc}"
echo " Update the Colibri checkout before building; a foreground daemon blocks live boot."
exit 1
fi
if [ ! -f "${_colibri_newsyslog}" ]; then
echo "ERROR: Colibri newsyslog source missing: ${_colibri_newsyslog}"
echo " Set COLIBRI_REPO=/path/to/colibri or FEATURE_COLIBRI=NO."
exit 1
fi
for _colibri_bin in colibri-daemon colibri colibri-smoke-agent colibri-mcp; do
if [ ! -x "${_resolved_colibri_artifact_dir}/${_colibri_bin}" ]; then
echo "ERROR: Colibri release binary missing: ${_resolved_colibri_artifact_dir}/${_colibri_bin}"
command -v cargo >/dev/null 2>&1 || \
echo " NOTE: rust toolchain not found on this host — install it: pkg install rust"
echo " Build first: (cd ${_resolved_colibri_repo} && cargo build --workspace --release)"
echo " Or set FEATURE_COLIBRI=NO to skip Colibri staging."
exit 1
fi
done
}
resolve_zot_paths() {
_resolved_zot_repo="${ZOT_REPO:-${SCRIPT_DIR}/../zot}"
case "${_resolved_zot_repo}" in
/*) ;;
*) _resolved_zot_repo="${SCRIPT_DIR}/${_resolved_zot_repo}" ;;
esac
if [ -n "${ZOT_ARTIFACT_DIR:-}" ]; then
_resolved_zot_artifact_dir="${ZOT_ARTIFACT_DIR}"
case "${_resolved_zot_artifact_dir}" in
/*) ;;
*) _resolved_zot_artifact_dir="${SCRIPT_DIR}/${_resolved_zot_artifact_dir}" ;;
esac
else
_resolved_zot_artifact_dir="${_resolved_zot_repo}/bin"
fi
}
preflight_zot_artifacts() {
[ "${FEATURE_COLIBRI:-NO}" = "YES" ] || return 0
[ "${COLIBRI_STAGE_AGENT:-YES}" = "YES" ] || return 0
[ "${FETCH_ONLY:-0}" -eq 0 ] || return 0
resolve_zot_paths
if [ ! -x "${_resolved_zot_artifact_dir}/zot" ]; then
echo "ERROR: Colibri agent binary missing: ${_resolved_zot_artifact_dir}/zot"
command -v go >/dev/null 2>&1 || \
echo " NOTE: go toolchain not found on this host — install it: pkg install go"
echo " The agent has no FreeBSD release — build it first:"
echo " (cd ${_resolved_zot_repo} && git checkout ${ZOT_VERSION:-v0.2.29} && \\"
echo " GOOS=freebsd GOARCH=amd64 go build -trimpath -o bin/zot ./cmd/zot)"
echo " Or set COLIBRI_STAGE_AGENT=NO to skip agent staging."
exit 1
fi
}
override_networkmgr_package() {
_networkmgr_pkg=$(find "${PKG_REPO_DIR}/All/Hashed" -maxdepth 1 -type f -name 'networkmgr-*.pkg' | sort | tail -1)
if [ -z "${_networkmgr_pkg:-}" ]; then
echo "ERROR: networkmgr package not found under ${PKG_REPO_DIR}/All/Hashed"
exit 1
fi
echo "==> [2c/7] Repacking NetworkMgr for mac_do/mdo policy..."
_networkmgr_stage=$(mktemp -d "${TMP_DIR}/networkmgr-override.XXXXXX")
_networkmgr_log="${TMP_DIR}/networkmgr-repack.log"
if ! node "${SCRIPT_DIR}/scripts/repack-networkmgr-for-mdo.mjs" "${_networkmgr_pkg}" "${_networkmgr_stage}" >"${_networkmgr_log}" 2>&1; then
cat "${_networkmgr_log}"
rm -rf "${_networkmgr_stage}"
echo "ERROR: failed to rebuild local networkmgr package"
exit 1
fi
rm -f "${PKG_REPO_DIR}/All/Hashed"/networkmgr-*.pkg
mv "${_networkmgr_stage}"/networkmgr-*.pkg "${PKG_REPO_DIR}/All/Hashed/"
# After replacing NetworkMgr's sudo dependency with the local mac_do/mdo
# package, remove any fetched sudo archive from the offline repo that will
# be copied onto the USB image.
find "${PKG_REPO_DIR}/All" -type f -name 'sudo-*.pkg' -delete
rm -rf "${_networkmgr_stage}"
}
json_escape() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
resolve_clawdie_commit() {
_ref="$1"
_repo="https://code.smilepowered.org/clawdie/clawdie-ai.git"
if printf '%s' "$_ref" | grep -Eq '^[0-9a-fA-F]{40}$'; then
printf '%s\n' "$_ref"
return 0
fi
if command -v git >/dev/null 2>&1; then
git ls-remote "$_repo" \
"refs/heads/${_ref}" \
"refs/tags/${_ref}^{}" \
"refs/tags/${_ref}" 2>/dev/null \
| awk '
$2 ~ /\^\{\}$/ { print $1; found = 1; exit }
first == "" { first = $1 }
END { if (!found && first != "") print first }
'
fi
}
resolve_latest_clawdie_tag() {
_repo_api="https://code.smilepowered.org/api/v1/repos/clawdie/clawdie-ai"
_repo_git="https://code.smilepowered.org/clawdie/clawdie-ai.git"
_tag=$(
curl -fsS "${_repo_api}/releases?limit=20" 2>/dev/null \
| grep -o '"tag_name":"[^"]*"' \
| head -1 \
| cut -d'"' -f4
)
if [ -n "$_tag" ]; then
printf '%s\n' "$_tag"
return 0
fi
git ls-remote --tags "$_repo_git" 2>/dev/null \
| awk -F/ '$2 == "tags" && $3 ~ /^v[0-9]+\.[0-9]+\.[0-9]+$/ { print $3 }' \
| sed 's/^v//' \
| sort -t. -k1,1n -k2,2n -k3,3n \
| tail -n 1 \
| sed 's/^/v/'
}
is_pinned_clawdie_ref() {
_ref="$1"
printf '%s' "$_ref" | grep -Eq '^[0-9a-fA-F]{40}$|^v[0-9]+\.[0-9]+\.[0-9]+$'
}
write_build_manifest() {
_manifest_path="$1"
_iso_repo_commit="unknown"
_iso_repo_dirty="null"
_live_ssh_pubkey_fp_json="null"
_tailscale_auth_key_baked="${TAILSCALE_AUTH_KEY_BAKED:-false}"
if command -v git >/dev/null 2>&1 && git -C "$SCRIPT_DIR" rev-parse --git-dir >/dev/null 2>&1; then
_iso_repo_commit=$(git -C "$SCRIPT_DIR" rev-parse HEAD 2>/dev/null || echo unknown)
if git -C "$SCRIPT_DIR" diff --quiet 2>/dev/null && git -C "$SCRIPT_DIR" diff --cached --quiet 2>/dev/null; then
_iso_repo_dirty="false"
else
_iso_repo_dirty="true"
fi
fi
if [ -n "${LIVE_SSH_PUBKEY_FP:-}" ]; then
_live_ssh_pubkey_fp_json="\"$(json_escape "${LIVE_SSH_PUBKEY_FP}")\""
fi
mkdir -p "$(dirname "$_manifest_path")"
cat > "$_manifest_path" <<EOF
{
"iso_version": "$(json_escape "${ISO_VERSION}")",
"iso_version_tracks": "zot",
"zot_version": "$(json_escape "${ZOT_RESOLVED_VERSION:-${ZOT_VERSION}}")",
"zot_commit": "$(json_escape "${ZOT_RESOLVED_COMMIT:-unknown}")",
"build_channel": "$(json_escape "${BUILD_CHANNEL}")",
"freebsd_version": "$(json_escape "${FREEBSD_VERSION}")",
"freebsd_arch": "$(json_escape "${FREEBSD_ARCH}")",
"clawdie_ai_ref": "$(json_escape "${CLAWDIE_REF}")",
"clawdie_ai_commit": "$(json_escape "${CLAWDIE_AI_COMMIT:-unknown}")",
"live_ssh_pubkey_fp": ${_live_ssh_pubkey_fp_json},
"tailscale_auth_key_baked": ${_tailscale_auth_key_baked},
"iso_repo_commit": "$(json_escape "${_iso_repo_commit}")",
"iso_repo_dirty": ${_iso_repo_dirty},
"built_at": "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
}
EOF
}
mount_memstick_rootfs() {
_memstick_mount="$1"
_memstick_slice_img="${CACHE_DIR}/memstick-freebsd-slice.img"
_memstick_ufs_img="${CACHE_DIR}/memstick-rootfs.img"
mkdir -p "$_memstick_mount"
rm -f "$_memstick_slice_img" "$_memstick_ufs_img"
MD_SRC=$(mdconfig -a -t vnode -f "$MEMSTICK")
_slice_meta=$(
gpart show -p "/dev/${MD_SRC}" \
| awk '$4 == "freebsd" { print $1 " " $2; exit }'
)
_slice_start=$(echo "$_slice_meta" | awk '{print $1}')
_slice_size=$(echo "$_slice_meta" | awk '{print $2}')
if [ -z "${_slice_start:-}" ] || [ -z "${_slice_size:-}" ]; then
echo "ERROR: could not determine FreeBSD slice start in ${MEMSTICK}"
mdconfig -d -u "${MD_SRC}" 2>/dev/null || true
exit 1
fi
truncate -s "$((_slice_size * 512))" "$_memstick_slice_img"
dd if="$MEMSTICK" of="$_memstick_slice_img" bs=512 skip="$_slice_start" conv=sparse status=none
MD_SLICE=$(mdconfig -a -t vnode -f "$_memstick_slice_img")
_ufs_meta=$(
bsdlabel "/dev/${MD_SLICE}" 2>/dev/null \
| awk '$1 == "a:" { print $2 " " $3; exit }'
)
_ufs_size=$(echo "$_ufs_meta" | awk '{print $1}')
_ufs_offset=$(echo "$_ufs_meta" | awk '{print $2}')
if [ -z "${_ufs_size:-}" ] || [ -z "${_ufs_offset:-}" ]; then
echo "ERROR: could not determine UFS root partition offset in ${MEMSTICK}"
mdconfig -d -u "${MD_SLICE}" 2>/dev/null || true
mdconfig -d -u "${MD_SRC}" 2>/dev/null || true
exit 1
fi
truncate -s "$((_ufs_size * 512))" "$_memstick_ufs_img"
dd if="$_memstick_slice_img" of="$_memstick_ufs_img" bs=512 skip="$_ufs_offset" conv=sparse status=none
MD_ROOTFS=$(mdconfig -a -t vnode -f "$_memstick_ufs_img")
mount -r -t ufs "/dev/${MD_ROOTFS}" "$_memstick_mount"
}
cleanup_memstick_rootfs() {
_memstick_mount="$1"
umount "$_memstick_mount" 2>/dev/null || true
[ -n "${MD_ROOTFS:-}" ] && mdconfig -d -u "${MD_ROOTFS}" 2>/dev/null || true
[ -n "${MD_SLICE:-}" ] && mdconfig -d -u "${MD_SLICE}" 2>/dev/null || true
[ -n "${MD_SRC:-}" ] && mdconfig -d -u "${MD_SRC}" 2>/dev/null || true
rm -f "${CACHE_DIR}/memstick-freebsd-slice.img" "${CACHE_DIR}/memstick-rootfs.img"
MD_ROOTFS=""
MD_SLICE=""
MD_SRC=""
}
verify_memstick_cache() {
if [ ! -f "$MEMSTICK" ] || [ ! -f "${MEMSTICK}.SHA256" ]; then
return 1
fi
_expected_sha=$(
awk -v image="$(basename "$MEMSTICK")" '
$2 == "(" image ")" { print $4; exit }
' "${MEMSTICK}.SHA256"
)
[ -n "${_expected_sha:-}" ] || return 1
[ "$(/sbin/sha256 -q "$MEMSTICK")" = "$_expected_sha" ]
}
install_image_bootcode() {
_image_md="$1"
_image_root="$2"
_freebsd_slice_index="${3:-}"
if [ -z "${_freebsd_slice_index}" ]; then
if [ -e "/dev/${_image_md}s2" ]; then
_freebsd_slice_index="2"
else
_freebsd_slice_index="1"
fi
fi
if [ ! -f "${_image_root}/boot/mbr" ] || [ ! -f "${_image_root}/boot/boot" ]; then
echo "ERROR: missing bootcode files in ${_image_root}/boot"
exit 1
fi
gpart bootcode -b "${_image_root}/boot/mbr" "/dev/${_image_md}"
gpart bootcode -b "${_image_root}/boot/boot" "/dev/${_image_md}s${_freebsd_slice_index}"
}
install_image_uefi_bootcode() {
_image_md="$1"
_image_root="$2"
_efi_slice_index="${3:-1}"
_efi_mount="${CACHE_DIR}/efi-mnt"
if [ ! -f "${_image_root}/boot/loader.efi" ]; then
echo "ERROR: missing UEFI loader at ${_image_root}/boot/loader.efi"
exit 1
fi
newfs_msdos -L EFISYS "/dev/${_image_md}s${_efi_slice_index}"
mkdir -p "${_efi_mount}"
mount_msdosfs "/dev/${_image_md}s${_efi_slice_index}" "${_efi_mount}"
mkdir -p "${_efi_mount}/EFI/BOOT"
cp "${_image_root}/boot/loader.efi" "${_efi_mount}/EFI/BOOT/BOOTX64.EFI"
sync
umount "${_efi_mount}"
rmdir "${_efi_mount}" 2>/dev/null || true
}
run_live_chroot() {
chroot "$MOUNT_POINT" /usr/bin/env \
PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \
LD_LIBRARY_PATH="/usr/local/lib:/usr/local/lib/compat:/lib:/usr/lib" \
"$@"
}
refresh_live_desktop_caches() {
echo " Refreshing live desktop schema/icon/mime caches..."
if [ -x "${MOUNT_POINT}/usr/local/bin/glib-compile-schemas" ]; then
run_live_chroot /usr/local/bin/glib-compile-schemas /usr/local/share/glib-2.0/schemas
fi
if [ ! -f "${MOUNT_POINT}/usr/local/share/glib-2.0/schemas/gschemas.compiled" ]; then
echo "ERROR: GLib GSettings schema cache missing from live image"
exit 1
fi
if [ -x "${MOUNT_POINT}/usr/local/bin/gdk-pixbuf-query-loaders" ]; then
run_live_chroot /usr/local/bin/gdk-pixbuf-query-loaders --update-cache
fi
if [ ! -f "${MOUNT_POINT}/usr/local/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache" ]; then
echo "ERROR: gdk-pixbuf loader cache missing from live image"
exit 1
fi
if ! grep -qi 'png' "${MOUNT_POINT}/usr/local/lib/gdk-pixbuf-2.0/2.10.0/loaders.cache"; then
echo "ERROR: gdk-pixbuf PNG loader missing from live image cache"
exit 1
fi
if [ -x "${MOUNT_POINT}/usr/local/bin/update-mime-database" ] && [ -d "${MOUNT_POINT}/usr/local/share/mime" ]; then
run_live_chroot /usr/local/bin/update-mime-database /usr/local/share/mime
fi
if [ -x "${MOUNT_POINT}/usr/local/bin/update-desktop-database" ] && [ -d "${MOUNT_POINT}/usr/local/share/applications" ]; then
run_live_chroot /usr/local/bin/update-desktop-database /usr/local/share/applications
fi
if [ -x "${MOUNT_POINT}/usr/local/bin/gtk-update-icon-cache" ]; then
# Regenerate every installed icon theme cache. FreeBSD packages and
# Clawdie branding install icons into hicolor and other theme
# directories; missed caches show up as blank/dot panel icons.
for _icon_theme_dir in "${MOUNT_POINT}/usr/local/share/icons"/*/; do
[ -d "$_icon_theme_dir" ] || continue
_theme_name=$(basename "$_icon_theme_dir")
run_live_chroot /usr/local/bin/gtk-update-icon-cache -f -t "/usr/local/share/icons/${_theme_name}" || true
done
fi
if [ -x "${MOUNT_POINT}/usr/local/bin/fc-cache" ] && [ -d "${MOUNT_POINT}/usr/local/share/fonts" ]; then
run_live_chroot /usr/local/bin/fc-cache -f
fi
}
ensure_sshd_include_line() {
_sshd_config="$1"
_include_line='Include /etc/ssh/sshd_config.d/*.conf'
if [ ! -f "$_sshd_config" ]; then
echo "ERROR: sshd_config missing from live image"
exit 1
fi
if grep -Eq '^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config\.d/\*\.conf([[:space:]]|$)' "$_sshd_config"; then
return 0
fi
_tmp="${_sshd_config}.tmp.$$"
{
printf '%s\n' "${_include_line}"
cat "$_sshd_config"
} > "$_tmp"
mv "$_tmp" "$_sshd_config"
}
configure_nsswitch_hosts_line() {
_file="$1"
_line='hosts: files mdns_minimal [NOTFOUND=return] dns mdns'
if [ ! -f "$_file" ]; then
echo "ERROR: nsswitch.conf missing from live image"
exit 1
fi
set_fstab_line "$_file" '^[[:space:]]*hosts:' "$_line"
}
install_live_runtime_packages() {
echo " Installing live operator runtime packages into image..."
_pkg_files=""
for _pkg in $(pkg_list_live_installer); do
_archive=$(pkg_archive_for "$_pkg")
if [ -z "${_archive:-}" ]; then
echo "ERROR: missing package archive for ${_pkg}"
exit 1
fi
_pkg_files="${_pkg_files} ${_archive}"
done
for _pkg in $(pkg_list_live_nvidia); do
_archive=$(pkg_archive_for "$_pkg")
if [ -z "${_archive:-}" ]; then
echo "ERROR: missing NVIDIA package archive for ${_pkg}"
exit 1
fi
_pkg_files="${_pkg_files} ${_archive}"
done
mkdir -p "${MOUNT_POINT}/dev" "${MOUNT_POINT}/proc"
_mounted_devfs=0
_mounted_procfs=0
if mount -t devfs devfs "${MOUNT_POINT}/dev"; then
_mounted_devfs=1
fi
if mount -t procfs proc "${MOUNT_POINT}/proc"; then
_mounted_procfs=1
fi
if ! env ASSUME_ALWAYS_YES=yes HANDLE_RC_SCRIPTS=no \
/usr/local/sbin/pkg-static -o PKG_TRIGGERS_ENABLE=false -r "$MOUNT_POINT" add -f ${_pkg_files}; then
[ "$_mounted_procfs" -eq 1 ] && umount "${MOUNT_POINT}/proc" 2>/dev/null || true
[ "$_mounted_devfs" -eq 1 ] && umount "${MOUNT_POINT}/dev" 2>/dev/null || true
echo "ERROR: failed to install live GUI runtime packages into image"
exit 1
fi
if ! chroot "${MOUNT_POINT}" /usr/sbin/pw usershow sddm >/dev/null 2>&1; then
[ "$_mounted_procfs" -eq 1 ] && umount "${MOUNT_POINT}/proc" 2>/dev/null || true
[ "$_mounted_devfs" -eq 1 ] && umount "${MOUNT_POINT}/dev" 2>/dev/null || true
echo "ERROR: sddm user missing from live image after package install"
exit 1
fi
# webcamd creates /dev/videoN with mode 0660 root:webcamd. Add clawdie
# to that group so the operator can open the camera without sudo.
if chroot "${MOUNT_POINT}" /usr/sbin/pw groupshow webcamd >/dev/null 2>&1; then
chroot "${MOUNT_POINT}" /usr/sbin/pw groupmod webcamd -m clawdie 2>/dev/null || true
else
[ "$_mounted_procfs" -eq 1 ] && umount "${MOUNT_POINT}/proc" 2>/dev/null || true
[ "$_mounted_devfs" -eq 1 ] && umount "${MOUNT_POINT}/dev" 2>/dev/null || true
echo "ERROR: webcamd group missing from live image after package install"
exit 1
fi
refresh_live_desktop_caches
[ "$_mounted_procfs" -eq 1 ] && umount "${MOUNT_POINT}/proc" 2>/dev/null || true
[ "$_mounted_devfs" -eq 1 ] && umount "${MOUNT_POINT}/dev" 2>/dev/null || true
}
install_colibri_service() {
[ "${FEATURE_COLIBRI:-NO}" = "YES" ] || {
echo " Colibri service staging disabled (FEATURE_COLIBRI=${FEATURE_COLIBRI:-NO})"
return 0
}
echo " Staging Colibri service..."
resolve_colibri_paths
env \
COLIBRI_REPO="${_resolved_colibri_repo}" \
COLIBRI_ARTIFACT_DIR="${_resolved_colibri_artifact_dir}" \
COLIBRI_STAGE_ENABLE="${COLIBRI_DAEMON_ENABLE:-YES}" \
COLIBRI_COST_MODE="${COLIBRI_COST_MODE:-smart}" \
"${SCRIPT_DIR}/scripts/stage-colibri-iso.sh" "${MOUNT_POINT}"
if ! /usr/sbin/pw -R "${MOUNT_POINT}" groupshow colibri >/dev/null 2>&1; then
/usr/sbin/pw -R "${MOUNT_POINT}" groupadd colibri
fi
if ! /usr/sbin/pw -R "${MOUNT_POINT}" usershow colibri >/dev/null 2>&1; then
/usr/sbin/pw -R "${MOUNT_POINT}" useradd colibri \
-g colibri \
-d /var/db/colibri \
-s /usr/sbin/nologin \
-c "Colibri Daemon"
fi
mkdir -p \
"${MOUNT_POINT}/var/db/colibri" \
"${MOUNT_POINT}/var/run/colibri" \
"${MOUNT_POINT}/var/log/colibri"
chroot "${MOUNT_POINT}" chown -R colibri:colibri \
/var/db/colibri \
/var/run/colibri \
/var/log/colibri
chmod 0755 \
"${MOUNT_POINT}/var/db/colibri" \
"${MOUNT_POINT}/var/run/colibri" \
"${MOUNT_POINT}/var/log/colibri"
# Allow operator to use 'colibri' CLI without root
if /usr/sbin/pw -R "${MOUNT_POINT}" usershow clawdie >/dev/null 2>&1; then
/usr/sbin/pw -R "${MOUNT_POINT}" groupmod colibri -m clawdie
fi
set_config_line "${MOUNT_POINT}/etc/rc.conf" "colibri_daemon_enable=\"${COLIBRI_DAEMON_ENABLE:-YES}\""
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'colibri_daemon_user="colibri"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'colibri_daemon_group="colibri"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'colibri_daemon_data_dir="/var/db/colibri"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'colibri_daemon_run_dir="/var/run/colibri"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'colibri_daemon_socket="/var/run/colibri/colibri.sock"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'colibri_daemon_db_path="/var/db/colibri/colibri.sqlite"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'colibri_daemon_logfile="/var/log/colibri/daemon.log"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'colibri_daemon_host="$(hostname)"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" "colibri_cost_mode=\"${COLIBRI_COST_MODE:-smart}\""
if [ ! -x "${MOUNT_POINT}/usr/local/bin/colibri-daemon" ] || \
[ ! -x "${MOUNT_POINT}/usr/local/bin/colibri" ] || \
[ ! -x "${MOUNT_POINT}/usr/local/bin/colibri-mcp" ]; then
echo "ERROR: Colibri binaries missing from live image"
exit 1
fi
if [ ! -x "${MOUNT_POINT}/usr/local/etc/rc.d/colibri_daemon" ]; then
echo "ERROR: Colibri rc.d script missing from live image"
exit 1
fi
if ! /usr/sbin/pw -R "${MOUNT_POINT}" usershow colibri >/dev/null 2>&1; then
echo "ERROR: colibri service user missing from live image"
exit 1
fi
# Seed the skills catalog with operator-useful entries. The daemon
# creates the SQLite DB on first start; pre-populate it so skills
# are available immediately without a first-boot script.
_colibri_db="${MOUNT_POINT}/var/db/colibri/colibri.sqlite"
if command -v sqlite3 >/dev/null 2>&1; then
sqlite3 "${_colibri_db}" "CREATE TABLE IF NOT EXISTS skills (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL UNIQUE,
description TEXT,
category TEXT,
created_at TEXT NOT NULL
);" 2>/dev/null || true
_now=$(date -u +%Y-%m-%dT%H:%M:%SZ)
sqlite3 "${_colibri_db}" "INSERT OR IGNORE INTO skills (id, name, description, category, created_at) VALUES
('$(uuidgen || echo 00000000-0000-0000-0000-000000000001)', 'freebsd-live-usb', 'FreeBSD live USB operator workstation procedures', 'freebsd', '${_now}'),
('$(uuidgen || echo 00000000-0000-0000-0000-000000000002)', 'colibri-smoke', 'Colibri daemon smoke test and validation', 'colibri', '${_now}'),
('$(uuidgen || echo 00000000-0000-0000-0000-000000000003)', 'iso-build', 'Clawdie ISO build and staging workflow', 'iso', '${_now}'),
('$(uuidgen || echo 00000000-0000-0000-0000-000000000004)', 'tailscale-join', 'Tailscale mesh join procedure for operator USB', 'networking', '${_now}'),
('$(uuidgen || echo 00000000-0000-0000-0000-000000000005)', 'disk-deploy', 'Deploy from USB live to permanent disk install. Provisions ZFS pool, installs FreeBSD boot environment, migrates config, and prepares for the future deployed-system clawdie service.', 'clawdie', '${_now}'),
('$(uuidgen || echo 00000000-0000-0000-0000-000000000006)', 'deployed-clawdie-health', 'Future post-deploy health check for service clawdie once the deployed-system service implementation lands.', 'clawdie', '${_now}');" 2>/dev/null || true
chroot "${MOUNT_POINT}" chown colibri:colibri /var/db/colibri/colibri.sqlite 2>/dev/null || true
echo " colibri skills seeded: 6 entries"
fi
# Import clawdie-ai skill definitions into the catalog.
# Reads .agent/skills/*/SKILL.md and registers name + description.
resolve_clawdie_ai_repo
_clawdie_ai_dir="${_resolved_clawdie_ai_repo}"
if [ -d "${_clawdie_ai_dir}/.agent/skills" ]; then
"${SCRIPT_DIR}/scripts/import-clawdie-skills.sh" \
"${_clawdie_ai_dir}" "${MOUNT_POINT}"
else
echo " clawdie-ai checkout not found, skipping skill import"
fi
}
install_zot_agent() {
[ "${FEATURE_COLIBRI:-NO}" = "YES" ] || {
echo " Colibri agent staging disabled (FEATURE_COLIBRI=${FEATURE_COLIBRI:-NO})"
return 0
}
[ "${COLIBRI_STAGE_AGENT:-YES}" = "YES" ] || {
echo " Colibri agent staging disabled (COLIBRI_STAGE_AGENT=${COLIBRI_STAGE_AGENT:-YES})"
return 0
}
echo " Staging Colibri agent (${ZOT_VERSION:-})..."
resolve_zot_paths
env \
ZOT_ARTIFACT_DIR="${_resolved_zot_artifact_dir}" \
ZOT_OPERATOR="clawdie" \
ZOT_DEEPSEEK_KEY="${ZOT_DEEPSEEK_KEY:-}" \
"${SCRIPT_DIR}/scripts/stage-zot-iso.sh" "${MOUNT_POINT}"
# zot state lives in the operator's home; own it as the operator.
if /usr/sbin/pw -R "${MOUNT_POINT}" usershow clawdie >/dev/null 2>&1; then
chroot "${MOUNT_POINT}" chown -R clawdie:clawdie /home/clawdie/.local 2>/dev/null || true
fi
if [ ! -x "${MOUNT_POINT}/usr/local/bin/zot" ]; then
echo "ERROR: zot binary missing from live image"
exit 1
fi
}
# Stage an on-image NVIDIA pkg repo (all branches) so clawdie_live_gpu can
# `pkg install` the detected branch at boot (NVIDIA_UNIVERSAL lane).
#
# FreeBSD-build-host step (authored on Linux; runs + must be validated on
# FreeBSD). Verify on the build host: (1) the `pkg fetch -o` layout matches what
# `pkg repo` expects, (2) the dependency closure is complete for offline boot
# install, (3) image size headroom, (4) `pkg install -r clawdie-nvidia` resolves
# from file:// at boot. See doc handoff.
install_nvidia_universal_repo() {
[ "${NVIDIA_UNIVERSAL:-NO}" = "YES" ] || {
echo " NVIDIA universal repo staging disabled (NVIDIA_UNIVERSAL=${NVIDIA_UNIVERSAL:-NO})"
return 0
}
echo " Staging universal NVIDIA repo (all branches) into image..."
_nv_repo="${MOUNT_POINT}/usr/local/share/clawdie/nvidia-repo"
mkdir -p "${_nv_repo}"
# Fetch all three branches + deps into the on-image repo dir, then build repo
# metadata. Uses pkg's own dependency resolution so the closure is complete.
if ! env ASSUME_ALWAYS_YES=yes pkg fetch -y -o "${_nv_repo}" -d \
nvidia-driver-390 nvidia-driver-470 nvidia-driver-580 nvidia-settings nvidia-xconfig; then
echo "ERROR: failed to fetch NVIDIA packages for universal repo"
exit 1
fi
if ! pkg repo "${_nv_repo}"; then
echo "ERROR: failed to generate NVIDIA universal repo metadata"
exit 1
fi
mkdir -p "${MOUNT_POINT}/usr/local/etc/pkg/repos"
cat > "${MOUNT_POINT}/usr/local/etc/pkg/repos/clawdie-nvidia.conf" <<'EOF'
# On-image NVIDIA package repo for the universal GPU lane. Consumed at boot by
# clawdie_live_gpu (nvidia-auto mode) via: pkg install -r clawdie-nvidia ...
clawdie-nvidia: {
url: "file:///usr/local/share/clawdie/nvidia-repo",
enabled: yes
}
EOF
echo " NVIDIA universal repo staged at /usr/local/share/clawdie/nvidia-repo"
}
install_live_npm_globals() {
echo " Installing bundled npm globals into live image..."
if [ ! -d "$NPM_GLOBALS_DIR" ]; then
echo " WARN: ${NPM_GLOBALS_DIR} not found — skipping live npm globals"
return 0
fi
_live_prefix="${MOUNT_POINT}/opt/clawdie/npm-global"
mkdir -p "${_live_prefix}/bin" "${_live_prefix}/lib" "${MOUNT_POINT}/usr/local/bin"
_live_npm_home="${CACHE_DIR}/npm-live-home"
rm -rf "${_live_npm_home}"
mkdir -p "${_live_npm_home}"
for _tgz in "${NPM_GLOBALS_DIR}"/*.tgz; do
[ -f "${_tgz}" ] || continue
# Live bundle policy: ship pi only via npm tarball. codex is covered
# separately via the FreeBSD pkg in pkg-list-live-operator.txt.
case "$(basename "${_tgz}")" in
*claude-code*)
echo " skip $(basename "${_tgz}") (broken on FreeBSD live image)"
continue
;;
*gemini-cli*)
echo " skip $(basename "${_tgz}") (not part of current live CLI policy)"
continue
;;
esac
echo " npm install -g $(basename "${_tgz}")"
env HOME="${_live_npm_home}" npm_config_prefix="${_live_prefix}" npm install -g --ignore-scripts --no-audit --no-fund "${_tgz}" >/dev/null
done
for _bin in "${_live_prefix}/bin"/*; do
[ -e "${_bin}" ] || continue
ln -sf "/opt/clawdie/npm-global/bin/$(basename "${_bin}")" "${MOUNT_POINT}/usr/local/bin/$(basename "${_bin}")"
done
patch_live_pi_footer_hostname "${_live_prefix}"
# npm runs as root on the build host and writes everything as
# root:wheel. configure_live_operator_session() used to do this
# chown but ran BEFORE install_live_npm_globals (caller order at
# bottom of build.sh), so it was chowning two empty directories
# before the package tree was written. Do it here, after the files
# exist, then validate — fail the build if ownership didn't take
# so this regression cannot ship silently again.
if ! chroot "${MOUNT_POINT}" /usr/sbin/pw usershow clawdie >/dev/null 2>&1; then
echo "ERROR: clawdie user missing — cannot chown npm-global tree"
exit 1
fi
chroot "${MOUNT_POINT}" chown -R clawdie:clawdie /opt/clawdie
_npm_owner=$(chroot "${MOUNT_POINT}" /usr/bin/stat -f '%Su:%Sg' /opt/clawdie/npm-global)
if [ "${_npm_owner}" != "clawdie:clawdie" ]; then
echo "ERROR: /opt/clawdie/npm-global ownership is ${_npm_owner}, expected clawdie:clawdie"
exit 1
fi
# Fix pi HTTP/2: the bundled http-dispatcher sets allowH2:false to
# work around a Node 26.0 undici decompression bug. We run Node 24
# with undici 8.3, where H2 works correctly. Enabling HTTP/2 gives
# HPACK header compression and multiplexing for API calls.
_pi_dispatcher="${_live_prefix}/lib/node_modules/@earendil-works/pi-coding-agent/dist/core/http-dispatcher.js"
if [ -f "${_pi_dispatcher}" ]; then
sed -i '' 's/allowH2: false/allowH2: true/' "${_pi_dispatcher}"
echo " pi http-dispatcher: allowH2 → true"
fi
}
patch_live_pi_footer_hostname() {
_npm_prefix="$1"
_patch_script="${SCRIPT_DIR}/firstboot/patch-pi-footer-hostname.js"
_patched=0
if [ ! -f "${_patch_script}" ]; then
echo "ERROR: Pi footer patch script missing: ${_patch_script}"
exit 1
fi
for _footer in \
"${_npm_prefix}/lib/node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/footer.js" \
"${_npm_prefix}/lib/node_modules/@mariozechner/pi-coding-agent/dist/modes/interactive/components/footer.js"
do
[ -f "${_footer}" ] || continue
echo " patch Pi footer hostname: ${_footer}"
node "${_patch_script}" "${_footer}"
node --check "${_footer}"
_patched=1
done
if [ "${_patched}" -ne 1 ]; then
echo "ERROR: Pi footer.js not found under ${_npm_prefix}"
exit 1
fi
}
seed_live_ai_source_repo() {
_repo_src="$1"
_repo_name="$2"
_repo_dest="${MOUNT_POINT}/home/clawdie/ai/${_repo_name}"
if [ ! -d "${_repo_src}" ]; then
echo " Skipping AI source seed ${_repo_name}: ${_repo_src} not found"
return 0
fi
if ! command -v git >/dev/null 2>&1 || ! git -C "${_repo_src}" rev-parse --git-dir >/dev/null 2>&1; then
echo " Skipping AI source seed ${_repo_name}: not a git worktree"
return 0
fi
echo " Seeding AI source snapshot: ${_repo_name}"
rm -rf "${_repo_dest}"
mkdir -p "${_repo_dest}"
git -C "${_repo_src}" archive --format=tar HEAD | tar -C "${_repo_dest}" -xf -
_repo_branch=$(git -C "${_repo_src}" symbolic-ref --short -q HEAD 2>/dev/null || echo detached)
_repo_commit=$(git -C "${_repo_src}" rev-parse HEAD 2>/dev/null || echo unknown)
_repo_origin=$(git -C "${_repo_src}" remote get-url origin 2>/dev/null || echo unknown)
_repo_dirty=false
if ! git -C "${_repo_src}" diff --quiet 2>/dev/null || ! git -C "${_repo_src}" diff --cached --quiet 2>/dev/null; then
_repo_dirty=true
fi
cat > "${_repo_dest}/.clawdie-source.json" <<EOF
{
"name": "$(json_escape "${_repo_name}")",
"source_path": "$(json_escape "${_repo_src}")",
"origin": "$(json_escape "${_repo_origin}")",
"branch": "$(json_escape "${_repo_branch}")",
"commit": "$(json_escape "${_repo_commit}")",
"dirty_at_build": ${_repo_dirty},
"snapshot_note": "git archive of HEAD; uncommitted changes and ignored/private files are not included"
}
EOF
}
install_live_ai_source_snapshots() {
echo " Installing live AI source snapshots..."
resolve_clawdie_ai_repo
resolve_colibri_paths
mkdir -p "${MOUNT_POINT}/home/clawdie/ai"
cat > "${MOUNT_POINT}/home/clawdie/ai/README.txt" <<'EOF'
Clawdie live AI source snapshots
These directories are included so the operator can start a local provider-backed
Pi session from the live XFCE desktop and inspect the shipped source beside the
running system.
No API keys, .env files, SSH private keys, build caches, package caches, tmp/
directories, or uncommitted worktree changes are included. Each snapshot has a
.clawdie-source.json file recording the source remote, branch, commit, and dirty
state at image build time.
EOF
seed_live_ai_source_repo "${SCRIPT_DIR}" "clawdie-iso"
seed_live_ai_source_repo "${_resolved_clawdie_ai_repo}" "clawdie-ai"
seed_live_ai_source_repo "${_resolved_colibri_repo}" "colibri"
chroot "${MOUNT_POINT}" chown -R clawdie:clawdie /home/clawdie/ai
}
configure_live_operator_session() {
echo " Configuring live operator session..."
mkdir -p "${MOUNT_POINT}/usr/local/bin"
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-bootstrap-launch.sh" \
"${MOUNT_POINT}/usr/local/bin/clawdie-bootstrap-launch.sh"
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-noblank-guard.sh" \
"${MOUNT_POINT}/usr/local/bin/clawdie-noblank-guard.sh"
install -m 0755 "${LIVE_SESSION_DIR}/hw-report" \
"${MOUNT_POINT}/usr/local/bin/hw-report"
# The stock FreeBSD memstick starts bsdinstall from /etc/rc.local before
# our graphical live session can own the USB workflow. Preserve a copy for
# debugging, but disable the automatic text installer.
if [ -f "${MOUNT_POINT}/etc/rc.local" ]; then
cp "${MOUNT_POINT}/etc/rc.local" "${MOUNT_POINT}/etc/rc.local.freebsd-installer"
rm -f "${MOUNT_POINT}/etc/rc.local"
fi
# The installer memstick may leave /etc/resolv.conf as a symlink into
# /tmp/bsdinstall_etc. The live image mounts /tmp as tmpfs, so that link
# becomes dangling on boot and DNS fails despite working DHCP/routing.
# Ship a real file, then let DHCP/NetworkMgr replace it at runtime.
if [ -L "${MOUNT_POINT}/etc/resolv.conf" ] && \
readlink "${MOUNT_POINT}/etc/resolv.conf" | grep -q '^/tmp/bsdinstall_etc/'; then
rm -f "${MOUNT_POINT}/etc/resolv.conf"
fi
if [ ! -e "${MOUNT_POINT}/etc/resolv.conf" ]; then
{
echo '# Created by the Clawdie live image build.'
echo '# DHCP/NetworkMgr may replace this file after interfaces come up.'
} > "${MOUNT_POINT}/etc/resolv.conf"
chmod 0644 "${MOUNT_POINT}/etc/resolv.conf"
fi
# FreeBSD packages ship python3.11 but not a bare python3 symlink.
# Scripts that use /usr/bin/env python3 break without this.
if [ -x "${MOUNT_POINT}/usr/local/bin/python3.11" ] && \
[ ! -e "${MOUNT_POINT}/usr/local/bin/python3" ]; then
ln -sf python3.11 "${MOUNT_POINT}/usr/local/bin/python3"
ln -sf python3.11 "${MOUNT_POINT}/usr/local/bin/python"
fi
mkdir -p "${MOUNT_POINT}/usr/local/etc/sddm.conf.d"
install -m 0644 "${LIVE_SESSION_DIR}/sddm.conf" \
"${MOUNT_POINT}/usr/local/etc/sddm.conf.d/50-clawdie-live.conf"
mkdir -p "${MOUNT_POINT}/var/lib/sddm" "${MOUNT_POINT}/var/log"
cat > "${MOUNT_POINT}/var/lib/sddm/state.conf" <<'EOF'
[Last]
Session=clawdie-xfce.desktop
User=clawdie
EOF
chmod 0755 "${MOUNT_POINT}/var/lib/sddm"
chmod 0644 "${MOUNT_POINT}/var/lib/sddm/state.conf"
if chroot "${MOUNT_POINT}" /usr/sbin/pw usershow sddm >/dev/null 2>&1; then
chroot "${MOUNT_POINT}" chown -R sddm:sddm /var/lib/sddm
fi
mkdir -p "${MOUNT_POINT}/usr/local/share/xsessions"
install -m 0644 "${LIVE_SESSION_DIR}/clawdie-xfce.desktop" \
"${MOUNT_POINT}/usr/local/share/xsessions/clawdie-xfce.desktop"
# Strip any Wayland session .desktop files dropped by transitive
# packages. The operator USB has no working Wayland compositor
# (XFCE on FreeBSD is X11-only here), so offering a Wayland entry
# in the SDDM greeter just hands the operator a non-booting session.
if [ -d "${MOUNT_POINT}/usr/local/share/wayland-sessions" ]; then
rm -f "${MOUNT_POINT}/usr/local/share/wayland-sessions/"*.desktop
rmdir "${MOUNT_POINT}/usr/local/share/wayland-sessions" 2>/dev/null || true
fi
mkdir -p "${MOUNT_POINT}/usr/local/etc/xdg/autostart"
install -m 0644 "${LIVE_SESSION_DIR}/autostart/clawdie-bootstrap.desktop" \
"${MOUNT_POINT}/usr/local/etc/xdg/autostart/clawdie-bootstrap.desktop"
install -m 0644 "${LIVE_SESSION_DIR}/autostart/clawdie-noblank-guard.desktop" \
"${MOUNT_POINT}/usr/local/etc/xdg/autostart/clawdie-noblank-guard.desktop"
install -m 0644 "${LIVE_SESSION_DIR}/autostart/volumeicon.desktop" \
"${MOUNT_POINT}/usr/local/etc/xdg/autostart/volumeicon.desktop"
mkdir -p "${MOUNT_POINT}/usr/local/etc/X11/xorg.conf.d"
install -m 0644 "${LIVE_SESSION_DIR}/xorg.conf.d/30-keyboard.conf" \
"${MOUNT_POINT}/usr/local/etc/X11/xorg.conf.d/30-keyboard.conf"
install -m 0644 "${LIVE_SESSION_DIR}/xorg.conf.d/40-clawdie-noblank.conf" \
"${MOUNT_POINT}/usr/local/etc/X11/xorg.conf.d/40-clawdie-noblank.conf"
mkdir -p "${MOUNT_POINT}/usr/local/etc/rc.d"
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-live-gpu" \
"${MOUNT_POINT}/usr/local/etc/rc.d/clawdie_live_gpu"
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-live-wifi" \
"${MOUNT_POINT}/usr/local/etc/rc.d/clawdie_live_wifi"
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-live-seed" \
"${MOUNT_POINT}/usr/local/etc/rc.d/clawdie_live_seed"
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-live-resolver" \
"${MOUNT_POINT}/usr/local/etc/rc.d/clawdie_live_resolver"
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-live-audio" \
"${MOUNT_POINT}/usr/local/etc/rc.d/clawdie_live_audio"
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-live-power" \
"${MOUNT_POINT}/usr/local/etc/rc.d/clawdie_live_power"
mkdir -p "${MOUNT_POINT}/usr/local/etc/polkit-1/rules.d"
install -m 0644 "${LIVE_SESSION_DIR}/49-clawdie-power.rules" \
"${MOUNT_POINT}/usr/local/etc/polkit-1/rules.d/49-clawdie-power.rules"
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-xfce-session" \
"${MOUNT_POINT}/usr/local/bin/clawdie-xfce-session"
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-xfce-session-inner" \
"${MOUNT_POINT}/usr/local/bin/clawdie-xfce-session-inner"
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-live-touchpad-guard" \
"${MOUNT_POINT}/usr/local/bin/clawdie-live-touchpad-guard"
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-startx" \
"${MOUNT_POINT}/usr/local/bin/clawdie-startx"
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-startx" \
"${MOUNT_POINT}/usr/local/bin/clawdie-gui"
for _xfce_xdg_dir in \
"${MOUNT_POINT}/usr/local/etc" \
"${MOUNT_POINT}/usr/local/etc/xdg" \
"${MOUNT_POINT}/usr/local/etc/xdg/xfce4"; do
if [ ! -d "$_xfce_xdg_dir" ]; then
echo "ERROR: XFCE XDG directory missing from live image: ${_xfce_xdg_dir#${MOUNT_POINT}}"
exit 1
fi
chmod 0755 "$_xfce_xdg_dir"
done
if [ -f "${MOUNT_POINT}/usr/local/etc/xdg/xfce4/xinitrc" ]; then
chmod 0755 "${MOUNT_POINT}/usr/local/etc/xdg/xfce4/xinitrc"
else
echo "ERROR: XFCE xinitrc missing from live image"
exit 1
fi
if [ ! -x "${MOUNT_POINT}/usr/local/bin/xinit" ] || [ ! -x "${MOUNT_POINT}/usr/local/bin/startx" ]; then
echo "ERROR: xinit/startx missing from live image"
exit 1
fi
if [ ! -x "${MOUNT_POINT}/usr/local/bin/clawdie-startx" ]; then
echo "ERROR: clawdie-startx rescue launcher missing from live image"
exit 1
fi
if [ ! -x "${MOUNT_POINT}/usr/local/bin/bash" ]; then
echo "ERROR: bash missing from live image"
exit 1
fi
if [ ! -x "${MOUNT_POINT}/usr/local/bin/zsh" ]; then
echo "ERROR: zsh missing from live image"
exit 1
fi
if ! grep -qx '/usr/local/bin/bash' "${MOUNT_POINT}/etc/shells" 2>/dev/null; then
printf '%s\n' '/usr/local/bin/bash' >> "${MOUNT_POINT}/etc/shells"
fi
if ! grep -qx '/usr/local/bin/zsh' "${MOUNT_POINT}/etc/shells" 2>/dev/null; then
printf '%s\n' '/usr/local/bin/zsh' >> "${MOUNT_POINT}/etc/shells"
fi
if ! /usr/sbin/pw -R "$MOUNT_POINT" usershow clawdie >/dev/null 2>&1; then
/usr/sbin/pw -R "$MOUNT_POINT" useradd clawdie \
-m \
-s /usr/local/bin/bash \
-c "Clawdie Operator"
fi
/usr/sbin/pw -R "$MOUNT_POINT" usermod clawdie -s /usr/local/bin/bash
/usr/sbin/pw -R "$MOUNT_POINT" groupmod wheel -m clawdie
if /usr/sbin/pw -R "$MOUNT_POINT" groupshow video >/dev/null 2>&1; then
/usr/sbin/pw -R "$MOUNT_POINT" groupmod video -m clawdie
fi
CLAWDIE_UID="$(/usr/sbin/pw -R "$MOUNT_POINT" usershow clawdie | cut -d: -f3)"
# NOTE: chown of /opt/clawdie/npm-global was moved into
# install_live_npm_globals (called later in the build). The previous
# chown here ran before npm globals were installed and silently
# chowned two empty directories, leaving the package tree root:wheel
# at runtime. Do not re-introduce a chown here.
if [ -n "${CLAWDIE_USER_PASSWORD:-}" ]; then
printf '%s\n' "${CLAWDIE_USER_PASSWORD}" | /usr/sbin/pw -R "$MOUNT_POINT" usermod clawdie -h 0
fi
# Do not force vendor-specific DRM firmware or KMS drivers through
# loader.conf. The live image must boot Intel, AMD, VMware, NVIDIA and
# fallback framebuffer hosts from the same rootfs. GPU KMS is selected at
# boot by /usr/local/etc/rc.d/clawdie_live_gpu after inspecting display PCI
# vendor IDs.
# Live root is on USB. Avoid USB autosuspend/resume surprises while the
# root filesystem is attached to the same bus.
set_config_line "${MOUNT_POINT}/boot/loader.conf" 'hw.usb.no_suspend="1"'
# The stock installer memstick brands the loader menu as "Installer".
# This artifact is an operator live USB, so use generic FreeBSD branding
# plus an explicit Clawdie live prompt.
set_config_line "${MOUNT_POINT}/boot/loader.conf" 'loader_brand="orb"'
set_config_line "${MOUNT_POINT}/boot/loader.conf" 'loader_menu_title="Clawdie Operator USB"'
set_config_line "${MOUNT_POINT}/boot/loader.conf" 'loader_menu_multi_user_prompt="Start Clawdie Operator USB"'
# Enable FreeBSD mac_do for kernel-enforced privilege escalation.
# Base system only — no sudo package needed. Wheel group members can
# become root via mdo(1). mdo -u root changes the primary gid to wheel,
# so the target rule must explicitly allow gid=0 as well as uid=0.
set_config_line "${MOUNT_POINT}/boot/loader.conf" 'mac_do_load="YES"'
set_config_line "${MOUNT_POINT}/etc/sysctl.conf" 'security.mac.do.rules=gid=0>uid=0,gid=0,+gid=*'
if ! grep -q '^\[clawdie_live=' "${MOUNT_POINT}/etc/devfs.rules" 2>/dev/null; then
cat >> "${MOUNT_POINT}/etc/devfs.rules" <<'EOF'
[clawdie_live=10]
add path 'dri' mode 0755 group video
add path 'dri/*' mode 0660 group video
add path 'drm/*' mode 0660 group video
EOF
fi
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'devfs_system_ruleset="clawdie_live"'
_nm_autostart="${MOUNT_POINT}/usr/local/etc/xdg/autostart/networkmgr.desktop"
if [ ! -f "$_nm_autostart" ]; then
echo "ERROR: networkmgr.desktop missing from live image"
exit 1
fi
if /usr/local/sbin/pkg -r "$MOUNT_POINT" info -e sudo 2>/dev/null; then
echo "ERROR: sudo is installed in the live image"
exit 1
fi
if [ -e "${MOUNT_POINT}/usr/local/etc/sudoers.d/networkmgr" ]; then
echo "ERROR: NetworkMgr sudoers policy is still present in the live image"
exit 1
fi
if ! grep -q 'Exec=mdo -u root networkmgr' "$_nm_autostart"; then
echo "ERROR: networkmgr.desktop does not use mdo -u root networkmgr"
exit 1
fi
if /usr/local/sbin/pkg -r "$MOUNT_POINT" info -d networkmgr 2>/dev/null | grep -q '^.*sudo-'; then
echo "ERROR: networkmgr still depends on sudo in the live image"
exit 1
fi
mkdir -p "${MOUNT_POINT}/home/clawdie" "${MOUNT_POINT}/etc/profile.d" "${MOUNT_POINT}/etc/skel"
cat > "${MOUNT_POINT}/etc/profile.d/clawdie.sh" <<'EOF'
# Clawdie live operator environment.
_clawdie_npm_prefix="/opt/clawdie/npm-global"
_clawdie_base_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
_clawdie_path_has() {
case ":${PATH:-}:" in
*:"$1":*) return 0 ;;
*) return 1 ;;
esac
}
export npm_config_prefix="${_clawdie_npm_prefix}"
export NPM_CONFIG_PREFIX="${_clawdie_npm_prefix}"
export NPM_CONFIG_UPDATE_NOTIFIER=false
export NO_UPDATE_NOTIFIER=1
export LANG="${LANG:-en_US.UTF-8}"
export LC_ALL="${LC_ALL:-en_US.UTF-8}"
if [ -z "${PATH:-}" ]; then
PATH="${_clawdie_base_path}"
else
for _clawdie_dir in /usr/local/sbin /usr/local/bin /usr/sbin /usr/bin /sbin /bin; do
_clawdie_path_has "${_clawdie_dir}" || PATH="${PATH}:${_clawdie_dir}"
done
fi
_clawdie_path_has "${_clawdie_npm_prefix}/bin" || PATH="${_clawdie_npm_prefix}/bin:${PATH}"
export PATH
# Colibri daemon socket — needed for 'colibri' CLI without --socket
export COLIBRI_DAEMON_SOCKET="/var/run/colibri/colibri.sock"
unset _clawdie_npm_prefix
unset _clawdie_base_path
unset -f _clawdie_path_has 2>/dev/null || true
EOF
chmod 0644 "${MOUNT_POINT}/etc/profile.d/clawdie.sh"
cat > "${MOUNT_POINT}/home/clawdie/.profile" <<'EOF'
# Clawdie operator POSIX shell profile.
[ -r /etc/profile.d/clawdie.sh ] && . /etc/profile.d/clawdie.sh
EOF
cat > "${MOUNT_POINT}/home/clawdie/.bash_profile" <<'EOF'
# Clawdie operator bash login profile.
[ -r /etc/profile ] && . /etc/profile
[ -L "${HOME}/.cache" ] && {
mkdir -p /tmp/clawdie/cache 2>/dev/null || true
chown "$(id -u):$(id -g)" /tmp/clawdie /tmp/clawdie/cache 2>/dev/null || true
chmod 0700 /tmp/clawdie /tmp/clawdie/cache 2>/dev/null || true
}
[ -r "${HOME}/.bashrc" ] && . "${HOME}/.bashrc"
EOF
cat > "${MOUNT_POINT}/home/clawdie/.bashrc" <<'EOF'
# Clawdie operator interactive bash profile.
[ -r /etc/profile.d/clawdie.sh ] && . /etc/profile.d/clawdie.sh
if [ -n "${PS1:-}" ]; then
export HISTFILE="${HISTFILE:-/tmp/clawdie/bash_history}"
mkdir -p /tmp/clawdie 2>/dev/null || true
fi
EOF
cat > "${MOUNT_POINT}/home/clawdie/.zprofile" <<'EOF'
# Clawdie operator zsh login profile.
[ -r /etc/profile ] && . /etc/profile
[ -L "${HOME}/.cache" ] && {
mkdir -p /tmp/clawdie/cache 2>/dev/null || true
chown "$(id -u):$(id -g)" /tmp/clawdie /tmp/clawdie/cache 2>/dev/null || true
chmod 0700 /tmp/clawdie /tmp/clawdie/cache 2>/dev/null || true
}
[ -r /etc/profile.d/clawdie.sh ] && . /etc/profile.d/clawdie.sh
[ -r "${HOME}/.zshrc" ] && . "${HOME}/.zshrc"
EOF
cat > "${MOUNT_POINT}/home/clawdie/.zshrc" <<'EOF'
# Clawdie operator interactive zsh profile.
[ -r /etc/profile.d/clawdie.sh ] && . /etc/profile.d/clawdie.sh
export HISTFILE="${HISTFILE:-/tmp/clawdie/zsh_history}"
mkdir -p /tmp/clawdie 2>/dev/null || true
# Keep zsh optional, but ready: use packaged oh-my-zsh when present.
if [ -d /usr/local/share/ohmyzsh ]; then
export ZSH="/usr/local/share/ohmyzsh"
elif [ -d /usr/local/share/oh-my-zsh ]; then
export ZSH="/usr/local/share/oh-my-zsh"
fi
if [ -n "${ZSH:-}" ] && [ -r "${ZSH}/oh-my-zsh.sh" ]; then
ZSH_THEME="${ZSH_THEME:-robbyrussell}"
plugins=(git)
source "${ZSH}/oh-my-zsh.sh"
fi
EOF
cat > "${MOUNT_POINT}/home/clawdie/.tmux.conf" <<'EOF'
# Clawdie operator tmux defaults.
# Pi uses modified Enter/key chords; tmux must pass CSI-u extended keys or Pi
# warns that modified Enter keys may not work.
set -g extended-keys on
set -g extended-keys-format csi-u
set -g escape-time 10
set -g mouse on
set -g base-index 1
setw -g pane-base-index 1
set -g renumber-windows on
EOF
mkdir -p "${MOUNT_POINT}/usr/local/etc"
cp "${MOUNT_POINT}/home/clawdie/.tmux.conf" "${MOUNT_POINT}/usr/local/etc/tmux.conf"
cp "${MOUNT_POINT}/home/clawdie/.profile" "${MOUNT_POINT}/etc/skel/.profile"
cp "${MOUNT_POINT}/home/clawdie/.bash_profile" "${MOUNT_POINT}/etc/skel/.bash_profile"
cp "${MOUNT_POINT}/home/clawdie/.bashrc" "${MOUNT_POINT}/etc/skel/.bashrc"
cp "${MOUNT_POINT}/home/clawdie/.zprofile" "${MOUNT_POINT}/etc/skel/.zprofile"
cp "${MOUNT_POINT}/home/clawdie/.zshrc" "${MOUNT_POINT}/etc/skel/.zshrc"
cp "${MOUNT_POINT}/home/clawdie/.tmux.conf" "${MOUNT_POINT}/etc/skel/.tmux.conf"
chmod 0644 \
"${MOUNT_POINT}/home/clawdie/.profile" \
"${MOUNT_POINT}/home/clawdie/.bash_profile" \
"${MOUNT_POINT}/home/clawdie/.bashrc" \
"${MOUNT_POINT}/home/clawdie/.zprofile" \
"${MOUNT_POINT}/home/clawdie/.zshrc" \
"${MOUNT_POINT}/home/clawdie/.tmux.conf" \
"${MOUNT_POINT}/usr/local/etc/tmux.conf" \
"${MOUNT_POINT}/etc/skel/.profile" \
"${MOUNT_POINT}/etc/skel/.bash_profile" \
"${MOUNT_POINT}/etc/skel/.bashrc" \
"${MOUNT_POINT}/etc/skel/.zprofile" \
"${MOUNT_POINT}/etc/skel/.zshrc" \
"${MOUNT_POINT}/etc/skel/.tmux.conf"
install -m 0755 "${LIVE_SESSION_DIR}/xprofile" \
"${MOUNT_POINT}/home/clawdie/.xprofile"
cat > "${MOUNT_POINT}/home/clawdie/.xinitrc" <<'EOF'
#!/bin/sh
exec /usr/local/bin/clawdie-xfce-session
EOF
mkdir -p "${MOUNT_POINT}/home/clawdie/.config/xfce4" "${MOUNT_POINT}/etc/skel/.config/xfce4"
cp "${MOUNT_POINT}/home/clawdie/.xinitrc" "${MOUNT_POINT}/home/clawdie/.config/xfce4/xinitrc"
cp "${MOUNT_POINT}/home/clawdie/.xinitrc" "${MOUNT_POINT}/etc/skel/.xinitrc"
cp "${MOUNT_POINT}/home/clawdie/.xinitrc" "${MOUNT_POINT}/etc/skel/.config/xfce4/xinitrc"
chmod 0755 \
"${MOUNT_POINT}/home/clawdie/.xinitrc" \
"${MOUNT_POINT}/home/clawdie/.config/xfce4/xinitrc" \
"${MOUNT_POINT}/etc/skel/.xinitrc" \
"${MOUNT_POINT}/etc/skel/.config/xfce4/xinitrc"
if [ -f "${MOUNT_POINT}/usr/local/etc/xdg/tumbler/tumbler.rc" ]; then
mkdir -p "${MOUNT_POINT}/home/clawdie/.config/tumbler" "${MOUNT_POINT}/etc/skel/.config/tumbler"
install -m 0644 "${MOUNT_POINT}/usr/local/etc/xdg/tumbler/tumbler.rc" \
"${MOUNT_POINT}/home/clawdie/.config/tumbler/tumbler.rc"
install -m 0644 "${MOUNT_POINT}/usr/local/etc/xdg/tumbler/tumbler.rc" \
"${MOUNT_POINT}/etc/skel/.config/tumbler/tumbler.rc"
fi
chroot "$MOUNT_POINT" chown -R clawdie:clawdie /home/clawdie
mkdir -p "${MOUNT_POINT}/usr/local/share/applications"
install -m 0644 "${LIVE_SESSION_DIR}/clawdie-bootstrap.desktop" \
"${MOUNT_POINT}/usr/local/share/applications/Clawdie Bootstrap.desktop"
install -m 0644 "${LIVE_SESSION_DIR}/hw-report.desktop" \
"${MOUNT_POINT}/usr/local/share/applications/Clawdie Hardware Report.desktop"
mkdir -p "${MOUNT_POINT}/home/clawdie/Desktop"
install -m 0644 "${LIVE_SESSION_DIR}/clawdie-bootstrap.desktop" \
"${MOUNT_POINT}/home/clawdie/Desktop/Clawdie Bootstrap.desktop"
install -m 0644 "${LIVE_SESSION_DIR}/hw-report.desktop" \
"${MOUNT_POINT}/home/clawdie/Desktop/Clawdie Hardware Report.desktop"
mkdir -p "${MOUNT_POINT}/usr/local/share/clawdie-iso/bootstrap"
install -m 0644 "${LIVE_SESSION_DIR}/bootstrap.html" \
"${MOUNT_POINT}/usr/local/share/clawdie-iso/bootstrap/index.html"
if [ -d "${LIVE_SESSION_DIR}/mcp-examples" ]; then
mkdir -p "${MOUNT_POINT}/usr/local/share/clawdie-iso/mcp-examples"
cp -R "${LIVE_SESSION_DIR}/mcp-examples/." \
"${MOUNT_POINT}/usr/local/share/clawdie-iso/mcp-examples/"
find "${MOUNT_POINT}/usr/local/share/clawdie-iso/mcp-examples" \
-type f -exec chmod 0644 {} +
fi
chroot "$MOUNT_POINT" chown -R clawdie:clawdie /home/clawdie/Desktop
chmod 0755 "${MOUNT_POINT}/home/clawdie/Desktop"
chmod 0644 \
"${MOUNT_POINT}/home/clawdie/Desktop/Clawdie Bootstrap.desktop" \
"${MOUNT_POINT}/home/clawdie/Desktop/Clawdie Hardware Report.desktop"
install_live_ai_source_snapshots
# The stock FreeBSD install memstick is intentionally read-only. This live
# operator USB needs a writable root so SDDM, Xorg, NetworkMgr, logs, and
# operator diagnostics can create runtime state on the flashed stick.
set_fstab_line "${MOUNT_POINT}/etc/fstab" '^[[:space:]]*/dev/ufs/FreeBSD_Install[[:space:]]+/[[:space:]]+ufs[[:space:]]' '/dev/ufs/FreeBSD_Install / ufs rw,noatime 1 1'
if ! grep -Eq '^[[:space:]]*/dev/ufs/FreeBSD_Install[[:space:]]+/[[:space:]]+ufs[[:space:]]+([^[:space:]]*,)?rw(,|[[:space:]])' "${MOUNT_POINT}/etc/fstab"; then
echo "ERROR: live USB root filesystem is not configured read-write in /etc/fstab"
exit 1
fi
ensure_fstab_line "${MOUNT_POINT}/etc/fstab" '^[[:space:]]*proc[[:space:]]+/proc[[:space:]]+procfs[[:space:]]' 'proc /proc procfs rw 0 0'
ensure_fstab_line "${MOUNT_POINT}/etc/fstab" '^[[:space:]]*tmpfs[[:space:]]+/tmp[[:space:]]+tmpfs[[:space:]]' 'tmpfs /tmp tmpfs rw,mode=1777 0 0'
ensure_fstab_line "${MOUNT_POINT}/etc/fstab" '^[[:space:]]*tmpfs[[:space:]]+/var/log[[:space:]]+tmpfs[[:space:]]' 'tmpfs /var/log tmpfs rw,mode=755 0 0'
# No swap on the live USB. USB write wear, unpredictable behaviour, and
# it would defeat the tmpfs work above (under memory pressure the kernel
# would page tmpfs contents to swap, putting writes back on the stick).
# On an 8 GB RAM baseline, "fail visibly under memory pressure" is the
# right behaviour, not "silently grind the USB". Strip any inherited
# swap entries and assert clean.
sed -i '' '/^[[:space:]]*[^#].*[[:space:]]swap[[:space:]]/d' "${MOUNT_POINT}/etc/fstab"
if grep -qE '^[[:space:]]*[^#].*[[:space:]]swap[[:space:]]' "${MOUNT_POINT}/etc/fstab"; then
echo "ERROR: swap entry remains in live image /etc/fstab"
exit 1
fi
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'root_rw_mount="YES"'
# FreeBSD default dumpdev_enable="AUTO" scans for swap on boot to use as
# the kernel crash-dump device. With no swap and no desire to crash-dump
# to the USB anyway, set NO explicitly so the scan never runs.
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'dumpdev_enable="NO"'
# The stock installer memstick overlays /tmp and /var with tiny tmpfs
# filesystems. That hides image content such as /var/lib/xkb and leaves
# too little runtime space for a graphical desktop. The operator USB has a
# writable root filesystem, so keep /tmp and /var on the root UFS image.
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'tmpmfs="NO"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'varmfs="NO"'
if ! grep -Eq '^root_rw_mount="YES"' "${MOUNT_POINT}/etc/rc.conf"; then
echo "ERROR: live USB root_rw_mount is not enabled in /etc/rc.conf"
exit 1
fi
if ! grep -Eq '^tmpmfs="NO"' "${MOUNT_POINT}/etc/rc.conf" || ! grep -Eq '^varmfs="NO"' "${MOUNT_POINT}/etc/rc.conf"; then
echo "ERROR: live USB tmpmfs/varmfs overlays are not disabled in /etc/rc.conf"
exit 1
fi
# Keep a real hostname so startx/xauth do not derive invalid display names
# such as bare :0 or /unix:0 on the installer-derived "Amnesiac" profile.
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'hostname="usb.clawdie.home.arpa"'
if ! grep -Eq '^hostname="usb.clawdie.home.arpa"' "${MOUNT_POINT}/etc/rc.conf"; then
echo "ERROR: live USB hostname is not configured in /etc/rc.conf"
exit 1
fi
ensure_fstab_line "${MOUNT_POINT}/etc/hosts" '^[[:space:]]*127\.0\.0\.1[[:space:]].*clawdie-live' '127.0.0.1 clawdie-live'
ensure_fstab_line "${MOUNT_POINT}/etc/hosts" '^[[:space:]]*::1[[:space:]].*clawdie-live' '::1 clawdie-live'
configure_nsswitch_hosts_line "${MOUNT_POINT}/etc/nsswitch.conf"
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'dbus_enable="YES"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'sshd_enable="YES"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'tailscaled_enable="YES"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'avahi_daemon_enable="YES"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'sddm_enable="YES"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'display_manager="sddm"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_live_gpu_enable="YES"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_live_gpu_mode="auto"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_live_gpu_nvidia_branch=""'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_live_wifi_enable="YES"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_live_seed_enable="YES"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_live_resolver_enable="YES"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_live_audio_enable="YES"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_live_power_enable="YES"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'webcamd_enable="YES"'
# CPU power management. powerdxx is a drop-in replacement for base
# powerd with moving-average load sampling and better multi-core
# behavior (matters on Ryzen 7 5700U-class chips). Do NOT also set
# powerd_enable — both daemons use the same pidfile and one would
# fail to start. Base powerd defaults to NO when unset, so leaving
# it unset is the right way to keep it off.
# The bigger battery lever is the C-state pair. Use C3 rather than Cmax
# on the live USB because root is USB-backed; deeper C6/C7-style states
# can expose USB controller resume bugs on some Ryzen mobile systems.
# Installed systems can choose Cmax once root is on internal storage.
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'powerdxx_enable="YES"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'powerdxx_flags="-a hiadaptive -b adaptive -n adaptive"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'performance_cx_lowest="C3"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'economy_cx_lowest="C3"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'linux_enable="YES"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'zfs_enable="YES"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'pf_enable="YES"'
# GPU KMS is selected at boot by /usr/local/etc/rc.d/clawdie_live_gpu so
# the live USB does not force the Intel driver on non-Intel hardware.
# kld_list breakdown:
# linux / linux64 — Linuxulator binary compatibility.
# zfs — runtime ZFS support (pool import on demand).
# cuse — userspace character devices; required by webcamd.
# hidbus / iichid — HID-over-I²C bridge for modern touchpads / keyboards.
# hms / hmt / hkbd — HID mouse / multitouch / keyboard class drivers.
# acpi_video — backlight control sysctls (hw.acpi.video.lcd0.brightness).
append_rc_list_values "${MOUNT_POINT}/etc/rc.conf" kld_list \
linux linux64 zfs \
cuse \
hidbus iichid hms hmt hkbd \
acpi_video
case "${GPU_DRIVER:-}" in
nvidia-390|nvidia-470|nvidia-590)
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_live_gpu_mode="nvidia"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" "clawdie_live_gpu_nvidia_branch=\"${GPU_DRIVER#nvidia-}\""
append_rc_list_values "${MOUNT_POINT}/etc/rc.conf" kld_list \
nvidia-modeset nvidia
;;
esac
# Universal NVIDIA lane: no branch is baked; clawdie_live_gpu installs the
# matching one from the on-image repo at boot. Overrides the mode set above.
# kld_list intentionally does NOT preload nvidia (it is not installed yet at
# first boot); the rc.d service kldloads it after install.
if [ "${NVIDIA_UNIVERSAL:-NO}" = "YES" ]; then
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_live_gpu_mode="nvidia-auto"'
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_live_gpu_nvidia_branch=""'
fi
mkdir -p "${MOUNT_POINT}/etc/ssh/sshd_config.d"
install -m 0644 "${LIVE_SESSION_DIR}/sshd-live.conf" \
"${MOUNT_POINT}/etc/ssh/sshd_config.d/clawdie-live.conf"
ensure_sshd_include_line "${MOUNT_POINT}/etc/ssh/sshd_config"
rm -rf "${MOUNT_POINT}/home/clawdie/.ssh"
if [ -n "${SSH_PUBLIC_KEY:-}" ]; then
mkdir -p "${MOUNT_POINT}/home/clawdie/.ssh"
chmod 0700 "${MOUNT_POINT}/home/clawdie/.ssh"
printf '%s\n' "${SSH_PUBLIC_KEY}" > "${MOUNT_POINT}/home/clawdie/.ssh/authorized_keys"
chmod 0600 "${MOUNT_POINT}/home/clawdie/.ssh/authorized_keys"
chroot "${MOUNT_POINT}" chown -R clawdie:clawdie /home/clawdie/.ssh
fi
if grep -q '^sshd_enable="YES"' "${MOUNT_POINT}/etc/rc.conf"; then
_drop_in="${MOUNT_POINT}/etc/ssh/sshd_config.d/clawdie-live.conf"
if [ ! -s "$_drop_in" ]; then
echo "ERROR: sshd_enable=YES but ${_drop_in#${MOUNT_POINT}} missing or empty"
exit 1
fi
if ! grep -qE '^PasswordAuthentication[[:space:]]+no' "$_drop_in" \
|| ! grep -qE '^PermitRootLogin[[:space:]]+no' "$_drop_in" \
|| ! grep -qE '^KbdInteractiveAuthentication[[:space:]]+no' "$_drop_in"; then
echo "ERROR: sshd drop-in does not disable password, keyboard-interactive, and root login"
exit 1
fi
if ! grep -Eq '^[[:space:]]*Include[[:space:]]+/etc/ssh/sshd_config\.d/\*\.conf([[:space:]]|$)' "${MOUNT_POINT}/etc/ssh/sshd_config"; then
echo "ERROR: sshd_config does not include /etc/ssh/sshd_config.d/*.conf"
exit 1
fi
fi
install -m 0644 "${LIVE_SESSION_DIR}/pf-live.conf" "${MOUNT_POINT}/etc/pf.conf"
if grep -q '^[^#].*\<log\>' "${MOUNT_POINT}/etc/pf.conf"; then
echo "ERROR: live PF ruleset unexpectedly enables logging"
exit 1
fi
install -m 0755 "${LIVE_SESSION_DIR}/clawdie-tailscale-up" \
"${MOUNT_POINT}/usr/local/etc/rc.d/clawdie_tailscale_up"
rm -f "${MOUNT_POINT}/var/lib/clawdie-iso/tailscale-authkey"
if [ -n "${TAILSCALE_AUTHKEY:-}" ]; then
mkdir -p "${MOUNT_POINT}/var/lib/clawdie-iso"
printf '%s\n' "${TAILSCALE_AUTHKEY}" > "${MOUNT_POINT}/var/lib/clawdie-iso/tailscale-authkey"
chmod 0600 "${MOUNT_POINT}/var/lib/clawdie-iso/tailscale-authkey"
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_tailscale_up_enable="YES"'
else
set_config_line "${MOUNT_POINT}/etc/rc.conf" 'clawdie_tailscale_up_enable="NO"'
fi
# Keep ZFS tools/modules ready on the live USB, but do not let a copied or
# stale zpool cache auto-import disks from the machine being rescued. The
# operator can still import pools explicitly with mdo when needed.
mkdir -p "${MOUNT_POINT}/etc/zfs" "${MOUNT_POINT}/boot/zfs"
rm -f "${MOUNT_POINT}/etc/zfs/zpool.cache" "${MOUNT_POINT}/boot/zfs/zpool.cache"
# Xorg's XKB compiler must be able to chdir into the XKB root and write a
# compiled keymap cache. The stock memstick/rootless-X path is unforgiving:
# if /tmp, /var/tmp, /var/lib/xkb, or any XKB parent directory is missing or
# not traversable, xkbcomp reports a misleading missing keycodes file.
mkdir -p "${MOUNT_POINT}/tmp" "${MOUNT_POINT}/var/tmp" "${MOUNT_POINT}/var/lib/xkb"
chmod 1777 "${MOUNT_POINT}/tmp" "${MOUNT_POINT}/var/tmp" "${MOUNT_POINT}/var/lib/xkb"
mkdir -p "${MOUNT_POINT}/var/run/user" "${MOUNT_POINT}/var/run/user/${CLAWDIE_UID}"
chroot "${MOUNT_POINT}" chown clawdie:clawdie "/var/run/user/${CLAWDIE_UID}"
chmod 0755 "${MOUNT_POINT}/var/run/user"
chmod 0700 "${MOUNT_POINT}/var/run/user/${CLAWDIE_UID}"
for _xkb_dir in \
"${MOUNT_POINT}/usr" \
"${MOUNT_POINT}/usr/local" \
"${MOUNT_POINT}/usr/local/share" \
"${MOUNT_POINT}/usr/local/share/X11" \
"${MOUNT_POINT}/usr/local/share/X11/xkb" \
"${MOUNT_POINT}/usr/local/share/X11/xkb/keycodes" \
"${MOUNT_POINT}/usr/local/share/X11/xkb/rules" \
"${MOUNT_POINT}/usr/local/share/X11/xkb/symbols" \
"${MOUNT_POINT}/usr/local/share/X11/xkb/types" \
"${MOUNT_POINT}/usr/local/share/X11/xkb/compat"; do
if [ ! -d "$_xkb_dir" ]; then
echo "ERROR: XKB directory missing from live image: ${_xkb_dir#${MOUNT_POINT}}"
exit 1
fi
chmod 0755 "$_xkb_dir"
done
if [ ! -f "${MOUNT_POINT}/usr/local/share/X11/xkb/keycodes/xfree86" ]; then
echo "ERROR: XKB keycodes missing from live image"
exit 1
fi
if [ ! -x "${MOUNT_POINT}/usr/local/bin/xkbcomp" ]; then
echo "ERROR: xkbcomp missing from live image"
exit 1
fi
_xkb_test="${CACHE_DIR}/xkb-default-test.xkb"
_xkb_out="${CACHE_DIR}/xkb-default-test.xkm"
cat > "$_xkb_test" <<'EOF'
xkb_keymap {
xkb_keycodes { include "xfree86+aliases(qwerty)" };
xkb_types { include "complete" };
xkb_compat { include "complete" };
xkb_symbols { include "pc+us+inet(evdev)" };
xkb_geometry { include "pc(pc105)" };
};
EOF
if ! "${MOUNT_POINT}/usr/local/bin/xkbcomp" -w 0 -R"${MOUNT_POINT}/usr/local/share/X11/xkb" "$_xkb_test" "$_xkb_out" >/dev/null 2>&1; then
echo "ERROR: XKB default keymap does not compile against the live image xkeyboard-config"
exit 1
fi
rm -f "$_xkb_test" "$_xkb_out"
_xkb_si_test="${CACHE_DIR}/xkb-si-test.xkb"
_xkb_si_out="${CACHE_DIR}/xkb-si-test.xkm"
cat > "$_xkb_si_test" <<'EOF'
xkb_keymap {
xkb_keycodes { include "xfree86+aliases(qwerty)" };
xkb_types { include "complete" };
xkb_compat { include "complete" };
xkb_symbols { include "pc+si+inet(evdev)" };
xkb_geometry { include "pc(pc105)" };
};
EOF
if ! "${MOUNT_POINT}/usr/local/bin/xkbcomp" -w 0 -R"${MOUNT_POINT}/usr/local/share/X11/xkb" "$_xkb_si_test" "$_xkb_si_out" >/dev/null 2>&1; then
echo "ERROR: Slovenian (si) XKB keymap does not compile — missing xkeyboard-config layout?"
exit 1
fi
rm -f "$_xkb_si_test" "$_xkb_si_out"
# Preseed XFCE panel layout into /etc/skel so the clawdie user gets it
# on first login. pw useradd -m copies /etc/skel into /home/clawdie, but
# the user was already created above, so copy directly into both locations.
PANEL_SKEL="${LIVE_SESSION_DIR}/panel-skel"
if [ -d "$PANEL_SKEL" ]; then
echo " Seeding XFCE panel layout..."
mkdir -p "${MOUNT_POINT}/usr/local/share/clawdie-iso/panel-skel"
cp -R "${PANEL_SKEL}/." "${MOUNT_POINT}/usr/local/share/clawdie-iso/panel-skel/"
mkdir -p "${MOUNT_POINT}/usr/local/etc/xdg/xfce4/xfconf/xfce-perchannel-xml"
cp "${PANEL_SKEL}/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-panel.xml" \
"${MOUNT_POINT}/usr/local/etc/xdg/xfce4/xfconf/xfce-perchannel-xml/xfce4-panel.xml"
for _xfconf_xml in xsettings.xml xfwm4.xml xfce4-desktop.xml xfce4-power-manager.xml displays.xml; do
if [ -f "${PANEL_SKEL}/.config/xfce4/xfconf/xfce-perchannel-xml/${_xfconf_xml}" ]; then
cp "${PANEL_SKEL}/.config/xfce4/xfconf/xfce-perchannel-xml/${_xfconf_xml}" \
"${MOUNT_POINT}/usr/local/etc/xdg/xfce4/xfconf/xfce-perchannel-xml/${_xfconf_xml}"
fi
done
# Install into /etc/skel for any future users
_skel_etc="${MOUNT_POINT}/etc/skel"
mkdir -p "${_skel_etc}/.config"
cp -R "${PANEL_SKEL}/.config/." "${_skel_etc}/.config/"
if [ -d "${PANEL_SKEL}/.local/share/applications" ]; then
mkdir -p "${_skel_etc}/.local/share/applications"
cp "${PANEL_SKEL}/.local/share/applications/mimeapps.list" \
"${_skel_etc}/.local/share/applications/" 2>/dev/null || true
fi
# Also copy directly into clawdie's home (user already exists)
mkdir -p "${MOUNT_POINT}/home/clawdie/.config"
cp -R "${PANEL_SKEL}/.config/." "${MOUNT_POINT}/home/clawdie/.config/"
if [ -d "${PANEL_SKEL}/.local/share/applications" ]; then
mkdir -p "${MOUNT_POINT}/home/clawdie/.local/share/applications"
cp "${PANEL_SKEL}/.local/share/applications/mimeapps.list" \
"${MOUNT_POINT}/home/clawdie/.local/share/applications/" 2>/dev/null || true
fi
chroot "$MOUNT_POINT" chown -R clawdie:clawdie /home/clawdie/.config
chroot "$MOUNT_POINT" chown -R clawdie:clawdie /home/clawdie/.local
chroot "$MOUNT_POINT" chown -R root:wheel /etc/skel
fi
_wallpapers_dir="${LIVE_SESSION_DIR}/wallpapers"
if [ -d "$_wallpapers_dir" ]; then
echo " Installing wallpapers..."
mkdir -p "${MOUNT_POINT}/usr/local/share/clawdie-iso/wallpapers"
cp "${_wallpapers_dir}"/* "${MOUNT_POINT}/usr/local/share/clawdie-iso/wallpapers/" 2>/dev/null || true
fi
# Brand icons (e.g. the Whisker Start-button triangle). Install both the
# raw files for direct diagnostics and hicolor theme names for XFCE plugins
# that do not reliably load absolute icon paths.
_icons_dir="${LIVE_SESSION_DIR}/icons"
if [ -d "$_icons_dir" ]; then
echo " Installing brand icons..."
mkdir -p \
"${MOUNT_POINT}/usr/local/share/clawdie-iso/icons" \
"${MOUNT_POINT}/usr/local/share/icons/hicolor/48x48/apps" \
"${MOUNT_POINT}/usr/local/share/icons/hicolor/64x64/apps" \
"${MOUNT_POINT}/usr/local/share/icons/hicolor/scalable/apps"
cp "${_icons_dir}"/* "${MOUNT_POINT}/usr/local/share/clawdie-iso/icons/" 2>/dev/null || true
if [ -f "${_icons_dir}/clawdie-start-48.png" ]; then
install -m 0644 "${_icons_dir}/clawdie-start-48.png" \
"${MOUNT_POINT}/usr/local/share/icons/hicolor/48x48/apps/clawdie-start.png"
fi
if [ -f "${_icons_dir}/clawdie-start.png" ]; then
install -m 0644 "${_icons_dir}/clawdie-start.png" \
"${MOUNT_POINT}/usr/local/share/icons/hicolor/64x64/apps/clawdie-start.png"
fi
if [ -f "${_icons_dir}/clawdie-start.svg" ]; then
install -m 0644 "${_icons_dir}/clawdie-start.svg" \
"${MOUNT_POINT}/usr/local/share/icons/hicolor/scalable/apps/clawdie-start.svg"
fi
if [ -x "${MOUNT_POINT}/usr/local/bin/gtk-update-icon-cache" ]; then
run_live_chroot /usr/local/bin/gtk-update-icon-cache -f -t /usr/local/share/icons/hicolor || true
fi
fi
rm -rf "${MOUNT_POINT}/home/clawdie/.cache" "${MOUNT_POINT}/etc/skel/.cache"
ln -s /tmp/clawdie/cache "${MOUNT_POINT}/home/clawdie/.cache"
ln -s /tmp/clawdie/cache "${MOUNT_POINT}/etc/skel/.cache"
chroot "${MOUNT_POINT}" chown -h clawdie:clawdie /home/clawdie/.cache
}
preflight_colibri_artifacts
preflight_zot_artifacts
# --- step 1: fetch FreeBSD memstick ---
MEMSTICK="${CACHE_DIR}/FreeBSD-${FREEBSD_VERSION}-${FREEBSD_ARCH}-memstick.img"
if [ "$SKIP_MEMSTICK_FETCH" -eq 1 ]; then
if [ ! -f "$MEMSTICK" ]; then
echo "ERROR: cached FreeBSD memstick not found: ${MEMSTICK}"
echo "Rerun without --skip-memstick-fetch to download it."
exit 1
fi
if ! verify_memstick_cache; then
echo "ERROR: cached FreeBSD memstick failed checksum verification."
echo "Remove ${MEMSTICK} and rerun without --skip-memstick-fetch."
exit 1
fi
echo "==> [1/7] FreeBSD memstick cached; fetch skipped."
elif [ "$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"
verify_memstick_cache || { echo "ERROR: checksum mismatch on memstick"; exit 1; }
else
if ! verify_memstick_cache; then
echo "ERROR: cached FreeBSD memstick failed checksum verification."
echo "Remove ${MEMSTICK} and rerun without --skip-fetch."
exit 1
fi
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
_PKG_FETCH_ERROR="${TMP_DIR}/pkg-fetch-error.log"
if ! echo "$PKGS" | xargs pkg fetch --yes --dependencies --output "$PKG_REPO_DIR" 2>"$_PKG_FETCH_ERROR"; then
# If privilege error, offer to re-run with sudo
if grep -q "Insufficient privileges" "$_PKG_FETCH_ERROR" 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 "$_PKG_FETCH_ERROR"
exit 1
fi
fi
echo " Fetch complete."
# Fetch pinned npm-global CLI tarballs for the live image bundle.
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
if [ -d "${PKG_REPO_DIR}/Hashed" ]; then
mkdir -p "${PKG_REPO_DIR}/All/Hashed"
find "${PKG_REPO_DIR}/Hashed" -maxdepth 1 -type f -name '*.pkg' -exec mv {} "${PKG_REPO_DIR}/All/Hashed/" \;
rmdir "${PKG_REPO_DIR}/Hashed" 2>/dev/null || true
fi
if [ -d "${PKG_REPO_DIR}/All/Hashed" ]; then
override_networkmgr_package
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
_PKG_REPO_ERROR="${TMP_DIR}/pkg-repo-error.log"
if ! pkg repo "$PKG_REPO_DIR" 2>"$_PKG_REPO_ERROR"; then
if grep -q "Insufficient privileges" "$_PKG_REPO_ERROR" 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 "$_PKG_REPO_ERROR"
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" from Forgejo releases first, then tags.
if [ "${CLAWDIE_REF:-${CLAWDIE_VERSION:-}}" = "latest" ] || [ -z "${CLAWDIE_REF:-}" ]; then
echo "==> [4/7] Resolving latest Clawdie-AI version..."
CLAWDIE_VERSION=$(resolve_latest_clawdie_tag | sed 's/^v//')
if [ -z "$CLAWDIE_VERSION" ]; then
echo "ERROR: could not resolve latest Clawdie-AI release/tag from Forgejo."
echo " Pin --clawdie-ref main or --clawdie-version X.Y.Z explicitly."
exit 1
fi
CLAWDIE_REF="v${CLAWDIE_VERSION}"
echo " Resolved: ${CLAWDIE_REF}"
fi
CLAWDIE_AI_COMMIT=$(resolve_clawdie_commit "$CLAWDIE_REF" | head -n 1)
CLAWDIE_AI_COMMIT="${CLAWDIE_AI_COMMIT:-unknown}"
echo " Clawdie commit: ${CLAWDIE_AI_COMMIT}"
if [ "$CLAWDIE_AI_COMMIT" = "unknown" ] && [ "$SKIP_FETCH" -eq 1 ] && ! is_pinned_clawdie_ref "$CLAWDIE_REF"; then
echo "ERROR: cannot safely use --skip-fetch for moving Clawdie-AI ref '${CLAWDIE_REF}' without resolving its commit."
echo " Run without --skip-fetch or pin --clawdie-version / --clawdie-ref to a commit."
exit 1
fi
if [ "$CLAWDIE_AI_COMMIT" != "unknown" ]; then
CLAWDIE_ARCHIVE_REF="$CLAWDIE_AI_COMMIT"
CLAWDIE_CACHE_KEY="$CLAWDIE_AI_COMMIT"
else
CLAWDIE_ARCHIVE_REF="$CLAWDIE_REF"
CLAWDIE_CACHE_KEY=$(printf '%s' "$CLAWDIE_REF" | tr -c 'A-Za-z0-9._-' '_')
fi
CLAWDIE_TARBALL="${CACHE_DIR}/clawdie-ai-${CLAWDIE_CACHE_KEY}.tar.gz"
CLAWDIE_TARBALL_URL="https://code.smilepowered.org/clawdie/clawdie-ai/archive/${CLAWDIE_ARCHIVE_REF}.tar.gz"
if [ "$SKIP_FETCH" -eq 0 ] || [ ! -f "$CLAWDIE_TARBALL" ]; then
echo "==> [4/7] Fetching Clawdie-AI ${CLAWDIE_REF} (${CLAWDIE_ARCHIVE_REF})..."
mkdir -p "$CACHE_DIR"
curl -L --progress-bar -o "$CLAWDIE_TARBALL" "$CLAWDIE_TARBALL_URL"
else
echo "==> [4/7] Clawdie-AI ${CLAWDIE_REF} (${CLAWDIE_ARCHIVE_REF}) cached."
fi
# Build an ISO-ready tarball that includes node_modules for offline firstboot.
CLAWDIE_TARBALL_ISO="${CACHE_DIR}/clawdie-ai-${CLAWDIE_CACHE_KEY}-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
_bundle_script="${CACHE_DIR}/bundle-clawdie-ai.sh"
cat > "$_bundle_script" <<'EOF'
#!/bin/sh
set -eu
cd "$BUILD_AI_SRC_DIR"
# Never let package-manager lifecycle hooks run the Clawdie installer on
# the ISO build host. Older Clawdie-AI refs used an npm "install" script
# as an operator shortcut; remove only that root lifecycle hook in the
# staged copy while still allowing dependency install scripts.
node -e '
const fs = require("fs");
const path = "package.json";
const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
if (pkg.scripts?.install) delete pkg.scripts.install;
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n");
'
if ! npm ci --no-audit --no-fund --legacy-peer-deps; then
echo " WARN: npm ci failed for Clawdie-AI; falling back to npm install."
echo " WARN: This usually means the published release tarball is out of sync with package-lock.json."
npm install --no-audit --no-fund --legacy-peer-deps
fi
EOF
chmod 700 "$_bundle_script"
if [ -n "${BUILD_HOST_USER}" ]; then
chown -R "$BUILD_HOST_USER":"$BUILD_HOST_USER" "$BUILD_AI_DIR" "$STAGE_AI_DIR" "$_bundle_script" 2>/dev/null || true
su -m "$BUILD_HOST_USER" -c "env HOME='${BUILD_HOST_HOME}' PATH='${PATH}' BUILD_AI_SRC_DIR='${AI_SRC_DIR}' sh '${_bundle_script}'"
else
env BUILD_AI_SRC_DIR="${AI_SRC_DIR}" sh "$_bundle_script"
fi
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"
# Recreate the working image on every assembly. Package/profile changes must not
# inherit stale packages or desktop files from a previous branch build.
rm -f "$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 an MBR layout compatible with modern UEFI removable boot:
# s1: EFI System Partition (FAT, contains /EFI/BOOT/BOOTX64.EFI)
# s2: FreeBSD slice with BSD label + UFS root
# s3: CLAWDIESEED FAT32 partition for operator seed files (SSH keys,
# future hostname / Tailscale / Wi-Fi overrides). Lives at the
# tail of the disk so Linux pre-flash tooling can mount and edit
# it without touching the UFS root. Imported on every boot by
# /usr/local/etc/rc.d/clawdie_live_seed.
_seed_size_m=64
_efi_size_m=64
_image_size_m=$(( $(stat -f %z "$WORK_IMG") / 1024 / 1024 ))
# 4 MiB headroom covers MBR overhead + alignment rounding. Without this
# the trailing 'gpart add -s ${_seed_size_m}M' can fail when the EFI
# slice rounds up past the requested 64M.
_freebsd_size_m=$(( _image_size_m - _efi_size_m - _seed_size_m - 4 ))
gpart create -s MBR /dev/${MD}
gpart add -t efi -s ${_efi_size_m}M /dev/${MD}
gpart add -t freebsd -s ${_freebsd_size_m}M /dev/${MD}
gpart add -t '!12' -s ${_seed_size_m}M /dev/${MD} # !12 = FAT32 LBA (0x0c)
gpart set -a active -i 2 /dev/${MD}
# Create BSD label (partition a) in the FreeBSD slice
gpart create -s BSD /dev/${MD}s2
gpart add -t freebsd-ufs /dev/${MD}s2
# Create UFS filesystem on partition a. Keep the label expected by the
# stock FreeBSD memstick loader configuration.
newfs -U -L FreeBSD_Install /dev/${MD}s2a
# Format and seed the CLAWDIESEED partition. Operators mount this on
# Linux (sudo mount -t vfat /dev/sdX3 /mnt/...) pre-flash to drop
# authorized_keys. The README.txt explains the allowlisted contract.
#
# -c 1 forces 1 sector/cluster (512 B). FAT32 requires >=65525 clusters
# by spec; at default cluster size (4 KiB+) a 64 MiB partition falls
# well below that and newfs_msdos rejects it ("too few clusters for
# FAT32, need 65525"). 512 B clusters give us 131072 clusters with a
# ~512 KiB FAT table — <1% overhead, invisible for the few-KiB text
# files this partition is meant to hold.
newfs_msdos -F 32 -c 1 -L CLAWDIESEED /dev/${MD}s3
_seed_mount="${CACHE_DIR}/seed-mnt"
mkdir -p "${_seed_mount}"
mount_msdosfs /dev/${MD}s3 "${_seed_mount}"
install -m 0644 "${LIVE_SESSION_DIR}/clawdie-live-seed.README.txt" \
"${_seed_mount}/README.txt"
sync
umount "${_seed_mount}"
rmdir "${_seed_mount}" 2>/dev/null || true
# Mount the new filesystem
MOUNT_POINT="${CACHE_DIR}/mnt"
mkdir -p "$MOUNT_POINT"
mount /dev/${MD}s2a "$MOUNT_POINT"
echo " Mounted /dev/${MD}s2a at ${MOUNT_POINT}"
# Mount memstick read-only to extract base system
MEMSTICK_MNT="${CACHE_DIR}/memstick-src"
mkdir -p "$MEMSTICK_MNT"
mount_memstick_rootfs "$MEMSTICK_MNT"
echo " Copying base system from memstick..."
# Copy all files from memstick to new image (excluding package cache)
(
cd "$MEMSTICK_MNT"
pax -rw -pe . "$MOUNT_POINT"
)
# Cleanup memstick mount
cleanup_memstick_rootfs "$MEMSTICK_MNT"
rm -rf "$MEMSTICK_MNT"
install_image_bootcode "$MD" "$MOUNT_POINT" "2"
install_image_uefi_bootcode "$MD" "$MOUNT_POINT" "1"
# 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"
if [ -e "/dev/${MD}s2a" ]; then
ROOT_PART="/dev/${MD}s2a"
ROOT_SLICE_INDEX="2"
else
ROOT_PART="/dev/${MD}s1a"
ROOT_SLICE_INDEX="1"
fi
mount "$ROOT_PART" "$MOUNT_POINT"
echo " Mounted ${ROOT_PART} 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"
mount_memstick_rootfs "$MEMSTICK_MNT"
(
cd "$MEMSTICK_MNT"
pax -rw -pe . "$MOUNT_POINT"
)
cleanup_memstick_rootfs "$MEMSTICK_MNT"
rm -rf "$MEMSTICK_MNT"
install_image_bootcode "$MD" "$MOUNT_POINT" "$ROOT_SLICE_INDEX"
if [ "$ROOT_SLICE_INDEX" = "2" ]; then
install_image_uefi_bootcode "$MD" "$MOUNT_POINT" "1"
fi
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"
# Step 1 switches the live boot entrypoint away from auto-running
# /etc/installerconfig. Keep the script in USB_SHARE for later explicit
# bsdinstall invocation, but remove any legacy boot-time auto script.
rm -f "${MOUNT_POINT}/etc/installerconfig"
install_live_runtime_packages
configure_live_operator_session
install_colibri_service
install_zot_agent
install_nvidia_universal_repo
# Copy payload
# Rebuild payload paths from scratch inside the reusable work image. A failed
# prior build can leave a partial package tree behind, and overlaying a new
# hashed pkg repo onto that stale path produces "Not a directory" copy errors.
rm -rf "${USB_SHARE}/firstboot" "${USB_SHARE}/packages" "${USB_SHARE}/npm-globals"
rm -f "${USB_SHARE}/installerconfig" \
"${USB_SHARE}/clawdie-ai.tar.gz" \
"${USB_SHARE}/build.cfg" \
"${USB_SHARE}/build-manifest.json"
cp "${SCRIPT_DIR}/installerconfig" "${USB_SHARE}/installerconfig"
mkdir -p "${USB_SHARE}/firstboot"
(
cd "${SCRIPT_DIR}/firstboot"
pax -rw -pe . "${USB_SHARE}/firstboot"
)
mkdir -p "${USB_SHARE}/packages"
(
cd "${PKG_REPO_DIR}"
pax -rw -pe . "${USB_SHARE}/packages"
)
if [ -d "$NPM_GLOBALS_DIR" ]; then
mkdir -p "${USB_SHARE}/npm-globals"
(
cd "${NPM_GLOBALS_DIR}"
pax -rw -pe . "${USB_SHARE}/npm-globals"
)
NPM_GLOBAL_TARBALL_COUNT=$(find "${NPM_GLOBALS_DIR}" -maxdepth 1 -type f -name '*.tgz' | wc -l | tr -d ' ')
echo " Bundled npm-globals: ${NPM_GLOBAL_TARBALL_COUNT} tarballs"
fi
install_live_npm_globals
cp "${CLAWDIE_TARBALL_ISO}" "${USB_SHARE}/clawdie-ai.tar.gz"
cp "${SCRIPT_DIR}/build.cfg" "${USB_SHARE}/"
write_build_manifest "${USB_SHARE}/build-manifest.json"
# Bake runtime vars so firstboot reads the right target config
{
echo "ISO_VERSION=\"${ISO_VERSION}\""
echo "BUILD_CHANNEL=\"${BUILD_CHANNEL}\""
echo "CLAWDIE_VERSION=\"${CLAWDIE_VERSION}\""
echo "CLAWDIE_REF=\"${CLAWDIE_REF}\""
echo "CLAWDIE_AI_COMMIT=\"${CLAWDIE_AI_COMMIT}\""
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 "${FEATURE_COLIBRI:-}" ] && echo "FEATURE_COLIBRI=\"${FEATURE_COLIBRI}\""
[ -n "${COLIBRI_DAEMON_ENABLE:-}" ] && echo "COLIBRI_DAEMON_ENABLE=\"${COLIBRI_DAEMON_ENABLE}\""
[ -n "${COLIBRI_COST_MODE:-}" ] && echo "COLIBRI_COST_MODE=\"${COLIBRI_COST_MODE}\""
[ -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 ""
OUTPUT_IMAGE="${OUTPUT_DIR}/${IMAGE_NAME}"
IMAGE_LOGICAL_SIZE=$(ls -lh "$OUTPUT_IMAGE" | awk '{print $5}')
IMAGE_ALLOCATED_SIZE=$(du -sh "$OUTPUT_IMAGE" | awk '{print $1}')
echo " Done : ${OUTPUT_IMAGE}"
echo " Image size : ${IMAGE_LOGICAL_SIZE}"
echo " Allocated : ${IMAGE_ALLOCATED_SIZE} (sparse on build host)"
echo ""
echo " Write to USB:"
echo " dd if=${OUTPUT_IMAGE} of=/dev/daX bs=1M status=progress"