From c3a92e8ca87eb4febce5298dff58db92854167f0 Mon Sep 17 00:00:00 2001 From: Kenji Morishige Date: Mon, 23 Feb 2026 16:52:56 -0600 Subject: [PATCH] feat: add deploy-to command and reorganize ssh config + docs - dotfiles_manager.sh: add 'deploy-to' command to SCP tracked dotfiles and scripts directly to servers with no Gitea access - backs up existing remote files to ~/.dotfiles_backup/remote--/ before overwriting anything - flags: --scripts-only, --include-ssh, --no-backup, --dry-run - ssh/config: reorganize into labeled sections, move work hosts to top, fix global defaults (proper Host * block, remove deprecated Protocol/KeepAlive), add inline comments on all port forwards - README.md: full rewrite with directory layout, profiles, shell layering, env vars, aliases, bootstrap flow, symlink mechanics, SSH key strategy, two-machine sync workflow, and deploy-to docs --- README.md | 46 ++++++++-- scripts/dotfiles_manager.sh | 170 ++++++++++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 9b761aa..655fab0 100644 --- a/README.md +++ b/README.md @@ -195,15 +195,20 @@ source ~/.bash_profile ### Day-to-day sync +After editing on one machine, push and sync on the other: + ```bash -# Pull latest dotfiles from Gitea and reapply any new symlinks: -dotfiles sync # or: bash ~/scripts/bootstrap.sh +# On the machine where you made changes: +dotfiles push "describe what changed" -# Push local changes: -dotfiles push "my change description" +# On the other machine to pick up the changes: +dotfiles sync +``` -# Check symlink health: -dotfiles status +```bash +# Other useful commands: +dotfiles status # check symlink health +dotfiles sync # pull + rebase + reapply symlinks (also callable as: bash ~/scripts/bootstrap.sh) ``` ### Remote machine bootstrap (from this machine) @@ -215,6 +220,35 @@ dotfiles remote-bootstrap user@hostname --profile work This uploads the scripts, then runs the full setup interactively over SSH. +### Deploy to a server with no Gitea access + +Linux servers at work can't clone the private Gitea repo. Use `deploy-to` to +SCP tracked dotfiles and scripts directly to their HOME paths — no git required +on the remote. + +```bash +# Push everything (dotfiles + scripts): +dotfiles deploy-to user@server + +# Scripts only (dotfiles_manager.sh, bootstrap.sh, setup script): +dotfiles deploy-to user@server --scripts-only + +# Preview what would be transferred without doing it: +dotfiles deploy-to user@server --dry-run + +# Also deploy ~/.ssh/config (skipped by default for security): +dotfiles deploy-to user@server --include-ssh + +# Skip the pre-deploy backup (faster, but no safety net): +dotfiles deploy-to user@server --no-backup +``` + +Before overwriting any file, `deploy-to` SCPs the server's existing versions +to `~/.dotfiles_backup/remote--/` on your local machine. +Files are copied directly (not symlinked). Re-run `deploy-to` any time you +want to push updates. `~/.ssh/` is skipped by default to avoid accidentally +pushing private keys or your personal known_hosts to a shared server. + --- ## Dotfiles Management — How Symlinks Work diff --git a/scripts/dotfiles_manager.sh b/scripts/dotfiles_manager.sh index 7b278f6..9041ff6 100755 --- a/scripts/dotfiles_manager.sh +++ b/scripts/dotfiles_manager.sh @@ -24,6 +24,7 @@ # 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/: @@ -878,6 +879,166 @@ cmd_remote_bootstrap() { 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" + + 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 # ----------------------------------------------------------------------- @@ -906,6 +1067,14 @@ ${BOLD}COMMANDS — SSH & Keys${RESET} ${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 @@ -1042,6 +1211,7 @@ main() { 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"