Initial commit: linux-provision repo

Distribution-agnostic provisioning script that sets up a new Linux machine
(Detected via lib/distro.sh - supports Debian/Ubuntu/Pop and Fedora families).

13 stages covering:
- System packages, external repos, toolchains (nvm, uv, Python)
- Shell config (zsh, oh-my-zsh, p10k), git, SSH
- Custom uv tools from ~40 git repos
- Desktop config (keybindings, hotkeys, ghostty, fonts)
- Docker, system tweaks, browser/app installs
- Custom systemd user services (porridge, swayidle, mempi-sync, etc.)
- API keys loaded from Bitwarden at shell startup
This commit is contained in:
2026-06-05 21:21:46 +10:00
commit 180c5838ea
36 changed files with 4176 additions and 0 deletions

21
.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
# Secrets — these should never be committed
.zshrc.local
.env
*.pem
# OS files
.DS_Store
Thumbs.db
# Editor
*.swp
*.swo
*~
# Python
__pycache__/
*.py[cod]
.venv/
# Node
node_modules/

106
README.md Normal file
View File

@@ -0,0 +1,106 @@
# Linux Machine Provisioning
**Purpose:** Rapidly set up a new Fedora Linux machine with Julian's toolchain,
config, and customisations. Generated from audit of Pop!_OS "thinkpad" machine.
## Supported Distributions
Auto-detected — no manual config needed:
| Family | Distros | Package Manager |
|--------|---------|-----------------|
| **Debian** | Ubuntu, Pop!_OS, Debian, Linux Mint | `apt` |
| **Fedora** | Fedora Workstation, RHEL, CentOS | `dnf` |
Detection is in `lib/distro.sh`. It sets variables like `$PKG_INSTALL`,
`$PKG_UPDATE`, `$GRUB_UPDATE` etc. that all stage scripts use.
## Quick Start
```bash
# Clone this repo on the new machine
git clone git@github.com:julianprester/linux-provision.git ~/linux-provision
cd ~/linux-provision
# Review and edit config/shell/zshrc.local with your API keys
cp config/shell/zshrc.local.example ~/.zshrc.local
# Edit ~/.zshrc.local with real API keys
# Run the full provisioning (will prompt for sudo)
bash provision.sh --all
# Or run individual stages
bash provision.sh --stage 03-toolchains
bash provision.sh --stage 06-uv-projects
# Or source it for interactive use
source provision.sh --interactive
```
## Structure
```
linux-provision/
├── README.md # This file
├── provision.sh # Master orchestrator — run with --all or --stage N
├── stages/ # Modular stage scripts, sourced by provision.sh
│ ├── 00-envcheck.sh # OS/sudo/environment checks
│ ├── 01-repos.sh # DNF repos (RPM Fusion, COPR, Microsoft, etc.)
│ ├── 02-packages.sh # System packages via DNF
│ ├── 03-toolchains.sh # nvm, Node, uv, Python
│ ├── 04-shell.sh # zsh, oh-my-zsh, powerlevel10k, configs
│ ├── 05-git.sh # Git config, SSH key setup
│ ├── 06-uv-projects.sh # Clone + install Julian's uv tools from ~/Development
│ ├── 07-scripts.sh # ~/.local/bin (bw, zoom, env, etc.)
│ ├── 08-systemd.sh # User systemd services (porridge, swayidle, etc.)
│ ├── 09-desktop.sh # Keybindings, hotkeys, ghostty, fonts
│ ├── 10-docker.sh # Docker CE setup
│ ├── 11-tweaks.sh # sysctl, kernel params, TLP/powertop, modprobe
│ └── 12-other-apps.sh # Chrome, Signal, Zotero
├── config/ # Dotfiles and config files (installed by stages)
│ ├── git/gitconfig
│ ├── shell/{zshrc,zshrc.local.example,p10k.zsh}
│ ├── scripts/{bw-load-ssh.sh,idle-battery-suspend.sh,zoom.sh,env.sh}
│ ├── systemd/{porridge.service,...}
│ ├── sysctl/99-custom.conf
│ └── modprobe/{system76-power.conf,pop-default-settings-dirty-frag.conf}
├── etc/ # System-level configs (copied to /etc)
└── TODO.md # Post-provisioning manual steps
```
## Stages Overview
| # | Stage | What it does |
| --- | --- | --- |
| 00 | envcheck | Verify Fedora, sudo access, directory setup |
| 01 | repos | RPM Fusion free/nonfree, COPRs, Microsoft, Docker, Google, Signal, Tailscale |
| 02 | packages | Install all system packages (distro-mapped names) |
| 03 | toolchains | Install nvm + Node LTS, uv, Python |
| 04 | shell | Install zsh, oh-my-zsh, p10k, deploy .zshrc, .p10k.zsh |
| 05 | git | Deploy .gitconfig, generate SSH key |
| 06 | uv-projects | Clone all Julian's Python tool repos from GitHub, uv install |
| 07 | scripts | Deploy ~/.local/bin scripts |
| 08 | systemd | Deploy and enable user systemd services |
| 09 | desktop | Configure keybindings, hotkeys, ghostty, fonts |
| 10 | docker | Install Docker CE, add user to docker group |
| 11 | tweaks | sysctl, kernel cmdline, TLP/powertop, modprobe blacklists |
| 12 | other-apps | Google Chrome, Signal, Zotero |
## Post-Install Manual Steps
See `TODO.md` for things that can't be automated: restoring SSH keys
from Bitwarden, configuring Tailscale, importing GPG keys, etc.
## Design Notes
- **Distribution-agnostic** — detects Debian/Ubuntu/Pop vs Fedora via
`lib/distro.sh`. Package manager commands, repo config, and package
names adapt automatically.
- **Idempotent** — safe to run multiple times. Stages check for existing
installations before repeating work.
- **Secrets out of repo** — API keys live in `~/.zshrc.local` (gitignored).
The repo ships `zshrc.local.example` with placeholder values.
- **One stage per concern** — comment out stages you don't need in
`provision.sh` or pass `--stage` individually.
- **Hardware-specific quirks commented out** — AMD GPU kernel params,
WiFi workarounds, etc. are documented but disabled by default.

173
TODO.md Normal file
View File

@@ -0,0 +1,173 @@
# Post-Provisioning TODO
Things that can't be fully automated (require manual setup, credentials,
or hardware-specific configuration).
## 1. SSH Keys & GitHub
- [ ] **Load SSH keys from Bitwarden** or generate a new key:
```bash
# Option A: Generate fresh key
ssh-keygen -t ed25519 -C "hi@julianprester.com"
# Option B: Set up Bitwarden+SSH loading
bw login
bw unlock --raw > ~/.config/Bitwarden\ CLI/.session
chmod 600 ~/.config/Bitwarden\ CLI/.session
bw-load-ssh.sh
# Option C: Copy keys from old machine
# scp old-machine:~/.ssh/id_ed25519* ~/.ssh/
```
- [ ] **Add SSH public key to GitHub**: https://github.com/settings/keys
- [ ] Clone this repo and remaining repos:
```bash
git clone git@github.com:julianprester/linux-provision.git
```
## 2. Bitwarden & Environment Variables
API keys are loaded directly in `.zshrc` via `bw` + `jq`. No separate script.
- [ ] **Run `bw login`** to authenticate with Bitwarden
- [ ] **Unlock vault and save session:**
```bash
bw unlock --raw > ~/.config/Bitwarden\ CLI/.session
chmod 600 ~/.config/Bitwarden\ CLI/.session
```
- [ ] **Create a Bitwarden item** named "Environment" (type: Secure Note)
with custom fields for each API key:
| Field Name | Type | Example Value |
|---|---|---|
| `GROQ_API_KEY` | Hidden | `gsk_your_key` |
| `ANTHROPIC_API_KEY` | Hidden | `sk-ant-your-key` |
| `GOOGLE_API_KEY` | Hidden | `AIza_your_key` |
| `CANVAS_API_KEY` | Hidden | `3156~your_key` |
| `NC_PASSWORD` | Hidden | `your_nextcloud_password` |
| ... (22 vars total — see `config/shell/zshrc.local.example` for the full list) |
- [ ] **Open a new shell** — `.zshrc` exports them automatically
- [ ] **Verify:** `echo $GROQ_API_KEY` (should show your key)
If you prefer a plain file instead of Bitwarden:
- [ ] Edit `~/.zshrc.local` with your API keys (template in `config/shell/zshrc.local.example`)
- [ ] Uncomment the alternate `source ~/.zshrc.local` line in the deployed `.zshrc`
If you prefer a plain file instead of Bitwarden:
- [ ] Edit `~/.zshrc.local` with your API keys (template in `config/shell/zshrc.local.example`)
- [ ] Uncomment the `source ~/.zshrc.local` line in your deployed `.zshrc`
## 3. Tailscale
- [ ] Authenticate Tailscale:
```bash
sudo tailscale up
```
- [ ] Verify connection: `tailscale status`
- [ ] Note your Tailscale IP for services (Actual Budget, Nextcloud, etc.)
## 4. Nextcloud
- [ ] Install Nextcloud Desktop Client (Flatpak or RPM)
- [ ] Connect to `https://nc.julianprester.com`
- [ ] Select sync folders (especially `Nextcloud/3_bibliography/`)
- [ ] Update `PandocCiter.DefaultBib` in VS Code settings if bib path changes
## 5. Actual Budget
- [ ] Verify connection: `actualpy accounts`
- [ ] Update URL/password in `~/.config/actualpy/config.yaml`
## 6. Docker & WinBoat
- [ ] Log out and back in for docker group to take effect
- [ ] Pull WinBoat image: `docker pull ghcr.io/dockur/windows:5.14`
- [ ] Set up `~/.winboat/docker-compose.yml` (see reference in repo notes)
- [ ] Pull grobid: `docker pull grobid/grobid`
- [ ] Run grobid: `docker run -d -p 8070:8070 grobid/grobid`
## 7. Zotero
- [ ] Install Zotero (Flatpak or tarball from zotero.org)
- [ ] Sign in to sync library
- [ ] Install Zotero browser connector
- [ ] Set ZOTERO_KEY env var in `~/.zshrc.local`
## 8. GNOME Keybindings (if using GNOME)
- [ ] Verify custom shortcuts were applied:
```bash
gsettings list-recursively org.gnome.settings-daemon.plugins.media-keys.custom-keybinding
```
- [ ] Or add them manually via Settings → Keyboard → Keyboard Shortcuts
## 9. Fonts
- [ ] If Nerd Font download failed, install manually:
- Download from https://www.nerdfonts.com/font-downloads
- MesloLGS NF (recommended for Powerlevel10k)
- Extract to `~/.local/share/fonts/` and run `fc-cache -fv`
## 10. Ghostty
- [ ] Verify Ghostty runs and fonts look correct (nerd font icons in prompt)
- [ ] If not, set `font-family = "MesloLGS NF"` in `~/.config/ghostty/config`
## 11. VS Code
- [ ] Open VS Code and verify extensions are installed
- [ ] Sign in to GitHub → Settings → Sync (if you use Settings Sync)
- [ ] Verify PandocCiter path to bibliography
## 12. Solaar (Logitech Peripherals)
- [ ] Open Solaar from applications menu
- [ ] Pair your Logitech receiver or connect via Bluetooth
- [ ] The config will auto-save to `~/.config/solaar/config.yaml`
## 13. Printer / Scanning
- [ ] If using a printer, add via Settings → Printers
- [ ] If using a scanner, install `simple-scan`:
```bash
sudo dnf install simple-scan
```
## 14. Reboot to Apply Kernel Changes
- [ ] `sudo reboot` — required for:
- GRUB kernel cmdline parameters (if uncommented)
- sysctl settings (most apply at runtime, but reboot ensures)
- Docker group membership
- Desktop environment changes
## 15. Verify Everything
Run a quick sanity check after reboot:
```bash
# Development tools
node --version
npm --version
python3 --version
uv --version
git --version
# Docker
docker run --rm hello-world
# Shell
zsh --version
echo $SHELL
# Services
systemctl --user status porridge.service 2>/dev/null | head -5
# Network
tailscale status
ping -c 1 google.com
# Config files exist
ls -la ~/.zshrc ~/.zshrc.local ~/.gitconfig ~/.p10k.zsh ~/.local/bin/
```

