#!/usr/bin/env bash # ============================================================================= # dotfiles_manager.sh — Centralized Dotfiles Management # # Manages shell configs, SSH config, and editor settings via a git repo # hosted on your local Gitea server. # # Remote: http://172.27.0.35:3000/kenjim/dotfiles # Strategy: Files live in ~/dotfiles/, HOME locations are symlinked to them. # # Usage: # ./dotfiles_manager.sh [args] # # Commands: # init Clone remote repo (or init + set remote if new machine) # add Track a file: move it into ~/dotfiles, create symlink back # remove Untrack: restore file to HOME, remove symlink record # install Reapply all symlinks (use on a new machine after cloning) # sync Pull latest from remote, reapply any new symlinks # push [message] Stage all changes, commit, and push to remote # status Show tracked files and their symlink health # list Alias for status # ssh-setup Interactively add SSH config and optionally keys # ssh-export GPG-encrypt private keys → dotfiles/.ssh/keys/*.gpg # ssh-import Decrypt GPG-encrypted keys from dotfiles to ~/.ssh/ # remote-bootstrap SSH into another machine and run full setup # deploy-to SCP tracked dotfiles + scripts to a server (no git needed) # help Show this message # # File layout inside ~/dotfiles/: # .bashrc → symlinked from ~/.bashrc # .bash_profile → symlinked from ~/.bash_profile # .bash_aliases → symlinked from ~/.bash_aliases # .gitconfig → symlinked from ~/.gitconfig # .ssh/config → symlinked from ~/.ssh/config # .ssh/keys/ → encrypted or public-only keys (see ssh-setup) # .vimrc → symlinked from ~/.vimrc # .tmux.conf → symlinked from ~/.tmux.conf # .inputrc → symlinked from ~/.inputrc # .dotfiles_manifest internal list of tracked HOME paths # install.sh portable restore script (run on new machines) # README.md documentation # ============================================================================= set -euo pipefail # ----------------------------------------------------------------------- # CONFIG — override with env vars if needed # ----------------------------------------------------------------------- DOTFILES_DIR="${DOTFILES_DIR:-$HOME/dotfiles}" DOTFILES_REMOTE="${DOTFILES_REMOTE:-http://172.27.0.35:3000/kenjim/dotfiles}" MANIFEST="$DOTFILES_DIR/.dotfiles_manifest" BACKUP_DIR="$HOME/.dotfiles_backup/$(date +%Y%m%d_%H%M%S)" # ----------------------------------------------------------------------- # COLORS # ----------------------------------------------------------------------- RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m' CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m' info() { echo -e "${CYAN}[dotfiles]${RESET} $*"; } success() { echo -e "${GREEN}[dotfiles]${RESET} ✓ $*"; } warn() { echo -e "${YELLOW}[dotfiles]${RESET} ⚠ $*"; } error() { echo -e "${RED}[dotfiles]${RESET} ✗ $*" >&2; } bold() { echo -e "${BOLD}$*${RESET}"; } die() { error "$*"; exit 1; } # ----------------------------------------------------------------------- # HELPERS # ----------------------------------------------------------------------- # Resolve the relative path stored in manifest → absolute HOME path manifest_to_home() { # entries are stored relative to HOME, e.g. ".bashrc" or ".ssh/config" echo "$HOME/$1" } # Compute the path inside dotfiles/ for a given HOME path home_to_dotfiles() { local home_path="$1" local rel="${home_path#"$HOME/"}" echo "$DOTFILES_DIR/$rel" } # Add entry to manifest (deduplicates) manifest_add() { local rel="${1#"$HOME/"}" mkdir -p "$(dirname "$MANIFEST")" touch "$MANIFEST" grep -qxF "$rel" "$MANIFEST" || echo "$rel" >> "$MANIFEST" # Keep manifest sorted for clean diffs sort -o "$MANIFEST" "$MANIFEST" } # Remove entry from manifest manifest_remove() { local rel="${1#"$HOME/"}" [ -f "$MANIFEST" ] && sed -i.bak "/^${rel//\//\\/}$/d" "$MANIFEST" && rm -f "${MANIFEST}.bak" } # Check if dotfiles dir is a git repo is_git_repo() { git -C "$DOTFILES_DIR" rev-parse --git-dir &>/dev/null } # Git command scoped to dotfiles dir dgit() { git -C "$DOTFILES_DIR" "$@" } # Credentials file (never committed — chmod 600) CREDS_FILE="${XDG_CONFIG_HOME:-$HOME/.config}/dotfiles/credentials" # Read saved token (returns empty string if not set) _read_token() { [ -f "$CREDS_FILE" ] && grep -m1 '^GITEA_TOKEN=' "$CREDS_FILE" | cut -d= -f2- || true } # Inject token into an HTTP(S) remote URL for a single git operation. # Keeps the stored remote URL clean — only the transient call gets credentials. _authed_url() { local url="$1" local token; token="${GITEA_TOKEN:-$(_read_token)}" if [[ -n "$token" && "$url" == http* ]]; then local host_path; host_path="${url#*://}" host_path="${host_path#*@}" # strip any existing user:pass echo "${url%%://*}://kenjim:${token}@${host_path}" else echo "$url" fi } # Check gpg is installed with OS-appropriate install hint _require_gpg() { command -v gpg &>/dev/null && return 0 case "$(uname -s)" in Darwin*) die "gpg not installed. Install with: brew install gnupg" ;; *) die "gpg not installed. Install with: sudo apt-get install gnupg" ;; esac } # ----------------------------------------------------------------------- # COMMAND: init # ----------------------------------------------------------------------- cmd_init() { bold "=== Initializing dotfiles repo ===" info "Local path : $DOTFILES_DIR" info "Remote : $DOTFILES_REMOTE" echo # Check if remote is reachable if curl --silent --max-time 5 --output /dev/null "$DOTFILES_REMOTE" 2>/dev/null; then info "Remote server is reachable." else warn "Remote server not reachable right now. Will init locally and set remote for later." fi if [ -d "$DOTFILES_DIR/.git" ]; then warn "Dotfiles repo already exists at $DOTFILES_DIR" # Ensure remote is set correctly if ! dgit remote get-url origin &>/dev/null; then dgit remote add origin "$DOTFILES_REMOTE" success "Remote 'origin' added: $DOTFILES_REMOTE" else local current_remote current_remote=$(dgit remote get-url origin) if [ "$current_remote" != "$DOTFILES_REMOTE" ]; then dgit remote set-url origin "$DOTFILES_REMOTE" success "Remote updated to: $DOTFILES_REMOTE" else info "Remote already set correctly." fi fi return fi # Try to clone first if curl --silent --max-time 5 --output /dev/null "$DOTFILES_REMOTE" 2>/dev/null; then info "Attempting to clone from remote..." if git clone "$DOTFILES_REMOTE" "$DOTFILES_DIR" 2>/dev/null; then success "Cloned from $DOTFILES_REMOTE" info "Run './dotfiles_manager.sh install' to apply symlinks." return else warn "Clone failed (repo may be empty). Initializing locally instead." fi fi # Init fresh repo mkdir -p "$DOTFILES_DIR" cd "$DOTFILES_DIR" git init dgit remote add origin "$DOTFILES_REMOTE" # Create .gitignore — protect private SSH keys by default cat > "$DOTFILES_DIR/.gitignore" <<'GITIGNORE' # macOS .DS_Store .DS_Store? ._* # Backup artifacts *.bak *.orig # SSH private keys — never commit unencrypted private keys # Remove a line below only if you store GPG-encrypted versions .ssh/id_rsa .ssh/id_ed25519 .ssh/id_ecdsa .ssh/id_dsa .ssh/keys/*_rsa .ssh/keys/*_ed25519 .ssh/keys/*_ecdsa .ssh/keys/*.pem # Public keys and config are fine !.ssh/*.pub !.ssh/keys/*.pub !.ssh/config !.ssh/known_hosts # GPG-encrypted private key backups are safe to commit !.ssh/keys/*.gpg # Secrets / tokens — never commit .env .env.* *.token *.secrets vault/ # Machine-local overrides — never commit (written by setup_enterprise_ai_bash.sh) .bashrc.local .bash_profile.local # Credential store — never commit .config/dotfiles/credentials GITIGNORE # Seed README cat > "$DOTFILES_DIR/README.md" < MARKDOWN touch "$MANIFEST" dgit add . dgit commit -m "chore: initial dotfiles skeleton" success "Dotfiles repo initialized at $DOTFILES_DIR" info "Push when ready: ./dotfiles_manager.sh push 'initial commit'" } # ----------------------------------------------------------------------- # COMMAND: add # ----------------------------------------------------------------------- cmd_add() { [ $# -ge 1 ] || die "Usage: add [ ...]" for target in "$@"; do # Expand ~ target="${target/#\~/$HOME}" target="$(realpath -m "$target" 2>/dev/null || echo "$target")" if [ ! -e "$target" ] && [ ! -L "$target" ]; then warn "Skipping '$target': does not exist." continue fi local rel="${target#"$HOME/"}" local dest="$DOTFILES_DIR/$rel" if [ -L "$target" ] && [ "$(readlink "$target")" = "$dest" ]; then info "$rel is already tracked and symlinked." continue fi # Create destination parent directories mkdir -p "$(dirname "$dest")" # Back up existing dotfiles destination if present and not a symlink if [ -e "$dest" ] && [ ! -L "$dest" ]; then mkdir -p "$BACKUP_DIR/$(dirname "$rel")" cp -a "$dest" "$BACKUP_DIR/$rel" warn "Backed up existing $dest → $BACKUP_DIR/$rel" fi # Move file if [ -L "$target" ]; then # If it's already a symlink to somewhere else, copy the contents cp -a "$(readlink "$target")" "$dest" rm "$target" else mv "$target" "$dest" fi # Create symlink ln -sf "$dest" "$target" manifest_add "$target" success "Tracked: $rel\n $target → $dest" done } # ----------------------------------------------------------------------- # COMMAND: remove # ----------------------------------------------------------------------- cmd_remove() { [ $# -ge 1 ] || die "Usage: remove " local target="$1" target="${target/#\~/$HOME}" local rel="${target#"$HOME/"}" local src="$DOTFILES_DIR/$rel" if [ ! -e "$src" ]; then die "Not tracked: $rel" fi if [ -L "$target" ]; then rm "$target" fi cp -a "$src" "$target" manifest_remove "$target" success "Untracked $rel — file restored to $target" info "The copy in $src still exists. Remove it manually if desired." } # ----------------------------------------------------------------------- # COMMAND: install (idempotent — safe to re-run) # ----------------------------------------------------------------------- cmd_install() { bold "=== Applying dotfiles symlinks ===" [ -f "$MANIFEST" ] || { warn "Manifest empty — nothing to install."; return; } local count=0 skipped=0 errors=0 while IFS= read -r rel || [ -n "$rel" ]; do [ -z "$rel" ] && continue local src="$DOTFILES_DIR/$rel" local dest="$HOME/$rel" if [ ! -e "$src" ]; then warn "Missing in dotfiles: $src — skipping." (( errors++ )) || true continue fi mkdir -p "$(dirname "$dest")" if [ -L "$dest" ] && [ "$(readlink "$dest")" = "$src" ]; then (( skipped++ )) || true continue fi # Back up conflicting file/directory if [ -e "$dest" ] && [ ! -L "$dest" ]; then mkdir -p "$BACKUP_DIR/$(dirname "$rel")" cp -a "$dest" "$BACKUP_DIR/$rel" warn "Backed up existing $dest" # rm -rf needed when $dest is a real directory (e.g. ~/.bashrc.d created manually) if [ -d "$dest" ]; then rm -rf "$dest"; else rm -f "$dest"; fi elif [ -L "$dest" ]; then rm "$dest" fi # Set safe permissions for SSH files if [[ "$rel" == .ssh/* ]]; then if [ -d "$src" ]; then chmod 700 "$src" else chmod 600 "$src" fi fi # -n: treat dest as a file (don't place link inside) if dest is a dir symlink ln -sfn "$src" "$dest" success "Linked: ~/$rel → $src" (( count++ )) || true done < "$MANIFEST" echo bold "Install complete: $count linked, $skipped already up-to-date, $errors errors." # Offer ssh-import if GPG-encrypted keys are present but private key is missing local gpg_count=0 gpg_count=$(find "$DOTFILES_DIR/.ssh/keys" -maxdepth 1 -name '*.gpg' 2>/dev/null | wc -l | tr -d ' ') if [[ "$gpg_count" -gt 0 ]]; then local missing_keys=false for gpg_f in "$DOTFILES_DIR/.ssh/keys/"*.gpg; do local base_name; base_name="$(basename "${gpg_f%.gpg}")" [ ! -f "$HOME/.ssh/$base_name" ] && missing_keys=true && break done if $missing_keys; then echo warn "$gpg_count GPG-encrypted SSH key(s) found in dotfiles but not yet decrypted." read -r -p "Decrypt SSH keys now? (y/n): " _imp [[ "$_imp" == [yY] ]] && cmd_ssh_import fi fi } # ----------------------------------------------------------------------- # COMMAND: sync # ----------------------------------------------------------------------- cmd_sync() { bold "=== Syncing from remote ===" is_git_repo || die "Not a git repo: $DOTFILES_DIR. Run 'init' first." local pull_url; pull_url="$(_authed_url "$DOTFILES_REMOTE")" # Stash any local changes so pull is clean local stashed=false if ! dgit diff --quiet || ! dgit diff --cached --quiet; then warn "Local changes detected — stashing before pull." dgit stash push -m "dotfiles_manager auto-stash before sync" stashed=true fi dgit pull --rebase "$pull_url" main 2>/dev/null || \ dgit pull --rebase "$pull_url" master 2>/dev/null || { warn "Could not pull (remote unreachable or no token set). Working offline." warn "Run 'dotfiles auth' to save credentials if push/pull keep failing." } if $stashed; then dgit stash pop || warn "Stash pop had conflicts. Resolve in $DOTFILES_DIR" fi cmd_install success "Sync complete." } # ----------------------------------------------------------------------- # CREDENTIAL HELPERS — cmd_auth is defined further below # (CREDS_FILE, _read_token, _authed_url are in the HELPERS block above) # ----------------------------------------------------------------------- # ----------------------------------------------------------------------- # COMMAND: auth — save Gitea credentials once # ----------------------------------------------------------------------- cmd_auth() { bold "=== Gitea Authentication Setup ===" echo info "Remote: $DOTFILES_REMOTE" info "Credentials are saved to $CREDS_FILE (chmod 600, never committed)" echo echo "Options:" echo " [1] Personal Access Token (recommended — Settings → Applications → Generate Token)" echo " [2] Password (not recommended)" read -r -p "Choice [1]: " _method _method="${_method:-1}" local token if [[ "$_method" == "2" ]]; then read -r -s -p "Gitea password: " token; echo else echo info "To generate a token: $DOTFILES_REMOTE (open in browser)" info " → Settings (top-right avatar) → Applications → Generate Token" info " → Name it 'dotfiles', tick Contents+Write, copy the token" echo read -r -s -p "Paste token: " token; echo fi [[ -z "$token" ]] && die "No token entered." mkdir -p "$(dirname "$CREDS_FILE")" chmod 700 "$(dirname "$CREDS_FILE")" printf 'GITEA_TOKEN=%s\n' "$token" > "$CREDS_FILE" chmod 600 "$CREDS_FILE" success "Credentials saved to $CREDS_FILE" info "Test with: dotfiles push" } # ----------------------------------------------------------------------- # COMMAND: push # ----------------------------------------------------------------------- cmd_push() { local msg="${1:-"chore: update dotfiles $(date '+%Y-%m-%d %H:%M')"}" bold "=== Pushing to remote ===" is_git_repo || die "Not a git repo. Run 'init' first." dgit add --all if dgit diff --cached --quiet; then info "Nothing to commit." else dgit commit -m "$msg" success "Committed: $msg" fi # Determine default branch local branch branch=$(dgit rev-parse --abbrev-ref HEAD) # Build push URL — inject token for HTTP remotes so we never hang on a prompt local push_url; push_url="$(_authed_url "$DOTFILES_REMOTE")" if [[ "$push_url" == "$DOTFILES_REMOTE" && "$DOTFILES_REMOTE" == http* ]]; then warn "No Gitea token found. Run 'dotfiles auth' to save one." warn "Attempting push anyway (may hang waiting for password)..." fi # Set upstream on first push if needed if ! dgit config "branch.$branch.remote" &>/dev/null; then dgit push --set-upstream "$push_url" "$branch" # Record the clean URL as upstream (not the token-embedded one) dgit remote set-url origin "$DOTFILES_REMOTE" 2>/dev/null || true dgit config "branch.$branch.remote" origin dgit config "branch.$branch.merge" "refs/heads/$branch" else dgit push "$push_url" "$branch" fi success "Pushed to $DOTFILES_REMOTE ($branch)." } # ----------------------------------------------------------------------- # COMMAND: status # ----------------------------------------------------------------------- cmd_status() { bold "=== Dotfiles Status ===" info "Repo : $DOTFILES_DIR" info "Remote : $DOTFILES_REMOTE" echo if [ ! -f "$MANIFEST" ] || [ ! -s "$MANIFEST" ]; then echo " (no files tracked yet — use 'add' to start)" return fi printf " %-42s %s\n" "HOME PATH" "STATUS" printf " %-42s %s\n" "-----------------------------------------" "-------" while IFS= read -r rel || [ -n "$rel" ]; do [ -z "$rel" ] && continue local home_path="$HOME/$rel" local src="$DOTFILES_DIR/$rel" local status if [ ! -e "$src" ]; then status="${RED}MISSING in dotfiles${RESET}" elif [ -L "$home_path" ] && [ "$(readlink "$home_path")" = "$src" ]; then status="${GREEN}OK (symlinked)${RESET}" elif [ -e "$home_path" ] && [ ! -L "$home_path" ]; then status="${YELLOW}CONFLICT (real file exists at HOME)${RESET}" elif [ ! -e "$home_path" ]; then status="${YELLOW}NOT LINKED (run install)${RESET}" else status="${YELLOW}STALE SYMLINK${RESET}" fi printf " %-42s " "~/$rel" echo -e "$status" done < "$MANIFEST" echo if is_git_repo; then bold "Git status:" dgit status --short echo local last_commit last_commit=$(dgit log -1 --format="%h %s (%ar)" 2>/dev/null || echo "no commits yet") info "Last commit: $last_commit" fi } # ----------------------------------------------------------------------- # COMMAND: ssh-setup # ----------------------------------------------------------------------- cmd_ssh_setup() { bold "=== SSH Config & Key Management ===" echo warn "SECURITY REMINDER: Private SSH key files are in .gitignore by default." warn "Only ~/.ssh/config, known_hosts, and .pub files will be committed." warn "To store encrypted private keys, use GPG encryption first." echo local ssh_config="$HOME/.ssh/config" local ssh_known="$HOME/.ssh/known_hosts" local dotfiles_ssh="$DOTFILES_DIR/.ssh" mkdir -p "$dotfiles_ssh/keys" chmod 700 "$dotfiles_ssh" # Track ssh/config if [ -f "$ssh_config" ]; then read -r -p "Track ~/.ssh/config in dotfiles? (y/n): " yn if [[ "$yn" == [yY] ]]; then cmd_add "$ssh_config" fi else info "No ~/.ssh/config found. Creating a template..." mkdir -p "$HOME/.ssh" cat > "$dotfiles_ssh/config" <<'SSH_CONFIG' # SSH Client Configuration # Managed by dotfiles_manager.sh # See: man ssh_config Host * ServerAliveInterval 60 ServerAliveCountMax 3 AddKeysToAgent yes IdentitiesOnly yes # Example host alias: # Host myserver # HostName 192.168.1.100 # User kenji # IdentityFile ~/.ssh/id_ed25519 # Port 22 # Local Gitea server Host gitea-local HostName 172.27.0.35 User git Port 22 IdentitiesOnly yes # IdentityFile ~/.ssh/id_ed25519_gitea SSH_CONFIG chmod 600 "$dotfiles_ssh/config" ln -sf "$dotfiles_ssh/config" "$HOME/.ssh/config" manifest_add "$HOME/.ssh/config" success "Created and tracked ~/.ssh/config" fi # Track known_hosts (useful for pre-seeding new machines) if [ -f "$ssh_known" ]; then read -r -p "Track ~/.ssh/known_hosts in dotfiles? (y/n): " yn [[ "$yn" == [yY] ]] && cmd_add "$ssh_known" fi # Track public keys local pub_keys=() while IFS= read -r -d '' f; do pub_keys+=("$f") done < <(find "$HOME/.ssh" -maxdepth 1 -name "*.pub" -print0 2>/dev/null) if [ ${#pub_keys[@]} -gt 0 ]; then echo info "Found public keys:" for k in "${pub_keys[@]}"; do echo " $k"; done read -r -p "Copy public keys to dotfiles/.ssh/keys/? (y/n): " yn if [[ "$yn" == [yY] ]]; then for pubkey in "${pub_keys[@]}"; do cp "$pubkey" "$dotfiles_ssh/keys/" success "Copied: $(basename "$pubkey") → dotfiles/.ssh/keys/" done fi fi # Offer to store encrypted private keys echo warn "Private key storage (advanced — optional):" info "If you want to store encrypted private key backups, use:" echo " gpg --symmetric --cipher-algo AES256 ~/.ssh/id_ed25519" echo " mv ~/.ssh/id_ed25519.gpg ~/dotfiles/.ssh/keys/" echo " Then amend .gitignore to allow the .gpg file." echo success "SSH setup complete." echo info "Next step: GPG-encrypt your private keys for cross-machine sharing:" info " dotfiles ssh-export → encrypt keys into dotfiles/.ssh/keys/*.gpg" info " dotfiles push → commit and push to $DOTFILES_REMOTE" info " dotfiles ssh-import → decrypt on any other machine after sync" } # ----------------------------------------------------------------------- # COMMAND: ssh-export (GPG-encrypt private keys → dotfiles/.ssh/keys/) # ----------------------------------------------------------------------- cmd_ssh_export() { bold "=== Export SSH Private Keys (GPG-encrypted) ===" _require_gpg local dotfiles_ssh="$DOTFILES_DIR/.ssh/keys" mkdir -p "$dotfiles_ssh" # Collect private keys (id_* files, no .pub extension, no .gpg) local private_keys=() while IFS= read -r -d '' f; do private_keys+=("$f") done < <(find "$HOME/.ssh" -maxdepth 1 -type f \ \( -name "id_*" ! -name "*.pub" ! -name "*.gpg" \) -print0 2>/dev/null) if [ ${#private_keys[@]} -eq 0 ]; then info "No private keys found in ~/.ssh/" return fi info "Found private keys:" for k in "${private_keys[@]}"; do echo " $k"; done echo warn "Each key will be GPG-encrypted with a symmetric passphrase you choose." warn "Store this passphrase in a password manager — you need it to restore keys." echo read -r -p "Proceed? (y/n): " yn [[ "$yn" == [yY] ]] || return for key in "${private_keys[@]}"; do local key_name; key_name="$(basename "$key")" local encrypted="$dotfiles_ssh/${key_name}.gpg" if [ -f "$encrypted" ]; then read -r -p " $key_name.gpg exists. Re-encrypt? (y/n): " re_enc [[ "$re_enc" == [yY] ]] || continue rm "$encrypted" fi if gpg --symmetric --cipher-algo AES256 --output "$encrypted" "$key"; then success "Encrypted: $key → $encrypted" else warn "Encryption failed for $key_name — skipping." rm -f "$encrypted" fi done # Ensure .gitignore allows .gpg files (idempotent) local gitignore="$DOTFILES_DIR/.gitignore" if ! grep -q "^!.ssh/keys/\*.gpg" "$gitignore" 2>/dev/null; then printf '\n# GPG-encrypted private key backups are safe to commit\n!.ssh/keys/*.gpg\n' >> "$gitignore" success "Updated .gitignore to allow .gpg files" fi echo success "SSH export complete. Run 'push' to save to $DOTFILES_REMOTE" } # ----------------------------------------------------------------------- # COMMAND: ssh-import (decrypt GPG keys from dotfiles/.ssh/keys/ → ~/.ssh/) # ----------------------------------------------------------------------- cmd_ssh_import() { bold "=== Import SSH Private Keys (GPG decrypt) ===" _require_gpg local dotfiles_ssh="$DOTFILES_DIR/.ssh/keys" local gpg_keys=() while IFS= read -r -d '' f; do gpg_keys+=("$f") done < <(find "$dotfiles_ssh" -maxdepth 1 -name "*.gpg" -print0 2>/dev/null) if [ ${#gpg_keys[@]} -eq 0 ]; then info "No GPG-encrypted keys found in dotfiles/.ssh/keys/" info "Run 'ssh-export' first to encrypt your private keys." return fi info "Found encrypted keys:" for k in "${gpg_keys[@]}"; do echo " $(basename "$k")"; done echo read -r -p "Decrypt and install to ~/.ssh/? (y/n): " yn [[ "$yn" == [yY] ]] || return mkdir -p "$HOME/.ssh" chmod 700 "$HOME/.ssh" local imported=0 for gpg_key in "${gpg_keys[@]}"; do local key_name; key_name="$(basename "${gpg_key%.gpg}")" local dest="$HOME/.ssh/$key_name" if [ -f "$dest" ]; then warn " $dest already exists — skipping. Remove it first to re-import." continue fi if gpg --decrypt --output "$dest" "$gpg_key"; then chmod 600 "$dest" success "Decrypted: $(basename "$gpg_key") → $dest" (( imported++ )) || true else warn " Failed to decrypt $(basename "$gpg_key")" rm -f "$dest" fi done # Add freshly imported keys to the running ssh-agent if [[ "$imported" -gt 0 ]] && ssh-add -l &>/dev/null 2>&1; then for gpg_key in "${gpg_keys[@]}"; do local key_name; key_name="$(basename "${gpg_key%.gpg}")" local dest="$HOME/.ssh/$key_name" [ -f "$dest" ] && ssh-add "$dest" 2>/dev/null && info " Added to agent: $key_name" done fi echo success "SSH import complete ($imported key(s) decrypted)." } # ----------------------------------------------------------------------- # COMMAND: remote-bootstrap (SSH into another machine and run full setup) # ----------------------------------------------------------------------- cmd_remote_bootstrap() { [ $# -ge 1 ] || die "Usage: remote-bootstrap [--profile work|personal]" local target="$1"; shift local profile="personal" # Parse optional --profile flag while [[ $# -gt 0 ]]; do case "$1" in --profile) profile="${2:-personal}"; shift 2 ;; *) die "Unknown option: $1" ;; esac done bold "=== Remote Bootstrap: $target (profile: $profile) ===" echo # ---- 1. Verify SSH connectivity (prefer key-based auth) ---- info "Testing SSH connectivity..." if ! ssh -o ConnectTimeout=10 -o BatchMode=yes -o StrictHostKeyChecking=accept-new \ "$target" "exit 0" 2>/dev/null; then warn "Key-based SSH auth failed for $target." read -r -p "Copy your SSH public key to $target now? (y/n): " yn if [[ "$yn" == [yY] ]]; then ssh-copy-id "$target" || die "ssh-copy-id failed. Fix SSH access to $target first." else die "SSH key access required. Run:\n ssh-copy-id $target" fi fi success "SSH connection OK." echo # ---- 2. Upload scripts ---- local self_dir; self_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" info "Uploading scripts to $target:~/scripts/ ..." ssh "$target" "mkdir -p ~/scripts" scp -q \ "$self_dir/dotfiles_manager.sh" \ "$self_dir/setup_enterprise_ai_bash.sh" \ "$target:~/scripts/" ssh "$target" "chmod +x ~/scripts/dotfiles_manager.sh ~/scripts/setup_enterprise_ai_bash.sh" success "Scripts uploaded." echo # ---- 3. Run setup interactively over SSH (-t allocates a tty) ---- info "Launching setup on $target (profile=$profile)..." info "You will be prompted interactively on the remote machine." echo ssh -t "$target" \ "MACHINE_PROFILE=${profile} DOTFILES_REMOTE=${DOTFILES_REMOTE} bash ~/scripts/setup_enterprise_ai_bash.sh" echo success "Remote bootstrap of $target complete." info "Log in and verify: ssh $target 'dotfiles status'" } # ----------------------------------------------------------------------- # COMMAND: deploy-to (push dotfiles to a server that can't reach Gitea) # ----------------------------------------------------------------------- cmd_deploy_to() { [ $# -ge 1 ] || die "Usage: deploy-to [--scripts-only] [--include-ssh] [--no-backup] [--dry-run]" local target="$1"; shift local scripts_only=false local skip_ssh=true # default: skip .ssh/ — avoid pushing keys/config to servers local no_backup=false local dry_run=false while [[ $# -gt 0 ]]; do case "$1" in --scripts-only) scripts_only=true; shift ;; --include-ssh) skip_ssh=false; shift ;; --no-backup) no_backup=true; shift ;; --dry-run) dry_run=true; shift ;; *) die "Unknown option: $1" ;; esac done bold "=== Deploy dotfiles → $target ===" echo info "Strategy: SCP files directly to their HOME paths (no git required on remote)" $dry_run && warn "DRY RUN — no files will be transferred." echo # ---- 1. Check SSH ---- info "Testing SSH connectivity..." if ! ssh -o ConnectTimeout=10 -o BatchMode=yes -o StrictHostKeyChecking=accept-new \ "$target" "exit 0" 2>/dev/null; then die "Cannot reach $target. Ensure SSH key access is set up:\n ssh-copy-id $target" fi success "SSH connection OK." echo local self_dir; self_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" local deployed=0 skipped=0 # ---- 2. Back up remote files locally before overwriting ---- # Saved to: ~/.dotfiles_backup/remote--/ if ! $no_backup && ! $dry_run; then local remote_host; remote_host="${target##*@}" # strip user@ prefix for dir name local backup_base="$HOME/.dotfiles_backup/remote-${remote_host}-$(date +%Y%m%d_%H%M%S)" info "Backing up existing remote files → $backup_base" # Build the list of remote paths to fetch: # - tracked dotfiles from manifest (applying same filters as deploy) # - remote ~/scripts/ if it exists local remote_paths=() if [ -f "$MANIFEST" ] && ! $scripts_only; then while IFS= read -r rel || [ -n "$rel" ]; do [ -z "$rel" ] && continue $skip_ssh && [[ "$rel" == .ssh/* ]] && continue [[ "$rel" == scripts/* ]] && continue remote_paths+=("$rel") done < "$MANIFEST" fi # Always attempt to back up remote scripts/ remote_paths+=("scripts/dotfiles_manager.sh" "scripts/bootstrap.sh" "scripts/setup_enterprise_ai_bash.sh") local backed_up=0 for rel in "${remote_paths[@]}"; do # Check if the file actually exists on the remote before fetching if ssh "$target" "[ -f ~/$rel ]" 2>/dev/null; then local local_dest="$backup_base/$rel" mkdir -p "$(dirname "$local_dest")" if scp -q "$target:~/$rel" "$local_dest" 2>/dev/null; then (( backed_up++ )) || true fi fi done if [[ "$backed_up" -gt 0 ]]; then success "Backed up $backed_up remote file(s) → $backup_base" else info "No existing remote files found to back up." rmdir "$backup_base" 2>/dev/null || true fi echo fi $no_backup && ! $dry_run && warn "Skipping remote backup (--no-backup set)." # ---- 3. Deploy scripts ---- info "Deploying scripts → $target:~/scripts/ ..." local script_files=() for f in \ "$self_dir/dotfiles_manager.sh" \ "$self_dir/setup_enterprise_ai_bash.sh" \ "$self_dir/bootstrap.sh"; do [ -f "$f" ] && script_files+=("$f") done if [[ ${#script_files[@]} -gt 0 ]]; then if $dry_run; then for f in "${script_files[@]}"; do echo " [dry-run] scp $(basename "$f") → $target:~/scripts/$(basename "$f")" done else ssh "$target" "mkdir -p ~/scripts" scp -q "${script_files[@]}" "$target:~/scripts/" ssh "$target" "chmod +x ~/scripts/dotfiles_manager.sh ~/scripts/setup_enterprise_ai_bash.sh ~/scripts/bootstrap.sh 2>/dev/null; true" (( deployed += ${#script_files[@]} )) || true success "Scripts deployed (${#script_files[@]} files)." fi fi $scripts_only && { echo; success "Done (scripts only)."; return; } # ---- 4. Deploy tracked dotfiles from manifest ---- [ -f "$MANIFEST" ] || { warn "No manifest found — only scripts were deployed."; return; } echo info "Deploying tracked dotfiles → $target home directory..." echo while IFS= read -r rel || [ -n "$rel" ]; do [ -z "$rel" ] && continue # Skip .ssh/ by default — avoids pushing your private keys/known_hosts to servers if $skip_ssh && [[ "$rel" == .ssh/* ]]; then info "Skipping: ~/$rel (use --include-ssh to override)" (( skipped++ )) || true continue fi # Scripts are handled above [[ "$rel" == scripts/* ]] && continue local src="$DOTFILES_DIR/$rel" if [ ! -e "$src" ]; then warn "Missing in dotfiles, skipping: $rel" (( skipped++ )) || true continue fi if $dry_run; then echo " [dry-run] ~/$rel" else # Ensure parent directory exists on remote local parent; parent="$(dirname "$rel")" [[ "$parent" != "." ]] && ssh "$target" "mkdir -p ~/$parent" scp -q "$src" "$target:~/$rel" success "Deployed: ~/$rel" (( deployed++ )) || true fi done < "$MANIFEST" # ---- 5. Deploy hosts/.bashrc.local → ~/.bashrc.local ---- # Strip user@ prefix, then strip domain suffix to get short hostname local remote_short; remote_short="${target##*@}" remote_short="${remote_short%%.*}" local host_local="$DOTFILES_DIR/hosts/${remote_short}.bashrc.local" if [ -f "$host_local" ]; then echo info "Found host-specific config: hosts/${remote_short}.bashrc.local" if $dry_run; then echo " [dry-run] hosts/${remote_short}.bashrc.local → ~/.bashrc.local" else # Back up existing remote .bashrc.local if not already captured above if $no_backup; then : # skip elif ssh "$target" '[ -f ~/.bashrc.local ]' 2>/dev/null; then local bl_backup="$HOME/.dotfiles_backup/remote-${remote_short}-$(date +%Y%m%d_%H%M%S)" mkdir -p "$bl_backup" scp -q "$target:~/.bashrc.local" "$bl_backup/.bashrc.local" 2>/dev/null || true info "Backed up remote ~/.bashrc.local → $bl_backup/.bashrc.local" fi scp -q "$host_local" "$target:~/.bashrc.local" success "Deployed: hosts/${remote_short}.bashrc.local → ~/.bashrc.local" (( deployed++ )) || true fi else info "No host-specific config found at hosts/${remote_short}.bashrc.local — skipping." info "Create one to manage ~/.bashrc.local centrally: dotfiles/hosts/${remote_short}.bashrc.local" fi echo if $dry_run; then info "Dry run complete. Re-run without --dry-run to transfer files." else bold "Deploy complete: $deployed file(s) deployed, $skipped skipped." info "Files were copied directly (no symlinks). Re-run deploy-to to push updates." $skip_ssh && info "~/.ssh/ was skipped. Use --include-ssh to also deploy ~/.ssh/config." fi } # ----------------------------------------------------------------------- # COMMAND: help # ----------------------------------------------------------------------- cmd_help() { cat < [args] ${BOLD}COMMANDS — Core${RESET} init Clone from remote or init locally with remote set add [...] Track file(s): move to ~/dotfiles, symlink back to HOME remove Untrack: restore file to HOME install Reapply all symlinks (use on a new machine after cloning) sync Pull latest from remote, reapply symlinks push [message] Commit all changes and push to $DOTFILES_REMOTE status / list Show tracked files and symlink health auth Save Gitea token so push never prompts for credentials ${BOLD}COMMANDS — SSH & Keys${RESET} ssh-setup Guided SSH config + key migration ssh-export GPG-encrypt private keys → dotfiles/.ssh/keys/*.gpg ssh-import Decrypt GPG-encrypted keys from dotfiles to ~/.ssh/ ${BOLD}COMMANDS — Multi-machine${RESET} remote-bootstrap [--profile work|personal] Upload scripts and run full setup on a remote machine deploy-to [--scripts-only] [--include-ssh] [--no-backup] [--dry-run] SCP tracked dotfiles + scripts directly to a server. Use when the server can't reach the Gitea repo. Backs up existing remote files locally before overwriting. --scripts-only Only push ~/scripts/, skip dotfiles --include-ssh Also deploy ~/.ssh/config (skipped by default) --no-backup Skip the pre-deploy remote backup --dry-run Preview what would be transferred ${BOLD}QUICK START — this machine (work)${RESET} ./dotfiles_manager.sh init ./dotfiles_manager.sh add ~/.bashrc ~/.bash_profile ~/.gitconfig ./dotfiles_manager.sh ssh-setup ./dotfiles_manager.sh ssh-export # GPG-encrypt private keys ./dotfiles_manager.sh push "initial migration" ${BOLD}QUICK START — personal machine restore${RESET} # Option A: remote bootstrap from work machine ./dotfiles_manager.sh remote-bootstrap user@personal-mac --profile personal # Option B: manual steps on the personal machine git clone $DOTFILES_REMOTE ~/dotfiles ~/dotfiles/install.sh # applies symlinks, prompts for key decrypt ${BOLD}.bashrc STRATEGY${RESET} ~/.bashrc → tracked in dotfiles, shared across all machines ~/.bashrc.local → NOT tracked, written by setup_enterprise_ai_bash.sh contains profile-specific vars (cloud paths, MACHINE_PROFILE) Work: OneDrive paths, CLOUD_ROOT=~/OneDrive Personal: ProtonDrive + Google Drive, CLOUD_ROOT=~/Cloud ${BOLD}SSH KEY SHARING STRATEGY${RESET} ~/.ssh/config → tracked in dotfiles (shared) ~/.ssh/*.pub → copied to dotfiles/.ssh/keys/ (shared) ~/.ssh/id_* → NOT committed plain-text ~/.ssh/id_*.gpg → GPG-encrypted backups committed to dotfiles, decrypted on new machines with 'ssh-import' ${BOLD}ENVIRONMENT OVERRIDES${RESET} DOTFILES_DIR=$DOTFILES_DIR DOTFILES_REMOTE=$DOTFILES_REMOTE HELP } # ----------------------------------------------------------------------- # GENERATE PORTABLE install.sh (regenerated on each push) # ----------------------------------------------------------------------- generate_install_sh() { cat > "$DOTFILES_DIR/install.sh" </dev/null || true ln -sfn "\$src" "\$dest" echo " Linked: ~/\$rel" done < "\$MANIFEST" echo echo "✓ Dotfiles symlinks applied." # ---- Machine-local config ---- # Write a minimal .bashrc.local if one does not exist (user edits profile) if [ ! -f "\$HOME/.bashrc.local" ]; then echo echo "Select profile for this machine:" echo " [1] work — OneDrive" echo " [2] personal — ProtonDrive + GoogleDrive" read -r -p "Profile (1/2) [2]: " _choice _profile="personal" [[ "\${_choice:-2}" == "1" ]] && _profile="work" # setup script lives inside dotfiles now: dotfiles/scripts/setup_enterprise_ai_bash.sh if [ -f "\$DOTFILES_DIR/scripts/setup_enterprise_ai_bash.sh" ]; then MACHINE_PROFILE="\$_profile" bash "\$DOTFILES_DIR/scripts/setup_enterprise_ai_bash.sh" else echo " ⚠ Could not find setup_enterprise_ai_bash.sh — create ~/.bashrc.local manually." fi fi # ---- SSH key decrypt ---- gpg_count=\$(find "\$DOTFILES_DIR/.ssh/keys" -maxdepth 1 -name '*.gpg' 2>/dev/null | wc -l | tr -d ' ') if [[ "\$gpg_count" -gt 0 ]]; then echo echo "Found \$gpg_count GPG-encrypted SSH key(s) in dotfiles." read -r -p "Decrypt SSH private keys now? (y/n): " _dec if [[ "\$_dec" == [yY] ]]; then # dotfiles_manager.sh lives inside dotfiles now: dotfiles/scripts/dotfiles_manager.sh bash "\$DOTFILES_DIR/scripts/dotfiles_manager.sh" ssh-import fi fi echo echo "✓ Restore complete. Run: source ~/.bash_profile" INSTALL chmod +x "$DOTFILES_DIR/install.sh" } # ----------------------------------------------------------------------- # ENTRYPOINT # ----------------------------------------------------------------------- main() { local cmd="${1:-help}" shift || true # Auto-generate install.sh when pushing case "$cmd" in init) cmd_init "$@" ;; add) cmd_add "$@" ;; remove) cmd_remove "$@" ;; install) cmd_install "$@" ;; sync) cmd_sync "$@" ;; push) generate_install_sh cmd_push "$@" ;; status|list) cmd_status "$@" ;; ssh-setup) cmd_ssh_setup "$@" ;; ssh-export) cmd_ssh_export "$@" ;; ssh-import) cmd_ssh_import "$@" ;; auth) cmd_auth "$@" ;; remote-bootstrap) cmd_remote_bootstrap "$@" ;; deploy-to) cmd_deploy_to "$@" ;; help|--help|-h) cmd_help ;; *) error "Unknown command: $cmd" cmd_help exit 1 ;; esac } main "$@"