From fec382fad8898089183cf03d682fa07fad9eb9cf Mon Sep 17 00:00:00 2001 From: modem7 Date: Sat, 26 Sep 2026 21:09:24 +0100 Subject: [PATCH 1/2] VM setup: detect login shell and use a dedicated GitHub SSH key - Write the env block to the login shell's rc file (zsh or bash, from the passwd entry), plus the other shell's rc only if it already exists - Generate ~/.ssh/id_ed25519_github instead of the default key and wire it to github.com via a managed ~/.ssh/config block; keep the old key as a fallback until ssh -T confirms GitHub accepts the new one - Fall back to ssh.github.com:443 when port 22 is blocked --- Bash/AI/Claude/claude-code-vm-setup.sh | 162 ++++++++++++++++++++----- 1 file changed, 131 insertions(+), 31 deletions(-) diff --git a/Bash/AI/Claude/claude-code-vm-setup.sh b/Bash/AI/Claude/claude-code-vm-setup.sh index 28e9d24..8a03c1f 100644 --- a/Bash/AI/Claude/claude-code-vm-setup.sh +++ b/Bash/AI/Claude/claude-code-vm-setup.sh @@ -8,9 +8,9 @@ # # What it does: # - Always: locale, core/build packages, Node.js LTS, Go, Rust, Claude Code. -# - Optional (asked up front, default yes): Docker, GitHub CLI + SSH key + -# `gh auth login`, the webapp-testing skill (Python Playwright), weekly -# unattended apt upgrades. +# - Optional (asked up front, default yes): Docker, GitHub CLI + a dedicated +# GitHub SSH key (wired up in ~/.ssh/config) + `gh auth login`, the +# webapp-testing skill (Python Playwright), weekly unattended apt upgrades. # - Claude Code config comes from ONE of: # a) claude-config-sync (https://github.com/modem7/claude-config-sync): # give it your private sync repo URL and it clones it to @@ -18,6 +18,8 @@ # hooks, skills, plugins and memory all come from your repo; or # b) a local baseline settings.json (+ optional plugin bundle), MERGED # into any existing file rather than overwriting it. +# - Shell environment (PATH, aliases) goes in the rc file of your login shell +# (zsh or bash), plus the other one's if it already exists. # - Remote Control auto-start, so the VM can be driven from Claude Desktop / # claude.ai/code (outbound HTTPS only, no inbound ports). # @@ -32,7 +34,7 @@ # Every prompt can be pre-answered with an environment variable, for # unattended runs (booleans accept y/yes/true/1 or n/no/false/0): # GIT_NAME, GIT_EMAIL git identity -# SETUP_GITHUB gh CLI + SSH key + gh auth login +# SETUP_GITHUB gh CLI + dedicated SSH key + gh auth login # INSTALL_DOCKER Docker Engine + Compose plugin # CLAUDE_SYNC_REPO_URL claude-config-sync repo ("" = don't use it) # INSTALL_PLUGINS plugin bundle (local config mode only) @@ -42,6 +44,8 @@ # PROJECT_DIR (default ~/project) LOCALE (default en_GB.UTF-8) # NODE_MAJOR (default 24) CLAUDE_INSTALL_METHOD (apt|native) # CLAUDE_SYNC_REPO_DIR (default ~/claude-config) +# GITHUB_SSH_KEY (default ~/.ssh/id_ed25519_github — a dedicated key, +# wired to github.com via ~/.ssh/config) # ============================================================================ set -euo pipefail @@ -53,6 +57,7 @@ NODE_MAJOR_EXPLICIT="${NODE_MAJOR:+true}" NODE_MAJOR="${NODE_MAJOR:-24}" CLAUDE_INSTALL_METHOD="${CLAUDE_INSTALL_METHOD:-apt}" CLAUDE_SYNC_REPO_DIR="${CLAUDE_SYNC_REPO_DIR:-$HOME/claude-config}" +GITHUB_SSH_KEY="${GITHUB_SSH_KEY:-$HOME/.ssh/id_ed25519_github}" CLAUDE_APT_KEY_FPR="31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE" ASSUME_YES="false" # Distinguishes "not set" from "set to empty" (= explicitly don't sync). @@ -220,6 +225,32 @@ preflight() { fi DPKG_ARCH=$(dpkg --print-architecture) + detect_shell +} + +# Works out which rc file(s) get the environment block. The login shell comes +# from the passwd entry rather than $SHELL, which can be inherited from +# whatever launched this script (and this script always runs under bash). +detect_shell() { + local shell_path + shell_path=$(getent passwd "$USER" 2>/dev/null | cut -d: -f7) + LOGIN_SHELL=$(basename "${shell_path:-${SHELL:-/bin/bash}}") + + SHELL_RC_FILES=() + case "$LOGIN_SHELL" in + zsh) SHELL_RC_FILES=("$HOME/.zshrc") + [[ -f "$HOME/.bashrc" ]] && SHELL_RC_FILES+=("$HOME/.bashrc") ;; + bash) SHELL_RC_FILES=("$HOME/.bashrc") + [[ -f "$HOME/.zshrc" ]] && SHELL_RC_FILES+=("$HOME/.zshrc") ;; + *) # fish, dash, etc. can't source the POSIX-ish block; still set up + # bash (used by scripts, tmux, Claude's own Bash tool) and say so. + warn "Login shell '$LOGIN_SHELL' isn't supported for the environment block; only ~/.bashrc will be updated." + warn "Add ~/.local/bin, ~/.npm-global/bin, ~/.cargo/bin and /usr/local/go/bin to PATH in your $LOGIN_SHELL config yourself." + SHELL_RC_FILES=("$HOME/.bashrc") + [[ -f "$HOME/.zshrc" ]] && SHELL_RC_FILES+=("$HOME/.zshrc") ;; + esac + PRIMARY_RC="${SHELL_RC_FILES[0]}" + info "Login shell: $LOGIN_SHELL (environment block goes in: ${SHELL_RC_FILES[*]/#$HOME/\~})" } # ── Questions (all asked up front so the long install can run unattended) ── @@ -444,14 +475,19 @@ setup_github() { fi echo " gh $(gh --version | head -1 | awk '{print $3}')" - step "Setting up SSH key for GitHub" - local key="$HOME/.ssh/id_ed25519" + step "Setting up a dedicated SSH key for GitHub" + # A key used only for GitHub (not the default ~/.ssh/id_ed25519), so it can + # be revoked on its own and is never offered to other hosts. + local key="$GITHUB_SSH_KEY" title legacy="$HOME/.ssh/id_ed25519" + title="$(hostname) (claude-code-vm-setup)" install -d -m 700 "$HOME/.ssh" if [[ -f "$key" ]]; then - info "Reusing existing SSH key at $key." + info "Reusing existing GitHub SSH key at $key." else - ssh-keygen -q -t ed25519 -C "$GIT_EMAIL" -f "$key" -N "" + ssh-keygen -q -t ed25519 -C "$GIT_EMAIL github@$(hostname)" -f "$key" -N "" + success "Generated $key." fi + # Pin github.com's host keys from its HTTPS API (authenticated by TLS) # rather than trusting whatever ssh-keyscan happens to be told. if ssh-keygen -F github.com >/dev/null 2>&1; then @@ -467,30 +503,94 @@ setup_github() { info "gh is already authenticated — skipping gh auth login." elif [[ -t 0 ]]; then step "Running 'gh auth login' (interactive — follow the browser/device-code prompts)" - # -p ssh makes gh offer to upload the key generated above. - gh auth login -h github.com -p ssh || warn "gh auth login did not complete; run it later with: gh auth login -p ssh" + # --skip-ssh-key: gh would otherwise offer whichever key it finds first + # (often the default one); the dedicated key is uploaded explicitly below, + # which is what the admin:public_key scope is for. + gh auth login -h github.com -p ssh --skip-ssh-key -s admin:public_key \ + || warn "gh auth login did not complete; run it later with: gh auth login -p ssh" else warn "No terminal attached — skipping gh auth login. Run it later with: gh auth login -p ssh" fi - - if gh auth status -h github.com >/dev/null 2>&1; then - gh auth setup-git >/dev/null 2>&1 || true - # Make sure the key is actually registered (covers re-runs, and logins - # where the upload prompt was skipped). Needs the public_key scopes, so - # this is best-effort. - local pub; pub=$(awk '{print $1" "$2}' "$key.pub") - if gh api user/keys --jq '.[].key' 2>/dev/null | grep -qxF "$pub"; then - info "SSH key is registered with GitHub." - elif gh ssh-key add "$key.pub" --title "$(hostname) (claude-code-vm-setup)" >/dev/null 2>&1; then - success "Uploaded SSH key to GitHub." - else - echo -e "${BOLD}Add this public key at https://github.com/settings/keys (or: gh auth refresh -s admin:public_key && gh ssh-key add $key.pub):${NC}" - cat "$key.pub" + gh auth status -h github.com >/dev/null 2>&1 && { gh auth setup-git >/dev/null 2>&1 || true; } + + # Register the key. `ssh -T` with only this key is the ground truth — it + # works whether or not gh is authenticated or has the key-listing scope. + if github_accepts_key "$key"; then + info "GitHub already accepts $key." + elif gh auth status -h github.com >/dev/null 2>&1; then + if ! gh ssh-key add "$key.pub" --title "$title" >/dev/null 2>&1 && [[ -t 0 ]]; then + info "gh needs the admin:public_key scope to upload the key — refreshing auth..." + { gh auth refresh -h github.com -s admin:public_key \ + && gh ssh-key add "$key.pub" --title "$title" >/dev/null 2>&1; } || true fi + github_accepts_key "$key" && success "Uploaded $key to GitHub." + fi + + local registered=false + github_accepts_key "$key" && registered=true + if ! $registered; then + echo -e "${BOLD}Add this public key at https://github.com/settings/keys, then re-run this script:${NC}" + cat "$key.pub" + fi + write_github_ssh_config "$key" "$registered" "$legacy" +} + +# True if GitHub authenticates us with exactly this key and nothing else. +# Tries port 22, then GitHub's SSH-over-443 endpoint for networks that block +# 22, and records the first reachable one in GH_SSH_HOST/GH_SSH_PORT for the +# ~/.ssh/config block. Ignores ~/.ssh/config so only this key is tested. +GH_SSH_HOST="github.com" +GH_SSH_PORT="22" +github_accepts_key() { + local endpoint host port out + for endpoint in github.com:22 ssh.github.com:443; do + host="${endpoint%:*}" port="${endpoint#*:}" + out=$(ssh -T -F /dev/null -p "$port" -i "$1" \ + -o BatchMode=yes -o IdentitiesOnly=yes -o IdentityAgent=none \ + -o ConnectTimeout=10 -o HostKeyAlias=github.com \ + git@"$host" 2>&1) || true + case "$out" in + *"successfully authenticated"*) GH_SSH_HOST="$host" GH_SSH_PORT="$port"; return 0 ;; + *"Permission denied"*) GH_SSH_HOST="$host" GH_SSH_PORT="$port"; return 1 ;; + esac + done + return 1 +} + +# Points github.com at the dedicated key via a managed block in ~/.ssh/config. +# Until GitHub is confirmed to accept the new key, the old default key stays +# listed as a fallback so existing git remotes keep working. +write_github_ssh_config() { + local key="$1" registered="$2" legacy="$3" cfg="$HOME/.ssh/config" block + local begin="# >>> claude-code-vm-setup github >>>" end="# <<< claude-code-vm-setup github <<<" + touch "$cfg" && chmod 600 "$cfg" + + # Don't fight a github.com entry the user wrote themselves. + if awk -v b="$begin" -v e="$end" '$0==b{skip=1} !skip; $0==e{skip=0}' "$cfg" \ + | grep -qiE '^[[:space:]]*Host([[:space:]]+[^#]*)?[[:space:]]github\.com([[:space:]]|$)'; then + warn "$cfg already has its own 'Host github.com' entry — leaving it alone. Point it at: IdentityFile $key" + return 0 + fi + + block="Host github.com + HostName $GH_SSH_HOST + Port $GH_SSH_PORT + User git + IdentityFile ${key/#$HOME/\~} + IdentitiesOnly yes" + # Port 22 unreachable: ssh.github.com:443 serves the same host keys. + [[ "$GH_SSH_HOST" != "github.com" ]] && block+=" + HostKeyAlias github.com" + if [[ "$registered" != "true" && -f "$legacy" && "$legacy" != "$key" ]]; then + block+=" + # Fallback until GitHub accepts the key above; removed on the next re-run once it does. + IdentityFile ${legacy/#$HOME/\~}" + warn "GitHub doesn't accept $key yet — keeping ${legacy/#$HOME/\~} as a fallback in ~/.ssh/config." fi + write_managed_block "$cfg" "$begin" "$end" "$block" + info "github.com now uses ${key/#$HOME/\~} (~/.ssh/config)." } -# ── Claude Code ──────────────────────────────────────────────────────────── install_claude_code() { step "Installing Claude Code" # The native installer puts claude in ~/.local/bin, which a fresh VM's @@ -671,7 +771,8 @@ are deployed by default." ## GitHub Access - **gh CLI** is installed and authenticated via \`gh auth login\` (verify: \`gh auth status\`). Prefer \`gh\` for issues/PRs/Actions. -- **SSH key**: ~/.ssh/id_ed25519 (verify: \`ssh -T git@github.com\`)." +- **SSH key**: dedicated key ${GITHUB_SSH_KEY/#$HOME/\~}, used for github.com via ~/.ssh/config + (verify: \`ssh -T git@github.com\`)." fi if $USE_SYNC; then sections+=" @@ -698,6 +799,7 @@ deployment-engineer${docker_plugin}." - **OS**: ${PRETTY_NAME:-Ubuntu} VM ($DPKG_ARCH) - **Working directory**: $PROJECT_DIR - **User**: $USER (sudo-capable, not root) +- **Shell**: $LOGIN_SHELL (PATH/aliases set in ${SHELL_RC_FILES[*]/#$HOME/\~}) - **Locale**: $LOCALE ## Available Tools @@ -721,7 +823,7 @@ this machine's session in Claude Desktop or claude.ai/code. Outbound HTTPS only; } setup_shell() { - step "Setting up shell environment (bash + zsh if present)" + step "Setting up shell environment ($LOGIN_SHELL)" local begin="# >>> claude-code-vm-setup >>>" end="# <<< claude-code-vm-setup <<<" block rc tmp # Quoted heredoc: $HOME, $PATH etc. stay literal and expand when the rc file # is sourced; @PLACEHOLDERS@ are filled in below. @@ -752,9 +854,7 @@ RCBLOCK fi block=${block//@DOCKER_ALIASES@/$docker_aliases} - local rc_files=("$HOME/.bashrc") - have zsh && rc_files+=("$HOME/.zshrc") - for rc in "${rc_files[@]}"; do + for rc in "${SHELL_RC_FILES[@]}"; do touch "$rc" # Migrate the unmarked block written by older versions of this script. # shellcheck disable=SC2016 # matching a literal $HOME @@ -826,7 +926,7 @@ print_summary() { if is_true "$INSTALL_DOCKER" && ! id -nG | grep -qw docker; then echo -e " $((n++)). Log out and back in (or ${CYAN}newgrp docker${NC}) so the docker group applies" fi - echo -e " $((n++)). Open a new shell (or ${CYAN}source ~/.bashrc${NC})" + echo -e " $((n++)). Open a new shell (or ${CYAN}source ${PRIMARY_RC/#$HOME/\~}${NC})" if is_true "$SETUP_GITHUB"; then echo -e " $((n++)). ${CYAN}gh auth status${NC} / ${CYAN}ssh -T git@github.com${NC} — confirm GitHub access" fi From 9e21c866d347f7c463e19e4624b4a8993d95151b Mon Sep 17 00:00:00 2001 From: modem7 Date: Sat, 26 Sep 2026 21:31:34 +0100 Subject: [PATCH 2/2] VM setup: sign-ins up front, Claude login, Remote Control service, health check - Split into an interactive phase (prereqs, git, gh + SSH key, Claude install + claude auth login) and a fully unattended phase - Sign in to Claude Code with claude auth login --claudeai when needed - Optional always-on claude remote-control systemd user service with lingering, Restart=always, and pre-accepted workspace trust - End-of-run health check replaces the manual verification steps --- Bash/AI/Claude/claude-code-vm-setup.sh | 269 +++++++++++++++++++++---- 1 file changed, 235 insertions(+), 34 deletions(-) diff --git a/Bash/AI/Claude/claude-code-vm-setup.sh b/Bash/AI/Claude/claude-code-vm-setup.sh index 8a03c1f..bcb9fe3 100644 --- a/Bash/AI/Claude/claude-code-vm-setup.sh +++ b/Bash/AI/Claude/claude-code-vm-setup.sh @@ -20,8 +20,13 @@ # into any existing file rather than overwriting it. # - Shell environment (PATH, aliases) goes in the rc file of your login shell # (zsh or bash), plus the other one's if it already exists. -# - Remote Control auto-start, so the VM can be driven from Claude Desktop / -# claude.ai/code (outbound HTTPS only, no inbound ports). +# - Signs you in to GitHub (gh) and Claude Code itself, and can run Remote +# Control as an always-on systemd user service, so the VM can be driven +# from Claude Desktop / claude.ai/code even after a reboot (outbound HTTPS +# only, no inbound ports). +# - Two phases: everything that needs you (questions, sign-ins) happens in +# the first minute or two; the long installs then run unattended and end +# with a health check of what was set up. # # Safe to re-run: every step checks existing state first. Generated shell rc # and CLAUDE.md content lives between marker comments and is refreshed in @@ -40,10 +45,15 @@ # INSTALL_PLUGINS plugin bundle (local config mode only) # INSTALL_WEBAPP_TESTING webapp-testing skill + Playwright # AUTO_UPDATE weekly apt upgrade cron +# CLAUDE_LOGIN sign in to Claude Code during setup +# REMOTE_CONTROL_SERVICE always-on Remote Control systemd service # Other knobs: # PROJECT_DIR (default ~/project) LOCALE (default en_GB.UTF-8) # NODE_MAJOR (default 24) CLAUDE_INSTALL_METHOD (apt|native) # CLAUDE_SYNC_REPO_DIR (default ~/claude-config) +# CLAUDE_EMAIL pre-fills the Claude sign-in page +# REMOTE_CONTROL_ARGS extra flags for `claude remote-control` +# (e.g. "--spawn worktree") # GITHUB_SSH_KEY (default ~/.ssh/id_ed25519_github — a dedicated key, # wired to github.com via ~/.ssh/config) # ============================================================================ @@ -87,6 +97,11 @@ step() { echo -e "\n${BOLD}>>> $1${NC}" } +phase() { + echo "" + echo -e "${BOLD}${CYAN}══ Phase $1 ══${NC}" +} + header() { echo "" echo -e "${BOLD}╔══════════════════════════════════════════════════╗${NC}" @@ -283,6 +298,8 @@ gather_config() { && ! ssh-keygen -F github.com >/dev/null 2>&1; then warn "Sync repo uses SSH but GitHub setup was declined — the clone will only work if this VM already has a key GitHub trusts." fi + ask_yn CLAUDE_LOGIN "Sign in to Claude Code during setup (Pro/Max account; needed for Remote Control)?" + ask_yn REMOTE_CONTROL_SERVICE "Run Remote Control as an always-on background service (reachable from Claude Desktop even after reboots)?" echo "" echo -e "${BOLD}Optional Extras${NC}" @@ -299,9 +316,16 @@ gather_config() { } # ── System ───────────────────────────────────────────────────────────────── +# Just what the sign-in phase needs (gh/Claude apt repos, SSH, JSON edits), +# so it can start within seconds instead of after the long installs. +bootstrap_packages() { + step "Installing prerequisites" + sudo apt-get update -qq + apt_install_required git curl ca-certificates gnupg jq openssh-client +} + setup_locale() { step "Configuring locale ($LOCALE)" - sudo apt-get update -qq apt_install_required locales local normalised="${LOCALE,,}"; normalised="${normalised/utf-8/utf8}" if locale -a 2>/dev/null | grep -qix "$normalised"; then @@ -321,7 +345,7 @@ install_packages() { sudo apt-get -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold upgrade -y -qq >/dev/null step "Installing core packages" - apt_install_required git curl wget ca-certificates gnupg jq rsync + apt_install_required wget rsync apt_install \ unzip zip lsb-release software-properties-common \ bash-completion htop nano vim tmux screen \ @@ -627,6 +651,27 @@ install_claude_code() { echo " Claude Code $(claude --version 2>/dev/null || echo 'installed')" } +claude_logged_in() { + claude auth status --json 2>/dev/null | jq -e '.loggedIn == true' >/dev/null +} + +claude_login() { + is_true "$CLAUDE_LOGIN" || { info "Skipping Claude Code sign-in (declined)."; return 0; } + step "Signing in to Claude Code" + if claude_logged_in; then + info "Already signed in ($(claude auth status --json | jq -r '.authMethod'))." + elif [[ -t 0 ]]; then + # --claudeai: Remote Control only works with a claude.ai subscription + # login, not Console/API billing. + local args=(--claudeai) + [[ -n "${CLAUDE_EMAIL:-}" ]] && args+=(--email "$CLAUDE_EMAIL") + echo "Open the URL below in a browser and sign in with your Pro/Max account." + claude auth login "${args[@]}" || warn "Claude sign-in didn't complete; run it later with: claude auth login" + else + warn "No terminal attached — skipping Claude sign-in. Run it later with: claude auth login" + fi +} + # Option a) claude-config-sync owns ~/.claude — clone the user's sync repo and # hand over to its install.sh (bootstrap on first run, sync afterwards). It # also provisions gh and its companion npm tools. @@ -722,6 +767,73 @@ enable_remote_control() { jq '.remoteControlAtStartup = true' "$cfg" > "$tmp" && cat "$tmp" > "$cfg" } +# Always-on `claude remote-control` (server mode) as a systemd *user* service +# with lingering enabled, so it starts at boot without anyone logged in and +# comes back if it exits (server mode gives up after ~10 min offline). +RC_UNIT="claude-remote-control.service" +setup_remote_control_service() { + is_true "$REMOTE_CONTROL_SERVICE" || { info "Skipping Remote Control service (declined)."; return 0; } + step "Setting up always-on Remote Control service" + if [[ ! -d /run/systemd/system ]]; then + warn "systemd isn't running here (container/WSL?) — skipping the service. Run 'claude remote-control' in tmux instead." + RC_SERVICE_STATE="unsupported"; return 0 + fi + + local claude_bin cfg="$HOME/.claude.json" tmp unit_dir="$HOME/.config/systemd/user" unit + claude_bin=$(command -v claude) + + # A headless server can't answer the workspace-trust dialog, so trust the + # project dir up front (the equivalent of accepting it in `claude` once). + if jq -e . "$cfg" >/dev/null 2>&1; then + tmp=$(mktemp -p "$WORK_DIR") + jq --arg d "$PROJECT_DIR" '.projects[$d] = ((.projects[$d] // {}) + {hasTrustDialogAccepted: true})' "$cfg" > "$tmp" \ + && cat "$tmp" > "$cfg" + fi + + mkdir -p "$unit_dir" + unit="[Unit] +Description=Claude Code Remote Control server (managed by claude-code-vm-setup) +Documentation=https://code.claude.com/docs/en/remote-control +StartLimitIntervalSec=0 + +[Service] +Type=simple +WorkingDirectory=$PROJECT_DIR +Environment=PATH=$HOME/.local/bin:$HOME/.npm-global/bin:$HOME/.cargo/bin:/usr/local/go/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +Environment=LANG=$LOCALE +ExecStart=$claude_bin remote-control --name \"$(hostname)\" ${REMOTE_CONTROL_ARGS:-} +# Also covers 'not signed in yet': it comes up by itself within a minute of +# 'claude auth login'. +Restart=always +RestartSec=60 + +[Install] +WantedBy=default.target" + local changed=false + if [[ "$(cat "$unit_dir/$RC_UNIT" 2>/dev/null)" != "$unit" ]]; then + printf '%s\n' "$unit" > "$unit_dir/$RC_UNIT" + changed=true + fi + + sudo loginctl enable-linger "$USER" + export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" + local _ + for _ in {1..10}; do # a just-lingered user manager takes a moment to appear + systemctl --user show-environment >/dev/null 2>&1 && break + sleep 1 + done + if ! systemctl --user show-environment >/dev/null 2>&1; then + warn "Can't reach your systemd user manager from this session. Unit written to $unit_dir/$RC_UNIT;" + warn "enable it from a normal login with: systemctl --user enable --now claude-remote-control" + RC_SERVICE_STATE="not-enabled"; return 0 + fi + systemctl --user daemon-reload + systemctl --user enable -q "$RC_UNIT" + if $changed; then systemctl --user restart "$RC_UNIT"; else systemctl --user start "$RC_UNIT"; fi + RC_SERVICE_STATE="enabled" + info "Enabled $RC_UNIT (logs: journalctl --user -u claude-remote-control -f)." +} + install_webapp_testing() { is_true "$INSTALL_WEBAPP_TESTING" || { info "Skipping webapp-testing skill + Playwright (declined)."; return 0; } @@ -758,7 +870,18 @@ write_project_claude_md() { info "Replaced pre-marker CLAUDE.md (old copy at CLAUDE.md.bak)." fi - local body sections="" docker_plugin="" + local body sections="" docker_plugin="" rc_section + if is_true "$REMOTE_CONTROL_SERVICE"; then + rc_section="An always-on \`claude remote-control\` server runs as the systemd user service +\`claude-remote-control\` in $PROJECT_DIR (starts at boot; logs: \`journalctl --user -u claude-remote-control\`). +Pick this machine's session in Claude Desktop or claude.ai/code. Interactive \`claude\` sessions also +register (remoteControlAtStartup). Needs a Pro/Max login; outbound HTTPS only." + else + rc_section="Auto-start is on (remoteControlAtStartup in ~/.claude.json). Needs a Pro/Max login. Run +\`claude\` in $PROJECT_DIR (or \`claude remote-control\` for multi-session server mode), then pick +this machine's session in Claude Desktop or claude.ai/code. Outbound HTTPS only; the local +\`claude\` process must stay running." + fi is_true "$INSTALL_DOCKER" && docker_plugin=", docker" if is_true "$INSTALL_DOCKER"; then sections+=" @@ -810,10 +933,7 @@ deployment-engineer${docker_plugin}." ${sections} ## Remote Control -Auto-start is on (remoteControlAtStartup in ~/.claude.json). Needs a Pro/Max login. Run -\`claude\` in $PROJECT_DIR (or \`claude remote-control\` for multi-session server mode), then pick -this machine's session in Claude Desktop or claude.ai/code. Outbound HTTPS only; the local -\`claude\` process must stay running. +${rc_section} ## Conventions - Use git for version control on all projects in $PROJECT_DIR/ @@ -899,43 +1019,114 @@ final_cleanup() { sudo apt-get clean -qq } +# ── Health check ─────────────────────────────────────────────────────────── +# Verifies what was actually set up instead of listing "go check X" steps. +HEALTH=() # "ok|warn|faillabeldetailfix" +check() { HEALTH+=("$1"$'\t'"$2"$'\t'"$3"$'\t'"${4:-}"); } + +health_check() { + step "Verifying setup" + local out + + if have claude; then check ok "Claude Code" "$(claude --version 2>/dev/null) via $CLAUDE_INSTALL_METHOD" + else check fail "Claude Code" "not on PATH" "re-run this script"; fi + + if claude_logged_in; then + check ok "Claude sign-in" "$(claude auth status --json | jq -r '.authMethod')" + else + check fail "Claude sign-in" "not signed in" "claude auth login" + fi + + if is_true "$SETUP_GITHUB"; then + if gh auth status -h github.com >/dev/null 2>&1; then check ok "gh CLI" "authenticated" + else check fail "gh CLI" "not authenticated" "gh auth login -p ssh"; fi + # Through ~/.ssh/config, i.e. exactly what git will use. + out=$(ssh -T -o BatchMode=yes -o ConnectTimeout=10 git@github.com 2>&1) || true + if [[ "$out" == *"successfully authenticated"* ]]; then + check ok "GitHub SSH" "authenticated as $(sed -n 's/^Hi \([^!]*\)!.*/\1/p' <<<"$out")" + else + check fail "GitHub SSH" "not accepted" "add ${GITHUB_SSH_KEY/#$HOME/\~}.pub at github.com/settings/keys" + fi + fi + + if $USE_SYNC; then + if [[ "${SYNC_FAILED:-false}" == "true" ]]; then + check fail "claude-config-sync" "setup failed (see above)" "$0 --sync-repo $CLAUDE_SYNC_REPO_URL" + elif "$CLAUDE_SYNC_REPO_DIR/claude-sync.sh" doctor >/dev/null 2>&1; then + check ok "claude-config-sync" "$CLAUDE_SYNC_REPO_URL" + else + check warn "claude-config-sync" "doctor found issues" "$CLAUDE_SYNC_REPO_DIR/claude-sync.sh doctor remediate" + fi + else + check ok "Claude config" "local settings.json (plugins: $(is_true "$INSTALL_PLUGINS" && echo bundle || echo none))" + fi + + if is_true "$REMOTE_CONTROL_SERVICE"; then + case "${RC_SERVICE_STATE:-}" in + enabled) + sleep 5 # a crash-looping unit shows as "activating (auto-restart)", not active + if ! claude_logged_in; then + check warn "Remote Control" "service enabled; starts once you sign in" "claude auth login" + elif systemctl --user is-active -q "$RC_UNIT"; then + check ok "Remote Control" "service running, starts at boot" + else + check fail "Remote Control" "service not staying up" "journalctl --user -u claude-remote-control -n 50" + fi ;; + unsupported) check warn "Remote Control" "no systemd — service skipped" "run 'claude remote-control' in tmux" ;; + *) check warn "Remote Control" "unit written, not enabled" "systemctl --user enable --now claude-remote-control" ;; + esac + fi + + if is_true "$INSTALL_DOCKER"; then + if sudo docker info >/dev/null 2>&1; then check ok "Docker" "$(sudo docker --version | awk '{print $3}' | tr -d ',')" + else check fail "Docker" "daemon not responding" "sudo systemctl status docker"; fi + fi + + if [[ -f /var/run/reboot-required ]]; then + check warn "Reboot" "required (kernel/libc updated)" "sudo reboot" + fi +} + # ── Summary ──────────────────────────────────────────────────────────────── print_summary() { - local n=1 + local n=1 line status label detail fix icon failed=0 echo "" echo -e "${GREEN}${BOLD}╔══════════════════════════════════════════════════╗${NC}" echo -e "${GREEN}${BOLD}║ Claude Code VM Ready! ║${NC}" echo -e "${GREEN}${BOLD}╚══════════════════════════════════════════════════╝${NC}" echo "" - echo -e " ${BOLD}User:${NC} $USER (sudo-capable)" - echo -e " ${BOLD}Project dir:${NC} $PROJECT_DIR" - echo -e " ${BOLD}Claude Code:${NC} $(claude --version 2>/dev/null || echo '?') via $CLAUDE_INSTALL_METHOD" - if $USE_SYNC; then - if [[ "${SYNC_FAILED:-false}" == "true" ]]; then - echo -e " ${BOLD}Config:${NC} ${YELLOW}claude-config-sync FAILED${NC} — see warnings above" - else - echo -e " ${BOLD}Config:${NC} claude-config-sync ← $CLAUDE_SYNC_REPO_URL" - fi - else - echo -e " ${BOLD}Config:${NC} local ~/.claude/settings.json (plugins: $(is_true "$INSTALL_PLUGINS" && echo bundle || echo none))" - fi - echo -e " ${BOLD}Locale:${NC} $LOCALE" + echo -e " ${BOLD}User:${NC} $USER ${BOLD}Shell:${NC} $LOGIN_SHELL ${BOLD}Project:${NC} $PROJECT_DIR ${BOLD}Locale:${NC} $LOCALE" is_true "$AUTO_UPDATE" && echo -e " ${BOLD}Auto-updates:${NC} apt upgrade every Sunday 03:00 (log: /var/log/auto-update.log)" echo "" + for line in "${HEALTH[@]}"; do + IFS=$'\t' read -r status label detail fix <<<"$line" + case "$status" in + ok) icon="${GREEN}✔${NC}" ;; + warn) icon="${YELLOW}!${NC}" ;; + *) icon="${RED}✘${NC}"; failed=$((failed+1)) ;; + esac + printf " %b %-20s %s\n" "$icon" "$label" "$detail" + done + echo "" echo -e " ${BOLD}Next steps:${NC}" + local seen=$'\n' + for line in "${HEALTH[@]}"; do + IFS=$'\t' read -r status label detail fix <<<"$line" + [[ "$status" == "ok" || -z "$fix" || "$seen" == *$'\n'"$fix"$'\n'* ]] && continue + seen+="$fix"$'\n' # one fix can clear several checks (e.g. signing in) + echo -e " $((n++)). ${label}: ${CYAN}${fix}${NC}" + done if is_true "$INSTALL_DOCKER" && ! id -nG | grep -qw docker; then echo -e " $((n++)). Log out and back in (or ${CYAN}newgrp docker${NC}) so the docker group applies" fi echo -e " $((n++)). Open a new shell (or ${CYAN}source ${PRIMARY_RC/#$HOME/\~}${NC})" - if is_true "$SETUP_GITHUB"; then - echo -e " $((n++)). ${CYAN}gh auth status${NC} / ${CYAN}ssh -T git@github.com${NC} — confirm GitHub access" - fi - if [[ "${SYNC_FAILED:-false}" == "true" ]]; then - echo -e " $((n++)). Re-run ${CYAN}$0 --sync-repo $CLAUDE_SYNC_REPO_URL${NC} once the cause is fixed" + if is_true "$REMOTE_CONTROL_SERVICE"; then + echo -e " $((n++)). In Claude Desktop / claude.ai/code, pick the ${BOLD}$(hostname)${NC} session" + else + echo -e " $((n++)). ${CYAN}cd $PROJECT_DIR && claude${NC}, then pick this VM's session in Claude Desktop / claude.ai/code" fi - echo -e " $((n++)). ${CYAN}cd $PROJECT_DIR && claude${NC}, then ${CYAN}/login${NC} (Pro/Max needed for Remote Control)" - echo -e " $((n++)). In Claude Desktop / claude.ai/code, pick this VM's session to drive it remotely" echo "" + [[ $failed -eq 0 ]] || warn "$failed check(s) failed — see Next steps. Re-running this script is safe." } # ── Main ─────────────────────────────────────────────────────────────────── @@ -946,23 +1137,33 @@ main() { gather_config export DEBIAN_FRONTEND=noninteractive + SYNC_FAILED=false + + # Phase 1: quick, and the only part that may need you. + phase "1/2 — Sign-ins (stay for this part)" + bootstrap_packages + setup_git + setup_github + install_claude_code + claude_login + + # Phase 2: long-running and fully unattended. + phase "2/2 — Installing everything else (no more input needed — safe to walk away)" setup_locale install_packages install_node install_go install_rust install_docker - setup_git - setup_github - install_claude_code - SYNC_FAILED=false if $USE_SYNC; then setup_config_sync; else setup_local_config; fi enable_remote_control install_webapp_testing write_project_claude_md setup_shell setup_auto_update + setup_remote_control_service final_cleanup + health_check print_summary }