View File

@@ -0,0 +1,67 @@
// =============================================================================
// COSMIC Desktop — Compositor Settings (RON format)
// File location: ~/.config/cosmic/com.system76.CosmicComp/v1/...
// FOR REFERENCE ONLY — adapt to your target DE.
// =============================================================================
// ---- XKB keyboard config ----
// Location: .../v1/xkb_config
(
rules: "",
model: "",
layout: "us",
variant: "",
options: Some("compose:ralt"), // Right Alt = Compose key
repeat_delay: 600,
repeat_rate: 25,
)
// ---- Touchpad ----
// Location: .../v1/input_touchpad
(
state: Enabled,
acceleration: Some((profile: Some(Flat), speed: 0.3213816999010042)),
click_method: Some(Clickfinger),
scroll_config: Some((
method: Some(TwoFinger),
natural_scroll: Some(true),
scroll_button: None,
scroll_factor: None,
)),
tap_config: Some((
enabled: true,
button_map: Some(LeftRightMiddle),
drag: true,
drag_lock: false,
)),
)
// ---- Mouse ----
// Location: .../v1/input_default
(
state: Enabled,
acceleration: Some((profile: Some(Flat), speed: 0.4628044123886297)),
scroll_config: Some((
method: None,
natural_scroll: Some(false),
scroll_button: None,
scroll_factor: Some(1.0),
)),
)
// ---- Workspaces ----
// Location: .../v1/workspaces
(
workspace_mode: OutputBound,
workspace_layout: Horizontal,
action_on_typing: None,
workspace_wraparound: true,
)
// ---- Autotile ----
// Location: .../v1/autotile
false
// ---- Autotile behavior ----
// Location: .../v1/autotile_behavior
PerWorkspace

View File

@@ -0,0 +1,88 @@
// =============================================================================
// COSMIC Desktop — Custom Shortcuts (RON format)
// File location: ~/.config/cosmic/com.system76.CosmicSettings.Shortcuts/v1/custom
// This is the keybinding config from the Pop!_OS machine.
// =============================================================================
// COSMIC is System76's Rust-based desktop environment (not shipped on Fedora).
// This file is FOR REFERENCE only — translate to your target DE:
//
// GNOME: Use gsettings (see stage 09-desktop.sh)
// KDE: Use kwriteconfig5 or System Settings
// Sway/Hypr: Use sway config or hyprland.conf
// swhkd: Use ~/.config/swhkd/swhkdrc
//
// The hotkey scripts (google.sh, scholar.sh, etc.) work on any DE/WM that
// supports Wayland. They use wl-clipboard, wofi, and xdg-open.
// =============================================================================
{
// ---- Text selection searches ----
// Select text anywhere, press shortcut → action with selected text
(
modifiers: [ Ctrl, Alt ],
key: "e",
description: Some("Emoji"),
): Spawn("/home/julian/Development/hotkeys/emoji.sh"),
(
modifiers: [ Ctrl, Alt ],
key: "o",
description: Some("PDF"),
): Spawn("/home/julian/Development/hotkeys/pdf.sh"),
(
modifiers: [ Ctrl, Alt ],
key: "a",
description: Some("Hotstrings"),
): Spawn("/home/julian/Development/hotkeys/hotstrings.sh"),
(
modifiers: [ Ctrl, Alt ],
key: "d",
description: Some("Dictionary"),
): Spawn("/home/julian/Development/hotkeys/dictionary.sh"),
(
modifiers: [ Ctrl, Alt ],
key: "g",
description: Some("Google"),
): Spawn("/home/julian/Development/hotkeys/google.sh"),
(
modifiers: [ Ctrl, Alt ],
key: "s",
description: Some("Scholar"),
): Spawn("/home/julian/Development/hotkeys/scholar.sh"),
// ---- Window management ----
(
modifiers: [ Super ],
key: "n",
): Minimize,
(
modifiers: [ Super ],
key: "Escape",
): Close,
(
modifiers: [ Super, Shift ],
key: "Escape",
): Disable,
(
modifiers: [ Super ],
key: "q",
): Disable,
// ---- System actions ----
(
modifiers: [ Super ],
key: "l",
): System(LockScreen),
(
modifiers: [ Super, Shift ],
key: "l",
): System(LogOut),
}

11
config/ghostty/config Normal file
View File

@@ -0,0 +1,11 @@
# =============================================================================
# Ghostty terminal configuration
# =============================================================================
# This is a minimal config. Ghostty ships with sensible defaults.
# Run `ghostty +show-config --default --docs` for all options.
# Terminfo for SSH — fixes terminal issues on remote hosts
shell-integration-features = ssh-terminfo,ssh-env
# Ignore super+shift+s (conflicts with Scribe/porridge dictation)
keybind = super+shift+s=ignore

49
config/git/gitconfig Normal file
View File

@@ -0,0 +1,49 @@
[user]
name = Your Name
email = your.email@example.com
signingkey = ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... your-public-key-comment
[init]
defaultBranch = main
[credential "https://git.julianprester.com"]
provider = generic
[core]
compression = 0
excludesfile = ~/.gitignore
editor = nano
[column]
ui = auto
[branch]
sort = -committerdate
[tag]
sort = version:refname
[diff]
algorithm = histogram
colorMoved = plain
mnemonicPrefix = true
renames = true
[push]
default = simple
autoSetupRemote = true
followTags = true
[pull]
rebase = true
[fetch]
prune = true
pruneTags = true
all = true
[help]
autocorrect = prompt
[commit]
verbose = true
gpgsign = true
[rerere]
enabled = true
autoupdate = true
[rebase]
autoSquash = true
autoStash = true
updateRefs = true
[merge]
conflictstyle = zdiff3
[gpg]
format = ssh

View File

@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# ===========================================================================
# bw-load-ssh.sh — Load SSH keys from Bitwarden into ssh-agent
# Reads SSH key items from Bitwarden vault and loads private keys into
# the running ssh-agent.
#
# Dependencies: bw (Bitwarden CLI), jq, ssh-agent running
# Usage:
# 1. First, authenticate: bw login
# 2. Unlock and save session: bw unlock --raw > ~/.config/Bitwarden\ CLI/.session
# 3. Run: ./bw-load-ssh.sh
# Or run automatically via bw-ssh-keys.service (systemd user service).
# ===========================================================================
set -euo pipefail
CONFIG_DIR="${HOME}/.config/Bitwarden CLI"
SESSION_FILE="${CONFIG_DIR}/.session"
# Check session file exists
if [ ! -f "$SESSION_FILE" ]; then
echo "ERROR: Session file not found at $SESSION_FILE"
echo "Run: bw unlock --raw > '$SESSION_FILE' && chmod 600 '$SESSION_FILE'"
exit 1
fi
# Check ssh-agent is running
if ! ssh-add -l >/dev/null 2>&1; then
echo "ERROR: ssh-agent is not running."
echo "Start it with: eval \$(ssh-agent)"
exit 1
fi
# Read session key
export BW_SESSION=$(cat "$SESSION_FILE")
# Verify session is still valid
if ! bw status 2>/dev/null | jq -e '.status == "unlocked"' >/dev/null 2>&1; then
echo "ERROR: Session is no longer valid (vault is locked or logged out)."
echo "Regenerate with: bw unlock --raw > '$SESSION_FILE' && chmod 600 '$SESSION_FILE'"
exit 1
fi
# Find all SSH key items
echo "Fetching SSH keys from vault..."
ITEMS=$(bw list items 2>/dev/null | jq -c '.[] | select(.type == 5)')
if [ -z "$ITEMS" ]; then
echo "No SSH keys found in vault."
exit 0
fi
LOADED=0
SKIPPED=0
echo "$ITEMS" | while IFS= read -r item; do
NAME=$(echo "$item" | jq -r '.name')
PUBLIC_KEY=$(echo "$item" | jq -r '.sshKey.publicKey // ""')
PRIVATE_KEY=$(echo "$item" | jq -r '.sshKey.privateKey // ""')
if [ -z "$PRIVATE_KEY" ]; then
echo " SKIP '$NAME' — no private key found"
continue
fi
# Extract comment from public key for checking if already loaded
COMMENT=$(echo "$PUBLIC_KEY" | awk '{print $3}' | tr -d '\n')
# Check if already loaded in ssh-agent
if [ -n "$COMMENT" ] && ssh-add -l 2>/dev/null | grep -q "$COMMENT"; then
echo " OK '$NAME' — already loaded"
SKIPPED=$((SKIPPED + 1))
continue
fi
# Load into ssh-agent
if echo "$PRIVATE_KEY" | ssh-add - 2>/dev/null; then
echo " LOAD '$NAME'"
LOADED=$((LOADED + 1))
else
echo " FAIL '$NAME'"
fi
done
echo "Done. Loaded: $LOADED, Skipped (already loaded): $SKIPPED"

15
config/scripts/env.sh Normal file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
# ===========================================================================
# env — PATH helper
# Sourced from ~/.profile and ~/.bashrc to ensure ~/.local/bin is in PATH.
# Idempotent — won't duplicate PATH entries.
# ===========================================================================
# affix colons on either side of $PATH to simplify matching
case ":${PATH}:" in
*:"$HOME/.local/bin":*)
;;
*)
# Prepending path in case a system-installed binary needs overriding
export PATH="$HOME/.local/bin:$PATH"
;;
esac

View File

@@ -0,0 +1,11 @@
#!/bin/bash
# ===========================================================================
# idle-battery-suspend.sh — Suspend laptop only when on battery
# Checks AC power status before suspending. If on AC power, does nothing.
# Used by swayidle.service (systemd user service).
# ===========================================================================
# Only suspend if on battery (AC online = 0)
AC_ONLINE=$(cat /sys/class/power_supply/AC/online 2>/dev/null)
if [ "$AC_ONLINE" = "0" ]; then
systemctl suspend-then-hibernate
fi

15
config/scripts/zoom.sh Normal file
View File

@@ -0,0 +1,15 @@
#!/bin/bash
# ===========================================================================
# zoom — Zoom launcher wrapper for COSMIC/Wayland on AMD GPU
# Forces Wayland-native mode (avoids X11 event freezes via XWayland).
# Forces VA-API hardware video decoding (fixes screen share performance).
#
# Works with: AMD Radeon 680M (Rembrandt) and similar.
# For NVIDIA GPUs, use: export LIBVA_DRIVER_NAME=nvidia
# ===========================================================================
export QT_QPA_PLATFORM=wayland
export LIBVA_DRIVER_NAME=radeonsi
export LIBVA_DRI3_DISABLE=0
exec /usr/bin/zoom "$@"

1713
config/shell/p10k.zsh Normal file

File diff suppressed because it is too large Load Diff

75
config/shell/zshrc Normal file
View File

