From dd75e26d6dac72ce6af292e106027feba0e97c39 Mon Sep 17 00:00:00 2001 From: Kenji M Date: Mon, 30 Mar 2026 13:35:58 +0000 Subject: [PATCH] initial dotfiles migration from zet-personal --- .bash_profile | 5 + .bashrc | 159 ++++ .dotfiles_manifest | 6 + .gitconfig | 3 + install.sh | 67 ++ scripts/bootstrap.sh | 26 + scripts/dotfiles_manager.sh | 1328 +++++++++++++++++++++++++++ scripts/setup_enterprise_ai_bash.sh | 473 ++++++++++ 8 files changed, 2067 insertions(+) create mode 100644 .bash_profile create mode 100644 .bashrc create mode 100644 .gitconfig create mode 100755 install.sh create mode 100755 scripts/bootstrap.sh create mode 100755 scripts/dotfiles_manager.sh create mode 100755 scripts/setup_enterprise_ai_bash.sh diff --git a/.bash_profile b/.bash_profile new file mode 100644 index 0000000..4bed812 --- /dev/null +++ b/.bash_profile @@ -0,0 +1,5 @@ + +# Load .bashrc if it exists +if [ -f ~/.bashrc ]; then + source ~/.bashrc +fi diff --git a/.bashrc b/.bashrc new file mode 100644 index 0000000..126656a --- /dev/null +++ b/.bashrc @@ -0,0 +1,159 @@ +# ~/.bashrc: executed by bash(1) for non-login shells. +# see /usr/share/doc/bash/examples/startup-files (in the package bash-doc) +# for examples + +# If not running interactively, don't do anything +case $- in + *i*) ;; + *) return;; +esac + +# don't put duplicate lines or lines starting with space in the history. +# See bash(1) for more options +HISTCONTROL=ignoreboth + +# append to the history file, don't overwrite it +shopt -s histappend + +# for setting history length see HISTSIZE and HISTFILESIZE in bash(1) +HISTSIZE=1000 +HISTFILESIZE=2000 + +# check the window size after each command and, if necessary, +# update the values of LINES and COLUMNS. +shopt -s checkwinsize + +# If set, the pattern "**" used in a pathname expansion context will +# match all files and zero or more directories and subdirectories. +#shopt -s globstar + +# make less more friendly for non-text input files, see lesspipe(1) +[ -x /usr/bin/lesspipe ] && eval "$(SHELL=/bin/sh lesspipe)" + +# set variable identifying the chroot you work in (used in the prompt below) +if [ -z "${debian_chroot:-}" ] && [ -r /etc/debian_chroot ]; then + debian_chroot=$(cat /etc/debian_chroot) +fi + +# set a fancy prompt (non-color, unless we know we "want" color) +case "$TERM" in + xterm-color|*-256color) color_prompt=yes;; +esac + +# uncomment for a colored prompt, if the terminal has the capability; turned +# off by default to not distract the user: the focus in a terminal window +# should be on the output of commands, not on the prompt +#force_color_prompt=yes + +if [ -n "$force_color_prompt" ]; then + if [ -x /usr/bin/tput ] && tput setaf 1 >&/dev/null; then + # We have color support; assume it's compliant with Ecma-48 + # (ISO/IEC-6429). (Lack of such support is extremely rare, and such + # a case would tend to support setf rather than setaf.) + color_prompt=yes + else + color_prompt= + fi +fi + +if [ "$color_prompt" = yes ]; then + PS1='${debian_chroot:+($debian_chroot)}\[\033[01;32m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ ' +else + PS1='${debian_chroot:+($debian_chroot)}\u@\h:\w\$ ' +fi +unset color_prompt force_color_prompt + +# If this is an xterm set the title to user@host:dir +case "$TERM" in +xterm*|rxvt*) + PS1="\[\e]0;${debian_chroot:+($debian_chroot)}\u@\h: \w\a\]$PS1" + ;; +*) + ;; +esac + +# enable color support of ls and also add handy aliases +if [ -x /usr/bin/dircolors ]; then + test -r ~/.dircolors && eval "$(dircolors -b ~/.dircolors)" || eval "$(dircolors -b)" + alias ls='ls --color=auto' + #alias dir='dir --color=auto' + #alias vdir='vdir --color=auto' + + alias grep='grep --color=auto' + alias fgrep='fgrep --color=auto' + alias egrep='egrep --color=auto' +fi + +# colored GCC warnings and errors +#export GCC_COLORS='error=01;31:warning=01;35:note=01;36:caret=01;32:locus=01:quote=01' + +# some more ls aliases +alias ll='ls -alF' +alias la='ls -A' +alias l='ls -CF' + +# Add an "alert" alias for long running commands. Use like so: +# sleep 10; alert +alias alert='notify-send --urgency=low -i "$([ $? = 0 ] && echo terminal || echo error)" "$(history|tail -n1|sed -e '\''s/^\s*[0-9]\+\s*//;s/[;&|]\s*alert$//'\'')"' + +# Alias definitions. +# You may want to put all your additions into a separate file like +# ~/.bash_aliases, instead of adding them here directly. +# See /usr/share/doc/bash-doc/examples in the bash-doc package. + +if [ -f ~/.bash_aliases ]; then + . ~/.bash_aliases +fi + +# enable programmable completion features (you don't need to enable +# this, if it's already enabled in /etc/bash.bashrc and /etc/profile +# sources /etc/bash.bashrc). +if ! shopt -oq posix; then + if [ -f /usr/share/bash-completion/bash_completion ]; then + . /usr/share/bash-completion/bash_completion + elif [ -f /etc/bash_completion ]; then + . /etc/bash_completion + fi +fi + +function dbash(){ + docker exec -it $1 bash +} + +# Created by `pipx` on 2025-09-18 03:06:34 +export PATH="$PATH:/home/kenjim/.local/bin" + +### ENTERPRISE_AI_ENV ### +export WORKSPACE="$HOME/workspace" +export DATA_ROOT="$HOME/data" +export MODEL_ROOT="$HOME/models" + +# HuggingFace cache location +export HF_HOME="$MODEL_ROOT/huggingface" + +# Ollama model location +export OLLAMA_MODELS="$MODEL_ROOT/ollama" + +# Convenience aliases +alias ws='cd $WORKSPACE' +alias src='cd $WORKSPACE/src' +alias data='cd $DATA_ROOT' +alias models='cd $MODEL_ROOT' + +# Machine-local overrides (cloud paths, work vs personal — not synced via dotfiles) +[ -f ~/.bashrc.local ] && source ~/.bashrc.local + + +### DOTFILES_ALIASES ### +export DOTFILES_DIR="$HOME/dotfiles" +export DOTFILES_REMOTE="http://172.27.0.35:3000/kenjim/dotfiles" + +# Dotfiles manager shortcut +alias dotfiles='bash $HOME/scripts/dotfiles_manager.sh' +alias dot='bash $HOME/scripts/dotfiles_manager.sh' + +# Quick sync alias +alias dots-sync='bash $HOME/scripts/dotfiles_manager.sh sync' +alias dots-push='bash $HOME/scripts/dotfiles_manager.sh push' +alias dots-status='bash $HOME/scripts/dotfiles_manager.sh status' + diff --git a/.dotfiles_manifest b/.dotfiles_manifest index e69de29..32d60c3 100644 --- a/.dotfiles_manifest +++ b/.dotfiles_manifest @@ -0,0 +1,6 @@ +.bash_profile +.bashrc +.gitconfig +scripts/bootstrap.sh +scripts/dotfiles_manager.sh +scripts/setup_enterprise_ai_bash.sh diff --git a/.gitconfig b/.gitconfig new file mode 100644 index 0000000..1b6300d --- /dev/null +++ b/.gitconfig @@ -0,0 +1,3 @@ +[user] + name = Kenji M + email = kenji@kenjim.com diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..c2de390 --- /dev/null +++ b/install.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Portable dotfiles restore script — generated by dotfiles_manager.sh +# Run on a new machine after: git clone http://172.27.0.35:3000/kenjim/dotfiles ~/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" diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh new file mode 100755 index 0000000..51fa23f --- /dev/null +++ b/scripts/bootstrap.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# bootstrap.sh — Restore Enterprise AI Environment on this machine +set -euo pipefail + +echo "🔄 Restoring Enterprise AI Environment..." + +# Reload shell config +# shellcheck disable=SC1091 +[ -f ~/.bashrc ] && source ~/.bashrc +[ -f ~/.bash_profile ] && source ~/.bash_profile + +echo " WORKSPACE : ${WORKSPACE:-not set}" +echo " DATA_ROOT : ${DATA_ROOT:-not set}" +echo " MODEL_ROOT : ${MODEL_ROOT:-not set}" +echo + +# Sync latest dotfiles from git server +if [ -f "$HOME/scripts/dotfiles_manager.sh" ]; then + echo "📦 Syncing dotfiles..." + bash "$HOME/scripts/dotfiles_manager.sh" sync +else + echo "⚠️ dotfiles_manager.sh not found — run setup_enterprise_ai_bash.sh first." +fi + +echo +echo "✅ Bootstrap complete." diff --git a/scripts/dotfiles_manager.sh b/scripts/dotfiles_manager.sh new file mode 100755 index 0000000..5c714ee --- /dev/null +++ b/scripts/dotfiles_manager.sh @@ -0,0 +1,1328 @@ +#!/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] [--run-setup] [--profile work|personal] [--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 run_setup=false + local setup_profile="work" # default profile for servers + 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 ;; + --run-setup) run_setup=true; shift ;; + --profile) setup_profile="${2:-work}"; shift 2 ;; + --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/dir actually exists on the remote before fetching + if ssh "$target" "[ -e ~/$rel ]" 2>/dev/null; then + local local_dest="$backup_base/$rel" + mkdir -p "$(dirname "$local_dest")" + # Use -r for directories, plain scp for files + if ssh "$target" "[ -d ~/$rel ]" 2>/dev/null; then + mkdir -p "$local_dest" + if scp -rq "$target:~/$rel/." "$local_dest/" 2>/dev/null; then + (( backed_up++ )) || true + fi + elif 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$([ -d "$src" ] && echo '/')" + else + local parent; parent="$(dirname "$rel")" + if [ -d "$src" ]; then + # For directories: remove stale dest first (avoids double-nesting on re-run) + # then scp -r into the parent so the dir name is preserved correctly. + ssh "$target" "rm -rf ~/$rel" + if [[ "$parent" == "." ]]; then + scp -rq "$src" "$target:~/" + else + ssh "$target" "mkdir -p ~/$parent" + scp -rq "$src" "$target:~/$parent/" + fi + else + [[ "$parent" != "." ]] && ssh "$target" "mkdir -p ~/$parent" + scp -q "$src" "$target:~/$rel" + fi + 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" '[ -L ~/.bashrc.local ]' 2>/dev/null; then + # It's a symlink (NFS setup) — back up the target file + local real_path + real_path=$(ssh "$target" 'readlink -f ~/.bashrc.local' 2>/dev/null || true) + if [[ -n "$real_path" ]]; then + local bl_backup="$HOME/.dotfiles_backup/remote-${remote_short}-$(date +%Y%m%d_%H%M%S)" + mkdir -p "$bl_backup" + scp -q "$target:$real_path" "$bl_backup/.bashrc.local" 2>/dev/null || true + info "Backed up remote $real_path → $bl_backup/.bashrc.local" + fi + 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 + + # Detect NFS home on the remote and deploy to /opt/kenjim/ if needed + local _remote_nfs=false + if ssh "$target" 'df -P "$HOME" 2>/dev/null | tail -1 | grep -qE ":|nfs"' 2>/dev/null; then + _remote_nfs=true + fi + + if $_remote_nfs; then + info "NFS home detected on $target — deploying to /opt/kenjim/.bashrc.local" + # Ensure /opt/kenjim exists — skip sudo if it already does + if ! ssh "$target" '[ -d /opt/kenjim ]' 2>/dev/null; then + info "Creating /opt/kenjim on $target (requires sudo)..." + ssh -t "$target" 'sudo mkdir -p /opt/kenjim && sudo chown $(id -u):$(id -g) /opt/kenjim && chmod 700 /opt/kenjim' + fi + scp -q "$host_local" "$target:/opt/kenjim/.bashrc.local" + ssh "$target" 'chmod 600 /opt/kenjim/.bashrc.local && ln -sfn /opt/kenjim/.bashrc.local ~/.bashrc.local' + success "Deployed: hosts/${remote_short}.bashrc.local → /opt/kenjim/.bashrc.local (symlinked from ~)" + else + scp -q "$host_local" "$target:~/.bashrc.local" + success "Deployed: hosts/${remote_short}.bashrc.local → ~/.bashrc.local" + fi + (( 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 + + # ---- 6. Optionally run setup on the remote ---- + if ! $dry_run; then + local do_setup=$run_setup + if ! $run_setup; then + echo + read -r -p "Run setup_enterprise_ai_bash.sh on $target now? Creates dirs, writes shell config. (y/n): " _ans + [[ "$_ans" == [yY] ]] && do_setup=true + fi + + if $do_setup; then + echo + info "Running setup on $target (profile=$setup_profile)..." + info "This will create the directory structure and write shell config." + echo + ssh -t "$target" \ + "MACHINE_PROFILE=${setup_profile} DOTFILES_REMOTE=${DOTFILES_REMOTE} bash ~/scripts/setup_enterprise_ai_bash.sh" + success "Setup complete on $target." + fi + 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." + ! $run_setup && info "Tip: add --run-setup to also create directories and shell config on the remote." + 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] [--run-setup] [--profile work|personal] [--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 + --run-setup Run setup_enterprise_ai_bash.sh on the remote + after deploy (creates dirs, shell config) + --profile work|personal Profile to pass to setup (default: work) + --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 "$@" diff --git a/scripts/setup_enterprise_ai_bash.sh b/scripts/setup_enterprise_ai_bash.sh new file mode 100755 index 0000000..43a3451 --- /dev/null +++ b/scripts/setup_enterprise_ai_bash.sh @@ -0,0 +1,473 @@ +#!/usr/bin/env bash + +set -euo pipefail + +echo "🚀 Setting up Enterprise + AI Development Environment (Bash Edition)" +echo + +HOME_DIR="$HOME" +BASHRC="$HOME_DIR/.bashrc" +BASH_PROFILE="$HOME_DIR/.bash_profile" + +# Dotfiles remote — your local Gitea server +DOTFILES_REMOTE="http://172.27.0.35:3000/kenjim/dotfiles" +DOTFILES_DIR="$HOME_DIR/dotfiles" + +# ------------------------------------------------------ +# 0️⃣ MACHINE PROFILE SELECTION +# ------------------------------------------------------ +# Override: MACHINE_PROFILE=work ./setup_enterprise_ai_bash.sh +# MACHINE_PROFILE=personal ./setup_enterprise_ai_bash.sh + +MACHINE_PROFILE="${MACHINE_PROFILE:-}" + +if [[ -z "$MACHINE_PROFILE" ]]; then + echo "Select machine profile:" + echo " [1] work — OneDrive cloud, work workspace" + echo " [2] personal — ProtonDrive + GoogleDrive, personal workspace" + echo + read -r -p "Profile (1=work / 2=personal) [2]: " _choice + case "${_choice:-2}" in + 1|work) MACHINE_PROFILE="work" ;; + 2|personal) MACHINE_PROFILE="personal" ;; + *) MACHINE_PROFILE="personal" ;; + esac +fi + +export MACHINE_PROFILE +echo " ▶ Profile: $MACHINE_PROFILE" +echo + +# Derive hostname tag used to tag commits +MACHINE_TAG="$(hostname -s)-${MACHINE_PROFILE}" + +# ------------------------------------------------------ +# 1️⃣ CREATE DIRECTORY STRUCTURE +# ------------------------------------------------------ + +echo "📁 Creating directory structure..." + +mkdir -p "$HOME_DIR/workspace/src/"{personal,work,research} +mkdir -p "$HOME_DIR/workspace/"{experiments,notebooks,sandboxes,archive} + +mkdir -p "$HOME_DIR/data/"{raw,processed,embeddings,synthetic} +mkdir -p "$HOME_DIR/models/"{huggingface,ollama,fine-tuned} + +mkdir -p "$HOME_DIR/infra/"{docker,terraform,scripts} +mkdir -p "$HOME_DIR/ops" +mkdir -p "$HOME_DIR/scripts" +mkdir -p "$HOME_DIR/vault" +mkdir -p "$HOME_DIR/dotfiles" +mkdir -p "$HOME_DIR/dotfiles/.ssh/keys" + +# Profile-specific cloud directories +if [[ "$MACHINE_PROFILE" == "work" ]]; then + # OneDrive is managed by the Microsoft OneDrive app and auto-mounts at: + # ~/Library/CloudStorage/OneDrive-/ (modern macOS) + # We create a stable symlink at ~/OneDrive for convenience. + ONEDRIVE_MOUNT=$(find "$HOME_DIR/Library/CloudStorage" -maxdepth 1 -iname 'OneDrive*' -type d 2>/dev/null | head -1 || true) + if [[ -n "$ONEDRIVE_MOUNT" ]]; then + ln -sfn "$ONEDRIVE_MOUNT" "$HOME_DIR/OneDrive" 2>/dev/null || true + echo " Linked ~/OneDrive → $ONEDRIVE_MOUNT" + else + mkdir -p "$HOME_DIR/OneDrive" + echo " Created ~/OneDrive placeholder (link manually once OneDrive app is signed in)" + fi +else + # Personal machine: ProtonDrive + Google Drive + mkdir -p "$HOME_DIR/Cloud/"{ProtonDrive,GoogleDrive} +fi + +echo "✅ Directories created." +echo + +# ------------------------------------------------------ +# 2️⃣ ENSURE .bash_profile LOADS .bashrc (macOS FIX) +# ------------------------------------------------------ + +if ! grep -q "source ~/.bashrc" "$BASH_PROFILE" 2>/dev/null; then +cat <<'EOF' >> "$BASH_PROFILE" + +# Load .bashrc if it exists +if [ -f ~/.bashrc ]; then + source ~/.bashrc +fi +EOF +echo "✅ .bash_profile updated to load .bashrc" +fi + +# ------------------------------------------------------ +# 3️⃣ ADD ENTERPRISE AI ENV VARIABLES +# ------------------------------------------------------ + +if ! grep -q "### ENTERPRISE_AI_ENV ###" "$BASHRC" 2>/dev/null; then +cat <<'EOF' >> "$BASHRC" + +### ENTERPRISE_AI_ENV ### +export WORKSPACE="$HOME/workspace" +export DATA_ROOT="$HOME/data" +export MODEL_ROOT="$HOME/models" + +# HuggingFace cache location +export HF_HOME="$MODEL_ROOT/huggingface" + +# Ollama model location +export OLLAMA_MODELS="$MODEL_ROOT/ollama" + +# Convenience aliases +alias ws='cd $WORKSPACE' +alias src='cd $WORKSPACE/src' +alias data='cd $DATA_ROOT' +alias models='cd $MODEL_ROOT' + +# Machine-local overrides (cloud paths, work vs personal — not synced via dotfiles) +[ -f ~/.bashrc.local ] && source ~/.bashrc.local + +EOF +echo "✅ Environment variables added to .bashrc" +else + # Ensure .bashrc.local sourcing is present even on existing installs + if ! grep -q ".bashrc.local" "$BASHRC" 2>/dev/null; then +cat >> "$BASHRC" <<'LOCALEOF' + +# Machine-local overrides (cloud paths, work vs personal — not synced via dotfiles) +[ -f ~/.bashrc.local ] && source ~/.bashrc.local +LOCALEOF + echo "✅ Added .bashrc.local sourcing to existing .bashrc" + fi +echo "ℹ️ Environment already configured." +fi + +# ---- Write machine-specific .bashrc.local (never committed to dotfiles) ---- +BASHRC_LOCAL="$HOME_DIR/.bashrc.local" +# Preserve any existing custom content below the managed block +LOCAL_CUSTOM="" +if [ -f "$BASHRC_LOCAL" ] && grep -q "### MACHINE_LOCAL_END ###" "$BASHRC_LOCAL" 2>/dev/null; then + LOCAL_CUSTOM=$(awk '/### MACHINE_LOCAL_END ###/{found=1; next} found{print}' "$BASHRC_LOCAL") +fi + +cat > "$BASHRC_LOCAL" <> "$BASHRC_LOCAL" <<'WORKEOF' +# --- Work / OneDrive --- +export CLOUD_ROOT="$HOME/OneDrive" +export ONEDRIVE_ROOT="$HOME/OneDrive" +alias cloud='cd $CLOUD_ROOT' +alias onedrive='cd $ONEDRIVE_ROOT' +WORKEOF +else +cat >> "$BASHRC_LOCAL" <<'PERSONALEOF' +# --- Personal / ProtonDrive + Google Drive --- +export CLOUD_ROOT="$HOME/Cloud" +export PROTON_ROOT="$HOME/Cloud/ProtonDrive" +export GDRIVE_ROOT="$HOME/Cloud/GoogleDrive" +alias cloud='cd $CLOUD_ROOT' +alias proton='cd $PROTON_ROOT' +alias gdrive='cd $GDRIVE_ROOT' +PERSONALEOF +fi + +cat >> "$BASHRC_LOCAL" <<'TAILEOF' +### MACHINE_LOCAL_END ### +TAILEOF + +# Re-append any custom content that was below the managed block +if [[ -n "$LOCAL_CUSTOM" ]]; then + echo "$LOCAL_CUSTOM" >> "$BASHRC_LOCAL" +fi + +echo "✅ .bashrc.local written for profile: $MACHINE_PROFILE" + +# ---- NFS-aware placement for work servers ---- +# Work servers with NFS-mounted home dirs share ~/.bashrc.local across hosts. +# To keep per-machine overrides, store the real file in /opt/kenjim/ and symlink. +if [[ "$MACHINE_PROFILE" == "work" ]]; then + _is_nfs_home=false + case "$(hostname -s)" in + etqc-*|etbg-*|engtech-dev-*|qnc-kenjim-toby-shell*|qtaas*|bng-kenjim-toby-shell*|btaas*|kenjim-taas*) + if df -P "$HOME" 2>/dev/null | tail -1 | grep -qE ':|nfs'; then + _is_nfs_home=true + elif mount | grep -q "on ${HOME%%/} .*type nfs"; then + _is_nfs_home=true + fi + ;; + esac + + if $_is_nfs_home; then + OPT_DIR="/opt/kenjim" + OPT_LOCAL="$OPT_DIR/.bashrc.local" + echo "🔗 NFS home detected — relocating .bashrc.local to $OPT_LOCAL" + + if [ ! -d "$OPT_DIR" ]; then + echo " Creating $OPT_DIR ..." + sudo mkdir -p "$OPT_DIR" + sudo chown "$(id -u):$(id -g)" "$OPT_DIR" + chmod 700 "$OPT_DIR" + fi + + # If ~/.bashrc.local is already a symlink (previous run), the cat above + # wrote through it into the target. Remove the symlink first so mv + # doesn't see source and destination as the same file. + if [ -L "$BASHRC_LOCAL" ]; then + rm -f "$BASHRC_LOCAL" + # Content already landed at the symlink target; just ensure perms + chmod 600 "$OPT_LOCAL" + else + mv "$BASHRC_LOCAL" "$OPT_LOCAL" + chmod 600 "$OPT_LOCAL" + fi + + ln -sfn "$OPT_LOCAL" "$BASHRC_LOCAL" + echo " Linked: ~/.bashrc.local → $OPT_LOCAL" + + # VS Code Remote stores large caches in ~/.vscode-server and + # ~/.vscode-remote-containers — keep them on local disk, not NFS. + for _vsdir in .vscode-server .vscode-remote-containers; do + _opt_target="$OPT_DIR/$_vsdir" + _home_link="$HOME_DIR/$_vsdir" + mkdir -p "$_opt_target" + if [ -d "$_home_link" ] && [ ! -L "$_home_link" ]; then + # Existing real directory — move contents then replace with symlink + echo " Moving ~/$_vsdir → $OPT_DIR/$_vsdir ..." + rsync -a "$_home_link/" "$_opt_target/" 2>/dev/null || \ + cp -a "$_home_link/." "$_opt_target/" 2>/dev/null || true + rm -rf "$_home_link" + fi + ln -sfn "$_opt_target" "$_home_link" + echo " Linked: ~/$_vsdir → $_opt_target" + done + + echo "✅ Per-machine .bashrc.local and VS Code dirs stored outside NFS at $OPT_DIR" + fi +fi + +# ------------------------------------------------------ +# 4️⃣ INITIALIZE DOTFILES REPO (with Gitea remote) +# ------------------------------------------------------ + +echo "📦 Setting up dotfiles repo..." + +# Copy the dotfiles manager into scripts/ if it isn't already there +SCRIPTS_DIR="$HOME_DIR/scripts" +DFM="$SCRIPTS_DIR/dotfiles_manager.sh" +if [ ! -f "$DFM" ]; then + SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + if [ -f "$SELF_DIR/dotfiles_manager.sh" ]; then + cp "$SELF_DIR/dotfiles_manager.sh" "$DFM" + chmod +x "$DFM" + echo "✅ dotfiles_manager.sh copied to $DFM" + else + echo "⚠️ dotfiles_manager.sh not found next to this script — skipping copy." + fi +fi + +# Run init via the manager (handles clone-or-init and remote setup) +if [ -f "$DFM" ]; then + DOTFILES_DIR="$DOTFILES_DIR" DOTFILES_REMOTE="$DOTFILES_REMOTE" bash "$DFM" init +else + # Minimal fallback if manager isn't available + cd "$DOTFILES_DIR" + if [ ! -d ".git" ]; then + git init + git remote add origin "$DOTFILES_REMOTE" + echo ".DS_Store" > .gitignore + touch README.md + git add . + git commit -m "Initial dotfiles commit" + echo "✅ Dotfiles repo initialized." + else + echo "ℹ️ Dotfiles repo already exists." + fi +fi +echo + +# ------------------------------------------------------ +# 5️⃣ ADD DOTFILES ALIASES TO .bashrc +# ------------------------------------------------------ + +if ! grep -q "### DOTFILES_ALIASES ###" "$BASHRC" 2>/dev/null; then +cat >> "$BASHRC" < "$HOME_DIR/scripts/bootstrap.sh" +#!/usr/bin/env bash +# bootstrap.sh — Restore Enterprise AI Environment on this machine +set -euo pipefail + +echo "🔄 Restoring Enterprise AI Environment..." + +# Reload shell config +# shellcheck disable=SC1091 +[ -f ~/.bashrc ] && source ~/.bashrc +[ -f ~/.bash_profile ] && source ~/.bash_profile + +echo " WORKSPACE : ${WORKSPACE:-not set}" +echo " DATA_ROOT : ${DATA_ROOT:-not set}" +echo " MODEL_ROOT : ${MODEL_ROOT:-not set}" +echo + +# Sync latest dotfiles from git server +if [ -f "$HOME/scripts/dotfiles_manager.sh" ]; then + echo "📦 Syncing dotfiles..." + bash "$HOME/scripts/dotfiles_manager.sh" sync +else + echo "⚠️ dotfiles_manager.sh not found — run setup_enterprise_ai_bash.sh first." +fi + +echo +echo "✅ Bootstrap complete." +EOF + +chmod +x "$HOME_DIR/scripts/bootstrap.sh" + +echo "✅ Bootstrap script created at ~/scripts/bootstrap.sh" + +# ------------------------------------------------------ +# 7️⃣ INITIAL DOTFILES MIGRATION (interactive) +# ------------------------------------------------------ + +echo +read -p "Migrate critical shell & config files into dotfiles repo now? (y/n): " MIGRATE_NOW + +if [[ "$MIGRATE_NOW" == "y" ]] && [ -f "$DFM" ]; then + echo + echo "📂 Tracking shell config files..." + + # .bashrc.local is machine-specific — never track it in dotfiles + TRACK_FILES=() + [ -f "$BASHRC" ] && TRACK_FILES+=("$BASHRC") + [ -f "$BASH_PROFILE" ] && TRACK_FILES+=("$BASH_PROFILE") + [ -f "$HOME_DIR/.bash_aliases" ] && TRACK_FILES+=("$HOME_DIR/.bash_aliases") + [ -f "$HOME_DIR/.inputrc" ] && TRACK_FILES+=("$HOME_DIR/.inputrc") + [ -f "$HOME_DIR/.gitconfig" ] && TRACK_FILES+=("$HOME_DIR/.gitconfig") + [ -f "$HOME_DIR/.vimrc" ] && TRACK_FILES+=("$HOME_DIR/.vimrc") + [ -f "$HOME_DIR/.tmux.conf" ] && TRACK_FILES+=("$HOME_DIR/.tmux.conf") + # Scripts — track so they are part of the dotfiles repo and re-bootstrap any machine + [ -f "$SCRIPTS_DIR/dotfiles_manager.sh" ] && TRACK_FILES+=("$SCRIPTS_DIR/dotfiles_manager.sh") + [ -f "$SCRIPTS_DIR/setup_enterprise_ai_bash.sh" ] && TRACK_FILES+=("$SCRIPTS_DIR/setup_enterprise_ai_bash.sh") + [ -f "$SCRIPTS_DIR/bootstrap.sh" ] && TRACK_FILES+=("$SCRIPTS_DIR/bootstrap.sh") + # NOTE: ~/.bashrc.local intentionally excluded — it is machine-specific + + if [ ${#TRACK_FILES[@]} -gt 0 ]; then + DOTFILES_DIR="$DOTFILES_DIR" DOTFILES_REMOTE="$DOTFILES_REMOTE" \ + bash "$DFM" add "${TRACK_FILES[@]}" + else + echo "ℹ️ No standard config files found to track." + fi + + echo + read -p "Set up SSH config and keys now? (y/n): " DO_SSH + if [[ "$DO_SSH" == "y" ]]; then + DOTFILES_DIR="$DOTFILES_DIR" DOTFILES_REMOTE="$DOTFILES_REMOTE" \ + bash "$DFM" ssh-setup + echo + read -p "GPG-encrypt and store private SSH keys in dotfiles? (y/n): " DO_EXPORT + if [[ "$DO_EXPORT" == "y" ]]; then + DOTFILES_DIR="$DOTFILES_DIR" DOTFILES_REMOTE="$DOTFILES_REMOTE" \ + bash "$DFM" ssh-export + fi + fi + + echo + read -p "Push initial dotfiles to $DOTFILES_REMOTE now? (y/n): " DO_PUSH + if [[ "$DO_PUSH" == "y" ]]; then + DOTFILES_DIR="$DOTFILES_DIR" DOTFILES_REMOTE="$DOTFILES_REMOTE" \ + bash "$DFM" push "initial dotfiles migration from ${MACHINE_TAG}" + fi +else + echo "ℹ️ Skipped. Run manually:" + echo " dotfiles add ~/.bashrc ~/.bash_profile ~/.gitconfig" + echo " dotfiles ssh-setup" + echo " dotfiles push" +fi + +# ------------------------------------------------------ +# 8️⃣ OPTIONAL TIME MACHINE EXCLUSIONS +# ------------------------------------------------------ + +echo +read -p "Exclude large AI folders from Time Machine? (y/n): " EXCLUDE_TM + +if [[ "$EXCLUDE_TM" == "y" ]]; then + sudo tmutil addexclusion "$HOME_DIR/data/raw" + sudo tmutil addexclusion "$HOME_DIR/models" + echo "✅ Time Machine exclusions added." +else + echo "ℹ️ Skipped Time Machine exclusions." +fi + +# ------------------------------------------------------ +# DONE +# ------------------------------------------------------ + +# ------------------------------------------------------ +# 9️⃣ REMOTE BOOTSTRAP (optional) +# ------------------------------------------------------ + +echo +read -r -p "Bootstrap a remote machine over SSH now? (y/n): " DO_REMOTE +if [[ "$DO_REMOTE" == "y" ]] && [ -f "$DFM" ]; then + read -r -p "Remote target (user@host): " REMOTE_TARGET + read -r -p "Profile for remote machine (work/personal) [personal]: " REMOTE_PROFILE + REMOTE_PROFILE="${REMOTE_PROFILE:-personal}" + DOTFILES_DIR="$DOTFILES_DIR" DOTFILES_REMOTE="$DOTFILES_REMOTE" \ + bash "$DFM" remote-bootstrap "$REMOTE_TARGET" --profile "$REMOTE_PROFILE" +else + echo "ℹ️ To bootstrap a remote machine later:" + echo " dotfiles remote-bootstrap user@hostname" + echo " dotfiles remote-bootstrap user@hostname --profile work" +fi + +# ------------------------------------------------------ +# DONE +# ------------------------------------------------------ + +echo +echo "🎉 Enterprise + AI Bash Environment Setup Complete!" +echo " Profile : $MACHINE_PROFILE" +echo " Host : $(hostname -s)" +echo +echo "👉 Run : source ~/.bash_profile" +echo "👉 Spaces : ws | src | data | models" +if [[ "$MACHINE_PROFILE" == "work" ]]; then +echo "👉 Cloud : onedrive" +else +echo "👉 Cloud : cloud | proton | gdrive" +fi +echo "👉 Dotfiles: dotfiles status" +echo "👉 Sync : dotfiles sync" +echo "👉 SSH keys: dotfiles ssh-setup (then: dotfiles ssh-export)" +echo "👉 Remote : dotfiles remote-bootstrap user@personal-mac" +echo "👉 Restore : git clone $DOTFILES_REMOTE ~/dotfiles && ~/dotfiles/install.sh" +echo +