Files
NanoClaw bot user 0d7458c6f3 fix(skills): replace sqlite3 CLI with in-tree better-sqlite3 wrapper
Setup deliberately avoids the sqlite3 CLI (`setup/verify.ts:5` calls
this out: "Uses better-sqlite3 directly (no sqlite3 CLI)") and never
installs or probes for the binary. Despite that, 13 skills shelled out
to `sqlite3 ...` directly, breaking on hosts where the CLI isn't
preinstalled — the same root cause as #2191 but spread across the
skill surface.

Add `scripts/q.ts`, a ~30-LOC wrapper over the `better-sqlite3` dep
that setup already installs and verifies. Default output matches
`sqlite3 -list` (pipe-separated, no header) so existing skill text
reads identically — only the binary changes. SELECT/WITH queries go
through `db.prepare().all()`; everything else (INSERT/UPDATE/DELETE,
including compound statements) goes through `db.exec()`.

Migrate every in-tree caller:

- 17 hardcoded invocations across 8 SKILL.md files (init-first-agent,
  add-deltachat, add-signal, add-emacs, add-whatsapp, add-ollama-provider,
  debug, add-parallel) plus add-deltachat/VERIFY.md.
- `manage-channels/SKILL.md` shows canonical SQL but never prescribed
  a tool, so the assistant defaulted to `sqlite3` and silently failed.
  Add a one-line wrapper hint above the SQL block.
- `migrate-v2.sh` schema/count probes (was the original #2191 case).
  Replace `.tables` with `SELECT name FROM sqlite_master`.
- Document the wrapper convention in root `CLAUDE.md` under "Central DB".

Add `scripts/q.test.ts` with 6 vitest cases covering both modes,
NULL rendering, empty-result, compound mutations, and arg validation.
Wire `scripts/**/*.test.ts` into `vitest.config.ts`.

Out of scope (flagged for follow-up):
- `debug` and `add-parallel` still reference the v1-only path
  `store/messages.db`. Routing through the wrapper now produces a
  cleaner "no such file" error, but the surrounding sections are
  v1-era throughout — a v1-content cleanup is its own PR.
- `cleanup-sessions.sh` is being addressed in #1889 (different style,
  hard-fail rather than wrap); left untouched here to avoid stepping
  on that author's work.

Closes #2191.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 19:38:33 +02:00

743 lines
26 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
#
# If you're in Claude Code, exit first or open a separate terminal.
#
# 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, exit first or open a separate terminal,"
echo "then run:"
echo " bash migrate-v2.sh"
echo ""
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.
# Uses the in-tree wrapper instead of the sqlite3 CLI: setup.sh (run via
# phase 0a above) installs Node + better-sqlite3 but NOT the sqlite3 CLI,
# and #2191 documented how a missing CLI here used to surface as a
# misleading "registered_groups missing" abort.
TABLES=$(pnpm exec tsx scripts/q.ts "$V1_DB" "SELECT name FROM sqlite_master WHERE type='table'" 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=$(pnpm exec tsx scripts/q.ts "$V1_DB" "SELECT COUNT(*) FROM registered_groups" 2>/dev/null || echo 0)
TASK_COUNT=$(pnpm exec tsx scripts/q.ts "$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. (Removed) WhatsApp LID resolution was previously needed because the
# v6 adapter couldn't reliably translate LID→phone JIDs, so the migration
# pre-created dual messaging_groups rows. With Baileys v7, the adapter
# resolves LIDs via extractAddressingContext + signalRepository.lidMapping
# on every inbound message, so dual rows are unnecessary and were causing
# split sessions.
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}/api/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