@@ -0,0 +1,75 @@
# =============================================================================
# .zshrc — Julian's Zsh configuration
# Generated from Pop!_OS "thinkpad" machine audit.
# =============================================================================
# This file is managed by the linux-provision repo.
#
# API keys and secrets are loaded from Bitwarden on shell startup.
# If Bitwarden is unlocked, keys are available immediately.
# If locked, bw-env fails silently and you just won't have env vars
# (run `bw && bw-env` when you need them).
#
# To set up: bw-env --setup (one-time interactive)
# See config/scripts/bw-env.sh for details.
# =============================================================================
# ---- Powerlevel10k instant prompt ----
# Must stay close to the top.
if [[ -r "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh" ]]; then
source "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh"
fi
# ---- PATH setup ----
export PATH=$HOME/bin:$HOME/.local/bin:/usr/local/bin:$PATH
# ---- Oh My Zsh ----
export ZSH="$HOME/.oh-my-zsh"
ZSH_THEME="powerlevel10k/powerlevel10zsh"
# ---- Plugins ----
plugins=(git zsh-autosuggestions)
source $ZSH/oh-my-zsh.sh
# ---- Custom Aliases ----
# Bluetooth hard reset (ath11k WiFi/Bluetooth adapter hang workaround)
alias bt-reset='rfkill block bluetooth && sleep 1 && rfkill unblock bluetooth && sleep 1 && systemctl restart bluetooth'
# ---- Key bindings ----
# Ctrl+Backspace → delete word before cursor
bindkey "^H" backward-kill-word
# ---- Powerlevel10k config ----
[[ ! -f ~/.p10k.zsh ]] || source ~/.p10k.zsh
# ---- NVM (Node Version Manager) ----
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
# ---- Custom completions ----
fpath+=~/.zfunc; autoload -Uz compinit; compinit
# =============================================================================
# Secrets — API keys and tokens
# Loaded from Bitwarden on every shell start.
# Fetches the "Environment" item and exports all custom fields as env vars.
# Falls back silently if vault is locked or item doesn't exist.
#
# Prerequisites: bw (Bitwarden CLI), jq
# To set up:
# 1. bw login
# 2. bw unlock --raw > ~/.config/Bitwarden\ CLI/.session
# 3. Create a Bitwarden item named "Environment" (type: Secure Note)
# with custom fields for each API key (name = VAR_NAME, value = the_key)
# =============================================================================
if [ -z "${BW_SESSION:-}" ] && [ -f "$HOME/.config/Bitwarden CLI/.session" ]; then
export BW_SESSION=$(cat "$HOME/.config/Bitwarden CLI/.session")
fi
if [ -n "${BW_SESSION:-}" ]; then
eval "$(bw get item Environment 2>/dev/null | jq -r '
.fields[] | select(.type != 0) |
"export " + (.name | gsub(" "; "_")) + "=" + (.value | @sh)
' 2>/dev/null)" 2>/dev/null
fi

View File

@@ -0,0 +1,65 @@
# =============================================================================
# ~/.zshrc.local — API keys and secrets (EXAMPLE)
# ===== THIS FILE IS OPTIONAL =====
# The recommended approach is to store all secrets in Bitwarden and load
# them via bw-env.sh. See config/scripts/bw-env.sh for setup.
#
# If you prefer a plain file, copy this to ~/.zshrc.local and fill in
# your real keys. Then uncomment the "source ~/.zshrc.local" line in .zshrc.
#
# DO NOT commit this file to version control.
# =============================================================================
# From the Pop machine audit, Julian stores ~17 API keys in his env.
# Using Bitwarden (bw-env.sh) is the recommended approach because:
# - Keys are encrypted at rest (not in dotfiles)
# - Syncs across machines automatically
# - One source of truth
# - Cache falls back silently if vault is locked
# =============================================================================
# ---- AI API Keys ----
export GROQ_API_KEY="gsk_your_key_here"
export ANTHROPIC_API_KEY="sk-ant-your-key-here"
export GOOGLE_API_KEY="AIza-your-key-here"
export OPENCODE_API_KEY="sk-your-key-here"
# ---- LiteLLM Proxy ----
export OPENAI_BASE_URL="https://ai.julianprester.com"
export OPENAI_API_KEY="sk-your-key-here"
# ---- Calendar (CalDAV) ----
export CALDAV_URL="https://nc.julianprester.com/remote.php/dav"
export CALDAV_USERNAME="julian.prester@sydney.edu.au"
export CALDAV_PASSWORD="your-password-here"
# ---- Thunderbird API ----
export TB_API_HOSTS="thunderbird"
# ---- Zotero ----
export ZOTERO_KEY=""
# ---- Canvas LMS ----
export CANVAS_API_KEY="3156~your-key-here"
export CANVAS_API_URL="https://canvas.sydney.edu.au/"
# ---- Nextcloud ----
export NC_URL="https://nc.julianprester.com"
export NC_USERNAME="julian.prester@sydney.edu.au"
export NC_PASSWORD="your-password-here"
# ---- Actual Budget ----
export ACTUAL_URL="https://actual.your-domain.ts.net"
export ACTUAL_PASSWORD="your-password-here"
export ACTUAL_SYNC_ID="your-sync-id-here"
# ---- Tavily (web search API) ----
export TAVILY_API_KEY="tvly-your-key-here"
# ---- FreshRSS ----
export FRESHRSS_API_KEY="your-key-here"
# ---- Semantic Scholar ----
export SEMANTIC_SCHOLAR_API_KEY="your-key-here"
# ---- OpenAlex ----
export OPENALEX_API_KEY="your-key-here"

View File

@@ -0,0 +1,13 @@
[Unit]
Description=Load Bitwarden SSH keys into ssh-agent
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=%h/.local/bin/bw-load-ssh.sh
Restart=on-failure
RestartSec=30
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Empty Downloads folder on login
[Service]
Type=oneshot
ExecStart=/usr/bin/bash -c 'rm -rf %h/Downloads/* %h/Downloads/.* 2>/dev/null'
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,6 @@
[Unit]
Description=Sync mempi database to Nextcloud
[Service]
Type=oneshot
ExecStart=/bin/bash -c 'sqlite3 %h/.local/share/pi/pi.db "PRAGMA wal_checkpoint(TRUNCATE)" && cp %h/.local/share/pi/pi.db "%h/Nextcloud/2_resources/90-99 Misc/98 ocpa/mempi-pi.db"'

View File

@@ -0,0 +1,8 @@
[Unit]
Description=Sync mempi database on boot
[Timer]
OnBootSec=5min
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,12 @@
[Unit]
Description=Pi Overview — session dashboard
After=default.target
[Service]
Environment=PATH=/home/julian/.nvm/versions/node/v24.11.1/bin:/usr/local/bin:/usr/bin:/bin
ExecStart=%h/.local/bin/pi-overview --port 3000
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,13 @@
[Unit]
Description=Porridge dictate - push-to-talk transcription
After=graphical-session.target
[Service]
Type=simple
EnvironmentFile=-%h/.config/porridge/env
ExecStart=%h/.local/bin/porridge dictate
Restart=on-failure
RestartSec=3
[Install]
WantedBy=default.target

View File

@@ -0,0 +1,14 @@
[Unit]
Description=Porridge - Zoom meeting transcriber daemon
After=graphical-session.target pulseaudio.service pipewire.service
Wants=graphical-session.target
[Service]
Type=simple
EnvironmentFile=-%h/.config/porridge/env
ExecStart=%h/.local/bin/porridge daemon
Restart=on-failure
RestartSec=5
[Install]
WantedBy=graphical-session.target

171
lib/distro.sh Normal file
View File

@@ -0,0 +1,171 @@
#!/usr/bin/env bash
# ===========================================================================
# Distribution Detection & Common Variables
# Sourced by 00-envcheck.sh, then available to all subsequent stages.
#
# After sourcing this, use these variables instead of hardcoding commands:
# $PKG_INSTALL <packages> — Install packages
# $PKG_UPDATE — Update package cache
# $PKG_REMOVE <packages> — Remove packages
# $PKG_GROUP_INSTALL <group> — Install package group
# $DISTRO_FAMILY — "debian" or "fedora"
# $DISTRO_ID — "pop", "ubuntu", "fedora", etc.
# $DISTRO_VERSION — "24.04", "41", etc.
# See full list below.
# ===========================================================================
# ---- Detect distribution ----
DISTRO_ID=""
DISTRO_VERSION=""
DISTRO_FAMILY="" # "debian" (apt) or "fedora" (dnf)
DISTRO_LIKE="" # from ID_LIKE in os-release
if [ -f /etc/os-release ]; then
. /etc/os-release
DISTRO_ID="$ID"
DISTRO_VERSION="$VERSION_ID"
DISTRO_LIKE="$ID_LIKE"
fi
# ---- Determine distro family ----
case "$DISTRO_ID" in
fedora|rhel|centos)
DISTRO_FAMILY="fedora"
;;
pop|ubuntu|debian|linuxmint|elementary|zorin)
DISTRO_FAMILY="debian"
;;
*)
# Fall back to ID_LIKE
case "$DISTRO_LIKE" in
*fedora*) DISTRO_FAMILY="fedora" ;;
*debian*) DISTRO_FAMILY="debian" ;;
*) DISTRO_FAMILY="unknown" ;;
esac
;;
esac
# ---- Fail early on unknown distro ----
if [ "$DISTRO_FAMILY" = "unknown" ]; then
echo "[ERROR] Unknown distribution: $DISTRO_ID (ID_LIKE=$DISTRO_LIKE)"
echo " Edit lib/distro.sh to add support."
exit 1
fi
# ===========================================================================
# Package Manager Commands
# ===========================================================================
if [ "$DISTRO_FAMILY" = "debian" ]; then
PKG_MGR="apt"
PKG_UPDATE="sudo apt update"
PKG_INSTALL="sudo apt install -y"
PKG_INSTALL_NO_REC="sudo apt install -y --no-install-recommends"
PKG_REMOVE="sudo apt remove -y"
PKG_PURGE="sudo apt purge -y"
PKG_AUTOREMOVE="sudo apt autoremove -y"
PKG_SEARCH="apt search"
PKG_LIST_INSTALLED="dpkg -l"
# Repo management
REPO_ADD_PPA="sudo add-apt-repository -y"
REPO_APT_KEY="sudo apt-key add -" # legacy, use signed-by when possible
REPO_ADD_LIST="sudo tee /etc/apt/sources.list.d"
# Service / boot
SERVICE_ENABLE="sudo systemctl enable --now"
GRUB_UPDATE="sudo update-grub"
GRUB_FILE="/etc/default/grub"
# Desktop environment detection
# Pop uses COSMIC; Ubuntu uses GNOME; Kubuntu uses KDE
case "$DISTRO_ID" in
pop) DEFAULT_DE="COSMIC" ;;
ubuntu|debian) DEFAULT_DE="GNOME" ;;
*) DEFAULT_DE="" ;; # unknown
esac
elif [ "$DISTRO_FAMILY" = "fedora" ]; then
PKG_MGR="dnf"
PKG_UPDATE="sudo dnf makecache"
PKG_INSTALL="sudo dnf install -y"
PKG_INSTALL_NO_REC="sudo dnf install -y" # dnf has no --no-install-recommends equivalent
PKG_REMOVE="sudo dnf remove -y"
PKG_PURGE="sudo dnf remove -y"
PKG_AUTOREMOVE="sudo dnf autoremove -y"
PKG_SEARCH="dnf search"
PKG_LIST_INSTALLED="dnf list installed"
# Repo management
REPO_ADD_COPR="sudo dnf copr enable -y"
REPO_ADD_RPM="sudo dnf config-manager --add-repo"
# Service / boot
SERVICE_ENABLE="sudo systemctl enable --now"
GRUB_UPDATE="sudo grub2-mkconfig -o /boot/grub2/grub.cfg"
GRUB_EFI_UPDATE="sudo grub2-mkconfig -o /boot/efi/EFI/fedora/grub.cfg"
GRUB_FILE="/etc/default/grub"
# Desktop environment detection
DEFAULT_DE="GNOME" # Fedora Workstation defaults to GNOME
fi
# ===========================================================================
# Helper function: install package(s), silently skip if already installed
# ===========================================================================
pkg_install() {
if [ "$DISTRO_FAMILY" = "debian" ]; then
sudo apt install -y "$@" 2>/dev/null || return 1
else
sudo dnf install -y "$@" 2>/dev/null || return 1
fi
}
# Wrapper for group installs (ignored on apt systems)
pkg_group_install() {
if [ "$DISTRO_FAMILY" = "fedora" ]; then
sudo dnf group install -y "$@" 2>/dev/null || return 1
else
# On Debian, groups aren't a thing — just skip
return 0
fi
}
# ===========================================================================
# Package Name Mappings (Debian → Fedora and vice versa)
# Usage: pkg_name_fedora="foo" pkg_name_debian="bar"
# pkg_name "$pkg_name_fedora" "$pkg_name_debian"
# Or use the shorthand below for known differences.
# ===========================================================================
# Known package name differences
resolve_pkg() {
local debian_name="$1"
local fedora_name="$2"
if [ "$DISTRO_FAMILY" = "debian" ]; then
echo "$debian_name"
else
echo "$fedora_name"
fi
}
# Convenience: install a package that may have different names
pkg_install_mapped() {
local debian_name="$1"
local fedora_name="$2"
if [ "$DISTRO_FAMILY" = "debian" ]; then
pkg_install "$debian_name"
else
pkg_install "$fedora_name"
fi
}
# Export all variables so sourced stages can use them
export DISTRO_FAMILY DISTRO_ID DISTRO_VERSION DISTRO_LIKE
export PKG_MGR PKG_UPDATE PKG_INSTALL PKG_INSTALL_NO_REC
export PKG_REMOVE PKG_PURGE PKG_AUTOREMOVE
export PKG_SEARCH PKG_LIST_INSTALLED
export SERVICE_ENABLE GRUB_UPDATE GRUB_EFI_UPDATE GRUB_FILE
export DEFAULT_DE
# Export helpers
export -f pkg_install pkg_group_install pkg_install_mapped resolve_pkg

