#!/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" — 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}" } allocate_host_slot() { local allocation local_reserved="" answers_file reserved_port for answers_file in "$PLAN_ROOT"/*/answers.env; do [ -f "$answers_file" ] || continue reserved_port="$(sed -n 's/^HUB_PORT=//p' "$answers_file" | tail -n1)" [[ "$reserved_port" =~ ^[0-9]+$ ]] || continue local_reserved="${local_reserved:+$local_reserved,}$reserved_port" done allocation="$(ssh \ -i "$DEPLOY_SSH_KEY" \ -p "$DEPLOY_SSH_PORT" \ -o BatchMode=yes \ -o StrictHostKeyChecking=accept-new \ "$DEPLOY_USER@$DEPLOY_HOST" \ bash -s -- "$local_reserved" <<'REMOTE' set -euo pipefail local_reserved=",${1:-}," for candidate in $(seq 8788 8999); do reserved=false if [[ "$local_reserved" == *",$candidate,"* ]]; then continue fi for env_file in /srv/curriculum-project-hub/.secrets/*/platform.env; do [ -f "$env_file" ] || continue if [ "$(sed -n "s/^PORT=//p" "$env_file")" = "$candidate" ]; then reserved=true break fi done workspace="/w/$candidate" if [ "$reserved" = false ] && ! ss -H -ltn "sport = :$candidate" | grep -q . && [ ! -e "$workspace" ]; then printf "%s %s\n" "$candidate" "$workspace" exit 0 fi done echo "no free Alpha Silo slot in 8788..8999" >&2 exit 1 REMOTE )" read -r HUB_PORT WORKSPACE_ROOT <<<"$allocation" require_value HUB_PORT "$HUB_PORT" require_value WORKSPACE_ROOT "$WORKSPACE_ROOT" write_env HUB_PORT "$HUB_PORT" write_env WORKSPACE_ROOT "$WORKSPACE_ROOT" } banner "New Alpha Silo" stage "Organization identity and private plan" 3 say "One run creates one Organization's private deployment bundle." ask ORGANIZATION_SLUG "Organization slug (lowercase, max 24 chars; e.g. school-a):" require_value ORGANIZATION_SLUG "$ORGANIZATION_SLUG" [[ "$ORGANIZATION_SLUG" =~ ^[a-z0-9]([a-z0-9-]{0,22}[a-z0-9])?$ ]] || { warn "invalid Organization slug" exit 1 } INSTANCE_ID="$ORGANIZATION_SLUG" ORGANIZATION_ID="$ORGANIZATION_SLUG" 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" write_env ORGANIZATION_ID "$ORGANIZATION_ID" write_env ORGANIZATION_SLUG "$ORGANIZATION_SLUG" capture ORGANIZATION_NAME "Organization display name:" stage "Host, release, and isolation" 5 say "The Alpha host is platform-managed. Port and short workspace path are allocated from live host state." DEPLOY_HOST="${CPH_ALPHA_HOST:-39.107.254.4}" DEPLOY_USER="${CPH_ALPHA_DEPLOY_USER:-root}" DEPLOY_SSH_PORT="${CPH_ALPHA_SSH_PORT:-22}" write_env DEPLOY_HOST "$DEPLOY_HOST" write_env DEPLOY_USER "$DEPLOY_USER" write_env DEPLOY_SSH_PORT "$DEPLOY_SSH_PORT" seed_default DEPLOY_BASE "/srv/curriculum-project-hub" seed_default DEPLOY_SSH_KEY "$HOME/.ssh/id_ed25519" write_env RELEASE_ID "v$(node -p 'require(process.argv[1]).version' "$REPO_ROOT/hub/package.json")" seed_default MEMORY_MAX "16G" seed_default CPU_QUOTA "400%" seed_default TASKS_MAX "512" seed_default CPH_BIN "/usr/local/bin/cph" note "Managed Alpha host: $DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_SSH_PORT" DEPLOY_SSH_KEY="$(_existing DEPLOY_SSH_KEY)" MEMORY_MAX="$(_existing MEMORY_MAX)" CPU_QUOTA="$(_existing CPU_QUOTA)" TASKS_MAX="$(_existing TASKS_MAX)" [ -f "$DEPLOY_SSH_KEY" ] || { warn "managed SSH key is missing: $DEPLOY_SSH_KEY"; exit 1; } if [[ "$(_existing HUB_PORT || true)" =~ ^(878[8-9]|87[9][0-9]|8[89][0-9]{2})$ ]] && \ [ "$(_existing WORKSPACE_ROOT || true)" = "/w/$(_existing HUB_PORT)" ]; then HUB_PORT="$(_existing HUB_PORT)" WORKSPACE_ROOT="$(_existing WORKSPACE_ROOT)" note "Keeping allocated host slot: port $HUB_PORT, workspace $WORKSPACE_ROOT" else say "Checking existing Silo environments, listening sockets, and workspace paths..." allocate_host_slot note "Allocated host slot: port $HUB_PORT, workspace $WORKSPACE_ROOT" fi note "Platform ceilings: MemoryMax=$MEMORY_MAX, CPUQuota=$CPU_QUOTA, TasksMax=$TASKS_MAX" 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//-/_}" DATABASE_NAME="$(_existing DATABASE_NAME)" if ! _existing DATABASE_PASSWORD >/dev/null 2>&1; then DATABASE_PASSWORD="$(openssl rand -base64 36 | tr -d '\n')" write_env DATABASE_PASSWORD "$DATABASE_PASSWORD" fi 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." PUBLIC_BASE_URL="https://${ORGANIZATION_SLUG}.${CPH_ALPHA_BASE_DOMAIN:-educraft.paradigm-edu.net}" write_env PUBLIC_BASE_URL "$PUBLIC_BASE_URL" note "Platform-assigned public URL: $PUBLIC_BASE_URL" capture FEISHU_APP_ID "Feishu App ID:" capture_secret FEISHU_APP_SECRET "Feishu App Secret:" say "Resolving the bot Open ID from Feishu..." FEISHU_BOT_OPEN_ID="$(printf '%s\0%s\0' "$FEISHU_APP_ID" "$FEISHU_APP_SECRET" | node "$SCRIPT_DIR/resolve_feishu_bot.mjs")" write_env FEISHU_BOT_OPEN_ID "$FEISHU_BOT_OPEN_ID" note "Resolved bot identity: $FEISHU_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:" write_env 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_secret PROVIDER_AUTH_TOKEN "Provider auth token:" for key in PROVIDER_ID PROVIDER_BASE_URL DEFAULT_MODEL DEFAULT_ROLE_ID DEFAULT_ROLE_LABEL \ MAX_TURNS MAX_CONCURRENT_RUNS MAX_RUN_SECONDS HTTP_BODY_LIMIT_BYTES \ MAX_FILES_PER_MESSAGE MAX_FILE_BYTES HTTP_REQUESTS_PER_MINUTE FEISHU_EVENTS_PER_MINUTE; do printf -v "$key" '%s' "$(_existing "$key")" done write_env APPROVED_SKILLS "outline,lesson-project,data-processing-spec,typst" note "Platform runtime: OpenRouter, concurrency 4, default education role, curated skills." 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 "The deployment package is an internal retry/audit checkpoint; you do not operate it manually." step "Target: $PUBLIC_BASE_URL → $DEPLOY_HOST:$HUB_PORT" step "Resources: database $DATABASE_NAME, workspace $WORKSPACE_ROOT, service cph-hub-$INSTANCE_ID" warn "Confirmation will create server, database, TLS, and runtime state." if confirm "Deploy this Organization now?"; then bash "$SCRIPT_DIR/apply_new_silo.sh" "$OUTPUT_DIR" else warn "deployment skipped; rerun the wizard later and keep existing answers" fi finish