v1 stored every WhatsApp DM as `<phone>@s.whatsapp.net`. v2's WA adapter sometimes resolves the chat to `<lid>@lid` instead — when WhatsApp delivers via the LID protocol and Baileys hasn't yet learned a LID→phone mapping for that contact (cold cache after migration). The router then can't find the phone-keyed messaging_group and silently drops the message at router.ts:184. Baileys persists every LID↔phone pair it has ever learned to disk as `store/auth/lid-mapping-<phone>.json` (forward) and `lid-mapping-<lid>_reverse.json` (reverse). v1 will already have these populated for every contact it has talked to. New step 2d-whatsapp-lids parses the reverse files and writes paired LID-keyed `messaging_groups` + `messaging_group_agents` rows so both `<phone>@s.whatsapp.net` and `<lid>@lid` route to the same agent_group with the same engage rules. No Baileys boot, no WhatsApp connectivity required — pure filesystem read of files we've already copied via 2b-channel-auth. Step is no-op-on-skip if either store/auth or whatsapp DM rows are missing. Anything that slips through (a contact whose LID v1 never learned) falls back to the runtime approval flow once the WA adapter sets isMention=true on DMs — each unknown LID DM auto-creates an approval-required messaging_group and the owner gets a one-tap register prompt. Verified end-to-end on a 12-group v1 install: 3 DM rows aliased, inbound DM routed via the LID-keyed row. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
704 lines
24 KiB
Bash
704 lines
24 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
|
||
#
|
||
# 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"
|
||
|
||
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
|
||
|
||
# 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 from auto-starting
|
||
if [ "$PLATFORM_SERVICE" = "systemd" ]; then
|
||
systemctl --user disable "$V1_SERVICE" 2>/dev/null || true
|
||
fi
|
||
fi
|
||
else
|
||
step_skip "Service switchover skipped"
|
||
fi
|
||
else
|
||
step_skip "v1 service not running — nothing to switch"
|
||
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"
|
||
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
|