131
provision.sh Executable file
View File

@@ -0,0 +1,131 @@
#!/usr/bin/env bash
# ===========================================================================
# Linux Machine Provisioning — Master Orchestrator
# ===========================================================================
# Usage:
# bash provision.sh --all # Run all stages in order
# bash provision.sh --stage <name> # Run a single stage
# bash provision.sh --list # List all stages
# source provision.sh --interactive # Source for manual calls
#
# Distribution-agnostic — detects Debian/Ubuntu/Pop/Fedora and adjusts
# package manager, repo config, etc. automatically.
# ===========================================================================
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STAGES_DIR="${SCRIPT_DIR}/stages"
LIBS_DIR="${SCRIPT_DIR}/lib"
CONFIG_DIR="${SCRIPT_DIR}/config"
# ---- Source distro detection first ----
source "${LIBS_DIR}/distro.sh"
# ---- Colour helpers ----
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; }
# ---- Available stages ----
STAGES=(
"00-envcheck" # Environment checks (uses lib/distro.sh vars)
"01-repos" # Third-party repositories
"02-packages" # System packages
"03-toolchains" # nvm, Node, uv, Python
"04-shell" # Zsh, oh-my-zsh, p10k
"05-git" # Git config, SSH
"06-uv-projects" # Clone + install Julian's Python tools
"07-scripts" # ~/.local/bin custom scripts
"08-systemd" # User systemd services
"09-desktop" # Keybindings, hotkeys, desktop config
"10-docker" # Docker CE
"11-tweaks" # sysctl, kernel, TLP/powertop
"12-other-apps" # Chrome, Signal, Zotero
)
# ===========================================================================
# Stage Runner
# ===========================================================================
run_stage() {
local stage_name="$1"
local stage_file="${STAGES_DIR}/${stage_name}.sh"
if [ ! -f "$stage_file" ]; then
error "Stage script not found: ${stage_file}"
return 1
fi
info "=============================================="
info " Running stage: ${stage_name}"
info "=============================================="
source "$stage_file"
ok "Stage '${stage_name}' completed."
echo ""
}
# ===========================================================================
# Main
# ===========================================================================
main() {
local mode="${1:-}"
case "$mode" in
--all)
info "Starting full provisioning on ${DISTRO_ID} ${DISTRO_VERSION} (${DISTRO_FAMILY})"
info "You may be prompted for sudo password."
for stage in "${STAGES[@]}"; do
run_stage "$stage" || warn "Stage '${stage}' failed (continuing)."
done
ok "Provisioning complete!"
warn "Review TODO.md for manual post-install steps."
;;
--stage)
local target="${2:-}"
if [ -z "$target" ]; then
error "Usage: provision.sh --stage <stage-name>"
exit 1
fi
run_stage "$target"
;;
--list)
echo "Available stages (distro: ${DISTRO_ID} / ${DISTRO_FAMILY}):"
for stage in "${STAGES[@]}"; do
echo " ${stage}"
done
;;
--interactive)
info "Sourcing stage functions for interactive use."
for stage in "${STAGES[@]}"; do
local f="${STAGES_DIR}/${stage}.sh"
[ -f "$f" ] && source "$f"
done
info "Stages loaded. Call: run_stage <stage-name>"
;;
*)
echo "Usage:"
echo " bash provision.sh --all # Run everything"
echo " bash provision.sh --stage <name> # Run one stage"
echo " bash provision.sh --list # List stages"
echo " source provision.sh --interactive # Load for manual use"
exit 1
;;
esac
}
main "$@"

42
stages/00-envcheck.sh Normal file
View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# ===========================================================================
# Stage 00: Environment Checks
# Verifies we can proceed: distro detected, sudo available, dirs exist.
# Distro detection is handled by lib/distro.sh (sourced by provision.sh).
# ===========================================================================
info "Distribution: ${DISTRO_ID} ${DISTRO_VERSION} (${DISTRO_FAMILY} family)"
info "Package manager: ${PKG_MGR}"
# ---- Sudo access ----
info "Checking sudo access..."
if ! sudo -n true 2>/dev/null; then
info "Sudo access required. You may be prompted for your password."
sudo -v || { error "Sudo required for provisioning."; exit 1; }
fi
ok "Sudo access confirmed."
# Keep sudo timestamp fresh
while true; do sudo -n true; sleep 60; kill -0 "$$" || exit; done 2>/dev/null &
# ---- Directory structure ----
info "Setting up directory structure..."
mkdir -p "$HOME/Development"
mkdir -p "$HOME/.local/bin"
mkdir -p "$HOME/.config"
mkdir -p "$HOME/.local/share"
ok "Directory structure ready."
# ---- Internet check ----
info "Checking internet connectivity..."
if ! ping -c 1 -W 3 google.com &>/dev/null && ! ping -c 1 -W 3 github.com &>/dev/null; then
warn "No internet detected. Some steps may fail."
else
ok "Internet connectivity confirmed."
fi
# ---- Package cache update (first time) ----
info "Updating package cache (first run)..."
$PKG_UPDATE 2>/dev/null || warn "Package cache update had issues."
ok "Stage 00 complete."

209
stages/01-repos.sh Normal file
View File

@@ -0,0 +1,209 @@
#!/usr/bin/env bash
# ===========================================================================
# Stage 01: Third-Party Repositories
# Adds external package repos. Completely different per distro family.
#
# Debian/Ubuntu/Pop: uses PPAs and .list/.sources files in
# /etc/apt/sources.list.d/
# Fedora: uses .repo files in /etc/yum.repos.d/ and COPRs
# ===========================================================================
# ==================================================================
# COMMON: Add GPG keys (shared helper)
# ==================================================================
add_gpg_key() {
local url="$1"
local dest="$2"
if [ ! -f "$dest" ]; then
sudo curl -fsSL "$url" -o "$dest" 2>/dev/null || {
warn "Failed to download GPG key: $url"
return 1
}
sudo chmod 644 "$dest"
fi
}
# ==================================================================
# DEBIAN / UBUNTU / POP — APT-based
# ==================================================================
if [ "$DISTRO_FAMILY" = "debian" ]; then
info "Configuring APT repositories..."
# ---- VS Code ----
info " Adding VS Code repo..."
if [ ! -f /etc/apt/sources.list.d/vscode.sources ]; then
add_gpg_key "https://packages.microsoft.com/keys/microsoft.asc" \
"/usr/share/keyrings/microsoft.gpg"
cat << 'EOF' | sudo tee /etc/apt/sources.list.d/vscode.sources > /dev/null
Types: deb
URIs: https://packages.microsoft.com/repos/code
Suites: stable
Components: main
Architectures: amd64
Signed-By: /usr/share/keyrings/microsoft.gpg
EOF
ok " VS Code repo added."
fi
# ---- Google Chrome ----
info " Adding Google Chrome repo..."
if [ ! -f /etc/apt/sources.list.d/google-chrome.sources ]; then
add_gpg_key "https://dl.google.com/linux/linux_signing_key.pub" \
"/usr/share/keyrings/google-chrome.gpg"
cat << 'EOF' | sudo tee /etc/apt/sources.list.d/google-chrome.sources > /dev/null
Types: deb
URIs: https://dl.google.com/linux/chrome/deb/
Suites: stable
Components: main
Architectures: amd64
Signed-By: /usr/share/keyrings/google-chrome.gpg
EOF
ok " Google Chrome repo added."
fi
# ---- Docker CE ----
info " Adding Docker CE repo..."
if [ ! -f /etc/apt/sources.list.d/docker.list ]; then
add_gpg_key "https://download.docker.com/linux/${DISTRO_ID}/gpg" \
"/etc/apt/keyrings/docker.asc"
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/${DISTRO_ID} ${DISTRO_VERSION_CODENAME:-$(. /etc/os-release && echo "$VERSION_CODENAME")} stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
ok " Docker CE repo added."
fi
# ---- Tailscale ----
info " Adding Tailscale repo..."
if [ ! -f /etc/apt/sources.list.d/tailscale.list ]; then
add_gpg_key "https://pkgs.tailscale.com/stable/${DISTRO_ID}/$(. /etc/os-release && echo "$VERSION_CODENAME").gz" \
"/usr/share/keyrings/tailscale-archive-keyring.gpg" 2>/dev/null || \
add_gpg_key "https://pkgs.tailscale.com/stable/${DISTRO_ID}/repo.gpg" \
"/usr/share/keyrings/tailscale-archive-keyring.gpg"
echo "deb [signed-by=/usr/share/keyrings/tailscale-archive-keyring.gpg] https://pkgs.tailscale.com/stable/${DISTRO_ID} $(. /etc/os-release && echo "$VERSION_CODENAME") main" | \
sudo tee /etc/apt/sources.list.d/tailscale.list > /dev/null
ok " Tailscale repo added."
fi
# ---- Signal Desktop ----
info " Adding Signal repo..."
if [ ! -f /etc/apt/sources.list.d/signal-desktop.sources ]; then
add_gpg_key "https://updates.signal.org/desktop/apt/keys.asc" \
"/usr/share/keyrings/signal-desktop-keyring.gpg"
cat << 'EOF' | sudo tee /etc/apt/sources.list.d/signal-desktop.sources > /dev/null
Types: deb
URIs: https://updates.signal.org/desktop/apt
Suites: xenial
Components: main
Architectures: amd64
Signed-By: /usr/share/keyrings/signal-desktop-keyring.gpg
EOF
ok " Signal repo added."
fi
# ---- Papirus icon theme (PPA) ----
info " Adding Papirus PPA..."
if [ ! -f /etc/apt/sources.list.d/papirus-ubuntu-papirus-*.sources ]; then
$REPO_ADD_PPA papirus/papirus 2>/dev/null && ok " Papirus PPA added." || warn " Papirus PPA failed."
fi
# ---- Solaar (Logitech) PPA ----
info " Adding Solaar PPA..."
if [ ! -f /etc/apt/sources.list.d/solaar-unifying-ubuntu-stable-*.sources ]; then
$REPO_ADD_PPA solaar-unifying/stable 2>/dev/null && ok " Solaar PPA added." || warn " Solaar PPA failed."
fi
# ---- Ghostty terminal PPA ----
info " Adding Ghostty PPA..."
$REPO_ADD_PPA ghostty/ghostty 2>/dev/null && ok " Ghostty PPA added." || warn " Ghostty PPA failed."
# ---- Zotero repo ----
info " Adding Zotero repo..."
if [ ! -f /etc/apt/sources.list.d/zotero.list ]; then
add_gpg_key "https://zotero.retorque.re/file/apt-package-archive/pubkey.gpg" \
"/usr/share/keyrings/zotero-archive-keyring.gpg"
echo "deb [signed-by=/usr/share/keyrings/zotero-archive-keyring.gpg by-hash=force] https://zotero.retorque.re/file/apt-package-archive ./" | \
sudo tee /etc/apt/sources.list.d/zotero.list > /dev/null
ok " Zotero repo added."
fi
# ---- Yubico PPA ----
info " Adding Yubico PPA..."
$REPO_ADD_PPA yubico/stable 2>/dev/null && ok " Yubico PPA added." || warn " Yubico PPA failed."
# ==================================================================
# FEDORA / RHEL — DNF-based
# ==================================================================
elif [ "$DISTRO_FAMILY" = "fedora" ]; then
info "Configuring DNF repositories..."
# ---- RPM Fusion (free + nonfree) ----
info " Enabling RPM Fusion..."
$PKG_INSTALL \
"https://download1.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm" \
"https://download1.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm" \
2>/dev/null && ok " RPM Fusion configured." || warn " RPM Fusion install failed."
# ---- VS Code ----
info " Adding VS Code repo..."
sudo rpm --import https://packages.microsoft.com/keys/microsoft.asc 2>/dev/null || true
cat << 'EOF' | sudo tee /etc/yum.repos.d/vscode.repo > /dev/null
[code]
name=Visual Studio Code
baseurl=https://packages.microsoft.com/yumrepos/vscode
enabled=1
gpgcheck=1
gpgkey=https://packages.microsoft.com/keys/microsoft.asc
EOF
ok " VS Code repo added."
# ---- Google Chrome ----
info " Adding Google Chrome repo..."
cat << 'EOF' | sudo tee /etc/yum.repos.d/google-chrome.repo > /dev/null
[google-chrome]
name=Google Chrome
baseurl=https://dl.google.com/linux/chrome/rpm/stable/x86_64
enabled=1
gpgcheck=1
gpgkey=https://dl.google.com/linux/linux_signing_key.pub
EOF
ok " Google Chrome repo added."
# ---- Docker CE ----
info " Adding Docker CE repo..."
$REPO_ADD_RPM https://download.docker.com/linux/fedora/docker-ce.repo 2>/dev/null && \
ok " Docker repo added." || warn " Docker repo add failed."
# ---- Tailscale ----
info " Adding Tailscale repo..."
$REPO_ADD_RPM https://pkgs.tailscale.com/stable/fedora/tailscale.repo 2>/dev/null && \
ok " Tailscale repo added." || warn " Tailscale repo add failed."
# ---- Signal Desktop ----
info " Adding Signal repo..."
cat << 'EOF' | sudo tee /etc/yum.repos.d/signal-desktop.repo > /dev/null
[signal-desktop]
name=Signal Desktop
baseurl=https://updates.signal.org/desktop/yum
enabled=1
gpgcheck=1
gpgkey=https://updates.signal.org/desktop/signal_pubkey.gpg
EOF
ok " Signal repo added."
# ---- COPRs for extra packages ----
# Papirus icon theme is in RPM Fusion nonfree.
# Solaar is in RPM Fusion.
# Yubico tools: use COPR
info " Adding COPR repos..."
# $REPO_ADD_COPR atim/papirus-icon-theme 2>/dev/null || true
# $REPO_ADD_COPR sergiomb/Solaar 2>/dev/null || true
# ---- Zotero — no DNF repo, use Flatpak (handled in stage 13) ----
info " Note: Zotero will be installed via Flatpak or tarball in stage 13."
fi
# ---- Update package cache after adding repos ----
info "Updating package cache..."
$PKG_UPDATE 2>/dev/null || warn "Package cache update had issues."
ok "Stage 01 complete: repositories configured."

