feat(install): add versioned setup and system contracts

---
Build: pass | Tests: pass — Tests  2000 passed (2000)
This commit is contained in:
Operator & Codex 2026-04-27 10:06:44 +02:00
parent d5182ec480
commit 975f37f895
16 changed files with 969 additions and 59 deletions

View file

@ -17,6 +17,9 @@ here for implementation reference:
| Tier | Field | Required? | Default |
| ----------- | -------------------- | --------- | ----------------------------------------------- |
| Prefilled | `SETUP_SCHEMA_VERSION` | no | `1` |
| Prefilled | `ISO_RELEASE` | no | ISO build release, e.g. `v0.10.0` |
| Optional | `ISO_GIT_COMMIT` | no | exact ISO build commit |
| Required | `OPENROUTER_API_KEY` | yes | — |
| Required | `TELEGRAM_BOT_TOKEN` | yes | — |
| Required | `TELEGRAM_ADMIN_ID` | yes | — |
@ -190,39 +193,45 @@ an unnecessary new interface. A natural follow-up is to surface
- keeps the legacy `operators` table password in sync for API/basic-auth compatibility
- if the Better Auth user already exists, keeps legacy/API password in sync and prints a clear note that dashboard password may need a manual reset
**Cleanup still worth doing later:**
- collapse the operator-facing script and setup-step path onto one shared implementation
- keep the legacy `operators` table in sync from that shared implementation
- document password rotation policy explicitly once dashboard reset flow is frozen
**Implementation status:** done — both entrypoints now delegate to one shared backend.
---
### Task 5 — ISO seed delivery mechanism validation
### Task 5 — ISO seed delivery mechanism validation ✅
**Owner:** _unclaimed_ (Codex required — needs ISO repo access)
**Type:** deploy / decision
**Owner:** Codex
**Type:** decision
Validates which of the three candidate delivery mechanisms actually
works on the target ISO build:
**Resolved V1 mechanism:**
- A. Dedicated writable seed partition on the flashed USB
- B. Separate second USB or removable media carrying `setup.txt`
- C. Unattended-install file on the install media itself
- the flashed USB exposes a writable FAT32 config surface
- operator-edited files on that surface are:
- `setup.txt`
- `system.env`
Output: a recommendation with evidence (boot logs, mount behavior,
operator UX in the flashing tool of choice). The chosen mechanism
becomes the V1 commitment; the others get banked as alternatives in
the lifted install docs.
**Why this won:**
- cross-platform writable from Windows, macOS, Linux, FreeBSD
- no second USB required
- works naturally with inspect output and backfill
- matches the ISO repos existing editable-config-on-removable-media direction
**Banked alternatives, not primary:**
- separate second USB
- unattended-install embedded file
**Inspect-mode note (V1):**
- the primary inspect path is a native shell collector:
`scripts/inspect-system.sh`
- it writes `system.txt`, `system.env`, `suggested-setup.txt`, and
- it writes `system.txt`, `system.env`, `inspect-facts.env`, `suggested-setup.txt`, and
raw command artifacts
- `system.env` now carries `SYSTEM_SCHEMA_VERSION=1` plus hardware contract fields
- it can optionally backfill blank `setup.txt` install/storage fields
via `--apply-setup <path>`
- it can optionally backfill blank `system.env` hardware fields via
`--apply-system-env <path>`
- this is deliberate so inspect works before Node/runtime concerns
are solved on the ISO
- a richer future live/interactive mode may reuse the same artifacts,
@ -256,8 +265,8 @@ follow-up to mark complete.
**Depends on:** 1, 2, 3, 4, 5
**Status:** skeleton landed at
[`docs/public/install/first-boot.md`](../public/install/first-boot.md),
linked from `docs/public/install/index.md`. Two TBD blocks remain:
"Where setup.txt lives" (waits on task 5) and "Troubleshooting"
linked from `docs/public/install/index.md`. One TBD block remains:
"Troubleshooting"
(waits on real failure traces from the ISO build).
Walkthrough at `docs/public/install/first-boot.md`. Covers:
@ -296,7 +305,7 @@ implementation.
---
## Adopt Mode (V1.1) — placeholder
## Upgrade And Rescue (V1.1) — design contract
Reflashing the same machine should not wipe data. The first-boot
flow we shipped covers fresh installs only. The follow-on slice
@ -320,12 +329,45 @@ already agreed (recorded in handoff Round 5) are:
- Telegram identity changes require an explicit flag, never silent.
- `INSTALL_MODE=auto` is the default; `fresh`, `upgrade`, and `rescue` are
explicit overrides.
- Install-fingerprint check gates upgrade — proposed primitive: a
marker file at `/var/db/clawdie/install-fingerprint.json`
containing install UUID, original `ASSISTANT_NAME`, `HOSTNAME`,
hash of `TELEGRAM_ADMIN_ID`, install timestamp.
- Install-fingerprint check gates upgrade — canonical primitive is
ZFS user properties on the root dataset, not a sidecar file.
Tentative task split (subject to Codex's design doc):
Fingerprint source of truth:
- dataset: `<ZFS_POOL>/<ZFS_PREFIX>`
- properties:
- `org.clawdie:install-uuid`
- `org.clawdie:setup-schema`
- `org.clawdie:system-schema`
- `org.clawdie:iso-release`
- `org.clawdie:iso-commit`
- `org.clawdie:installed-at`
- `org.clawdie:last-upgraded-at`
- `org.clawdie:assistant-name`
- `org.clawdie:hostname`
- `org.clawdie:tenant-id`
- `org.clawdie:telegram-admin-hash`
- `org.clawdie:install-mode`
- `org.clawdie:zfs-layout`
- `org.clawdie:zfs-data-disks`
- `org.clawdie:zfs-hot-spares`
Why ZFS metadata wins:
- lives with the data
- survives reflash/reinstall
- survives pool export/import and host migration
- inspectable with native `zfs get`
- avoids inventing another persistent state file
Upgrade comparison order:
1. persisted ZFS dataset metadata
2. `setup.txt` operator intent
3. `system.env` hardware intent
4. live detected host state
Task split:
1. **Upgrade/rescue design doc** (Codex) — overwrite rules per field, the
fingerprint schema, upgrade vs restore separation.
@ -333,7 +375,7 @@ Tentative task split (subject to Codex's design doc):
the multi-signal checklist (existing `.env`, controlplane DB
reachable, ZFS datasets/jails, platform service/user) and
returns a typed `DetectionResult`.
3. **Adopt-mode wiring** — branches in `setup/install.ts` based
3. **Upgrade/rescue wiring** — branches in `setup/install.ts` based
on detection + `INSTALL_MODE`. Honors the per-field overwrite
rules from the design doc.
4. **Restore path** (separate slice) — for new hardware /
@ -352,3 +394,19 @@ Field freeze contract (proposed, per Round 5):
| `OPERATOR_EMAIL` / `OPERATOR_PASSWORD` | ignored in upgrade (dashboard owns rotation) |
| `SSH_AUTHORIZED_KEY` | rotatable (additive — appends, doesn't replace) |
| `CLAWDIE_USER_PASSWORD` | rotatable if explicitly set; warn loudly |
Security checks for upgrade:
- `SETUP_SCHEMA_VERSION` must be supported
- `SYSTEM_SCHEMA_VERSION` must be supported
- `ISO_RELEASE` / `ISO_GIT_COMMIT` are recorded and surfaced, not used as sole trust signals
- root dataset must match declared `ZFS_POOL` + `ZFS_PREFIX`
- `ASSISTANT_NAME` / `HOSTNAME` / Telegram admin mismatch blocks `upgrade`
- `rescue` may proceed with mismatch warnings, but does not silently overwrite identity
`.env` versus `setup.txt`:
- they are not expected to be identical
- `setup.txt` is bootstrap/operator intent
- `.env` is runtime state plus derived/internal values
- upgrade compares only the declared overlap set, not full-file equality

View file

@ -3,9 +3,9 @@ title: 'First Boot (V1)'
description: Edit one file, flash a USB, boot — get to a working assistant with minimal interaction.
---
> **Status:** Draft skeleton. The seed-delivery mechanism (where
> `setup.txt` lives on the install media) is still being validated
> against the ISO build — see "Where setup.txt lives" below. The
> **Status:** Active V1 contract. The operator-facing flow is:
> edit `setup.txt`, optionally let inspect fill `system.env`, boot.
> The exact first-boot file locations are now frozen below. The
> rest of this guide is the operator-facing contract for the V1
> first-boot flow described in
> [`docs/internal/ISO-FIRST-BOOT-SECRETS-HANDOFF.md`](https://codeberg.org/Clawdie/Clawdie-AI/src/branch/multitenant/docs/internal/ISO-FIRST-BOOT-SECRETS-HANDOFF.md)
@ -19,6 +19,19 @@ installer reads your file on first start. No interactive
out-of-the-box wizard, no autogenerated secrets you have to write
down later.
The first-boot setup file is versioned. If you do not set these
yourself, the installer fills safe defaults:
```text
SETUP_SCHEMA_VERSION=1
ISO_RELEASE=v0.10.0
ISO_GIT_COMMIT=
```
- `SETUP_SCHEMA_VERSION` tracks the file format the installer understands
- `ISO_RELEASE` records which Clawdie release the install media came from
- `ISO_GIT_COMMIT` is optional exact build provenance
## What you need
- The Clawdie ISO ([releases](https://codeberg.org/Clawdie/Clawdie-ISO/releases))
@ -57,10 +70,35 @@ first-boot setup file:
That only fills blank or missing fields. It does not overwrite values
you already chose explicitly.
To populate the hardware contract file directly:
```bash
./scripts/inspect-system.sh \
--output /path/to/writable/media \
--apply-system-env /path/to/writable/media/system.env
```
That fills blank or missing hardware fields in `system.env` such as:
- `SYSTEM_SCHEMA_VERSION`
- `NETWORK_EXTERNAL_IF`
- `NETWORK_INTERNAL_IF`
- `TAILSCALE_IF`
- `ZFS_POOL`
- `ZFS_LAYOUT`
- `ZFS_DATA_DISKS`
- `ZFS_HOT_SPARES`
- `ZFS_DISKS`
- `ZFS_SPARE_DISKS`
- `ZFS_PREFIX`
- `GPU_DEVICE`
- `SND_DEVICE`
That writes:
- `system.txt` — a human summary
- `system.env` — machine-readable facts for future installer/UI use
- `system.env` — hardware contract values the installer can use directly
- `inspect-facts.env` — richer machine-readable inspect metadata
- `suggested-setup.txt` — lines you can copy back into `setup.txt`
- raw artifacts such as `dmesg.txt`, `ifconfig.txt`, `zpool-status.txt`,
`zfs-list.txt`, and `pf-interfaces.txt`
@ -193,6 +231,31 @@ ZFS_HOT_SPARES=1
The installer derives full dataset paths from these values. You do
not type raw dataset paths into the first-boot setup file.
These storage declarations are also part of the long-term upgrade
contract. The installer can later compare:
- `setup.txt` operator intent
- `system.env` hardware intent
- persisted ZFS dataset metadata
For advanced operators, that metadata lives as ZFS user properties on
the root dataset `<ZFS_POOL>/<ZFS_PREFIX>`, using keys such as:
- `org.clawdie:install-uuid`
- `org.clawdie:setup-schema`
- `org.clawdie:system-schema`
- `org.clawdie:iso-release`
- `org.clawdie:iso-commit`
- `org.clawdie:assistant-name`
- `org.clawdie:hostname`
- `org.clawdie:telegram-admin-hash`
- `org.clawdie:zfs-layout`
- `org.clawdie:zfs-data-disks`
- `org.clawdie:zfs-hot-spares`
This is deliberate: the upgrade fingerprint lives with the data,
survives reflash, and can be inspected with native `zfs get`.
If you ran inspect first, it will already have written suggested
storage lines based on:
@ -238,15 +301,38 @@ locked by design. Use `sudo` from the service user.
## Where The First-Boot Setup Lives
> **TBD:** the seed-delivery mechanism (writable partition on the
> flashed USB vs separate media vs unattended-install file) is
> being validated against the ISO build (task 5). This section
> will name the exact mechanism and walk through the operator
> steps once that resolves.
>
> Until then, the contract is: after you flash the USB, you can
> reach `setup.txt` from your laptop, edit it, and the installer
> picks it up on first boot.
The V1 decision is:
- the flashed USB exposes a writable FAT32 config surface
- the operator edits two files there:
- `setup.txt`
- `system.env`
- the installer reads those files on first boot
Why this path won:
- it is writable from Windows, macOS, Linux, and FreeBSD
- it supports long API keys without console typing
- it works with the inspect loop naturally
- it matches the ISO repos existing “editable config on removable media”
direction without requiring a second USB
Operator flow:
1. Flash the USB.
2. Reinsert it on your normal computer.
3. Open the writable config surface.
4. Edit `setup.txt`.
5. Optionally leave `system.env` blank and let inspect populate it.
6. Boot the target machine from that USB.
The installer treats:
- `setup.txt` as operator intent
- `system.env` as hardware intent
Both files are versioned and can be compared against persisted ZFS
metadata during later upgrade and rescue flows.
## Boot
@ -308,13 +394,18 @@ the install is healthy.
> retry). This section fills in once task 5 lands and we have
> real failure traces from the ISO build.
## Reflashing later (V1.1, planned)
## Reflashing later
This page covers fresh installs. Reflashing the same machine to
upgrade Clawdie *without* losing data (upgrade mode) is on the V1.1
roadmap — `INSTALL_MODE=auto|fresh|upgrade|rescue` in `setup.txt`, with
upgrade as the safe in-place path. Until that lands, the install
flow assumes fresh; back up before reflashing.
This page still focuses on fresh installs, but the first pieces of the
reflash path now exist:
- `INSTALL_MODE=auto|fresh|upgrade|rescue` is part of the contract
- inspect can suggest and backfill `upgrade`-oriented storage values
- `setup.txt` and `system.env` are both versioned
What is still being hardened is the full upgrade fingerprint and
overwrite-policy logic. Until that lands completely, treat reflash
upgrade as an advanced path and keep backups before you test it.
## Related

View file

@ -5,11 +5,12 @@ description: Operator install paths for Clawdie on FreeBSD.
## Recommended path
The V1 model: edit the first-boot setup (`setup.txt`), flash a USB, boot. No interactive
out-of-the-box wizard, no autogenerated secrets.
The V1 model: edit the first-boot setup (`setup.txt`), optionally let
inspect populate the hardware contract (`system.env`), flash a USB,
boot. No interactive out-of-the-box wizard, no autogenerated secrets.
- [First boot (V1)](./first-boot/) — the four lines, optional
fields, optional inspect step, and what to expect.
fields, versioned setup files, optional inspect step, and what to expect.
- [ISO install](./iso/) — image selection, USB writing, rebuild path.
## Existing-host install

View file

@ -118,6 +118,9 @@ explicit fallback when the first-boot setup is absent or invalid; it sources
locales from FreeBSD itself, so any installed locale can be
selected (`en_US.UTF-8`, `zh_CN.UTF-8`, etc.) and is applied
consistently.
`setup.txt` is now a versioned operator-intent contract, and
`system.env` is the matching hardware-intent contract. Inspect can
populate both before the installer runs.
Set `DB_RUNTIME=host` in `.env` to provision PostgreSQL directly on the host instead of a db jail; `DB_HOST` defaults to `${AGENT_SUBNET_BASE}.1` so jails can reach it. Use `DB_COMPRESSION=lz4` (default) or `DB_COMPRESSION=zstd` for ZFS compression on host datasets.
---

View file

@ -30,9 +30,11 @@ After flashing, either:
- edit the first-boot setup (`setup.txt`) directly, or
- run the optional inspect step first to collect disk/network facts
into `system.txt` and `suggested-setup.txt`
into `system.txt`, `system.env`, `inspect-facts.env`, and `suggested-setup.txt`
- optionally let inspect backfill blank install/storage values into
`setup.txt`
- optionally let inspect backfill blank hardware fields into
`system.env`
Then boot. See [First boot](./first-boot/) for the rest.

View file

@ -7,6 +7,8 @@ repo_root() {
ROOT_DIR="$(repo_root)"
DEFAULT_OUTPUT_DIR="$ROOT_DIR/tmp/inspect"
SETUP_SCHEMA_VERSION=1
SYSTEM_SCHEMA_VERSION=1
read_env_value() {
local key="$1"
@ -42,6 +44,7 @@ read_setup_value() {
OUTPUT_DIR="$DEFAULT_OUTPUT_DIR"
APPLY_SETUP_FILE=""
APPLY_SYSTEM_ENV_FILE=""
while [ $# -gt 0 ]; do
case "$1" in
--output)
@ -60,8 +63,16 @@ while [ $# -gt 0 ]; do
APPLY_SETUP_FILE="$2"
shift 2
;;
--apply-system-env)
if [ $# -lt 2 ]; then
echo "--apply-system-env requires a system.env path" >&2
exit 1
fi
APPLY_SYSTEM_ENV_FILE="$2"
shift 2
;;
*)
echo "Usage: $0 [--output <dir>] [--apply-setup <setup.txt>]" >&2
echo "Usage: $0 [--output <dir>] [--apply-setup <setup.txt>] [--apply-system-env <system.env>]" >&2
exit 1
;;
esac
@ -69,6 +80,15 @@ done
mkdir -p "$OUTPUT_DIR"
ISO_RELEASE="$(sed -n 's/.*"version":[[:space:]]*"\([^"]*\)".*/v\1/p' "$ROOT_DIR/package.json" | head -n 1 || true)"
if [ -z "$ISO_RELEASE" ]; then
ISO_RELEASE="v0.0.0"
fi
ISO_GIT_COMMIT=""
if command -v git >/dev/null 2>&1; then
ISO_GIT_COMMIT="$(git -C "$ROOT_DIR" rev-parse --short=8 HEAD 2>/dev/null || true)"
fi
capture() {
local filename="$1"
shift
@ -90,6 +110,11 @@ capture "pf-interfaces.txt" pfctl -s Interfaces
capture "zpool-list.txt" zpool list -H -o name,size,alloc,free,cap,frag,health
capture "zpool-status.txt" zpool status
capture "zfs-list.txt" zfs list -H -o name,used,avail,refer,mountpoint
if [ -r /dev/sndstat ]; then
cat /dev/sndstat >"$OUTPUT_DIR/sndstat.txt" 2>&1 || true
else
printf 'sndstat unavailable\n' >"$OUTPUT_DIR/sndstat.txt"
fi
apply_if_blank() {
local file="$1"
@ -307,7 +332,35 @@ fi
DATASET_PREVIEW="$(awk -F'\t' 'NF {print $1}' "$OUTPUT_DIR/zfs-list.txt" | head -n 8 | paste -sd',' -)"
[ -z "$DATASET_PREVIEW" ] && DATASET_PREVIEW="none detected"
PRIMARY_EXTERNAL_IF="$(printf '%s' "$EXTERNAL_IFACES" | awk -F',' 'NF {print $1}')"
PRIMARY_INTERNAL_IF="$(printf '%s' "$INTERNAL_IFACES" | awk -F',' 'NF {print $1}')"
[ "$PRIMARY_EXTERNAL_IF" = "none detected" ] && PRIMARY_EXTERNAL_IF=""
[ "$PRIMARY_INTERNAL_IF" = "none detected" ] && PRIMARY_INTERNAL_IF=""
TAILSCALE_IF=""
printf '%s\n' "$INTERFACES" | tr ',' '\n' | grep -qx 'tailscale0' && TAILSCALE_IF='tailscale0'
GPU_DEVICE=""
if [ -e /dev/drm0 ] || [ -e /dev/dri/card0 ]; then
GPU_DEVICE="drm0"
elif grep -Eiq '(VGA|display)' "$OUTPUT_DIR/pciconf.txt"; then
GPU_DEVICE="auto-gpu"
fi
SND_DEVICE="$(grep -Eo 'pcm[0-9]+' "$OUTPUT_DIR/sndstat.txt" | head -n 1 || true)"
ZFS_DATA_DEVICE_LIST=""
ZFS_SPARE_DEVICE_LIST=""
if [ "$DISK_COUNT" -gt 0 ] && [ -n "$DISK_DEVICES" ]; then
if [ "$SUGGESTED_ZFS_HOT_SPARES" -gt 0 ]; then
ZFS_DATA_DEVICE_LIST="$(printf '%s\n' "$DISK_DEVICES" | tr ',' '\n' | head -n "$SUGGESTED_ZFS_DATA_DISKS" | paste -sd',' -)"
ZFS_SPARE_DEVICE_LIST="$(printf '%s\n' "$DISK_DEVICES" | tr ',' '\n' | tail -n "$SUGGESTED_ZFS_HOT_SPARES" | paste -sd',' -)"
else
ZFS_DATA_DEVICE_LIST="$DISK_DEVICES"
fi
fi
cat >"$OUTPUT_DIR/suggested-setup.txt" <<EOF
SETUP_SCHEMA_VERSION=$SETUP_SCHEMA_VERSION
ISO_RELEASE=$ISO_RELEASE
ISO_GIT_COMMIT=$ISO_GIT_COMMIT
INSTALL_MODE=$SUGGESTED_INSTALL_MODE
ZFS_POOL=$POOL_NAME
ZFS_LAYOUT=$SUGGESTED_ZFS_LAYOUT
@ -315,11 +368,30 @@ ZFS_DATA_DISKS=$SUGGESTED_ZFS_DATA_DISKS
ZFS_HOT_SPARES=$SUGGESTED_ZFS_HOT_SPARES
EOF
cat >"$OUTPUT_DIR/system.env" <<EOF
SYSTEM_SCHEMA_VERSION=$SYSTEM_SCHEMA_VERSION
NETWORK_EXTERNAL_IF=$PRIMARY_EXTERNAL_IF
NETWORK_INTERNAL_IF=$PRIMARY_INTERNAL_IF
TAILSCALE_IF=$TAILSCALE_IF
ZFS_POOL=$POOL_NAME
ZFS_LAYOUT=$SUGGESTED_ZFS_LAYOUT
ZFS_DATA_DISKS=$SUGGESTED_ZFS_DATA_DISKS
ZFS_HOT_SPARES=$SUGGESTED_ZFS_HOT_SPARES
ZFS_DISKS=$ZFS_DATA_DEVICE_LIST
ZFS_SPARE_DISKS=$ZFS_SPARE_DEVICE_LIST
ZFS_PREFIX=$ZFS_PREFIX_VALUE
GPU_DEVICE=$GPU_DEVICE
SND_DEVICE=$SND_DEVICE
EOF
if [ -n "$APPLY_SETUP_FILE" ]; then
mkdir -p "$(dirname "$APPLY_SETUP_FILE")"
if [ ! -f "$APPLY_SETUP_FILE" ]; then
: >"$APPLY_SETUP_FILE"
fi
apply_if_blank "$APPLY_SETUP_FILE" "SETUP_SCHEMA_VERSION" "$SETUP_SCHEMA_VERSION"
apply_if_blank "$APPLY_SETUP_FILE" "ISO_RELEASE" "$ISO_RELEASE"
apply_if_blank "$APPLY_SETUP_FILE" "ISO_GIT_COMMIT" "$ISO_GIT_COMMIT"
apply_if_blank "$APPLY_SETUP_FILE" "INSTALL_MODE" "$SUGGESTED_INSTALL_MODE"
apply_if_blank "$APPLY_SETUP_FILE" "ZFS_POOL" "$POOL_NAME"
CURRENT_ZFS_LAYOUT="$(read_setup_value "$APPLY_SETUP_FILE" "ZFS_LAYOUT" 2>/dev/null || true)"
@ -330,7 +402,30 @@ if [ -n "$APPLY_SETUP_FILE" ]; then
fi
fi
cat >"$OUTPUT_DIR/system.env" <<EOF
if [ -n "$APPLY_SYSTEM_ENV_FILE" ]; then
mkdir -p "$(dirname "$APPLY_SYSTEM_ENV_FILE")"
if [ ! -f "$APPLY_SYSTEM_ENV_FILE" ]; then
: >"$APPLY_SYSTEM_ENV_FILE"
fi
apply_if_blank "$APPLY_SYSTEM_ENV_FILE" "SYSTEM_SCHEMA_VERSION" "$SYSTEM_SCHEMA_VERSION"
apply_if_blank "$APPLY_SYSTEM_ENV_FILE" "NETWORK_EXTERNAL_IF" "$PRIMARY_EXTERNAL_IF"
apply_if_blank "$APPLY_SYSTEM_ENV_FILE" "NETWORK_INTERNAL_IF" "$PRIMARY_INTERNAL_IF"
apply_if_blank "$APPLY_SYSTEM_ENV_FILE" "TAILSCALE_IF" "$TAILSCALE_IF"
apply_if_blank "$APPLY_SYSTEM_ENV_FILE" "ZFS_POOL" "$POOL_NAME"
CURRENT_SYSTEM_ZFS_LAYOUT="$(read_setup_value "$APPLY_SYSTEM_ENV_FILE" "ZFS_LAYOUT" 2>/dev/null || true)"
apply_if_blank "$APPLY_SYSTEM_ENV_FILE" "ZFS_LAYOUT" "$SUGGESTED_ZFS_LAYOUT"
if [ -z "$CURRENT_SYSTEM_ZFS_LAYOUT" ] || [ "$CURRENT_SYSTEM_ZFS_LAYOUT" = "$SUGGESTED_ZFS_LAYOUT" ]; then
apply_if_blank "$APPLY_SYSTEM_ENV_FILE" "ZFS_DATA_DISKS" "$SUGGESTED_ZFS_DATA_DISKS"
apply_if_blank "$APPLY_SYSTEM_ENV_FILE" "ZFS_HOT_SPARES" "$SUGGESTED_ZFS_HOT_SPARES"
apply_if_blank "$APPLY_SYSTEM_ENV_FILE" "ZFS_DISKS" "$ZFS_DATA_DEVICE_LIST"
apply_if_blank "$APPLY_SYSTEM_ENV_FILE" "ZFS_SPARE_DISKS" "$ZFS_SPARE_DEVICE_LIST"
fi
apply_if_blank "$APPLY_SYSTEM_ENV_FILE" "ZFS_PREFIX" "$ZFS_PREFIX_VALUE"
apply_if_blank "$APPLY_SYSTEM_ENV_FILE" "GPU_DEVICE" "$GPU_DEVICE"
apply_if_blank "$APPLY_SYSTEM_ENV_FILE" "SND_DEVICE" "$SND_DEVICE"
fi
cat >"$OUTPUT_DIR/inspect-facts.env" <<EOF
INSPECT_HOSTNAME=$HOSTNAME_VALUE
INSPECT_EXISTING_INSTALL=$EXISTING_INSTALL
INSPECT_STRONG_SIGNALS=$(IFS=,; echo "${STRONG_SIGNALS[*]-}")
@ -386,9 +481,11 @@ Suggested setup.txt lines
Artifacts
- system.txt
- system.env
- inspect-facts.env
- suggested-setup.txt
- dmesg.txt
- pciconf.txt
- sndstat.txt
- geom-disk-list.txt
- ifconfig.txt
- pf-status.txt
@ -407,3 +504,9 @@ if [ -n "$APPLY_SETUP_FILE" ]; then
echo "Skipped ZFS_DATA_DISKS/ZFS_HOT_SPARES backfill because setup.txt already declares ZFS_LAYOUT=$CURRENT_ZFS_LAYOUT"
fi
fi
if [ -n "$APPLY_SYSTEM_ENV_FILE" ]; then
echo "Applied missing system.env values to $APPLY_SYSTEM_ENV_FILE"
if [ -n "${CURRENT_SYSTEM_ZFS_LAYOUT:-}" ] && [ "$CURRENT_SYSTEM_ZFS_LAYOUT" != "$SUGGESTED_ZFS_LAYOUT" ]; then
echo "Skipped ZFS_DATA_DISKS/ZFS_HOT_SPARES/ZFS_DISKS/ZFS_SPARE_DISKS backfill because system.env already declares ZFS_LAYOUT=$CURRENT_SYSTEM_ZFS_LAYOUT"
fi
fi

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
FIRST_BOOT_SETUP_SCHEMA_VERSION,
PLAINTEXT_CREDENTIAL_WARNING,
deriveFirstBootIdentity,
deriveFirstBootStorageLayout,
@ -17,6 +18,9 @@ TELEGRAM_ADMIN_ID=85126311
`);
expect(result).toMatchObject({
setupSchemaVersion: FIRST_BOOT_SETUP_SCHEMA_VERSION,
isoRelease: 'v0.10.0',
isoGitCommit: null,
openRouterApiKey: 'sk-or-v1-test',
telegramBotToken: '123:abc',
telegramAdminId: 85126311,
@ -41,6 +45,9 @@ TELEGRAM_ADMIN_ID=85126311
it('supports comments, quotes, invisible chars, and explicit values', () => {
const result = parseFirstBootConfig(`
# comment
SETUP_SCHEMA_VERSION=1
ISO_RELEASE=v9.9.9
ISO_GIT_COMMIT=abcdef12
OPENROUTER_API_KEY="sk-or-v1-test"
TELEGRAM_BOT_TOKEN='123:abc'
TELEGRAM_ADMIN_ID=85126311
@ -60,6 +67,9 @@ CLAWDIE_USER_PASSWORD=sudofallback
`);
expect(result).toMatchObject({
setupSchemaVersion: 1,
isoRelease: 'v9.9.9',
isoGitCommit: 'abcdef12',
assistantName: 'Mevy',
installMode: 'auto',
profile: 'quality',
@ -99,6 +109,17 @@ PROFILE=wild
).toThrow(/PROFILE/);
});
it('rejects unsupported setup schema versions', () => {
expect(() =>
parseFirstBootConfig(`
SETUP_SCHEMA_VERSION=2
OPENROUTER_API_KEY=sk-or-v1-test
TELEGRAM_BOT_TOKEN=123:abc
TELEGRAM_ADMIN_ID=85126311
`),
).toThrow(/SETUP_SCHEMA_VERSION=2/);
});
it('rejects invalid zfs layout combinations', () => {
expect(() =>
parseFirstBootConfig(`

View file

@ -1,3 +1,6 @@
import fs from 'fs';
import path from 'path';
import { normalizeTimeZone } from '../src/locale-profile.js';
import { normalizeResourceId } from '../src/platform-layout.js';
@ -6,6 +9,9 @@ export type FirstBootInstallMode = 'auto' | 'fresh' | 'upgrade' | 'rescue';
export type FirstBootZfsLayout = 'single' | 'mirror' | 'raidz1' | 'raidz2';
export interface FirstBootConfig {
setupSchemaVersion: number;
isoRelease: string;
isoGitCommit: string | null;
openRouterApiKey: string;
telegramBotToken: string;
telegramAdminId: number;
@ -62,8 +68,12 @@ const DEFAULT_ZFS_LAYOUT: FirstBootZfsLayout = 'single';
const DEFAULT_ZFS_DATA_DISKS = 1;
const DEFAULT_ZFS_HOT_SPARES = 0;
const DEFAULT_ZFS_PREFIX = 'clawdie-runtime';
export const FIRST_BOOT_SETUP_SCHEMA_VERSION = 1;
const INVISIBLE_CHARS_RE = /[\u200B-\u200F\u202A-\u202E\u2066-\u2069\uFEFF]/gu;
const FIRST_BOOT_KEYS = [
'SETUP_SCHEMA_VERSION',
'ISO_RELEASE',
'ISO_GIT_COMMIT',
'OPENROUTER_API_KEY',
'TELEGRAM_BOT_TOKEN',
'TELEGRAM_ADMIN_ID',
@ -115,6 +125,21 @@ function parseSetupLines(content: string): Partial<Record<FirstBootKey, string>>
return values;
}
function currentIsoRelease(): string {
try {
const packageJson = JSON.parse(
fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf-8'),
) as { version?: string };
const version = String(packageJson.version || '').trim();
if (version) {
return `v${version.replace(/^v/u, '')}`;
}
} catch {
// ignore
}
return 'v0.0.0';
}
function assertPresent(
values: Partial<Record<FirstBootKey, string>>,
key: FirstBootKey,
@ -126,6 +151,39 @@ function assertPresent(
return value;
}
function parseSchemaVersion(value?: string): number {
const normalized = (value || '').trim();
if (!normalized) return FIRST_BOOT_SETUP_SCHEMA_VERSION;
if (!/^\d+$/u.test(normalized)) {
throw new Error(
`setup.txt has invalid SETUP_SCHEMA_VERSION=${value}. Expected a non-negative integer.`,
);
}
const parsed = Number.parseInt(normalized, 10);
if (parsed !== FIRST_BOOT_SETUP_SCHEMA_VERSION) {
throw new Error(
`setup.txt SETUP_SCHEMA_VERSION=${parsed} is not supported by this installer. Expected ${FIRST_BOOT_SETUP_SCHEMA_VERSION}.`,
);
}
return parsed;
}
function parseIsoRelease(value?: string): string {
const normalized = (value || '').trim();
return normalized || currentIsoRelease();
}
function parseIsoGitCommit(value?: string): string | null {
const normalized = (value || '').trim();
if (!normalized) return null;
if (!/^[0-9a-f]{7,40}$/iu.test(normalized)) {
throw new Error(
`setup.txt has invalid ISO_GIT_COMMIT=${value}. Expected a git commit hash.`,
);
}
return normalized.toLowerCase();
}
function parseProfile(value?: string): FirstBootProfile {
const normalized = (value || '').trim().toLowerCase();
if (!normalized) return DEFAULT_PROFILE;
@ -328,6 +386,9 @@ export function parseFirstBootConfig(content: string): FirstBootConfig {
'setup.txt ROOT_PASSWORD is not supported. Root login stays locked by default; see install docs.',
);
}
const setupSchemaVersion = parseSchemaVersion(values.SETUP_SCHEMA_VERSION);
const isoRelease = parseIsoRelease(values.ISO_RELEASE);
const isoGitCommit = parseIsoGitCommit(values.ISO_GIT_COMMIT);
const openRouterApiKey = assertPresent(values, 'OPENROUTER_API_KEY');
const telegramBotToken = assertPresent(values, 'TELEGRAM_BOT_TOKEN');
const telegramAdminId = parseTelegramAdminId(
@ -366,6 +427,9 @@ export function parseFirstBootConfig(content: string): FirstBootConfig {
operatorPassword || clawdieUserPassword ? PLAINTEXT_CREDENTIAL_WARNING : null;
return {
setupSchemaVersion,
isoRelease,
isoGitCommit,
openRouterApiKey,
telegramBotToken,
telegramAdminId,

View file

@ -64,6 +64,27 @@ describe('detectExistingInstall', () => {
expect(detection.strongSignals).toContain('service');
});
it('prefers system.env zfs pool/prefix when matching datasets', () => {
const root = makeProjectRoot();
roots.push(root);
fs.writeFileSync(
path.join(root, 'system.env'),
['ZFS_POOL=tank', 'ZFS_PREFIX=mevy-runtime', ''].join('\n'),
);
const detection = detectExistingInstall(root, {
existsSync: (target) => fs.existsSync(target) && target !== '/usr/local/etc/rc.d/mevy',
commandExists: (name) => name === 'zfs',
execFileSync: ((cmd: string) => {
if (cmd === 'zfs') return 'tank/mevy-runtime\ntank/mevy-runtime/pgdata\n';
throw new Error('unexpected command');
}) as typeof import('child_process').execFileSync,
});
expect(detection.existing).toBe(true);
expect(detection.strongSignals).toContain('zfs');
});
it('returns fresh when no signals are present', () => {
const root = makeProjectRoot();
roots.push(root);

View file

@ -9,6 +9,7 @@ import {
} from '../src/config.js';
import type { FirstBootInstallMode } from './first-boot.js';
import { commandExists } from './platform.js';
import { loadSystemEnv } from './system-env.js';
export interface ExistingInstallSignals {
envFile: boolean;
@ -84,9 +85,15 @@ function hasRuntimeUser(deps: DetectorDeps): boolean {
}
}
function hasZfsDataset(deps: DetectorDeps): boolean {
function hasZfsDataset(projectRoot: string, deps: DetectorDeps): boolean {
if (!deps.commandExists('zfs')) return false;
try {
const systemEnv = loadSystemEnv(projectRoot, deps);
const zfsPrefix = systemEnv.zfsPrefix || ZFS_PREFIX;
const exactDataset =
systemEnv.zfsPool && zfsPrefix
? `${systemEnv.zfsPool}/${zfsPrefix}`
: null;
const output = deps.execFileSync('zfs', ['list', '-H', '-o', 'name'], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
@ -97,10 +104,15 @@ function hasZfsDataset(deps: DetectorDeps): boolean {
.filter(Boolean);
return datasets.some(
(name) =>
name.endsWith(`/${ZFS_PREFIX}`) ||
name === ZFS_PREFIX ||
name.includes(`/${ZFS_PREFIX}/`) ||
name.endsWith(`/${ZFS_PREFIX}/pgdata`),
(exactDataset
? name === exactDataset ||
name.startsWith(`${exactDataset}/`) ||
name.endsWith(`${exactDataset}@`)
: false) ||
name.endsWith(`/${zfsPrefix}`) ||
name === zfsPrefix ||
name.includes(`/${zfsPrefix}/`) ||
name.endsWith(`/${zfsPrefix}/pgdata`),
);
} catch {
return false;
@ -116,7 +128,7 @@ export function detectExistingInstall(
envFile: hasProjectEnv(projectRoot, resolvedDeps),
groupsDir: hasNonEmptyGroupsDir(projectRoot, resolvedDeps),
serviceFile: hasInstalledService(resolvedDeps),
zfsDataset: hasZfsDataset(resolvedDeps),
zfsDataset: hasZfsDataset(projectRoot, resolvedDeps),
runtimeUser: hasRuntimeUser(resolvedDeps),
};

View file

@ -5,6 +5,7 @@ import { logger } from '../src/logger.js';
import { emitStatus } from './status.js';
import { getPlatform } from './platform.js';
import { SUBNET_BASE } from '../src/config.js';
import { resolveSystemEnv } from './system-env.js';
const PF_CONF = '/etc/pf.conf';
const PF_WARDEN_INCLUDE = '/etc/pf.warden.conf';
@ -19,6 +20,9 @@ function pfEnabled(): boolean {
}
function detectExtIf(): string {
const systemEnv = resolveSystemEnv(process.cwd());
if (systemEnv.networkExternalIf) return systemEnv.networkExternalIf;
const env = (process.env.EXT_IF || '').trim();
if (env) return env;
@ -63,7 +67,11 @@ export async function run(): Promise<void> {
}
try {
const wardenIf = (process.env.WARDEN_BRIDGE || 'warden0').trim() || 'warden0';
const systemEnv = resolveSystemEnv(process.cwd());
const wardenIf =
systemEnv.networkInternalIf ||
(process.env.WARDEN_BRIDGE || 'warden0').trim() ||
'warden0';
const wardenNet = (process.env.WARDEN_SUBNET || `${SUBNET_BASE}.0/24`).trim();
const extIf = detectExtIf();

137
setup/system-env.test.ts Normal file
View file

@ -0,0 +1,137 @@
import fs from 'fs';
import path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import {
SYSTEM_ENV_SCHEMA_VERSION,
loadSystemEnv,
resolveSystemEnv,
} from './system-env.js';
function makeProjectRoot(): string {
const base = path.join(process.cwd(), 'tmp', 'tests');
fs.mkdirSync(base, { recursive: true });
return fs.mkdtempSync(path.join(base, 'clawdie-system-env-'));
}
function removeDir(dirPath: string): void {
fs.rmSync(dirPath, { recursive: true, force: true });
}
describe('system env', () => {
const roots: string[] = [];
afterEach(() => {
while (roots.length > 0) {
removeDir(roots.pop()!);
}
});
it('loads blank defaults when system.env is absent', () => {
const root = makeProjectRoot();
roots.push(root);
expect(loadSystemEnv(root)).toEqual({
systemSchemaVersion: SYSTEM_ENV_SCHEMA_VERSION,
networkExternalIf: null,
networkInternalIf: null,
tailscaleIf: null,
zfsPool: null,
zfsLayout: null,
zfsDataDisks: null,
zfsHotSpares: null,
zfsDisks: [],
zfsSpareDisks: [],
zfsPrefix: null,
gpuDevice: null,
sndDevice: null,
});
});
it('parses explicit system.env values', () => {
const root = makeProjectRoot();
roots.push(root);
fs.writeFileSync(
path.join(root, 'system.env'),
[
'SYSTEM_SCHEMA_VERSION=1',
'NETWORK_EXTERNAL_IF=em0',
'NETWORK_INTERNAL_IF=warden0',
'TAILSCALE_IF=tailscale0',
'ZFS_POOL=tank',
'ZFS_LAYOUT=raidz1',
'ZFS_DATA_DISKS=3',
'ZFS_HOT_SPARES=1',
'ZFS_DISKS=ada0,ada1,ada2',
'ZFS_SPARE_DISKS=ada3',
'ZFS_PREFIX=mevy data',
'GPU_DEVICE=drm0',
'SND_DEVICE=pcm0',
'',
].join('\n'),
);
expect(loadSystemEnv(root)).toEqual({
systemSchemaVersion: 1,
networkExternalIf: 'em0',
networkInternalIf: 'warden0',
tailscaleIf: 'tailscale0',
zfsPool: 'tank',
zfsLayout: 'raidz1',
zfsDataDisks: 3,
zfsHotSpares: 1,
zfsDisks: ['ada0', 'ada1', 'ada2'],
zfsSpareDisks: ['ada3'],
zfsPrefix: 'mevy-data',
gpuDevice: 'drm0',
sndDevice: 'pcm0',
});
});
it('rejects unsupported system.env schema versions', () => {
const root = makeProjectRoot();
roots.push(root);
fs.writeFileSync(
path.join(root, 'system.env'),
['SYSTEM_SCHEMA_VERSION=2', ''].join('\n'),
);
expect(() => loadSystemEnv(root)).toThrow(/SYSTEM_SCHEMA_VERSION=2/);
});
it('resolves autodetected values only for blanks', () => {
const root = makeProjectRoot();
roots.push(root);
fs.writeFileSync(
path.join(root, 'system.env'),
['NETWORK_EXTERNAL_IF=', 'NETWORK_INTERNAL_IF=bridge2', 'GPU_DEVICE=', ''].join('\n'),
);
const resolved = resolveSystemEnv(root, {
commandExists: (name) => name === 'route' || name === 'ifconfig',
existsSync: (filePath) =>
filePath === '/dev/drm0' ||
filePath === '/dev/pcm0' ||
fs.existsSync(filePath),
execFileSync: ((cmd: string, args: string[]) => {
if (cmd === 'route') return 'interface: vtnet0\n';
if (cmd === 'ifconfig') return 'lo0 warden0 tailscale0';
throw new Error(`unexpected command ${cmd} ${args.join(' ')}`);
}) as typeof import('child_process').execFileSync,
});
expect(resolved.networkExternalIf).toBe('vtnet0');
expect(resolved.networkInternalIf).toBe('bridge2');
expect(resolved.tailscaleIf).toBe('tailscale0');
expect(resolved.gpuDevice).toBe('drm0');
expect(resolved.sndDevice).toBe('pcm0');
expect(resolved.detected).toEqual({
networkExternalIf: true,
networkInternalIf: false,
tailscaleIf: true,
gpuDevice: true,
sndDevice: true,
});
});
});

245
setup/system-env.ts Normal file
View file

@ -0,0 +1,245 @@
import { execFileSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import type { FirstBootZfsLayout } from './first-boot.js';
import { commandExists } from './platform.js';
import { normalizeResourceId } from '../src/platform-layout.js';
export interface SystemEnvConfig {
systemSchemaVersion: number;
networkExternalIf: string | null;
networkInternalIf: string | null;
tailscaleIf: string | null;
zfsPool: string | null;
zfsLayout: FirstBootZfsLayout | null;
zfsDataDisks: number | null;
zfsHotSpares: number | null;
zfsDisks: string[];
zfsSpareDisks: string[];
zfsPrefix: string | null;
gpuDevice: string | null;
sndDevice: string | null;
}
export interface ResolvedSystemEnv extends SystemEnvConfig {
detected: {
networkExternalIf: boolean;
networkInternalIf: boolean;
tailscaleIf: boolean;
gpuDevice: boolean;
sndDevice: boolean;
};
}
interface SystemEnvDeps {
commandExists: (name: string) => boolean;
execFileSync: typeof execFileSync;
existsSync: (filePath: string) => boolean;
readFileSync: typeof fs.readFileSync;
}
const DEFAULT_DEPS: SystemEnvDeps = {
commandExists,
execFileSync,
existsSync: fs.existsSync,
readFileSync: fs.readFileSync,
};
export const SYSTEM_ENV_SCHEMA_VERSION = 1;
function parseScalar(content: string, key: string): string | null {
const match = content.match(new RegExp(`^${key}=(.*)$`, 'm'));
if (!match) return null;
const raw = match[1].trim().replace(/^['"]|['"]$/gu, '');
return raw || null;
}
function parseInteger(content: string, key: string): number | null {
const value = parseScalar(content, key);
if (!value) return null;
if (!/^\d+$/u.test(value)) return null;
return Number.parseInt(value, 10);
}
function parseCsv(content: string, key: string): string[] {
const value = parseScalar(content, key);
if (!value) return [];
return value
.split(',')
.map((entry) => entry.trim())
.filter(Boolean);
}
function parseLayout(content: string): FirstBootZfsLayout | null {
const value = (parseScalar(content, 'ZFS_LAYOUT') || '').toLowerCase();
if (!value) return null;
if (
value === 'single' ||
value === 'mirror' ||
value === 'raidz1' ||
value === 'raidz2'
) {
return value;
}
return null;
}
export function loadSystemEnv(
projectRoot: string = process.cwd(),
deps: Partial<SystemEnvDeps> = {},
): SystemEnvConfig {
const resolvedDeps = { ...DEFAULT_DEPS, ...deps };
const systemEnvFile = path.join(projectRoot, 'system.env');
if (!resolvedDeps.existsSync(systemEnvFile)) {
return {
systemSchemaVersion: SYSTEM_ENV_SCHEMA_VERSION,
networkExternalIf: null,
networkInternalIf: null,
tailscaleIf: null,
zfsPool: null,
zfsLayout: null,
zfsDataDisks: null,
zfsHotSpares: null,
zfsDisks: [],
zfsSpareDisks: [],
zfsPrefix: null,
gpuDevice: null,
sndDevice: null,
};
}
const content = resolvedDeps.readFileSync(systemEnvFile, 'utf-8');
const parsedSchema = parseInteger(content, 'SYSTEM_SCHEMA_VERSION');
if (parsedSchema !== null && parsedSchema !== SYSTEM_ENV_SCHEMA_VERSION) {
throw new Error(
`system.env SYSTEM_SCHEMA_VERSION=${parsedSchema} is not supported. Expected ${SYSTEM_ENV_SCHEMA_VERSION}.`,
);
}
return {
systemSchemaVersion: parsedSchema ?? SYSTEM_ENV_SCHEMA_VERSION,
networkExternalIf: parseScalar(content, 'NETWORK_EXTERNAL_IF'),
networkInternalIf: parseScalar(content, 'NETWORK_INTERNAL_IF'),
tailscaleIf: parseScalar(content, 'TAILSCALE_IF'),
zfsPool: parseScalar(content, 'ZFS_POOL'),
zfsLayout: parseLayout(content),
zfsDataDisks: parseInteger(content, 'ZFS_DATA_DISKS'),
zfsHotSpares: parseInteger(content, 'ZFS_HOT_SPARES'),
zfsDisks: parseCsv(content, 'ZFS_DISKS'),
zfsSpareDisks: parseCsv(content, 'ZFS_SPARE_DISKS'),
zfsPrefix: (() => {
const raw = parseScalar(content, 'ZFS_PREFIX');
return raw ? normalizeResourceId(raw) || null : null;
})(),
gpuDevice: parseScalar(content, 'GPU_DEVICE'),
sndDevice: parseScalar(content, 'SND_DEVICE'),
};
}
function detectDefaultRouteInterface(deps: SystemEnvDeps): string | null {
if (!deps.commandExists('route')) return null;
try {
const out = deps.execFileSync('route', ['-n', 'get', 'default'], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
});
return out.match(/^\s*interface:\s*(\S+)/mu)?.[1]?.trim() || null;
} catch {
return null;
}
}
function detectInterfaces(deps: SystemEnvDeps): string[] {
if (!deps.commandExists('ifconfig')) return [];
try {
const out = deps.execFileSync('ifconfig', ['-l'], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
});
return out
.trim()
.split(/\s+/u)
.map((entry) => entry.trim())
.filter(Boolean);
} catch {
return [];
}
}
function detectInternalIf(deps: SystemEnvDeps): string | null {
const interfaces = detectInterfaces(deps);
if (interfaces.includes('warden0')) return 'warden0';
const bridge = interfaces.find((name) => /^bridge\d+$/u.test(name));
return bridge || null;
}
function detectTailscaleIf(deps: SystemEnvDeps): string | null {
const interfaces = detectInterfaces(deps);
return interfaces.includes('tailscale0') ? 'tailscale0' : null;
}
function detectGpuDevice(deps: SystemEnvDeps): string | null {
if (deps.existsSync('/dev/drm0')) return 'drm0';
if (deps.existsSync('/dev/dri/card0')) return 'drm0';
if (!deps.commandExists('pciconf')) return null;
try {
const out = deps.execFileSync('pciconf', ['-lv'], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
});
if (/(VGA|display)/iu.test(out)) {
return 'auto-gpu';
}
} catch {
// ignore
}
return null;
}
function detectSndDevice(deps: SystemEnvDeps): string | null {
if (deps.existsSync('/dev/pcm0')) return 'pcm0';
if (deps.commandExists('cat') && deps.existsSync('/dev/sndstat')) {
try {
const out = deps.execFileSync('cat', ['/dev/sndstat'], {
encoding: 'utf-8',
stdio: ['ignore', 'pipe', 'ignore'],
});
const match = out.match(/\b(pcm\d+)\b/u);
return match?.[1] || null;
} catch {
return null;
}
}
return null;
}
export function resolveSystemEnv(
projectRoot: string = process.cwd(),
deps: Partial<SystemEnvDeps> = {},
): ResolvedSystemEnv {
const resolvedDeps = { ...DEFAULT_DEPS, ...deps };
const loaded = loadSystemEnv(projectRoot, resolvedDeps);
const detectedExternal = !loaded.networkExternalIf;
const detectedInternal = !loaded.networkInternalIf;
const detectedTailscale = !loaded.tailscaleIf;
const detectedGpu = !loaded.gpuDevice;
const detectedSnd = !loaded.sndDevice;
return {
...loaded,
networkExternalIf:
loaded.networkExternalIf || detectDefaultRouteInterface(resolvedDeps),
networkInternalIf: loaded.networkInternalIf || detectInternalIf(resolvedDeps),
tailscaleIf: loaded.tailscaleIf || detectTailscaleIf(resolvedDeps),
gpuDevice: loaded.gpuDevice || detectGpuDevice(resolvedDeps),
sndDevice: loaded.sndDevice || detectSndDevice(resolvedDeps),
detected: {
networkExternalIf: detectedExternal,
networkInternalIf: detectedInternal,
tailscaleIf: detectedTailscale,
gpuDevice: detectedGpu,
sndDevice: detectedSnd,
},
};
}

View file

@ -0,0 +1,55 @@
import { describe, expect, it, vi } from 'vitest';
import { getZfsMetadata, setZfsMetadata } from './zfs-metadata.js';
describe('zfs metadata helpers', () => {
it('writes namespaced ZFS properties for non-empty values', () => {
const exec = vi.fn();
setZfsMetadata(
'zroot/clawdie-runtime',
{
'install-uuid': 'abc123',
'iso-release': 'v0.10.0',
empty: '',
skipped: null,
},
{ execFileSync: exec as never },
);
expect(exec).toHaveBeenCalledTimes(2);
expect(exec).toHaveBeenNthCalledWith(
1,
'zfs',
['set', 'org.clawdie:install-uuid=abc123', 'zroot/clawdie-runtime'],
expect.any(Object),
);
expect(exec).toHaveBeenNthCalledWith(
2,
'zfs',
['set', 'org.clawdie:iso-release=v0.10.0', 'zroot/clawdie-runtime'],
expect.any(Object),
);
});
it('reads namespaced ZFS properties and normalizes missing values', () => {
const exec = vi.fn((cmd: string, args: string[]) => {
const prop = args[4];
if (prop === 'org.clawdie:install-uuid') return 'abc123\n';
if (prop === 'org.clawdie:iso-release') return '-\n';
throw new Error('missing');
});
expect(
getZfsMetadata(
'zroot/clawdie-runtime',
['install-uuid', 'iso-release', 'assistant-name'],
{ execFileSync: exec as never },
),
).toEqual({
'install-uuid': 'abc123',
'iso-release': null,
'assistant-name': null,
});
});
});

65
setup/zfs-metadata.ts Normal file
View file

@ -0,0 +1,65 @@
import { execFileSync } from 'child_process';
export const ZFS_METADATA_PREFIX = 'org.clawdie';
interface ZfsMetadataDeps {
execFileSync: typeof execFileSync;
}
const DEFAULT_DEPS: ZfsMetadataDeps = {
execFileSync,
};
function propertyName(key: string): string {
const normalized = key.trim().toLowerCase();
if (!/^[a-z0-9][a-z0-9-]*$/u.test(normalized)) {
throw new Error(`invalid ZFS metadata key: ${key}`);
}
return `${ZFS_METADATA_PREFIX}:${normalized}`;
}
function normalizeValue(value: string | number | boolean): string {
return String(value).trim();
}
export function setZfsMetadata(
dataset: string,
props: Record<string, string | number | boolean | null | undefined>,
deps: Partial<ZfsMetadataDeps> = {},
): void {
const resolvedDeps = { ...DEFAULT_DEPS, ...deps };
for (const [key, raw] of Object.entries(props)) {
if (raw === null || raw === undefined) continue;
const value = normalizeValue(raw);
if (!value) continue;
resolvedDeps.execFileSync(
'zfs',
['set', `${propertyName(key)}=${value}`, dataset],
{ stdio: ['ignore', 'ignore', 'pipe'] },
);
}
}
export function getZfsMetadata(
dataset: string,
keys: string[],
deps: Partial<ZfsMetadataDeps> = {},
): Record<string, string | null> {
const resolvedDeps = { ...DEFAULT_DEPS, ...deps };
const result: Record<string, string | null> = {};
for (const key of keys) {
const prop = propertyName(key);
try {
const out = resolvedDeps.execFileSync(
'zfs',
['get', '-H', '-o', 'value', prop, dataset],
{ encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] },
);
const value = out.trim();
result[key] = !value || value === '-' ? null : value;
} catch {
result[key] = null;
}
}
return result;
}

24
system.env.example Normal file
View file

@ -0,0 +1,24 @@
# system.env
# Hardware and host-layout hints for Clawdie.
# Leave values blank to autodetect. Fill them only if you already know
# your FreeBSD device names and want to override detection.
SYSTEM_SCHEMA_VERSION=1
# Network
NETWORK_EXTERNAL_IF=
NETWORK_INTERNAL_IF=
TAILSCALE_IF=
# Storage
ZFS_POOL=
ZFS_LAYOUT=
ZFS_DATA_DISKS=
ZFS_HOT_SPARES=
ZFS_DISKS=
ZFS_SPARE_DISKS=
ZFS_PREFIX=
# Hardware
GPU_DEVICE=
SND_DEVICE=