forked from EduCraft/curriculum-project-hub
feat: add repeatable alpha silo setup
This commit is contained in:
Executable
+360
@@ -0,0 +1,360 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# A wizard — walks a human through a manual procedure step by step.
|
||||
# Generated by the /wizard skill.
|
||||
#
|
||||
# Everything above the "STAGES" marker is the wizard library: do not hand-edit
|
||||
# it. Author the per-step stages below the marker.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# Wizard library — delightful, consistent UX. Identical across every wizard.
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
if [[ -t 1 ]] && command -v tput >/dev/null 2>&1 && [[ "$(tput colors 2>/dev/null || echo 0)" -ge 8 ]]; then
|
||||
BOLD=$(tput bold); DIM=$(tput dim); RESET=$(tput sgr0)
|
||||
BLUE=$(tput setaf 4); GREEN=$(tput setaf 2); YELLOW=$(tput setaf 3); RED=$(tput setaf 1)
|
||||
else
|
||||
BOLD=""; DIM=""; RESET=""; BLUE=""; GREEN=""; YELLOW=""; RED=""
|
||||
fi
|
||||
|
||||
# Author sets these two at the top of the stages section.
|
||||
TOTAL_STAGES=0
|
||||
TOTAL_MINUTES=0
|
||||
|
||||
_STAGE_INDEX=0
|
||||
_MINUTES_ELAPSED=0
|
||||
ENV_FILE="${ENV_FILE:-.env}"
|
||||
WRITTEN_ENV=() # KEYs written to ENV_FILE this run
|
||||
WRITTEN_SECRET=() # secret NAMEs set this run
|
||||
SKIPPED=() # things we couldn't do (e.g. gh missing)
|
||||
|
||||
# _clear — wipe the terminal so only the current step is on screen. No-op when
|
||||
# output isn't a terminal, so piped logs stay readable.
|
||||
_clear() {
|
||||
[[ -t 1 ]] || return 0
|
||||
if command -v tput >/dev/null 2>&1; then tput clear; else printf '\033[2J\033[3J\033[H'; fi
|
||||
}
|
||||
|
||||
# banner "Title" — opening frame: what this wizard does and how long it takes.
|
||||
banner() {
|
||||
_clear
|
||||
printf '\n%s%s %s%s\n' "$BOLD" "$BLUE" "$1" "$RESET"
|
||||
printf '%s %s stages · about %s minutes%s\n\n' \
|
||||
"$DIM" "$TOTAL_STAGES" "$TOTAL_MINUTES" "$RESET"
|
||||
printf '%s You drive the browser; this wizard tells you exactly what to do and\n' "$DIM"
|
||||
printf ' captures the values you copy back. Stop any time with Ctrl-C and re-run\n'
|
||||
printf ' later — it remembers values already saved.%s\n' "$RESET"
|
||||
pause "Ready to start?"
|
||||
}
|
||||
|
||||
# stage "Name" <minutes> — clear the screen, then announce a stage and show
|
||||
# progress + time remaining. Clearing keeps only the current step on screen.
|
||||
stage() {
|
||||
_clear
|
||||
_STAGE_INDEX=$((_STAGE_INDEX + 1))
|
||||
local remaining=$((TOTAL_MINUTES - _MINUTES_ELAPSED))
|
||||
(( remaining < 0 )) && remaining=0
|
||||
_MINUTES_ELAPSED=$((_MINUTES_ELAPSED + ${2:-0}))
|
||||
printf '\n%s%s▸ Stage %s/%s · %s%s %s(~%s min left)%s\n' \
|
||||
"$BOLD" "$BLUE" "$_STAGE_INDEX" "$TOTAL_STAGES" "$1" "$RESET" "$DIM" "$remaining" "$RESET"
|
||||
}
|
||||
|
||||
# say "..." — a plain instruction line.
|
||||
say() { printf ' %s\n' "$1"; }
|
||||
# step "..." — a numbered-feeling action the human takes in the browser.
|
||||
step() { printf ' %s•%s %s\n' "$BLUE" "$RESET" "$1"; }
|
||||
note() { printf ' %s%s%s\n' "$DIM" "$1" "$RESET"; }
|
||||
warn() { printf ' %s⚠ %s%s\n' "$YELLOW" "$1" "$RESET"; }
|
||||
|
||||
# open_url URL — open in the human's browser, cross-platform incl. WSL.
|
||||
open_url() {
|
||||
local url="$1"
|
||||
printf ' %s↗ opening%s %s\n' "$GREEN" "$RESET" "$url"
|
||||
{ if command -v wslview >/dev/null 2>&1; then wslview "$url"
|
||||
elif command -v explorer.exe >/dev/null 2>&1; then explorer.exe "$url"
|
||||
elif command -v xdg-open >/dev/null 2>&1; then xdg-open "$url"
|
||||
elif command -v open >/dev/null 2>&1; then open "$url"
|
||||
else warn "couldn't open a browser — visit it manually: $url"; fi
|
||||
} >/dev/null 2>&1 || warn "couldn't open a browser — visit it manually: $url"
|
||||
}
|
||||
|
||||
# pause "msg" — wait for the human to confirm they've done the manual part.
|
||||
pause() {
|
||||
printf ' %s%s%s ' "$DIM" "${1:-Press Enter to continue}" "$RESET"
|
||||
read -r _ || true
|
||||
}
|
||||
|
||||
# confirm "question" — y/N gate; returns success on yes.
|
||||
confirm() {
|
||||
local reply=""
|
||||
printf ' %s? %s [y/N] ' "$YELLOW" "$1"
|
||||
read -r reply || true
|
||||
[[ "$reply" =~ ^[Yy] ]]
|
||||
}
|
||||
|
||||
# _existing KEY — current value of KEY in ENV_FILE, if any.
|
||||
_existing() {
|
||||
[[ -f "$ENV_FILE" ]] || return 1
|
||||
local line; line=$(grep -E "^${1}=" "$ENV_FILE" | tail -n1) || return 1
|
||||
printf '%s' "${line#*=}"
|
||||
}
|
||||
|
||||
# ask KEY "Prompt" — read a value into $KEY. Offers the existing .env value as
|
||||
# a default on re-runs (Enter keeps it). Visible input (non-secret).
|
||||
ask() {
|
||||
local key="$1" prompt="$2" current input
|
||||
current=$(_existing "$key" || true)
|
||||
if [[ -n "$current" ]]; then
|
||||
printf ' %s%s%s %s[Enter keeps current]%s ' "$BOLD" "$prompt" "$RESET" "$DIM" "$RESET"
|
||||
else
|
||||
printf ' %s%s%s ' "$BOLD" "$prompt" "$RESET"
|
||||
fi
|
||||
read -r input || true
|
||||
[[ -z "$input" && -n "$current" ]] && input="$current"
|
||||
printf -v "$key" '%s' "$input"
|
||||
}
|
||||
|
||||
# ask_secret KEY "Prompt" — like ask, but input is hidden.
|
||||
ask_secret() {
|
||||
local key="$1" prompt="$2" current input
|
||||
current=$(_existing "$key" || true)
|
||||
if [[ -n "$current" ]]; then
|
||||
printf ' %s%s%s %s[Enter keeps current]%s ' "$BOLD" "$prompt" "$RESET" "$DIM" "$RESET"
|
||||
else
|
||||
printf ' %s%s%s ' "$BOLD" "$prompt" "$RESET"
|
||||
fi
|
||||
read -rs input || true
|
||||
printf '\n'
|
||||
[[ -z "$input" && -n "$current" ]] && input="$current"
|
||||
printf -v "$key" '%s' "$input"
|
||||
}
|
||||
|
||||
# write_env KEY VALUE — upsert KEY=VALUE into ENV_FILE (creates it; replaces
|
||||
# any existing line). Idempotent.
|
||||
write_env() {
|
||||
local key="$1" value="$2" tmp
|
||||
touch "$ENV_FILE"
|
||||
tmp=$(mktemp)
|
||||
grep -vE "^${key}=" "$ENV_FILE" > "$tmp" || true
|
||||
printf '%s=%s\n' "$key" "$value" >> "$tmp"
|
||||
mv "$tmp" "$ENV_FILE"
|
||||
WRITTEN_ENV+=("$key")
|
||||
printf ' %s✓ wrote%s %s → %s\n' "$GREEN" "$RESET" "$key" "$ENV_FILE"
|
||||
}
|
||||
|
||||
# set_secret NAME VALUE — set a GitHub Actions repo secret via gh. Falls back
|
||||
# to a warning (and records it) if gh is unavailable or unauthenticated.
|
||||
set_secret() {
|
||||
local name="$1" value="$2"
|
||||
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
|
||||
if printf '%s' "$value" | gh secret set "$name" >/dev/null 2>&1; then
|
||||
WRITTEN_SECRET+=("$name")
|
||||
printf ' %s✓ set%s GitHub secret %s\n' "$GREEN" "$RESET" "$name"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
SKIPPED+=("GitHub secret $name (set it manually: gh secret set $name)")
|
||||
warn "skipped GitHub secret $name — gh not ready; set it later"
|
||||
}
|
||||
|
||||
# set_var NAME VALUE — set a GitHub Actions repo variable (non-secret).
|
||||
set_var() {
|
||||
local name="$1" value="$2"
|
||||
if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then
|
||||
if gh variable set "$name" --body "$value" >/dev/null 2>&1; then
|
||||
printf ' %s✓ set%s GitHub variable %s\n' "$GREEN" "$RESET" "$name"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
SKIPPED+=("GitHub variable $name")
|
||||
warn "skipped GitHub variable $name — gh not ready; set it later"
|
||||
}
|
||||
|
||||
# finish — clear, then a closing summary of everything configured.
|
||||
finish() {
|
||||
_clear
|
||||
printf '\n%s%s ✓ Setup complete%s\n' "$BOLD" "$GREEN" "$RESET"
|
||||
(( ${#WRITTEN_ENV[@]} )) && note "wrote ${#WRITTEN_ENV[@]} value(s) to $ENV_FILE: ${WRITTEN_ENV[*]}"
|
||||
(( ${#WRITTEN_SECRET[@]} )) && note "set ${#WRITTEN_SECRET[@]} GitHub secret(s): ${WRITTEN_SECRET[*]}"
|
||||
if (( ${#SKIPPED[@]} )); then
|
||||
printf '\n'; warn "still to do by hand:"
|
||||
for s in "${SKIPPED[@]}"; do note " - $s"; done
|
||||
fi
|
||||
printf '\n'
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
# STAGES — author this section. One stage() per step the human takes.
|
||||
# Replace the example below. Set the two totals to match the stages you write.
|
||||
# ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
TOTAL_STAGES=7
|
||||
TOTAL_MINUTES=35
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
PLAN_ROOT="${CPH_SILO_PLAN_ROOT:-$HOME/.cph-silo-plans}"
|
||||
umask 077
|
||||
|
||||
require_value() {
|
||||
local key="$1" value="$2"
|
||||
[[ -n "$value" ]] || { warn "$key is required"; exit 1; }
|
||||
[[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || {
|
||||
warn "$key must be a single line"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
seed_default() {
|
||||
local key="$1" value="$2"
|
||||
_existing "$key" >/dev/null 2>&1 || write_env "$key" "$value"
|
||||
}
|
||||
|
||||
capture() {
|
||||
local key="$1" prompt="$2"
|
||||
ask "$key" "$prompt"
|
||||
require_value "$key" "${!key}"
|
||||
write_env "$key" "${!key}"
|
||||
}
|
||||
|
||||
capture_secret() {
|
||||
local key="$1" prompt="$2"
|
||||
ask_secret "$key" "$prompt"
|
||||
require_value "$key" "${!key}"
|
||||
write_env "$key" "${!key}"
|
||||
}
|
||||
|
||||
banner "New Alpha Silo"
|
||||
|
||||
stage "Silo identity and private plan" 3
|
||||
say "One run creates one Organization's private deployment bundle."
|
||||
ask INSTANCE_ID "Unique instance id (lowercase, max 24 chars; e.g. school-a):"
|
||||
require_value INSTANCE_ID "$INSTANCE_ID"
|
||||
[[ "$INSTANCE_ID" =~ ^[a-z0-9]([a-z0-9-]{0,22}[a-z0-9])?$ ]] || {
|
||||
warn "invalid instance id"
|
||||
exit 1
|
||||
}
|
||||
OUTPUT_DIR="$PLAN_ROOT/$INSTANCE_ID"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
chmod 0700 "$OUTPUT_DIR"
|
||||
ENV_FILE="$OUTPUT_DIR/answers.env"
|
||||
touch "$ENV_FILE"
|
||||
chmod 0600 "$ENV_FILE"
|
||||
write_env INSTANCE_ID "$INSTANCE_ID"
|
||||
seed_default ORGANIZATION_ID "$INSTANCE_ID"
|
||||
seed_default ORGANIZATION_SLUG "$INSTANCE_ID"
|
||||
capture ORGANIZATION_ID "Organization id:"
|
||||
capture ORGANIZATION_SLUG "Organization slug:"
|
||||
capture ORGANIZATION_NAME "Organization display name:"
|
||||
|
||||
stage "Host, release, and isolation" 5
|
||||
say "Choose values that are unique on the shared host. The service binds loopback only."
|
||||
seed_default DEPLOY_USER "root"
|
||||
seed_default DEPLOY_SSH_PORT "22"
|
||||
seed_default DEPLOY_BASE "/srv/curriculum-project-hub"
|
||||
seed_default RELEASE_ID "$(git -C "$REPO_ROOT" describe --tags --exact-match 2>/dev/null || git -C "$REPO_ROOT" rev-parse --short HEAD)"
|
||||
seed_default MEMORY_MAX "16G"
|
||||
seed_default CPU_QUOTA "400%"
|
||||
seed_default TASKS_MAX "512"
|
||||
seed_default CPH_BIN "/usr/local/bin/cph"
|
||||
capture DEPLOY_HOST "Server IP or SSH host:"
|
||||
capture DEPLOY_USER "SSH deploy user:"
|
||||
capture DEPLOY_SSH_PORT "SSH port:"
|
||||
capture DEPLOY_SSH_KEY "Absolute path to SSH private key:"
|
||||
capture DEPLOY_BASE "Remote release base:"
|
||||
capture RELEASE_ID "Immutable release id/tag:"
|
||||
capture HUB_PORT "Unique loopback Hub port:"
|
||||
capture WORKSPACE_ROOT "Unique short workspace path (at most 16 bytes; e.g. /w/102):"
|
||||
capture MEMORY_MAX "systemd MemoryMax:"
|
||||
capture CPU_QUOTA "systemd CPUQuota:"
|
||||
capture TASKS_MAX "systemd TasksMax:"
|
||||
capture CPH_BIN "Remote cph binary path:"
|
||||
|
||||
stage "Dedicated PostgreSQL database" 4
|
||||
say "A PostgreSQL server may be shared, but this Silo gets a distinct login role and database."
|
||||
seed_default DATABASE_HOST "127.0.0.1"
|
||||
seed_default DATABASE_PORT "5432"
|
||||
seed_default DATABASE_NAME "cph_${INSTANCE_ID//-/_}"
|
||||
seed_default DATABASE_USER "cph_${INSTANCE_ID//-/_}"
|
||||
capture DATABASE_HOST "Database host as seen by the Hub service:"
|
||||
capture DATABASE_PORT "Database port:"
|
||||
capture DATABASE_NAME "Dedicated database name:"
|
||||
capture DATABASE_USER "Dedicated database login role:"
|
||||
capture_secret DATABASE_PASSWORD "New database password:"
|
||||
note "The generated OPERATE.md uses an interactive/protected SQL path; the password is never put in a command argument."
|
||||
|
||||
stage "Public URL and Feishu app" 9
|
||||
open_url "https://open.feishu.cn/app"
|
||||
say "Create or open the Organization's own app. Copy credentials from Credentials & Basic Info."
|
||||
capture PUBLIC_BASE_URL "Public base URL including https:// (e.g. https://school-a.example.com):"
|
||||
capture FEISHU_APP_ID "Feishu App ID:"
|
||||
capture_secret FEISHU_APP_SECRET "Feishu App Secret:"
|
||||
capture FEISHU_BOT_OPEN_ID "Bot Open ID:"
|
||||
open_url "https://open.feishu.cn/document/server-docs/contact-v3/user/get"
|
||||
step "In the user/get page, click the user_id value picker, select the first OWNER, and copy the returned open_id."
|
||||
capture OWNER_OPEN_ID "OWNER Open ID (ou_...):"
|
||||
capture OWNER_DISPLAY_NAME "OWNER display name:"
|
||||
ask OWNER_UNION_ID "OWNER Union ID (optional; Enter to skip):"
|
||||
write_env OWNER_UNION_ID "$OWNER_UNION_ID"
|
||||
say "The exact redirect URL and acceptance steps will be written to OPERATE.md."
|
||||
|
||||
stage "Provider and Alpha limits" 5
|
||||
say "Use a provider credential exclusive to this Organization. Host proxy setup is a separate prerequisite."
|
||||
seed_default PROVIDER_ID "openrouter"
|
||||
seed_default PROVIDER_BASE_URL "https://openrouter.ai/api"
|
||||
seed_default DEFAULT_MODEL "anthropic/claude-sonnet-5"
|
||||
seed_default DEFAULT_ROLE_ID "draft"
|
||||
seed_default DEFAULT_ROLE_LABEL "草稿"
|
||||
seed_default MAX_TURNS "25"
|
||||
seed_default MAX_CONCURRENT_RUNS "4"
|
||||
seed_default MAX_RUN_SECONDS "900"
|
||||
seed_default HTTP_BODY_LIMIT_BYTES "1048576"
|
||||
seed_default MAX_FILES_PER_MESSAGE "8"
|
||||
seed_default MAX_FILE_BYTES "26214400"
|
||||
seed_default HTTP_REQUESTS_PER_MINUTE "120"
|
||||
seed_default FEISHU_EVENTS_PER_MINUTE "120"
|
||||
capture PROVIDER_ID "Provider id:"
|
||||
capture PROVIDER_BASE_URL "Provider base URL:"
|
||||
capture_secret PROVIDER_AUTH_TOKEN "Provider auth token:"
|
||||
capture DEFAULT_MODEL "Default model id exposed by this provider:"
|
||||
capture DEFAULT_ROLE_ID "Default role id:"
|
||||
capture DEFAULT_ROLE_LABEL "Default role label:"
|
||||
ask APPROVED_SKILLS "Approved installed skill names, comma-separated (optional):"
|
||||
write_env APPROVED_SKILLS "$APPROVED_SKILLS"
|
||||
capture MAX_TURNS "Maximum turns per run:"
|
||||
capture MAX_CONCURRENT_RUNS "Organization concurrent runs:"
|
||||
capture MAX_RUN_SECONDS "Maximum run seconds:"
|
||||
capture HTTP_BODY_LIMIT_BYTES "HTTP body limit bytes:"
|
||||
capture MAX_FILES_PER_MESSAGE "Maximum files per message:"
|
||||
capture MAX_FILE_BYTES "Maximum bytes per file:"
|
||||
capture HTTP_REQUESTS_PER_MINUTE "HTTP requests per minute:"
|
||||
capture FEISHU_EVENTS_PER_MINUTE "Feishu events per minute:"
|
||||
if ! _existing HUB_SESSION_SECRET >/dev/null 2>&1; then
|
||||
command -v openssl >/dev/null 2>&1 || { warn "openssl is required"; exit 1; }
|
||||
HUB_SESSION_SECRET="$(openssl rand -hex 32)"
|
||||
write_env HUB_SESSION_SECRET "$HUB_SESSION_SECRET"
|
||||
fi
|
||||
|
||||
stage "Render the private deployment bundle" 2
|
||||
say "This renders platform.env, bootstrap.json, deploy.env, nginx.conf and OPERATE.md."
|
||||
command -v node >/dev/null 2>&1 || { warn "Node.js is required to render safely"; exit 1; }
|
||||
node "$SCRIPT_DIR/render_new_silo_bundle.mjs" "$ENV_FILE" "$OUTPUT_DIR"
|
||||
chmod 0700 "$OUTPUT_DIR"
|
||||
chmod 0600 "$OUTPUT_DIR"/*
|
||||
say "Bundle: $OUTPUT_DIR"
|
||||
warn "It contains database, Feishu, provider and session secrets. Never commit or paste it."
|
||||
|
||||
stage "Operator handoff and gates" 7
|
||||
say "Open OPERATE.md and execute its stages in order. Nothing has changed on the server yet."
|
||||
step "Verify DNS, Feishu redirect/permissions/events and the host proxy."
|
||||
step "Create the dedicated database role/database and publish the release."
|
||||
step "Install root-only secrets, back up the keyring, migrate and bootstrap."
|
||||
step "Install only reviewed runtime skills and the default role configuration."
|
||||
step "Validate Nginx, start the service, run health/Feishu/session/Typst acceptance, then back up."
|
||||
if confirm "Print the bundle filenames now?"; then
|
||||
find "$OUTPUT_DIR" -maxdepth 1 -type f -exec basename {} \; | sort
|
||||
fi
|
||||
|
||||
finish
|
||||
Reference in New Issue
Block a user