125
stages/02-packages.sh Normal file
View File

@@ -0,0 +1,125 @@
#!/usr/bin/env bash
# ===========================================================================
# Stage 02: System Packages
# Installs all non-default packages — distro-agnostic.
#
# Package names that differ between Debian and Fedora are handled via
# pkg_install_mapped() or conditional branches.
# Same-name packages are installed with pkg_install().
# ===========================================================================
info "Installing system packages (this may take a while)..."
# ===========================================================================
# A. Development tools & compilers
# ===========================================================================
echo " Development tools..."
pkg_group_install "Development Tools" # Fedora only
# Common dev packages (same name on both)
pkg_install cmake
# Differently-named dev packages
pkg_install_mapped "build-essential" "@development-tools"
pkg_install_mapped "g++" "gcc-c++"
# Kernel headers
if [ "$DISTRO_FAMILY" = "debian" ]; then
pkg_install "linux-headers-$(uname -r)" 2>/dev/null || pkg_install linux-headers-generic
else
pkg_install kernel-devel kernel-headers
fi
pkg_install dkms
# ===========================================================================
# B. CLI utilities
# ===========================================================================
echo " CLI utility packages..."
# Same-name CLI tools
pkg_install \
ripgrep \
fd-find \
jq \
just \
ffmpeg \
wl-clipboard \
wtype \
wofi
# fd-find may need a symlink (binary is fdfind on both distros)
if command -v fdfind &>/dev/null && ! command -v fd &>/dev/null; then
if [ "$DISTRO_FAMILY" = "debian" ]; then
sudo ln -sf "$(which fdfind)" /usr/local/bin/fd 2>/dev/null || true
else
sudo ln -sf "$(which fdfind)" /usr/local/bin/fd 2>/dev/null || true
fi
fi
# Differently-named
pkg_install_mapped "imagemagick" "ImageMagick"
# ===========================================================================
# C. Media & graphics
# ===========================================================================
echo " Media & graphics packages..."
pkg_install gimp vlc
# ===========================================================================
# D. Fonts
# ===========================================================================
echo " Font packages..."
if [ "$DISTRO_FAMILY" = "debian" ]; then
pkg_install fonts-powerline fonts-noto-cjk fonts-noto-cjk-extra \
fonts-noto-core fonts-noto-ui-core 2>/dev/null || true
else
pkg_install powerline-fonts google-noto-cjk-fonts \
google-noto-fonts-common 2>/dev/null || true
fi
# ===========================================================================
# E. System tools
# ===========================================================================
echo " System tool packages..."
pkg_install \
powertop \
smartmontools \
solaar 2>/dev/null || warn " Some system tools failed."
# TLP — laptop power management
if [ "$DISTRO_FAMILY" = "debian" ]; then
pkg_install tlp 2>/dev/null || warn " tlp not available."
else
pkg_install tlp tlp-rdw 2>/dev/null || warn " tlp not available."
fi
# ===========================================================================
# F. General utilities
# ===========================================================================
echo " General utility packages..."
pkg_install \
rsync \
curl \
wget \
unzip \
p7zip 2>/dev/null || true
# ---- Ghostty terminal emulator ----
# Fedora: in official repos since F40
# Ubuntu/Pop: via PPA (added in stage 01)
echo " Ghostty terminal emulator..."
pkg_install ghostty 2>/dev/null || warn " ghostty install failed."
# ---- VS Code ----
echo " VS Code..."
pkg_install code 2>/dev/null || warn " code install failed."
# ===========================================================================
# Start enabled services
# ===========================================================================
if command -v tlp &>/dev/null; then
$SERVICE_ENABLE tlp 2>/dev/null || true
fi
ok "Stage 02 complete: system packages installed."

102
stages/03-toolchains.sh Normal file
View File

@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# ===========================================================================
# Stage 03: Language Toolchains
# Installs nvm + Node.js LTS, uv (Python), and sets up the toolchain.
# ===========================================================================
# On Pop/Ubuntu these are post-system-package installs.
# Fedora packages for Node/python are available but we use version managers
# for flexibility (nvm for Node, uv for Python).
# ===========================================================================
# ---- nvm + Node.js ----
# nvm is installed to ~/.nvm. Node LTS is installed and set as default.
# This matches the Pop machine which had v24.11.1 via nvm.
info "Installing nvm (Node Version Manager)..."
if [ -d "$HOME/.nvm" ] && [ -f "$HOME/.nvm/nvm.sh" ]; then
ok "nvm already installed."
else
# Install latest nvm
# curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.2/install.sh | bash
# Use the install script from nvm's GitHub
export NVM_DIR="$HOME/.nvm"
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.2/install.sh | bash
ok "nvm installed."
fi
# Source nvm for this script
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
# Install latest LTS Node
info "Installing Node.js LTS via nvm..."
nvm install --lts 2>/dev/null || {
warn "nvm install --lts failed. Trying specific version..."
nvm install 24 2>/dev/null || warn "Could not install Node via nvm."
}
nvm alias default 'lts/*' 2>/dev/null || true
ok "Node.js $(node --version 2>/dev/null || echo 'installed')"
# Install/update npm to latest
npm install -g npm@latest 2>/dev/null || true
ok "npm $(npm --version 2>/dev/null || echo 'installed')"
# ---- npm global packages ----
# Julian's daily tools: pi agent, browser automation, subagents
info "Installing npm global packages..."
npm install -g \
@earendil-works/pi-coding-agent \
agent-browser \
pi-subagents \
2>/dev/null || warn "Some npm global packages failed."
ok "npm global packages installed."
# ---- uv (Python toolchain) ----
# uv is the recommended Python package + project manager.
# On Pop: uv was installed standalone from astral.sh — binary lives in ~/.local/bin
# The binary is self-updating via 'uv self update'.
info "Installing uv (Python project manager)..."
if command -v uv &>/dev/null; then
ok "uv already installed: $(uv --version)"
# Self-update to latest
uv self update 2>/dev/null || true
else
curl -fsSL https://astral.sh/uv/install.sh | bash
# Ensure ~/.local/bin is in PATH
export PATH="$HOME/.local/bin:$PATH"
ok "uv installed: $(uv --version)"
fi
# Install system Python if not present (Fedora usually has it)
if ! command -v python3 &>/dev/null; then
info "Installing Python..."
pkg_install python3 python3-pip python3-devel 2>/dev/null || true
fi
# ---- uv tool installs (from PyPI) ----
# Julian's daily Python CLI tools, installed via uv.
info "Installing uv tools from PyPI..."
if command -v uv &>/dev/null; then
uv tool install markitdown 2>/dev/null && echo " markitdown" || warn " markitdown install failed."
uv tool install pre-commit 2>/dev/null && echo " pre-commit" || warn " pre-commit install failed."
uv tool install yq 2>/dev/null && echo " yq (tomlq, xq)" || warn " yq install failed."
ok "PyPI uv tools installed."
fi
# ---- (Optional) Rust toolchain ----
# The Pop machine did NOT have Rust installed. Uncomment if you want it.
# info "Installing Rust via rustup..."
# if command -v rustc &>/dev/null; then
# ok "Rust already installed: $(rustc --version)"
# else
# curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
# . "$HOME/.cargo/env"
# ok "Rust installed: $(rustc --version)"
# fi
# ---- (Optional) Go toolchain ----
# The Pop machine did NOT have Go installed. Uncomment if you want it.
# info "Installing Go..."
# sudo dnf install -y golang 2>/dev/null || warn "Go install failed."
# ok "Go installed: $(go version)"
ok "Stage 03 complete: language toolchains installed."

101
stages/04-shell.sh Normal file
View File

