#!/bin/zsh # # dotfiles installer | https://github.com/fernandoataoldotcom/github_dotfiles # Served at https://setup.decentturing.com (Cloudflare Pages, served as text/plain) # # Install: curl -f https://setup.decentturing.com|zsh # (https:// matters: plain http only 301-redirects here, and that first # hop is unauthenticated. -f makes curl fail instead of piping an error # page into zsh. Nothing else is needed: the https root answers 200 # directly, and this script only runs once main() on its LAST line has # been read, so a truncated download cannot half-run.) # Read it: open the URL in a browser. Cloudflare Pages serves this file as # text/plain, so the browser shows it as raw monospace text and # `curl ... | zsh` runs it as a script. No HTML tricks needed. # # Linux/Debian only -- used mostly over SSH / remote VMs / devcontainers. # Installs Oh-My-Zsh + zsh plugins + config files (zsh, tmux, vim, htop, claude) # and the Claude Code CLI (official native installer, 'stable' channel). With a # terminal attached it then signs in to Claude and GitHub, clones the private Claude # memory repo and drops you into the new shell. # Does NOT install system packages -- it only tells you which of # tmux/gh/jq/kubectl/vim are missing (see README). Safe to re-run; backs up anything it would overwrite. set -euo pipefail # Which version of this repo to install. Default: the newest tag, no questions. # That tag's OWN installer runs (not just its config files), so a tag pins the whole # install. Escape hatches, e.g. to roll back to a known-good tag: # DOTFILES_REF= / `zsh -s -- ` install that tag (or main) # DOTFILES_PICK=1 choose from a menu (needs a terminal) # REPO_RAW= explicit config source, runs THIS installer # (e.g. file:///repo for tests) REPO_SLUG="${REPO_SLUG:-fernandoataoldotcom/github_dotfiles}" RAW_BASE="${DOTFILES_RAW_BASE:-https://raw.githubusercontent.com}" # overridable for tests REPO_RAW="${REPO_RAW:-}" DOTFILES_REF="${DOTFILES_REF:-}" # --- Pinned dependency versions (resolved 2026-06-26) ------------------------ # Pinned for reproducible, supply-chain-safe installs. Bump deliberately. # OMZ/vim-plug use commit SHAs (content-addressed). zsh plugins are cloned by tag # but the resolved commit is VERIFIED against a pinned SHA (tags are mutable). OMZ_REF="d2379b2701df66a36b217a7707e77f8029a99814" # ohmyzsh/ohmyzsh VIM_PLUG_REF="88e31471818e9a29a8a20a0ee61360cfd7bdc1cd" # junegunn/vim-plug ZSH_SYNTAX_HIGHLIGHTING_REF="0.8.0"; ZSH_SYNTAX_HIGHLIGHTING_SHA="db085e4661f6aafd24e5acb5b2e17e4dd5dddf3e" ZSH_AUTOSUGGESTIONS_REF="v0.7.1"; ZSH_AUTOSUGGESTIONS_SHA="e52ee8ca55bcc56a17c828767a3f98f22a68d4eb" YOU_SHOULD_USE_REF="1.11.1"; YOU_SHOULD_USE_SHA="ff371d6a11b653e1fa8dda4e61c896c78de26bfa" # Claude Code release channel for a fresh install: stable | latest | X.Y.Z. # 'stable' trails 'latest' by about a week and skips releases with regressions; # claude-settings.json keeps auto-updates on the same channel afterwards. CLAUDE_CHANNEL="${CLAUDE_CHANNEL:-stable}" # PRIVATE repo holding Claude Code's memory, cloned to ~/.claude-memory so it # survives throwaway VMs (see claude-memory-sync). Never make this repo public. MEMORY_REPO="${MEMORY_REPO:-fernandoataoldotcom/claude-memory}" MEMORY_DIR="${HOME}/.claude-memory" TS="$(date +%Y%m%d-%H%M%S)" BACKUP_DIR="${HOME}/.dotfiles-backup-${TS}" log() { print -r -- "==> $*"; } warn() { print -r -- "WARN: $*" >&2; } die() { print -r -- "ERROR: $*" >&2; exit 1; } # A real controlling terminal? Test by *opening* /dev/tty -- `[[ -r /dev/tty ]]` # only checks the device's permission bits (always rw), not whether a tty exists. has_tty() { { true /dev/null; } os_guard() { [[ "$(uname -s)" == "Linux" ]] || die "Linux (Debian/Ubuntu) only. Detected $(uname -s). Aborting." if [[ -r /etc/os-release ]]; then local id idlike id="$(. /etc/os-release 2>/dev/null; print -r -- "${ID:-}")" idlike="$(. /etc/os-release 2>/dev/null; print -r -- "${ID_LIKE:-}")" [[ "$id" == debian || "$id" == ubuntu || "$idlike" == *debian* ]] \ || warn "Non Debian/Ubuntu Linux (ID='$id'); continuing but untested." fi command -v git >/dev/null 2>&1 || die "git is required. Install it and re-run." command -v curl >/dev/null 2>&1 || die "curl is required. Install it and re-run." } # This repo's tags, newest first (empty if none / offline). list_tags() { git ls-remote --tags --refs "https://github.com/${REPO_SLUG}" 2>/dev/null \ | awk -F/ '{print $NF}' | sort -rV } # Interactive picker (DOTFILES_PICK=1) -- needs /dev/tty. The menu is written # to and read from /dev/tty (works even though the piped script is stdin); only the # chosen ref is printed to stdout so it can be captured. pick_tag() { local tags; tags="$(list_tags)" if [[ -z "$tags" ]]; then warn "No tags on ${REPO_SLUG} yet -- cut one (git tag v1 && git push --tags) to enable version selection. Installing from 'main'." print -r -- "refs/heads/main"; return 0 fi local -a arr=("${(@f)tags}") arr+=("refs/heads/main") # offer 'latest main' as a choice too { print -r -- "" print -r -- "Which version of ${REPO_SLUG} do you want to install?" local i label for (( i = 1; i <= ${#arr[@]}; i++ )); do label="${arr[$i]}"; [[ "$label" == "refs/heads/main" ]] && label="main (latest, unpinned)" print -r -- " ${i}) ${label}" done print -rn -- "Enter a number [default 1 = ${arr[1]}]: " } >/dev/tty local choice="" read -r choice ]] && (( choice >= 1 && choice <= ${#arr[@]} )); then print -r -- "${arr[$choice]}" else print -r -- "$choice" # treat input as a literal tag/ref name fi } # Resolve the ref to install and REPO_RAW, then hand over to that ref's installer. select_repo_ref() { if [[ -n "$REPO_RAW" ]]; then log "Using REPO_RAW override: ${REPO_RAW}"; return 0 fi local ref="${DOTFILES_REF:-${1:-}}" if [[ -z "$ref" && -n "${DOTFILES_PICK:-}" ]]; then has_tty || die "DOTFILES_PICK needs a terminal; set DOTFILES_REF= instead." ref="$(pick_tag)" fi if [[ -z "$ref" ]]; then local tags; tags="$(list_tags)" if [[ -n "$tags" ]]; then local -a arr=("${(@f)tags}"); ref="${arr[1]}"; else ref="refs/heads/main"; fi fi REPO_RAW="${RAW_BASE}/${REPO_SLUG}/${ref}" log "Installing ${REPO_SLUG} @ ${ref}" # The served installer is always main's. For a tag, run that tag's installer so # rolling back to an old tag also rolls back a broken installer. It gets REPO_RAW, # which every version honours, so it neither prompts nor hands over again. if [[ "$ref" != refs/heads/main && -z "${DOTFILES_RESOLVE_ONLY:-}" ]]; then local script; script="$(curl -fsSL "${REPO_RAW}/install")" \ || die "Could not download the installer for ${ref} (does the tag exist?)." REPO_RAW="$REPO_RAW" DOTFILES_REF="$ref" exec zsh -c "$script" install fi } backup_one() { # copy (not move) existing target into timestamped backup local t="$1"; [[ -e "$t" || -L "$t" ]] || return 0 mkdir -p "$BACKUP_DIR" local dest="${BACKUP_DIR}/${t#$HOME/}" mkdir -p "${dest:h}"; cp -a "$t" "$dest"; log " backed up: $t" } backup_existing() { log "Backing up any existing files to ${BACKUP_DIR}" for f in ~/.tmux.conf ~/.vimrc ~/.config/htop/htoprc ~/.zshrc ~/.claude/settings.json ~/.claude/CLAUDE.md \ ~/.local/bin/claude-memory-sync \ ~/.oh-my-zsh/custom/fernandoataoldotcom-functions.zsh; do backup_one "$f" done } install_omz() { # pinned clone; non-interactive; we ship our own .zshrc if [[ -d ~/.oh-my-zsh ]]; then log "Oh-My-Zsh already present; skipping." else # Clone + checkout a pinned commit instead of piping the upstream installer # to sh (reproducible, and no remote script execution). log "Installing Oh-My-Zsh (pinned @ ${OMZ_REF})" git -c advice.detachedHead=false clone --quiet https://github.com/ohmyzsh/ohmyzsh ~/.oh-my-zsh git -C ~/.oh-my-zsh -c advice.detachedHead=false checkout --quiet "$OMZ_REF" fi } fetch() { local u="$1" d="$2"; mkdir -p "${d:h}"; curl -fsSL "$u" -o "$d" || return; log " fetched: $d"; } fetch_configs() { log "Fetching configs and custom scripts" mkdir -p ~/.oh-my-zsh/custom fetch "${REPO_RAW}/zshrc" ~/.zshrc fetch "${REPO_RAW}/fernandoataoldotcom-functions.zsh" ~/.oh-my-zsh/custom/fernandoataoldotcom-functions.zsh fetch "${REPO_RAW}/tmux.conf" ~/.tmux.conf fetch "${REPO_RAW}/htoprc" ~/.config/htop/htoprc fetch "${REPO_RAW}/vimrc" ~/.vimrc fetch "${REPO_RAW}/claude-settings.json" ~/.claude/settings.json # Older tags do not have this file; picking one must not abort the install. fetch "${REPO_RAW}/claude-CLAUDE.md" ~/.claude/CLAUDE.md \ || warn "claude-CLAUDE.md is not in this ref; no personal CLAUDE.md installed." # Tags before v0.2.0 do not have this file; picking one must not abort the install. if fetch "${REPO_RAW}/claude-memory-sync" ~/.local/bin/claude-memory-sync; then chmod +x ~/.local/bin/claude-memory-sync else warn "claude-memory-sync is not in this ref; Claude memory sync is unavailable with it." fi fetch "https://raw.githubusercontent.com/junegunn/vim-plug/${VIM_PLUG_REF}/plug.vim" ~/.vim/autoload/plug.vim } # VS Code server (Remote-SSH, devcontainers, `code serve-web`) machine settings. Under # serve-web, user settings live in the browser and are lost with each VM, so the # settings we want everywhere go here. Merged with jq, never overwritten: the VM # image may have put its own keys in this file. VSCODE_MACHINE_SETTINGS="${HOME}/.vscode-server/data/Machine/settings.json" merge_vscode_settings() { local ours; ours="$(mktemp)" if ! curl -fsSL "${REPO_RAW}/vscode-machine-settings.json" -o "$ours" 2>/dev/null; then warn "vscode-machine-settings.json is not in this ref; VS Code settings unchanged." rm -f "$ours"; return 0 fi if ! command -v jq >/dev/null 2>&1; then warn "jq is missing; VS Code settings unchanged." rm -f "$ours"; return 0 fi local t="$VSCODE_MACHINE_SETTINGS" merged; merged="$(mktemp)" if [[ -s "$t" ]]; then # jq can't read JSONC (comments, trailing commas): leave such a file alone. if jq -s '.[0] * .[1]' "$t" "$ours" >"$merged" 2>/dev/null; then if ! cmp -s "$merged" "$t"; then backup_one "$t"; cat "$merged" >"$t"; log " merged: $t" fi else warn "$t is not plain JSON; add these by hand: $(jq -c . "$ours")" fi else mkdir -p "${t:h}"; jq . "$ours" >"$t"; log " created: $t" fi rm -f "$ours" "$merged" } clone_plugin() { # idempotent; tolerates already-installed under set -e local url="$1" ref="$2" sha="$3" dir="$4" [[ -d "$dir/.git" ]] && { log " plugin present: ${dir:t}"; return 0; } [[ -e "$dir" ]] && { backup_one "$dir"; rm -rf "$dir"; } git -c advice.detachedHead=false clone --depth 1 --branch "$ref" "$url" "$dir" # Verify the tag resolved to the pinned commit (detects a moved/tampered tag). local got; got="$(git -C "$dir" rev-parse HEAD)" [[ "$got" == "$sha" ]] || die "Plugin ${dir:t}: tag '$ref' resolved to $got, expected $sha (moved/tampered tag?)." log " cloned: ${dir:t}@${ref} (verified ${sha})" } install_zsh_plugins() { log "Installing zsh plugins" local p=~/.oh-my-zsh/custom/plugins; mkdir -p "$p" clone_plugin https://github.com/zsh-users/zsh-syntax-highlighting "$ZSH_SYNTAX_HIGHLIGHTING_REF" "$ZSH_SYNTAX_HIGHLIGHTING_SHA" "$p/zsh-syntax-highlighting" clone_plugin https://github.com/zsh-users/zsh-autosuggestions "$ZSH_AUTOSUGGESTIONS_REF" "$ZSH_AUTOSUGGESTIONS_SHA" "$p/zsh-autosuggestions" clone_plugin https://github.com/MichaelAquilina/zsh-you-should-use.git "$YOU_SHOULD_USE_REF" "$YOU_SHOULD_USE_SHA" "$p/you-should-use" } install_vim_plugins() { if command -v vim >/dev/null 2>&1; then log "Installing vim plugins (vim +PlugInstall +qall)" vim -E -s -u ~/.vimrc +PlugInstall +qall /dev/null 2>&1 \ || warn "vim +PlugInstall returned non-zero (often harmless); re-run 'vim +PlugInstall +qall' if needed." else warn "vim not found; skipping. After installing vim, run: vim +PlugInstall +qall" fi } # Install the Claude Code CLI so the box is usable right after this script. # If already present (e.g. baked into the image) it is moved to CLAUDE_CHANNEL with # `claude install`; auto-update then follows the channel set in claude-settings.json. # Uses Anthropic's official native installer, which needs no # Node, verifies the binary against the release manifest's SHA256 and installs # user-local to ~/.local/bin (no sudo). Never aborts the run. install_claude_code() { # The native installer drops the binary in ~/.local/bin, which our shipped .zshrc # adds to PATH but THIS script's shell usually does not. Put it on PATH up front so # both the "already installed" check and the post-install check see it; otherwise a # successful install gets misreported as a failure. export PATH="${HOME}/.local/bin:${PATH}" hash -r 2>/dev/null || true if command -v claude >/dev/null 2>&1; then # Usually baked into the base image at whatever was current when the image was # built. Move it to the channel now instead of waiting for the background updater. log "Claude Code already installed ($(claude --version 2>/dev/null || print -r -- present)); moving to channel: ${CLAUDE_CHANNEL}" claude install "$CLAUDE_CHANNEL" /dev/null || true log " Claude Code now at $(claude --version 2>/dev/null || print -r -- unknown)" return 0 fi log "Installing Claude Code CLI (channel: ${CLAUDE_CHANNEL})" curl -fsSL https://claude.ai/install.sh | bash -s -- "$CLAUDE_CHANNEL" \ || warn "Claude Code native install failed; see https://docs.anthropic.com/en/docs/claude-code" hash -r 2>/dev/null || true # forget cached lookups so a fresh binary is found if command -v claude >/dev/null 2>&1; then log " Claude Code installed ($(claude --version 2>/dev/null || print -r -- present))." else warn "Claude Code not on PATH yet; start a fresh shell (ensure ~/.local/bin is on PATH), then run 'claude' to authenticate." fi } # Sign in to Claude as part of the install. Claude's login prints a URL that ends on # a page showing a code to paste, and ALSO asks $BROWSER to open a second URL that # redirects to localhost on this VM -- which a browser on another machine cannot # reach. BROWSER is unset for the login so only the paste-a-code URL is offered. # Never aborts the run. claude_login() { command -v claude >/dev/null 2>&1 || return 0 if claude auth status 2>/dev/null | grep -q '"loggedIn": *true'; then log "Claude Code already signed in." return 0 fi if ! has_tty; then warn "No TTY; skipping Claude sign-in. Run 'claude-login' later." return 0 fi log "Signing in to Claude Code (open the URL printed below, then paste the code it shows)" env -u BROWSER claude auth login ${MEMORY_DIR}" if ! gh repo clone "$MEMORY_REPO" "$MEMORY_DIR" -- --quiet 2>/dev/null; then rm -rf "$MEMORY_DIR" if [[ -n "$aside" ]]; then mv "$aside" "$MEMORY_DIR"; fi warn "Could not clone ${MEMORY_REPO}; Claude memory stays local to this VM." warn "Create it once with: gh repo create ${MEMORY_REPO} --private (then re-run this installer)" return 0 fi if [[ -n "$aside" && -d "${aside}/memory" ]]; then mkdir -p "${MEMORY_DIR}/memory" cp -an "${aside}/memory/." "${MEMORY_DIR}/memory/" || true log " merged VM-local memories from ${aside}" fi fi mkdir -p "${MEMORY_DIR}/memory" # MEMORY.md is an append-mostly index every VM touches: union-merge it so two # VMs adding lines at once do not conflict. if [[ ! -f "${MEMORY_DIR}/.gitattributes" ]]; then print -r -- "MEMORY.md merge=union" > "${MEMORY_DIR}/.gitattributes" fi local vis; vis="$(gh repo view "$MEMORY_REPO" --json visibility --jq .visibility 2>/dev/null || true)" if [[ -n "$vis" && "$vis" != PRIVATE ]]; then warn "${MEMORY_REPO} is ${vis}, not PRIVATE -- memories can hold project details. Fix: gh repo edit ${MEMORY_REPO} --visibility private" fi return 0 } # GitHub login + everything that depends on it, so one command sets up the box. # Uses the base image's `yolo` helper when present (browser device flow), else a # plain `gh auth login`. Never aborts the run. post_auth() { if ! command -v gh >/dev/null 2>&1; then warn "gh not found; skipping GitHub login and Claude memory setup." return 0 fi if ! gh auth status >/dev/null 2>&1; then if ! has_tty; then warn "No TTY; skipping GitHub login. Log in later ('yolo' or 'gh auth login') and re-run for Claude memory sync." return 0 fi log "Logging in to GitHub" if command -v yolo >/dev/null 2>&1; then yolo /dev/null 2>&1; then warn "Not logged in to GitHub; skipping Claude memory setup." return 0 fi # A GitHub profile without a display name makes `gh api user | jq .name` yield # the literal string "null"; fall back to the login. local name; name="$(git config --global user.name 2>/dev/null || true)" if [[ -z "$name" || "$name" == null ]]; then name="$(gh api user --jq '.name // .login' 2>/dev/null || true)" if [[ -n "$name" && "$name" != null ]]; then git config --global user.name "$name" log " git user.name set to '${name}'" fi fi setup_memory } # Tools the shipped configs expect. Silent when they are all there (the usual images # ship them), so a line from this means the VM itself is missing something. check_deps() { local -a missing=(); local c for c in tmux gh jq kubectl vim; do command -v "$c" >/dev/null 2>&1 || missing+=("$c") done if command -v tmux >/dev/null 2>&1 && [[ "$(tmux -V 2>/dev/null)" =~ '([0-9]+\.[0-9]+)' ]]; then autoload -Uz is-at-least is-at-least 3.2 "${match[1]}" || missing+=("tmux>=3.2 (found ${match[1]})") fi if (( ${#missing} )); then warn "Missing tools on this VM (install them yourself): ${(j:, :)missing}" fi return 0 } main() { os_guard select_repo_ref "$@" # Dry run: print the resolved source and stop (no changes made). [[ -n "${DOTFILES_RESOLVE_ONLY:-}" ]] && { print -r -- "REPO_RAW=${REPO_RAW}"; return 0; } backup_existing install_omz install_zsh_plugins fetch_configs merge_vscode_settings install_vim_plugins install_claude_code claude_login post_auth log "Done. Backups (if any): ${BACKUP_DIR}" print -r -- "" check_deps print -r -- "Claude Code: settings are at ~/.claude/settings.json." print -r -- "Reload tmux with 'tmux source-file ~/.tmux.conf'." # Hand over to the freshly configured shell (DOTFILES_NO_EXEC=1 to opt out). This # shell runs inside the one that launched the installer; `exit` returns to it. # stdin must be the real terminal device, taken from stdout: with `curl ...|zsh` # stdin is the pipe, and a shell whose stdin is the /dev/tty alias breaks tmux # ("open terminal failed: can't use /dev/tty"). if [[ -t 1 && -z "${DOTFILES_NO_EXEC:-}" ]]; then exec zsh -l <&1 fi print -r -- "Next: start a fresh shell ('exec zsh')." } main "$@"