- hosts/etqc-kenjim-11.bashrc.local: per-host local config for work server, managed from kenjim-mbp and deployed via 'dotfiles deploy-to'. Credentials replaced with CHANGEME placeholders — set real values on server after first deploy, never commit actual secrets. - dotfiles_manager.sh: deploy-to step 5 auto-detects hosts/<hostname>.bashrc.local and SCPs it to ~/.bashrc.local on the remote (with backup of existing file) - .gitignore: clarify that hosts/*.bashrc.local is intentionally tracked (existing .bashrc.local rule only matches the exact filename) - README.md: document hosts/ layout, workflow, and credential placeholder strategy
1255 lines
45 KiB
Bash
Executable File
1255 lines
45 KiB
Bash
Executable File
#!/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 <command> [args]
|
|
#
|
|
# Commands:
|
|
# init Clone remote repo (or init + set remote if new machine)
|
|
# add <file> Track a file: move it into ~/dotfiles, create symlink back
|
|
# remove <file> 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
|
|
# dotfiles
|
|
|
|
Centralized configuration management for $(whoami)@$(hostname -s).
|
|
|
|
## Remote
|
|
\`$DOTFILES_REMOTE\`
|
|
|
|
## Structure
|
|
\`\`\`
|
|
dotfiles/
|
|
├── .bashrc → ~/.bashrc
|
|
├── .bash_profile → ~/.bash_profile
|
|
├── .bash_aliases → ~/.bash_aliases
|
|
├── .gitconfig → ~/.gitconfig
|
|
├── .ssh/
|
|
│ ├── config → ~/.ssh/config
|
|
│ └── keys/ (public keys + GPG-encrypted private keys)
|
|
├── scripts/
|
|
│ ├── dotfiles_manager.sh → ~/scripts/dotfiles_manager.sh
|
|
│ ├── setup_enterprise_ai_bash.sh → ~/scripts/setup_enterprise_ai_bash.sh
|
|
│ └── bootstrap.sh → ~/scripts/bootstrap.sh
|
|
├── .vimrc → ~/.vimrc
|
|
├── .dotfiles_manifest (list of managed HOME paths)
|
|
├── install.sh (new-machine restore script)
|
|
└── README.md
|
|
\`\`\`
|
|
|
|
## Quick start — restore on a new machine
|
|
\`\`\`bash
|
|
git clone $DOTFILES_REMOTE ~/dotfiles
|
|
bash ~/dotfiles/install.sh
|
|
\`\`\`
|
|
|
|
## Managed files
|
|
<!-- auto-updated by dotfiles_manager.sh status -->
|
|
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 <file-or-dir> [<file-or-dir> ...]"
|
|
|
|
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 <file>"
|
|
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 <user@host> [--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 <user@host> [--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-<host>-<timestamp>/
|
|
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/<hostname>.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 <<HELP
|
|
${BOLD}dotfiles_manager.sh${RESET} — Centralized dotfiles management
|
|
|
|
${BOLD}USAGE${RESET}
|
|
./dotfiles_manager.sh <command> [args]
|
|
|
|
${BOLD}COMMANDS — Core${RESET}
|
|
init Clone from remote or init locally with remote set
|
|
add <file> [...] Track file(s): move to ~/dotfiles, symlink back to HOME
|
|
remove <file> 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 <user@host> [--profile work|personal]
|
|
Upload scripts and run full setup on a remote machine
|
|
deploy-to <user@host> [--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" <<INSTALL
|
|
#!/usr/bin/env bash
|
|
# Portable dotfiles restore script — generated by dotfiles_manager.sh
|
|
# Run on a new machine after: git clone $DOTFILES_REMOTE ~/dotfiles
|
|
set -euo pipefail
|
|
|
|
DOTFILES_DIR="\$(cd "\$(dirname "\${BASH_SOURCE[0]}")" && pwd)"
|
|
MANIFEST="\$DOTFILES_DIR/.dotfiles_manifest"
|
|
BACKUP_DIR="\$HOME/.dotfiles_backup/\$(date +%Y%m%d_%H%M%S)"
|
|
|
|
echo "=== Restoring dotfiles from \$DOTFILES_DIR ==="
|
|
|
|
[ -f "\$MANIFEST" ] || { echo "No manifest found."; exit 1; }
|
|
|
|
while IFS= read -r rel || [ -n "\$rel" ]; do
|
|
[ -z "\$rel" ] && continue
|
|
src="\$DOTFILES_DIR/\$rel"
|
|
dest="\$HOME/\$rel"
|
|
[ -e "\$src" ] || { echo " MISSING: \$src"; continue; }
|
|
mkdir -p "\$(dirname "\$dest")"
|
|
if [ -e "\$dest" ] && [ ! -L "\$dest" ]; then
|
|
mkdir -p "\$BACKUP_DIR/\$(dirname "\$rel")"
|
|
cp -a "\$dest" "\$BACKUP_DIR/\$rel"
|
|
echo " Backed up: \$dest"
|
|
if [ -d "\$dest" ]; then rm -rf "\$dest"; else rm -f "\$dest"; fi
|
|
elif [ -L "\$dest" ]; then
|
|
rm "\$dest"
|
|
fi
|
|
[[ "\$rel" == .ssh/* ]] && chmod 600 "\$src" 2>/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 "$@"
|