@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# ===========================================================================
# Stage 04: Shell Configuration (zsh, oh-my-zsh, powerlevel10k)
# Deploys .zshrc, .p10k.zsh, and .zshrc.local (from config/shell/).
# ===========================================================================
# On the Pop machine, Julian uses:
# - zsh as default shell
# - oh-my-zsh with powerlevel10k theme (lean style, 1 line)
# - Plugin: git, zsh-autosuggestions
# - Custom aliases: bt-reset (Bluetooth)
# - 17 environment variables (API keys)
# - NVM integration
# - Ctrl+Backspace → backward-kill-word
#
# API keys go into ~/.zshrc.local (NOT tracked in this repo).
# See config/shell/zshrc.local.example for the template.
# ===========================================================================
# ---- Install zsh ----
info "Installing zsh..."
if command -v zsh &>/dev/null; then
ok "zsh already installed: $(zsh --version | head -1)"
else
pkg_install zsh 2>/dev/null
fi
# ---- Install oh-my-zsh ----
info "Installing Oh My Zsh..."
if [ -d "$HOME/.oh-my-zsh" ]; then
ok "Oh My Zsh already installed."
else
# Non-interactive install (no chsh prompt)
sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended 2>/dev/null || {
warn "Oh My Zsh install failed; trying without unattended flag."
# Fallback: set ZSH and clone directly
export ZSH="$HOME/.oh-my-zsh"
git clone --depth=1 https://github.com/ohmyzsh/ohmyzsh.git "$ZSH" 2>/dev/null || true
}
ok "Oh My Zsh installed."
fi
# ---- Install Powerlevel10k theme ----
info "Installing Powerlevel10k theme..."
if [ -d "${ZSH:-$HOME/.oh-my-zsh}/custom/themes/powerlevel10k" ]; then
ok "Powerlevel10k already installed."
else
git clone --depth=1 https://github.com/romkatv/powerlevel10k.git \
"${ZSH:-$HOME/.oh-my-zsh}/custom/themes/powerlevel10k" 2>/dev/null || warn "p10k clone failed."
fi
# ---- Install zsh-autosuggestions plugin ----
info "Installing zsh-autosuggestions plugin..."
if [ -d "${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/plugins/zsh-autosuggestions" ]; then
ok "zsh-autosuggestions already installed."
else
git clone --depth=1 https://github.com/zsh-users/zsh-autosuggestions \
"${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/plugins/zsh-autosuggestions" 2>/dev/null || warn "clone failed."
fi
# ---- Deploy .zshrc ----
info "Deploying .zshrc..."
CONFIG_DIR="${SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/config"
if [ -f "$HOME/.zshrc" ]; then
# Backup existing
cp "$HOME/.zshrc" "$HOME/.zshrc.bak.$(date +%Y%m%d)" 2>/dev/null
warn "Backed up existing .zshrc to .zshrc.bak.$(date +%Y%m%d)"
fi
cp "${CONFIG_DIR}/shell/zshrc" "$HOME/.zshrc"
ok ".zshrc deployed."
# ---- Deploy .p10k.zsh ----
info "Deploying .p10k.zsh..."
if [ -f "$HOME/.p10k.zsh" ]; then
cp "$HOME/.p10k.zsh" "$HOME/.p10k.zsh.bak.$(date +%Y%m%d)" 2>/dev/null
warn "Backed up existing .p10k.zsh"
fi
cp "${CONFIG_DIR}/shell/p10k.zsh" "$HOME/.p10k.zsh"
ok ".p10k.zsh deployed."
# ---- Deploy .zshrc.local (secrets template) ----
# This file should contain your API keys. The example file has placeholders.
# It is NOT sourced by default. To enable, uncomment the "source ~/.zshrc.local"
# line in your deployed .zshrc.
info "Installing .zshrc.local example..."
if [ ! -f "$HOME/.zshrc.local" ]; then
cp "${CONFIG_DIR}/shell/zshrc.local.example" "$HOME/.zshrc.local"
warn "Created ~/.zshrc.local from example. EDIT IT with your API keys."
else
ok ".zshrc.local already exists (keeping existing)."
fi
# ---- Change default shell to zsh ----
info "Setting zsh as default shell..."
if [ "$SHELL" != "$(which zsh)" ]; then
chsh -s "$(which zsh)" 2>/dev/null || warn "Could not change shell (chsh)."
ok "Default shell set to zsh. Log out and back in to activate."
else
ok "zsh is already the default shell."
fi
ok "Stage 04 complete: shell configured."

101
stages/05-git.sh Normal file
View File

@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# ===========================================================================
# Stage 05: Git Configuration & SSH Keys
# Deploys .gitconfig and optionally generates SSH keys.
# ===========================================================================
# The Pop machine's .gitconfig is well-optimised:
# - SSH key signing (gpg.format = ssh)
# - zdiff3 conflict style, histogram diff algorithm
# - rerere.enabled, autoSquash, autoStash
# - push.autoSetupRemote, pull.rebase, fetch.prune
# ===========================================================================
CONFIG_DIR="${SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/config"
# ---- Deploy .gitconfig ----
info "Deploying .gitconfig..."
if [ -f "$HOME/.gitconfig" ]; then
cp "$HOME/.gitconfig" "$HOME/.gitconfig.bak.$(date +%Y%m%d)" 2>/dev/null
warn "Backed up existing .gitconfig"
fi
# Use the template from config/git/gitconfig
# NOTE: This template does NOT contain your signing key or email.
# Edit it after deployment to set:
# [user]
# name = Your Name
# email = your.email@example.com
# signingkey = <your-ssh-public-key>
cp "${CONFIG_DIR}/git/gitconfig" "$HOME/.gitconfig"
ok ".gitconfig deployed."
warn "REMINDER: Edit ~/.gitconfig to set your name, email, and signingkey."
# ---- Deploy .gitignore_global ----
info "Deploying global .gitignore..."
if [ -f "$HOME/.gitignore" ]; then
warn "Global .gitignore already exists (keeping)."
else
# A sensible global gitignore for common OS + editor files
cat > "$HOME/.gitignore" << 'EOF'
# OS files
.DS_Store
Thumbs.db
Desktop.ini
# Editor/IDE
*.swp
*.swo
*~
.vscode/
.idea/
*.sublime-*
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
.eggs/
# Node
node_modules/
.npm/
# Rust
target/
EOF
ok "Global .gitignore deployed."
fi
# ---- Git SSH key ----
# The Pop machine has a single SSH key: id_ed25519_github
# Loading is handled by bw-load-ssh.sh (stage 07) from Bitwarden.
# This stage can generate a new key if one doesn't exist.
info "Checking SSH keys..."
if [ ! -f "$HOME/.ssh/id_ed25519" ]; then
warn "No SSH key found."
warn "Options:"
warn " 1. Generate a new key (recommended for new machine):"
warn " ssh-keygen -t ed25519 -C 'hi@julianprester.com'"
warn " 2. Restore from backup/Bitwarden (see TODO.md)"
warn " 3. Skip for now (run bw-load-ssh.sh later to load from Bitwarden)"
echo ""
read -r -p "Generate a new SSH key now? [y/N] " response
if [[ "$response" =~ ^[Yy]$ ]]; then
ssh-keygen -t ed25519 -C "hi@julianprester.com" -f "$HOME/.ssh/id_ed25519" -N "" 2>/dev/null || {
warn "Key generation skipped or failed."
}
ok "SSH key generated: ~/.ssh/id_ed25519.pub"
cat "$HOME/.ssh/id_ed25519.pub"
warn "Add this key to GitHub: https://github.com/settings/keys"
fi
else
ok "SSH key already exists."
fi
# Ensure proper SSH permissions
chmod 700 "$HOME/.ssh" 2>/dev/null || true
find "$HOME/.ssh" -type f -name "id_*" -exec chmod 600 {} \; 2>/dev/null || true
find "$HOME/.ssh" -type f -name "*.pub" -exec chmod 644 {} \; 2>/dev/null || true
ok "Stage 05 complete: Git configured."

81
stages/06-uv-projects.sh Normal file
View File

@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# ===========================================================================
# Stage 06: Julian's uv Python Tools — Clone & Install
# Clones all custom Python tool repos (from GitHub) into ~/Development/
# and installs them via 'uv tool install' (editable mode from local path).
# ===========================================================================
# These are Julian's own CLI tools — the ones installed on Pop via uv.
# Each has a remote on github.com/julianprester/ (or re3-work/) and a
# pyproject.toml defining the package.
#
# Tools without a public remote (oracle, panac, skill-eval, mondada) are
# noted — you'll need to push them to GitHub or copy them manually.
#
# Order: tools that depend on other tools should come after. Most are
# independent Python packages.
# ===========================================================================
# Ensure uv is in PATH
export PATH="$HOME/.local/bin:$PATH"
# ---- Define tool repos ----
# Format: "repo_name:github_org:has_pyproject:has_package_json"
# repo_name = directory name under ~/Development/
# github_org = GitHub org (julianprester or re3-work)
# has_pyproject = true if it has pyproject.toml and should be uv-installed
# has_package_json = true if it has package.json and should be npm-linked
TOOLS=(
"porridge:julianprester:true:false" # Zoom meeting transcriber daemon
"deepis:julianprester:true:false" # Literature discovery CLI
"pi-persist:julianprester:true:false" # Memory persistence (mempi, pi-overview)
"panac:julianprester:true:false" # Pandoc wrapper CLI
"gromd:julianprester:true:false" # Gromd tool
"kannwas:julianprester:true:false" # Kannwas tool
"tb-api:julianprester:false:false" # Thunderbird REST API (not a Python/npm pkg — Firefox addon)
"hotkeys:julianprester:false:false" # Shell scripts for Wayland hotkeys (no install needed)
"ocpa:julianprester:true:false" # OpenCode pi agent Python package
)
# ===========================================================================
info "Cloning & installing Julian's Python tools..."
mkdir -p "$HOME/Development"
# ---- Clone and install each tool ----
for tool_entry in "${TOOLS[@]}"; do
IFS=':' read -r name org has_pyproject has_package_json <<< "$tool_entry"
target_dir="$HOME/Development/$name"
if [ -d "$target_dir" ]; then
ok "Repo '$name' already cloned. Pulling latest..."
git -C "$target_dir" pull --ff-only 2>/dev/null || warn "Could not pull $name."
else
info "Cloning $org/$name..."
git clone "git@github.com:${org}/${name}.git" "$target_dir" 2>/dev/null || {
warn "Clone failed for ${org}/${name}. Trying HTTPS fallback..."
git clone "https://github.com/${org}/${name}.git" "$target_dir" 2>/dev/null || {
warn "Could not clone ${org}/${name}. SSH keys not set up? Skipping."
continue
}
}
ok "Cloned $org/$name$target_dir"
fi
# Install via uv if it has pyproject.toml
if [ "$has_pyproject" = "true" ] && [ -f "$target_dir/pyproject.toml" ]; then
info "Installing '$name' via uv tool..."
# Try editable install from local path; fall back to non-editable
uv tool install --editable "$target_dir" 2>/dev/null || \
uv tool install "$target_dir" 2>/dev/null || \
warn "uv tool install failed for '$name'. Check pyproject.toml."
ok "'$name' installed via uv."
fi
done
# ---- Verify installations ----
echo ""
info "Verifying uv tool installations..."
uv tool list 2>/dev/null || warn "No uv tools installed."
ok "Stage 06 complete: uv tools installed."

86
stages/07-scripts.sh Normal file
View File

