- setup_new: asks for Tier 2 config repo URL (personal/private) - Automatically sets up git remote with provided URL - Simplified join flow - no grapho URL prompt needed - Copy config URL + age key to clipboard (not nix run commands) The grapho flake URL is already known (user ran nix run from it). The config repo is where personal secrets/config get stored. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
601 lines
18 KiB
Bash
Executable File
601 lines
18 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# setup - One command setup for Ultimate Notetaking System
|
|
#
|
|
# Usage:
|
|
# ./setup # Interactive setup
|
|
# ./setup <url> <age-key> # Non-interactive join
|
|
# ./setup mobile # Pair mobile device
|
|
#
|
|
set -euo pipefail
|
|
|
|
# Colors
|
|
R='\033[0;31m' G='\033[0;32m' Y='\033[1;33m' B='\033[0;34m'
|
|
BOLD='\033[1m' DIM='\033[2m' NC='\033[0m'
|
|
|
|
info() { echo -e "${B}::${NC} $1"; }
|
|
ok() { echo -e "${G}OK${NC} $1"; }
|
|
warn() { echo -e "${Y}!!${NC} $1"; }
|
|
err() { echo -e "${R}ERR${NC} $1" >&2; }
|
|
step() { echo -e "${DIM}->>${NC} $1"; }
|
|
|
|
# Config
|
|
USYNC_DIR="$HOME/.config/usync"
|
|
AGE_KEY_FILE="$USYNC_DIR/age-key.txt"
|
|
|
|
has() { command -v "$1" &>/dev/null; }
|
|
|
|
ensure_deps() {
|
|
local missing=()
|
|
for cmd in git jq; do
|
|
has "$cmd" || missing+=("$cmd")
|
|
done
|
|
|
|
if [[ ${#missing[@]} -gt 0 ]]; then
|
|
err "Missing: ${missing[*]}"
|
|
echo "Install with: nix-shell -p ${missing[*]}"
|
|
exit 1
|
|
fi
|
|
|
|
# Optional but recommended
|
|
has syncthing || warn "syncthing not found - file sync won't work"
|
|
has age || warn "age not found - install for secret management"
|
|
has qrencode || warn "qrencode not found - QR codes won't work"
|
|
}
|
|
|
|
get_device_id() {
|
|
if has syncthing; then
|
|
# Try running instance
|
|
local id
|
|
id=$(syncthing cli show system 2>/dev/null | jq -r '.myID // empty' 2>/dev/null || true)
|
|
[[ -n "$id" ]] && { echo "$id"; return; }
|
|
|
|
# Try config file
|
|
local cfg="$HOME/.config/syncthing/config.xml"
|
|
if [[ -f "$cfg" ]]; then
|
|
id=$(grep -oP '(?<=<device id=")[^"]+' "$cfg" 2>/dev/null | head -1 || true)
|
|
[[ -n "$id" ]] && { echo "$id"; return; }
|
|
fi
|
|
|
|
# Generate new
|
|
step "Generating Syncthing identity..."
|
|
syncthing generate --skip-port-probing &>/dev/null || true
|
|
syncthing cli show system 2>/dev/null | jq -r '.myID' 2>/dev/null || echo ""
|
|
fi
|
|
}
|
|
|
|
# =============================================================================
|
|
# SETUP NEW (first device)
|
|
# =============================================================================
|
|
setup_new() {
|
|
echo ""
|
|
echo -e "${BOLD}Setting up first device...${NC}"
|
|
echo ""
|
|
|
|
# Ask for config repo URL (Tier 2: personal config/secrets)
|
|
echo "Where should your personal config be stored? (private git repo)"
|
|
echo -n "Config repo URL (e.g., git@github.com:you/my-config.git): "
|
|
read -r config_url
|
|
|
|
echo -n "Branch [master]: "
|
|
read -r config_branch
|
|
config_branch="${config_branch:-master}"
|
|
|
|
echo ""
|
|
mkdir -p "$USYNC_DIR"
|
|
|
|
# Generate age key
|
|
if [[ -f "$AGE_KEY_FILE" ]]; then
|
|
warn "Age key already exists at $AGE_KEY_FILE"
|
|
else
|
|
if has age-keygen; then
|
|
step "Generating age encryption key..."
|
|
age-keygen -o "$AGE_KEY_FILE" 2>/dev/null
|
|
chmod 600 "$AGE_KEY_FILE"
|
|
grep '^# public key:' "$AGE_KEY_FILE" | sed 's/^# //'
|
|
else
|
|
err "age not installed. Run: nix-shell -p age"
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
# Get/create syncthing ID
|
|
local device_id
|
|
device_id=$(get_device_id)
|
|
|
|
# Create local config structure
|
|
local config_dir="$USYNC_DIR/config"
|
|
if [[ ! -d "$config_dir" ]]; then
|
|
step "Creating config structure..."
|
|
mkdir -p "$config_dir/hosts/$(hostname)" "$config_dir/secrets"
|
|
|
|
# Create minimal secrets template
|
|
cat > "$config_dir/secrets/secrets.yaml" <<'EOF'
|
|
# Encrypted with sops - edit with: sops secrets/secrets.yaml
|
|
restic-password: "changeme"
|
|
EOF
|
|
|
|
# Init git repo
|
|
git -C "$config_dir" init -q
|
|
git -C "$config_dir" add -A
|
|
git -C "$config_dir" commit -q --no-gpg-sign -m "Initial setup"
|
|
fi
|
|
|
|
# Set up remote if URL provided
|
|
if [[ -n "$config_url" ]]; then
|
|
step "Setting up git remote..."
|
|
git -C "$config_dir" remote remove origin 2>/dev/null || true
|
|
git -C "$config_dir" remote add origin "$config_url"
|
|
git -C "$config_dir" branch -M "$config_branch"
|
|
fi
|
|
|
|
local age_key
|
|
age_key=$(grep 'AGE-SECRET-KEY' "$AGE_KEY_FILE")
|
|
|
|
# Build join command for other devices
|
|
local join_cmd="nix run '<grapho-flake>' -- '${config_url}' '${age_key}'"
|
|
|
|
# Summary
|
|
echo ""
|
|
echo -e "${G}=== Setup Complete ===${NC}"
|
|
echo ""
|
|
echo "Your age key (SAVE THIS SOMEWHERE SAFE):"
|
|
echo -e "${BOLD}${age_key}${NC}"
|
|
echo ""
|
|
if [[ -n "$device_id" ]]; then
|
|
echo "Device ID: ${device_id:0:20}..."
|
|
fi
|
|
echo ""
|
|
if [[ -n "$config_url" ]]; then
|
|
echo "Next steps:"
|
|
echo " 1. Push config:"
|
|
echo " cd $config_dir && git push -u origin $config_branch"
|
|
echo ""
|
|
echo " 2. On other devices, run this flake and choose 'Join':"
|
|
echo " Config URL: ${config_url}"
|
|
echo " Age key: ${age_key}"
|
|
else
|
|
echo "Next steps:"
|
|
echo " 1. Create a private git repo and push config:"
|
|
echo " cd $config_dir"
|
|
echo " git remote add origin <your-repo-url>"
|
|
echo " git push -u origin $config_branch"
|
|
fi
|
|
echo ""
|
|
echo "To pair mobile: run this flake and choose 'mobile'"
|
|
|
|
# Copy join info to clipboard if URL provided
|
|
if [[ -n "$config_url" ]]; then
|
|
local clipboard_text="Config URL: ${config_url}
|
|
Age key: ${age_key}"
|
|
if has xclip; then
|
|
echo "$clipboard_text" | xclip -selection clipboard
|
|
echo ""
|
|
ok "Config URL and age key copied to clipboard!"
|
|
elif has wl-copy; then
|
|
echo "$clipboard_text" | wl-copy
|
|
echo ""
|
|
ok "Config URL and age key copied to clipboard!"
|
|
elif has pbcopy; then
|
|
echo "$clipboard_text" | pbcopy
|
|
echo ""
|
|
ok "Config URL and age key copied to clipboard!"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# =============================================================================
|
|
# SETUP JOIN (existing setup)
|
|
# =============================================================================
|
|
setup_join() {
|
|
local git_url="$1"
|
|
local age_key="$2"
|
|
|
|
echo ""
|
|
echo -e "${BOLD}Joining existing setup...${NC}"
|
|
echo ""
|
|
|
|
mkdir -p "$USYNC_DIR"
|
|
|
|
# Handle @file syntax
|
|
if [[ "$age_key" == @* ]]; then
|
|
local keyfile="${age_key:1}"
|
|
[[ -f "$keyfile" ]] || { err "Key file not found: $keyfile"; exit 1; }
|
|
age_key=$(cat "$keyfile")
|
|
fi
|
|
|
|
# Save age key
|
|
echo "$age_key" > "$AGE_KEY_FILE"
|
|
chmod 600 "$AGE_KEY_FILE"
|
|
step "Saved age key"
|
|
|
|
# Clone config
|
|
local config_dir="$USYNC_DIR/config"
|
|
if [[ -d "$config_dir" ]]; then
|
|
step "Updating config..."
|
|
git -C "$config_dir" pull --ff-only 2>/dev/null || git -C "$config_dir" fetch
|
|
else
|
|
step "Cloning config..."
|
|
git clone "$git_url" "$config_dir"
|
|
fi
|
|
|
|
# Decrypt secrets
|
|
if has sops && [[ -f "$config_dir/secrets/secrets.yaml" ]]; then
|
|
step "Decrypting secrets..."
|
|
export SOPS_AGE_KEY="$age_key"
|
|
sops -d "$config_dir/secrets/secrets.yaml" > "$USYNC_DIR/secrets.yaml" 2>/dev/null || warn "Could not decrypt secrets"
|
|
chmod 600 "$USYNC_DIR/secrets.yaml" 2>/dev/null || true
|
|
fi
|
|
|
|
# Setup syncthing
|
|
local device_id
|
|
device_id=$(get_device_id)
|
|
|
|
# Register device
|
|
local devices_file="$USYNC_DIR/devices.json"
|
|
echo "{\"devices\":{\"$(hostname)\":{\"id\":\"$device_id\",\"added\":\"$(date -Iseconds)\"}}}" > "$devices_file"
|
|
|
|
echo ""
|
|
echo -e "${G}=== Joined! ===${NC}"
|
|
echo ""
|
|
if [[ -n "$device_id" ]]; then
|
|
echo "This device: ${device_id:0:20}..."
|
|
echo ""
|
|
echo "Run 'add' on other machines to connect this device:"
|
|
echo -e " ${BOLD}Device ID: $device_id${NC}"
|
|
echo -e " ${BOLD}Name: $(hostname)${NC}"
|
|
fi
|
|
echo ""
|
|
echo "To pair mobile: run this flake with 'mobile' option"
|
|
|
|
# Copy device info to clipboard
|
|
if [[ -n "$device_id" ]]; then
|
|
local clipboard_text="Device ID: $device_id
|
|
Name: $(hostname)"
|
|
if has xclip; then
|
|
echo "$clipboard_text" | xclip -selection clipboard
|
|
echo ""
|
|
ok "Device info copied to clipboard!"
|
|
elif has wl-copy; then
|
|
echo "$clipboard_text" | wl-copy
|
|
echo ""
|
|
ok "Device info copied to clipboard!"
|
|
elif has pbcopy; then
|
|
echo "$clipboard_text" | pbcopy
|
|
echo ""
|
|
ok "Device info copied to clipboard!"
|
|
fi
|
|
fi
|
|
}
|
|
|
|
# =============================================================================
|
|
# MOBILE PAIRING
|
|
# =============================================================================
|
|
scan_qr_camera() {
|
|
# Check for video devices
|
|
if [[ ! -e /dev/video0 ]]; then
|
|
err "No camera found (/dev/video0 doesn't exist)" >&2
|
|
return 1
|
|
fi
|
|
|
|
if ! has ffmpeg; then
|
|
err "ffmpeg not found. Run: nix-shell -p ffmpeg zbar" >&2
|
|
return 1
|
|
fi
|
|
if ! has zbarimg; then
|
|
err "zbarimg not found. Run: nix-shell -p zbar" >&2
|
|
return 1
|
|
fi
|
|
|
|
# Use fixed paths (with PID) that cleanup can access without local vars
|
|
USYNC_QR_TMPDIR="/tmp/usync_qr_$$"
|
|
USYNC_QR_PIDFILE="/tmp/usync_qr_pid_$$"
|
|
|
|
mkdir -p "$USYNC_QR_TMPDIR"
|
|
|
|
cleanup_camera() {
|
|
if [[ -f "/tmp/usync_qr_pid_$$" ]]; then
|
|
kill "$(cat "/tmp/usync_qr_pid_$$")" 2>/dev/null || true
|
|
rm -f "/tmp/usync_qr_pid_$$"
|
|
fi
|
|
rm -rf "/tmp/usync_qr_$$"
|
|
}
|
|
trap cleanup_camera RETURN EXIT
|
|
|
|
echo "" >&2
|
|
echo "Point camera at QR code - it will scan automatically." >&2
|
|
echo "(Press Ctrl+C to cancel)" >&2
|
|
echo "" >&2
|
|
|
|
# Start ffmpeg: show preview with crosshair AND save frames for scanning
|
|
ffmpeg -f v4l2 -video_size 640x480 -i /dev/video0 \
|
|
-vf "drawbox=x=iw/2-100:y=ih/2-100:w=200:h=200:color=green:t=3" \
|
|
-f sdl2 -window_title "Scan QR Code" - \
|
|
-vf "fps=2" -update 1 -q:v 2 "$USYNC_QR_TMPDIR/frame.jpg" \
|
|
-loglevel quiet 2>/dev/null &
|
|
echo $! > "$USYNC_QR_PIDFILE"
|
|
|
|
sleep 1 # Let camera warm up
|
|
|
|
# Scan frames as they're captured
|
|
for attempt in {1..60}; do
|
|
sleep 0.5
|
|
|
|
# Check if ffmpeg died
|
|
if [[ ! -f "$USYNC_QR_PIDFILE" ]] || ! kill -0 "$(cat "$USYNC_QR_PIDFILE")" 2>/dev/null; then
|
|
break
|
|
fi
|
|
|
|
if [[ -f "$USYNC_QR_TMPDIR/frame.jpg" ]]; then
|
|
local result
|
|
result=$(zbarimg --raw -q "$USYNC_QR_TMPDIR/frame.jpg" 2>/dev/null || true)
|
|
|
|
if [[ -n "$result" ]]; then
|
|
result=$(echo "$result" | tr -d ' \n\r' | tr '[:lower:]' '[:upper:]')
|
|
|
|
if [[ "$result" =~ ^[A-Z0-9]{7}-[A-Z0-9]{7}-[A-Z0-9]{7}-[A-Z0-9]{7}-[A-Z0-9]{7}-[A-Z0-9]{7}-[A-Z0-9]{7}-[A-Z0-9]{7}$ ]]; then
|
|
echo "" >&2
|
|
ok "Scanned!" >&2
|
|
echo "$result"
|
|
return 0
|
|
fi
|
|
fi
|
|
fi
|
|
done
|
|
|
|
echo "" >&2
|
|
warn "No QR code detected (timed out after 30s)" >&2
|
|
return 1
|
|
}
|
|
|
|
check_pending_devices() {
|
|
if ! has syncthing || ! syncthing cli show system &>/dev/null 2>&1; then
|
|
return 1
|
|
fi
|
|
|
|
# Get pending devices (devices trying to connect that we haven't approved)
|
|
local pending
|
|
pending=$(syncthing cli show pending devices 2>/dev/null || true)
|
|
|
|
if [[ -n "$pending" && "$pending" != "{}" && "$pending" != "null" ]]; then
|
|
echo "$pending" | jq -r 'to_entries[] | "\(.key)|\(.value.name // "unknown")"' 2>/dev/null
|
|
return 0
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
check_local_discovery() {
|
|
if ! has syncthing || ! syncthing cli show system &>/dev/null 2>&1; then
|
|
return 1
|
|
fi
|
|
|
|
# Check discovery cache for LAN devices
|
|
local discovery
|
|
discovery=$(syncthing cli show discovery 2>/dev/null || true)
|
|
|
|
if [[ -n "$discovery" && "$discovery" != "{}" && "$discovery" != "null" ]]; then
|
|
echo "$discovery" | jq -r 'to_entries[] | select(.value[] | contains("tcp://192.") or contains("tcp://10.") or contains("tcp://172.")) | .key' 2>/dev/null | head -5
|
|
return 0
|
|
fi
|
|
return 1
|
|
}
|
|
|
|
add_phone_device() {
|
|
local phone_id="$1"
|
|
local name="${2:-phone}"
|
|
|
|
phone_id=$(echo "$phone_id" | tr -d ' \n' | tr '[:lower:]' '[:upper:]')
|
|
|
|
if has syncthing && syncthing cli show system &>/dev/null 2>&1; then
|
|
syncthing cli config devices add --device-id "$phone_id" --name "$name" 2>/dev/null || true
|
|
syncthing cli config devices "$phone_id" auto-accept-folders set true 2>/dev/null || true
|
|
ok "Added: $name"
|
|
return 0
|
|
else
|
|
mkdir -p "$USYNC_DIR"
|
|
echo "$phone_id" > "$USYNC_DIR/pending-phone.id"
|
|
ok "Saved (will add when syncthing starts)"
|
|
return 0
|
|
fi
|
|
}
|
|
|
|
setup_mobile() {
|
|
echo ""
|
|
echo -e "${BOLD}=== Pair Mobile Device ===${NC}"
|
|
echo ""
|
|
|
|
local device_id
|
|
device_id=$(get_device_id)
|
|
|
|
if [[ -z "$device_id" ]]; then
|
|
err "Could not get device ID. Is syncthing installed?"
|
|
exit 1
|
|
fi
|
|
|
|
# Check for pending devices first (phone already trying to connect)
|
|
local pending
|
|
pending=$(check_pending_devices 2>/dev/null || true)
|
|
|
|
if [[ -n "$pending" ]]; then
|
|
echo -e "${G}Found device(s) waiting to connect:${NC}"
|
|
echo ""
|
|
local i=1
|
|
declare -a pending_ids
|
|
while IFS='|' read -r id name; do
|
|
echo " [$i] $name (${id:0:20}...)"
|
|
pending_ids+=("$id")
|
|
((i++))
|
|
done <<< "$pending"
|
|
echo ""
|
|
echo -n "Accept which? [1-$((i-1))], or Enter to continue: "
|
|
read -r choice
|
|
|
|
if [[ -n "$choice" && "$choice" =~ ^[0-9]+$ && "$choice" -ge 1 && "$choice" -lt "$i" ]]; then
|
|
local selected_id="${pending_ids[$((choice-1))]}"
|
|
add_phone_device "$selected_id" "phone"
|
|
echo ""
|
|
ok "Mobile pairing complete!"
|
|
return
|
|
fi
|
|
echo ""
|
|
fi
|
|
|
|
# Step 1: Show computer's ID for phone
|
|
echo -e "${BOLD}Step 1: Add this computer on your phone${NC}"
|
|
echo ""
|
|
echo "Computer's Device ID:"
|
|
echo ""
|
|
echo -e "${G}$device_id${NC}"
|
|
echo ""
|
|
|
|
if has qrencode; then
|
|
echo "Or scan this QR in Syncthing app:"
|
|
echo ""
|
|
qrencode -t ANSIUTF8 "$device_id"
|
|
fi
|
|
|
|
echo ""
|
|
echo "In Syncthing app: tap (+) > paste or scan above"
|
|
echo ""
|
|
echo -n "Press Enter when done..."
|
|
read -r
|
|
|
|
# Step 2: Get phone's ID
|
|
echo ""
|
|
echo -e "${BOLD}Step 2: Add your phone on this computer${NC}"
|
|
echo ""
|
|
echo "In Syncthing app: Menu (3 dots) > Show device ID"
|
|
echo ""
|
|
|
|
# Try camera first if available
|
|
if has zbarimg && has ffmpeg && [[ -e /dev/video0 ]]; then
|
|
echo " [1] Scan phone's QR with camera"
|
|
echo " [2] Paste phone's device ID"
|
|
echo ""
|
|
echo -n "Choice [1/2]: "
|
|
read -r method
|
|
|
|
if [[ "$method" == "1" ]]; then
|
|
local scanned
|
|
if scanned=$(scan_qr_camera); then
|
|
add_phone_device "$scanned" "phone"
|
|
echo ""
|
|
ok "Pairing complete! Devices should connect shortly."
|
|
return
|
|
fi
|
|
echo ""
|
|
echo "Camera didn't work. Paste the ID instead:"
|
|
fi
|
|
fi
|
|
|
|
echo -n "Paste phone's device ID: "
|
|
read -r phone_id
|
|
|
|
if [[ -n "$phone_id" ]]; then
|
|
add_phone_device "$phone_id" "phone"
|
|
echo ""
|
|
ok "Pairing complete! Devices should connect shortly."
|
|
else
|
|
warn "No ID entered. Run 'mobile' option again to retry."
|
|
fi
|
|
}
|
|
|
|
# =============================================================================
|
|
# ADD DEVICE
|
|
# =============================================================================
|
|
add_device() {
|
|
local device_id="$1"
|
|
local name="${2:-device}"
|
|
|
|
device_id=$(echo "$device_id" | tr -d ' \n' | tr '[:lower:]' '[:upper:]')
|
|
|
|
if has syncthing && syncthing cli show system &>/dev/null 2>&1; then
|
|
syncthing cli config devices add --device-id "$device_id" --name "$name"
|
|
syncthing cli config devices "$device_id" auto-accept-folders set true
|
|
ok "Added device: $name"
|
|
else
|
|
mkdir -p "$USYNC_DIR"
|
|
echo "{\"id\":\"$device_id\",\"name\":\"$name\"}" >> "$USYNC_DIR/pending-devices.json"
|
|
ok "Saved device (will add when syncthing runs)"
|
|
fi
|
|
}
|
|
|
|
# =============================================================================
|
|
# INTERACTIVE
|
|
# =============================================================================
|
|
interactive() {
|
|
echo ""
|
|
echo -e "${BOLD}Ultimate Notetaking System - Setup${NC}"
|
|
echo ""
|
|
echo " [1] New setup (first device)"
|
|
echo " [2] Join existing setup"
|
|
echo " [3] Pair mobile device"
|
|
echo ""
|
|
echo -n "Choice [1/2/3]: "
|
|
read -r choice
|
|
|
|
case "$choice" in
|
|
1|n|new)
|
|
setup_new
|
|
;;
|
|
2|j|join)
|
|
echo ""
|
|
echo -n "Config git URL: "
|
|
read -r git_url
|
|
echo -n "Age secret key: "
|
|
read -r age_key
|
|
setup_join "$git_url" "$age_key"
|
|
;;
|
|
3|m|mobile)
|
|
setup_mobile
|
|
;;
|
|
*)
|
|
err "Invalid choice"
|
|
exit 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# =============================================================================
|
|
# MAIN
|
|
# =============================================================================
|
|
ensure_deps
|
|
|
|
case "${1:-}" in
|
|
"")
|
|
interactive
|
|
;;
|
|
mobile|phone|m)
|
|
setup_mobile
|
|
;;
|
|
add)
|
|
[[ -z "${2:-}" ]] && { err "Usage: ./setup add <device-id> [name]"; exit 1; }
|
|
add_device "$2" "${3:-}"
|
|
;;
|
|
help|-h|--help)
|
|
cat <<'EOF'
|
|
setup - One command setup
|
|
|
|
Usage:
|
|
./setup Interactive setup
|
|
./setup <url> <key> Join existing setup
|
|
./setup mobile Pair mobile device
|
|
./setup add <id> [name] Add a device
|
|
|
|
Examples:
|
|
./setup # Interactive
|
|
./setup git@srv:me/cfg.git AGE-SECRET-KEY-1... # Join
|
|
./setup mobile # Pair phone
|
|
EOF
|
|
;;
|
|
*)
|
|
# Assume it's: ./setup <url> <key>
|
|
if [[ -n "${2:-}" ]]; then
|
|
setup_join "$1" "$2"
|
|
else
|
|
err "Usage: ./setup <git-url> <age-key>"
|
|
exit 1
|
|
fi
|
|
;;
|
|
esac
|