feat(primitives): 3 shell provisioning + hardening

- provision-hetzner.sh — idempotent hcloud wrapper; create/destroy/status/list
  * HCLOUD_TOKEN from ~/.claude/secrets/.env (RULE 0.8)
- provision-vultr.sh — idempotent vultr-cli wrapper; Vultr resolves IP async
  * VULTR_API_KEY from ~/.claude/secrets/.env (RULE 0.8)
- harden-base.sh — Debian/Ubuntu baseline; apt → ssh → ufw → fail2ban →
  auditd → unattended-upgrades; idempotent; ports generic patterns from
  vortex/control/setup/setup.sh:13-53 (no Xray/sing-box/WG steps)

All three reject unsupported platforms early; harden-base.sh never
auto-reboots (surfaces needrestart hints only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Parfii-bot 2026-04-21 20:59:38 +08:00
parent 19cbdbd689
commit 969e24c6c4
3 changed files with 600 additions and 0 deletions

240
_primitives/harden-base.sh Executable file
View file

@ -0,0 +1,240 @@
#!/usr/bin/env bash
# harden-base.sh — idempotent Debian/Ubuntu baseline hardening.
# Runs ON THE TARGET VPS (not the local workstation). Ports generic
# patterns from ~/Projects/vortex/control/setup/setup.sh:13-53 — strips
# Vortex-specific Xray/sing-box/Wireguard steps.
#
# USAGE
# curl -fsSL <your-raw-url>/harden-base.sh | sudo bash -s -- [options]
# OR
# scp harden-base.sh keiadmin@host:/tmp/
# ssh keiadmin@host "sudo bash /tmp/harden-base.sh"
#
# OPTIONS
# --admin-user <name> default: keiadmin
# --ssh-port <n> default: 22 (opens in ufw + enforces in sshd drop-in)
# --allow-port <n/proto> repeatable. e.g. --allow-port 443/tcp --allow-port 80/tcp
# --no-caddy skip Caddy install (default: skip — install via its own block)
# --no-reboot default (never reboots; surfaces needrestart hints only)
# --skip <step> repeatable; known steps: apt, ssh, ufw, fail2ban, auditd, unattended
#
# IDEMPOTENCY
# Every step is `test → configure → reload`. Re-run = diff-and-apply.
# Detects the sshd_config.d/99-kei.conf + audit rules files; overwrites with
# known-good content; never destroys /etc/ssh/sshd_config itself.
#
# ENV
# No secrets read. SECRETS SINGLE SOURCE (RULE 0.8) → harden-base does not
# touch tokens/keys.
#
# EXIT
# 0 ok
# 1 usage / platform not supported
# 2 hardening step failed (stderr)
set -euo pipefail
log() { printf '[%s] [harden-base] %s\n' "$(date '+%H:%M:%S')" "$*" >&2; }
die() { log "ERROR: $*"; exit "${2:-2}"; }
# ------------------------------------------------------------------ args
ADMIN_USER="keiadmin"
SSH_PORT="22"
ALLOW_PORTS=()
SKIPS=()
NO_CADDY=1 # default on; Caddy install is its own block
while [ $# -gt 0 ]; do
case "$1" in
--admin-user) ADMIN_USER="$2"; shift 2 ;;
--ssh-port) SSH_PORT="$2"; shift 2 ;;
--allow-port) ALLOW_PORTS+=("$2"); shift 2 ;;
--no-caddy) NO_CADDY=1; shift ;;
--no-reboot) shift ;; # accepted for clarity; we never auto-reboot
--skip) SKIPS+=("$2"); shift 2 ;;
-h|--help) cat <<EOF >&2
harden-base.sh — Debian/Ubuntu baseline hardening.
OPTIONS
--admin-user <name> default: keiadmin
--ssh-port <n> default: 22
--allow-port <n/proto> repeatable (e.g. 443/tcp, 80/tcp)
--no-caddy (default) skip Caddy install
--skip <step> apt|ssh|ufw|fail2ban|auditd|unattended
EOF
exit 0 ;;
*) die "unknown flag '$1'" 1 ;;
esac
done
# ------------------------------------------------------------------ guards
[ "$(id -u)" -eq 0 ] || die "must run as root (sudo)." 1
. /etc/os-release 2>/dev/null || die "cannot read /etc/os-release" 1
case "${ID:-}" in
debian|ubuntu) : ;;
*) die "only Debian/Ubuntu supported (got ID=${ID:-unknown})" 1 ;;
esac
skipped() {
local step="$1"
for s in "${SKIPS[@]}"; do [ "$s" = "$step" ] && return 0; done
return 1
}
# ------------------------------------------------------------------ step: apt
step_apt() {
skipped apt && { log "skip apt"; return; }
log "apt update + base packages…"
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq \
ufw fail2ban unattended-upgrades needrestart auditd audispd-plugins \
curl wget jq ca-certificates openssh-server
}
# ------------------------------------------------------------------ step: admin user
step_admin_user() {
if id "$ADMIN_USER" >/dev/null 2>&1; then
log "user '$ADMIN_USER' exists"
else
log "creating '$ADMIN_USER' (sudo, bash, NOPASSWD)"
useradd -m -s /bin/bash -G sudo "$ADMIN_USER"
install -d -m 0700 -o "$ADMIN_USER" -g "$ADMIN_USER" "/home/$ADMIN_USER/.ssh"
fi
install -d -m 0755 /etc/sudoers.d
cat >/etc/sudoers.d/90-keiadmin <<EOF
$ADMIN_USER ALL=(ALL) NOPASSWD:ALL
EOF
chmod 0440 /etc/sudoers.d/90-keiadmin
visudo -cf /etc/sudoers.d/90-keiadmin >/dev/null
}
# ------------------------------------------------------------------ step: ssh
step_ssh() {
skipped ssh && { log "skip ssh"; return; }
log "ssh: writing /etc/ssh/sshd_config.d/99-kei.conf (port=$SSH_PORT, user=$ADMIN_USER)…"
install -d -m 0755 /etc/ssh/sshd_config.d
cat >/etc/ssh/sshd_config.d/99-kei.conf <<EOF
# GENERATED by harden-base.sh — idempotent. Edit intent, re-run the script.
Port $SSH_PORT
Protocol 2
PasswordAuthentication no
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
PermitEmptyPasswords no
UsePAM yes
MaxAuthTries 3
MaxSessions 4
LoginGraceTime 20
AllowUsers $ADMIN_USER
AllowTcpForwarding no
X11Forwarding no
PermitTunnel no
ClientAliveInterval 120
ClientAliveCountMax 2
LogLevel VERBOSE
KexAlgorithms curve25519-sha256,curve25519-sha256@libssh.org,sntrup761x25519-sha512@openssh.com
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com
HostKeyAlgorithms ssh-ed25519,rsa-sha2-512,rsa-sha2-256
EOF
sshd -t
systemctl reload ssh 2>/dev/null || systemctl reload sshd
}
# ------------------------------------------------------------------ step: ufw
step_ufw() {
skipped ufw && { log "skip ufw"; return; }
log "ufw: default-deny-in + allow-out + ssh rate-limit…"
ufw --force reset >/dev/null
ufw default deny incoming
ufw default allow outgoing
ufw default deny routed
ufw limit "$SSH_PORT/tcp" comment "ssh (rate-limited)"
for p in "${ALLOW_PORTS[@]}"; do
log " allow $p"
ufw allow "$p"
done
ufw logging medium
ufw --force enable
}
# ------------------------------------------------------------------ step: fail2ban
step_fail2ban() {
skipped fail2ban && { log "skip fail2ban"; return; }
log "fail2ban: writing /etc/fail2ban/jail.local (sshd jail)…"
cat >/etc/fail2ban/jail.local <<EOF
[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 5
backend = systemd
[sshd]
enabled = true
port = $SSH_PORT
EOF
systemctl enable --now fail2ban
systemctl restart fail2ban
}
# ------------------------------------------------------------------ step: auditd
step_auditd() {
skipped auditd && { log "skip auditd"; return; }
log "auditd: writing /etc/audit/rules.d/99-kei.rules…"
install -d -m 0750 /etc/audit/rules.d
cat >/etc/audit/rules.d/99-kei.rules <<'EOF'
# GENERATED by harden-base.sh — idempotent baseline.
-w /etc/ssh/sshd_config -p wa -k sshd_config
-w /etc/ssh/sshd_config.d/ -p wa -k sshd_config
-w /root/.ssh/ -p wa -k ssh_keys_root
-w /etc/sudoers -p wa -k sudoers
-w /etc/sudoers.d/ -p wa -k sudoers
-a always,exit -F arch=b64 -S execve -F euid=0 -F auid>=1000 -F auid!=unset -k sudo_root
-w /etc/passwd -p wa -k identity
-w /etc/group -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/gshadow -p wa -k identity
-a always,exit -F arch=b64 -S init_module -S finit_module -S delete_module -k module
-a always,exit -F arch=b64 -S adjtimex -S settimeofday -S clock_settime -k time
-w /etc/localtime -p wa -k time
-e 2
EOF
augenrules --load >/dev/null
systemctl enable --now auditd
}
# ------------------------------------------------------------------ step: unattended-upgrades + needrestart
step_unattended() {
skipped unattended && { log "skip unattended"; return; }
log "unattended-upgrades + needrestart (list-only)…"
cat >/etc/apt/apt.conf.d/20auto-upgrades <<'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
EOF
cat >/etc/apt/apt.conf.d/50unattended-upgrades.kei <<'EOF'
Unattended-Upgrade::Origins-Pattern {
"origin=Debian,codename=${distro_codename}-security";
"origin=Debian,codename=${distro_codename}-updates";
"origin=Ubuntu,archive=${distro_codename}-security";
};
Unattended-Upgrade::Automatic-Reboot "false";
Unattended-Upgrade::MailReport "on-change";
EOF
# needrestart: list services, suppress TTY prompts (non-TTY cron safe).
if [ -f /etc/needrestart/needrestart.conf ]; then
sed -i "s/^#\?\$nrconf{restart}.*/\$nrconf{restart} = 'l';/" /etc/needrestart/needrestart.conf
sed -i "s/^#\?\$nrconf{kernelhints}.*/\$nrconf{kernelhints} = -1;/" /etc/needrestart/needrestart.conf
fi
}
# ------------------------------------------------------------------ main
log "start: admin=$ADMIN_USER ssh=$SSH_PORT extra-ports=${ALLOW_PORTS[*]:-none}"
step_apt
step_admin_user
step_ssh
step_ufw
step_fail2ban
step_auditd
step_unattended
log "done. Next: verify via _primitives/_rust/ssh-check + firewall-diff."

164
_primitives/provision-hetzner.sh Executable file
View file

@ -0,0 +1,164 @@
#!/usr/bin/env bash
# provision-hetzner.sh — idempotent Hetzner Cloud server provisioning.
# Wraps the `hcloud` CLI. Install path:
# $HOME/.claude/agents/_primitives/provision-hetzner.sh
#
# USAGE
# provision-hetzner.sh create <name> [--type cx22|cax11] [--location fsn1] \
# [--image debian-12] [--ssh-key <id>] \
# [--firewall <name>] [--user-data <file>]
# provision-hetzner.sh status <name>
# provision-hetzner.sh destroy <name> [--force]
# provision-hetzner.sh list
#
# ENV (RULE 0.8 — secrets single source)
# HCLOUD_TOKEN — Hetzner API token (REQUIRED). Source:
# $(grep ^HCLOUD_TOKEN ~/.claude/secrets/.env | cut -d= -f2)
#
# EXIT
# 0 ok
# 1 usage / missing args / missing deps / unknown command
# 2 hcloud API error (non-idempotent path — inspect stderr)
#
# IDEMPOTENCY
# `create <name>` on an existing server prints its IP + exits 0.
# `destroy <name>` on a missing server exits 0 (nothing to do).
set -euo pipefail
log() { printf '[%s] [provision-hetzner] %s\n' "$(date '+%H:%M:%S')" "$*" >&2; }
die() { log "ERROR: $*"; exit "${2:-2}"; }
check_deps() {
command -v hcloud >/dev/null 2>&1 || \
die "hcloud CLI missing. Install: brew install hcloud (macOS) | https://github.com/hetznercloud/cli/releases" 1
command -v jq >/dev/null 2>&1 || die "jq missing. brew install jq" 1
[ -n "${HCLOUD_TOKEN:-}" ] || die "HCLOUD_TOKEN not set. Source ~/.claude/secrets/.env first." 1
}
# Print server JSON if it exists, empty string otherwise. Never fails.
server_json() {
local name="$1"
hcloud server describe "$name" -o json 2>/dev/null || true
}
cmd_list() {
check_deps
hcloud server list -o 'columns=id,name,status,ipv4,location,server_type,created'
}
cmd_status() {
check_deps
local name="${1:-}"; [ -n "$name" ] || die "status: <name> required" 1
local json; json=$(server_json "$name")
if [ -z "$json" ]; then
echo "absent"
return 0
fi
printf 'name=%s\nstatus=%s\nipv4=%s\nlocation=%s\ntype=%s\n' \
"$(jq -r .name <<<"$json")" \
"$(jq -r .status <<<"$json")" \
"$(jq -r '.public_net.ipv4.ip // "-"' <<<"$json")" \
"$(jq -r .datacenter.location.name <<<"$json")" \
"$(jq -r .server_type.name <<<"$json")"
}
cmd_create() {
check_deps
local name="${1:-}"; shift || true
[ -n "$name" ] || die "create: <name> required" 1
local type="cx22" location="fsn1" image="debian-12"
local ssh_key="" firewall="" user_data=""
while [ $# -gt 0 ]; do
case "$1" in
--type) type="$2"; shift 2 ;;
--location) location="$2"; shift 2 ;;
--image) image="$2"; shift 2 ;;
--ssh-key) ssh_key="$2"; shift 2 ;;
--firewall) firewall="$2"; shift 2 ;;
--user-data) user_data="$2"; shift 2 ;;
*) die "create: unknown flag '$1'" 1 ;;
esac
done
# Idempotent fast-path: if the server already exists, just print its IP.
local existing; existing=$(server_json "$name")
if [ -n "$existing" ]; then
local ip; ip=$(jq -r '.public_net.ipv4.ip // "-"' <<<"$existing")
log "server '$name' already exists → $ip (no-op)"
echo "$ip"
return 0
fi
local args=(server create
--name "$name"
--type "$type"
--image "$image"
--location "$location"
--label "project=kei"
)
[ -n "$ssh_key" ] && args+=(--ssh-key "$ssh_key")
[ -n "$firewall" ] && args+=(--firewall "$firewall")
[ -n "$user_data" ] && { [ -r "$user_data" ] || die "user-data not readable: $user_data" 1; args+=(--user-data-from-file "$user_data"); }
log "creating '$name' ($type @ $location, image=$image)…"
hcloud "${args[@]}" -o json >/tmp/provision-hetzner-$$.json
local ip; ip=$(jq -r '.server.public_net.ipv4.ip' /tmp/provision-hetzner-$$.json)
rm -f /tmp/provision-hetzner-$$.json
[ "$ip" != "null" ] && [ -n "$ip" ] || die "create returned no IPv4 (check stderr)"
log "created '$name' → $ip"
echo "$ip"
}
cmd_destroy() {
check_deps
local name="${1:-}"; shift || true
[ -n "$name" ] || die "destroy: <name> required" 1
local force=""
[ "${1:-}" = "--force" ] && force=1
local existing; existing=$(server_json "$name")
if [ -z "$existing" ]; then
log "server '$name' absent (no-op)"
return 0
fi
if [ -z "$force" ]; then
printf 'Destroy server "%s"? [y/N] ' "$name" >&2
read -r ans
[ "$ans" = "y" ] || [ "$ans" = "Y" ] || { log "aborted"; return 1; }
fi
log "deleting '$name'…"
hcloud server delete "$name" >&2
log "deleted '$name'"
}
main() {
local cmd="${1:-}"; shift || true
case "$cmd" in
create) cmd_create "$@" ;;
destroy) cmd_destroy "$@" ;;
status) cmd_status "$@" ;;
list) cmd_list "$@" ;;
-h|--help|"") cat <<EOF >&2
provision-hetzner.sh — idempotent Hetzner Cloud server provisioning.
USAGE
provision-hetzner.sh create <name> [--type cx22|cax11] [--location fsn1] \\
[--image debian-12] [--ssh-key <id>] \\
[--firewall <name>] [--user-data <file>]
provision-hetzner.sh status <name>
provision-hetzner.sh destroy <name> [--force]
provision-hetzner.sh list
ENV
HCLOUD_TOKEN (required) — load via: source ~/.claude/secrets/.env
EOF
[ "$cmd" = "-h" ] || [ "$cmd" = "--help" ] && exit 0 || exit 1
;;
*) die "unknown command '$cmd'. Run --help." 1 ;;
esac
}
main "$@"