@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# ===========================================================================
# Stage 07: Custom Scripts (~/.local/bin/)
# Deploys Julian's custom scripts: Bitwarden SSH loader, Zoom wrapper,
# idle battery suspend, env PATH helper, and more.
# ===========================================================================
# These are the "glue" scripts that make the desktop work the way Julian
# expects. They were found in ~/.local/bin/ on the Pop machine.
#
# Config templates are in config/scripts/
# ===========================================================================
CONFIG_DIR="${SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/config"
SCRIPTS_DIR="${CONFIG_DIR}/scripts"
TARGET_DIR="$HOME/.local/bin"
mkdir -p "$TARGET_DIR"
info "Deploying custom scripts to $TARGET_DIR..."
# ---- 1. env.sh — PATH helper (sourced by .profile / .bashrc) ----
# Ensures ~/.local/bin is in PATH without duplicate entries.
if [ -f "$SCRIPTS_DIR/env.sh" ]; then
cp "$SCRIPTS_DIR/env.sh" "$TARGET_DIR/env"
chmod +x "$TARGET_DIR/env"
ok "env deployed."
fi
# ---- 2. bw-load-ssh.sh — Load SSH keys from Bitwarden ----
# Script that fetches SSH keys from Bitwarden vault and loads into ssh-agent.
# Depends on: bw (Bitwarden CLI), jq, ssh-agent running.
if [ -f "$SCRIPTS_DIR/bw-load-ssh.sh" ]; then
cp "$SCRIPTS_DIR/bw-load-ssh.sh" "$TARGET_DIR/bw-load-ssh.sh"
chmod +x "$TARGET_DIR/bw-load-ssh.sh"
ok "bw-load-ssh.sh deployed."
fi
# ---- 3. zoom.sh — Zoom wrapper for Wayland + AMD GPU ----
# Forces Wayland native mode and VAAPI hardware video decoding on AMD Radeon.
# Without this, Zoom would use XWayland and software decoding (bad perf).
if [ -f "$SCRIPTS_DIR/zoom.sh" ]; then
cp "$SCRIPTS_DIR/zoom.sh" "$TARGET_DIR/zoom"
chmod +x "$TARGET_DIR/zoom"
ok "zoom wrapper deployed."
else
# Create default Zoom wrapper
cat > "$TARGET_DIR/zoom" << 'SCRIPT'
#!/bin/bash
# Zoom wrapper — forces Wayland + HW acceleration on AMD GPU
export QT_QPA_PLATFORM=wayland
export LIBVA_DRIVER_NAME=radeonsi
export LIBVA_DRI3_DISABLE=0
exec /usr/bin/zoom "$@"
SCRIPT
chmod +x "$TARGET_DIR/zoom"
ok "zoom wrapper created (default)."
fi
# ---- 4. idle-battery-suspend.sh — Suspend on battery after idle ----
# Checks if AC is disconnected before suspending. Prevents suspend on desktop.
# Used by swayidle.service (stage 08).
if [ -f "$SCRIPTS_DIR/idle-battery-suspend.sh" ]; then
cp "$SCRIPTS_DIR/idle-battery-suspend.sh" "$TARGET_DIR/idle-battery-suspend.sh"
chmod +x "$TARGET_DIR/idle-battery-suspend.sh"
ok "idle-battery-suspend.sh deployed."
fi
# ---- 5. Bitwarden CLI (bw) ----
# On Pop: ~/.local/bin/bw (138 MB standalone binary)
if ! command -v bw &>/dev/null; then
info "Installing Bitwarden CLI..."
# bw is a standalone binary — download it
BW_LATEST=$(curl -fsSL "https://api.github.com/repos/bitwarden/clients/releases?per_page=1" | grep -oP '"tag_name":\s*"cli-v\K[^"]+' | head -1) || BW_LATEST="2025.1.0"
curl -fsSL "https://github.com/bitwarden/clients/releases/download/cli-v${BW_LATEST}/bw-linux-${BW_LATEST}.zip" -o /tmp/bw.zip 2>/dev/null && {
unzip -o /tmp/bw.zip -d /tmp/bw-extract 2>/dev/null
cp /tmp/bw-extract/bw "$TARGET_DIR/bw"
chmod +x "$TARGET_DIR/bw"
rm -rf /tmp/bw.zip /tmp/bw-extract
ok "Bitwarden CLI installed."
} || warn "Bitwarden CLI download failed. Install manually: https://bitwarden.com/help/cli/"
fi
# Ensure permissions
chmod -R 755 "$TARGET_DIR" 2>/dev/null || true
ok "Stage 07 complete: custom scripts deployed to ~/.local/bin."

96
stages/08-systemd.sh Normal file
View File

@@ -0,0 +1,96 @@
#!/usr/bin/env bash
# ===========================================================================
# Stage 08: User Systemd Services
# Deploys and enables Julian's custom user systemd services.
# ===========================================================================
# On the Pop machine, Julian runs several custom services:
# - porridge.service : Zoom meeting transcriber daemon
# - porridge-dictate.service : Push-to-talk transcription
# - pi-overview.service : Session dashboard on port 3000
# - bw-ssh-keys.service : Load Bitwarden SSH keys at boot
# - mempi-sync.service : Sync memory DB to Nextcloud
# - mempi-sync.timer : Run mempi-sync on boot +5min
# - empty_downloads.service : Clear Downloads folder at login
# ===========================================================================
CONFIG_DIR="${SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/config"
SERVICES_DIR="${CONFIG_DIR}/systemd"
UNIT_DIR="$HOME/.config/systemd/user"
mkdir -p "$UNIT_DIR"
info "Deploying user systemd services..."
# ---- Helper: install service file ----
install_service_file() {
local src="$1"
local name="$2"
if [ -f "$src" ]; then
cp "$src" "$UNIT_DIR/$name"
ok "Installed: $name"
else
warn "Service file not found: $src (skipping)"
fi
}
# ---- 1. porridge.service — Zoom transcriber daemon ----
install_service_file "$SERVICES_DIR/porridge.service" "porridge.service"
# ---- 2. porridge-dictate.service — Push-to-talk transcription ----
install_service_file "$SERVICES_DIR/porridge-dictate.service" "porridge-dictate.service"
# ---- 3. pi-overview.service — Session dashboard ----
install_service_file "$SERVICES_DIR/pi-overview.service" "pi-overview.service"
# ---- 4. bw-ssh-keys.service — Load Bitwarden SSH keys at boot ----
install_service_file "$SERVICES_DIR/bw-ssh-keys.service" "bw-ssh-keys.service"
# ---- 5. mempi-sync.service + timer — Sync memory DB to Nextcloud ----
install_service_file "$SERVICES_DIR/mempi-sync.service" "mempi-sync.service"
install_service_file "$SERVICES_DIR/mempi-sync.timer" "mempi-sync.timer"
# ---- 6. empty_downloads.service — Clear Downloads at login ----
install_service_file "$SERVICES_DIR/empty_downloads.service" "empty_downloads.service"
# ---- Enable and start services ----
info "Enabling and starting services..."
# Services that should start automatically (enabled)
systemctl --user daemon-reload
# Check which scripts from stage 07 are available before enabling services.
# This avoids failures when running stages out of order.
if [ -x "$HOME/.local/bin/porridge" ]; then
systemctl --user enable --now porridge.service 2>/dev/null && ok "porridge.service enabled"
else
warn "porridge.service skipped (binary not found — run stage 07 first)."
fi
if [ -x "$HOME/.local/bin/porridge" ]; then
systemctl --user enable --now porridge-dictate.service 2>/dev/null && ok "porridge-dictate.service enabled"
else
warn "porridge-dictate.service skipped (binary not found — run stage 07 first)."
fi
if [ -x "$HOME/.local/bin/pi-overview" ]; then
systemctl --user enable --now pi-overview.service 2>/dev/null && ok "pi-overview.service enabled"
else
warn "pi-overview.service skipped (binary not found — run stage 06-uv-projects first)."
fi
if [ -f "$HOME/.local/bin/bw-load-ssh.sh" ]; then
systemctl --user enable bw-ssh-keys.service 2>/dev/null && ok "bw-ssh-keys.service enabled"
else
warn "bw-ssh-keys.service skipped (script not found — run stage 07 first)."
fi
systemctl --user enable --now empty_downloads.service 2>/dev/null && ok "empty_downloads.service enabled" || warn "empty_downloads.service not started."
# Timers
systemctl --user enable --now mempi-sync.timer 2>/dev/null && ok "mempi-sync.timer enabled" || warn "mempi-sync.timer not started."
info "===== Service Status ====="
systemctl --user list-units --type=service --state=running 2>/dev/null | grep -E "(porridge|swayidle|pi-overview|mempi|bw-ssh|empty)" || true
ok "Stage 08 complete: user systemd services deployed."

199
stages/09-desktop.sh Normal file
View File

