unify upgrade script

This commit is contained in:
David Chen 2025-11-25 14:32:50 -08:00
parent 9748029320
commit 0579840c8a
4 changed files with 418 additions and 106 deletions

View file

@ -9,7 +9,7 @@ This config folder includes configurations for various development tools and app
### Quick Setup
Run the deployment script to install all tools and configure symlinks:
```bash
./deploy.sh
bin/upgrade-all
```
The script is idempotent - you can run it multiple times safely. It will:

View file

@ -11,7 +11,7 @@
### 快速设置
运行部署脚本来安装所有工具并配置符号链接:
```bash
./deploy.sh
bin/upgrade-all
```
脚本具有幂等性 - 你可以多次安全地运行它。它将:
@ -143,4 +143,4 @@ yay -S wqy-bitmapfont wqy-microhei wqy-microhei-lite wqy-zenhei adobe-source-han
### Arch 软件包
查看 [my-packages.txt](https://github.com/theniceboy/.config/blob/master/my-packages.txt) 获取完整软件包列表。
</details>
</details>

View file

@ -1,8 +1,10 @@
#!/usr/bin/env python3
import json
import os
import shutil
import subprocess
import tempfile
import threading
import time
import sys
@ -203,6 +205,137 @@ NPM_PACKAGE_LOCKS = {
# "@openai/codex": "0.40.0",
}
def ensure_homebrew():
if shutil.which("brew") is not None:
return True, "(found)", ""
candidate_paths = [
"/opt/homebrew/bin/brew",
"/usr/local/bin/brew",
"/home/linuxbrew/.linuxbrew/bin/brew",
os.path.expanduser("~/.linuxbrew/bin/brew"),
]
def configure_env(brew_path):
env_result = subprocess.run(
[brew_path, "shellenv"], capture_output=True, text=True
)
for line in env_result.stdout.splitlines():
if not line.startswith("export "):
continue
key_val = line[len("export "):]
if "=" not in key_val:
continue
key, val = key_val.split("=", 1)
val = val.strip().strip('"')
if "${PATH" in val or "$PATH" in val:
current_path = os.environ.get("PATH", "")
val = val.replace("${PATH+:$PATH}", f":{current_path}" if current_path else "")
val = val.replace("$PATH", current_path)
os.environ[key] = val
if shutil.which("brew") is None:
os.environ["PATH"] = f"{os.path.dirname(brew_path)}:{os.environ.get('PATH','')}"
for path in candidate_paths:
if os.path.exists(path):
configure_env(path)
return True, "(found)", ""
install_output_parts = []
script_url = "https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh"
try:
with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp_path = tmp.name
fetch = subprocess.run(
["curl", "-fsSL", script_url, "-o", tmp_path],
capture_output=True,
text=True,
)
install_output_parts.append(fetch.stdout or "")
install_output_parts.append(fetch.stderr or "")
if fetch.returncode != 0:
os.unlink(tmp_path)
return False, "", "".join(install_output_parts)
env = os.environ.copy()
env["NONINTERACTIVE"] = "1"
run_install = subprocess.run(
["/bin/bash", tmp_path],
capture_output=True,
text=True,
env=env,
)
install_output_parts.append(run_install.stdout or "")
install_output_parts.append(run_install.stderr or "")
os.unlink(tmp_path)
if run_install.returncode != 0:
return False, "", "".join(install_output_parts)
except Exception as exc:
return False, "", f"{''.join(install_output_parts)}\n{exc}"
for path in candidate_paths:
if os.path.exists(path):
configure_env(path)
return True, "(installed)", "".join(install_output_parts)
return False, "", "".join(install_output_parts)
def ensure_git_available():
if shutil.which("git") is not None:
return True, "(found)", ""
result = subprocess.run(
["brew", "install", "git"],
capture_output=True,
text=True,
)
output = f"{result.stdout or ''}{result.stderr or ''}"
if result.returncode == 0:
return True, "(installed)", output
return False, "", output
def ensure_github_known_host():
ssh_dir = os.path.expanduser("~/.ssh")
known_hosts = os.path.join(ssh_dir, "known_hosts")
os.makedirs(ssh_dir, exist_ok=True)
check = subprocess.run(
["ssh-keygen", "-F", "github.com"],
capture_output=True,
text=True,
)
if check.returncode == 0 and check.stdout:
return True
scan = subprocess.run(
["ssh-keyscan", "-H", "github.com"],
capture_output=True,
text=True,
)
if scan.returncode != 0:
return False
with open(known_hosts, "a", encoding="utf-8") as f:
f.write(scan.stdout)
return True
def check_github_ssh():
if not ensure_github_known_host():
return False, "", "failed to add github.com host key"
cmd = ["ssh", "-o", "BatchMode=yes", "-T", "git@github.com"]
result = subprocess.run(cmd, capture_output=True, text=True)
output = f"{result.stdout or ''}{result.stderr or ''}"
ok_phrase = "successfully authenticated"
if result.returncode in (0, 1) and ok_phrase in output:
return True, "(auth ok)", output
if "Permission denied" in output:
return False, "", "GitHub SSH key rejected (Permission denied)"
if "Could not resolve hostname" in output:
return False, "", "Cannot reach github.com"
return False, "", output
def install_brew_packages():
"""Install missing brew packages."""
print("📦 Checking brew packages...")
@ -216,10 +349,10 @@ def install_brew_packages():
print("❌ Failed to get brew package list")
return False
packages = [
base_packages = [
# System utilities
"htop", "dust", "ncdu", "fswatch", "pipx", "uv", "terminal-notifier",
# macOS GNU utilities
"htop", "dust", "ncdu", "fswatch", "pipx", "uv",
# macOS GNU utilities / common core tools
"coreutils", "gnu-tar", "gnu-getopt", "gnu-sed",
# Development tools
"node", "go", "gcc", "gdb", "automake", "cmake", "jsdoc3", "oven-sh/bun/bun",
@ -236,8 +369,16 @@ def install_brew_packages():
# Terminal and shell
"tmux", "neovim", "starship", "rainbarf", "neofetch", "onefetch", "tldr", "bmon", "loc",
# Applications
"awscli", "azure-cli", "terraform"
"awscli", "azure-cli", "hashicorp/tap/terraform",
]
mac_only = ["terminal-notifier"]
linux_skip = ["oven-sh/bun/bun"]
system = os.uname().sysname
packages = base_packages + (mac_only if system == "Darwin" else [])
if system == "Linux":
packages = [p for p in packages if p not in linux_skip]
missing_packages = []
installed_list = installed_packages.split('\n')
@ -252,14 +393,26 @@ def install_brew_packages():
if package not in installed_list:
missing_packages.append(package)
failed = []
if missing_packages:
print(f"Installing {len(missing_packages)} missing packages...")
for package in missing_packages:
try:
if package.startswith("hashicorp/tap/"):
subprocess.run(
['brew', 'tap', 'hashicorp/tap'],
capture_output=True,
text=True,
check=True
)
subprocess.run(['brew', 'install', package], check=True)
print(f"✅ Installed {package}")
except subprocess.CalledProcessError:
print(f"❌ Failed to install {package}")
failed.append(package)
if failed:
print(f"❌ Failed packages: {', '.join(failed)}")
return False
return True
else:
print("✅ All packages already installed")
@ -485,20 +638,274 @@ def schedule_npm_updates(runner):
runner.add_task("Node Apps Update", action=create_npm_update_action(packages, locks=NPM_PACKAGE_LOCKS))
def interpret_action_result(result):
success = True
note = ''
output = ''
if isinstance(result, tuple):
if len(result) == 3:
success, note, output = result
elif len(result) == 2:
success, output = result
elif isinstance(result, dict):
success = bool(result.get('success', True))
note = result.get('note', '')
output = result.get('output', '')
elif isinstance(result, bool):
success = result
elif result is not None:
output = str(result)
return bool(success), note, output
def ensure_symlink(target, link_name):
target = os.path.expanduser(target)
link_name = os.path.expanduser(link_name)
if os.path.islink(link_name):
if os.readlink(link_name) == target:
return True, "(already linked)", ""
os.remove(link_name)
elif os.path.exists(link_name):
backup = f"{link_name}.backup"
while os.path.exists(backup):
backup = f"{backup}.{int(time.time())}"
os.rename(link_name, backup)
os.symlink(target, link_name)
return True, f"(linked to {target})", ""
def create_symlink_action(target, link_name):
def action():
return ensure_symlink(target, link_name)
return action
def ensure_repo(path, slug, must_exist=False):
path = os.path.expanduser(path)
repo_url = f"git@github.com:{slug}.git"
def normalize(url):
return url.rstrip("/").removesuffix(".git")
if not os.path.exists(path):
if must_exist:
return False, "", f"{path} missing"
os.makedirs(os.path.dirname(path), exist_ok=True)
clone = subprocess.run(
["git", "clone", repo_url, path],
capture_output=True,
text=True,
)
output = f"{clone.stdout or ''}{clone.stderr or ''}"
if clone.returncode != 0:
return False, "", output
return True, "(cloned)", output
git_dir = os.path.join(path, ".git")
if not os.path.isdir(git_dir):
return False, "", f"{path} exists but is not a git repo"
remote = subprocess.run(
["git", "-C", path, "config", "--get", "remote.origin.url"],
capture_output=True,
text=True,
)
remote_url = (remote.stdout or "").strip()
if remote.returncode != 0:
return False, "", f"cannot read remote for {path}"
if normalize(remote_url) != normalize(repo_url):
return False, "", f"remote mismatch: {remote_url}"
fetch = subprocess.run(
["git", "-C", path, "fetch", "--all", "--prune"],
capture_output=True,
text=True,
)
fetch_output = f"{fetch.stdout or ''}{fetch.stderr or ''}"
if fetch.returncode != 0:
return False, "", fetch_output
pull = subprocess.run(
["git", "-C", path, "pull", "--ff-only"],
capture_output=True,
text=True,
)
pull_output = f"{pull.stdout or ''}{pull.stderr or ''}"
if pull.returncode != 0:
return False, "", f"{fetch_output}{pull_output}"
return True, "(updated)", f"{fetch_output}{pull_output}"
def create_repo_action(path, slug, must_exist=False):
def action():
return ensure_repo(path, slug, must_exist=must_exist)
return action
def is_writable(path):
path = os.path.expanduser(path)
try:
test_file = os.path.join(path, ".writetest")
with open(test_file, "w", encoding="utf-8") as f:
f.write("ok")
os.remove(test_file)
return True
except OSError:
return False
def ensure_paths_writable(paths):
for p in paths:
if not is_writable(p):
return False, "", f"path not writable: {os.path.expanduser(p)}"
return True, "(writable)", ""
def ensure_system_build_tools():
if os.uname().sysname != "Linux":
return True, "(not needed on macOS)", ""
update = subprocess.run(
["sudo", "apt-get", "update", "-y"],
capture_output=True,
text=True,
)
output_parts = [update.stdout or "", update.stderr or ""]
if update.returncode != 0:
return False, "", "".join(output_parts)
install = subprocess.run(
["sudo", "apt-get", "install", "-y", "build-essential"],
capture_output=True,
text=True,
)
output_parts.extend([install.stdout or "", install.stderr or ""])
if install.returncode != 0:
return False, "", "".join(output_parts)
return True, "(build-essential ok)", "".join(output_parts)
def create_zshrc_action():
def action():
zshrc_path = os.path.expanduser("~/.zshrc")
source_line = "source ~/.config/zsh/zshrc"
if not os.path.exists(zshrc_path):
with open(zshrc_path, "w", encoding="utf-8") as f:
f.write(f"{source_line}\n")
return True, "(created ~/.zshrc)", ""
with open(zshrc_path, "r", encoding="utf-8") as f:
lines = f.read().splitlines()
if any(source_line in line for line in lines):
return True, "(already sources config)", ""
with open(zshrc_path, "a", encoding="utf-8") as f:
if lines and lines[-1].strip() != "":
f.write("\n")
f.write(f"{source_line}\n")
return True, "(added zsh source line)", ""
return action
def create_command_action(command):
def action():
result = subprocess.run(
command,
capture_output=True,
text=True,
)
output = f"{result.stdout or ''}{result.stderr or ''}"
return result.returncode == 0, "", output
return action
def create_agent_tracker_service_action():
def action():
env = os.environ.copy()
env["HOMEBREW_NO_AUTO_UPDATE"] = "1"
service = subprocess.run(
["bash", os.path.expanduser("~/.config/agent-tracker/scripts/install_brew_service.sh")],
capture_output=True,
text=True,
env=env,
)
output = f"{service.stdout or ''}{service.stderr or ''}"
if service.returncode != 0:
return False, "", output
return True, "(service installed)", output
return action
def create_agent_tracker_build_action():
def action():
base = os.path.expanduser("~/.config/agent-tracker")
if not os.path.isdir(base):
return False, "", f"{base} missing"
build = subprocess.run(
["bash", "-lc", "cd ~/.config/agent-tracker && ./install.sh"],
capture_output=True,
text=True,
)
output = f"{build.stdout or ''}{build.stderr or ''}"
if build.returncode != 0:
return False, "", output
binary = os.path.join(base, "bin", "tracker-server")
if not os.path.isfile(binary):
return False, "", f"tracker-server still missing after build ({binary})"
return True, "(built)", output
return action
def run_inline(name, action):
success, note, output = interpret_action_result(action())
prefix = f"{Colors.GREEN}[OK]{Colors.RESET}" if success else f"{Colors.RED}[FAIL]{Colors.RESET}"
note_part = f" {note}" if note else ""
print(f"{prefix} {name}{note_part}")
if not success and output:
lines = output.strip().splitlines()
for line in lines[:10]:
print(f" {line}")
if len(lines) > 10:
print(f" ... ({len(lines) - 10} more lines)")
return success
def main():
runner = ParallelTaskRunner()
pre_steps = [
("Homebrew Available", ensure_homebrew),
("Git Available", ensure_git_available),
("GitHub SSH Auth", check_github_ssh),
("Paths Writable", lambda: ensure_paths_writable(["~", "~/.config", "~/.config/bin"])),
("System Build Tools", ensure_system_build_tools),
("Repo ~/.config", create_repo_action("~/.config", "theniceboy/.config", must_exist=True)),
("Repo ~/.config/nvim", create_repo_action("~/.config/nvim", "theniceboy/nvim")),
("Repo ~/.sconfig", create_repo_action("~/.sconfig", "theniceboy/.sconfig")),
("Repo ~/Github/mac-ctrl", create_repo_action("~/Github/mac-ctrl", "theniceboy/mac-ctrl")),
]
for name, action in pre_steps:
if not run_inline(name, action):
sys.exit(1)
# Install missing brew packages first
if not install_brew_packages():
print("❌ Brew package installation failed")
sys.exit(1)
env_steps = [
("Zsh Config Ensure", create_zshrc_action()),
("Symlink Tmux Config", create_symlink_action("~/.config/.tmux.conf", "~/.tmux.conf")),
("Symlink Claude Config", create_symlink_action("~/.config/claude", "~/.claude")),
("Symlink Codex Config", create_symlink_action("~/.config/codex", "~/.codex")),
("Symlink Ripgrep Config", create_symlink_action("~/.config/rg/ripgreprc", "~/.ripgreprc")),
("Agent Tracker Build", create_agent_tracker_build_action()),
("Agent Tracker Brew Service", create_agent_tracker_service_action()),
]
for name, action in env_steps:
if not run_inline(name, action):
sys.exit(1)
schedule_npm_updates(runner)
runner.add_task("Homebrew Update", action=create_brew_update_action())
runner.add_task("Config Git Pull", "cd ~/.config && git pull")
runner.add_task("Neovim Config Git Pull", "cd ~/.config/nvim && git pull")
runner.add_task("SConfig Git Pull", "cd ~/.sconfig && git pull")
runner.add_task( "Agent Tracker Build", "cd ~/.config/agent-tracker && ./install.sh")
runner.add_task("Agent Tracker Build", "cd ~/.config/agent-tracker && ./install.sh")
success = runner.run_all()
sys.exit(0 if success else 1)

View file

@ -1,95 +0,0 @@
#!/bin/bash
set -euo pipefail
echo "🚀 Starting deployment script..."
# Check if brew is installed or try to find it
if ! command -v brew &> /dev/null; then
echo "⚠️ Homebrew not in PATH, checking common locations..."
# Try common Homebrew paths for macOS
if [[ "$OSTYPE" == "darwin"* ]]; then
for brew_path in "/opt/homebrew/bin/brew" "/usr/local/bin/brew"; do
if [[ -x "$brew_path" ]]; then
echo "🔧 Found Homebrew at $brew_path, setting up environment..."
eval "$($brew_path shellenv)"
break
fi
done
fi
# Check again after PATH update
if ! command -v brew &> /dev/null; then
echo "❌ Homebrew not found"
echo "📦 Please install Homebrew first by running:"
echo " /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""
echo "After installation, make sure to run the commands it suggests to add brew to your PATH."
echo "Then run this script again."
exit 1
fi
fi
echo "✅ Homebrew found"
# Run upgrade-all to install/update packages
echo "📦 Running upgrade-all to install/update packages..."
if command -v upgrade-all &> /dev/null; then
upgrade-all
elif [ -x "$HOME/.config/bin/upgrade-all" ]; then
python3 "$HOME/.config/bin/upgrade-all"
else
echo "❌ upgrade-all script not found"
echo " Expected at: $HOME/.config/bin/upgrade-all"
exit 1
fi
# Ensure zsh configuration is sourced
echo "🔗 Setting up zsh configuration..."
if [ ! -f "$HOME/.zshrc" ]; then
echo "source ~/.config/zsh/zshrc" > "$HOME/.zshrc"
echo "✅ Created ~/.zshrc with config source"
elif ! grep -q "source ~/.config/zsh/zshrc" "$HOME/.zshrc"; then
echo "source ~/.config/zsh/zshrc" >> "$HOME/.zshrc"
echo "✅ Added config source to ~/.zshrc"
else
echo "✅ Zsh config source already exists in ~/.zshrc"
fi
# Function to create symlinks
create_symlink() {
local target="$1"
local link_name="$2"
local display_name="$3"
echo "🔗 Setting up $display_name symlink..."
if [ -L "$link_name" ]; then
local current
current=$(readlink "$link_name")
if [ "$current" = "$target" ]; then
echo "$display_name symlink already exists and is correct"
return 0
fi
echo "⚠️ $link_name points to $current; updating to $target"
rm "$link_name"
elif [ -e "$link_name" ]; then
local backup="${link_name}.backup"
if [ -e "$backup" ]; then
backup="${backup}.$(date +%Y%m%d%H%M%S)"
fi
echo "⚠️ Backing up existing $link_name to $backup"
mv "$link_name" "$backup"
fi
ln -s "$target" "$link_name"
echo "✅ Symlink ensured: $link_name -> $target"
}
bash ~/.config/agent-tracker/scripts/install_brew_service.sh
# Create configuration symlinks
create_symlink "$HOME/.config/.tmux.conf" "$HOME/.tmux.conf" "Tmux"
create_symlink "$HOME/.config/claude" "$HOME/.claude" "Claude"
create_symlink "$HOME/.config/codex" "$HOME/.codex" "Codex"
echo "🎉 Deployment complete!"