The previous approach deleted the v1 unit file and symlinked it to v2, making rollback impossible. Now we just disable v1 and leave the file on disk so users can switch back with a single command. Also adds rollback instructions to the migration summary output. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
748 lines
26 KiB
Bash
748 lines
26 KiB
Bash
#!/usr/bin/env bash
|
||
#
|
||
# migrate-v2.sh — Migrate a NanoClaw v1 install into this v2 checkout.
|
||
#
|
||
# Run from the v2 directory:
|
||
# bash migrate-v2.sh
|
||
#
|
||
# From Claude Code, use the ! prefix so it runs in your terminal:
|
||
# ! bash migrate-v2.sh
|
||
#
|
||
# Finds v1 automatically (sibling directory, or $NANOCLAW_V1_PATH).
|
||
# Installs prerequisites (Node, pnpm, deps) via the existing setup.sh
|
||
# bootstrap, then runs the migration steps.
|
||
#
|
||
# Idempotent — safe to re-run. Use migrate-v2-reset.sh to wipe v2 state
|
||
# back to clean for development iteration.
|
||
|
||
set -uo pipefail
|
||
|
||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
cd "$PROJECT_ROOT"
|
||
|
||
# This script has interactive prompts (channel selection, service switchover)
|
||
# and streams progress output — it must run in a real terminal, not inside
|
||
# a tool subprocess (e.g. Claude Code's Bash tool, which collapses output).
|
||
if ! [ -t 0 ] || ! [ -t 1 ]; then
|
||
echo "This script requires an interactive terminal."
|
||
echo ""
|
||
echo "If you're in Claude Code, run it directly with the ! prefix:"
|
||
echo " ! bash migrate-v2.sh"
|
||
echo ""
|
||
echo "Or run it in a separate terminal session."
|
||
exit 1
|
||
fi
|
||
|
||
LOGS_DIR="$PROJECT_ROOT/logs"
|
||
STEPS_DIR="$LOGS_DIR/migrate-steps"
|
||
MIGRATE_LOG="$LOGS_DIR/migrate-v2.log"
|
||
|
||
# Defaults for variables that may not be set if we exit early
|
||
V1_PATH=""
|
||
V1_VERSION="unknown"
|
||
ONECLI_OK=false
|
||
SERVICE_SWITCHED=false
|
||
SELECTED_CHANNELS=()
|
||
ABORTED_AT=""
|
||
|
||
# Per-step status tracking. Parallel indexed arrays so this works on
|
||
# bash 3.2 (macOS default) which has no associative arrays.
|
||
STEP_NAMES=()
|
||
STEP_STATUSES=()
|
||
|
||
record_step() {
|
||
STEP_NAMES+=("$1")
|
||
STEP_STATUSES+=("$2")
|
||
}
|
||
|
||
# Write handoff.json on any exit so the skill can always read it
|
||
write_handoff() {
|
||
local handoff_dir="$LOGS_DIR/setup-migration"
|
||
mkdir -p "$handoff_dir"
|
||
|
||
local has_failures=false
|
||
local i
|
||
for ((i=0; i<${#STEP_NAMES[@]}; i++)); do
|
||
[ "${STEP_STATUSES[$i]}" = "failed" ] && has_failures=true
|
||
done
|
||
|
||
local overall="success"
|
||
$has_failures && overall="partial"
|
||
[ -n "$ABORTED_AT" ] && overall="failed"
|
||
|
||
local steps_json="{"
|
||
for ((i=0; i<${#STEP_NAMES[@]}; i++)); do
|
||
local n="${STEP_NAMES[$i]}"
|
||
local s="${STEP_STATUSES[$i]}"
|
||
steps_json="${steps_json}\"${n}\": {\"status\": \"${s}\", \"log\": \"logs/migrate-steps/${n}.log\"},"
|
||
done
|
||
steps_json="${steps_json%,}}"
|
||
|
||
cat > "$handoff_dir/handoff.json" <<HANDOFF_EOF
|
||
{
|
||
"version": 1,
|
||
"started_at": "$(ts_utc)",
|
||
"v1_path": "$V1_PATH",
|
||
"v1_version": "$V1_VERSION",
|
||
"overall_status": "$overall",
|
||
"aborted_at": "$ABORTED_AT",
|
||
"source": "migrate-v2.sh",
|
||
"channels_installed": [$(printf '"%s",' "${SELECTED_CHANNELS[@]}" 2>/dev/null | sed 's/,$//')],
|
||
"onecli_healthy": $ONECLI_OK,
|
||
"service_switched": $SERVICE_SWITCHED,
|
||
"steps": $steps_json,
|
||
"step_logs_dir": "logs/migrate-steps",
|
||
"followups": [
|
||
"Seed owner user and access policy",
|
||
"Review CLAUDE.local.md files for v1-specific patterns",
|
||
"Verify container.json mount paths are valid"
|
||
]
|
||
}
|
||
HANDOFF_EOF
|
||
}
|
||
|
||
trap write_handoff EXIT
|
||
|
||
abort() {
|
||
ABORTED_AT="$1"
|
||
log "ABORTED at $1"
|
||
exit 1
|
||
}
|
||
|
||
# ─── output helpers ──────────────────────────────────────────────────────
|
||
|
||
use_ansi() { [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; }
|
||
dim() { use_ansi && printf '\033[2m%s\033[0m' "$1" || printf '%s' "$1"; }
|
||
green() { use_ansi && printf '\033[32m%s\033[0m' "$1" || printf '%s' "$1"; }
|
||
red() { use_ansi && printf '\033[31m%s\033[0m' "$1" || printf '%s' "$1"; }
|
||
bold() { use_ansi && printf '\033[1m%s\033[0m' "$1" || printf '%s' "$1"; }
|
||
clear_line() { use_ansi && printf '\r\033[2K' || printf '\n'; }
|
||
|
||
step_ok() { printf '%s %s\n' "$(green '✓')" "$1"; }
|
||
step_fail() { printf '%s %s\n' "$(red '✗')" "$1"; }
|
||
step_skip() { printf '%s %s\n' "$(dim '–')" "$1"; }
|
||
step_info() { printf '%s %s\n' "$(dim '·')" "$1"; }
|
||
|
||
ts_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; }
|
||
|
||
log() {
|
||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$MIGRATE_LOG"
|
||
}
|
||
|
||
# ─── init logs ───────────────────────────────────────────────────────────
|
||
|
||
mkdir -p "$STEPS_DIR"
|
||
{
|
||
echo "## $(ts_utc) · migrate-v2.sh started"
|
||
echo " cwd: $PROJECT_ROOT"
|
||
echo ""
|
||
} > "$MIGRATE_LOG"
|
||
|
||
echo
|
||
bold "NanoClaw v1 → v2 migration"
|
||
echo
|
||
echo
|
||
|
||
# ─── phase 0a: bootstrap prerequisites ──────────────────────────────────
|
||
|
||
step_info "Installing prerequisites (Node, pnpm, dependencies)…"
|
||
|
||
BOOTSTRAP_RAW="$STEPS_DIR/01-bootstrap.log"
|
||
export NANOCLAW_BOOTSTRAP_LOG="$BOOTSTRAP_RAW"
|
||
|
||
if bash "$PROJECT_ROOT/setup.sh" > "$BOOTSTRAP_RAW" 2>&1; then
|
||
# Parse the status block from setup.sh output
|
||
STATUS=$(grep '^STATUS:' "$BOOTSTRAP_RAW" | head -1 | sed 's/^STATUS: *//')
|
||
NODE_VERSION=$(grep '^NODE_VERSION:' "$BOOTSTRAP_RAW" | head -1 | sed 's/^NODE_VERSION: *//')
|
||
|
||
if [ "$STATUS" = "success" ]; then
|
||
step_ok "Prerequisites ready $(dim "(node $NODE_VERSION)")"
|
||
log "Bootstrap succeeded: node=$NODE_VERSION"
|
||
else
|
||
step_fail "Bootstrap reported: $STATUS"
|
||
echo
|
||
dim " See: $BOOTSTRAP_RAW"
|
||
echo
|
||
abort "bootstrap"
|
||
fi
|
||
else
|
||
step_fail "Bootstrap failed"
|
||
echo
|
||
echo "$(dim '── last 20 lines ──')"
|
||
tail -20 "$BOOTSTRAP_RAW" 2>/dev/null || true
|
||
echo
|
||
dim " Full log: $BOOTSTRAP_RAW"
|
||
echo
|
||
abort "bootstrap"
|
||
fi
|
||
|
||
# setup.sh may have installed pnpm to a prefix not on our PATH — replay
|
||
# the same lookup nanoclaw.sh does.
|
||
if ! command -v pnpm >/dev/null 2>&1 && command -v npm >/dev/null 2>&1; then
|
||
NPM_PREFIX="$(npm config get prefix 2>/dev/null)"
|
||
if [ -n "$NPM_PREFIX" ] && [ -x "$NPM_PREFIX/bin/pnpm" ]; then
|
||
export PATH="$NPM_PREFIX/bin:$PATH"
|
||
fi
|
||
fi
|
||
|
||
if ! command -v pnpm >/dev/null 2>&1; then
|
||
step_fail "pnpm not found after bootstrap"
|
||
abort "pnpm-missing"
|
||
fi
|
||
|
||
# ─── phase 0b: find v1 install ──────────────────────────────────────────
|
||
|
||
find_v1() {
|
||
# Explicit override
|
||
if [ -n "${NANOCLAW_V1_PATH:-}" ]; then
|
||
if [ -f "$NANOCLAW_V1_PATH/store/messages.db" ]; then
|
||
echo "$NANOCLAW_V1_PATH"
|
||
return 0
|
||
fi
|
||
step_fail "NANOCLAW_V1_PATH=$NANOCLAW_V1_PATH does not contain store/messages.db"
|
||
return 1
|
||
fi
|
||
|
||
# Scan sibling directories for anything claw-ish with a v1 DB
|
||
local parent
|
||
parent="$(dirname "$PROJECT_ROOT")"
|
||
for entry in "$parent"/*/; do
|
||
[ -d "$entry" ] || continue
|
||
# Skip ourselves
|
||
[ "$(cd "$entry" && pwd)" = "$PROJECT_ROOT" ] && continue
|
||
# Must have the v1 DB
|
||
[ -f "$entry/store/messages.db" ] || continue
|
||
# Must not be v2 (check package.json version)
|
||
if [ -f "$entry/package.json" ]; then
|
||
local ver
|
||
ver=$(grep '"version"' "$entry/package.json" 2>/dev/null | head -1 | sed -E 's/.*"([0-9]+)\..*/\1/')
|
||
[ "$ver" = "2" ] && continue
|
||
fi
|
||
echo "$(cd "$entry" && pwd)"
|
||
return 0
|
||
done
|
||
|
||
return 1
|
||
}
|
||
|
||
V1_PATH=""
|
||
if V1_PATH=$(find_v1); then
|
||
V1_VERSION=$(grep '"version"' "$V1_PATH/package.json" 2>/dev/null | head -1 | sed -E 's/.*"([^"]+)".*/\1/' || echo "unknown")
|
||
step_ok "Found v1 at $(dim "$V1_PATH") $(dim "(v$V1_VERSION)")"
|
||
log "v1 found: $V1_PATH (v$V1_VERSION)"
|
||
else
|
||
step_fail "No v1 install found"
|
||
echo
|
||
echo " $(dim 'Set NANOCLAW_V1_PATH to point at your v1 checkout:')"
|
||
echo " $(dim 'NANOCLAW_V1_PATH=~/nanoclaw bash migrate-v2.sh')"
|
||
echo
|
||
abort "v1-not-found"
|
||
fi
|
||
|
||
# ─── phase 0c: validate v1 DB ───────────────────────────────────────────
|
||
|
||
V1_DB="$V1_PATH/store/messages.db"
|
||
|
||
# Quick schema check — make sure the tables we need exist
|
||
TABLES=$(sqlite3 "$V1_DB" ".tables" 2>/dev/null || true)
|
||
|
||
if echo "$TABLES" | grep -q "registered_groups"; then
|
||
step_ok "v1 database has registered_groups"
|
||
else
|
||
step_fail "v1 database missing registered_groups table"
|
||
abort "v1-db-invalid"
|
||
fi
|
||
|
||
# Show what we found
|
||
GROUP_COUNT=$(sqlite3 "$V1_DB" "SELECT COUNT(*) FROM registered_groups" 2>/dev/null || echo 0)
|
||
TASK_COUNT=$(sqlite3 "$V1_DB" "SELECT COUNT(*) FROM scheduled_tasks WHERE status='active'" 2>/dev/null || echo 0)
|
||
ENV_KEYS=0
|
||
if [ -f "$V1_PATH/.env" ]; then
|
||
ENV_KEYS=$(grep -c '=' "$V1_PATH/.env" 2>/dev/null || echo 0)
|
||
fi
|
||
|
||
step_info "v1 state: $(bold "$GROUP_COUNT") groups, $(bold "$TASK_COUNT") active tasks, $(bold "$ENV_KEYS") env keys"
|
||
|
||
echo
|
||
step_ok "Phase 0 complete — ready to migrate"
|
||
echo
|
||
log "Phase 0 complete: groups=$GROUP_COUNT tasks=$TASK_COUNT env_keys=$ENV_KEYS"
|
||
|
||
export NANOCLAW_V1_PATH="$V1_PATH"
|
||
export NANOCLAW_V2_PATH="$PROJECT_ROOT"
|
||
|
||
# ─── run_step helper ─────────────────────────────────────────────────────
|
||
# Runs a TypeScript migration step, captures output, reports success/failure.
|
||
|
||
# Step outcomes are tracked via record_step() into STEP_NAMES/STEP_STATUSES
|
||
# (defined above, near write_handoff).
|
||
|
||
run_step() {
|
||
local name=$1 label=$2 script=$3
|
||
shift 3
|
||
local raw="$STEPS_DIR/${name}.log"
|
||
|
||
if pnpm exec tsx "$script" "$@" > "$raw" 2>&1; then
|
||
local result
|
||
result=$(grep '^OK:' "$raw" | head -1 || true)
|
||
step_ok "$label $(dim "$result")"
|
||
log "$name: $result"
|
||
record_step "$name" "success"
|
||
# Surface partial errors (rows skipped due to parse/lookup failures)
|
||
# even when the step exited successfully — they're easy to miss in the
|
||
# raw log and have caused silent migrations before.
|
||
if grep -q '^ERROR:' "$raw" 2>/dev/null; then
|
||
local err_count
|
||
err_count=$(grep -c '^ERROR:' "$raw")
|
||
echo " $(dim "${err_count} error(s) reported — see $raw")"
|
||
grep '^ERROR:' "$raw" | head -3 | while IFS= read -r line; do
|
||
echo " $(dim "$line")"
|
||
done
|
||
log "$name: ${err_count} non-fatal errors"
|
||
fi
|
||
elif grep -q '^SKIPPED:' "$raw" 2>/dev/null; then
|
||
local reason
|
||
reason=$(grep '^SKIPPED:' "$raw" | head -1 | sed 's/^SKIPPED://')
|
||
step_skip "$label $(dim "($reason)")"
|
||
log "$name: skipped ($reason)"
|
||
record_step "$name" "skipped"
|
||
else
|
||
step_fail "$label"
|
||
echo
|
||
tail -10 "$raw" 2>/dev/null | while IFS= read -r line; do
|
||
echo " $(dim "$line")"
|
||
done
|
||
echo
|
||
log "$name: FAILED (see $raw)"
|
||
record_step "$name" "failed"
|
||
fi
|
||
}
|
||
|
||
# ─── phase 1: core state ────────────────────────────────────────────────
|
||
|
||
echo "$(bold 'Phase 1: Core state')"
|
||
echo
|
||
|
||
run_step "1a-env" \
|
||
"Merge .env" \
|
||
"setup/migrate-v2/env.ts" "$V1_PATH"
|
||
|
||
run_step "1b-db" \
|
||
"Seed v2 database" \
|
||
"setup/migrate-v2/db.ts" "$V1_PATH"
|
||
|
||
run_step "1c-groups" \
|
||
"Copy group folders" \
|
||
"setup/migrate-v2/groups.ts" "$V1_PATH"
|
||
|
||
run_step "1d-sessions" \
|
||
"Copy session data" \
|
||
"setup/migrate-v2/sessions.ts" "$V1_PATH"
|
||
|
||
run_step "1e-tasks" \
|
||
"Port scheduled tasks" \
|
||
"setup/migrate-v2/tasks.ts" "$V1_PATH"
|
||
|
||
echo
|
||
step_ok "Phase 1 complete"
|
||
echo
|
||
|
||
# ─── phase 2: channels (interactive) ────────────────────────────────────
|
||
|
||
echo "$(bold 'Phase 2: Channels')"
|
||
echo
|
||
|
||
# Channel selection — clack multiselect (interactive) or NANOCLAW_CHANNELS env var.
|
||
# NANOCLAW_CHANNELS accepts comma-separated channel names: "telegram,discord"
|
||
SELECTED_CHANNELS=()
|
||
CHANNEL_SELECT_OUT="$STEPS_DIR/2a-channels-selected.txt"
|
||
|
||
pnpm exec tsx setup/migrate-v2/select-channels.ts "$CHANNEL_SELECT_OUT" || true
|
||
|
||
if [ -f "$CHANNEL_SELECT_OUT" ]; then
|
||
while IFS= read -r ch; do
|
||
[ -n "$ch" ] && SELECTED_CHANNELS+=("$ch")
|
||
done < "$CHANNEL_SELECT_OUT"
|
||
fi
|
||
|
||
if [ ${#SELECTED_CHANNELS[@]} -eq 0 ]; then
|
||
echo
|
||
step_skip "No channels selected"
|
||
else
|
||
echo
|
||
step_info "Selected: ${SELECTED_CHANNELS[*]}"
|
||
echo
|
||
|
||
# 2b. Copy channel auth state
|
||
run_step "2b-channel-auth" \
|
||
"Copy channel credentials" \
|
||
"setup/migrate-v2/channel-auth.ts" "$V1_PATH" "${SELECTED_CHANNELS[@]}"
|
||
|
||
# 2c. Install channel code
|
||
for ch in "${SELECTED_CHANNELS[@]}"; do
|
||
INSTALL_SCRIPT="setup/install-${ch}.sh"
|
||
STEP_NAME="2c-install-${ch}"
|
||
if [ -f "$INSTALL_SCRIPT" ]; then
|
||
STEP_LOG="$STEPS_DIR/${STEP_NAME}.log"
|
||
if bash "$INSTALL_SCRIPT" > "$STEP_LOG" 2>&1; then
|
||
STATUS_LINE=$(grep '^STATUS:' "$STEP_LOG" | head -1 | sed 's/^STATUS: *//')
|
||
if [ "$STATUS_LINE" = "already-installed" ]; then
|
||
step_skip "Install $ch $(dim "(already installed)")"
|
||
record_step "$STEP_NAME" "skipped"
|
||
else
|
||
step_ok "Install $ch"
|
||
record_step "$STEP_NAME" "success"
|
||
fi
|
||
log "install-$ch: $STATUS_LINE"
|
||
else
|
||
step_fail "Install $ch"
|
||
tail -5 "$STEP_LOG" 2>/dev/null | while IFS= read -r line; do
|
||
echo " $(dim "$line")"
|
||
done
|
||
log "install-$ch: FAILED (see $STEP_LOG)"
|
||
record_step "$STEP_NAME" "failed"
|
||
fi
|
||
else
|
||
step_skip "Install $ch $(dim "(no install script)")"
|
||
log "install-$ch: no install script"
|
||
record_step "$STEP_NAME" "failed"
|
||
fi
|
||
done
|
||
|
||
# 2d. WhatsApp LID resolution. After whatsapp is installed (so Baileys
|
||
# is on disk) and auth files have been copied (so we can connect with
|
||
# the migrated identity), boot Baileys briefly to learn LID↔phone
|
||
# mappings during initial sync, then write paired LID-keyed
|
||
# messaging_groups. Best-effort: any failure degrades to runtime
|
||
# approval flow, which the WA adapter's isMention=true on DMs handles.
|
||
for ch in "${SELECTED_CHANNELS[@]}"; do
|
||
if [ "$ch" = "whatsapp" ]; then
|
||
run_step "2d-whatsapp-lids" \
|
||
"Resolve WhatsApp LIDs for migrated DMs" \
|
||
"setup/migrate-v2/whatsapp-resolve-lids.ts"
|
||
break
|
||
fi
|
||
done
|
||
fi
|
||
|
||
echo
|
||
step_ok "Phase 2 complete"
|
||
echo
|
||
|
||
# ─── phase 3: infrastructure ────────────────────────────────────────────
|
||
|
||
echo "$(bold 'Phase 3: Infrastructure')"
|
||
echo
|
||
|
||
# 3a. Docker — install if missing (OneCLI needs it)
|
||
if command -v docker >/dev/null 2>&1; then
|
||
DOCKER_V=$(docker --version 2>/dev/null | head -1)
|
||
step_ok "Docker available $(dim "($DOCKER_V)")"
|
||
log "Docker: $DOCKER_V"
|
||
else
|
||
step_info "Installing Docker…"
|
||
DOCKER_LOG="$STEPS_DIR/3a-docker.log"
|
||
if bash setup/install-docker.sh > "$DOCKER_LOG" 2>&1; then
|
||
hash -r 2>/dev/null || true
|
||
step_ok "Docker installed"
|
||
record_step "3a-docker" "success"
|
||
log "Docker: installed"
|
||
else
|
||
step_fail "Docker install failed $(dim "(see $DOCKER_LOG)")"
|
||
record_step "3a-docker" "failed"
|
||
log "Docker: FAILED"
|
||
fi
|
||
fi
|
||
|
||
# 3b. OneCLI — detect or install via setup step (requires Docker)
|
||
ONECLI_OK=false
|
||
ONECLI_URL_FROM_ENV=$(grep '^ONECLI_URL=' .env 2>/dev/null | head -1 | sed 's/^ONECLI_URL=//')
|
||
ONECLI_URL_CHECK="${ONECLI_URL_FROM_ENV:-http://127.0.0.1:10254}"
|
||
|
||
if curl -sf "${ONECLI_URL_CHECK}/health" >/dev/null 2>&1; then
|
||
step_ok "OneCLI running at $(dim "$ONECLI_URL_CHECK")"
|
||
ONECLI_OK=true
|
||
log "OneCLI: running at $ONECLI_URL_CHECK"
|
||
elif command -v docker >/dev/null 2>&1; then
|
||
step_info "Setting up OneCLI…"
|
||
ONECLI_LOG="$STEPS_DIR/3b-onecli.log"
|
||
ONECLI_ERR="$STEPS_DIR/3b-onecli.err"
|
||
if pnpm exec tsx setup/index.ts --step onecli > "$ONECLI_LOG" 2>"$ONECLI_ERR"; then
|
||
step_ok "OneCLI ready"
|
||
ONECLI_OK=true
|
||
record_step "3b-onecli" "success"
|
||
log "OneCLI: installed/configured"
|
||
else
|
||
step_fail "OneCLI setup failed $(dim "(see $ONECLI_LOG)")"
|
||
record_step "3b-onecli" "failed"
|
||
log "OneCLI: FAILED"
|
||
fi
|
||
else
|
||
step_fail "OneCLI needs Docker $(dim "(install Docker first)")"
|
||
record_step "3b-onecli" "failed"
|
||
log "OneCLI: skipped (no Docker)"
|
||
fi
|
||
|
||
# 3c. Anthropic credential — run the auth setup step if no credential found
|
||
if grep -qE '^(ANTHROPIC_API_KEY|CLAUDE_CODE_OAUTH_TOKEN)=' .env 2>/dev/null; then
|
||
step_ok "Anthropic credential found in .env"
|
||
log "Anthropic credential: found in .env"
|
||
elif [ "$ONECLI_OK" = "true" ]; then
|
||
step_info "Registering Anthropic credential…"
|
||
AUTH_LOG="$STEPS_DIR/3c-auth.log"
|
||
AUTH_ERR="$STEPS_DIR/3c-auth.err"
|
||
if pnpm exec tsx setup/index.ts --step auth > "$AUTH_LOG" 2>"$AUTH_ERR"; then
|
||
step_ok "Anthropic credential registered"
|
||
record_step "3c-auth" "success"
|
||
log "Anthropic credential: registered via auth step"
|
||
else
|
||
step_fail "Auth setup failed $(dim "(see $AUTH_LOG)")"
|
||
record_step "3c-auth" "failed"
|
||
log "Anthropic credential: FAILED"
|
||
fi
|
||
else
|
||
step_info "No Anthropic credential $(dim "(OneCLI not available — add manually to .env)")"
|
||
log "Anthropic credential: skipped (no OneCLI)"
|
||
fi
|
||
|
||
# 3d. Copy container skills from v1 that v2 doesn't have
|
||
V1_SKILLS_DIR="$V1_PATH/container/skills"
|
||
V2_SKILLS_DIR="$PROJECT_ROOT/container/skills"
|
||
|
||
if [ -d "$V1_SKILLS_DIR" ]; then
|
||
SKILLS_COPIED=0
|
||
SKILLS_SKIPPED=0
|
||
for skill_dir in "$V1_SKILLS_DIR"/*/; do
|
||
[ -d "$skill_dir" ] || continue
|
||
skill_name=$(basename "$skill_dir")
|
||
if [ -d "$V2_SKILLS_DIR/$skill_name" ]; then
|
||
SKILLS_SKIPPED=$((SKILLS_SKIPPED + 1))
|
||
else
|
||
cp -r "$skill_dir" "$V2_SKILLS_DIR/$skill_name"
|
||
SKILLS_COPIED=$((SKILLS_COPIED + 1))
|
||
fi
|
||
done
|
||
if [ $SKILLS_COPIED -gt 0 ]; then
|
||
step_ok "Copied $SKILLS_COPIED container skills $(dim "(skipped $SKILLS_SKIPPED already in v2)")"
|
||
else
|
||
step_skip "All v1 container skills already in v2 $(dim "($SKILLS_SKIPPED)")"
|
||
fi
|
||
log "Container skills: copied=$SKILLS_COPIED skipped=$SKILLS_SKIPPED"
|
||
else
|
||
step_skip "No v1 container skills"
|
||
fi
|
||
|
||
# 3e. Build agent container image
|
||
if command -v docker >/dev/null 2>&1; then
|
||
step_info "Building agent container image…"
|
||
BUILD_LOG="$STEPS_DIR/3e-container-build.log"
|
||
if bash container/build.sh > "$BUILD_LOG" 2>&1; then
|
||
step_ok "Container image built"
|
||
record_step "3e-build" "success"
|
||
log "Container build: success"
|
||
else
|
||
step_fail "Container build failed"
|
||
record_step "3e-build" "failed"
|
||
tail -10 "$BUILD_LOG" 2>/dev/null | while IFS= read -r line; do
|
||
echo " $(dim "$line")"
|
||
done
|
||
log "Container build: FAILED (see $BUILD_LOG)"
|
||
fi
|
||
else
|
||
step_fail "Docker not available — cannot build container"
|
||
record_step "3e-build" "failed"
|
||
log "Container build: skipped (no Docker)"
|
||
fi
|
||
|
||
echo
|
||
step_ok "Phase 3 complete"
|
||
echo
|
||
|
||
# ─── service switchover ─────────────────────────────────────────────────
|
||
|
||
echo "$(bold 'Service switchover')"
|
||
echo
|
||
|
||
# Disable the v1 service so it doesn't auto-start, but leave the unit file
|
||
# on disk so the user can rollback with: systemctl --user start nanoclaw
|
||
# Idempotent — safe to call multiple times.
|
||
disable_v1_service() {
|
||
if [ "$PLATFORM_SERVICE" = "systemd" ]; then
|
||
local v1_file="$HOME/.config/systemd/user/${V1_SERVICE}.service"
|
||
if [ -f "$v1_file" ] || [ -L "$v1_file" ]; then
|
||
systemctl --user stop "$V1_SERVICE" 2>/dev/null || true
|
||
systemctl --user disable "$V1_SERVICE" 2>/dev/null || true
|
||
step_ok "Disabled $V1_SERVICE (unit file kept for rollback)"
|
||
fi
|
||
elif [ "$PLATFORM_SERVICE" = "launchd" ]; then
|
||
local v1_plist="$HOME/Library/LaunchAgents/${V1_SERVICE}.plist"
|
||
if [ -f "$v1_plist" ] || [ -L "$v1_plist" ]; then
|
||
launchctl unload "$v1_plist" 2>/dev/null || true
|
||
step_ok "Unloaded $V1_SERVICE (plist kept for rollback)"
|
||
fi
|
||
fi
|
||
}
|
||
|
||
# Detect platform and service names
|
||
V1_SERVICE=""
|
||
V2_SERVICE=""
|
||
PLATFORM_SERVICE=""
|
||
|
||
if [ "$(uname -s)" = "Darwin" ]; then
|
||
PLATFORM_SERVICE="launchd"
|
||
V1_SERVICE="com.nanoclaw"
|
||
# v2 uses install-slug for unique service names
|
||
V2_SERVICE=$(pnpm exec tsx -e "import{getLaunchdLabel}from'./src/install-slug.js';console.log(getLaunchdLabel())" 2>/dev/null || echo "")
|
||
elif [ "$(uname -s)" = "Linux" ]; then
|
||
PLATFORM_SERVICE="systemd"
|
||
V1_SERVICE="nanoclaw"
|
||
V2_SERVICE=$(pnpm exec tsx -e "import{getSystemdUnit}from'./src/install-slug.js';console.log(getSystemdUnit())" 2>/dev/null || echo "")
|
||
fi
|
||
|
||
# Check if v1 service is running
|
||
V1_RUNNING=false
|
||
if [ "$PLATFORM_SERVICE" = "systemd" ]; then
|
||
systemctl --user is-active "$V1_SERVICE" >/dev/null 2>&1 && V1_RUNNING=true
|
||
elif [ "$PLATFORM_SERVICE" = "launchd" ]; then
|
||
launchctl list "$V1_SERVICE" >/dev/null 2>&1 && V1_RUNNING=true
|
||
fi
|
||
|
||
SERVICE_SWITCHED=false
|
||
if [ "$V1_RUNNING" = "true" ]; then
|
||
step_info "v1 service is running $(dim "($V1_SERVICE)")"
|
||
|
||
# Ask user if they want to switch
|
||
SWITCH_ANSWER_FILE=$(mktemp)
|
||
pnpm exec tsx setup/migrate-v2/switchover-prompt.ts --offer-switch "$SWITCH_ANSWER_FILE" || true
|
||
SWITCH_ANSWER=$(cat "$SWITCH_ANSWER_FILE" 2>/dev/null || echo "skip")
|
||
rm -f "$SWITCH_ANSWER_FILE"
|
||
|
||
if [ "$SWITCH_ANSWER" = "switch" ]; then
|
||
# Stop v1
|
||
if [ "$PLATFORM_SERVICE" = "systemd" ]; then
|
||
systemctl --user stop "$V1_SERVICE" 2>/dev/null && step_ok "Stopped v1 service" || step_fail "Could not stop v1"
|
||
elif [ "$PLATFORM_SERVICE" = "launchd" ]; then
|
||
launchctl unload ~/Library/LaunchAgents/${V1_SERVICE}.plist 2>/dev/null && step_ok "Stopped v1 service" || step_fail "Could not stop v1"
|
||
fi
|
||
|
||
# Install and start v2 service
|
||
V2_SERVICE_LOG="$STEPS_DIR/service-install.log"
|
||
V2_SERVICE_ERR="$STEPS_DIR/service-install.err"
|
||
if pnpm exec tsx setup/index.ts --step service > "$V2_SERVICE_LOG" 2>"$V2_SERVICE_ERR"; then
|
||
# Parse the actual unit name from the service step stdout (clean, no ANSI)
|
||
if [ "$PLATFORM_SERVICE" = "systemd" ]; then
|
||
V2_SERVICE=$(grep '^SERVICE_UNIT:' "$V2_SERVICE_LOG" | head -1 | sed 's/^SERVICE_UNIT: *//')
|
||
elif [ "$PLATFORM_SERVICE" = "launchd" ]; then
|
||
V2_SERVICE=$(grep '^SERVICE_LABEL:' "$V2_SERVICE_LOG" | head -1 | sed 's/^SERVICE_LABEL: *//')
|
||
fi
|
||
step_ok "v2 service installed and started $(dim "($V2_SERVICE)")"
|
||
else
|
||
step_fail "Could not start v2 service $(dim "(see $V2_SERVICE_LOG)")"
|
||
fi
|
||
|
||
SERVICE_SWITCHED=true
|
||
echo
|
||
step_info "v2 is running — send a test message to your bot"
|
||
echo
|
||
|
||
# Ask: keep or revert?
|
||
KEEP_ANSWER_FILE=$(mktemp)
|
||
pnpm exec tsx setup/migrate-v2/switchover-prompt.ts --keep-or-revert "$KEEP_ANSWER_FILE" || true
|
||
KEEP_ANSWER=$(cat "$KEEP_ANSWER_FILE" 2>/dev/null || echo "keep")
|
||
rm -f "$KEEP_ANSWER_FILE"
|
||
|
||
if [ "$KEEP_ANSWER" = "revert" ]; then
|
||
# Stop v2
|
||
if [ "$PLATFORM_SERVICE" = "systemd" ] && [ -n "$V2_SERVICE" ]; then
|
||
systemctl --user stop "$V2_SERVICE" 2>/dev/null || true
|
||
systemctl --user disable "$V2_SERVICE" 2>/dev/null || true
|
||
elif [ "$PLATFORM_SERVICE" = "launchd" ] && [ -n "$V2_SERVICE" ]; then
|
||
launchctl unload ~/Library/LaunchAgents/${V2_SERVICE}.plist 2>/dev/null || true
|
||
fi
|
||
|
||
# Restart v1
|
||
if [ "$PLATFORM_SERVICE" = "systemd" ]; then
|
||
systemctl --user start "$V1_SERVICE" 2>/dev/null || true
|
||
elif [ "$PLATFORM_SERVICE" = "launchd" ]; then
|
||
launchctl load ~/Library/LaunchAgents/${V1_SERVICE}.plist 2>/dev/null || true
|
||
fi
|
||
|
||
step_ok "Reverted to v1 service"
|
||
SERVICE_SWITCHED=false
|
||
else
|
||
step_ok "Keeping v2 service"
|
||
disable_v1_service
|
||
fi
|
||
else
|
||
step_skip "Service switchover skipped"
|
||
fi
|
||
else
|
||
step_skip "v1 service not running — nothing to switch"
|
||
disable_v1_service
|
||
fi
|
||
|
||
echo
|
||
|
||
# ─── phase 4: handoff ───────────────────────────────────────────────────
|
||
# handoff.json is written by the EXIT trap (write_handoff) — always, even on
|
||
# abort. Here we just print the summary.
|
||
|
||
echo "$(bold 'Phase 4: Handoff')"
|
||
echo
|
||
|
||
step_ok "Wrote handoff summary"
|
||
|
||
# Summary
|
||
echo
|
||
echo "$(bold '── Migration complete ──')"
|
||
echo
|
||
echo " $(dim 'v1:') $V1_PATH"
|
||
echo " $(dim 'v2:') $PROJECT_ROOT"
|
||
echo
|
||
echo " $(bold 'What was done:')"
|
||
echo " $(green '✓') .env keys merged"
|
||
echo " $(green '✓') Database seeded (agent groups, messaging groups, wiring)"
|
||
echo " $(green '✓') Group folders copied (CLAUDE.md → CLAUDE.local.md)"
|
||
echo " $(green '✓') Session data copied"
|
||
echo " $(green '✓') Scheduled tasks ported"
|
||
if [ ${#SELECTED_CHANNELS[@]} -gt 0 ]; then
|
||
echo " $(green '✓') Channels installed: ${SELECTED_CHANNELS[*]}"
|
||
fi
|
||
echo " $(green '✓') Container skills copied"
|
||
echo " $(green '✓') Container image built"
|
||
if [ "$SERVICE_SWITCHED" = "true" ] && [ -n "$V2_SERVICE" ]; then
|
||
echo " $(green '✓') Service switched to v2 $(dim "($V2_SERVICE)")"
|
||
echo
|
||
echo " $(bold 'Rollback to v1:')"
|
||
if [ "$PLATFORM_SERVICE" = "systemd" ]; then
|
||
echo " $(dim '$') systemctl --user stop $V2_SERVICE && systemctl --user start $V1_SERVICE"
|
||
elif [ "$PLATFORM_SERVICE" = "launchd" ]; then
|
||
echo " $(dim '$') launchctl unload ~/Library/LaunchAgents/${V2_SERVICE}.plist && launchctl load ~/Library/LaunchAgents/${V1_SERVICE}.plist"
|
||
fi
|
||
fi
|
||
echo
|
||
echo " $(bold 'What still needs a human:')"
|
||
if [ "$ONECLI_OK" = "false" ]; then
|
||
echo " $(dim '·') Set up OneCLI: pnpm exec tsx setup/index.ts --step onecli"
|
||
fi
|
||
if ! grep -qE '^(ANTHROPIC_API_KEY|CLAUDE_CODE_OAUTH_TOKEN)=' .env 2>/dev/null; then
|
||
echo " $(dim '·') Add Anthropic credential to .env or OneCLI vault"
|
||
fi
|
||
echo " $(dim '·') Run $(bold '/migrate-from-v1') in Claude to finish:"
|
||
echo " $(dim '- Seed your owner account')"
|
||
echo " $(dim '- Set access policies')"
|
||
echo " $(dim '- Port any custom v1 code')"
|
||
echo
|
||
echo " $(dim "Handoff: $LOGS_DIR/setup-migration/handoff.json")"
|
||
echo " $(dim "Full log: $MIGRATE_LOG")"
|
||
echo " $(dim "Step logs: $STEPS_DIR/")"
|
||
echo
|
||
|
||
# ─── hand off to Claude ─────────────────────────────────────────────────
|
||
|
||
if command -v claude >/dev/null 2>&1; then
|
||
write_handoff
|
||
trap - EXIT
|
||
exec claude "/migrate-from-v1"
|
||
fi
|