@@ -0,0 +1,199 @@
#!/usr/bin/env bash
# ===========================================================================
# Stage 09: Desktop Configuration
# Keybindings, hotkey scripts, Ghostty, fonts, and desktop theme.
# ===========================================================================
# On Pop!_OS, Julian uses COSMIC desktop (Rust-based, System76's own DE).
# Fedora ships with GNOME by default. This stage provides keybinding config
# for both GNOME (via gsettings) and a fallback swhkd/keyd approach.
#
# If you install a different DE (KDE, COSMIC via COPR, Sway/Hyprland),
# adjust this stage accordingly.
# ===========================================================================
CONFIG_DIR="${SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/config"
# Detect desktop environment
DE="${XDG_CURRENT_DESKTOP:-unknown}"
# ===========================================================================
# 1. Hotkey Scripts (from hotkeys repo)
# ===========================================================================
# These are shell scripts that use selected text for quick actions.
# They rely on: wl-clipboard, wtype, wofi, jq, xdg-open
# Installed from ~/Development/hotkeys/ (cloned in stage 06).
info "Setting up hotkey scripts..."
HOTKEYS_DIR="$HOME/Development/hotkeys"
if [ -d "$HOTKEYS_DIR" ]; then
# Create a symlink or copy to ~/.local/bin for PATH access
for script in google.sh scholar.sh dictionary.sh pdf.sh emoji.sh hotstrings.sh; do
if [ -f "$HOTKEYS_DIR/$script" ]; then
ln -sf "$HOTKEYS_DIR/$script" "$HOME/.local/bin/${script%.sh}" 2>/dev/null || true
fi
done
ok "Hotkey scripts linked to ~/.local/bin/"
else
warn "hotkeys repo not cloned yet. Run stage 06 first."
fi
# ===========================================================================
# 2. Desktop Environment Keybindings
# ===========================================================================
# Map Julian's COSMIC keybindings to the current DE.
case "$DE" in
*GNOME*|*gnome*)
info "Configuring GNOME keybindings..."
# ---- Custom keybindings (hotkey scripts) ----
# Map Ctrl+Alt+{G,S,D,E,A,O} to the hotkey scripts
gsettings set org.gnome.settings-daemon.plugins.media-keys custom-keybindings "[]"
_add_gnome_kb() {
local name="$1"
local binding="$2"
local command="$3"
local path="/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/${name}/"
gsettings set org.gnome.settings-daemon.plugins.media-keys.custom-keybinding:"${path}" \
name "${name}" 2>/dev/null || true
gsettings set org.gnome.settings-daemon.plugins.media-keys.custom-keybinding:"${path}" \
binding "${binding}" 2>/dev/null || true
gsettings set org.gnome.settings-daemon.plugins.media-keys.custom-keybinding:"${path}" \
command "${command}" 2>/dev/null || true
}
_add_gnome_kb "google" "<Control><Alt>g" "$HOME/.local/bin/google"
_add_gnome_kb "scholar" "<Control><Alt>s" "$HOME/.local/bin/scholar"
_add_gnome_kb "dictionary" "<Control><Alt>d" "$HOME/.local/bin/dictionary"
_add_gnome_kb "emoji" "<Control><Alt>e" "$HOME/.local/bin/emoji"
_add_gnome_kb "hotstrings" "<Control><Alt>a" "$HOME/.local/bin/hotstrings"
_add_gnome_kb "pdf" "<Control><Alt>o" "$HOME/.local/bin/pdf"
# ---- Collect all custom paths into array for gsettings ----
KB_PATHS="["
for n in google scholar dictionary emoji hotstrings pdf; do
KB_PATHS+="'/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/${n}/', "
done
KB_PATHS="${KB_PATHS%, }]"
gsettings set org.gnome.settings-daemon.plugins.media-keys custom-keybindings "${KB_PATHS}"
# ---- GNOME-specific shortcuts ----
# Super+L → lock (default on GNOME too)
# Super+Q → close window (default is Alt+F4; set Super+Q too)
gsettings set org.gnome.desktop.wm.keybindings close "['<Super>q', '<Alt>F4']" 2>/dev/null || true
ok "GNOME keybindings configured."
;;
*COSMIC*)
info "Configuring COSMIC keybindings..."
# On COSMIC, keybindings are stored in:
# ~/.config/cosmic/com.system76.CosmicSettings.Shortcuts/v1/custom
# This is a RON (Rust Object Notation) file.
# We deploy our custom bindings from the config template.
if [ -f "$CONFIG_DIR/cosmic/custom-shortcuts.ron" ]; then
mkdir -p "$HOME/.config/cosmic/com.system76.CosmicSettings.Shortcuts/v1"
cp "$CONFIG_DIR/cosmic/custom-shortcuts.ron" \
"$HOME/.config/cosmic/com.system76.CosmicSettings.Shortcuts/v1/custom"
# Set terminal command (COSMIC desktop)
mkdir -p "$HOME/.config/cosmic/com.system76.CosmicSettings.Shortcuts/v1"
echo '{
Terminal: "/usr/bin/ghostty --gtk-single-instance=true",
}' > "$HOME/.config/cosmic/com.system76.CosmicSettings.Shortcuts/v1/system_actions"
ok "COSMIC keybindings deployed."
else
warn "COSMIC shortcuts template not found. Run 'cp config/cosmic/* ~/.config/cosmic/' manually."
fi
;;
*Hyprland*|*sway*|*Sway*)
info "Configuring Wayland compositor keybindings..."
warn "No automatic config for ${DE}. Apply manually from config/hotkeys/hyprland.conf or config/hotkeys/sway.config"
warn "See hotkeys/README.md for config examples."
;;
*KDE*|*Plasma*)
info "KDE Plasma detected. Applying keybindings via kwriteconfig..."
warn "KDE keybinding automation not yet implemented. Configure manually via System Settings > Shortcuts."
;;
*)
warn "Unknown DE: ${DE}. Keybindings must be configured manually."
warn "Reference: Ctrl+Alt+G/S/D/E/A/O → hotkey scripts in ~/Development/hotkeys/"
;;
esac
# ===========================================================================
# 4. Ghostty terminal config
# ===========================================================================
info "Deploying Ghostty config..."
mkdir -p "$HOME/.config/ghostty"
if [ -f "$CONFIG_DIR/ghostty/config" ]; then
cp "$CONFIG_DIR/ghostty/config" "$HOME/.config/ghostty/config"
ok "Ghostty config deployed."
fi
# ===========================================================================
# 5. Fonts (Nerd Fonts for terminal + dev)
# ===========================================================================
info "Installing Nerd Fonts..."
FONT_DIR="$HOME/.local/share/fonts"
mkdir -p "$FONT_DIR"
# MesloLGS NF — recommended for Powerlevel10k
# This is the font used on the Pop machine (MesloLGS NF Regular).
install_nerd_font() {
local font_name="$1"
local repo_url="$2"
local font_dir="$FONT_DIR"
# Check if already installed
if ls "$font_dir"/*"$font_name"* 2>/dev/null | grep -q .; then
ok "Font '$font_name' already installed."
return
fi
info "Downloading $font_name Nerd Font..."
local tmpdir
tmpdir=$(mktemp -d)
# Download from Nerd Fonts GitHub releases
curl -fsSL "https://github.com/ryanoasis/nerd-fonts/releases/latest/download/${font_name}.zip" \
-o "$tmpdir/${font_name}.zip" 2>/dev/null && {
unzip -o "$tmpdir/${font_name}.zip" -d "$tmpdir" 2>/dev/null
cp "$tmpdir"/*.ttf "$font_dir"/ 2>/dev/null || true
ok "Font '$font_name' installed."
} || warn "Font download failed for $font_name."
rm -rf "$tmpdir"
}
# On Pop, these were found:
# MesloLGS NF (Regular, Bold, Italic, Bold Italic)
# FiraCode Nerd Font Propo
# ApercuMonoPro-Regular.otf (proprietary — not distributed)
install_nerd_font "Meslo" ""
install_nerd_font "FiraCode" ""
# Rebuild font cache
fc-cache -f "$FONT_DIR" 2>/dev/null || true
ok "Font cache rebuilt."
# ===========================================================================
# 6. GTK Theme (dark mode + Papirus icons)
# ===========================================================================
info "Setting GTK theme and icons..."
gsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' 2>/dev/null || true
gsettings set org.gnome.desktop.interface gtk-theme 'adw-gtk3-dark' 2>/dev/null || true
gsettings set org.gnome.desktop.interface icon-theme 'Papirus-Dark' 2>/dev/null || true
ok "GTK dark theme + Papirus icons set."
# ===========================================================================
# 7. Solaar (Logitech peripherals) config
# ===========================================================================
# The Pop machine had config for MX Keys Mini + MX Master 3.
# Solaar config is auto-generated when you pair devices. The config
# template (if supplied) can be placed at ~/.config/solaar/config.yaml
info "Solaar config will be generated automatically when you pair devices."
ok "Stage 09 complete: desktop configured."

68
stages/10-docker.sh Normal file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# ===========================================================================
# Stage 10: Docker CE
# Installs Docker CE, enables on boot, and adds user to docker group.
# ===========================================================================
# On the Pop machine, Julian uses Docker extensively:
# - grobid (PDF extraction server)
# - rocker/rstudio (R environment)
# - pandoc/extra (document conversion)
#
# This stage covers Docker CE install only. Docker images are pulled
# on demand (too large to pre-pull in provisioning).
# ===========================================================================
info "Installing Docker CE..."
# Docker repo was added in stage 01. Install from it.
if command -v docker &>/dev/null; then
ok "Docker already installed: $(docker --version 2>/dev/null)"
else
$PKG_INSTALL docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin 2>/dev/null || {
error "Docker install failed. Check repo config in stage 01."
exit 1
}
ok "Docker CE packages installed."
# Enable and start Docker
$SERVICE_ENABLE docker 2>/dev/null
ok "Docker service enabled and started."
fi
# ---- Add user to docker group ----
if groups | grep -q docker; then
ok "User is already in the docker group."
else
info "Adding user to docker group (required to run Docker without sudo)..."
sudo usermod -aG docker "$USER" 2>/dev/null || warn "Could not add user to docker group."
warn "You'll need to log out and back in for docker group changes to take effect."
warn "Alternatively, run: newgrp docker (temporary, current shell only)."
fi
# ---- Verify Docker works ----
info "Verifying Docker installation..."
# Use a simple test (may need re-login for group changes)
if sg docker -c "docker run --rm hello-world" 2>/dev/null; then
ok "Docker verified: hello-world ran successfully."
else
warn "Docker verification failed. You may need to log out and back in."
warn "Or run: sudo usermod -aG docker $USER && newgrp docker"
fi
# ---- Install Docker Compose (plugin) ----
# docker compose (v2, plugin) was installed with docker-compose-plugin above.
if docker compose version 2>/dev/null; then
ok "Docker Compose v2 plugin ready: $(docker compose version --short 2>/dev/null)"
fi
# ---- (Optional) Pre-pull commonly used images ----
# Uncomment to pre-pull images Julian uses frequently.
# Note: grobid image is large (~2GB each).
info "Note: Docker images are pulled on demand when you run containers."
info "Common images Julian uses:"
info " - grobid/grobid (PDF extraction)"
info " - rocker/tidyverse or rocker/verse (RStudio)"
info " - pandoc/extra"
echo ""
ok "Stage 10 complete: Docker installed."

37
stages/11-tweaks.sh Normal file
View File

@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# ===========================================================================
# Stage 11: System Tweaks
# sysctl tuning, kernel cmdline parameters, TLP/powertop, modprobe.
# Uses distro-agnostic variables from lib/distro.sh.
# ===========================================================================
# CAUTION: GPU kernel parameters are hardware-specific (AMD Radeon 680M).
# They are COMMENTED OUT by default. Uncomment only if you have the same GPU.
# ===========================================================================
CONFIG_DIR="${SCRIPT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}/config"
# ===========================================================================
# 1. TLP / PowerTOP
# ===========================================================================
info "Configuring power management..."
$SERVICE_ENABLE tlp 2>/dev/null && ok "TLP enabled." || warn "TLP not available."
if command -v powertop &>/dev/null; then
# Enable powertop auto-tune via systemd service
if [ ! -f /etc/systemd/system/powertop.service ]; then
sudo tee /etc/systemd/system/powertop.service > /dev/null << 'EOF'
[Unit]
Description=PowerTOP auto tune
[Service]
Type=oneshot
ExecStart=/usr/sbin/powertop --auto-tune
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
fi
$SERVICE_ENABLE powertop 2>/dev/null && ok "PowerTOP auto-tune enabled." || true
fi
ok "Stage 11 complete: system tweaks applied."

58
stages/12-other-apps.sh Normal file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# ===========================================================================
# Stage 12: Other Applications
# Depends on: stage 01 (repos), stage 02 (packages)
# Chrome, Signal, Zotero, Obsidian, Nextcloud client, FreeRDP.
# Distro-agnostic — uses pkg_install / Flatpak / tarball.
# ===========================================================================
# ---- Google Chrome ----
info "Installing Google Chrome..."
pkg_install google-chrome-stable 2>/dev/null && ok "Chrome installed." \
|| warn "Chrome not available. Install from https://www.google.com/chrome/"
# ---- Signal Desktop ----
info "Installing Signal Desktop..."
pkg_install signal-desktop 2>/dev/null && ok "Signal installed." \
|| warn "Signal not available. Check repo in stage 01."
# ---- Zotero (reference manager) ----
info "Installing Zotero..."
if command -v zotero &>/dev/null || flatpak list 2>/dev/null | grep -qi "zotero"; then
ok "Zotero already installed."
elif command -v flatpak &>/dev/null; then
flatpak install -y flathub org.zotero.Zotero 2>/dev/null && ok "Zotero installed via Flatpak." \
|| warn "Zotero Flatpak failed. Try manual tarball from zotero.org."
else
warn "Zotero not installed. Get it from https://www.zotero.org/download/"
fi
# ---- Obsidian (knowledge base) ----
info "Installing Obsidian..."
if command -v obsidian &>/dev/null || flatpak list 2>/dev/null | grep -qi "obsidian"; then
ok "Obsidian already installed."
elif command -v flatpak &>/dev/null; then
flatpak install -y flathub md.obsidian.Obsidian 2>/dev/null && ok "Obsidian installed." \
|| warn "Obsidian Flatpak failed. Download from https://obsidian.md/"
else
warn "Obsidian not installed."
fi
# ---- Nextcloud Desktop Client ----
info "Installing Nextcloud Desktop Client..."
if [ "$DISTRO_FAMILY" = "debian" ]; then
pkg_install nextcloud-desktop 2>/dev/null && ok "Nextcloud client installed." || \
warn "Nextcloud client not available."
else
pkg_install nextcloud-client 2>/dev/null && ok "Nextcloud client installed." || \
warn "Nextcloud client not available."
fi
# ---- FreeRDP (for WinBoat RDP) ----
info "Installing FreeRDP (Flatpak)..."
if command -v flatpak &>/dev/null; then
flatpak install -y flathub com.freerdp.FreeRDP 2>/dev/null && ok "FreeRDP installed." \
|| warn "FreeRDP Flatpak failed."
fi
ok "Stage 12 complete: additional applications installed."