196
_primitives/provision-vultr.sh Executable file
View file

@ -0,0 +1,196 @@
#!/usr/bin/env bash
# provision-vultr.sh — idempotent Vultr VPS provisioning.
# Wraps the `vultr-cli` v3. Install path:
# $HOME/.claude/agents/_primitives/provision-vultr.sh
#
# USAGE
# provision-vultr.sh create <label> [--plan vc2-1c-1gb] [--region ams] \
# [--os-id 2136] [--ssh-key <id>] \
# [--firewall <group-id>] [--user-data <file>]
# provision-vultr.sh status <label>
# provision-vultr.sh destroy <label> [--force]
# provision-vultr.sh list
#
# ENV (RULE 0.8 — secrets single source)
# VULTR_API_KEY — Vultr API key (REQUIRED). Source:
# $(grep ^VULTR_API_KEY ~/.claude/secrets/.env | cut -d= -f2)
#
# NOTES
# * vultr-cli v3: `vultr-cli instance create …` (not `server`).
# * --os-id 2136 = Debian 12 x86_64 (subject to change; verify via
# `vultr-cli os list | grep Debian`). We do NOT hard-code the ID.
# * Vultr identifies instances by UUID; we use the human-friendly `label`
# field for idempotency. Labels must be unique within the account.
#
# EXIT
# 0 ok
# 1 usage / missing args / missing deps / unknown command
# 2 vultr API error
set -euo pipefail
log() { printf '[%s] [provision-vultr] %s\n' "$(date '+%H:%M:%S')" "$*" >&2; }
die() { log "ERROR: $*"; exit "${2:-2}"; }
check_deps() {
command -v vultr-cli >/dev/null 2>&1 || \
die "vultr-cli missing. Install: brew install vultr/vultr-cli/vultr-cli | https://github.com/vultr/vultr-cli" 1
command -v jq >/dev/null 2>&1 || die "jq missing. brew install jq" 1
[ -n "${VULTR_API_KEY:-}" ] || die "VULTR_API_KEY not set. Source ~/.claude/secrets/.env first." 1
}
# Return JSON of instance with matching label, or empty string.
instance_json_by_label() {
local label="$1"
vultr-cli instance list -o json 2>/dev/null \
| jq -c --arg l "$label" '.instances[] | select(.label == $l)' \
| head -n1
}
cmd_list() {
check_deps
vultr-cli instance list -o json \
| jq -r '.instances[] | [.label, .region, .plan, .status, .main_ip] | @tsv'
}
cmd_status() {
check_deps
local label="${1:-}"; [ -n "$label" ] || die "status: <label> required" 1
local json; json=$(instance_json_by_label "$label")
if [ -z "$json" ]; then
echo "absent"
return 0
fi
printf 'label=%s\nid=%s\nstatus=%s\npower=%s\nip=%s\nregion=%s\nplan=%s\n' \
"$(jq -r .label <<<"$json")" \
"$(jq -r .id <<<"$json")" \
"$(jq -r .status <<<"$json")" \
"$(jq -r .power_status <<<"$json")" \
"$(jq -r .main_ip <<<"$json")" \
"$(jq -r .region <<<"$json")" \
"$(jq -r .plan <<<"$json")"
}
resolve_debian_12_os() {
# Return the OS id for "Debian 12 x64" (subject to Vultr catalog updates).
vultr-cli os list -o json \
| jq -r '.os[] | select(.name | test("Debian 12.*x64"; "i")) | .id' \
| head -n1
}
cmd_create() {
check_deps
local label="${1:-}"; shift || true
[ -n "$label" ] || die "create: <label> required" 1
local plan="vc2-1c-1gb" region="ams" os_id=""
local ssh_key="" firewall="" user_data=""
while [ $# -gt 0 ]; do
case "$1" in
--plan) plan="$2"; shift 2 ;;
--region) region="$2"; shift 2 ;;
--os-id) os_id="$2"; shift 2 ;;
--ssh-key) ssh_key="$2"; shift 2 ;;
--firewall) firewall="$2"; shift 2 ;;
--user-data) user_data="$2"; shift 2 ;;
*) die "create: unknown flag '$1'" 1 ;;
esac
done
# Idempotency.
local existing; existing=$(instance_json_by_label "$label")
if [ -n "$existing" ]; then
local ip; ip=$(jq -r '.main_ip // "-"' <<<"$existing")
log "instance '$label' already exists → $ip (no-op)"
echo "$ip"
return 0
fi
[ -z "$os_id" ] && { os_id=$(resolve_debian_12_os) || true; }
[ -n "$os_id" ] || die "cannot resolve Debian 12 OS id. Pass --os-id explicitly." 1
local args=(instance create
--region "$region"
--plan "$plan"
--os "$os_id"
--label "$label"
--tags "project=kei"
)
[ -n "$ssh_key" ] && args+=(--ssh-keys "$ssh_key")
[ -n "$firewall" ] && args+=(--firewall-group-id "$firewall")
if [ -n "$user_data" ]; then
[ -r "$user_data" ] || die "user-data not readable: $user_data" 1
# vultr-cli expects base64 for userdata.
args+=(--userdata "$(base64 < "$user_data" | tr -d '\n')")
fi
log "creating '$label' ($plan @ $region, os=$os_id)…"
vultr-cli "${args[@]}" -o json >/tmp/provision-vultr-$$.json
local ip; ip=$(jq -r '.instance.main_ip' /tmp/provision-vultr-$$.json)
rm -f /tmp/provision-vultr-$$.json
# Vultr assigns IP asynchronously — re-poll if empty.
if [ "$ip" = "" ] || [ "$ip" = "null" ] || [ "$ip" = "0.0.0.0" ]; then
log "IP pending — polling instance status up to 60s…"
for _ in $(seq 1 30); do
sleep 2
ip=$(instance_json_by_label "$label" | jq -r '.main_ip // ""')
[ -n "$ip" ] && [ "$ip" != "0.0.0.0" ] && break
done
fi
[ -n "$ip" ] && [ "$ip" != "0.0.0.0" ] || die "create: no IPv4 after 60s poll"
log "created '$label' → $ip"
echo "$ip"
}
cmd_destroy() {
check_deps
local label="${1:-}"; shift || true
[ -n "$label" ] || die "destroy: <label> required" 1
local force=""
[ "${1:-}" = "--force" ] && force=1
local existing; existing=$(instance_json_by_label "$label")
if [ -z "$existing" ]; then
log "instance '$label' absent (no-op)"
return 0
fi
local id; id=$(jq -r .id <<<"$existing")
if [ -z "$force" ]; then
printf 'Destroy instance "%s" (%s)? [y/N] ' "$label" "$id" >&2
read -r ans
[ "$ans" = "y" ] || [ "$ans" = "Y" ] || { log "aborted"; return 1; }
fi
log "deleting '$label' ($id)…"
vultr-cli instance delete "$id" >&2
log "deleted '$label'"
}
main() {
local cmd="${1:-}"; shift || true
case "$cmd" in
create) cmd_create "$@" ;;
destroy) cmd_destroy "$@" ;;
status) cmd_status "$@" ;;
list) cmd_list "$@" ;;
-h|--help|"") cat <<EOF >&2
provision-vultr.sh — idempotent Vultr VPS provisioning.
USAGE
provision-vultr.sh create <label> [--plan vc2-1c-1gb] [--region ams] \\
[--os-id <id>] [--ssh-key <id>] \\
[--firewall <group-id>] [--user-data <file>]
provision-vultr.sh status <label>
provision-vultr.sh destroy <label> [--force]
provision-vultr.sh list
ENV
VULTR_API_KEY (required) — load via: source ~/.claude/secrets/.env
EOF
[ "$cmd" = "-h" ] || [ "$cmd" = "--help" ] && exit 0 || exit 1
;;
*) die "unknown command '$cmd'. Run --help." 1 ;;
esac
}
main "$@"